# Mail Custom Keywords — Architektur-Plan

**Status:** Plan (kein Code, kein Schema-Commit)
**Datum:** 2026-05-13
**Autor:** Mo (Mail-Architektur-Agent)
**Scope:** Pro + Legend User

---

## 1. Use-Case + Motivation

Rebreak filtert Gambling-Mails heute mit zwei Mechanismen:

1. `GAMBLING_KEYWORDS` — statische Liste in `server/utils/gambling-keywords.mjs` (single-source-of-truth)
2. Domain-Blocklist — global (Pro/Legend) oder kuratierter Stub (Free)

Der blinde Fleck: personalisierte Gambling-Kommunikation. Ein Anbieter der User persönlich mit Promotions wie "Dein VIP-Bonus wartet, Chahine" oder "Tipico Oktoberfest-Wette exklusiv" anschreibt, umgeht eine generische Keyword-Liste. User kennt seinen eigenen Spam-Pattern besser als wir.

Custom Keywords sind kein Convenience-Feature — sie sind eine direkte Antwort auf Sucht-Psychologie: Anbieter personalisieren aggressiv. Der User bekommt damit ein Werkzeug zurück, das auf seine konkrete Situation zugeschnitten ist.

**Feature-Gate:** Pro + Legend. Free bleibt bei statischer Liste (Motivation zum Upgrade).

---

## 2. Architektur-Vorschlag

### 2.1 Aktueller Filter-Pfad (Ist-Zustand)

```
IMAP EXISTS-Event
      |
      v
imap-idle/index.mjs
  triggerScan(conn) → POST /api/mail/scan-internal
      |
      v
scan-internal.post.ts
  fetchAll(envelope)         ← nur Header, kein Body
  haystack = senderEmail + subject
  GAMBLING_KEYWORDS.some(kw => haystack.includes(kw))  ← statisch
  blockedDomainSet.has(senderDomain)                   ← DB-Lookup
      |
      v
  messageDelete + insertMailBlocked
```

Wichtig: Der Daemon (`imap-idle`) triggert nur `scan-internal`. Die eigentliche Matching-Logik liegt komplett in `scan-internal.post.ts` (und identisch in `scan.post.ts`). Der Daemon selbst macht kein Matching.

### 2.2 Ziel-Architektur (Soll-Zustand)

```
IMAP EXISTS-Event
      |
      v
imap-idle/index.mjs
  triggerScan(conn) → POST /api/mail/scan-internal
      |
      v
scan-internal.post.ts
  fetchAll(envelope)         ← weiterhin nur Header (kein Body-Fetch)
  haystack = senderEmail + subject
  [1] GAMBLING_KEYWORDS.some(kw => haystack.includes(kw))   ← statisch, wie bisher
  [2] customKeywords.some(kw => haystack.includes(kw))       ← NEU: user-spezifisch
  [3] blockedDomainSet.has(senderDomain)                     ← wie bisher
      |
      v
  messageDelete + insertMailBlocked (action="deleted_custom_keyword" wenn [2] matched)
```

Der Custom-Keyword-Match erfolgt **im selben Scan-Loop** wie der statische Match. Kein separater IMAP-Fetch, kein extra Netzwerk-Hop. Die Keywords werden **einmalig pro Scan-Call** aus der DB geladen (nicht pro Mail).

### 2.3 Keyword-Laden: Wann und Wie

```
pro Scan-Call (nicht pro Mail):
  getUserCustomKeywords(userId) → string[] aus DB
  compiledRegex = buildKeywordRegex(keywords)  ← einmal pro Scan

pro Mail im Loop:
  customMatch = compiledRegex.test(haystack)   ← regex.test() ist O(n) auf haystack-length
```

Die Keywords werden als **kompiliertes regex-OR** ausgeführt, nicht als `.some()` + `.includes()` Chain. Das ist relevant sobald ein User 10+ Keywords hat.

---

## 3. Schema-Spec

### 3.1 Neue Tabelle: `user_mail_keywords`

Separate Tabelle, keine Spalte in `mail_connections`. Begründung:

