From d3b171f721b235fe9787a4d096a9f3f107d87161 Mon Sep 17 00:00:00 2001
From: noroot
Date: Tue, 1 Sep 2026 10:06:09 +0200
Subject: [PATCH] feat: pro Mandant editierbare E-Mail-Vorlagen
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Zweistufig wie der Werkzeugkatalog: Betreiber pflegt den plattformweiten
Standard (/betreiber/email-vorlagen), jeder Mandant kann ihn für sich
übersteuern (/verwaltung/email-vorlagen) - ResolveEmailVorlage nutzt die
eigene Vorlage falls vorhanden, sonst fällt sie auf den Plattform-
Standard zurück. Passwort-Zurücksetzen ist die einzige aktuell
existierende E-Mail und nutzt jetzt diese Vorlage statt Hardcoding.
Bug beim Live-Verifizieren gefunden: UNIQUE(account_id, typ) verhindert
bei NULLABLE account_id keine Duplikate (NULL != NULL in SQL) - jedes
Speichern des Plattform-Standards erzeugte eine neue Zeile statt sie zu
aktualisieren. Fix: zwei partielle Unique-Indizes statt eines
gemeinsamen Constraints, mit Regressionstest abgesichert.
Co-Authored-By: Claude Sonnet 5
---
CLAUDE.md | 40 ++++
internal/store/email_vorlage.go | 116 ++++++++++++
internal/store/email_vorlage_test.go | 117 ++++++++++++
.../migrations/0022_email_vorlage.down.sql | 4 +
.../migrations/0022_email_vorlage.up.sql | 55 ++++++
internal/web/email_vorlage_handlers.go | 178 ++++++++++++++++++
internal/web/email_vorlage_handlers_test.go | 105 +++++++++++
internal/web/passwort_reset_handlers.go | 7 +-
internal/web/server.go | 9 +
internal/web/server_test.go | 65 ++++++-
.../web/templates/betreiber_dashboard.html | 1 +
.../web/templates/email_vorlagen_liste.html | 54 ++++++
internal/web/templates/layout.html | 1 +
13 files changed, 749 insertions(+), 3 deletions(-)
create mode 100644 internal/store/email_vorlage.go
create mode 100644 internal/store/email_vorlage_test.go
create mode 100644 internal/store/migrations/0022_email_vorlage.down.sql
create mode 100644 internal/store/migrations/0022_email_vorlage.up.sql
create mode 100644 internal/web/email_vorlage_handlers.go
create mode 100644 internal/web/email_vorlage_handlers_test.go
create mode 100644 internal/web/templates/email_vorlagen_liste.html
diff --git a/CLAUDE.md b/CLAUDE.md
index 222d68f..76c4719 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -633,6 +633,46 @@ und live per curl gegen echten Server + Postgres verifiziert.
---
+## E-Mail-Vorlagen (2026-09-01, Migration 0022)
+
+Auf Nutzerwunsch: alle vom System versendeten E-Mails sollen editierbar
+sein — für E-Mails, die an die eigenen Mitarbeiter einer Firma gehen,
+durch den Mandanten selbst; für E-Mails, die die Plattform an
+Mandanten-Admins schickt, durch den Betreiber. Zweistufig wie der
+Werkzeugkatalog: `email_vorlage.account_id` NULL = plattformweiter
+Standard (`GET/POST /betreiber/email-vorlagen`, nur Betreiber), gesetzt
+= mandantenspezifische Übersteuerung (`GET/POST /verwaltung/email-
+vorlagen`, nur Admin). `ResolveEmailVorlage` liefert die eigene
+Übersteuerung, falls vorhanden, sonst den Plattform-Standard — ein
+Mandant, der nie etwas anpasst, bekommt automatisch jede künftige
+Änderung des Plattform-Standards. `emailVorlageTypen` in
+`internal/web/email_vorlage_handlers.go` ist die feste, bekannte Liste
+der vom System versendeten E-Mails — aktuell nur
+`passwort_zuruecksetzen` (die einzige E-Mail, die es bisher gibt),
+bewusst keine generische "beliebige E-Mail anlegen"-UI, da jeder Typ an
+eine echte Code-Stelle gebunden ist, die ihn tatsächlich versendet
+(Platzhalter wie `{{link}}` sind pro Typ verschieden und müssten sonst
+geraten werden).
+
+**Bug beim Live-Verifizieren gefunden und behoben:** `UNIQUE
+(account_id, typ)` als einzelner Tabellen-Constraint reicht bei
+NULLABLE `account_id` NICHT — SQL behandelt NULL nie als gleich zu
+NULL, ein Mandant/Betreiber hätte also bei jedem Speichern eine neue
+Plattform-Standard-Zeile statt eines Updates bekommen (genau das ist
+beim ersten Testlauf passiert: zwei "Version 1"/"Version 2"-Zeilen
+gleichzeitig, `ResolveEmailVorlage` griff die falsche). Fix: zwei
+partielle Unique-Indizes (`... WHERE account_id IS NULL` /
+`... WHERE account_id IS NOT NULL`) statt eines gemeinsamen Constraints
+— `UpsertEmailVorlage` braucht dafür zwei unterschiedliche
+`ON CONFLICT`-Ziele (SQL erlaubt nur ein Ziel je INSERT-Anweisung), mit
+dediziertem Regressionstest (`TestUpsertPlattformStandardAktualisiertStattZuDuplizieren`)
+abgesichert. Live per curl mit echtem SMTP-Test-Server erneut
+verifiziert: zweimaliges Speichern des Plattform-Standards ergibt eine
+Zeile mit dem aktuellen Stand, eine neu registrierte Firma ohne eigene
+Übersteuerung bekommt automatisch den zuletzt gesetzten Plattform-Text.
+
+---
+
## Row-Level-Security (2026-09-01, Migration 0021)
**Ausgangslage:** RLS-Policies wirken nie bei Postgres-Superusern, und
diff --git a/internal/store/email_vorlage.go b/internal/store/email_vorlage.go
new file mode 100644
index 0000000..ed3c0cd
--- /dev/null
+++ b/internal/store/email_vorlage.go
@@ -0,0 +1,116 @@
+// E-Mail-Vorlagen (Migration 0022) — zweistufig wie der Werkzeugkatalog:
+// AccountID nil = plattformweiter Standard (Betreiber), gesetzt =
+// mandantenspezifische Übersteuerung. ResolveEmailVorlage löst beides
+// auf: eigene Vorlage, falls vorhanden, sonst der Plattform-Standard.
+package store
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/jackc/pgx/v5"
+)
+
+type EmailVorlage struct {
+ ID string
+ AccountID *string
+ Typ string
+ Betreff string
+ Text string
+ UpdatedAt time.Time
+}
+
+// UpsertEmailVorlage legt eine Vorlage an oder aktualisiert sie —
+// accountID nil schreibt den plattformweiten Standard (nur für den
+// Betreiber sinnvoll, RLS erzwingt das zusätzlich auf DB-Ebene).
+//
+// Zwei unterschiedliche ON-CONFLICT-Ziele, weil SQL NULL nie als gleich
+// zu NULL behandelt: ein einzelner Unique-Index über (account_id, typ)
+// hätte beliebig viele Plattform-Standard-Zeilen (account_id IS NULL)
+// je typ zugelassen, siehe Migration 0022 und den dort dokumentierten
+// Bug (ohne diese Aufteilung erzeugte jedes Speichern des Plattform-
+// Standards eine neue Zeile statt die bestehende zu aktualisieren).
+func (s *Store) UpsertEmailVorlage(ctx context.Context, accountID *string, typ, betreff, text string) (EmailVorlage, error) {
+ var v EmailVorlage
+ var err error
+ if accountID == nil {
+ err = s.db(ctx).QueryRow(ctx, `
+ INSERT INTO email_vorlage (account_id, typ, betreff, text)
+ VALUES (NULL, $1, $2, $3)
+ ON CONFLICT (typ) WHERE account_id IS NULL
+ DO UPDATE SET betreff = $2, text = $3, updated_at = now()
+ RETURNING id, account_id, typ, betreff, text, updated_at
+ `, typ, betreff, text).Scan(&v.ID, &v.AccountID, &v.Typ, &v.Betreff, &v.Text, &v.UpdatedAt)
+ } else {
+ err = s.db(ctx).QueryRow(ctx, `
+ INSERT INTO email_vorlage (account_id, typ, betreff, text)
+ VALUES ($1, $2, $3, $4)
+ ON CONFLICT (account_id, typ) WHERE account_id IS NOT NULL
+ DO UPDATE SET betreff = $3, text = $4, updated_at = now()
+ RETURNING id, account_id, typ, betreff, text, updated_at
+ `, accountID, typ, betreff, text).Scan(&v.ID, &v.AccountID, &v.Typ, &v.Betreff, &v.Text, &v.UpdatedAt)
+ }
+ if err != nil {
+ return EmailVorlage{}, fmt.Errorf("store: upsert email vorlage: %w", err)
+ }
+ return v, nil
+}
+
+// GetEmailVorlage liest eine Vorlage exakt (keine Fallback-Auflösung) —
+// accountID nil sucht den plattformweiten Standard.
+func (s *Store) GetEmailVorlage(ctx context.Context, accountID *string, typ string) (EmailVorlage, error) {
+ var v EmailVorlage
+ err := s.db(ctx).QueryRow(ctx, `
+ SELECT id, account_id, typ, betreff, text, updated_at FROM email_vorlage
+ WHERE account_id IS NOT DISTINCT FROM $1 AND typ = $2
+ `, accountID, typ).Scan(&v.ID, &v.AccountID, &v.Typ, &v.Betreff, &v.Text, &v.UpdatedAt)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return EmailVorlage{}, ErrNotFound
+ }
+ if err != nil {
+ return EmailVorlage{}, fmt.Errorf("store: get email vorlage: %w", err)
+ }
+ return v, nil
+}
+
+// ResolveEmailVorlage liefert die für einen Mandanten tatsächlich
+// wirksame Vorlage: die eigene Übersteuerung, falls vorhanden, sonst
+// den plattformweiten Standard (account_id IS NULL). ErrNotFound nur,
+// wenn keins von beidem existiert (sollte praktisch nie vorkommen, der
+// Plattform-Standard wird per Migration angelegt).
+func (s *Store) ResolveEmailVorlage(ctx context.Context, accountID, typ string) (EmailVorlage, error) {
+ var v EmailVorlage
+ err := s.db(ctx).QueryRow(ctx, `
+ SELECT id, account_id, typ, betreff, text, updated_at FROM email_vorlage
+ WHERE typ = $2 AND (account_id = $1 OR account_id IS NULL)
+ ORDER BY account_id NULLS LAST
+ LIMIT 1
+ `, accountID, typ).Scan(&v.ID, &v.AccountID, &v.Typ, &v.Betreff, &v.Text, &v.UpdatedAt)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return EmailVorlage{}, ErrNotFound
+ }
+ if err != nil {
+ return EmailVorlage{}, fmt.Errorf("store: resolve email vorlage: %w", err)
+ }
+ return v, nil
+}
+
+// DeleteEmailVorlage entfernt eine Vorlage — bei einem Mandanten "auf
+// Plattform-Standard zurücksetzen" (ResolveEmailVorlage greift danach
+// wieder auf den plattformweiten Standard zurück), beim Betreiber
+// bewusst nicht vorgesehen (der Standard muss immer existieren, siehe
+// Migration 0022 — kein Lösch-Button auf der Betreiber-Seite).
+func (s *Store) DeleteEmailVorlage(ctx context.Context, accountID *string, typ string) error {
+ tag, err := s.db(ctx).Exec(ctx, `
+ DELETE FROM email_vorlage WHERE account_id IS NOT DISTINCT FROM $1 AND typ = $2
+ `, accountID, typ)
+ if err != nil {
+ return fmt.Errorf("store: delete email vorlage: %w", err)
+ }
+ if tag.RowsAffected() == 0 {
+ return ErrNotFound
+ }
+ return nil
+}
diff --git a/internal/store/email_vorlage_test.go b/internal/store/email_vorlage_test.go
new file mode 100644
index 0000000..2f016d0
--- /dev/null
+++ b/internal/store/email_vorlage_test.go
@@ -0,0 +1,117 @@
+package store_test
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/netcell-it/deklarix/internal/store"
+)
+
+func TestEmailVorlagePlattformStandardExistiertNachMigration(t *testing.T) {
+ s := openTestStore(t)
+ ctx := context.Background()
+ v, err := s.GetEmailVorlage(ctx, nil, "passwort_zuruecksetzen")
+ if err != nil {
+ t.Fatalf("GetEmailVorlage (Plattform): %v", err)
+ }
+ if v.AccountID != nil {
+ t.Fatalf("AccountID = %v, want nil (Plattform-Standard)", v.AccountID)
+ }
+ if v.Betreff == "" || v.Text == "" {
+ t.Fatalf("Plattform-Standard unvollständig: %+v", v)
+ }
+}
+
+func TestResolveEmailVorlageFaelltAufPlattformStandardZurueck(t *testing.T) {
+ s := openTestStore(t)
+ ctx := context.Background()
+ accID := testAccountID(t, s)
+
+ // Ohne eigene Übersteuerung liefert Resolve den Plattform-Standard.
+ resolved, err := s.ResolveEmailVorlage(ctx, accID, "passwort_zuruecksetzen")
+ if err != nil {
+ t.Fatalf("ResolveEmailVorlage (kein eigener Override): %v", err)
+ }
+ if resolved.AccountID != nil {
+ t.Fatalf("AccountID = %v, want nil (geerbt vom Plattform-Standard)", resolved.AccountID)
+ }
+
+ // Eigene Übersteuerung anlegen — Resolve muss jetzt die eigene liefern.
+ eigene, err := s.UpsertEmailVorlage(ctx, &accID, "passwort_zuruecksetzen", "Eigener Betreff", "Eigener Text {{link}}")
+ if err != nil {
+ t.Fatalf("UpsertEmailVorlage: %v", err)
+ }
+ resolved, err = s.ResolveEmailVorlage(ctx, accID, "passwort_zuruecksetzen")
+ if err != nil {
+ t.Fatalf("ResolveEmailVorlage (mit Override): %v", err)
+ }
+ if resolved.ID != eigene.ID || resolved.Betreff != "Eigener Betreff" {
+ t.Fatalf("resolved = %+v, want eigene Vorlage", resolved)
+ }
+
+ // Zurücksetzen entfernt die Übersteuerung, Resolve fällt wieder zurück.
+ if err := s.DeleteEmailVorlage(ctx, &accID, "passwort_zuruecksetzen"); err != nil {
+ t.Fatalf("DeleteEmailVorlage: %v", err)
+ }
+ resolved, err = s.ResolveEmailVorlage(ctx, accID, "passwort_zuruecksetzen")
+ if err != nil {
+ t.Fatalf("ResolveEmailVorlage (nach Reset): %v", err)
+ }
+ if resolved.AccountID != nil {
+ t.Fatalf("AccountID nach Reset = %v, want nil (wieder Plattform-Standard)", resolved.AccountID)
+ }
+}
+
+func TestUpsertPlattformStandardAktualisiertStattZuDuplizieren(t *testing.T) {
+ // Regressionstest: SQL behandelt NULL nie als gleich zu NULL, ein
+ // naiver UNIQUE(account_id, typ)-Constraint hätte beliebig viele
+ // Plattform-Standard-Zeilen je typ zugelassen — jedes erneute
+ // Speichern hätte eine neue Zeile erzeugt statt die bestehende zu
+ // aktualisieren (genau das ist beim Live-Verifizieren passiert).
+ s := openTestStore(t)
+ ctx := context.Background()
+
+ first, err := s.UpsertEmailVorlage(ctx, nil, "passwort_zuruecksetzen", "Erster Betreff", "Erster Text {{link}}")
+ if err != nil {
+ t.Fatalf("UpsertEmailVorlage (1): %v", err)
+ }
+ second, err := s.UpsertEmailVorlage(ctx, nil, "passwort_zuruecksetzen", "Zweiter Betreff", "Zweiter Text {{link}}")
+ if err != nil {
+ t.Fatalf("UpsertEmailVorlage (2): %v", err)
+ }
+ if first.ID != second.ID {
+ t.Fatalf("zweites Upsert erzeugte eine neue Zeile (ID %s statt %s) statt zu aktualisieren", second.ID, first.ID)
+ }
+
+ got, err := s.GetEmailVorlage(ctx, nil, "passwort_zuruecksetzen")
+ if err != nil {
+ t.Fatalf("GetEmailVorlage: %v", err)
+ }
+ if got.Betreff != "Zweiter Betreff" {
+ t.Fatalf("Betreff = %q, want %q (aktueller Stand)", got.Betreff, "Zweiter Betreff")
+ }
+}
+
+func TestEmailVorlageZweierMandantenIsoliert(t *testing.T) {
+ s := openTestStore(t)
+ ctx := context.Background()
+ accA := testAccountID(t, s)
+ accB := testAccountID(t, s)
+
+ if _, err := s.UpsertEmailVorlage(ctx, &accA, "passwort_zuruecksetzen", "Vorlage A", "Text A {{link}}"); err != nil {
+ t.Fatalf("UpsertEmailVorlage A: %v", err)
+ }
+
+ resolvedB, err := s.ResolveEmailVorlage(ctx, accB, "passwort_zuruecksetzen")
+ if err != nil {
+ t.Fatalf("ResolveEmailVorlage B: %v", err)
+ }
+ if resolvedB.Betreff == "Vorlage A" {
+ t.Fatal("Mandant B sieht die Vorlage von Mandant A — Isolation defekt")
+ }
+
+ if _, err := s.GetEmailVorlage(ctx, &accB, "passwort_zuruecksetzen"); !errors.Is(err, store.ErrNotFound) {
+ t.Fatalf("GetEmailVorlage B (kein eigener Override) err=%v, want ErrNotFound", err)
+ }
+}
diff --git a/internal/store/migrations/0022_email_vorlage.down.sql b/internal/store/migrations/0022_email_vorlage.down.sql
new file mode 100644
index 0000000..85a523d
--- /dev/null
+++ b/internal/store/migrations/0022_email_vorlage.down.sql
@@ -0,0 +1,4 @@
+ALTER TABLE email_vorlage NO FORCE ROW LEVEL SECURITY;
+ALTER TABLE email_vorlage DISABLE ROW LEVEL SECURITY;
+DROP POLICY IF EXISTS tenant_isolation ON email_vorlage;
+DROP TABLE email_vorlage;
diff --git a/internal/store/migrations/0022_email_vorlage.up.sql b/internal/store/migrations/0022_email_vorlage.up.sql
new file mode 100644
index 0000000..95ca7e7
--- /dev/null
+++ b/internal/store/migrations/0022_email_vorlage.up.sql
@@ -0,0 +1,55 @@
+-- Editierbare E-Mail-Vorlagen, zweistufig wie der Werkzeugkatalog:
+-- account_id NULL = plattformweiter Standard (nur vom Betreiber
+-- editierbar, z. B. künftige E-Mails, die die Plattform selbst an
+-- Mandanten-Admins schickt), account_id gesetzt = mandantenspezifische
+-- Übersteuerung (vom Mandanten-Admin editierbar, z. B. eigener Ton/
+-- Branding für eine E-Mail, die an die eigenen Mitarbeiter geht).
+-- "typ" identifiziert, welche vom System versendete E-Mail gemeint ist
+-- (aktuell nur "passwort_zuruecksetzen" — die einzige E-Mail, die das
+-- System bisher verschickt, siehe internal/mail). Text darf Platzhalter
+-- wie "{{link}}" enthalten, die beim Versand ersetzt werden (siehe
+-- internal/web/email_vorlage_handlers.go).
+CREATE TABLE email_vorlage (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ account_id UUID REFERENCES account(id),
+ typ TEXT NOT NULL,
+ betreff TEXT NOT NULL,
+ text TEXT NOT NULL,
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- ACHTUNG: ein einfaches UNIQUE (account_id, typ) würde NICHT reichen —
+-- SQL behandelt NULL nie als gleich zu NULL, ein normaler UNIQUE-
+-- Constraint hätte also beliebig viele Plattform-Standard-Zeilen
+-- (account_id IS NULL) je typ zugelassen. Zwei partielle Unique-Indizes
+-- statt eines gemeinsamen Constraints, dafür braucht UpsertEmailVorlage
+-- zwei unterschiedliche ON-CONFLICT-Ziele (siehe internal/store/email_vorlage.go).
+CREATE UNIQUE INDEX email_vorlage_plattform_uidx ON email_vorlage (typ) WHERE account_id IS NULL;
+CREATE UNIQUE INDEX email_vorlage_mandant_uidx ON email_vorlage (account_id, typ) WHERE account_id IS NOT NULL;
+
+-- Plattformweiter Standard für die einzige aktuell existierende
+-- E-Mail — ohne diese Zeile gäbe es nichts, worauf ResolveEmailVorlage
+-- zurückfallen könnte, solange ein Mandant keine eigene Vorlage hat.
+INSERT INTO email_vorlage (account_id, typ, betreff, text) VALUES (
+ NULL,
+ 'passwort_zuruecksetzen',
+ 'Deklarix — Passwort zurücksetzen',
+ 'Hallo,' || E'\n\n' ||
+ 'über diesen Link kannst du dein Deklarix-Passwort zurücksetzen (gültig 1 Stunde):' || E'\n' ||
+ '{{link}}' || E'\n\n' ||
+ 'Falls du das nicht angefordert hast, ignoriere diese E-Mail.'
+);
+
+-- Wie werkzeug (account_id NULLABLE): NULL-Zeilen sind für alle lesbar,
+-- aber nur vom Betreiber schreibbar; ein Mandant darf nur seine eigene
+-- account_id-Zeile anlegen/ändern.
+CREATE POLICY tenant_isolation ON email_vorlage FOR ALL USING (
+ account_id IS NULL
+ OR account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
+ OR current_setting('app.is_betreiber', true) = 'true'
+) WITH CHECK (
+ (account_id IS NULL AND current_setting('app.is_betreiber', true) = 'true')
+ OR account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
+);
+ALTER TABLE email_vorlage ENABLE ROW LEVEL SECURITY;
+ALTER TABLE email_vorlage FORCE ROW LEVEL SECURITY;
diff --git a/internal/web/email_vorlage_handlers.go b/internal/web/email_vorlage_handlers.go
new file mode 100644
index 0000000..de5d9b6
--- /dev/null
+++ b/internal/web/email_vorlage_handlers.go
@@ -0,0 +1,178 @@
+// E-Mail-Vorlagen (Migration 0022) — zweistufig wie der Werkzeugkatalog:
+// der Betreiber pflegt den plattformweiten Standard
+// (/betreiber/email-vorlagen), jeder Mandant kann ihn für sich
+// übersteuern (/verwaltung/email-vorlagen). emailVorlageTypen ist die
+// feste, bekannte Liste der vom System versendeten E-Mails — aktuell
+// nur Passwort-Zurücksetzen, die einzige E-Mail, die es bisher gibt.
+package web
+
+import (
+ "errors"
+ "net/http"
+ "strings"
+
+ "github.com/netcell-it/deklarix/internal/store"
+)
+
+type emailVorlageTypInfo struct {
+ Typ string
+ Label string
+ Platzhalter string
+}
+
+var emailVorlageTypen = []emailVorlageTypInfo{
+ {
+ Typ: "passwort_zuruecksetzen",
+ Label: "Passwort zurücksetzen",
+ Platzhalter: "{{link}} — der Zurücksetzen-Link, wird beim Versand eingesetzt.",
+ },
+}
+
+func istGueltigerEmailVorlageTyp(typ string) bool {
+ for _, t := range emailVorlageTypen {
+ if t.Typ == typ {
+ return true
+ }
+ }
+ return false
+}
+
+// renderEmailVorlage ersetzt die bekannten Platzhalter im Text einer
+// Vorlage — aktuell nur {{link}} (Passwort-Zurücksetzen-Link).
+func renderEmailVorlage(text, link string) string {
+ return strings.ReplaceAll(text, "{{link}}", link)
+}
+
+// ─── Mandant (Ebene 4, admin-only): eigene Übersteuerung ───────────────
+
+type emailVorlageZeile struct {
+ Typ string
+ Label string
+ Platzhalter string
+ Betreff string
+ Text string
+ IstUebersteuert bool // eigene Vorlage vorhanden statt Plattform-Standard geerbt
+}
+
+type emailVorlagenListeData struct {
+ Title string
+ Nav navData
+ Zeilen []emailVorlageZeile
+ Error string
+ Gespeichert bool
+ ActionBase string // unterscheidet Mandant- von Betreiber-Ziel-URL im gemeinsamen Template
+}
+
+func (s *Server) handleEmailVorlagenListe(w http.ResponseWriter, r *http.Request) {
+ accountID := currentUser(r).AccountID
+ data := emailVorlagenListeData{
+ Title: "E-Mail-Vorlagen", Nav: navFor(r),
+ Gespeichert: r.URL.Query().Get("gespeichert") == "1",
+ ActionBase: "/verwaltung/email-vorlagen",
+ }
+ for _, t := range emailVorlageTypen {
+ resolved, err := s.store.ResolveEmailVorlage(r.Context(), accountID, t.Typ)
+ if err != nil {
+ http.Error(w, "E-Mail-Vorlagen konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+ eigene, err := s.store.GetEmailVorlage(r.Context(), &accountID, t.Typ)
+ istUebersteuert := err == nil && eigene.ID == resolved.ID
+ data.Zeilen = append(data.Zeilen, emailVorlageZeile{
+ Typ: t.Typ, Label: t.Label, Platzhalter: t.Platzhalter,
+ Betreff: resolved.Betreff, Text: resolved.Text, IstUebersteuert: istUebersteuert,
+ })
+ }
+ if err := s.templates.ExecuteTemplate(w, "email-vorlagen-liste", data); err != nil {
+ http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
+ }
+}
+
+func (s *Server) handleEmailVorlageSpeichern(w http.ResponseWriter, r *http.Request) {
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, "ungültiges Formular", http.StatusBadRequest)
+ return
+ }
+ typ := r.FormValue("typ")
+ if !istGueltigerEmailVorlageTyp(typ) {
+ http.Error(w, "unbekannter Vorlagen-Typ", http.StatusBadRequest)
+ return
+ }
+ accountID := currentUser(r).AccountID
+ betreff := strings.TrimSpace(r.FormValue("betreff"))
+ text := strings.TrimSpace(r.FormValue("text"))
+ if betreff == "" || text == "" {
+ http.Error(w, "Betreff und Text sind Pflicht", http.StatusBadRequest)
+ return
+ }
+ if _, err := s.store.UpsertEmailVorlage(r.Context(), &accountID, typ, betreff, text); err != nil {
+ http.Error(w, "Speichern fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+ http.Redirect(w, r, "/verwaltung/email-vorlagen?gespeichert=1", http.StatusSeeOther)
+}
+
+func (s *Server) handleEmailVorlageZuruecksetzen(w http.ResponseWriter, r *http.Request) {
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, "ungültiges Formular", http.StatusBadRequest)
+ return
+ }
+ typ := r.FormValue("typ")
+ if !istGueltigerEmailVorlageTyp(typ) {
+ http.Error(w, "unbekannter Vorlagen-Typ", http.StatusBadRequest)
+ return
+ }
+ accountID := currentUser(r).AccountID
+ if err := s.store.DeleteEmailVorlage(r.Context(), &accountID, typ); err != nil && !errors.Is(err, store.ErrNotFound) {
+ http.Error(w, "Zurücksetzen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+ http.Redirect(w, r, "/verwaltung/email-vorlagen?gespeichert=1", http.StatusSeeOther)
+}
+
+// ─── Betreiber (Ebene 5): plattformweiter Standard ─────────────────────
+
+func (s *Server) handleBetreiberEmailVorlagenListe(w http.ResponseWriter, r *http.Request) {
+ data := emailVorlagenListeData{
+ Title: "E-Mail-Vorlagen (Plattform-Standard)", Nav: navFor(r),
+ Gespeichert: r.URL.Query().Get("gespeichert") == "1",
+ ActionBase: "/betreiber/email-vorlagen",
+ }
+ for _, t := range emailVorlageTypen {
+ v, err := s.store.GetEmailVorlage(r.Context(), nil, t.Typ)
+ if err != nil {
+ http.Error(w, "E-Mail-Vorlagen konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+ data.Zeilen = append(data.Zeilen, emailVorlageZeile{
+ Typ: t.Typ, Label: t.Label, Platzhalter: t.Platzhalter,
+ Betreff: v.Betreff, Text: v.Text,
+ })
+ }
+ if err := s.templates.ExecuteTemplate(w, "email-vorlagen-liste", data); err != nil {
+ http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
+ }
+}
+
+func (s *Server) handleBetreiberEmailVorlageSpeichern(w http.ResponseWriter, r *http.Request) {
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, "ungültiges Formular", http.StatusBadRequest)
+ return
+ }
+ typ := r.FormValue("typ")
+ if !istGueltigerEmailVorlageTyp(typ) {
+ http.Error(w, "unbekannter Vorlagen-Typ", http.StatusBadRequest)
+ return
+ }
+ betreff := strings.TrimSpace(r.FormValue("betreff"))
+ text := strings.TrimSpace(r.FormValue("text"))
+ if betreff == "" || text == "" {
+ http.Error(w, "Betreff und Text sind Pflicht", http.StatusBadRequest)
+ return
+ }
+ if _, err := s.store.UpsertEmailVorlage(r.Context(), nil, typ, betreff, text); err != nil {
+ http.Error(w, "Speichern fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+ http.Redirect(w, r, "/betreiber/email-vorlagen?gespeichert=1", http.StatusSeeOther)
+}
diff --git a/internal/web/email_vorlage_handlers_test.go b/internal/web/email_vorlage_handlers_test.go
new file mode 100644
index 0000000..d41b313
--- /dev/null
+++ b/internal/web/email_vorlage_handlers_test.go
@@ -0,0 +1,105 @@
+package web_test
+
+import (
+ "net/http"
+ "net/url"
+ "regexp"
+ "strings"
+ "testing"
+)
+
+func TestAdminKannEigeneEmailVorlageSpeichernUndZuruecksetzen(t *testing.T) {
+ fs := newFakeStore()
+ s := newServer(t, fs)
+ cookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
+
+ getResp := getWithCookie(t, s, cookie, "/verwaltung/email-vorlagen")
+ if getResp.Code != http.StatusOK {
+ t.Fatalf("GET status = %d, want 200", getResp.Code)
+ }
+ if !strings.Contains(getResp.Body.String(), "Plattform-Standard, nicht angepasst") {
+ t.Fatalf("erwartet Hinweis auf geerbten Plattform-Standard, body: %s", getResp.Body.String())
+ }
+
+ saveResp := postForm(t, s, cookie, "/verwaltung/email-vorlagen", url.Values{
+ "typ": {"passwort_zuruecksetzen"}, "betreff": {"Mein eigener Betreff"}, "text": {"Mein eigener Text {{link}}"},
+ })
+ if saveResp.Code != http.StatusSeeOther {
+ t.Fatalf("save status = %d, want 303, body: %s", saveResp.Code, saveResp.Body.String())
+ }
+
+ afterResp := getWithCookie(t, s, cookie, "/verwaltung/email-vorlagen")
+ body := afterResp.Body.String()
+ if !strings.Contains(body, "Mein eigener Betreff") {
+ t.Fatalf("gespeicherter Betreff fehlt, body: %s", body)
+ }
+ if !strings.Contains(body, "(eigene Vorlage)") {
+ t.Fatalf("erwartet Hinweis auf eigene Vorlage, body: %s", body)
+ }
+
+ resetResp := postForm(t, s, cookie, "/verwaltung/email-vorlagen/zuruecksetzen", url.Values{"typ": {"passwort_zuruecksetzen"}})
+ if resetResp.Code != http.StatusSeeOther {
+ t.Fatalf("reset status = %d, want 303", resetResp.Code)
+ }
+ finalResp := getWithCookie(t, s, cookie, "/verwaltung/email-vorlagen")
+ if strings.Contains(finalResp.Body.String(), "Mein eigener Betreff") {
+ t.Fatal("nach Zurücksetzen ist die eigene Vorlage immer noch aktiv")
+ }
+}
+
+func TestBetreiberKannPlattformStandardBearbeiten(t *testing.T) {
+ fs := newFakeStore()
+ s := newServer(t, fs)
+ cookie := seedAccountWithRole(t, fs, "Betreiber-Firma", "betreiber@example.com", "betreiber")
+
+ saveResp := postForm(t, s, cookie, "/betreiber/email-vorlagen", url.Values{
+ "typ": {"passwort_zuruecksetzen"}, "betreff": {"Neuer Plattform-Betreff"}, "text": {"Neuer Plattform-Text {{link}}"},
+ })
+ if saveResp.Code != http.StatusSeeOther {
+ t.Fatalf("save status = %d, want 303, body: %s", saveResp.Code, saveResp.Body.String())
+ }
+
+ getResp := getWithCookie(t, s, cookie, "/betreiber/email-vorlagen")
+ if !strings.Contains(getResp.Body.String(), "Neuer Plattform-Betreff") {
+ t.Fatalf("neuer Plattform-Standard fehlt, body: %s", getResp.Body.String())
+ }
+}
+
+func TestNichtAdminKannEmailVorlagenNichtBearbeiten(t *testing.T) {
+ fs := newFakeStore()
+ s := newServer(t, fs)
+ cookie := seedAccountWithRole(t, fs, "Test-Mandant", "mitarbeiter@example.com", "mitarbeiter")
+
+ resp := getWithCookie(t, s, cookie, "/verwaltung/email-vorlagen")
+ if resp.Code != http.StatusNotFound {
+ t.Fatalf("status = %d, want 404", resp.Code)
+ }
+}
+
+func TestPasswortVergessenNutztMandantenspezifischeVorlage(t *testing.T) {
+ fs := newFakeStore()
+ s, fm := newServerWithMailer(t, fs)
+ cookie := seedAccountWithRole(t, fs, "Vorlage-Firma", "vorlage-admin@example.com", "admin")
+
+ if resp := postForm(t, s, cookie, "/verwaltung/email-vorlagen", url.Values{
+ "typ": {"passwort_zuruecksetzen"}, "betreff": {"Firmenspezifischer Betreff"}, "text": {"Hallo! Link: {{link}} Ende."},
+ }); resp.Code != http.StatusSeeOther {
+ t.Fatalf("save status = %d, want 303", resp.Code)
+ }
+
+ if resp := postForm(t, s, nil, "/passwort-vergessen", url.Values{"email": {"vorlage-admin@example.com"}}); resp.Code != http.StatusOK {
+ t.Fatalf("passwort-vergessen status = %d, want 200", resp.Code)
+ }
+ if len(fm.Sent) != 1 {
+ t.Fatalf("got %d gesendete Mails, want 1", len(fm.Sent))
+ }
+ if fm.Sent[0].Subject != "Firmenspezifischer Betreff" {
+ t.Fatalf("Subject = %q, want die firmenspezifische Vorlage", fm.Sent[0].Subject)
+ }
+ if !strings.Contains(fm.Sent[0].Body, "Hallo! Link: ") || strings.Contains(fm.Sent[0].Body, "{{link}}") {
+ t.Fatalf("Body = %q, want Platzhalter ersetzt und Rest der eigenen Vorlage erhalten", fm.Sent[0].Body)
+ }
+ if !regexp.MustCompile(`/passwort-zuruecksetzen/[a-f0-9]+`).MatchString(fm.Sent[0].Body) {
+ t.Fatalf("kein Reset-Link im Body gefunden: %q", fm.Sent[0].Body)
+ }
+}
diff --git a/internal/web/passwort_reset_handlers.go b/internal/web/passwort_reset_handlers.go
index a55ebfb..c8aa6ae 100644
--- a/internal/web/passwort_reset_handlers.go
+++ b/internal/web/passwort_reset_handlers.go
@@ -36,13 +36,16 @@ func (s *Server) handlePasswortVergessen(w http.ResponseWriter, r *http.Request)
scheme = "https"
}
link := fmt.Sprintf("%s://%s/passwort-zuruecksetzen/%s", scheme, r.Host, token)
- body := "Hallo,\n\nüber diesen Link kannst du dein Deklarix-Passwort zurücksetzen (gültig 1 Stunde):\n" + link + "\n\nFalls du das nicht angefordert hast, ignoriere diese E-Mail."
+ betreff, text := "Deklarix — Passwort zurücksetzen", "Hallo,\n\nüber diesen Link kannst du dein Deklarix-Passwort zurücksetzen (gültig 1 Stunde):\n{{link}}\n\nFalls du das nicht angefordert hast, ignoriere diese E-Mail."
+ if vorlage, err := s.store.ResolveEmailVorlage(r.Context(), user.AccountID, "passwort_zuruecksetzen"); err == nil {
+ betreff, text = vorlage.Betreff, vorlage.Text
+ }
// Versandfehler wird bewusst nicht an den Nutzer durchgereicht
// (keine Enumeration über unterschiedliches Verhalten bei
// Versandfehlern), aber serverseitig geloggt statt
// verschluckt — sonst bleibt ein dauerhaft falsch
// konfigurierter SMTP-Server unbemerkt.
- if err := s.mailer.Send(user.Email, "Deklarix — Passwort zurücksetzen", body); err != nil {
+ if err := s.mailer.Send(user.Email, betreff, renderEmailVorlage(text, link)); err != nil {
log.Printf("passwort-vergessen: mail versand fehlgeschlagen: %v", err)
}
}
diff --git a/internal/web/server.go b/internal/web/server.go
index 2f636e4..4c91103 100644
--- a/internal/web/server.go
+++ b/internal/web/server.go
@@ -52,6 +52,10 @@ type Store interface {
SetUserPassword(ctx context.Context, id, passwordHash string) error
UpsertLoeschfristEinstellung(ctx context.Context, accountID, datenklasseID string, maxTage int) (store.LoeschfristEinstellung, error)
ListLoeschfristEinstellungenForAccount(ctx context.Context, accountID string) ([]store.LoeschfristEinstellung, error)
+ UpsertEmailVorlage(ctx context.Context, accountID *string, typ, betreff, text string) (store.EmailVorlage, error)
+ GetEmailVorlage(ctx context.Context, accountID *string, typ string) (store.EmailVorlage, error)
+ ResolveEmailVorlage(ctx context.Context, accountID, typ string) (store.EmailVorlage, error)
+ DeleteEmailVorlage(ctx context.Context, accountID *string, typ string) error
CreatePasswordResetToken(ctx context.Context, userID, token string) (store.PasswordResetToken, error)
GetValidPasswordResetToken(ctx context.Context, token string) (store.PasswordResetToken, error)
MarkPasswordResetTokenUsed(ctx context.Context, id string) error
@@ -162,6 +166,11 @@ func NewServer(st Store, regelwerk Regelwerk, mailer mail.Mailer) (*Server, erro
mux.HandleFunc("POST /login", s.handleLogin)
mux.HandleFunc("GET /verwaltung/loeschfristen", s.requireAdmin(s.handleLoeschfristenListe))
mux.HandleFunc("POST /verwaltung/loeschfristen", s.requireAdmin(s.handleLoeschfristenSpeichern))
+ mux.HandleFunc("GET /verwaltung/email-vorlagen", s.requireAdmin(s.handleEmailVorlagenListe))
+ mux.HandleFunc("POST /verwaltung/email-vorlagen", s.requireAdmin(s.handleEmailVorlageSpeichern))
+ mux.HandleFunc("POST /verwaltung/email-vorlagen/zuruecksetzen", s.requireAdmin(s.handleEmailVorlageZuruecksetzen))
+ mux.HandleFunc("GET /betreiber/email-vorlagen", s.requireBetreiber(s.handleBetreiberEmailVorlagenListe))
+ mux.HandleFunc("POST /betreiber/email-vorlagen", s.requireBetreiber(s.handleBetreiberEmailVorlageSpeichern))
mux.HandleFunc("GET /passwort-vergessen", s.handlePasswortVergessenForm)
mux.HandleFunc("POST /passwort-vergessen", s.handlePasswortVergessen)
mux.HandleFunc("GET /passwort-zuruecksetzen/{token}", s.handlePasswortZuruecksetzenForm)
diff --git a/internal/web/server_test.go b/internal/web/server_test.go
index d623375..f23f9d7 100644
--- a/internal/web/server_test.go
+++ b/internal/web/server_test.go
@@ -45,7 +45,18 @@ type fakeStore struct {
freigabeRegeln map[string]store.FreigabeRegel
freigabeschritte map[string]store.Freigabeschritt
passwordResetTokens map[string]store.PasswordResetToken
- loeschfristen map[string]map[string]int // accountID -> datenklasseID -> maxTage
+ loeschfristen map[string]map[string]int // accountID -> datenklasseID -> maxTage
+ emailVorlagen map[string]store.EmailVorlage // key: accountKeyFor(accountID)+"|"+typ
+}
+
+// accountKeyFor macht nil und "" für die Plattform-Vorlage im
+// fakeStore-Key ununterscheidbar von einer echten, aber leeren
+// account_id — genügt für Tests, da echte account_ids nie leer sind.
+func accountKeyFor(accountID *string) string {
+ if accountID == nil {
+ return ""
+ }
+ return *accountID
}
func newFakeStore() *fakeStore {
@@ -67,6 +78,16 @@ func newFakeStore() *fakeStore {
freigabeschritte: map[string]store.Freigabeschritt{},
passwordResetTokens: map[string]store.PasswordResetToken{},
loeschfristen: map[string]map[string]int{},
+ emailVorlagen: map[string]store.EmailVorlage{
+ // Platzhalter für den plattformweiten Standard, wie ihn
+ // Migration 0022 für echtes Postgres seedet — sonst würde
+ // ResolveEmailVorlage im fakeStore ErrNotFound liefern,
+ // wo die echte DB immer einen Treffer hat.
+ "|passwort_zuruecksetzen": {
+ Typ: "passwort_zuruecksetzen", Betreff: "Deklarix — Passwort zurücksetzen",
+ Text: "Hallo,\n\nüber diesen Link kannst du dein Deklarix-Passwort zurücksetzen (gültig 1 Stunde):\n{{link}}\n\nFalls du das nicht angefordert hast, ignoriere diese E-Mail.",
+ },
+ },
}
}
@@ -81,6 +102,48 @@ func (f *fakeStore) SetTenantScope(ctx context.Context, accountID string, isBetr
return nil
}
+func (f *fakeStore) UpsertEmailVorlage(ctx context.Context, accountID *string, typ, betreff, text string) (store.EmailVorlage, error) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ key := accountKeyFor(accountID) + "|" + typ
+ v := store.EmailVorlage{ID: f.newID(), AccountID: accountID, Typ: typ, Betreff: betreff, Text: text, UpdatedAt: time.Now()}
+ f.emailVorlagen[key] = v
+ return v, nil
+}
+
+func (f *fakeStore) GetEmailVorlage(ctx context.Context, accountID *string, typ string) (store.EmailVorlage, error) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ v, ok := f.emailVorlagen[accountKeyFor(accountID)+"|"+typ]
+ if !ok {
+ return store.EmailVorlage{}, store.ErrNotFound
+ }
+ return v, nil
+}
+
+func (f *fakeStore) ResolveEmailVorlage(ctx context.Context, accountID, typ string) (store.EmailVorlage, error) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ if v, ok := f.emailVorlagen[accountID+"|"+typ]; ok {
+ return v, nil
+ }
+ if v, ok := f.emailVorlagen["|"+typ]; ok {
+ return v, nil
+ }
+ return store.EmailVorlage{}, store.ErrNotFound
+}
+
+func (f *fakeStore) DeleteEmailVorlage(ctx context.Context, accountID *string, typ string) error {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ key := accountKeyFor(accountID) + "|" + typ
+ if _, ok := f.emailVorlagen[key]; !ok {
+ return store.ErrNotFound
+ }
+ delete(f.emailVorlagen, key)
+ return nil
+}
+
func (f *fakeStore) newID() string {
f.nextID++
return fmt.Sprintf("id-%d", f.nextID)
diff --git a/internal/web/templates/betreiber_dashboard.html b/internal/web/templates/betreiber_dashboard.html
index 9300828..f2a4b2b 100644
--- a/internal/web/templates/betreiber_dashboard.html
+++ b/internal/web/templates/betreiber_dashboard.html
@@ -24,6 +24,7 @@
Audit-Log (neueste)
+ E-Mail-Vorlagen →
+{{template "nav" .Nav}}
+
+
+{{if .Error}}
{{.Error}}
{{end}}
+{{if .Gespeichert}}
Gespeichert.
{{end}}
+{{range .Zeilen}}
+
+{{end}}
+
+
diff --git a/internal/web/templates/email_vorlagen_liste.html b/internal/web/templates/email_vorlagen_liste.html
new file mode 100644
index 0000000..4ad1ba3
--- /dev/null
+++ b/internal/web/templates/email_vorlagen_liste.html
@@ -0,0 +1,54 @@
+{{define "email-vorlagen-liste"}}
+
+