# 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 |