- Keywords sind user-scoped, nicht connection-scoped. Ein User mit drei Mail-Accounts (Legend) will dasselbe Keyword gegen alle drei prüfen.
- Erleichtert Downgrade-Handling: Tabelle bleibt befüllt, wird nur ignoriert.
- Saubere DB-Normalisierung.

```
Tabelle: rebreak.user_mail_keywords

id            UUID  PK default(uuid())
userId        UUID  FK → rebreak.profiles(id) ON DELETE CASCADE
keyword       TEXT  NOT NULL
matchScope    TEXT  NOT NULL  -- 'subject_sender' | 'body'   (siehe 3.2)
createdAt     TIMESTAMP NOT NULL DEFAULT NOW()

INDEX: (userId)
UNIQUE: (userId, keyword)  -- kein Duplikat pro User
```

**Kein `caseSensitive`-Flag.** Matching ist immer case-insensitive (`.toLowerCase()` auf beiden Seiten). Deutsche Umlaute: JavaScript `.toLowerCase()` behandelt ä/ö/ü/ß korrekt in V8, kein Extra-Handling nötig.

**Kein `matchType` mit subject/sender/body einzeln wählbar.** Stattdessen zwei Scopes (Details in 3.2).

### 3.2 matchScope statt matchType

Ursprüngliche Idee: User wählt ob subject, sender oder body gematcht wird. Problem: drei getrennte Felder erhöhen UI-Komplexität deutlich und body-Match braucht separaten IMAP-FETCH (teuer). Empfehlung: zwei Scopes.

| Scope | Was wird gematcht | IMAP-Fetch nötig |
|---|---|---|
| `subject_sender` | Subject + Sender-Email + Sender-Name | Nein (envelope reicht) |
| `body` | Gesamter Mail-Body (text/plain) | Ja (separater FETCH TEXT) |

`body` ist **Legend-only**. Pro bekommt nur `subject_sender`. Begründung: Body-Fetch pro Mail erhöht IMAP-Traffic und Latenz signifikant (Details in Abschnitt 6).

Default-Scope wenn User nichts angibt: `subject_sender`.

### 3.3 Limits pro Plan

| Plan | Max Keywords | Scopes verfügbar |
|---|---|---|
| free | 0 (Feature gesperrt) | — |
| pro | 10 | `subject_sender` only |
| legend | 50 | `subject_sender` + `body` |

Die Limits kommen als neue Felder in `PLAN_LIMITS` in `plan-features.ts`:

```
customKeywords: number         // 0 | 10 | 50
customKeywordBodyMatch: boolean // false | false | true
```

**Eskalation an rebreak-backend:** Schema-Migration für `user_mail_keywords` + neuer Index. PLAN_LIMITS-Erweiterung in `plan-features.ts` ist eigenständig, kein Schema-Change.

---

## 4. Tier-Gating

### 4.1 Wo wird gegated

**Doppelt gegated:**

1. **Endpoint `POST /api/mail/keywords`** (neu, anlegen/ändern/löschen von Keywords): Prüft Plan beim Schreiben. Free bekommt 403 mit `error: "plan_limit"`. Pro darf maximal 10 Keywords anlegen, Legend 50.

2. **`scan-internal.post.ts` + `scan.post.ts`**: Lädt Keywords nur wenn `limits.customKeywords > 0`. Bei Free: `getUserCustomKeywords()` wird gar nicht aufgerufen — kein unnötiger DB-Round-Trip.

Der IDLE-Daemon selbst (`imap-idle/index.mjs`) macht keinen Tier-Check — er triggert nur den Scan. Der Tier-Check bleibt in `scan-internal.post.ts`, analog zum bestehenden `includeGlobal`-Pattern.

### 4.2 Downgrade-Handling

**Keywords werden nicht gelöscht bei Downgrade.** Sie werden pausiert durch den Plan-Check beim Scan. Konkret: `getUserCustomKeywords()` gibt bei Free immer `[]` zurück (early-return wenn `limits.customKeywords === 0`). Die Rows in `user_mail_keywords` bleiben erhalten.

Bei Re-Upgrade auf Pro/Legend sind Keywords sofort wieder aktiv — ohne dass User sie neu eingeben muss. Das ist UX-kritisch: User der downgraded wegen Kosten und dann upgraded will nicht neu konfigurieren.

