Files
paliad/docs/design-email-templates-2026-04-29.md
m c4122bc265 docs(admin): t-paliad-072 — m greenlighted all 5 open Qs
DB-backed (Q1), subjects customisable with SYSTEMAUSFALL comment in seed
(Q2), base.html editable (Q3-A), 20-version retention (Q4), note field
kept (Q5). Coder shift unblocked from the inventor side.
2026-04-29 21:46:35 +02:00

32 KiB
Raw Permalink Blame History

Admin Email-Templates editor — design

Task: t-paliad-072 (ritchie, inventor) Date: 2026-04-29 Status: design — awaiting m's go/no-go before coder shift

Problem statement

Today the three email templates Paliad sends (invitation, deadline_digest, plus the shared base.html wrapper) live in internal/templates/email/*.html, embedded into the binary at build time and rendered by MailService (internal/services/mail_service.go). Editing copy — even fixing a typo in the German Heute fällig heading — requires a code change, PR, merge to main, Dokploy redeploy.

The /admin landing page already advertises an "Email-Templates" card as "Kommt bald" (frontend/src/admin.tsx:36-42). This task fills it in: an admin can read each template, edit subject + body, preview against sample data, save without a deploy, and roll back if a save was wrong.


1. Storage decision

Decision: DB-backed, with the embedded files as the fallback default.

The task brief recommends DB unless m explicitly wants filesystem-only, and the whole rationale for surfacing a card on /admin is in-place editing. A filesystem-only "preview + variable docs" page would be a different feature (more like /admin/email-templates/docs) and doesn't fit the card we promised.

The embedded files stay. They are:

  1. The seed source (initial DB rows are populated from them).
  2. The render-time fallback when a DB row is missing or malformed (so a broken save can never wedge an entire send path — see §3).
  3. The "Reset to default" target (always available, always parseable).

Schema

Two new tables in a single migration 026_email_templates.up.sql. RLS enabled with no policies — service-only access, same pattern as paliad.invitations / paliad.reminder_log.

-- Active template body per (key, lang). Exactly one row per pair, kept
-- current by UPSERT on save. Absence == use embedded fallback.
CREATE TABLE paliad.email_templates (
    key          text NOT NULL,
    lang         text NOT NULL CHECK (lang IN ('de', 'en')),
    subject      text NOT NULL,                 -- text/template source
    body         text NOT NULL,                 -- html/template source ({{define "content"}})
    updated_at   timestamptz NOT NULL DEFAULT now(),
    updated_by   uuid REFERENCES auth.users(id) ON DELETE SET NULL,
    PRIMARY KEY (key, lang)
);

-- Append-only version log. Captures every save (and every reset). The
-- service garbage-collects to the most recent VERSION_RETENTION rows per
-- (key, lang) inside the same transaction as the save.
CREATE TABLE paliad.email_template_versions (
    id           uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    key          text NOT NULL,
    lang         text NOT NULL CHECK (lang IN ('de', 'en')),
    subject      text NOT NULL,
    body         text NOT NULL,
    saved_at     timestamptz NOT NULL DEFAULT now(),
    saved_by     uuid REFERENCES auth.users(id) ON DELETE SET NULL,
    note         text NOT NULL DEFAULT ''       -- '', 'reset', 'restore from <version_id>'
);

CREATE INDEX email_template_versions_key_lang_idx
    ON paliad.email_template_versions (key, lang, saved_at DESC);

ALTER TABLE paliad.email_templates          ENABLE ROW LEVEL SECURITY;
ALTER TABLE paliad.email_template_versions  ENABLE ROW LEVEL SECURITY;

key is one of invitation, deadline_digest, base — the existing template-name set. lang is de or en. The pair ('base', 'de') is the shared wrapper (it has lang-conditional bits in it today, but those become language-specific copies after the split — see §1.3).

VERSION_RETENTION = 20 per (key, lang). After 20 the oldest is deleted on save. Trade-off: 20 saves per language per template means at most 3 templates × 2 languages × 20 = 120 rows total in steady state. Negligible storage; gives admins room to recover from "I edited this five times in a row to get the wording right and then realised the third version was correct".

Migration plan

  1. Migration 026 (this PR) — creates both tables. Does not seed any rows. First-render-after-deploy reads embedded fallbacks; first save from the editor inserts the active row.

  2. Embedded file split (this PR) — replace each existing <name>.html with <name>.de.html and <name>.en.html. The current bilingual files use {{if eq .Lang "en"}}…{{else}}…{{end}} blocks; we split each branch into its own file. After this migration no template contains a Lang conditional — language is selected by file (or DB row) lookup, not by an in-template branch. This makes the editor UX simple ("you're editing the German invitation" — one file, no nested conditionals to confuse the reader).

  3. MailService refactorRenderTemplate looks up EmailTemplateService.GetActive(key, lang) first; on miss reads the embedded <key>.<lang>.html. base is loaded the same way (so the wrapper is editable). The render itself stays html/template over the cloned base.

  4. Subject becomes data, not code. The hard-coded inviteSubject and buildDigestSubject in Go go away. Each template's DB row has a subject column whose contents are a text/template source (not html/template — subject lines aren't HTML). Caller passes the same Data map; service renders subject and body from the same payload.

    Trade-off: today's subject logic for deadline_digest is conditional ("SYSTEMAUSFALL" vs "URGENT" vs plain count). It moves into the template syntax verbatim. Admins editing the subject see the conditional clearly and can adjust the framing. Mitigation against admin breakage: save validates the subject template parses cleanly and renders without error against the same sample data the body preview uses (§3.4).

Why not split DE/EN at the file level only (no DB)?

Considered. Rejected because the rejected version is "the editor card is a preview + docs page, no editing". That removes the only feature the card was named after. If m vetoes DB-backed editing, the fallback is to swap this card for "Email-Templates (Vorschau + Variablen)" — but that's a different product decision and I'd want to confirm before building toward it.


2. Editor UX

Page layout

GET /admin/email-templates — gated identically to /admin/team: auth.RequireAdminFunc(users, gateOnboarded(handleAdminEmailTemplatesPage)).

┌──────────────────────────────────────────────────────────────────────┐
│  Sidebar                                                             │
│                                                                       │
│  Email-Templates                                          [Vorschau ↻]│
│  Vorlagen für Einladungen, Erinnerungen und Layout-Wrapper.           │
│                                                                       │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐                │
│  │ Einladung    │  │ Fristen-     │  │ Basis        │                │
│  │ invitation   │  │ Sammelmail   │  │ base         │                │
│  │              │  │ deadline_…   │  │ Layout-      │                │
│  │ Zuletzt:     │  │              │  │ Wrapper      │                │
│  │ 2026-04-12   │  │ Standard     │  │ Standard     │                │
│  │ Standard     │  │              │  │              │                │
│  └──────────────┘  └──────────────┘  └──────────────┘                │
│                                                                       │
└──────────────────────────────────────────────────────────────────────┘

Three template cards. Each card shows: human title, internal key, "Zuletzt geändert" date (or "Standard" if no DB override), language badges (DE/EN — clicking enters the editor for that language).

Editor view

/admin/email-templates/{key}?lang=de (lang query, defaults to de).

┌────────────────────────────────────────────────────────────────────────┐
│ ← Zurück  Einladung — Deutsch                       [DE] [EN]          │
│                                                                        │
│ ┌─────────────────────────────────┐  ┌───────────────────────────────┐ │
│ │  Betreff                        │  │  VORSCHAU                     │ │
│ │  ┌───────────────────────────┐  │  │  ┌─────────────────────────┐  │ │
│ │  │ Einladung von {{.Inviter…│  │  │  │ <iframe rendered HTML>  │  │ │
│ │  └───────────────────────────┘  │  │  │                         │  │ │
│ │                                 │  │  │                         │  │ │
│ │  HTML-Body                      │  │  └─────────────────────────┘  │ │
│ │  ┌───────────────────────────┐  │  │                               │ │
│ │  │ {{define "content"}}      │  │  │  Betreff (gerendert):         │ │
│ │  │ <h1>{{.InviterName}}…    │  │  │  Einladung von Maria Schmidt  │ │
│ │  │ …                         │  │  │                               │ │
│ │  └───────────────────────────┘  │  │  [Vorschau aktualisieren]     │ │
│ │                                 │  │                               │ │
│ │  Verfügbare Variablen ⓘ         │  │                               │ │
│ │  ┌───────────────────────────┐  │  │                               │ │
│ │  │ .InviterName  Maria S…   │  │  │                               │ │
│ │  │ .InviterEmail maria@hl…  │  │  │                               │ │
│ │  │ .ToEmail      neu@hlc.de │  │  │                               │ │
│ │  │ .Message      "Komm rein"│  │  │                               │ │
│ │  │ .RegisterURL  https://…  │  │  │                               │ │
│ │  │ .Firm         HLC        │  │  │                               │ │
│ │  └───────────────────────────┘  │  │                               │ │
│ │                                 │  │                               │ │
│ │  [Speichern]  [Auf Standard    │  │  [Versionen ▾]                │ │
│ │   zurücksetzen]                 │  │                               │ │
│ └─────────────────────────────────┘  └───────────────────────────────┘ │
└────────────────────────────────────────────────────────────────────────┘

Three columns at desktop width, stacked on mobile (mobile-edit will be rare; acceptable to deprioritise).

Editing controls

  • Subject input — single-line text input. Holds a text/template source. Variable hints from the variable list autocomplete on {{ (v1 can skip the autocomplete and just rely on the variable list).
  • Body textarea — full raw HTML. Tall (~24 rows). Monospace. No syntax highlighting in v1 (textarea is fine — task brief).
  • Variable list — read-only list of available {{.Foo}} placeholders for this template, with the sample value next to each so the admin can see what they're substituting. List is hard-coded server-side per template key (§5).
  • Lang toggle [DE] [EN] — switching prompts "Ungespeicherte Änderungen verwerfen?" if the editor is dirty. Saves remain per-language.
  • Preview pane — iframe, sandboxed (sandbox="allow-same-origin" only — no scripts, no top-nav). Server renders with sample data and returns HTML; client srcdoc=s it. Updates on debounce (500ms after typing stops) and on explicit "Vorschau aktualisieren" click. Subject rendered above the iframe.
  • Save button — disabled until dirty. POSTs subject + body to PUT /api/admin/email-templates/{key}/{lang}. Server validates parse and a render against sample data before accepting; bad templates return 422 with the parse error.
  • Reset button — confirm modal, then POST /api/admin/email-templates/{key}/{lang}/reset deletes the active row (versions stay). Editor reloads with embedded fallback content.
  • Versionen dropdown — opens a side panel listing the most recent 20 versions for (key, lang). Each row: timestamp, who saved, optional note, "Vorschau" + "Wiederherstellen" buttons. Restoring is a save with note restore from <version_id>.

State machine on the client

loading -> ready         (active row + sample variables fetched)
ready -> dirty           (any input change)
dirty -> previewing      (debounce / button click → POST preview)
previewing -> ready      (success — but stays dirty until save)
dirty -> saving          (Save click → PUT)
saving -> ready          (success: clear dirty, reload active row)
saving -> save_error     (4xx → show parse error inline above subject input)
ready -> resetting       (Reset confirm → POST reset)
resetting -> ready       (success: reload, clear dirty)

No autosave. Patent lawyers will edit, preview, edit, preview, then commit intentionally — autosave would clutter the version log with intermediate junk.


3. Preview surface design

Sample data per template

Hard-coded server-side in internal/services/email_template_samples.go. One function per template key, returning a map[string]any plus a "sample subject context" for text/template rendering. Not user-editable in v1 (deferred — task brief out-of-scope is silent on this, but customising sample data is a lot of UI for marginal value).

invitation sample:

{
  "InviterName":  "Maria Schmidt",
  "InviterEmail": "maria.schmidt@hlc.com",
  "ToEmail":      "neu.kollege@hlc.de",
  "Message":      "Hallo Kolleg:in, ich glaube Paliad würde dir gefallen — schau es dir an.",
  "RegisterURL":  "https://paliad.de/login",
  "Firm":         "HLC",
}

deadline_digest sample (morning slot, 1 overdue + 2 today + 1 weekly):

{
  "Slot":           "morning",
  "IsEvening":      false,
  "Overdue": []map[string]any{{
    "DueDate": "2026-04-27", "Title": "Beschwerde gegen EP-Anmeldung",
    "ProjectReference": "HL-2024-0083", "ProjectTitle": "Acme vs Beta GmbH",
    "OwnerName": "Maria Schmidt", "IsOtherOwner": true,
    "URL": "https://paliad.de/deadlines/sample-1",
  }},
  "DueToday": []map[string]any{
    { "DueDate": "2026-04-29", "Title": "Klageerwiderung einreichen", ... },
    { "DueDate": "2026-04-29", "Title": "Vollmacht prüfen", ... },
  },
  "DueWarning": []map[string]any{
    { "DueDate": "2026-05-06", "Title": "Stellungnahme vorbereiten", ... },
  },
  "OverdueCount": 1, "DueTodayCount": 2, "DueWarningCount": 1,
  "DeadlinesURL": "https://paliad.de/deadlines",
  "Firm":         "HLC",
}

A ?slot=evening toggle on the preview endpoint flips IsEvening: true so the admin can see how the same body renders for the evening DRINGEND slot.

base sample — minimal: Subject: "Beispielbetreff", Lang: "de", Firm: "HLC", plus a placeholder content block (<p>Inhalt der spezifischen Mail …</p>).

Endpoint

POST /api/admin/email-templates/{key}/{lang}/preview
Body: { subject, body, slot? }

Server:

  1. Looks up samples[key] (404 if unknown key).
  2. Validates body parses as an html/template (returns 422 + parse error on failure).
  3. Validates subject parses as a text/template.
  4. Renders body inside the active base (DB row or embedded fallback for the same lang).
  5. Renders subject against the same data map.
  6. Returns { subject_rendered, html_rendered }. Client srcdoc=s the HTML.

Latency budget: < 100ms for sample rendering. No external I/O — all in-process html/template execution.

Why iframe (not innerHTML)

Email HTML uses inline styles aggressively that would otherwise leak into the editor's chrome (table-resets, body background colours, custom font stacks). Iframe gives a clean rendering boundary that matches what an email client would see. sandbox strips JS so a hostile template (impossible in v1 — only admins write — but defense-in-depth) can't escape.


4. Permission model

Identical to /admin/team (the existing precedent):

  • Page route: protected.HandleFunc("GET /admin/email-templates", adminGate(users, gateOnboarded(handleAdminEmailTemplatesPage)))
  • Editor route: protected.HandleFunc("GET /admin/email-templates/{key}", adminGate(users, gateOnboarded(handleAdminEmailTemplatesEdit)))
  • API routes: all under /api/admin/email-templates/..., gated by adminGate(users, ...).

adminGate is the existing auth.RequireAdminFunc(users, h) from internal/auth/require_admin.go. It checks paliad.users.global_role = 'global_admin' (post-migration 023). Non-admins on the API path get 403 JSON; non-admins on the page paths get 302 to /dashboard?forbidden=admin. Unauth gets 302 to /login from the outer Middleware before the admin gate runs. No new auth machinery.

The updated_by / saved_by audit columns capture the acting admin's auth.users.id so future "who broke the invitation template" questions have an answer in the version log.


5. Variable docs per template

Single source of truth: internal/services/email_template_variables.go, shipped alongside the sample-data file. Each template gets a typed list:

type Variable struct {
    Name        string  // ".InviterName"
    Type        string  // "string" | "date" | "url" | "[]Row"
    Description string  // "Anzeigename der einladenden Person"
    Sample      string  // "Maria Schmidt"
}

var Variables = map[string][]Variable{
    "invitation":      {  },
    "deadline_digest": {  },
    "base":            {  },
}

Served from GET /api/admin/email-templates/{key}/variables so the editor sidebar can render the list with samples without duplicating the schema in TypeScript.

Per-template variable contracts

invitation (lang ∈ {de, en}):

Variable Type Sample Description
.Lang string "de" Sprache der gerenderten Mail. Nicht direkt verwenden — wird im Body nicht mehr per {{if}} benötigt, da DE/EN getrennte Templates haben.
.Firm string "HLC" Firmenname (aus FIRM_NAME).
.InviterName string "Maria Schmidt" Anzeigename der einladenden Person.
.InviterEmail string "maria.schmidt@hlc.com" E-Mail der einladenden Person.
.ToEmail string "neu.kollege@hlc.de" Empfänger:in der Einladung.
.Message string "Hallo Kolleg:in …" Optionale persönliche Nachricht; leer wenn nichts angegeben. {{if .Message}}…{{end}} umschliesst den Block.
.RegisterURL string "https://paliad.de/login" Zielseite für den Anmelde-Button.
.Subject string "Einladung von Maria Schmidt zu Paliad" Vom System aus dem subject-Feld gerendert; der Body verwendet ihn typischerweise nicht, das <title> der base schon.

deadline_digest (lang ∈ {de, en}):

Variable Type Sample Description
.Lang, .Firm string wie oben wie oben
.Slot string "morning" / "evening" Trigger-Slot. Im Body meist über .IsEvening benutzt.
.IsEvening bool false True wenn Abend-Slot — steuert die DRINGEND-Headline.
.Overdue []Row siehe unten Überfällige Fristen.
.OverdueCount int 1 Länge von .Overdue, vorgerechnet für die Überschrift.
.DueToday []Row Heute fällig.
.DueTodayCount int 2
.DueWarning []Row In ≤ 1 Woche fällig.
.DueWarningCount int 1
.DeadlinesURL string "https://paliad.de/deadlines" Ziel des „Alle Fristen" Buttons.

Row (innerhalb range):

Feld Typ Sample Beschreibung
.DueDate string "2026-04-29" Fälligkeitsdatum, ISO.
.Title string "Klageerwiderung einreichen" Frist-Titel.
.ProjectReference string "HL-2024-0083" Akten-/Projekt-Aktenzeichen.
.ProjectTitle string "Acme vs Beta GmbH" Projekt-Titel; kann leer sein.
.OwnerName string "Maria Schmidt" Eigentümer:in der Frist.
.IsOtherOwner bool true True wenn die Frist nicht dem:der Empfänger:in gehört (Anzeige der Eigentümer-Zeile).
.URL string "https://paliad.de/deadlines/<uuid>" Direktlink zur Frist.

base (lang ∈ {de, en}):

Variable Type Sample Description
.Lang string "de" <html lang="…"> Attribut.
.Subject string "Einladung von Maria Schmidt" Wird ins <title> der Mail eingesetzt.
.Firm string "HLC" Footer-Branding.

base rendert via {{block "content" .}}{{end}} den Body des spezifischen Templates. Diese Block-Direktive darf nicht entfernt werden — der Editor muss sie validieren (siehe §6 Test plan).


6. Test plan

Unit tests — service

internal/services/email_template_service_test.go (new):

  • GetActive returns embedded fallback when no DB row.
  • GetActive returns DB row when present.
  • Save parses subject + body with text/template / html/template.
  • Save rejects bad template syntax ({{ .Foo unterminated → 422 path).
  • Save rejects body that doesn't redefine {{define "content"}} for non-base keys (otherwise the base block wouldn't fill).
  • Save rejects base body that removes the {{block "content" .}}{{end}} directive (would silently produce an empty inner body).
  • Save writes one row to email_template_versions per call.
  • Save triggers retention GC: after 21 saves to the same (key, lang), only 20 rows remain.
  • Reset deletes the active row but leaves versions intact.
  • RestoreVersion copies a historical row into active and adds a new version with note restore from <id>.

Unit tests — handlers

internal/handlers/email_templates_test.go (new):

  • GET /admin/email-templates and /admin/email-templates/{key} both return 302 to /login for unauth, 403 for non-admin, 200 for admin.
  • GET /api/admin/email-templates returns the canonical key list with active/lang info.
  • GET /api/admin/email-templates/{key}/variables returns the variable contract.
  • POST /api/admin/email-templates/{key}/{lang}/preview:
    • 200 with rendered subject + body for valid input.
    • 422 with parse error for bad subject template.
    • 422 with parse error for bad body template.
    • 404 for unknown key.
  • PUT /api/admin/email-templates/{key}/{lang} saves and returns the new version row id; rejects bad templates with 422.
  • POST /api/admin/email-templates/{key}/{lang}/reset deletes active row.
  • GET /api/admin/email-templates/{key}/{lang}/versions returns the version log, newest first.
  • POST /api/admin/email-templates/{key}/{lang}/restore/{version_id} restores.

Integration test

internal/services/mail_service_db_test.go (new):

  • With a DB-backed EmailTemplateService: insert a custom invitation row → MailService.RenderTemplate(invitation, de) returns the custom row, not the embedded fallback.
  • Delete the row → next render falls back to embedded.
  • Insert a syntactically broken row directly via SQL (bypassing the service validation that would normally reject it) → RenderTemplate falls back to embedded and logs an error. This is the core safety property: a corrupt DB row never breaks email delivery.

Manual smoke test (Playwright, optional v1)

  1. Login as tester@hlc.de / xdMmC7iCeDSTFmPXAlAyY0 (admin).
  2. Visit /admin → see "Email-Templates" card now linked, not "Kommt bald".
  3. Click → land on /admin/email-templates with three template cards.
  4. Click "Einladung" → editor with German content.
  5. Edit subject to Test {{.InviterName}} → preview pane shows Test Maria Schmidt.
  6. Save → success toast, "Zuletzt geändert" date updates.
  7. Send a real test invitation via the sidebar invite modal to m@flexsiebels.de → verify the new subject lands.
  8. Open Versionen → restore previous → invitation reverts.
  9. Reset to default → DB row deleted, fallback restored.
  10. Logout, login as a non-admin → /admin/email-templates 302s to /dashboard?forbidden=admin.

Smoke gate before merge

go test ./internal/services/... ./internal/handlers/... clean, go build ./... clean, cd frontend && bun run build clean.


7. Implementation order

Six logical chunks. Coder shift implementer's call whether to land them as one PR or split.

  1. Migration 026 + embedded file split (DE/EN). Server still uses embedded files; nothing else changes. Verifies the split renders identically to the bilingual originals (golden tests in mail_service_test.go already exercise both languages — keep them).
  2. EmailTemplateServiceGetActive, Save, Reset, Versions, Restore, retention GC, sample data + variable docs.
  3. MailService refactor — replace embedded-only render with service lookup; subject moves from Go-built strings to template render. Update the two callers (invite_service.go, reminder_service.go) to pass subject data instead of the formatted subject string. Verify buildDigestSubject is fully removed.
  4. Handlers + API routes — both page handlers (/admin/email-templates, /admin/email-templates/{key}) plus the eight API endpoints.
  5. Frontendfrontend/src/admin-email-templates.tsx + frontend/src/admin-email-templates-edit.tsx + their client/*.ts counterparts; admin.tsx flips the placeholder card; i18n.ts gains the new strings.
  6. Smoke — manual Playwright run with tester@hlc.de.

8. Open questions for m — RESOLVED 2026-04-29

  1. DB-backed editing confirmed?YES, DB.
  2. Subjects move into templates (admin-editable)?YES, customisable. Mitigation kept: seeded deadline_digest subject ships with a {{/* keep the SYSTEMAUSFALL phrasing — see docs/design-reminder-redesign-2026-04-28.md */}} comment so the next admin who edits the SLO-critical framing sees the rationale.
  3. base.html editable, or locked?A. Editable like the others. Version log + reset-to-default + render-time fallback on parse error are the safety net.
  4. Versioning depth20 per (key, lang). Confirmed.
  5. note field on version rows + optional "Notiz" input on save?YES, keep.

