// 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"}}

Inner content of the specific email is rendered here.

Example link: paliad.de.

{{end}}` } return `{{define "content"}}

Inhalt der spezifischen Mail wird hier gerendert.

Beispiel-Link: paliad.de.

{{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 { return buildMIMEWithReplyTo(from, fromName, "", to, subject, htmlBody, textBody) } // buildMIMEWithReplyTo is buildMIME plus an optional Reply-To header. // Bulk-broadcast email uses this so replies route to the human sender even // though From: stays on the SMTP infrastructure address. func buildMIMEWithReplyTo(from, fromName, replyTo, 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) if replyTo != "" { fmt.Fprintf(&b, "Reply-To: %s\r\n", replyTo) } 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)[^>]*>.*?`) 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( "
", "\n", "
", "\n", "
", "\n", "

", "\n\n", "", "\n", "", "\n", "", "\n", "", "\n\n", "", "\n\n", "", "\n\n", ).Replace(s) s = stripHTMLRE.ReplaceAllString(s, "") s = strings.NewReplacer( " ", " ", "&", "&", "<", "<", ">", ">", """, "\"", "—", "—", "–", "–", "ä", "ä", "ö", "ö", "ü", "ü", "Ä", "Ä", "Ö", "Ö", "Ü", "Ü", "ß", "ß", "…", "…", ).Replace(s) s = wsRE.ReplaceAllString(s, " ") s = nlRE.ReplaceAllString(s, "\n\n") return strings.TrimSpace(s) }