# 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 — 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 depth** → **20 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.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.
