Files
nexarch/mail/internal/mailrules/store.go
T
sysopsandClaude Sonnet 5 089d7e6d96 IMP-03: e-mail-regeln-zuordnung-tags-klassifizierung
Regelwerk für automatische Zuordnung, Verschlagwortung und
Klassifizierung importierter E-Mails nach Absender, Betreff, Postfach
und Anhangstyp.

- store.go: Postgres-Store für Regeln (Absender-/Betreff-/Postfach-/
  Anhangstyp-Muster als reguläre Ausdrücke, Category einwertig, Tag
  mehrwertig, Priority — niedrigere Zahl = höhere Priorität).
- engine.go: Engine.Evaluate wertet Regeln in Prioritätsreihenfolge aus,
  "first match wins" für Category, alle zutreffenden Regeln tragen zu
  Tags bei. Muster werden beim Erzeugen der Engine einmal kompiliert.
- Bewusst keine Funktion zum rückwirkenden Neuklassifizieren bestehender
  Nachrichten — nur explizite RunOnce-artige Neuauswertung wirkt.

Prüfungen (alle real durchgeführt, siehe mail/docs/IMP-03-PRUEFPROTOKOLL.md):
1. TestEvaluate_ConflictingRulesRespectDocumentedPriority: höherpriorisierte
   Regel gewinnt real bei widersprüchlichen Kategorien.
2. TestNewEngine_NewRuleDoesNotAffectAlreadyCapturedResult: bereits
   erfasstes Ergebnis bleibt real unverändert nach neuer Regel.
3. TestEvaluate_TwentyPlusRulesStayPerformant: 31 Regeln, 2,64µs/Auswertung.

Kein Umbau: kein bestehendes Paket angefasst.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ
2026-09-01 00:02:56 +02:00

131 lines
5.0 KiB
Go

// 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
}