From 507dee6431211e2d16805ece9d4e77662b0d0169 Mon Sep 17 00:00:00 2001 From: sysops Date: Sat, 13 Jun 2026 20:48:16 +0200 Subject: [PATCH] feat(PROJ-51): Aufbewahrungsfristen nach Dokumentenart (Retention-Kategorien) Fuehrt archiving_rules ein (PROJ-43-Basis: Tabelle + CRUD-API + Admin-UI) und erweitert die Retention-Logik (PROJ-34) um Regel-basierte Fristen, eine globale Mindestfrist (min_retention_days) sowie Nachvollziehbarkeit der Frist-Quelle (retain_until_source) in API und Mail-Detailansicht. --- cmd/archivmail/cmd_status.go | 36 ++ cmd/archivmail/main.go | 11 +- config/config.docker.yml.example | 5 + config/config.go | 5 +- features/INDEX.md | 2 +- internal/api/archiving_rules_handlers.go | 137 ++++++ internal/api/search_handlers.go | 14 + internal/api/server.go | 5 + internal/storage/retention_rules.go | 379 +++++++++++++++ internal/storage/storage.go | 27 +- src/app/admin/page.tsx | 7 + src/app/mail/[id]/page.tsx | 31 ++ .../admin/tabs/ArchivingRulesTab.tsx | 459 ++++++++++++++++++ src/lib/api/archiving_rules.ts | 61 +++ src/lib/api/index.ts | 13 + src/lib/api/mail.ts | 4 + 16 files changed, 1175 insertions(+), 21 deletions(-) create mode 100644 internal/api/archiving_rules_handlers.go create mode 100644 internal/storage/retention_rules.go create mode 100644 src/components/admin/tabs/ArchivingRulesTab.tsx create mode 100644 src/lib/api/archiving_rules.ts diff --git a/cmd/archivmail/cmd_status.go b/cmd/archivmail/cmd_status.go index 2f043a9..3b88196 100644 --- a/cmd/archivmail/cmd_status.go +++ b/cmd/archivmail/cmd_status.go @@ -47,6 +47,7 @@ func runStatus(args []string) { checkStorage(cfg), checkEncryption(cfg), checkAuditLog(cfg), + checkRetention(cfg), } allOK := true @@ -227,6 +228,41 @@ func checkAuditLog(cfg *config.Config) checkResult { return checkResult{Name: "Audit-Log", OK: true, Detail: detail} } +// checkRetention warns (PROJ-51) when effectively no deletion lock is active: +// global retention_days = 0 AND min_retention_days = 0 AND no tenant with +// retention_days > 0 AND no archiving rule carrying a retention_days value. +// Like the encryption check it never reports a hard error (OK stays true), it +// only surfaces the GoBD-relevant state in the detail text. +func checkRetention(cfg *config.Config) checkResult { + global := cfg.Storage.RetentionDays + minRet := cfg.Storage.MinRetentionDays + if global > 0 || minRet > 0 { + return checkResult{Name: "Retention", OK: true, + Detail: fmt.Sprintf("Löschsperre aktiv (global=%d Tage, min=%d Tage)", global, minRet)} + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + pool, err := pgxpool.New(ctx, cfg.Database.DSN()) + if err != nil { + return checkResult{Name: "Retention", OK: true, + Detail: "DB nicht erreichbar — Tenant-/Regel-Aufbewahrung nicht prüfbar"} + } + defer pool.Close() + + var tenantLocks, ruleLocks int + _ = pool.QueryRow(ctx, `SELECT COUNT(*) FROM tenants WHERE retention_days > 0`).Scan(&tenantLocks) + _ = pool.QueryRow(ctx, `SELECT COUNT(*) FROM archiving_rules WHERE retention_days IS NOT NULL`).Scan(&ruleLocks) + + if tenantLocks > 0 || ruleLocks > 0 { + return checkResult{Name: "Retention", OK: true, + Detail: fmt.Sprintf("Löschsperre aktiv (%d Mandant(en), %d Regel(n) mit Aufbewahrung)", tenantLocks, ruleLocks)} + } + + return checkResult{Name: "Retention", OK: true, + Detail: "WARNUNG — keine Löschsperre aktiv: retention_days=0, min_retention_days=0, keine Mandanten-/Regel-Aufbewahrung (GoBD-relevant)"} +} + func formatBytes(b uint64) string { const unit = 1024 if b < unit { diff --git a/cmd/archivmail/main.go b/cmd/archivmail/main.go index a1c92a2..d657341 100644 --- a/cmd/archivmail/main.go +++ b/cmd/archivmail/main.go @@ -124,8 +124,9 @@ func main() { Dir: cfg.Storage.StorePath, Keyfile: cfg.Storage.Keyfile, DSN: cfg.Database.DSN(), - RetentionDays: cfg.Storage.RetentionDays, - CompressEnabled: cfg.Storage.Compress, + RetentionDays: cfg.Storage.RetentionDays, + MinRetentionDays: cfg.Storage.MinRetentionDays, + CompressEnabled: cfg.Storage.Compress, } mailStore, err := storage.New(storeCfg) if err != nil { @@ -556,6 +557,12 @@ func runBackfill(ctx context.Context, store *storage.Store, idx index.Indexer, w logger.Warn("backfill: save meta failed", "id", id, "err", err) } + // PROJ-51: (re-)apply retention for mails without a retain_until yet. + // Only sets a value when retain_until IS NULL — never shortens an + // existing lock (no retroactive shortening per spec). + tenantID, _ := store.GetTenantForMail(ctx, id) + store.ApplyRetentionBackfill(ctx, id, pm, tenantID) + // Check if already indexed alreadyIndexed, err := store.IsIndexed(ctx, id) if err != nil { diff --git a/config/config.docker.yml.example b/config/config.docker.yml.example index 25cc187..de4d7e5 100644 --- a/config/config.docker.yml.example +++ b/config/config.docker.yml.example @@ -19,6 +19,11 @@ storage: astore_path: /var/archivmail/astore xapian_path: /var/archivmail/xapian keyfile: /etc/archivmail/keyfile + # PROJ-34: GoBD-Löschsperre in Tagen (0 = keine; z.B. 3650 für 10 Jahre) + retention_days: 0 + # PROJ-51: globale Mindest-Aufbewahrungsfrist; Regeln/Mandanten dürfen nur + # verlängern, nie verkürzen (0 = keine Mindestfrist) + min_retention_days: 0 index: path: /var/archivmail/xapian diff --git a/config/config.go b/config/config.go index b0f00a9..f33417b 100644 --- a/config/config.go +++ b/config/config.go @@ -72,8 +72,9 @@ type StorageConfig struct { StorePath string `yaml:"store_path"` AStorePath string `yaml:"astore_path"` Keyfile string `yaml:"keyfile"` - RetentionDays int `yaml:"retention_days"` // 0 = kein Lock (GoBD-Compliance: z.B. 3650 für 10 Jahre) - Compress bool `yaml:"compress"` // gzip-Kompression vor AES-256-GCM (spart ~40-60% Disk) + RetentionDays int `yaml:"retention_days"` // 0 = kein Lock (GoBD-Compliance: z.B. 3650 für 10 Jahre) + MinRetentionDays int `yaml:"min_retention_days"` // PROJ-51: globale Mindestfrist; Regeln/Tenants dürfen nur verlängern (0 = keine) + Compress bool `yaml:"compress"` // gzip-Kompression vor AES-256-GCM (spart ~40-60% Disk) } // DatabaseConfig holds PostgreSQL connection settings. diff --git a/features/INDEX.md b/features/INDEX.md index 0aa0608..a7b6b4e 100644 --- a/features/INDEX.md +++ b/features/INDEX.md @@ -67,7 +67,7 @@ | PROJ-48 | Audit-Log Unveränderbarkeit (Nachbesserung PROJ-11) | Deployed | [PROJ-48](PROJ-48-audit-log-unveraenderbarkeit.md) | 2026-06-13 | | PROJ-49 | Verschlüsselungspflicht at-rest (Healthcheck & Warnung) | Deployed | [PROJ-49](PROJ-49-verschluesselungspflicht.md) | 2026-06-13 | | PROJ-50 | DSGVO-Löschersuchen für Mail-Inhalte (GoBD-Vorrang) | Planned | [PROJ-50](PROJ-50-dsgvo-loeschersuchen.md) | 2026-06-13 | -| PROJ-51 | Aufbewahrungsfristen nach Dokumentenart (Retention-Kategorien) | In Progress | [PROJ-51](PROJ-51-retention-kategorien.md) | 2026-06-13 | +| PROJ-51 | Aufbewahrungsfristen nach Dokumentenart (Retention-Kategorien) | In Review | [PROJ-51](PROJ-51-retention-kategorien.md) | 2026-06-13 | | PROJ-52 | Vollständigkeits-Reconciliation (Zähl-Report) | Planned | [PROJ-52](PROJ-52-vollstaendigkeits-reconciliation.md) | 2026-06-13 | diff --git a/internal/api/archiving_rules_handlers.go b/internal/api/archiving_rules_handlers.go new file mode 100644 index 0000000..f3ee7b5 --- /dev/null +++ b/internal/api/archiving_rules_handlers.go @@ -0,0 +1,137 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + + "archivmail/internal/audit" + "archivmail/internal/storage" +) + +// PROJ-51: Archiving rules CRUD (minimal PROJ-43). Rules optionally carry a +// retention_days value that determines a matching mail's retention period. +// Superadmin only — global, cross-tenant configuration. + +type archivingRuleBody struct { + TenantID *int64 `json:"tenant_id"` + CondType string `json:"condition_type"` + Pattern string `json:"pattern"` + Priority int `json:"priority"` + RetentionDays *int `json:"retention_days"` +} + +// handleListArchivingRules returns all archiving rules. +// GET /api/admin/archiving-rules — superadmin only. +func (s *Server) handleListArchivingRules(w http.ResponseWriter, r *http.Request) { + rules, err := s.store.ListArchivingRules(r.Context(), nil) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + if rules == nil { + rules = []storage.ArchivingRule{} + } + writeJSON(w, http.StatusOK, map[string]interface{}{ + "rules": rules, + "min_retention_days": s.store.MinRetentionDays(), + }) +} + +// handleCreateArchivingRule creates a new archiving rule. +// POST /api/admin/archiving-rules — superadmin only. +func (s *Server) handleCreateArchivingRule(w http.ResponseWriter, r *http.Request) { + var body archivingRuleBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid body") + return + } + id, err := s.store.CreateArchivingRule(r.Context(), storage.ArchivingRule{ + TenantID: body.TenantID, + CondType: body.CondType, + Pattern: body.Pattern, + Priority: body.Priority, + RetentionDays: body.RetentionDays, + }) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + s.auditRule(r, "archiving_rule_created", fmt.Sprintf("id=%d type=%s pattern=%s retention_days=%s", + id, body.CondType, body.Pattern, retDaysStr(body.RetentionDays))) + + writeJSON(w, http.StatusCreated, map[string]interface{}{"id": id}) +} + +// handleUpdateArchivingRule updates an existing archiving rule. +// PUT /api/admin/archiving-rules/{id} — superadmin only. +func (s *Server) handleUpdateArchivingRule(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid rule id") + return + } + var body archivingRuleBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid body") + return + } + if err := s.store.UpdateArchivingRule(r.Context(), storage.ArchivingRule{ + ID: id, + TenantID: body.TenantID, + CondType: body.CondType, + Pattern: body.Pattern, + Priority: body.Priority, + RetentionDays: body.RetentionDays, + }); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + s.auditRule(r, "archiving_rule_updated", fmt.Sprintf("id=%d type=%s pattern=%s retention_days=%s", + id, body.CondType, body.Pattern, retDaysStr(body.RetentionDays))) + + writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true}) +} + +// handleDeleteArchivingRule deletes an archiving rule. Existing retain_until +// values on already-archived mails are left unchanged (PROJ-51). +// DELETE /api/admin/archiving-rules/{id} — superadmin only. +func (s *Server) handleDeleteArchivingRule(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid rule id") + return + } + if err := s.store.DeleteArchivingRule(r.Context(), id); err != nil { + writeError(w, http.StatusNotFound, err.Error()) + return + } + + s.auditRule(r, "archiving_rule_deleted", fmt.Sprintf("id=%d", id)) + + writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true}) +} + +func (s *Server) auditRule(r *http.Request, event, detail string) { + if s.audlog == nil { + return + } + sess := sessionFromCtx(r.Context()) + s.audlog.Log(audit.Entry{ + EventType: event, + Username: sess.Username, + IPAddress: s.remoteIP(r), + Success: true, + Detail: detail, + }) +} + +func retDaysStr(d *int) string { + if d == nil { + return "none" + } + return strconv.Itoa(*d) +} diff --git a/internal/api/search_handlers.go b/internal/api/search_handlers.go index 31cb4a0..6b6a1d5 100644 --- a/internal/api/search_handlers.go +++ b/internal/api/search_handlers.go @@ -325,6 +325,18 @@ func (s *Server) handleGetMail(w http.ResponseWriter, r *http.Request) { ocrStatus = "pending" } + // PROJ-51: retention lock + its source for auditor traceability. + var retainUntil interface{} = nil + var retainSource interface{} = nil + if until, source, rerr := s.store.GetRetentionInfo(r.Context(), id); rerr == nil { + if until != nil { + retainUntil = until.UTC().Format(time.RFC3339) + } + if source != "" { + retainSource = source + } + } + writeJSON(w, http.StatusOK, map[string]interface{}{ "id": id, "from": pm.From, @@ -342,6 +354,8 @@ func (s *Server) handleGetMail(w http.ResponseWriter, r *http.Request) { "thread_id": threadID, "ocr_status": ocrStatus, "ocr_chars": ocrChars, + "retain_until": retainUntil, + "retain_until_source": retainSource, }) } diff --git a/internal/api/server.go b/internal/api/server.go index 56c0723..fdbff81 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -230,6 +230,11 @@ func (s *Server) routes() { s.mux.HandleFunc("POST /api/admin/purge", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handlePurge))) s.mux.HandleFunc("GET /api/admin/retention", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleGetRetention))) s.mux.HandleFunc("PUT /api/admin/tenant/{id}/retention", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleSetTenantRetention))) + // PROJ-51: Archiving rules (retention categories) CRUD — superadmin only. + s.mux.HandleFunc("GET /api/admin/archiving-rules", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleListArchivingRules))) + 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))) // SMTP-Out Relay Konfiguration — superadmin only s.mux.HandleFunc("GET /api/admin/smtp-out", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleGetSMTPOut))) diff --git a/internal/storage/retention_rules.go b/internal/storage/retention_rules.go new file mode 100644 index 0000000..89bbf4e --- /dev/null +++ b/internal/storage/retention_rules.go @@ -0,0 +1,379 @@ +package storage + +import ( + "context" + "fmt" + "strings" + "time" + + "archivmail/pkg/mailparser" +) + +// Retention source markers stored in emails.retain_until_source for auditor +// traceability (PROJ-51). The "rule:" form is built dynamically. +const ( + RetainSourceTenantDefault = "tenant_default" + RetainSourceGlobalDefault = "global_default" + RetainSourceMinRetention = "min_retention" +) + +// Archiving rule condition types (PROJ-51 / minimal PROJ-43). +const ( + RuleCondSenderDomain = "sender_domain" + RuleCondRecipientDomain = "recipient_domain" + RuleCondSenderAddress = "sender_address" + RuleCondRecipientAddr = "recipient_address" +) + +// ArchivingRule is a minimal PROJ-43 rule extended with a PROJ-51 retention +// period. A rule with RetentionDays != nil determines the retention period of +// every mail it matches (highest priority wins, longest on a tie). +type ArchivingRule struct { + ID int64 `json:"id"` + TenantID *int64 `json:"tenant_id"` // nil = applies to all tenants + CondType string `json:"condition_type"` // RuleCond* + Pattern string `json:"pattern"` // e.g. "firma.de" or "rechnung@firma.de" + Priority int `json:"priority"` // higher = evaluated first + RetentionDays *int `json:"retention_days"` // nil = no rule-specific retention + CreatedAt time.Time `json:"created_at"` +} + +// minRetentionDays is the global floor (config storage.min_retention_days). +// Rules and tenant settings may only extend (never shorten) the period below +// this value (PROJ-51). +func (s *Store) initRetentionRulesSchema(ctx context.Context) { + if s.db == nil { + return + } + _, _ = s.db.Exec(ctx, ` + CREATE TABLE IF NOT EXISTS archiving_rules ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT REFERENCES tenants(id) ON DELETE CASCADE, + condition_type TEXT NOT NULL, + pattern TEXT NOT NULL, + priority INT NOT NULL DEFAULT 0, + retention_days INT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + )`) + _, _ = s.db.Exec(ctx, `CREATE INDEX IF NOT EXISTS idx_archiving_rules_priority ON archiving_rules (priority DESC)`) + // PROJ-51: traceability for which source set retain_until. + _, _ = s.db.Exec(ctx, `ALTER TABLE emails ADD COLUMN IF NOT EXISTS retain_until_source TEXT`) +} + +// SetMinRetentionDays wires the global minimum retention floor (PROJ-51). +func (s *Store) SetMinRetentionDays(days int) { + if days < 0 { + days = 0 + } + s.minRetentionDays = days +} + +// MinRetentionDays returns the configured global minimum retention floor. +func (s *Store) MinRetentionDays() int { return s.minRetentionDays } + +// ── Rule CRUD ──────────────────────────────────────────────────────────────── + +func validCondType(t string) bool { + switch t { + case RuleCondSenderDomain, RuleCondRecipientDomain, RuleCondSenderAddress, RuleCondRecipientAddr: + return true + } + return false +} + +// ListArchivingRules returns all rules ordered by priority (desc). If tenantID +// is non-nil, only rules for that tenant plus global rules (tenant_id IS NULL) +// are returned. +func (s *Store) ListArchivingRules(ctx context.Context, tenantID *int64) ([]ArchivingRule, error) { + if s.db == nil { + return nil, fmt.Errorf("storage: no db") + } + query := `SELECT id, tenant_id, condition_type, pattern, priority, retention_days, created_at + FROM archiving_rules` + var args []interface{} + if tenantID != nil { + query += ` WHERE tenant_id = $1 OR tenant_id IS NULL` + args = append(args, *tenantID) + } + query += ` ORDER BY priority DESC, id ASC` + + rows, err := s.db.Query(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("storage: list rules: %w", err) + } + defer rows.Close() + + var out []ArchivingRule + for rows.Next() { + var r ArchivingRule + if err := rows.Scan(&r.ID, &r.TenantID, &r.CondType, &r.Pattern, &r.Priority, &r.RetentionDays, &r.CreatedAt); err != nil { + return nil, fmt.Errorf("storage: scan rule: %w", err) + } + out = append(out, r) + } + return out, rows.Err() +} + +// CreateArchivingRule inserts a new rule and returns its generated ID. +func (s *Store) CreateArchivingRule(ctx context.Context, r ArchivingRule) (int64, error) { + if s.db == nil { + return 0, fmt.Errorf("storage: no db") + } + if !validCondType(r.CondType) { + return 0, fmt.Errorf("storage: invalid condition_type %q", r.CondType) + } + if strings.TrimSpace(r.Pattern) == "" { + return 0, fmt.Errorf("storage: pattern must not be empty") + } + if r.RetentionDays != nil && *r.RetentionDays < 0 { + return 0, fmt.Errorf("storage: retention_days must be >= 0") + } + var id int64 + err := s.db.QueryRow(ctx, ` + INSERT INTO archiving_rules (tenant_id, condition_type, pattern, priority, retention_days) + VALUES ($1, $2, $3, $4, $5) RETURNING id`, + r.TenantID, r.CondType, strings.ToLower(strings.TrimSpace(r.Pattern)), r.Priority, r.RetentionDays, + ).Scan(&id) + if err != nil { + return 0, fmt.Errorf("storage: create rule: %w", err) + } + return id, nil +} + +// UpdateArchivingRule replaces all editable fields of an existing rule. +func (s *Store) UpdateArchivingRule(ctx context.Context, r ArchivingRule) error { + if s.db == nil { + return fmt.Errorf("storage: no db") + } + if !validCondType(r.CondType) { + return fmt.Errorf("storage: invalid condition_type %q", r.CondType) + } + if strings.TrimSpace(r.Pattern) == "" { + return fmt.Errorf("storage: pattern must not be empty") + } + if r.RetentionDays != nil && *r.RetentionDays < 0 { + return fmt.Errorf("storage: retention_days must be >= 0") + } + tag, err := s.db.Exec(ctx, ` + UPDATE archiving_rules + SET tenant_id=$1, condition_type=$2, pattern=$3, priority=$4, retention_days=$5 + WHERE id=$6`, + r.TenantID, r.CondType, strings.ToLower(strings.TrimSpace(r.Pattern)), r.Priority, r.RetentionDays, r.ID, + ) + if err != nil { + return fmt.Errorf("storage: update rule: %w", err) + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("storage: rule %d not found", r.ID) + } + return nil +} + +// DeleteArchivingRule removes a rule. Existing retain_until values on already +// archived mails are intentionally left unchanged (PROJ-51 edge case: no +// retroactive shortening). +func (s *Store) DeleteArchivingRule(ctx context.Context, id int64) error { + if s.db == nil { + return fmt.Errorf("storage: no db") + } + tag, err := s.db.Exec(ctx, `DELETE FROM archiving_rules WHERE id=$1`, id) + if err != nil { + return fmt.Errorf("storage: delete rule: %w", err) + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("storage: rule %d not found", id) + } + return nil +} + +// ── Retention computation ──────────────────────────────────────────────────── + +// ruleMatches reports whether a parsed mail matches a rule's condition. +func ruleMatches(r ArchivingRule, pm *mailparser.ParsedMail) bool { + if pm == nil { + return false + } + pat := strings.ToLower(strings.TrimSpace(r.Pattern)) + if pat == "" { + return false + } + domainOf := func(addr string) string { + addr = strings.ToLower(strings.TrimSpace(addr)) + if i := strings.LastIndex(addr, "@"); i >= 0 { + return addr[i+1:] + } + return "" + } + switch r.CondType { + case RuleCondSenderAddress: + return strings.ToLower(strings.TrimSpace(pm.From)) == pat + case RuleCondSenderDomain: + return domainOf(pm.From) == pat + case RuleCondRecipientAddr: + for _, t := range pm.To { + if strings.ToLower(strings.TrimSpace(t)) == pat { + return true + } + } + for _, c := range pm.CC { + if strings.ToLower(strings.TrimSpace(c)) == pat { + return true + } + } + return false + case RuleCondRecipientDomain: + for _, t := range pm.To { + if domainOf(t) == pat { + return true + } + } + for _, c := range pm.CC { + if domainOf(c) == pat { + return true + } + } + return false + } + return false +} + +// resolveRetention computes the retention period (in days), the source marker, +// and whether any lock applies for a mail. It implements the PROJ-51 ordering: +// 1. matching rule with retention_days (highest priority, longest on tie) +// 2. tenant_default (tenants.retention_days > 0) +// 3. global_default (s.retentionDays > 0, only when no tenant) +// 4. min_retention floor — may only extend the above +// +// Returns days=0 and apply=false when no lock applies at all. +func (s *Store) resolveRetention(ctx context.Context, pm *mailparser.ParsedMail, tenantID *int64) (days int, source string, apply bool) { + // Step 1: matching rule + if s.db != nil && pm != nil { + rules, err := s.ListArchivingRules(ctx, tenantID) + if err == nil { + var best *ArchivingRule + for i := range rules { + r := rules[i] + if r.RetentionDays == nil { + continue + } + // Tenant scoping: a tenant-specific rule only applies to its own + // tenant; a global rule (nil) applies everywhere. + if r.TenantID != nil { + if tenantID == nil || *r.TenantID != *tenantID { + continue + } + } + if !ruleMatches(r, pm) { + continue + } + if best == nil { + rr := r + best = &rr + continue + } + // Highest priority wins; on tie the longest retention wins. + if r.Priority > best.Priority || + (r.Priority == best.Priority && *r.RetentionDays > *best.RetentionDays) { + rr := r + best = &rr + } + } + if best != nil { + days = *best.RetentionDays + source = fmt.Sprintf("rule:%d", best.ID) + apply = true + } + } + } + + // Step 2/3: tenant / global default (only if no rule set a value) + if !apply { + if tenantID != nil && s.db != nil { + var tenantDays int + if err := s.db.QueryRow(ctx, `SELECT retention_days FROM tenants WHERE id=$1`, *tenantID).Scan(&tenantDays); err == nil && tenantDays > 0 { + days = tenantDays + source = RetainSourceTenantDefault + apply = true + } + } else if tenantID == nil && s.retentionDays > 0 { + days = s.retentionDays + source = RetainSourceGlobalDefault + apply = true + } + } + + // Step 4: global minimum floor — may only extend. + if s.minRetentionDays > 0 && s.minRetentionDays > days { + days = s.minRetentionDays + source = RetainSourceMinRetention + apply = true + } + + return days, source, apply +} + +// applyRetention computes and writes retain_until + retain_until_source for a +// freshly stored mail (PROJ-51). It never shortens an existing retain_until: +// it only writes when retain_until IS NULL. +func (s *Store) applyRetention(ctx context.Context, id string, pm *mailparser.ParsedMail, tenantID *int64) { + if s.db == nil { + return + } + days, source, apply := s.resolveRetention(ctx, pm, tenantID) + if !apply || days <= 0 { + return + } + until := time.Now().AddDate(0, 0, days) + _, _ = s.db.Exec(ctx, + `UPDATE emails SET retain_until=$1, retain_until_source=$2 WHERE id=$3 AND retain_until IS NULL`, + until, source, id) +} + +// ApplyRetentionBackfill is the exported entry point for runBackfill (PROJ-34 / +// PROJ-51). It (re-)evaluates rules, tenant/global defaults and the minimum +// floor for an existing mail and sets retain_until only when it is still NULL. +// It never shortens an existing lock. +func (s *Store) ApplyRetentionBackfill(ctx context.Context, id string, pm *mailparser.ParsedMail, tenantID *int64) { + s.applyRetention(ctx, id, pm, tenantID) +} + +// EffectiveLockActive reports whether any retention lock can currently apply +// system-wide: a positive global default, a positive minimum floor, any tenant +// with retention_days > 0, or any rule carrying a retention_days value. Used by +// the healthcheck (PROJ-51) to warn when effectively no deletion lock is in +// force. +func (s *Store) EffectiveLockActive(ctx context.Context) (bool, error) { + if s.retentionDays > 0 || s.minRetentionDays > 0 { + return true, nil + } + if s.db == nil { + return false, nil + } + var n int + if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM tenants WHERE retention_days > 0`).Scan(&n); err == nil && n > 0 { + return true, nil + } + if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM archiving_rules WHERE retention_days IS NOT NULL`).Scan(&n); err == nil && n > 0 { + return true, nil + } + return false, nil +} + +// GetRetentionInfo returns the stored retain_until and its source marker for a +// single mail (PROJ-51), for the admin/auditor detail view. +func (s *Store) GetRetentionInfo(ctx context.Context, id string) (until *time.Time, source string, err error) { + if s.db == nil { + return nil, "", fmt.Errorf("storage: no db") + } + var src *string + err = s.db.QueryRow(ctx, + `SELECT retain_until, retain_until_source FROM emails WHERE id=$1`, id, + ).Scan(&until, &src) + if err != nil { + return nil, "", fmt.Errorf("storage: retention info: %w", err) + } + if src != nil { + source = *src + } + return until, source, nil +} diff --git a/internal/storage/storage.go b/internal/storage/storage.go index b95afde..75d9027 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -32,6 +32,7 @@ type Config struct { Keyfile string // path to 32-byte AES key file; empty = no encryption DSN string // PostgreSQL DSN; empty = no DB RetentionDays int // 0 = no lock; >0 = GoBD retention period in days + MinRetentionDays int // PROJ-51: global minimum retention floor (0 = none) CompressEnabled bool // gzip-compress emails and attachments before encryption } @@ -42,6 +43,7 @@ type Store struct { key []byte // nil = no encryption db *pgxpool.Pool // nil = no DB retentionDays int // 0 = no lock + minRetentionDays int // PROJ-51: global minimum retention floor (0 = none) compressEnabled bool // gzip before encryption } @@ -72,7 +74,7 @@ func New(cfg Config) (*Store, error) { } } - s := &Store{dir: cfg.Dir, retentionDays: cfg.RetentionDays, compressEnabled: cfg.CompressEnabled} + s := &Store{dir: cfg.Dir, retentionDays: cfg.RetentionDays, minRetentionDays: cfg.MinRetentionDays, compressEnabled: cfg.CompressEnabled} // Load encryption key if err := s.loadKey(cfg.Keyfile); err != nil { @@ -103,6 +105,8 @@ func New(cfg Config) (*Store, error) { _, _ = s.db.Exec(ctx, `CREATE UNIQUE INDEX IF NOT EXISTS idx_emails_uid ON emails (uid)`) // 2.0: storage_objects FK on emails _, _ = s.db.Exec(ctx, `ALTER TABLE emails ADD COLUMN IF NOT EXISTS storage_id BIGINT REFERENCES storage_objects(id)`) + // PROJ-51: archiving_rules table + retain_until_source column + s.initRetentionRulesSchema(ctx) } return s, nil @@ -470,21 +474,12 @@ func (s *Store) Save(ctx context.Context, raw []byte, _ time.Time, tenantID *int if parseErr == nil { _ = s.saveAttachments(ctx, id, pm) } - // PROJ-34: Set retention lock. - // Mandanten-Mails: nur wenn der Mandant explizit retention_days > 0 gesetzt hat. - // Globale config greift NICHT automatisch — jeder Mandant muss selbst opt-in. - // Mails ohne Mandant (tenantID == nil): globale config als Fallback. - if tenantID != nil { - var tenantDays int - if err := s.db.QueryRow(ctx, `SELECT retention_days FROM tenants WHERE id=$1`, *tenantID).Scan(&tenantDays); err == nil && tenantDays > 0 { - until := time.Now().AddDate(0, 0, tenantDays) - _, _ = s.db.Exec(ctx, `UPDATE emails SET retain_until=$1 WHERE id=$2 AND retain_until IS NULL`, until, id) - } - // else: tenant hat retention_days=0 → kein Lock gesetzt → keine automatische Löschung - } else if s.retentionDays > 0 { - until := time.Now().AddDate(0, 0, s.retentionDays) - _, _ = s.db.Exec(ctx, `UPDATE emails SET retain_until=$1 WHERE id=$2 AND retain_until IS NULL`, until, id) - } + // PROJ-34 + PROJ-51: Set retention lock. + // Priority: matching archiving_rule with retention_days > tenant + // default > global default, then raised by the global minimum floor. + // Behaviour without rules/min stays identical to PROJ-34 (tenant + // opt-in; global default only for tenant-less mails). + s.applyRetention(ctx, id, pm, tenantID) } } diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 88c747e..d56403b 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -70,6 +70,7 @@ import { CertTab } from "@/components/admin/tabs/CertTab"; import { ModulesTab } from "@/components/admin/ModulesTab"; import { IMAPSettingsTab } from "@/components/admin/tabs/IMAPSettingsTab"; import { RetentionTab } from "@/components/admin/tabs/RetentionTab"; +import { ArchivingRulesTab } from "@/components/admin/tabs/ArchivingRulesTab"; import { QuotaTab } from "@/components/admin/tabs/QuotaTab"; import { SMTPOutTab } from "@/components/admin/tabs/SMTPOutTab"; import { ResetPasswordDialog, DeleteUserDialog } from "@/components/admin/UserDialogs"; @@ -712,6 +713,7 @@ export default function AdminPage() { {isSuperAdmin && Zertifikat} {isSuperAdmin && Mandanten} {isSuperAdmin && Retention} + {isSuperAdmin && Regeln} {isSuperAdmin && Quotas} {isSuperAdmin && SMTP-Out} {isSuperAdmin && Module} @@ -975,6 +977,11 @@ export default function AdminPage() { )} + {isSuperAdmin && ( + + + + )} {isSuperAdmin && ( diff --git a/src/app/mail/[id]/page.tsx b/src/app/mail/[id]/page.tsx index 296cb7f..c50214d 100644 --- a/src/app/mail/[id]/page.tsx +++ b/src/app/mail/[id]/page.tsx @@ -46,6 +46,23 @@ function formatDate(iso: string): string { } } +// PROJ-51: human-readable label for the retain_until_source marker. +function retentionSourceLabel(source: string): string { + if (source.startsWith("rule:")) { + return `Archivierungsregel #${source.slice(5)}`; + } + switch (source) { + case "tenant_default": + return "Mandanten-Standard"; + case "global_default": + return "Globaler Standard"; + case "min_retention": + return "Globale Mindestfrist"; + default: + return source; + } +} + function triggerDownload(blob: Blob, filename: string) { const url = URL.createObjectURL(blob); const a = document.createElement("a"); @@ -115,6 +132,20 @@ function MailHeaderGrid({ mail }: { mail: MailDetail }) { )} + {/* PROJ-51: retention lock + source for auditor traceability */} + {mail.retain_until && ( + <> + Aufbewahrung: + + bis {formatDate(mail.retain_until)} + {mail.retain_until_source && ( + + {retentionSourceLabel(mail.retain_until_source)} + + )} + + + )} + + + {error &&

