---
id: PROJ-43
title: Automatische Archivierungsregeln (by Domain/Sender)
status: Deployed
created: 2026-04-05
---

## Kontext

Das SMTP Domain-Routing (PROJ-21 Phase 5) ist bereits implementiert:
- `tenant_domains`-Tabelle ordnet Domains Mandanten zu
- `resolveTenantFromRcpts()` im SMTP-Daemon weist Mails automatisch zu
- IMAP/POP3-Importer unterstützen ebenfalls TenantID

PROJ-43 **erweitert** diese Basis um flexiblere Muster-Regeln und eine GUI.

## Ziel

Admins können über die Web-Oberfläche Regeln verwalten die über einfache Domain-Zuordnung
hinausgehen — z.B. Wildcard-Domains, Absender-Adressen, Betreff-Muster.

## User Stories

- Als Admin möchte ich alle Mails von @kunde.de automatisch dem Mandanten "Kunde GmbH" zuweisen
- Als Admin möchte ich Mails an archiv@firma.de einem bestimmten Tenant zuordnen

## Namenskonflikt (Nachtrag 2026-07-03)

PROJ-51 (Retention-Kategorien) hat bereits eine Tabelle `archiving_rules` angelegt
(`internal/storage/retention_rules.go`, Spalten `condition_type`, `pattern`,
`priority`, `retention_days`) — andere Bedeutung (Aufbewahrungsfrist statt
Tenant-Zuordnung). Um Kollision zu vermeiden, heißt die neue Tabelle für PROJ-43
**`tenant_routing_rules`**, nicht `archiving_rules` wie ursprünglich benannt.

## Acceptance Criteria

- [ ] Tabelle `tenant_routing_rules (id, tenant_id, match_type [from_domain|to_domain|from_addr|to_addr], pattern, priority, created_at)`
- [ ] SMTP-Daemon und IMAP-Import prüfen Regeln nach jedem eingehenden Mail
- [ ] API: CRUD für tenant_routing_rules (Admin only)
- [ ] Frontend: Regel-Verwaltung im Admin-Bereich
- [ ] Priorität: höhere Priorität gewinnt bei mehreren Treffern
- [ ] Dry-Run: zeigt welche bestehenden Mails von einer Regel betroffen wären

## Betroffene Dateien

- `internal/storage/storage.go` bzw. neues `internal/storage/tenant_routing_rules.go` (DB-Schema + ApplyRules-Methode, analog `retention_rules.go`)
- `internal/smtpd/smtpd.go` (Regel-Check nach Save, ergänzt bestehendes `resolveTenantFromRcpts()` aus PROJ-21 Phase 5)
- `internal/imap/importer.go` (Regel-Check nach Import)
- `internal/api/rules_handlers.go` (neu)
- `src/app/admin/` (Regel-UI)

## Implementierungsnotizen (2026-07-03, Status: In Review)

Kein lokaler `go build` möglich (kein Go-Toolchain im Arbeitsverzeichnis) — Build/Tests
laufen separat auf dem Testserver.

### Neue/geänderte Dateien
- `internal/storage/tenant_routing_rules.go` (NEU) — Tabelle `tenant_routing_rules`,
  CRUD, Matching-Engine `ResolveTenantByRoutingRules()`, Dry-Run `DryRunRoutingRule()`.
  Stil analog `retention_rules.go`. Match-Typen: `from_domain`, `to_domain`,
  `from_addr`, `to_addr`. Wildcard-Domains via `*.kunde.de` (matcht Sub-Domains).
- `internal/storage/storage.go` — `initTenantRoutingRulesSchema(ctx)` in Init-Kette.
- `internal/smtpd/smtpd.go` — `resolveTenantByRules()` neu; in `resolveTenant()` als
  Stufe 0 VOR der `tenant_domains`-Logik (Regeln sind expliziter → Vorrang).
