package storage import ( "context" "fmt" "strings" "time" ) // PROJ-43: Tenant routing rules — flexible pattern rules that assign an // incoming mail to a tenant. This complements the simple 1:1 tenant_domains // mapping (PROJ-21 Phase 5): routing rules are more explicit and therefore // evaluated *before* the plain domain fallback. Higher priority wins on ties. // // NOTE: this is intentionally a separate table from PROJ-51's `archiving_rules` // (retention categories) — same "rules engine" style, different purpose. // Routing rule match types. const ( RouteMatchFromDomain = "from_domain" // domain of the From address RouteMatchToDomain = "to_domain" // domain of any To/Cc/envelope recipient RouteMatchFromAddr = "from_addr" // full From address (exact) RouteMatchToAddr = "to_addr" // full To/Cc/envelope recipient (exact) ) // TenantRoutingRule assigns matching mails to TenantID. type TenantRoutingRule struct { ID int64 `json:"id"` TenantID int64 `json:"tenant_id"` // target tenant (always set — a routing rule must resolve to a tenant) MatchType string `json:"match_type"` Pattern string `json:"pattern"` Priority int `json:"priority"` // higher = evaluated first CreatedAt time.Time `json:"created_at"` } func (s *Store) initTenantRoutingRulesSchema(ctx context.Context) { if s.db == nil { return } _, _ = s.db.Exec(ctx, ` CREATE TABLE IF NOT EXISTS tenant_routing_rules ( id BIGSERIAL PRIMARY KEY, tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, match_type TEXT NOT NULL, pattern TEXT NOT NULL, priority INT NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() )`) _, _ = s.db.Exec(ctx, `CREATE INDEX IF NOT EXISTS idx_tenant_routing_rules_priority ON tenant_routing_rules (priority DESC)`) _, _ = s.db.Exec(ctx, `CREATE INDEX IF NOT EXISTS idx_tenant_routing_rules_tenant ON tenant_routing_rules (tenant_id)`) } func validRouteMatchType(t string) bool { switch t { case RouteMatchFromDomain, RouteMatchToDomain, RouteMatchFromAddr, RouteMatchToAddr: return true } return false } // ── CRUD ────────────────────────────────────────────────────────────────────── // ListTenantRoutingRules returns rules ordered by priority (desc). If tenantID // is non-nil, only rules for that tenant are returned (tenant-admin scope). // A nil tenantID returns all rules (superadmin scope). func (s *Store) ListTenantRoutingRules(ctx context.Context, tenantID *int64) ([]TenantRoutingRule, error) { if s.db == nil { return nil, fmt.Errorf("storage: no db") } query := `SELECT id, tenant_id, match_type, pattern, priority, created_at FROM tenant_routing_rules` var args []interface{} if tenantID != nil { query += ` WHERE tenant_id = $1` 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 routing rules: %w", err) } defer rows.Close() var out []TenantRoutingRule for rows.Next() { var r TenantRoutingRule if err := rows.Scan(&r.ID, &r.TenantID, &r.MatchType, &r.Pattern, &r.Priority, &r.CreatedAt); err != nil { return nil, fmt.Errorf("storage: scan routing rule: %w", err) } out = append(out, r) } return out, rows.Err() } // GetTenantRoutingRule loads a single rule (used for IDOR scope checks and // dry-run by rule ID). func (s *Store) GetTenantRoutingRule(ctx context.Context, id int64) (*TenantRoutingRule, error) { if s.db == nil { return nil, fmt.Errorf("storage: no db") } var r TenantRoutingRule err := s.db.QueryRow(ctx, `SELECT id, tenant_id, match_type, pattern, priority, created_at FROM tenant_routing_rules WHERE id=$1`, id, ).Scan(&r.ID, &r.TenantID, &r.MatchType, &r.Pattern, &r.Priority, &r.CreatedAt) if err != nil { return nil, fmt.Errorf("storage: get routing rule: %w", err) } return &r, nil } func normalizeRoutePattern(matchType, pattern string) (string, error) { if !validRouteMatchType(matchType) { return "", fmt.Errorf("storage: invalid match_type %q", matchType) } p := strings.ToLower(strings.TrimSpace(pattern)) if p == "" { return "", fmt.Errorf("storage: pattern must not be empty") } return p, nil } // CreateTenantRoutingRule inserts a new rule and returns its generated ID. func (s *Store) CreateTenantRoutingRule(ctx context.Context, r TenantRoutingRule) (int64, error) { if s.db == nil { return 0, fmt.Errorf("storage: no db") } pat, err := normalizeRoutePattern(r.MatchType, r.Pattern) if err != nil { return 0, err } if r.TenantID <= 0 { return 0, fmt.Errorf("storage: tenant_id must be set") } var id int64 err = s.db.QueryRow(ctx, ` INSERT INTO tenant_routing_rules (tenant_id, match_type, pattern, priority) VALUES ($1, $2, $3, $4) RETURNING id`, r.TenantID, r.MatchType, pat, r.Priority, ).Scan(&id) if err != nil { return 0, fmt.Errorf("storage: create routing rule: %w", err) } return id, nil } // UpdateTenantRoutingRule replaces the editable fields of an existing rule. func (s *Store) UpdateTenantRoutingRule(ctx context.Context, r TenantRoutingRule) error { if s.db == nil { return fmt.Errorf("storage: no db") } pat, err := normalizeRoutePattern(r.MatchType, r.Pattern) if err != nil { return err } if r.TenantID <= 0 { return fmt.Errorf("storage: tenant_id must be set") } tag, err := s.db.Exec(ctx, ` UPDATE tenant_routing_rules SET tenant_id=$1, match_type=$2, pattern=$3, priority=$4 WHERE id=$5`, r.TenantID, r.MatchType, pat, r.Priority, r.ID, ) if err != nil { return fmt.Errorf("storage: update routing rule: %w", err) } if tag.RowsAffected() == 0 { return fmt.Errorf("storage: routing rule %d not found", r.ID) } return nil } // DeleteTenantRoutingRule removes a rule. Already-archived mails keep their // assigned tenant (no retroactive re-routing). func (s *Store) DeleteTenantRoutingRule(ctx context.Context, id int64) error { if s.db == nil { return fmt.Errorf("storage: no db") } tag, err := s.db.Exec(ctx, `DELETE FROM tenant_routing_rules WHERE id=$1`, id) if err != nil { return fmt.Errorf("storage: delete routing rule: %w", err) } if tag.RowsAffected() == 0 { return fmt.Errorf("storage: routing rule %d not found", id) } return nil } // ── Matching ──────────────────────────────────────────────────────────────── func routeDomainOf(addr string) string { addr = strings.ToLower(strings.TrimSpace(addr)) // strip a possible "Name " wrapper if i := strings.LastIndex(addr, "<"); i >= 0 { if j := strings.LastIndex(addr, ">"); j > i { addr = addr[i+1 : j] } } if i := strings.LastIndex(addr, "@"); i >= 0 { return addr[i+1:] } return "" } func routeBareAddr(addr string) string { addr = strings.ToLower(strings.TrimSpace(addr)) if i := strings.LastIndex(addr, "<"); i >= 0 { if j := strings.LastIndex(addr, ">"); j > i { addr = addr[i+1 : j] } } return strings.TrimSpace(addr) } // domainMatches supports exact match and a leading "*." wildcard that also // matches sub-domains (e.g. "*.kunde.de" matches "mail.kunde.de" and "kunde.de"). func domainMatches(pattern, domain string) bool { if pattern == "" || domain == "" { return false } if strings.HasPrefix(pattern, "*.") { base := pattern[2:] return domain == base || strings.HasSuffix(domain, "."+base) } return domain == pattern } // routingRuleMatches reports whether a rule matches the given (already // lowercased-on-store) from address and recipient list. func routingRuleMatches(r TenantRoutingRule, from string, recipients []string) bool { pat := strings.ToLower(strings.TrimSpace(r.Pattern)) if pat == "" { return false } switch r.MatchType { case RouteMatchFromAddr: return routeBareAddr(from) == pat case RouteMatchFromDomain: return domainMatches(pat, routeDomainOf(from)) case RouteMatchToAddr: for _, t := range recipients { if routeBareAddr(t) == pat { return true } } return false case RouteMatchToDomain: for _, t := range recipients { if domainMatches(pat, routeDomainOf(t)) { return true } } return false } return false } // ResolveTenantByRoutingRules evaluates all routing rules against a mail's From // address and recipient list. The highest-priority matching rule wins (lowest // id on a tie). Returns nil when no rule matches. This is the PROJ-43 explicit // stage that runs *before* the plain tenant_domains fallback. func (s *Store) ResolveTenantByRoutingRules(ctx context.Context, from string, recipients []string) (*int64, error) { if s.db == nil { return nil, nil } rules, err := s.ListTenantRoutingRules(ctx, nil) if err != nil { return nil, err } for i := range rules { if routingRuleMatches(rules[i], from, recipients) { tid := rules[i].TenantID return &tid, nil } } return nil, nil } // ── Dry-run ───────────────────────────────────────────────────────────────── // RoutingDryRunMatch is a single sample row for the dry-run preview. type RoutingDryRunMatch struct { ID string `json:"id"` MailFrom string `json:"mail_from"` MailTo string `json:"mail_to"` Subject string `json:"subject"` ReceivedAt time.Time `json:"received_at"` } // RoutingDryRunResult reports how many already-archived mails would be matched // by a (match_type, pattern) pair, plus a bounded sample. type RoutingDryRunResult struct { MatchType string `json:"match_type"` Pattern string `json:"pattern"` MatchCount int64 `json:"match_count"` SampleLimit int `json:"sample_limit"` Sample []RoutingDryRunMatch `json:"sample"` } // dryRunCondition builds a SQL condition + argument approximating the rule // against the emails table (mail_from / mail_to are display strings, so we use // ILIKE containment — this is a preview estimate, not the exact live matcher). func dryRunCondition(matchType, pattern string) (cond string, arg string, err error) { p := strings.ToLower(strings.TrimSpace(pattern)) if p == "" { return "", "", fmt.Errorf("storage: pattern must not be empty") } // "*." wildcard: drop the leading star so ILIKE '%.base' / '%@base' catches // both the base domain and its sub-domains. wild := strings.HasPrefix(p, "*.") base := p if wild { base = p[2:] } switch matchType { case RouteMatchFromAddr: return "LOWER(mail_from) LIKE $1", "%" + p + "%", nil case RouteMatchToAddr: return "LOWER(mail_to) LIKE $1", "%" + p + "%", nil case RouteMatchFromDomain: return "LOWER(mail_from) LIKE $1", "%@%" + base + "%", nil case RouteMatchToDomain: return "LOWER(mail_to) LIKE $1", "%@%" + base + "%", nil } return "", "", fmt.Errorf("storage: invalid match_type %q", matchType) } // DryRunRoutingRule counts and samples archived mails that a rule would match. // sampleLimit bounds the sample (and is applied to the query) to avoid full // table scans / timeouts on large archives. // tenantScope, when non-nil, restricts the dry-run to mails already assigned to // that tenant (enforced for tenant/domain admins so they cannot preview other // tenants' mails). A nil tenantScope (superadmin) counts across all tenants. func (s *Store) DryRunRoutingRule(ctx context.Context, matchType, pattern string, sampleLimit int, tenantScope *int64) (*RoutingDryRunResult, error) { if s.db == nil { return nil, fmt.Errorf("storage: no db") } if !validRouteMatchType(matchType) { return nil, fmt.Errorf("storage: invalid match_type %q", matchType) } if sampleLimit <= 0 || sampleLimit > 100 { sampleLimit = 20 } cond, arg, err := dryRunCondition(matchType, pattern) if err != nil { return nil, err } args := []interface{}{arg} if tenantScope != nil { cond += fmt.Sprintf(" AND tenant_id = $%d", len(args)+1) args = append(args, *tenantScope) } res := &RoutingDryRunResult{ MatchType: matchType, Pattern: strings.ToLower(strings.TrimSpace(pattern)), SampleLimit: sampleLimit, Sample: []RoutingDryRunMatch{}, } if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM emails WHERE `+cond, args..., ).Scan(&res.MatchCount); err != nil { return nil, fmt.Errorf("storage: dry-run count: %w", err) } sampleArgs := append(append([]interface{}{}, args...), sampleLimit) rows, err := s.db.Query(ctx, `SELECT id, COALESCE(mail_from,''), COALESCE(mail_to,''), COALESCE(subject,''), received_at FROM emails WHERE `+cond+fmt.Sprintf(` ORDER BY received_at DESC LIMIT $%d`, len(sampleArgs)), sampleArgs...) if err != nil { return nil, fmt.Errorf("storage: dry-run sample: %w", err) } defer rows.Close() for rows.Next() { var m RoutingDryRunMatch if err := rows.Scan(&m.ID, &m.MailFrom, &m.MailTo, &m.Subject, &m.ReceivedAt); err != nil { return nil, fmt.Errorf("storage: dry-run scan: %w", err) } res.Sample = append(res.Sample, m) } return res, rows.Err() }