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:
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user