All decisions baked into §1§7. No remaining blockers from the inventor side; coder shift can start once head greenlights.


9. Out of scope (deferred, per task brief)

  • New template types beyond the existing three (password reset, account locked, etc.) — defer until those flows exist.
  • Per-firm overrides — FIRM_NAME already templates "HLC" → "anything" but per-firm full-template branching is not needed today (paliad serves one firm per deployment).
  • A/B testing — not justified for transactional mail at this volume.
  • WYSIWYG editor — explicit out-of-scope. Plain textarea is the v1.
  • Editable sample data — admins use a fixed sample set in v1.
  • Side-by-side DE / EN editing — language toggle in v1, not a split view.
  • Plain-text body editing — text fallback is auto-derived by htmlToText; exposing it as an editable field is a future-feature.

10. Coder fit

The implementation is mostly straight-line: migration, service, handlers, frontend. The interesting risks are (a) the embedded-file DE/EN split must golden-match the existing bilingual render byte-for-byte where possible (or with explainable diffs), and (b) the MailService fallback path must be provably safe — bad DB row → embedded render, never a 500 inside the reminder ticker. Both are testable.

Suggested coder for the implementation shift: same role/skill that landed t-paliad-021 (knuth) or whoever currently has the warmest cache on mail_service.go and reminder_service.go. I'm fine to implement this myself if head wants — but no strong preference; head decides.


