// Package mailrules implementiert IMP-03: ein Regelwerk für automatische // Zuordnung, Verschlagwortung und Klassifizierung importierter E-Mails // nach Absender, Betreff, Postfach und Anhangstyp. Kein Vorbild in // archivmail für diesen Zuschnitt — Neubau. // // Dieses Paket ist eine REINE Regelverwaltung + Auswertungsfunktion — // es persistiert selbst KEINE Klassifizierungsergebnisse und bietet // bewusst KEINE Funktion, um bestehende, bereits importierte Nachrichten // automatisch neu zu klassifizieren (Akzeptanzkriterium 3: Regel- // änderungen wirken nur auf künftige Importe). Ein Reindex bestehender // Nachrichten ist Sache eines expliziten, separaten Auftrags (z. B. // SRC-09-artig) — dieses Paket kennt diesen Mechanismus nicht. package mailrules import ( "context" _ "embed" "fmt" "regexp" "sort" "github.com/jackc/pgx/v5/pgxpool" ) //go:embed migrations/0001_mail_rules.sql var schemaMigration string // Rule ist eine Zuordnungs-/Klassifizierungsregel. *Pattern-Felder sind // leer, wenn die Dimension für diese Regel keine Rolle spielt (immer // "passend"), sonst reguläre Ausdrücke (Akzeptanzkriterium 1: Absender, // Betreff-Muster, Postfach — zusätzlich Anhangstyp aus dem Auftragstext). type Rule struct { ID int64 Name string SenderPattern string SubjectPattern string MailboxPattern string AttachmentTypePattern string Category string Tag string // Priority: NIEDRIGERE Zahl = HÖHERE Priorität (Akzeptanzkriterium 2). // Dokumentierte Anwendungsreihenfolge: Regeln werden aufsteigend nach // Priority ausgewertet; bei widersprüchlichen Category-Zuweisungen // gewinnt die zuerst ausgewertete (höchstpriorisierte) Regel — "first // match wins" für das einwertige Category-Feld. Tags sind dagegen // mehrwertig: JEDE zutreffende Regel trägt ihren Tag bei. Priority int } // EmailMetadata/Result sind in engine.go definiert. // Store verwaltet Regeln je Mandant in Postgres. type Store struct { pool *pgxpool.Pool } func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} } // EnsureSchema legt die Tabelle an, falls sie noch nicht existiert. func (s *Store) EnsureSchema(ctx context.Context) error { if _, err := s.pool.Exec(ctx, schemaMigration); err != nil { return fmt.Errorf("mailrules: schema anlegen: %w", err) } return nil } // Create legt eine neue Regel an. func (s *Store) Create(ctx context.Context, tenantSlug string, rule Rule) (int64, error) { if _, err := regexp.Compile(rule.SenderPattern); rule.SenderPattern != "" && err != nil { return 0, fmt.Errorf("mailrules: sender_pattern ungültig: %w", err) } if _, err := regexp.Compile(rule.SubjectPattern); rule.SubjectPattern != "" && err != nil { return 0, fmt.Errorf("mailrules: subject_pattern ungültig: %w", err) } if _, err := regexp.Compile(rule.MailboxPattern); rule.MailboxPattern != "" && err != nil { return 0, fmt.Errorf("mailrules: mailbox_pattern ungültig: %w", err) } if _, err := regexp.Compile(rule.AttachmentTypePattern); rule.AttachmentTypePattern != "" && err != nil { return 0, fmt.Errorf("mailrules: attachment_type_pattern ungültig: %w", err) } var id int64 err := s.pool.QueryRow(ctx, ` INSERT INTO mail_rules (tenant_slug, name, sender_pattern, subject_pattern, mailbox_pattern, attachment_type_pattern, category, tag, priority) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id `, tenantSlug, rule.Name, rule.SenderPattern, rule.SubjectPattern, rule.MailboxPattern, rule.AttachmentTypePattern, rule.Category, rule.Tag, rule.Priority).Scan(&id) if err != nil { return 0, fmt.Errorf("mailrules: regel anlegen: %w", err) } return id, nil } // List liefert alle Regeln eines Mandanten, aufsteigend nach Priority // sortiert (höchste Priorität zuerst — Akzeptanzkriterium 2). func (s *Store) List(ctx context.Context, tenantSlug string) ([]Rule, error) { rows, err := s.pool.Query(ctx, ` SELECT id, name, sender_pattern, subject_pattern, mailbox_pattern, attachment_type_pattern, category, tag, priority FROM mail_rules WHERE tenant_slug = $1 ORDER BY priority ASC, id ASC `, tenantSlug) if err != nil { return nil, fmt.Errorf("mailrules: regeln lesen: %w", err) } defer rows.Close() var rules []Rule for rows.Next() { var r Rule if err := rows.Scan(&r.ID, &r.Name, &r.SenderPattern, &r.SubjectPattern, &r.MailboxPattern, &r.AttachmentTypePattern, &r.Category, &r.Tag, &r.Priority); err != nil { return nil, fmt.Errorf("mailrules: regelzeile lesen: %w", err) } rules = append(rules, r) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("mailrules: regeln iterieren: %w", err) } sort.SliceStable(rules, func(i, j int) bool { return rules[i].Priority < rules[j].Priority }) return rules, nil } // Delete entfernt eine Regel. func (s *Store) Delete(ctx context.Context, tenantSlug string, id int64) error { if _, err := s.pool.Exec(ctx, `DELETE FROM mail_rules WHERE tenant_slug = $1 AND id = $2`, tenantSlug, id); err != nil { return fmt.Errorf("mailrules: regel löschen: %w", err) } return nil }