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 }