- `internal/imap/importer.go` — `storeAndIndex()`: Mail wird jetzt früh geparst,
  Regel-Match kann die Account-Default-TenantID vor dem Save überschreiben.
- `internal/api/rules_handlers.go` (NEU) — CRUD + Dry-Run, `authAdmin` (domain_admin+),
  Tenant-Scoping + IDOR-Check (`tenantAccessAllowed`) bei jedem `{id}`.
- `internal/api/server.go` — Routen registriert.

### Tabelle
`tenant_routing_rules (id, tenant_id [NOT NULL, FK tenants ON DELETE CASCADE],
match_type, pattern, priority, created_at)`. Höhere `priority` gewinnt, bei
Gleichstand niedrigste `id`.

### API-Endpunkte (alle domain_admin+; superadmin = alle Tenants, domain_admin = eigener)
- `GET  /api/admin/routing-rules` → `{ "rules": RoutingRule[] }`
- `POST /api/admin/routing-rules` Body `{tenant_id?, match_type, pattern, priority}`
  → `201 {"id": <int>}` (superadmin muss `tenant_id` setzen; domain_admin bekommt
  eigenen Tenant erzwungen)
- `PUT /api/admin/routing-rules/{id}` gleicher Body → `200 {"ok": true}`
- `DELETE /api/admin/routing-rules/{id}` → `200 {"ok": true}`
- `POST /api/admin/routing-rules/dry-run` Body entweder `{rule_id}` ODER
  `{match_type, pattern}` (+ optional `limit`, default 20, max 100) →
  `200 {match_type, pattern, match_count, sample_limit, sample: [{id, mail_from,
  mail_to, subject, received_at}]}`. Dry-Run ist ILIKE-Näherung gegen `emails`,
  LIMIT-begrenzt (kein Vollscan-Timeout); domain_admin sieht nur eigene Tenant-Mails.

RoutingRule-Shape: `{id, tenant_id, match_type, pattern, priority, created_at}`.

### Frontend (2026-07-03, Status: In Review — QA offen)
- `src/lib/api/routing_rules.ts` (NEU) — TS-Typen (`RoutingRule`, `RoutingRuleInput`,
  `RoutingMatchType`, `RoutingDryRunResult/Input/Sample`) + API-Funktionen
  `getRoutingRules`, `createRoutingRule`, `updateRoutingRule`, `deleteRoutingRule`,
  `dryRunRoutingRule`. Nutzt zentralen `request()`-Wrapper aus `core.ts`.
- `src/lib/api/index.ts` — Re-exports der neuen Typen + Funktionen ergänzt.
- `src/components/admin/tabs/RoutingRulesTab.tsx` (NEU) — Tab „Routing-Regeln":
  Tabelle (Prio, Typ, Muster, Angelegt; bei superadmin zusätzlich Tenant-Spalte),
  CRUD via Dialog (match_type-Select, pattern-Input mit typabhängigem Placeholder,
  priority-Input, bei superadmin Tenant-Auswahl-Dropdown), Löschen mit Bestätigung.
  Dry-Run-Button im Dialog ruft `dryRunRoutingRule({match_type, pattern})` und zeigt
  `match_count` + Stichproben-Tabelle. Prioritäts-Erklärung + Hinweis „keine
  rückwirkende Umroutung" als Alert. Loading/Error/Empty-States implementiert.
- `src/app/admin/page.tsx` — Tab-Trigger + `<TabsContent value="routing-rules">`
  eingebunden; Tab für alle Admin-Seiten-Besucher sichtbar (Seite ist bereits per
  `useAuth("domain_admin")` auf domain_admin+ gegated). `isSuperAdmin`-Prop steuert
  Tenant-Spalte/-Dropdown; serverseitiges Scoping bleibt maßgeblich.
- Typecheck: `npx tsc --noEmit` → sauber (Exit 0).

### Offen / Handoff
- QA gegen Acceptance Criteria (CRUD, Dry-Run, Rollen-Gate) auf Testserver.
- Bereits archivierte Mails werden NICHT rückwirkend umgeroutet (nur neue Ingests).

