feat: pro Mandant editierbare E-Mail-Vorlagen

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 <noreply@anthropic.com>
This commit is contained in:
noroot
2026-09-01 10:06:09 +02:00
parent b0d6b00045
commit d3b171f721
13 changed files with 749 additions and 3 deletions

View File

@@ -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) ## Row-Level-Security (2026-09-01, Migration 0021)
**Ausgangslage:** RLS-Policies wirken nie bei Postgres-Superusern, und **Ausgangslage:** RLS-Policies wirken nie bei Postgres-Superusern, und

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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)
}

View File

@@ -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)
}
}

View File

@@ -36,13 +36,16 @@ func (s *Server) handlePasswortVergessen(w http.ResponseWriter, r *http.Request)
scheme = "https" scheme = "https"
} }
link := fmt.Sprintf("%s://%s/passwort-zuruecksetzen/%s", scheme, r.Host, token) 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 // Versandfehler wird bewusst nicht an den Nutzer durchgereicht
// (keine Enumeration über unterschiedliches Verhalten bei // (keine Enumeration über unterschiedliches Verhalten bei
// Versandfehlern), aber serverseitig geloggt statt // Versandfehlern), aber serverseitig geloggt statt
// verschluckt — sonst bleibt ein dauerhaft falsch // verschluckt — sonst bleibt ein dauerhaft falsch
// konfigurierter SMTP-Server unbemerkt. // 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) log.Printf("passwort-vergessen: mail versand fehlgeschlagen: %v", err)
} }
} }

View File

