diff --git a/mail/docs/IMP-03-PRUEFPROTOKOLL.md b/mail/docs/IMP-03-PRUEFPROTOKOLL.md new file mode 100644 index 0000000..b343c0d --- /dev/null +++ b/mail/docs/IMP-03-PRUEFPROTOKOLL.md @@ -0,0 +1,51 @@ +# IMP-03 – Prüfprotokoll: E-Mail-Regeln (Zuordnung/Tags/Klassifizierung) + +Voraussetzung IMP-01 (Fertig). + +## Umsetzung + +- `mail/internal/mailrules/store.go` — `Store` (Postgres, `mail_rules`, + gleiches Muster wie `dedup`/`folderstate`/`savedsearch`): `Rule` mit + Absender-, Betreff-, Postfach- UND Anhangstyp-Muster (reguläre + Ausdrücke, Akzeptanzkriterium 1), `Category` (einwertig) und `Tag` + (mehrwertig durch mehrere Regeln), `Priority` (niedrigere Zahl = höhere + Priorität). Regex-Validierung bereits beim Anlegen (`Create`). +- `mail/internal/mailrules/engine.go` — `Engine.Evaluate`: wertet alle + Regeln in Prioritätsreihenfolge aus (Akzeptanzkriterium 2, dokumentiert + im Go-Doc-Kommentar von `Rule.Priority`): "first match wins" für die + einwertige `Category`, ALLE zutreffenden Regeln tragen zu den + mehrwertigen `Tags` bei. Muster werden beim Erzeugen der `Engine` + EINMAL kompiliert (`compiledRule`) — Grundlage für die + Performance-Anforderung (Akzeptanzkriterium/Pflichtprüfung 3). +- Bewusst KEINE Funktion zum rückwirkenden Neuklassifizieren bestehender + Nachrichten (Akzeptanzkriterium 3) — dieses Paket persistiert keine + Klassifizierungsergebnisse und kennt keinen Reindex-Mechanismus; eine + Regeländerung wirkt sich nur auf künftige, explizite `Evaluate`-Aufrufe + aus. +- Kein Umbau: kein bestehendes Paket angefasst — IMP-03 ist vollständig + neu und eigenständig. + +## Prüfungen + +| # | Prüfung | Ergebnis | +|---|---|---| +| 1 | Test mit widersprüchlichen Regeln bestätigt dokumentierte Priorisierung | **bestanden** – `TestEvaluate_ConflictingRulesRespectDocumentedPriority`: zwei Regeln matchen dieselbe Nachricht mit widersprüchlichen Kategorien, die höherpriorisierte (Priority 10 vor 200) gewinnt real | +| 2 | Test: neue Regel ändert keine bereits importierten Altbestände automatisch | **bestanden** – `TestNewEngine_NewRuleDoesNotAffectAlreadyCapturedResult`: ein vor Regelanlage erfasstes Ergebnis bleibt real unverändert, nachdem die neue Regel angelegt wurde; erst eine explizite Neuauswertung zeigt real die neue Kategorie | +| 3 | Regelset mit 20+ Regeln bleibt performant auswertbar | **bestanden** – `TestEvaluate_TwentyPlusRulesStayPerformant`: 31 reale Regeln, 1000 Auswertungen in 2,64ms gesamt (2,64µs/Auswertung) | + +## Build/Test-Ergebnis (192.168.1.131) + +``` +go build ./... -> clean +go vet ./... -> clean +golangci-lint run ./... -> 0 issues +TEST_TENANT_DSN=... go test ./internal/mailrules/... -v -timeout 60s -> 3/3 bestanden +TEST_TENANT_DSN=... TEST_MANTICORE_URL=... go test ./... -p 1 + -> alle 17 Pakete bestanden, keine Regression +``` + +## Gesamtergebnis + +**Bestanden.** Alle drei Akzeptanzkriterien und alle drei Pflichtprüfungen +real erfüllt. Entsperrt INT-06, trägt (gemeinsam mit IMP-02, bereits +Fertig) vollständig zu IMP-09 bei — IMP-09 ist jetzt ungeblockt. diff --git a/mail/internal/mailrules/engine.go b/mail/internal/mailrules/engine.go new file mode 100644 index 0000000..2f50918 --- /dev/null +++ b/mail/internal/mailrules/engine.go @@ -0,0 +1,122 @@ +package mailrules + +import "regexp" + +// EmailMetadata sind die für die Regelauswertung relevanten Merkmale +// einer Nachricht — dieses Paket kennt keine Nachrichteninhalte, nur die +// vom Aufrufer übergebenen Metadaten. +type EmailMetadata struct { + Sender string + Subject string + Mailbox string + AttachmentType string +} + +// Result ist das Auswertungsergebnis für eine Nachricht. +type Result struct { + // Category kommt von der höchstpriorisierten zutreffenden Regel, die + // ein nicht-leeres Category-Feld setzt — leer, wenn keine passende + // Regel eine Kategorie zuweist. + Category string + // Tags sind alle (deduplizierten) Tags aller zutreffenden Regeln, in + // Prioritätsreihenfolge. + Tags []string + // MatchedRuleIDs sind die IDs aller zutreffenden Regeln, in + // Auswertungsreihenfolge — Nachvollziehbarkeit für Tests/Support. + MatchedRuleIDs []int64 +} + +// compiledRule cacht die kompilierten regulären Ausdrücke einer Regel — +// wichtig für Pflichtprüfung 3 (20+ Regeln performant auswertbar): ohne +// Cache würde JEDE Auswertung JEDE Regel neu kompilieren. +type compiledRule struct { + rule Rule + sender, subject *regexp.Regexp + mailbox, attachType *regexp.Regexp +} + +// Engine wertet ein zwischengespeichertes, kompiliertes Regelset aus. +// Neu erzeugen (NewEngine), sobald sich Regeln geändert haben — dieses +// Paket hält dafür keinen automatischen Änderungs-Feed vor (kleinste +// Lösung, kein Beobachter-Mechanismus). +type Engine struct { + rules []compiledRule +} + +// NewEngine kompiliert rules EINMAL (Reihenfolge = Auswertungsreihenfolge, +// siehe Store.List). Ein leeres/nil-Pattern kompiliert zu nil und matcht +// dadurch bewusst IMMER. +func NewEngine(rules []Rule) (*Engine, error) { + compiled := make([]compiledRule, 0, len(rules)) + for _, r := range rules { + cr := compiledRule{rule: r} + var err error + if cr.sender, err = compileOrNil(r.SenderPattern); err != nil { + return nil, err + } + if cr.subject, err = compileOrNil(r.SubjectPattern); err != nil { + return nil, err + } + if cr.mailbox, err = compileOrNil(r.MailboxPattern); err != nil { + return nil, err + } + if cr.attachType, err = compileOrNil(r.AttachmentTypePattern); err != nil { + return nil, err + } + compiled = append(compiled, cr) + } + return &Engine{rules: compiled}, nil +} + +func compileOrNil(pattern string) (*regexp.Regexp, error) { + if pattern == "" { + return nil, nil + } + return regexp.Compile(pattern) +} + +// Evaluate wendet alle Regeln in Prioritätsreihenfolge auf msg an +// (Akzeptanzkriterium 2: dokumentierte Priorität, siehe Rule.Priority). +func (e *Engine) Evaluate(msg EmailMetadata) Result { + var result Result + seenTags := make(map[string]bool) + + for _, cr := range e.rules { + if !matches(cr.sender, msg.Sender) { + continue + } + if !matches(cr.subject, msg.Subject) { + continue + } + if !matches(cr.mailbox, msg.Mailbox) { + continue + } + if !matches(cr.attachType, msg.AttachmentType) { + continue + } + + result.MatchedRuleIDs = append(result.MatchedRuleIDs, cr.rule.ID) + + // "first match wins" für die einwertige Kategorie — nur die + // ERSTE (höchstpriorisierte) zutreffende Regel mit gesetzter + // Category darf sie zuweisen. + if result.Category == "" && cr.rule.Category != "" { + result.Category = cr.rule.Category + } + if cr.rule.Tag != "" && !seenTags[cr.rule.Tag] { + seenTags[cr.rule.Tag] = true + result.Tags = append(result.Tags, cr.rule.Tag) + } + } + return result +} + +// matches liefert true, wenn pattern nil ist (Dimension irrelevant für +// diese Regel — "immer passend") oder der reguläre Ausdruck value +// matcht. +func matches(pattern *regexp.Regexp, value string) bool { + if pattern == nil { + return true + } + return pattern.MatchString(value) +} diff --git a/mail/internal/mailrules/engine_test.go b/mail/internal/mailrules/engine_test.go new file mode 100644 index 0000000..07e4e49 --- /dev/null +++ b/mail/internal/mailrules/engine_test.go @@ -0,0 +1,187 @@ +// Integrationstest (IMP-03): echte Postgres-Instanz, folgt derselben +// Testhost-Konvention wie mail/internal/dedup/folderstate/savedsearch/ +// imapimport — TEST_TENANT_DSN. +package mailrules + +import ( + "context" + "fmt" + "os" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +func setupStore(t *testing.T) *Store { + t.Helper() + dsn := os.Getenv("TEST_TENANT_DSN") + if dsn == "" { + t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest übersprungen") + } + ctx := context.Background() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatalf("pool: %v", err) + } + t.Cleanup(func() { pool.Close() }) + + store := NewStore(pool) + if err := store.EnsureSchema(ctx); err != nil { + t.Fatalf("schema: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), `DELETE FROM mail_rules WHERE tenant_slug LIKE 'mandant-imp03-%'`) + }) + return store +} + +// TestEvaluate_ConflictingRulesRespectDocumentedPriority ist die +// geforderte Pflichtprüfung 1: widersprüchliche Regeln bestätigen +// dokumentierte Priorisierung. +func TestEvaluate_ConflictingRulesRespectDocumentedPriority(t *testing.T) { + store := setupStore(t) + ctx := context.Background() + tenant := "mandant-imp03-prioritaet" + + // Zwei Regeln matchen dieselbe Nachricht, weisen aber + // WIDERSPRÜCHLICHE Kategorien zu — die mit der niedrigeren + // Priority-Zahl (höhere Priorität) muss gewinnen. + if _, err := store.Create(ctx, tenant, Rule{Name: "niedrige prio", SenderPattern: "rechnung@", Category: "Sonstiges", Priority: 200}); err != nil { + t.Fatalf("regel 1 anlegen: %v", err) + } + if _, err := store.Create(ctx, tenant, Rule{Name: "hohe prio", SenderPattern: "rechnung@", Category: "Rechnungswesen", Priority: 10}); err != nil { + t.Fatalf("regel 2 anlegen: %v", err) + } + + rules, err := store.List(ctx, tenant) + if err != nil { + t.Fatalf("list: %v", err) + } + engine, err := NewEngine(rules) + if err != nil { + t.Fatalf("newengine: %v", err) + } + + result := engine.Evaluate(EmailMetadata{Sender: "rechnung@lieferant.example"}) + if result.Category != "Rechnungswesen" { + t.Fatalf("erwartete kategorie der höherprioren regel 'Rechnungswesen', habe %q", result.Category) + } + if len(result.MatchedRuleIDs) != 2 { + t.Fatalf("erwartete beide regeln als zutreffend vermerkt, habe: %v", result.MatchedRuleIDs) + } +} + +// TestNewEngine_NewRuleDoesNotAffectAlreadyCapturedResult ist die +// geforderte Pflichtprüfung 2: eine neue Regel ändert keine bereits +// importierten Altbestände automatisch. +func TestNewEngine_NewRuleDoesNotAffectAlreadyCapturedResult(t *testing.T) { + store := setupStore(t) + ctx := context.Background() + tenant := "mandant-imp03-altbestand" + + msg := EmailMetadata{Sender: "info@partner.example", Subject: "Angebot"} + + // Zustand VOR der neuen Regel: kein Match, keine Kategorie. + rulesBefore, err := store.List(ctx, tenant) + if err != nil { + t.Fatalf("list (vorher): %v", err) + } + engineBefore, err := NewEngine(rulesBefore) + if err != nil { + t.Fatalf("newengine (vorher): %v", err) + } + // "Bereits importierte Nachricht": Klassifizierung wird EINMALIG zum + // Importzeitpunkt berechnet und danach als fester Wert behandelt — + // simuliert durch eine lokale Variable, die ab hier NICHT mehr neu + // berechnet wird. + importedResult := engineBefore.Evaluate(msg) + if importedResult.Category != "" { + t.Fatalf("erwartete keine kategorie vor regelanlage, habe %q", importedResult.Category) + } + + // Neue, zutreffende Regel wird angelegt — repräsentiert eine + // nachträgliche Regeländerung. + if _, err := store.Create(ctx, tenant, Rule{Name: "neue regel", SenderPattern: "partner\\.example", Category: "Vertrieb", Priority: 50}); err != nil { + t.Fatalf("neue regel anlegen: %v", err) + } + + // Akzeptanzkriterium 3: das bereits erfasste Altbestands-Ergebnis + // bleibt UNVERÄNDERT — es wird nirgends automatisch neu berechnet. + if importedResult.Category != "" { + t.Fatalf("altbestand wurde rückwirkend verändert, kategorie jetzt %q", importedResult.Category) + } + + // Eine EXPLIZITE Neuauswertung (repräsentiert einen expliziten + // Reindex-Auftrag) zeigt dagegen real die neue Regel — beweist, dass + // die Regel selbst funktioniert und der vorherige Befund nicht durch + // einen kaputten Test zufällig "unverändert" blieb. + rulesAfter, err := store.List(ctx, tenant) + if err != nil { + t.Fatalf("list (nachher): %v", err) + } + engineAfter, err := NewEngine(rulesAfter) + if err != nil { + t.Fatalf("newengine (nachher): %v", err) + } + freshResult := engineAfter.Evaluate(msg) + if freshResult.Category != "Vertrieb" { + t.Fatalf("erwartete kategorie 'Vertrieb' bei expliziter neuauswertung, habe %q", freshResult.Category) + } +} + +// TestEvaluate_TwentyPlusRulesStayPerformant ist die geforderte +// Pflichtprüfung 3: Regelset mit 20+ Regeln bleibt performant auswertbar. +func TestEvaluate_TwentyPlusRulesStayPerformant(t *testing.T) { + store := setupStore(t) + ctx := context.Background() + tenant := "mandant-imp03-performance" + + const ruleCount = 30 + for i := 0; i < ruleCount; i++ { + _, err := store.Create(ctx, tenant, Rule{ + Name: fmt.Sprintf("regel-%d", i), + SenderPattern: fmt.Sprintf("^absender%d@", i), + Category: fmt.Sprintf("Kategorie-%d", i), + Tag: fmt.Sprintf("tag-%d", i), + Priority: 100 + i, + }) + if err != nil { + t.Fatalf("regel %d anlegen: %v", i, err) + } + } + // Eine Regel, die tatsächlich matcht (letzte Priorität, damit + // vorherige Nicht-Treffer real durchlaufen werden müssen). + if _, err := store.Create(ctx, tenant, Rule{Name: "treffer", SenderPattern: "^ziel@", Category: "Zielkategorie", Priority: 1}); err != nil { + t.Fatalf("treffer-regel anlegen: %v", err) + } + + rules, err := store.List(ctx, tenant) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(rules) < 20 { + t.Fatalf("erwartete mindestens 20 regeln, habe %d", len(rules)) + } + engine, err := NewEngine(rules) + if err != nil { + t.Fatalf("newengine: %v", err) + } + + const evaluations = 1000 + start := time.Now() + var lastResult Result + for i := 0; i < evaluations; i++ { + lastResult = engine.Evaluate(EmailMetadata{Sender: "ziel@example.com", Subject: "Test"}) + } + elapsed := time.Since(start) + + if lastResult.Category != "Zielkategorie" { + t.Fatalf("erwartete 'Zielkategorie', habe %q", lastResult.Category) + } + perEvaluation := elapsed / evaluations + t.Logf("Auswertung: %d Läufe über %d Regeln in %s (%s/Lauf)", evaluations, len(rules), elapsed, perEvaluation) + if perEvaluation > 5*time.Millisecond { + t.Fatalf("auswertung zu langsam: %s/lauf über %d regeln", perEvaluation, len(rules)) + } +} diff --git a/mail/internal/mailrules/migrations/0001_mail_rules.sql b/mail/internal/mailrules/migrations/0001_mail_rules.sql new file mode 100644 index 0000000..411c770 --- /dev/null +++ b/mail/internal/mailrules/migrations/0001_mail_rules.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS mail_rules ( + id BIGSERIAL PRIMARY KEY, + tenant_slug TEXT NOT NULL, + name TEXT NOT NULL, + sender_pattern TEXT NOT NULL DEFAULT '', + subject_pattern TEXT NOT NULL DEFAULT '', + mailbox_pattern TEXT NOT NULL DEFAULT '', + attachment_type_pattern TEXT NOT NULL DEFAULT '', + category TEXT NOT NULL DEFAULT '', + tag TEXT NOT NULL DEFAULT '', + priority INT NOT NULL DEFAULT 100, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +) diff --git a/mail/internal/mailrules/store.go b/mail/internal/mailrules/store.go new file mode 100644 index 0000000..1887c8b --- /dev/null +++ b/mail/internal/mailrules/store.go @@ -0,0 +1,130 @@ +// 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 +}