feat(PROJ-56c): Purge-Cron löscht nur explizit markierte Mails

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 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-06-24 16:40:18 +02:00
co-authored by Claude Sonnet 4.6
parent 586af2478c
commit b3ff8e6cf9
6 changed files with 390 additions and 10 deletions
+96
View File
@@ -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) {
+4
View File
@@ -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.
+148
View File
@@ -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
}
+14
View File
@@ -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
}