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) +
+ Betreff und Text der vom System versendeten E-Mails. Platzhalter
+ in geschweiften Klammern (z. B. {{"{{link}}"}})
+ werden beim Versand automatisch ersetzt.
+
{{.Error}}
{{end}} +{{if .Gespeichert}}Gespeichert.
{{end}} +{{range .Zeilen}} +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"}} + +
{{template "head" .}} +
+