11. Files (for the implementing coder)

New

  • internal/db/migrations/026_email_templates.up.sql
  • internal/db/migrations/026_email_templates.down.sql
  • internal/services/email_template_service.go
  • internal/services/email_template_service_test.go
  • internal/services/email_template_samples.go
  • internal/services/email_template_variables.go
  • internal/services/mail_service_db_test.go
  • internal/handlers/email_templates.go
  • internal/handlers/email_templates_test.go
  • internal/templates/email/invitation.de.html + invitation.en.html
  • internal/templates/email/deadline_digest.de.html + .en.html
  • internal/templates/email/base.de.html + base.en.html
  • frontend/src/admin-email-templates.tsx
  • frontend/src/admin-email-templates-edit.tsx
  • frontend/src/client/admin-email-templates.ts
  • frontend/src/client/admin-email-templates-edit.ts

Edit

  • internal/templates/email.go — embed pattern stays; embed glob already covers *.html so the per-lang split files come for free.
  • internal/services/mail_service.goRenderTemplate consults EmailTemplateService first, falls back to embedded; SendTemplate accepts a subject template + data, stops requiring a pre-formatted subject string.
  • internal/services/invite_service.go — drop inviteSubject; pass subject via the data map.
  • internal/services/reminder_service.go — drop buildDigestSubject; pass slot/counts via the data map.
  • internal/services/mail_service_test.go — adjust for new subject path.
  • internal/handlers/handlers.go — register the new routes alongside the existing /admin/team block.
  • cmd/server/main.go — wire EmailTemplateService, pass it to NewMailService (or set on MailService post-construct).
  • frontend/src/admin.tsx — flip the "Email-Templates" placeholder card from admin-card-soon to a real card card-link pointing at /admin/email-templates. Re-sequence PLANNED so it drops to three entries.
  • frontend/src/client/i18n.ts — drop "kommt bald" framing from email_templates; add new strings: admin.email_templates.title, .heading, .subtitle, .list.last_modified, .list.default, .editor.subject, .editor.body, .editor.variables, .editor.preview, .editor.save, .editor.reset, .editor.reset_confirm, .editor.versions, .editor.restore, .editor.restore_confirm, .editor.dirty_warn, .editor.parse_error, .editor.note_optional, all DE + EN.
  • frontend/build.ts — add renderAdminEmailTemplates + renderAdminEmailTemplatesEdit entry points and bundle the two client TS files.