DB-backed (Q1), subjects customisable with SYSTEMAUSFALL comment in seed (Q2), base.html editable (Q3-A), 20-version retention (Q4), note field kept (Q5). Coder shift unblocked from the inventor side.
32 KiB
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:
- The seed source (initial DB rows are populated from them).
- 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).
- The "Reset to default" target (always available, always parseable).
Schema
Two new tables in a single migration 026_email_templates.up.sql. RLS
enabled with no policies — service-only access, same pattern as
paliad.invitations / paliad.reminder_log.
-- Active template body per (key, lang). Exactly one row per pair, kept
-- current by UPSERT on save. Absence == use embedded fallback.
CREATE TABLE paliad.email_templates (
key text NOT NULL,
lang text NOT NULL CHECK (lang IN ('de', 'en')),
subject text NOT NULL, -- text/template source
body text NOT NULL, -- html/template source ({{define "content"}})
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
PRIMARY KEY (key, lang)
);
-- Append-only version log. Captures every save (and every reset). The
-- service garbage-collects to the most recent VERSION_RETENTION rows per
-- (key, lang) inside the same transaction as the save.
CREATE TABLE paliad.email_template_versions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
key text NOT NULL,
lang text NOT NULL CHECK (lang IN ('de', 'en')),
subject text NOT NULL,
body text NOT NULL,
saved_at timestamptz NOT NULL DEFAULT now(),
saved_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
note text NOT NULL DEFAULT '' -- '', 'reset', 'restore from <version_id>'
);
CREATE INDEX email_template_versions_key_lang_idx
ON paliad.email_template_versions (key, lang, saved_at DESC);
ALTER TABLE paliad.email_templates ENABLE ROW LEVEL SECURITY;
ALTER TABLE paliad.email_template_versions ENABLE ROW LEVEL SECURITY;
key is one of invitation, deadline_digest, base — the existing
template-name set. lang is de or en. The pair ('base', 'de') is the
shared wrapper (it has lang-conditional bits in it today, but those become
language-specific copies after the split — see §1.3).
VERSION_RETENTION = 20 per (key, lang). After 20 the oldest is deleted on
save. Trade-off: 20 saves per language per template means at most
3 templates × 2 languages × 20 = 120 rows total in steady state. Negligible
storage; gives admins room to recover from "I edited this five times in a row
to get the wording right and then realised the third version was correct".
Migration plan
-
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.
-
Embedded file split (this PR) — replace each existing
<name>.htmlwith<name>.de.htmland<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 aLangconditional — 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). -
MailService refactor —
RenderTemplatelooks upEmailTemplateService.GetActive(key, lang)first; on miss reads the embedded<key>.<lang>.html.baseis loaded the same way (so the wrapper is editable). The render itself stayshtml/templateover the cloned base. -
Subject becomes data, not code. The hard-coded
inviteSubjectandbuildDigestSubjectin Go go away. Each template's DB row has asubjectcolumn whose contents are atext/templatesource (nothtml/template— subject lines aren't HTML). Caller passes the sameDatamap; service renders subject and body from the same payload.Trade-off: today's subject logic for
deadline_digestis 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/templatesource. 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; clientsrcdoc=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}/resetdeletes the active row (versions stay). Editor reloads with embedded fallback content. - Versionen dropdown — opens a side panel listing the most recent 20
versions for (key, lang). Each row: timestamp, who saved, optional note,
"Vorschau" + "Wiederherstellen" buttons. Restoring is a save with note
restore from <version_id>.
State machine on the client
loading -> ready (active row + sample variables fetched)
ready -> dirty (any input change)
dirty -> previewing (debounce / button click → POST preview)
previewing -> ready (success — but stays dirty until save)
dirty -> saving (Save click → PUT)
saving -> ready (success: clear dirty, reload active row)
saving -> save_error (4xx → show parse error inline above subject input)
ready -> resetting (Reset confirm → POST reset)
resetting -> ready (success: reload, clear dirty)
No autosave. Patent lawyers will edit, preview, edit, preview, then commit intentionally — autosave would clutter the version log with intermediate junk.
3. Preview surface design
Sample data per template
Hard-coded server-side in internal/services/email_template_samples.go. One
function per template key, returning a map[string]any plus a "sample
subject context" for text/template rendering. Not user-editable in v1
(deferred — task brief out-of-scope is silent on this, but customising
sample data is a lot of UI for marginal value).
invitation sample:
{
"InviterName": "Maria Schmidt",
"InviterEmail": "maria.schmidt@hlc.com",
"ToEmail": "neu.kollege@hlc.de",
"Message": "Hallo Kolleg:in, ich glaube Paliad würde dir gefallen — schau es dir an.",
"RegisterURL": "https://paliad.de/login",
"Firm": "HLC",
}
deadline_digest sample (morning slot, 1 overdue + 2 today + 1 weekly):
{
"Slot": "morning",
"IsEvening": false,
"Overdue": []map[string]any{{
"DueDate": "2026-04-27", "Title": "Beschwerde gegen EP-Anmeldung",
"ProjectReference": "HL-2024-0083", "ProjectTitle": "Acme vs Beta GmbH",
"OwnerName": "Maria Schmidt", "IsOtherOwner": true,
"URL": "https://paliad.de/deadlines/sample-1",
}},
"DueToday": []map[string]any{
{ "DueDate": "2026-04-29", "Title": "Klageerwiderung einreichen", ... },
{ "DueDate": "2026-04-29", "Title": "Vollmacht prüfen", ... },
},
"DueWarning": []map[string]any{
{ "DueDate": "2026-05-06", "Title": "Stellungnahme vorbereiten", ... },
},
"OverdueCount": 1, "DueTodayCount": 2, "DueWarningCount": 1,
"DeadlinesURL": "https://paliad.de/deadlines",
"Firm": "HLC",
}
A ?slot=evening toggle on the preview endpoint flips IsEvening: true so
the admin can see how the same body renders for the evening DRINGEND slot.
base sample — minimal: Subject: "Beispielbetreff", Lang: "de", Firm: "HLC", plus a placeholder content block (<p>Inhalt der spezifischen Mail …</p>).
Endpoint
POST /api/admin/email-templates/{key}/{lang}/preview
Body: { subject, body, slot? }
Server:
- Looks up
samples[key](404 if unknown key). - Validates
bodyparses as anhtml/template(returns 422 + parse error on failure). - Validates
subjectparses as atext/template. - Renders body inside the active
base(DB row or embedded fallback for the same lang). - Renders subject against the same data map.
- Returns
{ subject_rendered, html_rendered }. Clientsrcdoc=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 byadminGate(users, ...).
adminGate is the existing auth.RequireAdminFunc(users, h) from
internal/auth/require_admin.go. It checks paliad.users.global_role = 'global_admin' (post-migration 023). Non-admins on the API path get 403
JSON; non-admins on the page paths get 302 to /dashboard?forbidden=admin.
Unauth gets 302 to /login from the outer Middleware before the admin gate
runs. No new auth machinery.
The updated_by / saved_by audit columns capture the acting admin's
auth.users.id so future "who broke the invitation template" questions
have an answer in the version log.
5. Variable docs per template
Single source of truth: internal/services/email_template_variables.go,
shipped alongside the sample-data file. Each template gets a typed list:
type Variable struct {
Name string // ".InviterName"
Type string // "string" | "date" | "url" | "[]Row"
Description string // "Anzeigename der einladenden Person"
Sample string // "Maria Schmidt"
}
var Variables = map[string][]Variable{
"invitation": { … },
"deadline_digest": { … },
"base": { … },
}
Served from GET /api/admin/email-templates/{key}/variables so the editor
sidebar can render the list with samples without duplicating the schema in
TypeScript.
Per-template variable contracts
invitation (lang ∈ {de, en}):
| Variable | Type | Sample | Description |
|---|---|---|---|
.Lang |
string |
"de" |
Sprache der gerenderten Mail. Nicht direkt verwenden — wird im Body nicht mehr per {{if}} benötigt, da DE/EN getrennte Templates haben. |
.Firm |
string |
"HLC" |
Firmenname (aus FIRM_NAME). |
.InviterName |
string |
"Maria Schmidt" |
Anzeigename der einladenden Person. |
.InviterEmail |
string |
"maria.schmidt@hlc.com" |
E-Mail der einladenden Person. |
.ToEmail |
string |
"neu.kollege@hlc.de" |
Empfänger:in der Einladung. |
.Message |
string |
"Hallo Kolleg:in …" |
Optionale persönliche Nachricht; leer wenn nichts angegeben. {{if .Message}}…{{end}} umschliesst den Block. |
.RegisterURL |
string |
"https://paliad.de/login" |
Zielseite für den Anmelde-Button. |
.Subject |
string |
"Einladung von Maria Schmidt zu Paliad" |
Vom System aus dem subject-Feld gerendert; der Body verwendet ihn typischerweise nicht, das <title> der base schon. |
deadline_digest (lang ∈ {de, en}):
| Variable | Type | Sample | Description |
|---|---|---|---|
.Lang, .Firm |
string |
wie oben | wie oben |
.Slot |
string |
"morning" / "evening" |
Trigger-Slot. Im Body meist über .IsEvening benutzt. |
.IsEvening |
bool |
false |
True wenn Abend-Slot — steuert die DRINGEND-Headline. |
.Overdue |
[]Row |
siehe unten | Überfällige Fristen. |
.OverdueCount |
int |
1 |
Länge von .Overdue, vorgerechnet für die Überschrift. |
.DueToday |
[]Row |
… | Heute fällig. |
.DueTodayCount |
int |
2 |
… |
.DueWarning |
[]Row |
… | In ≤ 1 Woche fällig. |
.DueWarningCount |
int |
1 |
… |
.DeadlinesURL |
string |
"https://paliad.de/deadlines" |
Ziel des „Alle Fristen" Buttons. |
Row (innerhalb range):
| Feld | Typ | Sample | Beschreibung |
|---|---|---|---|
.DueDate |
string |
"2026-04-29" |
Fälligkeitsdatum, ISO. |
.Title |
string |
"Klageerwiderung einreichen" |
Frist-Titel. |
.ProjectReference |
string |
"HL-2024-0083" |
Akten-/Projekt-Aktenzeichen. |
.ProjectTitle |
string |
"Acme vs Beta GmbH" |
Projekt-Titel; kann leer sein. |
.OwnerName |
string |
"Maria Schmidt" |
Eigentümer:in der Frist. |
.IsOtherOwner |
bool |
true |
True wenn die Frist nicht dem:der Empfänger:in gehört (Anzeige der Eigentümer-Zeile). |
.URL |
string |
"https://paliad.de/deadlines/<uuid>" |
Direktlink zur Frist. |
base (lang ∈ {de, en}):
| Variable | Type | Sample | Description |
|---|---|---|---|
.Lang |
string |
"de" |
<html lang="…"> Attribut. |
.Subject |
string |
"Einladung von Maria Schmidt" |
Wird ins <title> der Mail eingesetzt. |
.Firm |
string |
"HLC" |
Footer-Branding. |
base rendert via {{block "content" .}}{{end}} den Body des spezifischen
Templates. Diese Block-Direktive darf nicht entfernt werden — der
Editor muss sie validieren (siehe §6 Test plan).
6. Test plan
Unit tests — service
internal/services/email_template_service_test.go (new):
GetActivereturns embedded fallback when no DB row.GetActivereturns DB row when present.Saveparses subject + body withtext/template/html/template.Saverejects bad template syntax ({{ .Foounterminated → 422 path).Saverejects body that doesn't redefine{{define "content"}}for non-base keys (otherwise thebaseblock wouldn't fill).Saverejectsbasebody that removes the{{block "content" .}}{{end}}directive (would silently produce an empty inner body).Savewrites one row toemail_template_versionsper call.Savetriggers retention GC: after 21 saves to the same (key, lang), only 20 rows remain.Resetdeletes the active row but leaves versions intact.RestoreVersioncopies a historical row into active and adds a new version with noterestore from <id>.
Unit tests — handlers
internal/handlers/email_templates_test.go (new):
GET /admin/email-templatesand/admin/email-templates/{key}both return 302 to/loginfor unauth, 403 for non-admin, 200 for admin.GET /api/admin/email-templatesreturns the canonical key list with active/lang info.GET /api/admin/email-templates/{key}/variablesreturns 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}/resetdeletes active row.GET /api/admin/email-templates/{key}/{lang}/versionsreturns 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) →
RenderTemplatefalls 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)
- Login as
tester@hlc.de/xdMmC7iCeDSTFmPXAlAyY0(admin). - Visit
/admin→ see "Email-Templates" card now linked, not "Kommt bald". - Click → land on
/admin/email-templateswith three template cards. - Click "Einladung" → editor with German content.
- Edit subject to
Test {{.InviterName}}→ preview pane showsTest Maria Schmidt. - Save → success toast, "Zuletzt geändert" date updates.
- Send a real test invitation via the sidebar invite modal to
m@flexsiebels.de→ verify the new subject lands. - Open Versionen → restore previous → invitation reverts.
- Reset to default → DB row deleted, fallback restored.
- Logout, login as a non-admin →
/admin/email-templates302s 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.
- 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.goalready exercise both languages — keep them). - EmailTemplateService —
GetActive,Save,Reset,Versions,Restore, retention GC, sample data + variable docs. - 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. VerifybuildDigestSubjectis fully removed. - Handlers + API routes — both page handlers (
/admin/email-templates,/admin/email-templates/{key}) plus the eight API endpoints. - Frontend —
frontend/src/admin-email-templates.tsx+frontend/src/admin-email-templates-edit.tsx+ theirclient/*.tscounterparts;admin.tsxflips the placeholder card;i18n.tsgains the new strings. - Smoke — manual Playwright run with
tester@hlc.de.
8. Open questions for m — RESOLVED 2026-04-29
- DB-backed editing confirmed? → YES, DB.
- Subjects move into templates (admin-editable)? → YES, customisable.
Mitigation kept: seeded
deadline_digestsubject 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. base.htmleditable, or locked? → A. Editable like the others. Version log + reset-to-default + render-time fallback on parse error are the safety net.- Versioning depth → 20 per (key, lang). Confirmed.
notefield 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_NAMEalready 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.sqlinternal/db/migrations/026_email_templates.down.sqlinternal/services/email_template_service.gointernal/services/email_template_service_test.gointernal/services/email_template_samples.gointernal/services/email_template_variables.gointernal/services/mail_service_db_test.gointernal/handlers/email_templates.gointernal/handlers/email_templates_test.gointernal/templates/email/invitation.de.html+invitation.en.htmlinternal/templates/email/deadline_digest.de.html+.en.htmlinternal/templates/email/base.de.html+base.en.htmlfrontend/src/admin-email-templates.tsxfrontend/src/admin-email-templates-edit.tsxfrontend/src/client/admin-email-templates.tsfrontend/src/client/admin-email-templates-edit.ts
Edit
internal/templates/email.go— embed pattern stays; embed glob already covers*.htmlso the per-lang split files come for free.internal/services/mail_service.go—RenderTemplateconsultsEmailTemplateServicefirst, falls back to embedded;SendTemplateaccepts a subject template + data, stops requiring a pre-formatted subject string.internal/services/invite_service.go— dropinviteSubject; pass subject via the data map.internal/services/reminder_service.go— dropbuildDigestSubject; 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/teamblock.cmd/server/main.go— wireEmailTemplateService, pass it toNewMailService(or set onMailServicepost-construct).frontend/src/admin.tsx— flip the "Email-Templates" placeholder card fromadmin-card-soonto a realcard card-linkpointing at/admin/email-templates. Re-sequencePLANNEDso 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— addrenderAdminEmailTemplates+renderAdminEmailTemplatesEditentry points and bundle the two client TS files.