{error}

} + {loading ? ( +
+ + +
+ ) : rules.length === 0 ? ( +

+ Noch keine Archivierungsregeln definiert. +

+ ) : ( +
+ + + + Prio + Bedingung + Muster + Mandant + Aufbewahrung + + + + + {rules.map((r) => ( + + {r.priority} + {COND_LABELS[r.condition_type] ?? r.condition_type} + {r.pattern} + {tenantName(r.tenant_id)} + {retentionLabel(r.retention_days)} + +
+ + +
+
+
+ ))} +
+
+
+ )} +
+ + + {/* Create / Edit Dialog */} + { if (!o) setEdit(null); }}> + + + + {edit?.id === null ? "Regel hinzufügen" : "Regel bearbeiten"} + + + Legt fest, welche Mails von dieser Regel erfasst werden und – optional – wie lange + sie aufbewahrt werden. + + + + {edit && ( +
+
+ + +
+ +
+ + setEdit({ ...edit, pattern: e.target.value })} + /> +
+ +
+ + +
+ +
+ + setEdit({ ...edit, priority: e.target.value })} + /> +

+ Höher = wird zuerst geprüft. Bei mehreren Treffern gewinnt die höchste Priorität. +

+
+ +
+ + setEdit({ ...edit, retention_days: e.target.value })} + /> +
+ {RETENTION_PRESETS.map((p) => ( + + ))} +
+ {edit.retention_days.trim() !== "" && + parseInt(edit.retention_days, 10) > 0 && ( +

+ ≈ {(parseInt(edit.retention_days, 10) / 365).toFixed(1)} Jahre + {minRetentionDays > 0 && + parseInt(edit.retention_days, 10) < minRetentionDays && ( + + {" "} + – wird durch globale Mindestfrist ({minRetentionDays} Tage) angehoben + + )} +

+ )} +

