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