docs(admin): design — admin email-templates editor (t-paliad-072)

DB-backed templates with embedded fallback, per-language split, full
edit/preview/version/restore loop. Subject moves from Go-built strings to
template-rendered. Five open questions for m parked at §8 — most loaded:
should base.html be editable or read-only.
This commit is contained in:
m
2026-04-29 19:06:15 +02:00
parent 933a16b6eb
commit 9e216a4c44

View File

@@ -0,0 +1,611 @@
# 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`.
```sql
-- 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 refactor**`RenderTemplate` 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:
```go
{
"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):
```go
{
"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:
```go
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. **EmailTemplateService** `GetActive`, `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. **Frontend** `frontend/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
1. **DB-backed editing confirmed?** The brief recommends DB; this design
builds on that. If you want filesystem-only, scope shrinks to "preview +
variable docs" (no edit, no save, no versions) and the admin card text
should change to match.
2. **Subject moves into templates.** Today `buildDigestSubject` and
`inviteSubject` are Go code. After this design they become the `subject`
column of the (`deadline_digest`, lang) and (`invitation`, lang) rows.
That means the SYSTEMAUSFALL / DRINGEND / URGENT subject framing
becomes admin-editable. **OK?** Risk: an admin softens "SYSTEMAUSFALL"
to "Bitte beachten" and the SLO-no-overdue framing weakens. Mitigation:
leave a comment in the seeded subject template explaining the framing
(`{{/* keep the SYSTEMAUSFALL phrasing — see docs/design-reminder-redesign-2026-04-28.md */}}`)
so the next admin who edits sees the rationale.
3. **`base` editable, or locked?** The shared wrapper sets the brand
colours, lime header, footer link. Letting admins edit it lets them fix
a footer typo without code, but they can also break every email at
once. Two options:
- **A**: Editable like the others, full versioning. Risk above accepted.
- **B**: Read-only in the editor (preview + variable docs only); only
content templates are editable. Admin who needs to change the
wrapper opens a Gitea issue.
I lean **A** the version log + reset-to-default + render-time fallback
on parse error is enough safety net, and the alternative leaves a
foot-gun where the only escape is "redeploy". Confirm.
4. **Versioning depth.** Proposed 20 per (key, lang). Going to 5 (task
suggestion) saves nothing meaningful (table is tiny) and increases the
chance the version a user wants is already gone. Going to "unbounded"
is fine too scale is negligible. **20 OK?**
5. **Edit log on the version rows: does it include the `note` text field?**
I added `note` to capture intent ('reset', 'restore from <version_id>',
user-provided "Korrektur Apr-29 nach Anwalts-Feedback"). The save form
would have a small optional "Notiz" input. **Worth it, or strip the
field?**
---
## 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.go``RenderTemplate` 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.