Wenn Pro-User mehr als 10 Keywords hatte und auf Legend upgraded: alle Keywords aktiv (50er-Limit greift). Umgekehrt (Legend → Pro): nur die ersten 10 nach `createdAt ASC` werden genutzt, der Rest ruht. Im UI deutlich kommunizieren welche Keywords aktiv sind.

### 4.3 Plan-Check beim Schreiben

```
POST /api/mail/keywords  (Keyword anlegen)
  → getPlanLimits(profile.plan)
  → if limits.customKeywords === 0: 403 plan_limit
  → count existing keywords für userId
  → if count >= limits.customKeywords: 403 keyword_limit
  → INSERT
```

---

## 5. UX-Anforderungen (für rebreak-native-ui)

Diese Section ist für den UI-Agent (`rebreak-ui`) — nicht für Mo's Scope. Hier nur die Spezifikation.

### 5.1 Platzierung

Keywords-Management gehört in die Mail-Settings, als eigener Bereich unterhalb der Account-Cards. Kein separater Menü-Punkt. Begründung: User kommt in Mail-Tab wenn er Mail-Schutz konfigurieren will — Keywords sind Teil desselben mentalen Modells.

Vorschlag Hierarchie:
```
Mail-Tab
  └── [Mail-Accounts] (bestehend)
  └── [Keyword-Filter] (neu, Pro/Legend-Badge)
        └── Liste der aktiven Keywords (Tags)
        └── "+ Keyword hinzufügen" Button
```

### 5.2 Eingabe-Pattern

Tag-Input-Muster: User gibt Text ein, tippt "Hinzufügen" oder Return, Keyword erscheint als Tag in der Liste. Kein Freitext-Textarea. Jedes Keyword einzeln editierbar/löschbar.

**Kein matchScope-Picker für Pro-User** (haben nur `subject_sender` sowieso). Bei Legend: optionaler Toggle "Auch Mail-Body durchsuchen" pro Keyword.

### 5.3 Validation im UI (vor API-Call)

- Min-Länge: 4 Zeichen (False-Positive-Schutz, Details in Abschnitt 7)
- Max-Länge: 100 Zeichen
- Keine Sonderzeichen die regex-Syntax brechen (escapen auf Server-Seite, UI gibt Warnung)
- Duplikate: Client-seitiger Check gegen existierende Tags

### 5.4 Feedback wenn Keyword Treffer erzielt

Im Activity-Log (bestehende `MailActivityLog`-Komponente) taucht eine gelöschte Mail mit Reason "Dein Keyword: Tipico Bonus" auf, nicht nur "Gambling-Erkennung". User sieht welche seiner Keywords greifen. Das ist motivational — User merkt dass das Feature funktioniert.

Dafür: `action`-Feld in `MailBlocked` bekommt einen neuen Wert `"deleted_custom_keyword"` plus ein optionales `matchedKeyword`-Feld (max 100 Zeichen). Eskalation an rebreak-backend für Schema-Erweiterung von `MailBlocked`.

---

## 6. DSGVO-Aspekte

### 6.1 Klassifikation der Keywords

User-eingegebene Keywords ("Tipico Konto", "Wiesn-Wette") sind selbsteingegebene Daten des Users zu eigenem Schutz. Das ist Art. 6 Abs. 1 lit. b DSGVO (Vertrag) — kein separater DSB-Review nötig. Keine Art. 9-Klassifikation (keine Gesundheitsdaten, nur selbstgewählte Filterbegriffe).

**Einschränkung:** Keywords können indirekt sensitive Informationen enthalten ("Mein Konto bei Lottoland" als Keyword). Das ist vertretbar: User gibt diese Daten freiwillig und zu eigenem Zweck ein. Datenminimierung ist gegeben — wir speichern nur was User eingibt.

### 6.2 Verschlüsselung at-rest

Keywords werden **nicht** AES-verschlüsselt gespeichert. Begründung: Keywords sind keine Credentials. Sie sind konfigurierter Filter-Input. AES-Verschlüsselung würde DB-Level-Queries (LIKE, Index-Nutzung) unmöglich machen und Mehrwert ist minimal bei selbsteingegebenen Filterwörtern.