## QA Test Results

**Getestet:** 2026-07-04 auf Testserver 192.168.1.132 (Binary v0.9.1, deployt 2026-07-03).
Test-Accounts: `qa-superadmin` (superadmin), `qa-da-t1` (domain_admin Tenant 1),
`qa-da-t3` (domain_admin Tenant 3). **Gesamtergebnis: BESTANDEN MIT 1 BUG** (Dry-Run für
`from_addr`/`to_addr` liefert falsche Nullwerte — siehe Bug-1). CRUD, Tenant-Scoping/IDOR und
Wildcard-Matching sind grün.

### Ergebnis je Acceptance Criterion
- [x] **Tabelle `tenant_routing_rules`** — BESTANDEN. Schema mit Spalten (id, tenant_id NOT NULL
  FK, match_type, pattern, priority, created_at) vorhanden; CRUD liefert erwartete Shape.
- [x] **SMTP-Daemon + IMAP-Import prüfen Regeln** — Code-verifiziert (nicht per Live-Mail).
  `resolveTenantByRules()` in `internal/smtpd/smtpd.go:107` VOR `tenant_domains`-Logik;
  `ResolveTenantByRoutingRules()` in `internal/imap/importer.go:255`. Verdrahtung vorhanden.
  Hinweis: End-to-End-Zuordnung per echtem Mailversand nicht durchgeführt (kein Live-SMTP-Test).
- [x] **API CRUD (Admin only)** — BESTANDEN. GET/POST/PUT/DELETE unter `/api/admin/routing-rules`
  funktionieren; ohne Cookie → `401`. POST als domain_admin ohne `tenant_id` erzwingt eigenen
  Tenant (`201 {"id":5}`).
- [x] **Frontend Regel-Verwaltung** — Komponente `RoutingRulesTab.tsx` vorhanden, Datenkontrakt
  passt zur API. UI visuell nicht separat durchgeklickt.
- [x] **Priorität: höhere gewinnt** — Code-verifiziert. `ListTenantRoutingRules` sortiert
  `ORDER BY priority DESC, id ASC`; `ResolveTenantByRoutingRules` nimmt ersten Match →
  höchste Priorität, bei Gleichstand niedrigste id. Logik korrekt.
- [~] **Dry-Run** — TEILWEISE. `from_domain`/`to_domain` korrekt (perlbach24.de → 955 bzw.
  2962 Treffer, inkl. Wildcard). `from_addr`/`to_addr` liefern falsche 0-Treffer → **Bug-1**.

### Sicherheit / Tenant-Isolation / IDOR
- **Auth-Bypass:** Ohne Cookie → `401` auf allen Endpunkten. BESTANDEN.
- **IDOR Create:** domain_admin T1 versucht Regel mit `tenant_id:3` → `403
  {"error":"tenant_id required and must match your scope"}`. BESTANDEN.
- **IDOR Update:** T1 versucht PUT auf Regel id 2 (gehört Tenant 3) → `403
  {"error":"forbidden"}`, Regel unverändert. BESTANDEN.
- **IDOR Delete:** T1 versucht DELETE Regel id 2 (Tenant 3) → `403 {"error":"forbidden"}`,
  Regel bleibt bestehen. BESTANDEN.
- **Tenant-Move via Body:** T1 versucht eigene Regel id 5 per PUT `tenant_id:3` zu verschieben
  → `403 {"error":"tenant_id must match your scope"}`, Regel bleibt Tenant 1. BESTANDEN.
- **Listing-Scope:** domain_admin T1 sieht nur Tenant-1-Regeln, T3 nur Tenant-3; superadmin
  alle. Kein Cross-Tenant-Leck. BESTANDEN. (Deckt die PROJ-61-Bugklasse ab: Tenant-Scope wird
  zusätzlich zum Rollen-Check via `tenantAccessAllowed` geprüft.)
