Files
paliad/internal/services/mail_service.go
m 460736ad1e refactor(t-paliad-092): rename Go module path patholo → paliad
F-6 from t-paliad-074 architecture audit. The Gitea repo was renamed
m/patholo → mAi/paliad → m/paliad, but go.mod still declared
`mgit.msbls.de/m/patholo` and every internal import echoed the
pre-rebrand name.

Sweep:
- go.mod: module path → mgit.msbls.de/m/paliad
- All *.go files: imports rewritten via sed
- README.md, docs/design-kanzlai-integration.md: mAi/paliad → m/paliad
- Frontend issue-reference comments (mAi/paliad#N → m/paliad#N) in
  i18n.ts, theme.ts, sidebar.ts, app.ts, Sidebar.tsx, PWAHead.tsx,
  global.css

Verified: go build/vet/test ./... clean, bun run build clean,
no remaining mgit.msbls.de/m/patholo or mAi/paliad references
outside docs that intentionally describe the rename history.
2026-04-30 16:46:31 +02:00

493 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package services — MailService — SMTP delivery for transactional email.
//
// Handles two flows today: deadline reminders (reminder_service.go) and
// colleague invitations (handlers/invite.go). Body and subject for both come
// from EmailTemplateService — a DB-backed override falls through to the
// embedded per-language file when no override exists. See
// docs/design-email-templates-2026-04-29.md for the override semantics.
//
// Config is read from env vars at startup; when any required var is unset
// the service logs a warning and becomes a silent no-op. This lets the
// server run locally without SMTP credentials — no crashes, no surprise
// 500s.
//
// Port 465 uses implicit TLS (tls.Dial from the start), not STARTTLS. The
// Hostinger submission endpoint only accepts implicit TLS on that port.
package services
import (
"bytes"
"context"
"crypto/tls"
"errors"
"fmt"
htmltemplate "html/template"
"log/slog"
"maps"
"mime"
"net"
"net/smtp"
"os"
"regexp"
"strings"
texttemplate "text/template"
"time"
"mgit.msbls.de/m/paliad/internal/branding"
)
// MailConfig holds resolved SMTP settings. Built once at startup.
type MailConfig struct {
Host string
Port string
Username string
Password string
From string
FromName string
UseTLS bool
}
// MailService sends branded HTML+text email over SMTP. Safe to use
// concurrently. When the service is disabled (missing env vars), every Send*
// call logs and returns nil so callers can treat it as fire-and-forget.
//
// Templates are looked up via EmailTemplateService at render time, so an
// admin save propagates without a process restart. The service is created
// without one and gets it via SetTemplateService — wiring order in main.go
// (mail before DB pool) makes constructor injection awkward.
type MailService struct {
cfg MailConfig
enabled bool
templateSvc *EmailTemplateService
}
// NewMailService reads SMTP_* from the environment. Returns a non-nil
// service either way; callers check Enabled() if they care whether mail
// actually went out. Template lookup is bound later via SetTemplateService.
func NewMailService() (*MailService, error) {
cfg := MailConfig{
Host: strings.TrimSpace(os.Getenv("SMTP_HOST")),
Port: strings.TrimSpace(os.Getenv("SMTP_PORT")),
Username: strings.TrimSpace(os.Getenv("SMTP_USERNAME")),
Password: os.Getenv("SMTP_PASSWORD"),
From: strings.TrimSpace(os.Getenv("SMTP_FROM")),
FromName: strings.TrimSpace(os.Getenv("SMTP_FROM_NAME")),
UseTLS: !strings.EqualFold(os.Getenv("SMTP_USE_TLS"), "false"),
}
if cfg.Port == "" {
cfg.Port = "465"
}
if cfg.From == "" && cfg.Username != "" {
cfg.From = cfg.Username
}
if cfg.FromName == "" {
cfg.FromName = "Paliad"
}
enabled := cfg.Host != "" && cfg.Username != "" && cfg.Password != "" && cfg.From != ""
if !enabled {
slog.Warn("mail: SMTP_* env vars incomplete — email delivery disabled",
"host_set", cfg.Host != "",
"username_set", cfg.Username != "",
"password_set", cfg.Password != "",
"from_set", cfg.From != "",
)
} else {
slog.Info("mail: SMTP configured", "host", cfg.Host, "port", cfg.Port, "from", cfg.From)
}
// Default to a fallback-only EmailTemplateService (nil DB). Tests that
// don't wire a DB still get rendering against the embedded files.
return &MailService{
cfg: cfg,
enabled: enabled,
templateSvc: NewEmailTemplateService(nil),
}, nil
}
// SetTemplateService swaps the in-use EmailTemplateService. main.go calls
// this once after the DB pool is up so DB-backed overrides start applying.
// Safe to call before any Send/Render — there's no concurrent access yet.
func (s *MailService) SetTemplateService(t *EmailTemplateService) {
if t == nil {
t = NewEmailTemplateService(nil)
}
s.templateSvc = t
}
// Enabled reports whether SMTP is configured. Handlers can surface a clearer
// error when invite/reminder features require a live SMTP connection.
func (s *MailService) Enabled() bool { return s.enabled }
// Send delivers one multipart/alternative email (HTML + text). A zero or
// nil-value service (Enabled() == false) no-ops and returns nil.
//
// textBody may be empty; when omitted we auto-derive a plain-text fallback
// from the HTML so every message still has both parts (some clients flag
// HTML-only mail as spam).
func (s *MailService) Send(to, subject, htmlBody, textBody string) error {
if !s.enabled {
slog.Debug("mail: Send skipped (disabled)", "to", to, "subject", subject)
return nil
}
if to == "" {
return errors.New("mail: empty recipient")
}
if subject == "" {
return errors.New("mail: empty subject")
}
if textBody == "" {
textBody = htmlToText(htmlBody)
}
msg := buildMIME(s.cfg.From, s.cfg.FromName, to, subject, htmlBody, textBody)
return s.deliver(to, msg)
}
// TemplateData is the shape passed to SendTemplate. Lang defaults to "de"
// when empty. Subject is no longer set by the caller — it's looked up and
// rendered from the (key, lang) row.
type TemplateData struct {
To string
Lang string
Name string
Data map[string]any
}
// SendTemplate renders subject + body via EmailTemplateService and sends.
// Render errors surface even when SMTP is disabled — that catches typos and
// missing fields in dev/test where SMTP isn't configured.
func (s *MailService) SendTemplate(in TemplateData) error {
subject, html, err := s.RenderTemplate(in)
if err != nil {
return err
}
if !s.enabled {
slog.Debug("mail: SendTemplate skipped (disabled)", "to", in.To, "template", in.Name)
return nil
}
return s.Send(in.To, subject, html, htmlToText(html))
}
// RenderTemplate produces the rendered subject and HTML body. Falls back to
// the embedded default if a DB row is malformed at parse time — admins can
// never wedge the send path with a bad save (per design-email-templates §3).
// Exposed for tests and the admin preview endpoint.
func (s *MailService) RenderTemplate(in TemplateData) (subject string, html string, err error) {
lang := in.Lang
if lang == "" {
lang = "de"
}
ctx := context.Background()
// Build payload once — both subject and body use the same data.
payload := map[string]any{
"Lang": lang,
"Firm": branding.Name,
}
maps.Copy(payload, in.Data)
// Body comes from (key, lang); on parse error fall back to the embedded
// default so a corrupt DB row never breaks delivery.
body, _, err := s.lookupBody(ctx, in.Name, lang)
if err != nil {
return "", "", fmt.Errorf("lookup body %s: %w", in.Name, err)
}
// Base wrapper: same lookup, key='base'.
baseBody, _, err := s.lookupBody(ctx, EmailTemplateKeyBase, lang)
if err != nil {
return "", "", fmt.Errorf("lookup base: %w", err)
}
// Compose: parse base, then layer the content body on top. If either
// parse fails on the active row, retry with the embedded default.
html, err = renderBaseAndContent(baseBody, body, payload)
if err != nil {
// Active row was bad. Pull the embedded fallback for both and retry —
// log loudly so the admin can fix it, but keep email working.
slog.Error("mail: active template parse failed, falling back to embedded default",
"key", in.Name, "lang", lang, "err", err)
fbContent, fbErr := readEmbeddedBody(in.Name, lang)
if fbErr != nil {
return "", "", fmt.Errorf("fallback body %s: %w", in.Name, fbErr)
}
fbBase, fbErr := readEmbeddedBody(EmailTemplateKeyBase, lang)
if fbErr != nil {
return "", "", fmt.Errorf("fallback base: %w", fbErr)
}
html, err = renderBaseAndContent(fbBase, fbContent, payload)
if err != nil {
return "", "", fmt.Errorf("render embedded fallback %s: %w", in.Name, err)
}
}
// Subject: same fallback discipline. Empty subject template (key='base'
// case, when SendTemplate is somehow asked for the wrapper directly) is
// allowed and returns "".
subjectTpl, _, err := s.lookupSubject(ctx, in.Name, lang)
if err != nil {
return "", "", fmt.Errorf("lookup subject %s: %w", in.Name, err)
}
subject, err = renderSubject(subjectTpl, payload)
if err != nil {
slog.Error("mail: active subject parse failed, falling back to embedded default",
"key", in.Name, "lang", lang, "err", err)
fbSubj := defaultSubjects[in.Name][lang]
fbSubject, fbErr := renderSubject(fbSubj, payload)
if fbErr != nil {
return "", "", fmt.Errorf("render embedded fallback subject %s: %w", in.Name, fbErr)
}
subject = fbSubject
}
return subject, html, nil
}
// lookupBody returns the body string + IsDefault marker. When the template
// service has no DB or the row is missing, the embedded body wins.
func (s *MailService) lookupBody(ctx context.Context, key, lang string) (string, bool, error) {
row, err := s.templateSvc.GetActive(ctx, key, lang)
if err != nil {
return "", false, err
}
return row.Body, row.IsDefault, nil
}
// lookupSubject mirrors lookupBody for subjects.
func (s *MailService) lookupSubject(ctx context.Context, key, lang string) (string, bool, error) {
row, err := s.templateSvc.GetActive(ctx, key, lang)
if err != nil {
return "", false, err
}
return row.Subject, row.IsDefault, nil
}
// RenderPreview renders user-supplied subject+body against the active base
// wrapper (or embedded fallback) for (lang). Used by the admin preview
// endpoint — never persists. Sample data is supplied by the caller and is
// merged on top of {Lang, Firm} so previews see the same baseline payload
// the production render would.
//
// For key=='base' the user-supplied body IS the wrapper; we layer a small
// built-in content sample on top so the preview shows what an inner email
// looks like inside the proposed wrapper.
func (s *MailService) RenderPreview(key, lang, subjectSrc, bodySrc string, data map[string]any) (string, string, error) {
if lang == "" {
lang = "de"
}
payload := map[string]any{
"Lang": lang,
"Firm": branding.Name,
}
maps.Copy(payload, data)
var (
baseBody, contentBody string
err error
)
if key == EmailTemplateKeyBase {
baseBody = bodySrc
contentBody = previewBaseInnerContent(lang)
} else {
baseBody, _, err = s.lookupBody(context.Background(), EmailTemplateKeyBase, lang)
if err != nil {
return "", "", fmt.Errorf("lookup base: %w", err)
}
contentBody = bodySrc
}
html, err := renderBaseAndContent(baseBody, contentBody, payload)
if err != nil {
return "", "", err
}
subject, err := renderSubject(subjectSrc, payload)
if err != nil {
return "", "", err
}
return subject, html, nil
}
// previewBaseInnerContent is the placeholder body wrapped during a base
// preview. Kept tiny so the preview pane shows the wrapper, not a fake email.
func previewBaseInnerContent(lang string) string {
if lang == "en" {
return `{{define "content"}}
<p>Inner content of the specific email is rendered here.</p>
<p>Example link: <a href="https://paliad.de">paliad.de</a>.</p>
{{end}}`
}
return `{{define "content"}}
<p>Inhalt der spezifischen Mail wird hier gerendert.</p>
<p>Beispiel-Link: <a href="https://paliad.de">paliad.de</a>.</p>
{{end}}`
}
func renderBaseAndContent(baseBody, contentBody string, payload map[string]any) (string, error) {
tpl, err := htmltemplate.New("base.html").Parse(baseBody)
if err != nil {
return "", fmt.Errorf("parse base: %w", err)
}
if _, err := tpl.Parse(contentBody); err != nil {
return "", fmt.Errorf("parse content: %w", err)
}
var out bytes.Buffer
if err := tpl.ExecuteTemplate(&out, "base.html", payload); err != nil {
return "", fmt.Errorf("execute: %w", err)
}
return out.String(), nil
}
func renderSubject(src string, payload map[string]any) (string, error) {
if strings.TrimSpace(src) == "" {
return "", nil
}
tpl, err := texttemplate.New("subject").Parse(src)
if err != nil {
return "", fmt.Errorf("parse subject: %w", err)
}
var out bytes.Buffer
if err := tpl.Execute(&out, payload); err != nil {
return "", fmt.Errorf("execute subject: %w", err)
}
return out.String(), nil
}
// --- SMTP transport ---------------------------------------------------------
func (s *MailService) deliver(to string, msg []byte) error {
addr := net.JoinHostPort(s.cfg.Host, s.cfg.Port)
tlsCfg := &tls.Config{ServerName: s.cfg.Host, MinVersion: tls.VersionTLS12}
var (
client *smtp.Client
err error
)
if s.cfg.UseTLS {
// Implicit TLS (port 465). Establish the TLS connection first, then
// hand it to smtp.NewClient. STARTTLS upgrades on port 587 would go
// through smtp.Dial + client.StartTLS — different code path, not
// needed for Hostinger's submission endpoint.
conn, dialErr := tls.Dial("tcp", addr, tlsCfg)
if dialErr != nil {
return fmt.Errorf("smtp tls dial: %w", dialErr)
}
client, err = smtp.NewClient(conn, s.cfg.Host)
} else {
client, err = smtp.Dial(addr)
}
if err != nil {
return fmt.Errorf("smtp connect: %w", err)
}
defer client.Close()
if err := client.Hello(hostnameForHelo()); err != nil {
return fmt.Errorf("smtp helo: %w", err)
}
auth := smtp.PlainAuth("", s.cfg.Username, s.cfg.Password, s.cfg.Host)
if err := client.Auth(auth); err != nil {
return fmt.Errorf("smtp auth: %w", err)
}
if err := client.Mail(s.cfg.From); err != nil {
return fmt.Errorf("smtp mail from: %w", err)
}
if err := client.Rcpt(to); err != nil {
return fmt.Errorf("smtp rcpt to: %w", err)
}
w, err := client.Data()
if err != nil {
return fmt.Errorf("smtp data: %w", err)
}
if _, err := w.Write(msg); err != nil {
w.Close()
return fmt.Errorf("smtp write: %w", err)
}
if err := w.Close(); err != nil {
return fmt.Errorf("smtp close data: %w", err)
}
return client.Quit()
}
func hostnameForHelo() string {
if h, err := os.Hostname(); err == nil && h != "" {
return h
}
return "localhost"
}
// --- MIME construction ------------------------------------------------------
// buildMIME assembles a multipart/alternative message with a fixed boundary.
// Subjects are encoded as UTF-8 per RFC 2047 so non-ASCII characters
// (umlauts) render correctly in every client.
func buildMIME(from, fromName, to, subject, htmlBody, textBody string) []byte {
boundary := "paliad-mixed-" + randBoundary()
fromHeader := from
if fromName != "" {
fromHeader = fmt.Sprintf("%s <%s>", mime.QEncoding.Encode("utf-8", fromName), from)
}
var b bytes.Buffer
fmt.Fprintf(&b, "From: %s\r\n", fromHeader)
fmt.Fprintf(&b, "To: %s\r\n", to)
fmt.Fprintf(&b, "Subject: %s\r\n", mime.QEncoding.Encode("utf-8", subject))
fmt.Fprintf(&b, "Date: %s\r\n", time.Now().UTC().Format(time.RFC1123Z))
fmt.Fprintf(&b, "MIME-Version: 1.0\r\n")
fmt.Fprintf(&b, "Content-Type: multipart/alternative; boundary=\"%s\"\r\n\r\n", boundary)
// Plain text part
fmt.Fprintf(&b, "--%s\r\n", boundary)
fmt.Fprintf(&b, "Content-Type: text/plain; charset=\"utf-8\"\r\n")
fmt.Fprintf(&b, "Content-Transfer-Encoding: 8bit\r\n\r\n")
b.WriteString(textBody)
b.WriteString("\r\n")
// HTML part
fmt.Fprintf(&b, "--%s\r\n", boundary)
fmt.Fprintf(&b, "Content-Type: text/html; charset=\"utf-8\"\r\n")
fmt.Fprintf(&b, "Content-Transfer-Encoding: 8bit\r\n\r\n")
b.WriteString(htmlBody)
b.WriteString("\r\n")
fmt.Fprintf(&b, "--%s--\r\n", boundary)
return b.Bytes()
}
// randBoundary produces a short unique boundary marker. Crypto-strength
// isn't required — we only need to avoid collision with the body content.
func randBoundary() string {
return fmt.Sprintf("%d", time.Now().UnixNano())
}
// --- HTML → plain text ------------------------------------------------------
var (
stripTagRE = regexp.MustCompile(`(?is)<(script|style)[^>]*>.*?</(script|style)>`)
stripHTMLRE = regexp.MustCompile(`<[^>]+>`)
wsRE = regexp.MustCompile(`[ \t]+`)
nlRE = regexp.MustCompile(`\n{3,}`)
)
// htmlToText produces a readable plain-text version of an HTML body. Good
// enough for the fallback part of a multipart/alternative message — users
// whose clients render HTML will see the styled version; this is only read
// by text-only clients and spam filters.
func htmlToText(html string) string {
s := stripTagRE.ReplaceAllString(html, "")
// Convert common block breaks to newlines before stripping.
s = strings.NewReplacer(
"<br>", "\n", "<br/>", "\n", "<br />", "\n",
"</p>", "\n\n", "</div>", "\n", "</tr>", "\n",
"</li>", "\n", "</h1>", "\n\n", "</h2>", "\n\n", "</h3>", "\n\n",
).Replace(s)
s = stripHTMLRE.ReplaceAllString(s, "")
s = strings.NewReplacer(
"&nbsp;", " ", "&amp;", "&", "&lt;", "<", "&gt;", ">", "&quot;", "\"",
"&mdash;", "—", "&ndash;", "", "&auml;", "ä", "&ouml;", "ö",
"&uuml;", "ü", "&Auml;", "Ä", "&Ouml;", "Ö", "&Uuml;", "Ü", "&szlig;", "ß",
"&hellip;", "…",
).Replace(s)
s = wsRE.ReplaceAllString(s, " ")
s = nlRE.ReplaceAllString(s, "\n\n")
return strings.TrimSpace(s)
}