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.
This commit is contained in:
sysops
2026-06-13 20:48:16 +02:00
parent 7c08ebe1b7
commit 507dee6431
16 changed files with 1175 additions and 21 deletions
+36
View File
@@ -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 {
+7
View File
@@ -125,6 +125,7 @@ func main() {
Keyfile: cfg.Storage.Keyfile,
DSN: cfg.Database.DSN(),
RetentionDays: cfg.Storage.RetentionDays,
MinRetentionDays: cfg.Storage.MinRetentionDays,
CompressEnabled: cfg.Storage.Compress,
}
mailStore, err := storage.New(storeCfg)
@@ -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 {
+5
View File
@@ -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
+1
View File
@@ -73,6 +73,7 @@ type StorageConfig struct {
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)
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)
}
+1 -1
View File
@@ -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 |
<!-- Add features above this line -->
+137
View File
@@ -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)
}
+14
View File
@@ -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,
})
}
+5
View File
@@ -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)))
+379
View File
@@ -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:<id>" 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
}
+11 -16
View File
@@ -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)
}
}
+7
View File
@@ -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 && <TabsTrigger value="cert" onClick={loadCert}>Zertifikat</TabsTrigger>}
{isSuperAdmin && <TabsTrigger value="tenants" onClick={loadTenants}>Mandanten</TabsTrigger>}
{isSuperAdmin && <TabsTrigger value="retention">Retention</TabsTrigger>}
{isSuperAdmin && <TabsTrigger value="archiving-rules">Regeln</TabsTrigger>}
{isSuperAdmin && <TabsTrigger value="quotas">Quotas</TabsTrigger>}
{isSuperAdmin && <TabsTrigger value="smtp-out">SMTP-Out</TabsTrigger>}
{isSuperAdmin && <TabsTrigger value="modules">Module</TabsTrigger>}
@@ -975,6 +977,11 @@ export default function AdminPage() {
<RetentionTab />
</TabsContent>
)}
{isSuperAdmin && (
<TabsContent value="archiving-rules">
<ArchivingRulesTab />
</TabsContent>
)}
{isSuperAdmin && (
<TabsContent value="smtp-out">
+31
View File
@@ -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 }) {
)}
<OcrBadge status={mail.ocr_status} />
</span>
{/* PROJ-51: retention lock + source for auditor traceability */}
{mail.retain_until && (
<>
<span className="font-medium text-muted-foreground">Aufbewahrung:</span>
<span className="flex flex-wrap items-center gap-2 text-sm">
<span>bis {formatDate(mail.retain_until)}</span>
{mail.retain_until_source && (
<Badge variant="secondary">
{retentionSourceLabel(mail.retain_until_source)}
</Badge>
)}
</span>
</>
)}
</div>
<button
@@ -0,0 +1,459 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import {
getArchivingRules,
createArchivingRule,
updateArchivingRule,
deleteArchivingRule,
getTenants,
type ArchivingRule,
type ArchivingRuleInput,
type RuleConditionType,
type Tenant,
} from "@/lib/api";
const COND_LABELS: Record<RuleConditionType, string> = {
sender_domain: "Absender-Domain",
recipient_domain: "Empfänger-Domain",
sender_address: "Absender-Adresse",
recipient_address: "Empfänger-Adresse",
};
// PROJ-51 retention suggestion values.
const RETENTION_PRESETS = [
{ days: 2190, label: "6 Jahre (Handels-/Geschäftsbriefe)" },
{ days: 3650, label: "10 Jahre (Rechnungen, Belege)" },
{ days: 36500, label: "Dauerhaft (100 Jahre)" },
];
function retentionLabel(days: number | null): React.ReactNode {
if (days === null) {
return <Badge variant="secondary">kein Regel-Wert</Badge>;
}
if (days >= 36500) {
return <Badge>Dauerhaft</Badge>;
}
return <Badge>{days} Tage ({(days / 365).toFixed(1)} Jahre)</Badge>;
}
interface EditState {
id: number | null; // null = create
tenant_id: string; // "" = alle Mandanten
condition_type: RuleConditionType;
pattern: string;
priority: string;
retention_days: string; // "" = kein Regel-Wert
}
const EMPTY_EDIT: EditState = {
id: null,
tenant_id: "",
condition_type: "sender_domain",
pattern: "",
priority: "0",
retention_days: "",
};
export function ArchivingRulesTab() {
const [rules, setRules] = useState<ArchivingRule[]>([]);
const [minRetentionDays, setMinRetentionDays] = useState(0);
const [tenants, setTenants] = useState<Tenant[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [edit, setEdit] = useState<EditState | null>(null);
const [saving, setSaving] = useState(false);
const [formError, setFormError] = useState("");
const [deleteRule, setDeleteRule] = useState<ArchivingRule | null>(null);
const [deleting, setDeleting] = useState(false);
const load = useCallback(() => {
setLoading(true);
setError("");
Promise.all([getArchivingRules(), getTenants()])
.then(([data, ts]) => {
setRules(data.rules);
setMinRetentionDays(data.min_retention_days);
setTenants(ts);
})
.catch(() => setError("Archivierungsregeln konnten nicht geladen werden"))
.finally(() => setLoading(false));
}, []);
useEffect(() => {
load();
}, [load]);
const tenantName = (id: number | null): React.ReactNode => {
if (id === null) return <span className="text-muted-foreground">Alle Mandanten</span>;
const t = tenants.find((x) => x.id === id);
return t ? t.name : `#${id}`;
};
const openCreate = () => {
setEdit({ ...EMPTY_EDIT });
setFormError("");
};
const openEdit = (r: ArchivingRule) => {
setEdit({
id: r.id,
tenant_id: r.tenant_id === null ? "" : String(r.tenant_id),
condition_type: r.condition_type,
pattern: r.pattern,
priority: String(r.priority),
retention_days: r.retention_days === null ? "" : String(r.retention_days),
});
setFormError("");
};
const handleSave = async () => {
if (!edit) return;
const pattern = edit.pattern.trim();
if (!pattern) {
setFormError("Muster darf nicht leer sein");
return;
}
const priority = parseInt(edit.priority, 10);
if (isNaN(priority)) {
setFormError("Priorität muss eine Zahl sein");
return;
}
let retention_days: number | null = null;
if (edit.retention_days.trim() !== "") {
retention_days = parseInt(edit.retention_days, 10);
if (isNaN(retention_days) || retention_days < 0) {
setFormError("Aufbewahrung muss eine positive Zahl sein");
return;
}
}
const input: ArchivingRuleInput = {
tenant_id: edit.tenant_id === "" ? null : parseInt(edit.tenant_id, 10),
condition_type: edit.condition_type,
pattern,
priority,
retention_days,
};
setSaving(true);
setFormError("");
try {
if (edit.id === null) {
await createArchivingRule(input);
} else {
await updateArchivingRule(edit.id, input);
}
setEdit(null);
load();
} catch (e: unknown) {
setFormError(e instanceof Error ? e.message : "Speichern fehlgeschlagen");
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (!deleteRule) return;
setDeleting(true);
try {
await deleteArchivingRule(deleteRule.id);
setDeleteRule(null);
load();
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Löschen fehlgeschlagen");
setDeleteRule(null);
} finally {
setDeleting(false);
}
};
return (
<div className="mt-4 space-y-4">
<Card>
<CardHeader>
<CardTitle>Globale Mindest-Aufbewahrungsfrist</CardTitle>
<CardDescription>
Gilt systemweit als unterste Grenze. Regeln und Mandanten-Einstellungen dürfen die
Frist nur verlängern, nie verkürzen. Konfiguration über{" "}
<code className="text-xs">storage.min_retention_days</code> in der{" "}
<code className="text-xs">config.yml</code>.
</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<Skeleton className="h-6 w-48" />
) : minRetentionDays > 0 ? (
<Badge>
{minRetentionDays} Tage ({(minRetentionDays / 365).toFixed(1)} Jahre)
</Badge>
) : (
<Badge variant="secondary">Keine Mindestfrist (0)</Badge>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-2">
<div>
<CardTitle>Archivierungsregeln</CardTitle>
<CardDescription>
Regeln mit Aufbewahrungsfrist bestimmen bei Treffer die Mindestaufbewahrung einer
Mail (höchste Priorität gewinnt).
</CardDescription>
</div>
<Button size="sm" onClick={openCreate}>
Regel hinzufügen
</Button>
</CardHeader>
<CardContent>
{error && <p className="mb-3 text-sm text-destructive">{error}</p>}
{loading ? (
<div className="space-y-2">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
) : rules.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">
Noch keine Archivierungsregeln definiert.
</p>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-16">Prio</TableHead>
<TableHead>Bedingung</TableHead>
<TableHead>Muster</TableHead>
<TableHead>Mandant</TableHead>
<TableHead>Aufbewahrung</TableHead>
<TableHead className="w-32"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rules.map((r) => (
<TableRow key={r.id}>
<TableCell>{r.priority}</TableCell>
<TableCell>{COND_LABELS[r.condition_type] ?? r.condition_type}</TableCell>
<TableCell className="font-mono text-xs break-all">{r.pattern}</TableCell>
<TableCell>{tenantName(r.tenant_id)}</TableCell>
<TableCell>{retentionLabel(r.retention_days)}</TableCell>
<TableCell>
<div className="flex gap-1">
<Button size="sm" variant="outline" onClick={() => openEdit(r)}>
Bearbeiten
</Button>
<Button
size="sm"
variant="ghost"
className="text-destructive"
onClick={() => setDeleteRule(r)}
>
Löschen
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
{/* Create / Edit Dialog */}
<Dialog open={!!edit} onOpenChange={(o) => { if (!o) setEdit(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{edit?.id === null ? "Regel hinzufügen" : "Regel bearbeiten"}
</DialogTitle>
<DialogDescription>
Legt fest, welche Mails von dieser Regel erfasst werden und optional wie lange
sie aufbewahrt werden.
</DialogDescription>
</DialogHeader>
{edit && (
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label>Bedingungstyp</Label>
<Select
value={edit.condition_type}
onValueChange={(v) =>
setEdit({ ...edit, condition_type: v as RuleConditionType })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(COND_LABELS) as RuleConditionType[]).map((k) => (
<SelectItem key={k} value={k}>
{COND_LABELS[k]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="rule-pattern">Muster</Label>
<Input
id="rule-pattern"
placeholder="z.B. firma.de oder rechnung@firma.de"
value={edit.pattern}
onChange={(e) => setEdit({ ...edit, pattern: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label>Mandant</Label>
<Select
value={edit.tenant_id === "" ? "__all__" : edit.tenant_id}
onValueChange={(v) =>
setEdit({ ...edit, tenant_id: v === "__all__" ? "" : v })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">Alle Mandanten</SelectItem>
{tenants.map((t) => (
<SelectItem key={t.id} value={String(t.id)}>
{t.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="rule-priority">Priorität</Label>
<Input
id="rule-priority"
type="number"
value={edit.priority}
onChange={(e) => setEdit({ ...edit, priority: e.target.value })}
/>
<p className="text-xs text-muted-foreground">
Höher = wird zuerst geprüft. Bei mehreren Treffern gewinnt die höchste Priorität.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="rule-retention">Aufbewahrung (Tage)</Label>
<Input
id="rule-retention"
type="number"
min={0}
placeholder="leer = kein Regel-spezifischer Wert"
value={edit.retention_days}
onChange={(e) => setEdit({ ...edit, retention_days: e.target.value })}
/>
<div className="flex flex-wrap gap-1">
{RETENTION_PRESETS.map((p) => (
<Button
key={p.days}
type="button"
size="sm"
variant="outline"
onClick={() => setEdit({ ...edit, retention_days: String(p.days) })}
>
{p.label}
</Button>
))}
</div>
{edit.retention_days.trim() !== "" &&
parseInt(edit.retention_days, 10) > 0 && (
<p className="text-xs text-muted-foreground">
{(parseInt(edit.retention_days, 10) / 365).toFixed(1)} Jahre
{minRetentionDays > 0 &&
parseInt(edit.retention_days, 10) < minRetentionDays && (
<span className="text-amber-600">
{" "}
wird durch globale Mindestfrist ({minRetentionDays} Tage) angehoben
</span>
)}
</p>
)}
<p className="text-xs text-muted-foreground">
Leer = diese Regel setzt keine eigene Frist (Mandanten-/globaler Standard gilt).
</p>
</div>
{formError && <p className="text-sm text-destructive">{formError}</p>}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setEdit(null)}>
Abbrechen
</Button>
<Button disabled={saving} onClick={handleSave}>
{saving ? "Speichern..." : "Speichern"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Dialog */}
<Dialog open={!!deleteRule} onOpenChange={(o) => { if (!o) setDeleteRule(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Regel löschen</DialogTitle>
<DialogDescription>
Die Regel wird gelöscht. Bereits gesetzte Aufbewahrungsfristen bestehender Mails
bleiben unverändert.
</DialogDescription>
</DialogHeader>
{deleteRule && (
<p className="py-2 text-sm">
<span className="font-mono">{deleteRule.pattern}</span> (
{COND_LABELS[deleteRule.condition_type]})
</p>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteRule(null)}>
Abbrechen
</Button>
<Button variant="destructive" disabled={deleting} onClick={handleDelete}>
{deleting ? "Lösche..." : "Löschen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+61
View File
@@ -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<ArchivingRulesResponse> {
return request<ArchivingRulesResponse>("/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<void> {
await request<void>(`/api/admin/archiving-rules/${id}`, {
method: "PUT",
body: JSON.stringify(input),
});
}
export async function deleteArchivingRule(id: number): Promise<void> {
await request<void>(`/api/admin/archiving-rules/${id}`, { method: "DELETE" });
}
+13
View File
@@ -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,
+4
View File
@@ -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:<id>" | "tenant_default" | "global_default" | "min_retention"
retain_until?: string | null;
retain_until_source?: string | null;
}
export interface ImapFolder {