rebreak-monorepo/backend/docs/mail-custom-keywords-plan.md
chahinebrini 0ab635c74a feat: art-9 consent flow + outlook-oauth schema + cooldown patterns + mail draft persist
DSGVO Art. 9 — Compliance-Gap im Mail-Connect-Flow geschlossen (Hans-Müller-DSB
hat den Gap für Gmail/iCloud/GMX identifiziert, schon vor Outlook-OAuth-Pflicht):

- Schema: mail_connections.consent_at + consent_version + consent_ip_address;
  neue consent_logs-Tabelle für Audit (grant + revoke append-only)
- Endpoints:
  - POST /api/mail-connections/consent (Bulk-Array für Re-Consent, partial-fail
    wirft sofort = DSGVO-sicher gegen silent-skip fremder IDs)
  - POST /api/mail-connections/:id mit consent-gate (412 wenn consentVersion fehlt)
  - DELETE /api/mail-connections/:id mit Widerruf-Log (OAuth-Token-Revoke als
    TODO für mo Phase 2)
  - GET /api/mail-connections/pending-consent — listet Bestands-Connections
    mit consent_at=NULL für Re-Consent-Modal
- Account-Lösch-Bug fix: deleteAllMailConnections() war in user/delete nicht
  eingebunden — Verbindungen blieben als Waisen
- Frontend:
  - ConnectMailSheet: neuer Consent-Step VOR Provider-Grid (view-Machine
    consent → grid → form), exakter Hans-Müller-Wortlaut für Art. 9 Abs. 2
    lit. a Einwilligung
  - MailConsentReminderSheet: Re-Consent-Modal beim App-Open für Bestands-User
  - Stores mailConsent + mailConnectDraft (letzterer fixt Bug: Email/Provider
    ging verloren wenn User Browser für App-Pw-Generierung öffnete)
  - 12 neue i18n-Keys mail.consent.* in DE + EN
- Versionierter Consent-Text: art9-mail-v1-2026-05-13 (Bump bei Text-Änderung
  triggert Re-Consent für alle)

Outlook-OAuth Schema (Phase 0 — additiv, Endpoints kommen später):

- mail_connections: auth_method (default 'app_password' → keine Bestands-
  Connection bricht), oauth_access_token, oauth_refresh_token,
  oauth_token_expiry, oauth_scope
- Encryption via bestehendes server/utils/crypto.ts (AES-256-GCM, Key aus
  Infisical)
- Plan-Doc backend/docs/mail-outlook-oauth-plan.md (mo)
- DSB-Review backend/docs/mail-outlook-oauth-dsgvo-review.md (Hans-Müller):
  MS als Sub-AV via DPA Sep 2025, EU Data Boundary seit Feb 2025; 5 Pflicht-
  Aufgaben + Anwalts-Klärung zu DPA-Anspruch ohne MS-Lizenz

Profile — Cooldown-Pattern-Analysis als Collapsible:

- CooldownPatternAnalysis: 24h-Uhrzeit-Heatmap, Mo–So-Wochentag-Histogramm,
  Top-5-Reason-Wortcloud mit Stop-Words-Filter, Cancel-Rate-Anzeige
- DiGA-relevant: NLP läuft client-side, reason-Texte verlassen das Device
  nicht (gut für DSB-Akte)
- useProfileData: useCooldownHistoryFull (limit=100) für Pattern-Analyse
- Neutral formuliert, kein Stigma, alle Headings als Frage

Plan-Docs (kein Code):

- backend/docs/mail-custom-keywords-plan.md — Pro/Legend Custom-Keyword-Filter
  (3.25 PT MVP, user-scoped, Body-Match in Phase 2)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 16:35:18 +02:00

351 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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