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>
16 KiB
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:
GAMBLING_KEYWORDS— statische Liste inserver/utils/gambling-keywords.mjs(single-source-of-truth)- 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:
-
Endpoint
POST /api/mail/keywords(neu, anlegen/ändern/löschen von Keywords): Prüft Plan beim Schreiben. Free bekommt 403 miterror: "plan_limit". Pro darf maximal 10 Keywords anlegen, Legend 50. -
scan-internal.post.ts+scan.post.ts: Lädt Keywords nur wennlimits.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 |