Falls Hans-Müller (DSB) anderes bewertet: Encryption möglich auf Spaltenebene, aber dann kein DB-Index mehr auf `keyword` — Performance-Implikation.

### 6.3 Audit-Log

Kein separates Audit-Log für Keyword-Änderungen. `createdAt` genügt für Datenminimierung. Änderungen (Delete + Re-Insert) hinterlassen keinen History-Trail — das ist DSGVO-konform (weniger ist mehr).

### 6.4 Datenlöschung

Bei Account-Löschung: `ON DELETE CASCADE` über `userId` FK. Keine manuelle Cleanup-Logik nötig.

**Eskalation an hans-mueller:** DSB-Review für `matchedKeyword`-Feld in `MailBlocked` (speichern wir das Keyword das zur Löschung geführt hat). Das könnte als Log personenbezogener Filter-Entscheidungen gewertet werden. Alternativ: `matchedKeyword` weglassen, nur `action="deleted_custom_keyword"` ohne welches Keyword matched hat. Letzteres ist sicherer.

---

## 7. Performance + False-Positive-Risiken

### 7.1 Performance-Analyse

**Normaler Fall (Pro, 10 Keywords, subject_sender):**

```
Pro Scan-Call:
  getUserCustomKeywords() → 1 DB-Query, ~1ms, gibt 10 Strings zurück
  buildKeywordRegex(10 keywords) → kompiliert zu /keyword1|keyword2|.../i
                                   einmal pro Scan-Call, nicht pro Mail

Pro Mail im Loop:
  regex.test(haystack) → O(len(haystack)) ≈ O(200 Zeichen) → < 0.01ms
```

Selbst bei 200 Mails pro Scan (SCAN_LIMIT): 200 × 0.01ms = 2ms. Nicht messbar. Kein Performance-Problem für `subject_sender`.

**Legend, 50 Keywords, body-Match:**

Body-Match ist fundamental anders. Der aktuelle IMAP-Fetch lädt nur `envelope` (Header). Für Body braucht es:

```
await imap.fetchAll(range, { envelope: true, bodyParts: ['TEXT'] })
```

Das erhöht:
- IMAP-Traffic: Body kann 10–500 KB pro Mail sein
- Latenz: Provider drosseln große FETCH-Requests (Gmail: ~10 concurrent)
- Memory: 200 Mails × ∅ 50 KB = 10 MB pro Scan-Session

**Empfehlung für body-Match:** Nicht im Batch-Fetch. Stattdessen: erst `envelope`-Scan wie bisher. Wenn envelope-Match (statisch oder subject_sender-keyword) NICHT anschlägt UND User hat body-keywords: dann lazy FETCH TEXT für diese Mail einzeln, `imap.fetchOne()`. Das hält den Common-Path (header-only) schnell.

Alternativ: body-scope aus MVP raushalten, erst in Phase 2. Der subject_sender-scope deckt 90% der Fälle ab — Gambling-Anbieter setzen das Wichtigste ins Subject.

### 7.2 False-Positive-Risiken

**Kernproblem:** Substring-Match ist breit. "spiel" matcht "Spielzeug-Newsletter", "wettkampf" matcht nicht (Whitelist), aber "sport" würde "Sport Bild" treffen.

**Maßnahmen:**

| Maßnahme | Umsetzung | Schutz-Level |
|---|---|---|
| Min-Länge 4 Zeichen | Server-Validation beim Anlegen | Blockt "ca", "bet" als Standalone |
| Existierende GAMBLING_WHITELIST | Wird vor custom-keyword-check angewandt | Schützt "wetter", "wettkampf" etc. |
| User trägt Verantwortung | Terms of Service + UI-Hinweis | Verhaltens-Steuerung |
| Action im Log sichtbar | User sieht welches Keyword griff | User kann Keyword nachbessern |

**Kein Confidence-Score** für MVP. Zu komplex, zu wenig Mehrwert wenn User Kontrolle hat.

**Kein Trash-statt-Delete** für MVP. Begründung: Der bestehende Mechanismus löscht hart. Ein gemischtes Verhalten (statische Keywords → hart löschen, custom keywords → Trash) ist UX-inkonsistent und macht dem User das Modell schwerer verständlich. Wenn Trash generell gewünscht ist, ist das ein separates Feature für alle Löschungen.

