Files
sysops 507dee6431 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.
2026-06-13 20:48:16 +02:00

380 lines
12 KiB
Go

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
}