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

16 KiB
Raw Blame History

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