### 7.3 Regex-Injection

User-Input wird vor Regex-Kompilierung escaped (`keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')`). Kein Injection-Risiko. Das ist Server-side Pflicht, nicht optional.

---

## 8. Aufwand-Schätzung

| Komponente | Aufwand | Abhängigkeit |
|---|---|---|
| Schema: neue Tabelle `user_mail_keywords` | 0.5 PT | rebreak-backend |
| Schema: `matchedKeyword`-Feld in `MailBlocked` (optional) | 0.25 PT | rebreak-backend |
| `PLAN_LIMITS` erweitern (`customKeywords`, `customKeywordBodyMatch`) | 0.25 PT | Mo |
| `getUserCustomKeywords()` in `server/db/mail.ts` | 0.25 PT | Mo |
| `scan-internal.post.ts` + `scan.post.ts` erweitern | 0.5 PT | Mo |
| Neue API-Endpoints (CRUD für Keywords) | 0.5 PT | Mo |
| UI: Keyword-Manager in Mail-Tab | 1.0 PT | rebreak-ui |
| **Gesamt MVP (subject_sender, kein body)** | **3.25 PT** | — |
| Body-Match (Legend, Phase 2) | +1.0 PT | Mo + rebreak-backend |

MVP-Definition: `subject_sender`-Scope, Pro 10 / Legend 50 Limit, CRUD-Endpoints, UI-Integration. Kein body-Match.

**Hauptaufwand liegt in der UI** (Tag-Input-Komponente, Plan-Gate-Anzeige, Activity-Log-Erweiterung). Der Backend-Teil ist überschaubar, da das Matching-Pattern aus `scan-internal.post.ts` minimal erweitert wird.

---

## 9. Open Questions

**Q1: Body-Match in Phase 1 oder Phase 2?**
Empfehlung: Phase 2. Subject_sender deckt den Hauptanwendungsfall. Body-Match bringt IMAP-Traffic-Overhead und neue Edge-Cases (MIME-decoding, encoding-Vielfalt). Nicht für MVP.

**Q2: matchedKeyword in MailBlocked speichern?**
Für User-Feedback wichtig (er sieht welches Keyword griff). Aber DSB-Review empfohlen (hans-mueller). Alternativvorschlag: im ersten MVP nur `action="deleted_custom_keyword"` ohne Keyword-Text. Das reicht für statistische Auswertung, lässt das Detail-Thema offen.

**Q3: Regex-OR vs. `.some()` + `.includes()`?**
Bei <= 10 Keywords: vernachlässigbarer Unterschied. Regex-OR ist trotzdem besser als Pattern, weil es skaliert und weil ein einzelner `regex.test()` call klarer ist als eine Schleife. Einigung vorab verhindert spätere Refactors.

**Q4: Keyword-Deaktivierung ohne Löschen?**
User möchte vielleicht ein Keyword temporär pausieren. Nicht für MVP — erhöht Schema-Komplexität (aktives `enabled`-Flag). User kann löschen und neu anlegen.

**Q5: Case-Sensitivity für Deutsche Umlaute**
JavaScript V8 `.toLowerCase()` auf String mit ä/ö/ü: `"Ö".toLowerCase() === "ö"` — korrekt. Kein ICU-Library-Problem in Node.js >= 13 (full-icu built-in). Kein Handlungsbedarf, aber in Integrations-Test verifizieren.

**Q6: Keyword-Import / Bulk-Add?**
Kein MVP-Feature. Pro-User kann 10 Keywords manuell eingeben. Bulk-CSV-Import ist Overkill für diesen Use-Case.

---

## Eskalationen

| Thema | Agent |
|---|---|
| Schema-Migration (`user_mail_keywords`, `MailBlocked.matchedKeyword`) | rebreak-backend |
| DSB-Review `matchedKeyword`-Feld | hans-mueller |
| UI-Implementierung (Tag-Input, Mail-Settings, Activity-Log) | rebreak-ui |
| Plan-Tier-Änderungen jenseits `customKeywords`-Feld | Orchestrator |
