Files
paliad/internal/services/mail_service.go
m 52ee319fd8 feat(t-paliad-147): bulk team email — send to filtered selection from /team page
Implements issue #7. Adds an "E-Mail an Auswahl" button on /team that sends
personalised emails to a filter-narrowed subset of the team. Each recipient
gets their own envelope (per-recipient privacy, no shared To: list); From
stays on the SMTP infrastructure address with Reply-To set to the human
sender so replies route correctly without forging DKIM/SPF.

Backend
- Migration 057: paliad.email_broadcasts (subject, body, sender_id,
  template_key, recipient_filter jsonb, recipient_user_ids uuid[],
  send_report jsonb, sent_at). RLS: senders read own rows, global_admin
  reads all; inserts must self-attribute. No CHECK-constraint extension to
  partner_unit_events — broadcasts get their own table per the lock.
- BroadcastService (internal/services/broadcast_service.go): validates
  subject/body/recipient cap (100), enforces project_lead-OR-global_admin,
  persists audit row, dispatches via 5-deep goroutine pool with 15s
  per-send timeout. Send report (sent/failed counts + per-recipient errors)
  is captured back into email_broadcasts.send_report.
- markdown.go: minimal Markdown→safe HTML renderer (paragraphs, **bold**,
  *italic*, `code`, [text](url), bullet lists). Inputs are HTML-escaped
  first; only whitelisted tags re-emitted. Script tags and javascript:
  URLs can't slip through.
- Placeholder substitution: {{name}}, {{first_name}},
  {{role_on_project}} (whitespace tolerated). Unknown {{...}} tokens pass
  through unchanged.
- mail_service.go: buildMIMEWithReplyTo helper layers a Reply-To header
  on top of the existing multipart/alternative envelope.
- TeamService.ListMembershipsIndex: visibility-gated user→project_ids
  index. Powers the /team project multi-select filter without N round
  trips per project.
- Handlers: POST /api/team/broadcast (gateOnboarded; service enforces
  authority), GET /api/team/memberships, GET /api/admin/broadcasts (list),
  GET /api/admin/broadcasts/{id} (detail), GET /admin/broadcasts (page).
  /admin/broadcasts is gateOnboarded (not adminGate) so leads can see
  their own sends; the service applies the per-row visibility filter.

Frontend
- /team gains a project multi-select chip dropdown (visible projects
  loaded from /api/projects, intersected against the memberships index)
  alongside the existing office and role filters.
- "E-Mail an Auswahl (N)" button appears only when canBroadcast() is
  true (global_admin always; non-admin needs lead-ship on selected
  projects, or at least one project when no filter is set). Server still
  re-checks per send.
- Compose modal (broadcast.ts): subject + body textarea + optional
  template dropdown (loads existing email templates and strips Go-template
  directives) + recipient preview (first 5 + expand) + send. Hard-blocks
  empty subject/body and N=0. Shows per-send report on success.
- /admin/broadcasts viewer: read-only list with click-row-to-expand
  detail (subject, body, recipient list, send_report counts).

Tests
- broadcast_service_test.go: placeholder substitution table-driven,
  Markdown safe-render incl. XSS guards (<script>, javascript: URLs),
  validation cases (empty subject/body, recipient cap, invalid email),
  signature rendering DE/EN.
- broadcast_service_live_test.go: end-to-end Send + List + Get + visibility
  rules (lead can send on own project, member cannot, admin sees all,
  member can't read lead's row). Skips when TEST_DATABASE_URL is unset.

i18n: 60 new keys × 2 langs (broadcast modal labels, error messages,
recipient summary, /admin/broadcasts viewer, common.close/loading/forbidden/
load_error).
2026-05-07 20:58:57 +02:00

503 lines
17 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 {
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)[^>]*>.*?</(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)
}