+ Leer = diese Regel setzt keine eigene Frist (Mandanten-/globaler Standard gilt). +

+
+ + {formError &&

{formError}

} +
+ )} + + + + + +
+
+ + {/* Delete Dialog */} + { if (!o) setDeleteRule(null); }}> + + + Regel löschen + + Die Regel wird gelöscht. Bereits gesetzte Aufbewahrungsfristen bestehender Mails + bleiben unverändert. + + + {deleteRule && ( +

+ {deleteRule.pattern} ( + {COND_LABELS[deleteRule.condition_type]}) +

+ )} + + + + +
+
+ + ); +} diff --git a/src/lib/api/archiving_rules.ts b/src/lib/api/archiving_rules.ts new file mode 100644 index 0000000..ab78171 --- /dev/null +++ b/src/lib/api/archiving_rules.ts @@ -0,0 +1,61 @@ +import { request } from "./core"; + +// PROJ-51: Archiving rules (minimal PROJ-43) with optional retention period. +// Superadmin-only CRUD under /api/admin/archiving-rules. + +// Condition types — must match storage.RuleCond* constants in the Go backend. +export type RuleConditionType = + | "sender_domain" + | "recipient_domain" + | "sender_address" + | "recipient_address"; + +export interface ArchivingRule { + id: number; + tenant_id: number | null; // null = applies to all tenants + condition_type: RuleConditionType; + pattern: string; + priority: number; + retention_days: number | null; // null = no rule-specific retention + created_at: string; +} + +export interface ArchivingRulesResponse { + rules: ArchivingRule[]; + min_retention_days: number; +} + +export interface ArchivingRuleInput { + tenant_id: number | null; + condition_type: RuleConditionType; + pattern: string; + priority: number; + retention_days: number | null; +} + +export async function getArchivingRules(): Promise { + return request("/api/admin/archiving-rules"); +} + +export async function createArchivingRule( + input: ArchivingRuleInput +): Promise<{ id: number }> { + return request<{ id: number }>("/api/admin/archiving-rules", { + method: "POST", + body: JSON.stringify(input), + }); +} + +export async function updateArchivingRule( + id: number, + input: ArchivingRuleInput +): Promise { + await request(`/api/admin/archiving-rules/${id}`, { + method: "PUT", + body: JSON.stringify(input), + }); +} + +export async function deleteArchivingRule(id: number): Promise { + await request(`/api/admin/archiving-rules/${id}`, { method: "DELETE" }); +} diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index fefdb8f..537cb7a 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -164,6 +164,19 @@ export { requestACMECert, } from "./system"; +export type { + RuleConditionType, + ArchivingRule, + ArchivingRulesResponse, + ArchivingRuleInput, +} from "./archiving_rules"; +export { + getArchivingRules, + createArchivingRule, + updateArchivingRule, + deleteArchivingRule, +} from "./archiving_rules"; + export type { SavedSearch } from "./saved_searches"; export { listSavedSearches, diff --git a/src/lib/api/mail.ts b/src/lib/api/mail.ts index 523a50d..51bc509 100644 --- a/src/lib/api/mail.ts +++ b/src/lib/api/mail.ts @@ -73,6 +73,10 @@ export interface MailDetail { // PROJ-44: OCR status and extracted-text length ocr_status?: OCRStatus; ocr_chars?: number; + // PROJ-51: retention lock + its source for auditor traceability. + // retain_until_source: "rule:" | "tenant_default" | "global_default" | "min_retention" + retain_until?: string | null; + retain_until_source?: string | null; } export interface ImapFolder {