// Package services — EmailTemplateService — manages the active and // versioned email-template rows surfaced through /admin/email-templates. // // The service is intentionally tolerant of a nil DB: knowledge-platform-only // deployments (no DATABASE_URL) still send invitations and reminders if // SMTP is configured, falling back to the embedded per-language template // files. Mutating methods (Save / Reset / RestoreVersion) require a DB and // return ErrTemplateStoreUnavailable when called against a nil store. // // Active row precedence: a row in paliad.email_templates wins over the // embedded default. Removing the row (Reset) restores the default. Saves // also append to paliad.email_template_versions; the most recent // EmailTemplateVersionRetention versions per (key, lang) are kept. // // See docs/design-email-templates-2026-04-29.md for the full design and // the rationale for keeping subjects editable but seeded with explicit // SLO-framing comments. package services import ( "context" "database/sql" "errors" "fmt" htmltemplate "html/template" "slices" "strings" texttemplate "text/template" "time" "github.com/google/uuid" "github.com/jmoiron/sqlx" "mgit.msbls.de/m/paliad/internal/templates" ) // Canonical template keys. The editor and seed loop iterate this list; new // templates land by adding a key here and shipping a per-language body file // under internal/templates/email/. const ( EmailTemplateKeyInvitation = "invitation" EmailTemplateKeyDeadlineDigest = "deadline_digest" EmailTemplateKeyBase = "base" // EmailTemplateKeyAddUserWelcome — t-paliad-223 Slice B (#49). Sent when // a global_admin directly creates a paliad.users + auth.users pair from // /admin/team's "Konto direkt anlegen" form. Carries a Supabase // recovery-link so the new colleague can set their own password. EmailTemplateKeyAddUserWelcome = "add_user_welcome" ) // CanonicalEmailTemplateKeys is the closed set in canonical display order. var CanonicalEmailTemplateKeys = []string{ EmailTemplateKeyInvitation, EmailTemplateKeyAddUserWelcome, EmailTemplateKeyDeadlineDigest, EmailTemplateKeyBase, } // EmailTemplateVersionRetention caps the per-(key, lang) version history. // Storage is negligible (3 keys × 2 langs × 20 = 120 rows steady-state) so // 20 leaves enough headroom that the version a user wants to restore is // almost always still around. const EmailTemplateVersionRetention = 20 // EmailTemplateLanguages is the closed set of editor-supported languages. var EmailTemplateLanguages = []string{"de", "en"} // EmailTemplateRow is the active subject+body for one (key, lang). When the // row came from the embedded fallback (no DB override) IsDefault is true and // UpdatedAt / UpdatedBy are nil. type EmailTemplateRow struct { Key string `db:"key" json:"key"` Lang string `db:"lang" json:"lang"` Subject string `db:"subject" json:"subject"` Body string `db:"body" json:"body"` UpdatedAt *time.Time `db:"updated_at" json:"updated_at,omitempty"` UpdatedBy *uuid.UUID `db:"updated_by" json:"updated_by,omitempty"` IsDefault bool `db:"-" json:"is_default"` } // EmailTemplateVersionRow is one entry in the per-(key, lang) save log. type EmailTemplateVersionRow struct { ID uuid.UUID `db:"id" json:"id"` Key string `db:"key" json:"key"` Lang string `db:"lang" json:"lang"` Subject string `db:"subject" json:"subject"` Body string `db:"body" json:"body"` SavedAt time.Time `db:"saved_at" json:"saved_at"` SavedBy *uuid.UUID `db:"saved_by" json:"saved_by,omitempty"` Note string `db:"note" json:"note"` } // Sentinel errors so handlers can map cleanly to status codes. var ( ErrTemplateUnknownKey = errors.New("unknown email template key") ErrTemplateUnknownLang = errors.New("unknown email template language") ErrTemplateBodySyntax = errors.New("template body has invalid syntax") ErrTemplateSubjectSyntax = errors.New("template subject has invalid syntax") ErrTemplateMissingContent = errors.New(`template body must contain {{define "content"}}…{{end}}`) ErrTemplateMissingBaseBlock = errors.New(`base template must keep {{block "content" .}}{{end}}`) ErrTemplateStoreUnavailable = errors.New("email template store unavailable (DATABASE_URL not set)") ErrTemplateVersionNotFound = errors.New("email template version not found") ) // EmailTemplateService is the read/write authority for active + versioned // rows. db may be nil — see package docs. type EmailTemplateService struct { db *sqlx.DB } // NewEmailTemplateService accepts a possibly-nil DB. Callers can hand it the // shared sqlx.DB pool or pass nil for fallback-only mode. func NewEmailTemplateService(db *sqlx.DB) *EmailTemplateService { return &EmailTemplateService{db: db} } // HasStore reports whether mutating operations will succeed. False during // knowledge-platform-only deployments. func (s *EmailTemplateService) HasStore() bool { return s != nil && s.db != nil } // IsCanonicalKey reports whether key is in the editor's closed set. func IsCanonicalKey(key string) bool { return slices.Contains(CanonicalEmailTemplateKeys, key) } func canonicaliseLang(lang string) (string, error) { switch lang { case "": return "de", nil case "de", "en": return lang, nil default: return "", ErrTemplateUnknownLang } } // GetActive returns the active subject+body for (key, lang). DB row wins // when present; the embedded default applies otherwise. Errors only when // the key/lang are unknown or the embedded default is missing. func (s *EmailTemplateService) GetActive(ctx context.Context, key, lang string) (EmailTemplateRow, error) { if !IsCanonicalKey(key) { return EmailTemplateRow{}, ErrTemplateUnknownKey } lang, err := canonicaliseLang(lang) if err != nil { return EmailTemplateRow{}, err } if s.HasStore() { var row EmailTemplateRow err := s.db.GetContext(ctx, &row, ` SELECT key, lang, subject, body, updated_at, updated_by FROM paliad.email_templates WHERE key = $1 AND lang = $2`, key, lang) if err == nil { return row, nil } if !errors.Is(err, sql.ErrNoRows) { return EmailTemplateRow{}, fmt.Errorf("read email_templates: %w", err) } } return embeddedDefault(key, lang) } // embeddedDefault returns the on-disk default for (key, lang). Subject // defaults are Go consts (defaultSubjects) for two reasons: subjects are // short, and the digest subject's conditional logic benefits from being // colocated with the service that documents its intent. func embeddedDefault(key, lang string) (EmailTemplateRow, error) { body, err := readEmbeddedBody(key, lang) if err != nil { return EmailTemplateRow{}, err } return EmailTemplateRow{ Key: key, Lang: lang, Subject: defaultSubjects[key][lang], Body: body, IsDefault: true, }, nil } func readEmbeddedBody(key, lang string) (string, error) { path := fmt.Sprintf("email/%s.%s.html", key, lang) data, err := templates.EmailFS.ReadFile(path) if err != nil { return "", fmt.Errorf("embedded body %s: %w", path, err) } return string(data), nil } // SaveInput is the payload for Save. Validation happens before any DB // interaction so a typo is reported as 422 without a tx round-trip. type SaveInput struct { Key string Lang string Subject string Body string Note string // free-form admin annotation, optional SavedBy uuid.UUID // uuid.Nil when actor is unknown — column is nullable } // Save validates and upserts the active row, appends a version, and GCs the // version log to EmailTemplateVersionRetention. All in one transaction. func (s *EmailTemplateService) Save(ctx context.Context, in SaveInput) (EmailTemplateVersionRow, error) { if !s.HasStore() { return EmailTemplateVersionRow{}, ErrTemplateStoreUnavailable } if !IsCanonicalKey(in.Key) { return EmailTemplateVersionRow{}, ErrTemplateUnknownKey } lang, err := canonicaliseLang(in.Lang) if err != nil { return EmailTemplateVersionRow{}, err } in.Lang = lang if err := ValidateTemplate(in.Key, in.Subject, in.Body); err != nil { return EmailTemplateVersionRow{}, err } tx, err := s.db.BeginTxx(ctx, nil) if err != nil { return EmailTemplateVersionRow{}, fmt.Errorf("begin tx: %w", err) } defer func() { _ = tx.Rollback() }() if _, err := tx.ExecContext(ctx, ` INSERT INTO paliad.email_templates (key, lang, subject, body, updated_at, updated_by) VALUES ($1, $2, $3, $4, now(), $5) ON CONFLICT (key, lang) DO UPDATE SET subject = EXCLUDED.subject, body = EXCLUDED.body, updated_at = now(), updated_by = EXCLUDED.updated_by`, in.Key, in.Lang, in.Subject, in.Body, nullableUUID(in.SavedBy)); err != nil { return EmailTemplateVersionRow{}, fmt.Errorf("upsert active: %w", err) } ver, err := insertVersion(ctx, tx, in.Key, in.Lang, in.Subject, in.Body, in.SavedBy, in.Note) if err != nil { return EmailTemplateVersionRow{}, err } if err := gcVersions(ctx, tx, in.Key, in.Lang); err != nil { return EmailTemplateVersionRow{}, err } if err := tx.Commit(); err != nil { return EmailTemplateVersionRow{}, fmt.Errorf("commit: %w", err) } return ver, nil } // Reset deletes the active row and appends a 'reset' version capturing the // embedded default. Subsequent renders fall back to the embedded body. func (s *EmailTemplateService) Reset(ctx context.Context, key, lang string, savedBy uuid.UUID) (EmailTemplateVersionRow, error) { if !s.HasStore() { return EmailTemplateVersionRow{}, ErrTemplateStoreUnavailable } if !IsCanonicalKey(key) { return EmailTemplateVersionRow{}, ErrTemplateUnknownKey } lang, err := canonicaliseLang(lang) if err != nil { return EmailTemplateVersionRow{}, err } def, err := embeddedDefault(key, lang) if err != nil { return EmailTemplateVersionRow{}, err } tx, err := s.db.BeginTxx(ctx, nil) if err != nil { return EmailTemplateVersionRow{}, fmt.Errorf("begin tx: %w", err) } defer func() { _ = tx.Rollback() }() if _, err := tx.ExecContext(ctx, `DELETE FROM paliad.email_templates WHERE key = $1 AND lang = $2`, key, lang); err != nil { return EmailTemplateVersionRow{}, fmt.Errorf("delete active: %w", err) } ver, err := insertVersion(ctx, tx, key, lang, def.Subject, def.Body, savedBy, "reset") if err != nil { return EmailTemplateVersionRow{}, err } if err := gcVersions(ctx, tx, key, lang); err != nil { return EmailTemplateVersionRow{}, err } if err := tx.Commit(); err != nil { return EmailTemplateVersionRow{}, fmt.Errorf("commit: %w", err) } return ver, nil } // ListVersions returns the most recent EmailTemplateVersionRetention rows. func (s *EmailTemplateService) ListVersions(ctx context.Context, key, lang string) ([]EmailTemplateVersionRow, error) { if !s.HasStore() { return nil, ErrTemplateStoreUnavailable } if !IsCanonicalKey(key) { return nil, ErrTemplateUnknownKey } lang, err := canonicaliseLang(lang) if err != nil { return nil, err } rows := []EmailTemplateVersionRow{} if err := s.db.SelectContext(ctx, &rows, ` SELECT id, key, lang, subject, body, saved_at, saved_by, note FROM paliad.email_template_versions WHERE key = $1 AND lang = $2 ORDER BY saved_at DESC LIMIT $3`, key, lang, EmailTemplateVersionRetention); err != nil { return nil, fmt.Errorf("list versions: %w", err) } return rows, nil } // RestoreVersion copies a historical version back into the active row, // appending a fresh version that records the restore source. func (s *EmailTemplateService) RestoreVersion(ctx context.Context, key, lang string, versionID, savedBy uuid.UUID) (EmailTemplateVersionRow, error) { if !s.HasStore() { return EmailTemplateVersionRow{}, ErrTemplateStoreUnavailable } if !IsCanonicalKey(key) { return EmailTemplateVersionRow{}, ErrTemplateUnknownKey } lang, err := canonicaliseLang(lang) if err != nil { return EmailTemplateVersionRow{}, err } var src EmailTemplateVersionRow err = s.db.GetContext(ctx, &src, ` SELECT id, key, lang, subject, body, saved_at, saved_by, note FROM paliad.email_template_versions WHERE id = $1 AND key = $2 AND lang = $3`, versionID, key, lang) if errors.Is(err, sql.ErrNoRows) { return EmailTemplateVersionRow{}, ErrTemplateVersionNotFound } if err != nil { return EmailTemplateVersionRow{}, fmt.Errorf("fetch version: %w", err) } return s.Save(ctx, SaveInput{ Key: key, Lang: lang, Subject: src.Subject, Body: src.Body, Note: fmt.Sprintf("restore from %s", versionID), SavedBy: savedBy, }) } // ValidateTemplate checks subject + body against the templating engines. // The structural checks ensure content templates re-define {{block "content"}} // (otherwise the body silently vanishes inside the base wrapper) and that // the base body still contains the {{block "content" .}} call (otherwise // every email loses its body). Returns nil iff both are syntactically and // structurally valid. func ValidateTemplate(key, subject, body string) error { if subject != "" { if _, err := texttemplate.New("subject").Parse(subject); err != nil { return fmt.Errorf("%w: %v", ErrTemplateSubjectSyntax, err) } } if _, err := htmltemplate.New("body").Parse(body); err != nil { return fmt.Errorf("%w: %v", ErrTemplateBodySyntax, err) } if key == EmailTemplateKeyBase { if !strings.Contains(body, `block "content"`) { return ErrTemplateMissingBaseBlock } } else { if !strings.Contains(body, `define "content"`) { return ErrTemplateMissingContent } } return nil } // --- internal helpers ------------------------------------------------------- func insertVersion(ctx context.Context, tx *sqlx.Tx, key, lang, subject, body string, savedBy uuid.UUID, note string) (EmailTemplateVersionRow, error) { var ver EmailTemplateVersionRow err := tx.GetContext(ctx, &ver, ` INSERT INTO paliad.email_template_versions (key, lang, subject, body, saved_by, note) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, key, lang, subject, body, saved_at, saved_by, note`, key, lang, subject, body, nullableUUID(savedBy), note) if err != nil { return EmailTemplateVersionRow{}, fmt.Errorf("insert version: %w", err) } return ver, nil } func gcVersions(ctx context.Context, tx *sqlx.Tx, key, lang string) error { if _, err := tx.ExecContext(ctx, ` DELETE FROM paliad.email_template_versions WHERE key = $1 AND lang = $2 AND id NOT IN ( SELECT id FROM paliad.email_template_versions WHERE key = $1 AND lang = $2 ORDER BY saved_at DESC LIMIT $3 )`, key, lang, EmailTemplateVersionRetention); err != nil { return fmt.Errorf("gc versions: %w", err) } return nil } func nullableUUID(u uuid.UUID) any { if u == uuid.Nil { return nil } return u } // defaultSubjects is the embedded-fallback subject template per (key, lang). // They're text/template (not html/template); subjects are plain strings, // no HTML escaping required. // // Keep the SYSTEMAUSFALL / SYSTEM FAILURE phrasing — see // docs/design-reminder-redesign-2026-04-28.md. Softening it weakens the // zero-overdue SLO and the comment in the seed exists so the next admin // who edits sees the rationale instead of pattern-matching the framing as // noise. var defaultSubjects = map[string]map[string]string{ EmailTemplateKeyInvitation: { "de": `[Paliad] {{.InviterName}} lädt Sie zu Paliad ein`, "en": `[Paliad] {{.InviterName}} invites you to Paliad`, }, EmailTemplateKeyAddUserWelcome: { "de": `[Paliad] Ihr Paliad-Konto ist bereit`, "en": `[Paliad] Your Paliad account is ready`, }, EmailTemplateKeyDeadlineDigest: { "de": digestSubjectDE, "en": digestSubjectEN, }, EmailTemplateKeyBase: {"de": "", "en": ""}, } const digestSubjectDE = `{{- /* keep the SYSTEMAUSFALL phrasing — see docs/design-reminder-redesign-2026-04-28.md */ -}} {{- if and .IsEvening (gt .OverdueCount 0) -}} [Paliad] SYSTEMAUSFALL: {{.OverdueCount}} überfällig — plus {{.DueTodayCount}} heute offen {{- else if and .IsEvening (gt .DueTodayCount 0) -}} [Paliad] DRINGEND — {{.DueTodayCount}} heute noch offen {{- else if .IsEvening -}} [Paliad] {{.OverdueCount}} überfällig {{- else if gt .OverdueCount 0 -}} [Paliad] ÜBERFÄLLIG: {{.OverdueCount}} — plus {{.OpenTotal}} weitere {{- else if eq .OpenTotal 1 -}} [Paliad] Frist-Erinnerung: 1 offen {{- else -}} [Paliad] Frist-Erinnerung: {{.OpenTotal}} offen {{- end -}}` const digestSubjectEN = `{{- /* keep the SYSTEM FAILURE phrasing — see docs/design-reminder-redesign-2026-04-28.md */ -}} {{- if and .IsEvening (gt .OverdueCount 0) -}} [Paliad] SYSTEM FAILURE: {{.OverdueCount}} overdue — plus {{.DueTodayCount}} still open today {{- else if and .IsEvening (gt .DueTodayCount 0) -}} [Paliad] URGENT — {{.DueTodayCount}} still open today {{- else if .IsEvening -}} [Paliad] {{.OverdueCount}} overdue {{- else if gt .OverdueCount 0 -}} [Paliad] OVERDUE: {{.OverdueCount}} — plus {{.OpenTotal}} more {{- else if eq .OpenTotal 1 -}} [Paliad] Deadline reminder: 1 open {{- else -}} [Paliad] Deadline reminder: {{.OpenTotal}} open {{- end -}}`