mailrules.Store (IMP-03) bekommt Update (bislang nur Create/List/
Delete) — gleiches Muster wie Create: Musterprüfung vor dem Schreiben,
streng auf tenant_slug+id beschränkt, ErrNotFound bei fremder/nicht
existierender ID.
Neues Paket mail/internal/mailrulesapi: vier Endpunkte (GET/POST
/api/v1/mail/rules, PUT/DELETE /api/v1/mail/rules/{id}), tenant-Query-
Parameter Pflicht, gleiche Konvention wie mailapi (INT-01).
Akzeptanzkriterium 3 ist strukturell garantiert: mailrulesapi ruft
ausschließlich mailrules.Store auf, denselben Store, den IMP-03s
Import-Pfad ohnehin verwendet — kein zweiter, paralleler Schreibpfad.
Alle drei Pflichtprüfungen mit echten Nachweisen: vollständiger
Anlegen/Priorisieren/Einsehen/Löschen-Zyklus über echte HTTP-Requests;
eine über die API angelegte Regel wird über genau den Weg gelesen und
ausgewertet, den IMP-03s Import-Pfad geht (Store.List ->
mailrules.NewEngine -> Evaluate) und liefert das korrekte
Klassifizierungsergebnis; Mandant Bs Update-Versuch mit der echten,
bekannten ID von Mandant As Regel liefert 404, Mandant As Regel bleibt
unverändert.
go build/go vet/golangci-lint clean, gesamtes Mail-Modul
regressionsfrei getestet — bestehende mailrules-Tests (IMP-03/IMP-09)
bleiben nach der Update-Erweiterung unverändert grün.
173 lines
7.0 KiB
Go
173 lines
7.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"
|
|
"errors"
|
|
"fmt"
|
|
"regexp"
|
|
"sort"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
//go:embed migrations/0001_mail_rules.sql
|
|
var schemaMigration string
|
|
|
|
// ErrNotFound wird geliefert, wenn keine Regel mit den angegebenen
|
|
// Bezugsdaten (tenantSlug+id) existiert.
|
|
var ErrNotFound = errors.New("mailrules: regel nicht gefunden")
|
|
|
|
// 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
|
|
}
|
|
|
|
// Update ersetzt eine bestehende Regel vollständig (INT-06
|
|
// Akzeptanzkriterium 1: Regeln lassen sich über die API ändern,
|
|
// Akzeptanzkriterium 2: Priorität ist darüber änderbar — Priority ist
|
|
// ein normales Feld von rule wie jedes andere). Wie Create werden
|
|
// Muster vor dem Schreiben validiert. Streng auf tenantSlug+id
|
|
// beschränkt — ErrNotFound, wenn keine Zeile dieses Mandanten diese ID
|
|
// hat (auch wenn die ID bei einem ANDEREN Mandanten existiert).
|
|
func (s *Store) Update(ctx context.Context, tenantSlug string, id int64, rule Rule) error {
|
|
if _, err := regexp.Compile(rule.SenderPattern); rule.SenderPattern != "" && err != nil {
|
|
return fmt.Errorf("mailrules: sender_pattern ungültig: %w", err)
|
|
}
|
|
if _, err := regexp.Compile(rule.SubjectPattern); rule.SubjectPattern != "" && err != nil {
|
|
return fmt.Errorf("mailrules: subject_pattern ungültig: %w", err)
|
|
}
|
|
if _, err := regexp.Compile(rule.MailboxPattern); rule.MailboxPattern != "" && err != nil {
|
|
return fmt.Errorf("mailrules: mailbox_pattern ungültig: %w", err)
|
|
}
|
|
if _, err := regexp.Compile(rule.AttachmentTypePattern); rule.AttachmentTypePattern != "" && err != nil {
|
|
return fmt.Errorf("mailrules: attachment_type_pattern ungültig: %w", err)
|
|
}
|
|
|
|
tag, err := s.pool.Exec(ctx, `
|
|
UPDATE mail_rules
|
|
SET name = $3, sender_pattern = $4, subject_pattern = $5, mailbox_pattern = $6,
|
|
attachment_type_pattern = $7, category = $8, tag = $9, priority = $10
|
|
WHERE tenant_slug = $1 AND id = $2
|
|
`, tenantSlug, id, rule.Name, rule.SenderPattern, rule.SubjectPattern, rule.MailboxPattern,
|
|
rule.AttachmentTypePattern, rule.Category, rule.Tag, rule.Priority)
|
|
if err != nil {
|
|
return fmt.Errorf("mailrules: regel aktualisieren: %w", err)
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return 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
|
|
}
|