- **SQL-Injection Dry-Run:** Pattern `' OR 1=1 --` → `match_count:0`, keine Fehler, keine
  Anomalie im Log. Parametrisierte Query (`$1`), sauber. BESTANDEN.

### Wildcard-Matching
- `domainMatches()` (`tenant_routing_rules.go:217`): `*.base` matcht Sub-Domains UND die
  Apex-Domain (dokumentiert im Code-Kommentar Zeile 216). Dry-Run `*.perlbach24.de` = 955 =
  `perlbach24.de` bestätigt dieses beabsichtigte Verhalten. BESTANDEN (Spec "matcht
  Sub-Domains" wird als "Sub-Domains + Apex" umgesetzt — konsistent Engine↔Dry-Run).

### Offene Bugs
**Bug-1 — Dry-Run für `from_addr`/`to_addr` liefert falsche Nullwerte. Severity: MEDIUM.
Priorität: Hoch (Kernfunktion des AC "Dry-Run" für 2 von 4 Match-Typen unbrauchbar).**
- Repro: `POST /api/admin/routing-rules/dry-run {"match_type":"from_addr","pattern":
  "support@perlbach24.de"}` → `match_count:0`, obwohl 955 Mails exakt von dieser Adresse
  existieren (bestätigt via `SELECT ... WHERE mail_from ILIKE '%support@perlbach24.de%'`).
- Ursache: `dryRunCondition()` in `internal/storage/tenant_routing_rules.go:317-319` baut für
  `from_addr`/`to_addr` die Bedingung `LOWER(mail_from) LIKE '%<' + pattern + '>%'`, erwartet
  also die Winkelklammer-Form `Name <addr>`. Die Spalte `mail_from` speichert die Adresse aber
  bare (`support@perlbach24.de`, ohne `<>`), daher matcht das Pattern nie. Der Live-Matcher
  (`routeBareAddr`, Zeile 205) normalisiert korrekt und würde matchen → Dry-Run und echte
  Regel-Anwendung sind inkonsistent. Admin sieht "0 betroffen" und konfiguriert Adress-Regeln
  im Blindflug.
- Fix-Richtung (für Backend Developer, nicht von QA umgesetzt): Bedingung so bauen, dass beide
  Speicherformen abgedeckt sind, z.B. Match auf bare Adresse (`LOWER(mail_from) LIKE '%'||p||'%'`
  bzw. genauer gegen `@`-Grenze) statt harte `<...>`-Umklammerung.

### Testdaten-Hygiene
- Die für den Test angelegte Regel id 5 (Tenant 1) wurde nach dem Test wieder gelöscht
  (`DELETE /api/admin/routing-rules/5` → `200 {"ok":true}`). Vorbestehende Regeln id 1-4
  (aus früherem QA-Lauf) unangetastet gelassen.
- Passwörter der `qa-*`-Accounts für den Test gesetzt (siehe PROJ-52 QA-Hinweis). Kein
  Produktivserver (131) berührt.

## Deployment

**Deployt:** 2026-07-04 auf Produktivserver 192.168.1.131 via `update.sh` (Commit 4c92587,
zusammen mit PROJ-52 im selben Deploy). Enthält den QA-Bugfix "Bug-1" (Dry-Run für
`from_addr`/`to_addr` matcht jetzt bare Adressen statt der falschen `<addr>`-Form in
`internal/storage/tenant_routing_rules.go`).

- Backend ✓ läuft (`archivmail`, Health `/api/health` → `{"status":"ok","version":"0.9.1"}`)
- Frontend ✓ läuft (`archivmail-web`)
- Smoke-Test: `GET /api/admin/routing-rules` ohne Auth → `401` (Endpoint erreichbar, Auth-Gate
  aktiv). Rollen-/Funktionstest ist QA-Scope, nicht Teil dieses Deploy-Checks.
- Kein offener Handoff-Punkt für devops-deploy (Feature hat keinen eigenen Cron-Job).
