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:
611
docs/design-email-templates-2026-04-29.md
Normal file
611
docs/design-email-templates-2026-04-29.md
Normal 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.
|
||||
Reference in New Issue
Block a user