From b3ff8e6cf9e6a491e3ba9e6a9583db52591025ba Mon Sep 17 00:00:00 2001 From: sysops Date: Wed, 24 Jun 2026 16:40:18 +0200 Subject: [PATCH] =?UTF-8?q?feat(PROJ-56c):=20Purge-Cron=20l=C3=B6scht=20nu?= =?UTF-8?q?r=20explizit=20markierte=20Mails?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der automatische Purge-Cron darf nicht allein anhand des abgelaufenen retain_until löschen — eine Mail muss zusätzlich von einem Admin im UI zur Löschung markiert worden sein. Dafür: neue Spalten marked_for_deletion(_by/_at) auf emails, Store.ListExpiredMarkedMailIDs() (retain_until abgelaufen UND markiert) für den Cron-Pfad, und Store.SetMarkedForDeletion() zum Setzen/Löschen der Markierung. Neue Endpoints (domain_admin+, tenant-scoped): - GET /api/admin/retention/expired Metadaten abgelaufener Mails (kein Body-Zugriff, SEC-29) - PUT /api/admin/mails/{id}/mark-deletion Markierung setzen/entfernen, mit Audit-Log-Eintrag RetentionTab.tsx zeigt abgelaufene Mails mit Checkbox zum Markieren. Der bestehende manuelle "Jetzt löschen"-Button (/api/admin/purge) bleibt unverändert und löscht weiterhin alle abgelaufenen Mails auf einen Klick — nur der unbeaufsichtigte Cron-Job ist jetzt auf markierte Mails beschränkt. Co-Authored-By: Claude Sonnet 4.6 --- cmd/archivmail/cmd_purge.go | 22 +-- internal/api/retention_handlers.go | 96 +++++++++++++ internal/api/server.go | 4 + internal/storage/mark_deletion.go | 148 +++++++++++++++++++++ internal/storage/storage.go | 14 ++ src/components/admin/tabs/RetentionTab.tsx | 116 +++++++++++++++- 6 files changed, 390 insertions(+), 10 deletions(-) create mode 100644 internal/storage/mark_deletion.go diff --git a/cmd/archivmail/cmd_purge.go b/cmd/archivmail/cmd_purge.go index d497c17..4f1fce2 100644 --- a/cmd/archivmail/cmd_purge.go +++ b/cmd/archivmail/cmd_purge.go @@ -12,11 +12,15 @@ import ( "archivmail/internal/storage" ) -// runPurge deletes all mails whose retain_until has passed, removes them -// from the search index, and writes one audit log entry per deleted mail -// (GoBD-Nachvollziehbarkeit). Intended to be cron-driven (PROJ-56c), mirrors -// the manual /api/admin/purge endpoint but adds index cleanup + audit trail, -// which the plain Store.Purge() helper intentionally does not do. +// runPurge deletes mails that are BOTH past retain_until AND explicitly +// marked_for_deletion=TRUE by a user in the UI, removes them from the +// search index, and writes one audit log entry per deleted mail +// (GoBD-Nachvollziehbarkeit). Intended to be cron-driven (PROJ-56c). +// +// Deliberately NOT the same query as the manual /api/admin/purge endpoint +// (Store.Purge, deletes everything past retain_until regardless of marking): +// an unattended cron job must never delete mails on date alone — a human +// has to have explicitly flagged each one for deletion first. // // Usage: archivmail purge [-config /path/to/config.yml] [-dry-run] func runPurge(args []string) { @@ -48,13 +52,13 @@ func runPurge(args []string) { ctx := context.Background() - ids, err := mailStore.ListExpiredMailIDs(ctx) + ids, err := mailStore.ListExpiredMarkedMailIDs(ctx) if err != nil { - logger.Error("purge: list expired failed", "err", err) + logger.Error("purge: list expired+marked failed", "err", err) os.Exit(1) } if len(ids) == 0 { - logger.Info("purge: nothing to do, no expired mails") + logger.Info("purge: nothing to do, no expired+marked mails") return } @@ -116,7 +120,7 @@ func runPurge(args []string) { TenantID: tenantID, MailID: id, Success: true, - Detail: "automatischer Purge nach Ablauf der Aufbewahrungsfrist (retain_until)", + Detail: "Cron-Purge: Aufbewahrungsfrist abgelaufen UND vom Nutzer zur Löschung markiert", }) } diff --git a/internal/api/retention_handlers.go b/internal/api/retention_handlers.go index 39c5af4..be05a3b 100644 --- a/internal/api/retention_handlers.go +++ b/internal/api/retention_handlers.go @@ -5,10 +5,106 @@ import ( "fmt" "net/http" "strconv" + "time" "archivmail/internal/audit" ) +// handleListExpiredMails returns metadata (no body content, SEC-29) for all +// mails whose retain_until has passed, so an admin can review and mark +// individual mails for the cron-driven purge (PROJ-56c). +// GET /api/admin/retention/expired — domain_admin+, tenant-scoped. +func (s *Server) handleListExpiredMails(w http.ResponseWriter, r *http.Request) { + sess := sessionFromCtx(r.Context()) + + mails, err := s.store.ListExpiredMails(r.Context(), sess.TenantID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + type item struct { + ID string `json:"id"` + From string `json:"from"` + Subject string `json:"subject"` + ReceivedAt string `json:"received_at"` + RetainUntil string `json:"retain_until"` + Marked bool `json:"marked"` + MarkedBy string `json:"marked_by,omitempty"` + } + out := make([]item, len(mails)) + for i, m := range mails { + out[i] = item{ + ID: m.ID, + From: m.From, + Subject: m.Subject, + ReceivedAt: m.ReceivedAt.UTC().Format(time.RFC3339), + RetainUntil: m.RetainUntil.UTC().Format(time.RFC3339), + Marked: m.Marked, + MarkedBy: m.MarkedBy, + } + } + writeJSON(w, http.StatusOK, map[string]interface{}{"mails": out}) +} + +// handleSetMarkedForDeletion sets or clears marked_for_deletion for one mail +// (PROJ-56c). This is the only way a mail becomes eligible for the +// cron-driven purge — Store.ListExpiredMarkedMailIDs requires retain_until +// to have passed AND this flag to be set; an expired retention date alone +// is never enough to delete a mail unattended. +// PUT /api/admin/mails/{id}/mark-deletion — domain_admin+ only. +func (s *Server) handleSetMarkedForDeletion(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if id == "" { + writeError(w, http.StatusBadRequest, "missing mail id") + return + } + + sess := sessionFromCtx(r.Context()) + mailTenant, err := s.store.GetTenantForMail(r.Context(), id) + if err != nil { + writeError(w, http.StatusNotFound, "mail not found") + return + } + if !tenantAccessAllowed(sess, mailTenant) { + writeError(w, http.StatusForbidden, "access denied") + return + } + + var body struct { + Marked bool `json:"marked"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid body") + return + } + + if err := s.store.SetMarkedForDeletion(r.Context(), id, body.Marked, sess.Username); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + if s.audlog != nil { + eventType := "mail_marked_for_deletion" + detail := fmt.Sprintf("mail_id=%s zur Löschung markiert", id) + if !body.Marked { + eventType = "mail_unmarked_for_deletion" + detail = fmt.Sprintf("mail_id=%s Löschmarkierung entfernt", id) + } + s.audlog.Log(audit.Entry{ + EventType: eventType, + Username: sess.Username, + TenantID: sess.TenantID, + IPAddress: s.remoteIP(r), + MailID: id, + Success: true, + Detail: detail, + }) + } + + writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "marked": body.Marked}) +} + // handlePurge deletes all mails whose retention period has expired. // POST /api/admin/purge — superadmin only (PROJ-34). func (s *Server) handlePurge(w http.ResponseWriter, r *http.Request) { diff --git a/internal/api/server.go b/internal/api/server.go index 98a83fe..345b605 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -235,6 +235,10 @@ func (s *Server) routes() { s.mux.HandleFunc("POST /api/admin/archiving-rules", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleCreateArchivingRule))) s.mux.HandleFunc("PUT /api/admin/archiving-rules/{id}", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleUpdateArchivingRule))) s.mux.HandleFunc("DELETE /api/admin/archiving-rules/{id}", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleDeleteArchivingRule))) + // PROJ-56c: pro-Mail Löschmarkierung — domain_admin+, tenant-scoped (kein + // Mail-Lesezugriff nötig, daher requireRole statt requireMailAccess). + s.mux.HandleFunc("GET /api/admin/retention/expired", s.authAdmin(s.handleListExpiredMails)) + s.mux.HandleFunc("PUT /api/admin/mails/{id}/mark-deletion", s.authAdmin(s.handleSetMarkedForDeletion)) // PROJ-50: DSGVO Löschersuchen — admin (manage) / auditor (read-only), // role enforced inside the handlers. diff --git a/internal/storage/mark_deletion.go b/internal/storage/mark_deletion.go new file mode 100644 index 0000000..9ac0754 --- /dev/null +++ b/internal/storage/mark_deletion.go @@ -0,0 +1,148 @@ +package storage + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" +) + +// MarkedForDeletionInfo reports the deletion-mark state of one mail. +type MarkedForDeletionInfo struct { + Marked bool + By string + At *time.Time +} + +// SetMarkedForDeletion sets or clears the marked_for_deletion flag for a +// single mail (PROJ-56c). This is a deliberate, per-mail user action — the +// cron-driven purge only ever deletes mails that are BOTH past retain_until +// AND marked here; an expired retention date alone is never sufficient. +func (s *Store) SetMarkedForDeletion(ctx context.Context, id string, marked bool, username string) error { + if s.db == nil { + return nil + } + if id == "" { + return errors.New("storage: SetMarkedForDeletion: empty id") + } + if marked { + _, err := s.db.Exec(ctx, + `UPDATE emails SET marked_for_deletion = TRUE, marked_for_deletion_by = $1, marked_for_deletion_at = NOW() WHERE id = $2`, + username, id) + if err != nil { + return fmt.Errorf("storage: set marked for deletion: %w", err) + } + return nil + } + _, err := s.db.Exec(ctx, + `UPDATE emails SET marked_for_deletion = FALSE, marked_for_deletion_by = NULL, marked_for_deletion_at = NULL WHERE id = $1`, + id) + if err != nil { + return fmt.Errorf("storage: clear marked for deletion: %w", err) + } + return nil +} + +// GetMarkedForDeletion returns the current deletion-mark state for one mail. +func (s *Store) GetMarkedForDeletion(ctx context.Context, id string) (MarkedForDeletionInfo, error) { + if s.db == nil { + return MarkedForDeletionInfo{}, nil + } + var info MarkedForDeletionInfo + var by *string + row := s.db.QueryRow(ctx, + `SELECT COALESCE(marked_for_deletion, FALSE), marked_for_deletion_by, marked_for_deletion_at + FROM emails WHERE id = $1`, id) + if err := row.Scan(&info.Marked, &by, &info.At); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return MarkedForDeletionInfo{}, nil + } + return MarkedForDeletionInfo{}, fmt.Errorf("storage: get marked for deletion: %w", err) + } + if by != nil { + info.By = *by + } + return info, nil +} + +// ExpiredMailMeta is metadata-only info about a mail past retain_until, +// for the admin "mark for deletion" review list. Deliberately omits body +// content — domain_admin/superadmin may manage retention without having +// mail-content read access (SEC-29 separation of duties). +type ExpiredMailMeta struct { + ID string + From string + Subject string + ReceivedAt time.Time + RetainUntil time.Time + Marked bool + MarkedBy string +} + +// ListExpiredMails returns metadata for all mails whose retain_until has +// passed, regardless of marked_for_deletion — this is what the admin UI +// shows so a human can review and mark individual mails (PROJ-56c). +// If tenantID is non-nil, results are restricted to that tenant. +func (s *Store) ListExpiredMails(ctx context.Context, tenantID *int64) ([]ExpiredMailMeta, error) { + if s.db == nil { + return nil, nil + } + query := `SELECT id, COALESCE(mail_from, ''), COALESCE(subject, ''), received_at, + retain_until, COALESCE(marked_for_deletion, FALSE), COALESCE(marked_for_deletion_by, '') + FROM emails + WHERE retain_until IS NOT NULL AND retain_until < NOW()` + args := []interface{}{} + if tenantID != nil { + args = append(args, *tenantID) + query += fmt.Sprintf(" AND tenant_id = $%d", len(args)) + } + query += " ORDER BY retain_until ASC LIMIT 500" + + rows, err := s.db.Query(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("storage: list expired mails: %w", err) + } + defer rows.Close() + + var out []ExpiredMailMeta + for rows.Next() { + var m ExpiredMailMeta + if err := rows.Scan(&m.ID, &m.From, &m.Subject, &m.ReceivedAt, &m.RetainUntil, &m.Marked, &m.MarkedBy); err == nil { + out = append(out, m) + } + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("storage: list expired mails rows: %w", err) + } + return out, nil +} + +// ListExpiredMarkedMailIDs returns IDs of mails that are BOTH past +// retain_until AND explicitly marked_for_deletion=TRUE (PROJ-56c). This is +// the query the cron-driven purge uses — unlike ListExpiredMailIDs (used by +// the manual admin "Jetzt löschen" button), it never deletes anything based +// on the date alone. +func (s *Store) ListExpiredMarkedMailIDs(ctx context.Context) ([]string, error) { + if s.db == nil { + return nil, nil + } + rows, err := s.db.Query(ctx, + `SELECT id FROM emails WHERE retain_until IS NOT NULL AND retain_until < NOW() AND marked_for_deletion = TRUE`) + if err != nil { + return nil, fmt.Errorf("storage: list expired marked query: %w", err) + } + defer rows.Close() + var ids []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err == nil { + ids = append(ids, id) + } + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("storage: list expired marked rows: %w", err) + } + return ids, nil +} diff --git a/internal/storage/storage.go b/internal/storage/storage.go index ebff584..cb1fa11 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -345,6 +345,20 @@ func (s *Store) initSchema(ctx context.Context) error { _, err = s.db.Exec(ctx, ` ALTER TABLE emails ADD COLUMN IF NOT EXISTS ocr_chars BIGINT DEFAULT 0; `) + if err != nil { + return err + } + + // PROJ-56c: explizite "zur Löschung markiert"-Markierung. Der + // Purge-Cron löscht NUR Mails, die sowohl retain_until überschritten + // haben ALS AUCH hier markiert wurden — eine reine Datumsabfrage allein + // reicht nicht, das Löschen muss von einem Nutzer im UI ausgelöst werden. + _, err = s.db.Exec(ctx, ` + ALTER TABLE emails ADD COLUMN IF NOT EXISTS marked_for_deletion BOOLEAN DEFAULT FALSE; + ALTER TABLE emails ADD COLUMN IF NOT EXISTS marked_for_deletion_by TEXT; + ALTER TABLE emails ADD COLUMN IF NOT EXISTS marked_for_deletion_at TIMESTAMPTZ; + CREATE INDEX IF NOT EXISTS idx_emails_marked_for_deletion ON emails (marked_for_deletion) WHERE marked_for_deletion = TRUE; + `) return err } diff --git a/src/components/admin/tabs/RetentionTab.tsx b/src/components/admin/tabs/RetentionTab.tsx index 7d6974d..5191b79 100644 --- a/src/components/admin/tabs/RetentionTab.tsx +++ b/src/components/admin/tabs/RetentionTab.tsx @@ -5,6 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; +import { Checkbox } from "@/components/ui/checkbox"; import { Table, TableBody, @@ -22,6 +23,43 @@ import { DialogTitle, } from "@/components/ui/dialog"; +interface ExpiredMail { + id: string; + from: string; + subject: string; + received_at: string; + retain_until: string; + marked: boolean; + marked_by?: string; +} + +async function fetchExpiredMails(): Promise { + const res = await fetch("/api/admin/retention/expired", { credentials: "include" }); + if (!res.ok) throw new Error("Fehler beim Laden"); + const data = await res.json(); + return data.mails ?? []; +} + +async function setMarkedForDeletion(id: string, marked: boolean): Promise { + const res = await fetch(`/api/admin/mails/${id}/mark-deletion`, { + method: "PUT", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ marked }), + }); + if (!res.ok) throw new Error("Markierung fehlgeschlagen"); +} + +function formatDateTime(iso: string): string { + try { + return new Date(iso).toLocaleString("de-DE", { + day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit", + }); + } catch { + return iso; + } +} + interface TenantRetention { id: number; name: string; @@ -85,6 +123,11 @@ export function RetentionTab() { const [purging, setPurging] = useState(false); const [purgeResult, setPurgeResult] = useState(null); + // PROJ-56c: expired-mails review + mark-for-deletion state + const [expired, setExpired] = useState([]); + const [expiredLoading, setExpiredLoading] = useState(true); + const [markingID, setMarkingID] = useState(null); + const load = useCallback(() => { setLoading(true); fetchRetention() @@ -93,7 +136,29 @@ export function RetentionTab() { .finally(() => setLoading(false)); }, []); - useEffect(() => { load(); }, [load]); + const loadExpired = useCallback(() => { + setExpiredLoading(true); + fetchExpiredMails() + .then(setExpired) + .catch(() => setError("Abgelaufene Mails konnten nicht geladen werden")) + .finally(() => setExpiredLoading(false)); + }, []); + + useEffect(() => { load(); loadExpired(); }, [load, loadExpired]); + + const handleToggleMark = async (mail: ExpiredMail) => { + setMarkingID(mail.id); + try { + await setMarkedForDeletion(mail.id, !mail.marked); + setExpired((prev) => + prev.map((m) => (m.id === mail.id ? { ...m, marked: !mail.marked } : m)) + ); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : "Markierung fehlgeschlagen"); + } finally { + setMarkingID(null); + } + }; const handleEditOpen = (t: TenantRetention) => { setEditTenant(t); @@ -163,6 +228,55 @@ export function RetentionTab() { + {/* PROJ-56c: expired mails — review + mark for deletion */} + + + Abgelaufene Mails — zur Löschung markieren + + +

+ Diese Mails haben ihre Aufbewahrungsfrist überschritten. Der automatische + nächtliche Purge-Job löscht ausschließlich Mails, + die hier explizit markiert wurden — ein abgelaufenes Datum allein reicht nicht. + Der Button "Jetzt löschen" oben löscht dagegen sofort alle abgelaufenen Mails, unabhängig von der Markierung. +

+ {expiredLoading ? ( +

Lädt...

+ ) : expired.length === 0 ? ( +

Keine abgelaufenen Mails.

+ ) : ( + + + + + Von + Betreff + Abgelaufen seit + Markiert von + + + + {expired.map((m) => ( + + + handleToggleMark(m)} + /> + + {m.from || "–"} + {m.subject || "(kein Betreff)"} + {formatDateTime(m.retain_until)} + {m.marked ? (m.marked_by || "–") : "–"} + + ))} + +
+ )} +
+
+ {/* Per-tenant table */}