Files
paliad/internal/services/email_template_service.go
m 0e3411c40b feat(admin): /admin/email-templates editor (t-paliad-072)
DB-backed email-template editor for global_admins, replacing the
"Kommt bald" placeholder. Admins can edit invitation, deadline_digest,
and the shared base wrapper for both DE and EN, preview against sample
data, save with versions, and reset to the embedded default.

Backend:
- Migration 026 adds paliad.email_templates (active row per (key, lang))
  and paliad.email_template_versions (append-only, retained 20 deep).
- EmailTemplateService — GetActive falls through to the embedded per-
  language file when no DB row, Save validates parse + structural
  invariants and writes a version, Reset deletes the active row, Restore
  copies a version back. Mutations require DB; reads work without.
- MailService now consults the service for body and subject and falls
  back to the embedded default if the active row is malformed at parse
  time — a corrupt admin save can never wedge the send path.
- Subjects move from Go (buildDigestSubject + inviteSubject) to
  text/template strings stored in the (key, lang) row. Default subjects
  ship with a {{/* keep this phrasing */}} comment pointing at the
  reminder-redesign doc so the SLO framing rationale survives edits.
- Bilingual templates split into per-language files (invitation.de.html
  + .en.html, deadline_digest.de.html + .en.html, base.de.html + .en.html).
  No more {{if eq .Lang}} branching inside templates.
- Handlers under /api/admin/email-templates/* gated by the existing
  RequireAdminFunc(users) admin middleware, same shape as /admin/team.

Frontend:
- /admin/email-templates list page — three cards (one per template),
  each linking to DE + EN editors with their last-modified status.
- /admin/email-templates/{key}?lang=de three-pane editor — subject + body
  textarea + variable docs + actions on the left, sandboxed iframe
  preview + version log on the right. 500 ms debounced live preview;
  save validates server-side (422 on parse error, surfaced inline).
- admin.tsx flips the Email-Templates card from PLANNED to verfügbar.
- 50 new i18n keys (DE + EN) for the editor surface.

Tests: GetActive fallback path, ValidateTemplate happy + sad paths,
SaveRequiresStore on no-DB service, RenderTemplate body + subject
goldens, full SYSTEMAUSFALL/SYSTEM FAILURE subject matrix.

Smoke (knowledge-platform-only run, no DB/auth):
- GET /admin/email-templates → 302 to /login
- GET /api/admin/email-templates → 401
- go build/vet/test clean, bun run build clean

Design: docs/design-email-templates-2026-04-29.md.
2026-04-29 22:09:39 +02:00

459 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 — 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/patholo/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"
)
// CanonicalEmailTemplateKeys is the closed set in canonical display order.
var CanonicalEmailTemplateKeys = []string{
EmailTemplateKeyInvitation,
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`,
},
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 -}}`