DB-backed email-template editor for global_admins, replacing the
"Kommt bald" placeholder. Admins can edit invitation, deadline_digest,
and the shared base wrapper for both DE and EN, preview against sample
data, save with versions, and reset to the embedded default.
Backend:
- Migration 026 adds paliad.email_templates (active row per (key, lang))
and paliad.email_template_versions (append-only, retained 20 deep).
- EmailTemplateService — GetActive falls through to the embedded per-
language file when no DB row, Save validates parse + structural
invariants and writes a version, Reset deletes the active row, Restore
copies a version back. Mutations require DB; reads work without.
- MailService now consults the service for body and subject and falls
back to the embedded default if the active row is malformed at parse
time — a corrupt admin save can never wedge the send path.
- Subjects move from Go (buildDigestSubject + inviteSubject) to
text/template strings stored in the (key, lang) row. Default subjects
ship with a {{/* keep this phrasing */}} comment pointing at the
reminder-redesign doc so the SLO framing rationale survives edits.
- Bilingual templates split into per-language files (invitation.de.html
+ .en.html, deadline_digest.de.html + .en.html, base.de.html + .en.html).
No more {{if eq .Lang}} branching inside templates.
- Handlers under /api/admin/email-templates/* gated by the existing
RequireAdminFunc(users) admin middleware, same shape as /admin/team.
Frontend:
- /admin/email-templates list page — three cards (one per template),
each linking to DE + EN editors with their last-modified status.
- /admin/email-templates/{key}?lang=de three-pane editor — subject + body
textarea + variable docs + actions on the left, sandboxed iframe
preview + version log on the right. 500 ms debounced live preview;
save validates server-side (422 on parse error, surfaced inline).
- admin.tsx flips the Email-Templates card from PLANNED to verfügbar.
- 50 new i18n keys (DE + EN) for the editor surface.
Tests: GetActive fallback path, ValidateTemplate happy + sad paths,
SaveRequiresStore on no-DB service, RenderTemplate body + subject
goldens, full SYSTEMAUSFALL/SYSTEM FAILURE subject matrix.
Smoke (knowledge-platform-only run, no DB/auth):
- GET /admin/email-templates → 302 to /login
- GET /api/admin/email-templates → 401
- go build/vet/test clean, bun run build clean
Design: docs/design-email-templates-2026-04-29.md.
158 lines
4.4 KiB
Go
158 lines
4.4 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// TestGetActiveEmbeddedFallback covers the no-DB path. Without a sqlx.DB,
|
|
// every key/lang pair returns the embedded default with IsDefault=true.
|
|
func TestGetActiveEmbeddedFallback(t *testing.T) {
|
|
svc := NewEmailTemplateService(nil)
|
|
for _, key := range CanonicalEmailTemplateKeys {
|
|
for _, lang := range EmailTemplateLanguages {
|
|
row, err := svc.GetActive(context.Background(), key, lang)
|
|
if err != nil {
|
|
t.Errorf("GetActive(%s, %s): %v", key, lang, err)
|
|
continue
|
|
}
|
|
if !row.IsDefault {
|
|
t.Errorf("GetActive(%s, %s): IsDefault=false on no-DB service", key, lang)
|
|
}
|
|
if row.Body == "" {
|
|
t.Errorf("GetActive(%s, %s): empty body", key, lang)
|
|
}
|
|
if key == EmailTemplateKeyBase {
|
|
if row.Subject != "" {
|
|
t.Errorf("base subject expected empty, got %q", row.Subject)
|
|
}
|
|
} else if row.Subject == "" {
|
|
t.Errorf("GetActive(%s, %s): empty subject for non-base key", key, lang)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestGetActiveUnknownKey ensures unknown keys are 404-shaped.
|
|
func TestGetActiveUnknownKey(t *testing.T) {
|
|
svc := NewEmailTemplateService(nil)
|
|
if _, err := svc.GetActive(context.Background(), "nope", "de"); err != ErrTemplateUnknownKey {
|
|
t.Errorf("expected ErrTemplateUnknownKey, got %v", err)
|
|
}
|
|
}
|
|
|
|
// TestGetActiveUnknownLang ensures unknown languages are 400-shaped.
|
|
func TestGetActiveUnknownLang(t *testing.T) {
|
|
svc := NewEmailTemplateService(nil)
|
|
if _, err := svc.GetActive(context.Background(), EmailTemplateKeyInvitation, "fr"); err != ErrTemplateUnknownLang {
|
|
t.Errorf("expected ErrTemplateUnknownLang, got %v", err)
|
|
}
|
|
}
|
|
|
|
// TestSaveRequiresStore checks that mutations against a no-DB service fail
|
|
// closed — no silent acceptance, no in-memory drift.
|
|
func TestSaveRequiresStore(t *testing.T) {
|
|
svc := NewEmailTemplateService(nil)
|
|
_, err := svc.Save(context.Background(), SaveInput{
|
|
Key: EmailTemplateKeyInvitation,
|
|
Lang: "de",
|
|
Subject: "x",
|
|
Body: `{{define "content"}}<p>x</p>{{end}}`,
|
|
})
|
|
if err != ErrTemplateStoreUnavailable {
|
|
t.Errorf("expected ErrTemplateStoreUnavailable, got %v", err)
|
|
}
|
|
}
|
|
|
|
// TestValidateTemplate checks every code path of the validation function.
|
|
// Save and the preview endpoint both lean on this — a drift here breaks both.
|
|
func TestValidateTemplate(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
key, subj string
|
|
body string
|
|
wantErr error
|
|
wantSubsErr string // substring required in the wrapped error
|
|
}{
|
|
{
|
|
"invitation valid",
|
|
EmailTemplateKeyInvitation,
|
|
"[Paliad] {{.InviterName}} invites you",
|
|
`{{define "content"}}<h1>{{.InviterName}}</h1>{{end}}`,
|
|
nil, "",
|
|
},
|
|
{
|
|
"invitation missing content block",
|
|
EmailTemplateKeyInvitation,
|
|
"x",
|
|
`<p>oops, no define</p>`,
|
|
ErrTemplateMissingContent, "",
|
|
},
|
|
{
|
|
"invitation bad body syntax",
|
|
EmailTemplateKeyInvitation,
|
|
"x",
|
|
`{{define "content"}}<p>{{.InviterName}{{end}}`,
|
|
nil, "syntax",
|
|
},
|
|
{
|
|
"invitation bad subject syntax",
|
|
EmailTemplateKeyInvitation,
|
|
`[Paliad] {{.InviterName`,
|
|
`{{define "content"}}<p>x</p>{{end}}`,
|
|
nil, "syntax",
|
|
},
|
|
{
|
|
"base valid",
|
|
EmailTemplateKeyBase,
|
|
"",
|
|
`<html><body>{{block "content" .}}{{end}}</body></html>`,
|
|
nil, "",
|
|
},
|
|
{
|
|
"base missing block call",
|
|
EmailTemplateKeyBase,
|
|
"",
|
|
`<html><body>no inner</body></html>`,
|
|
ErrTemplateMissingBaseBlock, "",
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
err := ValidateTemplate(tc.key, tc.subj, tc.body)
|
|
if tc.wantErr != nil {
|
|
if err != tc.wantErr {
|
|
t.Errorf("got %v, want %v", err, tc.wantErr)
|
|
}
|
|
return
|
|
}
|
|
if tc.wantSubsErr != "" {
|
|
if err == nil || !strings.Contains(err.Error(), tc.wantSubsErr) {
|
|
t.Errorf("got %v, want substring %q", err, tc.wantSubsErr)
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Errorf("expected no error, got %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestEmailTemplateVariablesShape ensures every canonical key has a non-empty
|
|
// variable contract — the editor sidebar would render an empty box otherwise.
|
|
func TestEmailTemplateVariablesShape(t *testing.T) {
|
|
for _, key := range CanonicalEmailTemplateKeys {
|
|
vars := EmailTemplateVariables(key)
|
|
if len(vars) == 0 {
|
|
t.Errorf("key %s: no variables registered", key)
|
|
}
|
|
for _, v := range vars {
|
|
if v.Name == "" || v.Type == "" || v.Description == "" {
|
|
t.Errorf("key %s: variable %+v has empty field", key, v)
|
|
}
|
|
}
|
|
}
|
|
}
|