@@ -52,6 +52,10 @@ type Store interface {
SetUserPassword(ctx context.Context, id, passwordHash string) error SetUserPassword(ctx context.Context, id, passwordHash string) error
UpsertLoeschfristEinstellung(ctx context.Context, accountID, datenklasseID string, maxTage int) (store.LoeschfristEinstellung, error) UpsertLoeschfristEinstellung(ctx context.Context, accountID, datenklasseID string, maxTage int) (store.LoeschfristEinstellung, error)
ListLoeschfristEinstellungenForAccount(ctx context.Context, accountID string) ([]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) CreatePasswordResetToken(ctx context.Context, userID, token string) (store.PasswordResetToken, error)
GetValidPasswordResetToken(ctx context.Context, token string) (store.PasswordResetToken, error) GetValidPasswordResetToken(ctx context.Context, token string) (store.PasswordResetToken, error)
MarkPasswordResetTokenUsed(ctx context.Context, id string) 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("POST /login", s.handleLogin)
mux.HandleFunc("GET /verwaltung/loeschfristen", s.requireAdmin(s.handleLoeschfristenListe)) mux.HandleFunc("GET /verwaltung/loeschfristen", s.requireAdmin(s.handleLoeschfristenListe))
mux.HandleFunc("POST /verwaltung/loeschfristen", s.requireAdmin(s.handleLoeschfristenSpeichern)) 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("GET /passwort-vergessen", s.handlePasswortVergessenForm)
mux.HandleFunc("POST /passwort-vergessen", s.handlePasswortVergessen) mux.HandleFunc("POST /passwort-vergessen", s.handlePasswortVergessen)
mux.HandleFunc("GET /passwort-zuruecksetzen/{token}", s.handlePasswortZuruecksetzenForm) mux.HandleFunc("GET /passwort-zuruecksetzen/{token}", s.handlePasswortZuruecksetzenForm)

View File

@@ -46,6 +46,17 @@ type fakeStore struct {
freigabeschritte map[string]store.Freigabeschritt freigabeschritte map[string]store.Freigabeschritt
passwordResetTokens map[string]store.PasswordResetToken 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 { func newFakeStore() *fakeStore {
@@ -67,6 +78,16 @@ func newFakeStore() *fakeStore {
freigabeschritte: map[string]store.Freigabeschritt{}, freigabeschritte: map[string]store.Freigabeschritt{},
passwordResetTokens: map[string]store.PasswordResetToken{}, passwordResetTokens: map[string]store.PasswordResetToken{},
loeschfristen: map[string]map[string]int{}, 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 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 { func (f *fakeStore) newID() string {
f.nextID++ f.nextID++
return fmt.Sprintf("id-%d", f.nextID) return fmt.Sprintf("id-%d", f.nextID)

View File

@@ -24,6 +24,7 @@
<span class="stat-card-label">Audit-Log (neueste)</span> <span class="stat-card-label">Audit-Log (neueste)</span>
</a> </a>
</li> </li>
<li><a class="stat-card stat-card-action" href="/betreiber/email-vorlagen"><span class="stat-card-label">E-Mail-Vorlagen &rarr;</span></a></li>
</ul> </ul>
</div> </div>
</body> </body>

View File

@@ -0,0 +1,54 @@
{{define "email-vorlagen-liste"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page page-wide">
<div class="page-header">
<div>
<h1>{{.Title}}</h1>
<p class="hinweis">
Betreff und Text der vom System versendeten E-Mails. Platzhalter
in geschweiften Klammern (z. B. <code>{{"{{link}}"}}</code>)
werden beim Versand automatisch ersetzt.
</p>
</div>
</div>
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
{{if .Gespeichert}}<p class="hinweis">Gespeichert.</p>{{end}}
{{range .Zeilen}}
<div class="form-card">
<form method="post" action="{{$.ActionBase}}">
<input type="hidden" name="typ" value="{{.Typ}}">
<fieldset class="form-section">
<legend>{{.Label}}{{if .IstUebersteuert}} <span class="hinweis">(eigene Vorlage)</span>{{else if eq $.ActionBase "/verwaltung/email-vorlagen"}} <span class="hinweis">(Plattform-Standard, nicht angepasst)</span>{{end}}</legend>
<p class="hinweis">Verfügbare Platzhalter: {{.Platzhalter}}</p>
<div class="form-grid">
<div class="form-full">
<label for="betreff-{{.Typ}}">Betreff</label>
<input type="text" id="betreff-{{.Typ}}" name="betreff" value="{{.Betreff}}" required>
</div>
<div class="form-full">
<label for="text-{{.Typ}}">Text</label>
<textarea id="text-{{.Typ}}" name="text" rows="8" required>{{.Text}}</textarea>
</div>
</div>
</fieldset>
<div class="form-actions">
<button type="submit">Speichern</button>
</div>
</form>
{{if and .IstUebersteuert (eq $.ActionBase "/verwaltung/email-vorlagen")}}
<div class="form-gefahrenzone">
<form method="post" action="/verwaltung/email-vorlagen/zuruecksetzen" onsubmit="return confirm('Eigene Vorlage löschen und zum Plattform-Standard zurückkehren?')">
<input type="hidden" name="typ" value="{{.Typ}}">
<button type="submit" class="entfernen">Auf Plattform-Standard zurücksetzen</button>
</form>
</div>
{{end}}
</div>
{{end}}
</div>
</body>
</html>
{{end}}

View File

@@ -35,6 +35,7 @@
{{if .IsAdmin}}<a href="/verwaltung/genehmiger-rollen">Genehmiger-Rollen</a>{{end}} {{if .IsAdmin}}<a href="/verwaltung/genehmiger-rollen">Genehmiger-Rollen</a>{{end}}
{{if .IsAdmin}}<a href="/verwaltung/freigabe-regeln">Freigabe-Regeln</a>{{end}} {{if .IsAdmin}}<a href="/verwaltung/freigabe-regeln">Freigabe-Regeln</a>{{end}}
{{if .IsAdmin}}<a href="/verwaltung/loeschfristen">Löschfristen</a>{{end}} {{if .IsAdmin}}<a href="/verwaltung/loeschfristen">Löschfristen</a>{{end}}
{{if .IsAdmin}}<a href="/verwaltung/email-vorlagen">E-Mail-Vorlagen</a>{{end}}
{{if .IsBetreiber}}<a href="/betreiber">Plattform</a>{{end}} {{if .IsBetreiber}}<a href="/betreiber">Plattform</a>{{end}}
<form method="post" action="/logout"> <form method="post" action="/logout">
<button type="submit">Abmelden</button> <button type="submit">Abmelden</button>