diff --git a/apps/rebreak-native/app/(app)/_layout.tsx b/apps/rebreak-native/app/(app)/_layout.tsx index 337fc95..f453f9f 100644 --- a/apps/rebreak-native/app/(app)/_layout.tsx +++ b/apps/rebreak-native/app/(app)/_layout.tsx @@ -5,10 +5,13 @@ import * as Notifications from 'expo-notifications'; import { useTranslation } from 'react-i18next'; import { useAuthStore } from '../../stores/auth'; import { useNotificationStore } from '../../stores/notifications'; +import { useMailConsentStore } from '../../stores/mailConsent'; import { useColors } from '../../lib/theme'; import { NativeTabs } from '../../components/NativeTabs'; +import { MailConsentReminderSheet } from '../../components/mail/MailConsentReminderSheet'; import { protection } from '../../lib/protection'; import { preloadTabIcons, getTabIcon } from '../../lib/tabIcons'; +import { apiFetch } from '../../lib/api'; export default function AppLayout() { const router = useRouter(); @@ -19,6 +22,7 @@ export default function AppLayout() { const startRealtime = useNotificationStore((s) => s.startRealtime); const stopRealtime = useNotificationStore((s) => s.stopRealtime); const resetNotifications = useNotificationStore((s) => s.reset); + const { visible: consentVisible, connections: consentConnections, show: showConsent, hide: hideConsent, markConsented } = useMailConsentStore(); const rearmInFlightRef = useRef(false); const bypassNotifiedRef = useRef(false); @@ -51,6 +55,18 @@ export default function AppLayout() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [session?.user?.id]); + useEffect(() => { + if (!session) return; + apiFetch<{ id: string; email: string }[]>('/api/mail-connections/pending-consent') + .then((pending) => { + if (pending.length > 0) { + showConsent(pending); + } + }) + .catch(() => {}); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [session?.user?.id]); + useEffect(() => { if (!session || Platform.OS !== 'ios') return; @@ -158,6 +174,14 @@ export default function AppLayout() { } return ( + <> + {consentVisible && ( + + )} + ); } diff --git a/apps/rebreak-native/app/profile/index.tsx b/apps/rebreak-native/app/profile/index.tsx index 5707eec..3acb015 100644 --- a/apps/rebreak-native/app/profile/index.tsx +++ b/apps/rebreak-native/app/profile/index.tsx @@ -18,6 +18,7 @@ import { useSocialStats, useApprovedDomains, useCooldownHistory, + useCooldownHistoryFull, useSosInsights, useDemographics, } from '../../hooks/useProfileData'; @@ -95,6 +96,7 @@ export default function ProfileScreen() { const { stats: socialStats } = useSocialStats(me?.id); const { domains: approvedDomainsData } = useApprovedDomains(); const { cooldownHistory } = useCooldownHistory(); + const { rawCooldowns } = useCooldownHistoryFull(); const { sosInsights } = useSosInsights(); const { demographics: serverDemographics, @@ -211,6 +213,7 @@ export default function ProfileScreen() { longestDays={longestDays} startDate={streakStartDate} cooldowns={cooldownHistory?.items ?? EMPTY_COOLDOWNS} + rawCooldowns={rawCooldowns} /> ('grid'); - const [selectedProvider, setSelectedProvider] = useState(null); - const [email, setEmail] = useState(''); + const { + view, + consentGiven, + selectedProvider, + email, + setView, + setConsentGiven, + setSelectedProvider, + setEmail, + reset: resetDraft, + } = useMailConnectDraft(); + const [password, setPassword] = useState(''); const [passwordVisible, setPasswordVisible] = useState(false); const [formError, setFormError] = useState(null); const [fieldsComplete, setFieldsComplete] = useState(false); function handleClose() { - setView('grid'); - setSelectedProvider(null); - setEmail(''); + resetDraft(); setPassword(''); setPasswordVisible(false); setFormError(null); @@ -117,6 +130,10 @@ export function ConnectMailSheet({ visible, onClose, onSuccess }: Props) { onClose(); } + function handleConsentNext() { + setView('grid'); + } + function handleProviderSelect(provider: ProviderConfig) { setSelectedProvider(provider); setEmail(''); @@ -128,10 +145,23 @@ export function ConnectMailSheet({ visible, onClose, onSuccess }: Props) { async function handleConnect() { setFormError(null); + + try { + await apiFetch('/api/mail-connections/consent', { + method: 'POST', + body: { consentVersion: CONSENT_VERSION }, + }); + } catch { + // Backend macht Consent atomar beim Connect-Endpoint — Fehler hier ignorieren. + } + const result = await connect({ email: email.trim(), password }); if (result.ok) { handleClose(); onSuccess(); + } else if (result.error?.includes('412') || result.error?.includes('consent_required')) { + setView('consent'); + setConsentGiven(false); } else { setFormError(t(humanizeMailError(result.error))); } @@ -150,7 +180,15 @@ export function ConnectMailSheet({ visible, onClose, onSuccess }: Props) { initialHeightPct={0.75} growWithKeyboard > - {view === 'grid' ? ( + {view === 'consent' ? ( + + ) : view === 'grid' ? ( ) : ( void; + onNext: () => void; + t: (key: string) => string; + colors: ReturnType; +}) { + return ( + + + {t('mail.consent.intro')} + + + + + + + {t('mail.consent.legal_text')} + + + + + + onToggleConsent(!consentGiven)} + style={{ flex: 1 }} + > + + {t('mail.consent.checkbox_label')} + + + + + + Linking.openURL(PRIVACY_URL)} + > + + {t('mail.consent.more_link')} → + + + + + + + {t('mail.consent.cta_next')} + + + + + ); +} + // --------------------------------------------------------------------------- // Sub-View: Provider-Grid // --------------------------------------------------------------------------- diff --git a/apps/rebreak-native/components/mail/MailConsentReminderSheet.tsx b/apps/rebreak-native/components/mail/MailConsentReminderSheet.tsx new file mode 100644 index 0000000..7013ced --- /dev/null +++ b/apps/rebreak-native/components/mail/MailConsentReminderSheet.tsx @@ -0,0 +1,237 @@ +import { useEffect, useRef, useState } from 'react'; +import { ActivityIndicator, Switch, Text, TouchableOpacity, View } from 'react-native'; +import { TrueSheet, type SheetDetent } from '@lodev09/react-native-true-sheet'; +import { Ionicons } from '@expo/vector-icons'; +import { useTranslation } from 'react-i18next'; +import { useRouter } from 'expo-router'; +import { apiFetch } from '../../lib/api'; +import { useColors } from '../../lib/theme'; + +const CONSENT_VERSION = 'art9-mail-v1-2026-05-13'; + +type PendingConnection = { + id: string; + email: string; +}; + +type Props = { + connections: PendingConnection[]; + onDismiss: () => void; + onConsented: () => void; +}; + +export function MailConsentReminderSheet({ connections, onDismiss, onConsented }: Props) { + const { t } = useTranslation(); + const colors = useColors(); + const router = useRouter(); + const sheetRef = useRef(null); + const [consentGiven, setConsentGiven] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (connections.length > 0) { + sheetRef.current?.present(); + } + }, [connections.length]); + + async function handleConsent() { + if (!consentGiven || submitting) return; + setSubmitting(true); + setError(null); + try { + await apiFetch('/api/mail-connections/consent', { + method: 'POST', + body: { + mailConnectionId: connections.map((c) => c.id), + consentVersion: CONSENT_VERSION, + }, + }); + sheetRef.current?.dismiss(); + onConsented(); + } catch { + setError(t('mail.consent.reminder_consent_error')); + } finally { + setSubmitting(false); + } + } + + function handleLater() { + sheetRef.current?.dismiss(); + onDismiss(); + } + + function handleDisconnect() { + sheetRef.current?.dismiss(); + onDismiss(); + router.push('/(app)/mail'); + } + + const count = connections.length; + const bodyText = + count === 1 + ? t('mail.consent.reminder_body_one') + : t('mail.consent.reminder_body_other', { count }); + + return ( + + + + + + + + + {t('mail.consent.reminder_title')} + + + {bodyText} + + + + + + + setConsentGiven((v) => !v)} + style={{ flex: 1 }} + > + + {t('mail.consent.reminder_legal_short')} + + + + + + {error ? ( + + {error} + + ) : null} + + + + {submitting ? ( + + ) : ( + + {t('mail.consent.reminder_cta_consent')} + + )} + + + + + + + + {t('mail.consent.reminder_cta_later')} + + + + + + + + {t('mail.consent.reminder_cta_disconnect')} + + + + + + + ); +} diff --git a/apps/rebreak-native/components/profile/CooldownPatternAnalysis.tsx b/apps/rebreak-native/components/profile/CooldownPatternAnalysis.tsx new file mode 100644 index 0000000..0c6b5f2 --- /dev/null +++ b/apps/rebreak-native/components/profile/CooldownPatternAnalysis.tsx @@ -0,0 +1,377 @@ +import { useState } from 'react'; +import { View, Text, TouchableOpacity, LayoutAnimation, Platform, UIManager } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { useTranslation } from 'react-i18next'; +import { useColors } from '../../lib/theme'; +import type { BackendCooldownEntry } from '../../hooks/useProfileData'; + +if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) { + UIManager.setLayoutAnimationEnabledExperimental(true); +} + +const DE_STOP_WORDS = new Set([ + 'der', 'die', 'das', 'den', 'dem', 'des', 'ein', 'eine', 'einer', 'einem', 'einen', 'eines', + 'und', 'oder', 'aber', 'auch', 'noch', 'nur', 'nicht', 'kein', 'keine', 'keinen', + 'ich', 'du', 'er', 'sie', 'es', 'wir', 'ihr', 'sie', 'mich', 'dich', 'sich', + 'mir', 'dir', 'uns', 'euch', 'ihm', 'ihr', 'ihnen', + 'mein', 'dein', 'sein', 'ihr', 'unser', 'euer', + 'meinen', 'deinen', 'seinen', 'ihren', 'unseren', + 'meiner', 'deiner', 'seiner', 'ihrer', 'unserer', + 'meinem', 'deinem', 'seinem', 'ihrem', 'unserem', + 'ist', 'sind', 'war', 'waren', 'bin', 'bist', 'hat', 'haben', 'hatte', 'hatten', + 'wird', 'werden', 'wurde', 'wurden', 'kann', 'muss', 'soll', 'will', 'mag', + 'bei', 'von', 'mit', 'aus', 'auf', 'in', 'an', 'im', 'am', 'zu', 'zum', 'zur', + 'als', 'wie', 'wenn', 'weil', 'dass', 'ob', 'damit', 'wann', 'wo', 'was', 'wer', + 'so', 'da', 'hier', 'dort', 'nun', 'mal', 'gerade', 'dann', 'nach', 'vor', 'seit', + 'sehr', 'mehr', 'viel', 'wieder', 'immer', 'schon', 'halt', 'einfach', 'eigentlich', + 'heute', 'jetzt', 'wieder', 'etwa', +]); + +const EN_STOP_WORDS = new Set([ + 'the', 'a', 'an', 'and', 'or', 'but', 'not', 'no', 'nor', + 'i', 'me', 'my', 'myself', 'we', 'our', 'you', 'your', 'he', 'she', 'it', 'they', + 'him', 'her', 'his', 'its', 'them', 'their', 'this', 'that', 'these', 'those', + 'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had', + 'do', 'does', 'did', 'will', 'would', 'could', 'should', 'may', 'might', 'can', + 'of', 'in', 'to', 'for', 'on', 'at', 'by', 'with', 'from', 'as', 'into', 'about', + 'so', 'just', 'also', 'then', 'now', 'here', 'there', 'up', 'out', 'very', + 'really', 'already', 'again', 'too', 'today', 'after', 'before', +]); + +const WEEKDAY_KEYS = [ + 'profile.cooldown.patterns.weekday_mon', + 'profile.cooldown.patterns.weekday_tue', + 'profile.cooldown.patterns.weekday_wed', + 'profile.cooldown.patterns.weekday_thu', + 'profile.cooldown.patterns.weekday_fri', + 'profile.cooldown.patterns.weekday_sat', + 'profile.cooldown.patterns.weekday_sun', +] as const; + +const MAX_BAR_HEIGHT = 48; +const MIN_BAR_HEIGHT = 2; + +type TopWord = { word: string; count: number }; + +function buildHourBuckets(entries: BackendCooldownEntry[]): number[] { + const buckets = Array(24).fill(0); + for (const e of entries) { + const h = new Date(e.startedAt).getHours(); + buckets[h]++; + } + return buckets; +} + +function buildWeekdayBuckets(entries: BackendCooldownEntry[]): number[] { + const buckets = Array(7).fill(0); + for (const e of entries) { + const jsDay = new Date(e.startedAt).getDay(); + const moFirst = jsDay === 0 ? 6 : jsDay - 1; + buckets[moFirst]++; + } + return buckets; +} + +function extractTopWords(entries: BackendCooldownEntry[], lang: string): TopWord[] { + const stopWords = lang.startsWith('de') ? DE_STOP_WORDS : EN_STOP_WORDS; + const freq: Record = {}; + + for (const e of entries) { + if (!e.reason) continue; + const words = e.reason + .toLowerCase() + .replace(/[^a-zA-ZäöüÄÖÜß\s]/g, ' ') + .split(/\s+/); + for (const w of words) { + if (w.length < 3) continue; + if (stopWords.has(w)) continue; + freq[w] = (freq[w] ?? 0) + 1; + } + } + + return Object.entries(freq) + .sort((a, b) => b[1] - a[1]) + .slice(0, 5) + .map(([word, count]) => ({ word, count })); +} + +function cancelRate(entries: BackendCooldownEntry[]): number { + if (entries.length === 0) return 0; + const cancelled = entries.filter((e) => e.status === 'cancelled').length; + return Math.round((cancelled / entries.length) * 100); +} + +type BarChartProps = { + buckets: number[]; + labels: string[]; + highlightIndices?: number[]; + colors: ReturnType; +}; + +function BarChart({ buckets, labels, highlightIndices, colors }: BarChartProps) { + const maxCount = Math.max(...buckets, 1); + return ( + + {buckets.map((count, i) => { + const isEmpty = count === 0; + const barHeight = isEmpty + ? MIN_BAR_HEIGHT + : Math.max(MIN_BAR_HEIGHT, (count / maxCount) * MAX_BAR_HEIGHT); + const isHighlight = highlightIndices?.includes(i); + return ( + + + {labels[i] !== '' ? ( + + {labels[i]} + + ) : ( + + )} + + ); + })} + + ); +} + +type SectionHeadingProps = { label: string; colors: ReturnType }; + +function SectionHeading({ label, colors }: SectionHeadingProps) { + return ( + + {label.toUpperCase()} + + ); +} + +type Props = { + rawCooldowns: BackendCooldownEntry[] | null; +}; + +export function CooldownPatternAnalysis({ rawCooldowns }: Props) { + const { t, i18n } = useTranslation(); + const colors = useColors(); + const lang = i18n.language ?? 'de'; + const [expanded, setExpanded] = useState(false); + + function toggle() { + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); + setExpanded((v) => !v); + } + + const entries = rawCooldowns ?? []; + const hasData = entries.length > 0; + + const hourBuckets = hasData ? buildHourBuckets(entries) : Array(24).fill(0); + const weekdayBuckets = hasData ? buildWeekdayBuckets(entries) : Array(7).fill(0); + const topWords = hasData ? extractTopWords(entries, lang) : []; + const pct = cancelRate(entries); + + const hourLabels: string[] = Array(24) + .fill('') + .map((_, h) => { + if (h === 6) return t('profile.cooldown.patterns.hour_morning'); + if (h === 12) return t('profile.cooldown.patterns.hour_afternoon'); + if (h === 18) return t('profile.cooldown.patterns.hour_evening'); + if (h === 0) return t('profile.cooldown.patterns.hour_night'); + return ''; + }); + + const weekdayLabels: string[] = WEEKDAY_KEYS.map((k) => t(k)); + + const peakHour = hasData ? hourBuckets.indexOf(Math.max(...hourBuckets)) : -1; + const peakDay = hasData ? weekdayBuckets.indexOf(Math.max(...weekdayBuckets)) : -1; + + return ( + + + + + {t('profile.cooldown.patterns.toggle_label')} + + + + + + {expanded ? ( + + {!hasData ? ( + + {t('profile.cooldown.patterns.not_enough')} + + ) : ( + <> + + + = 0 ? [peakHour] : []} + colors={colors} + /> + + + + + + + = 0 ? [peakDay] : []} + colors={colors} + /> + + + + + + + {topWords.length >= 3 ? ( + + {topWords.map(({ word, count }) => ( + + + {word} + + + ({count}) + + + ))} + + ) : ( + + {t('profile.cooldown.patterns.not_enough')} + + )} + + {t('profile.cooldown.patterns.cancel_rate', { pct })} + + + + )} + + ) : null} + + ); +} diff --git a/apps/rebreak-native/components/profile/StreakSection.tsx b/apps/rebreak-native/components/profile/StreakSection.tsx index f08d17e..a525129 100644 --- a/apps/rebreak-native/components/profile/StreakSection.tsx +++ b/apps/rebreak-native/components/profile/StreakSection.tsx @@ -2,6 +2,8 @@ import { View, Text } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { useTranslation } from 'react-i18next'; import { useColors } from '../../lib/theme'; +import { CooldownPatternAnalysis } from './CooldownPatternAnalysis'; +import type { BackendCooldownEntry } from '../../hooks/useProfileData'; export type CooldownEntry = { id: string; @@ -17,6 +19,7 @@ type Props = { longestDays: number; startDate: string; cooldowns: CooldownEntry[]; + rawCooldowns: BackendCooldownEntry[] | null; }; const WEEKS = 8; @@ -76,7 +79,7 @@ function formatAvg(totalCount: number, language: string): string { return avg.toFixed(1); } -export function StreakSection({ currentDays, longestDays, startDate, cooldowns }: Props) { +export function StreakSection({ currentDays, longestDays, startDate, cooldowns, rawCooldowns }: Props) { const colors = useColors(); const { t, i18n } = useTranslation(); const lang = i18n.language ?? 'de'; @@ -292,6 +295,8 @@ export function StreakSection({ currentDays, longestDays, startDate, cooldowns } ) : null} + + ); diff --git a/apps/rebreak-native/hooks/useProfileData.ts b/apps/rebreak-native/hooks/useProfileData.ts index f01a9b3..b23d376 100644 --- a/apps/rebreak-native/hooks/useProfileData.ts +++ b/apps/rebreak-native/hooks/useProfileData.ts @@ -24,7 +24,7 @@ export type SosInsightsData = { topEmotion: string | null; }; -type BackendCooldownEntry = { +export type BackendCooldownEntry = { id: string; startedAt: string; cooldownEndsAt: string; @@ -135,6 +135,15 @@ export function useCooldownHistory() { return { cooldownHistory: mapped, loading, error, reload }; } +export function useCooldownHistoryFull() { + const { data, loading, error, reload } = useFetchOnce<{ + items: BackendCooldownEntry[]; + nextCursor: string | null; + }>('/api/profile/me/cooldown-history?limit=100'); + + return { rawCooldowns: data?.items ?? null, loading, error, reload }; +} + export function useSosInsights() { const { data, loading, error, reload } = useFetchOnce( '/api/profile/me/sos-insights', diff --git a/apps/rebreak-native/locales/de.json b/apps/rebreak-native/locales/de.json index 35a5b6e..a62c27b 100644 --- a/apps/rebreak-native/locales/de.json +++ b/apps/rebreak-native/locales/de.json @@ -423,6 +423,22 @@ "tls_error": "Sichere Verbindung zum Mail-Server konnte nicht hergestellt werden. Provider kontaktieren.", "rate_limited": "Zu viele Verbindungsversuche. Bitte ein paar Minuten warten und erneut versuchen.", "unknown": "Verbindung fehlgeschlagen. Prüfe das App-Passwort oder schreib uns an support@rebreak.org — wir fügen deinen Anbieter gerne hinzu." + }, + "consent": { + "title": "Bevor du dein Postfach anbindest", + "intro": "Rebreak sucht in deinem Postfach gezielt nach Glücksspiel-Werbemails und löscht sie automatisch. Aus dieser Verarbeitung können Rückschlüsse auf eine Suchterkrankung gezogen werden — wir behandeln das als besondere Datenkategorie nach Art. 9 DSGVO.", + "legal_text": "Mit der Verbindung meines E-Mail-Postfachs willige ich ausdrücklich ein, dass Rebreak in meinem Postfach gezielt nach Glücksspiel-Werbemails sucht und diese löscht. Mir ist bewusst, dass aus dieser Verarbeitung Rückschlüsse auf eine Suchterkrankung möglich sind, und ich willige in diese Verarbeitung von Gesundheitsdaten gem. Art. 9 Abs. 2 lit. a DSGVO ausdrücklich ein. Diese Einwilligung kann ich jederzeit für die Zukunft widerrufen, indem ich die Mail-Verbindung in den Einstellungen trenne.", + "checkbox_label": "Ich willige ausdrücklich ein", + "cta_next": "Weiter", + "more_link": "Mehr zur Verarbeitung", + "reminder_title": "Wichtige Datenschutz-Information", + "reminder_body_one": "Für deine bestehende Postfach-Verbindung brauchen wir deine ausdrückliche erneute Einwilligung — sonst pausieren wir das Auto-Löschen.", + "reminder_body_other": "Für deine %{count} bestehenden Postfach-Verbindungen brauchen wir deine ausdrückliche erneute Einwilligung — sonst pausieren wir das Auto-Löschen.", + "reminder_legal_short": "Ich willige in die Verarbeitung meiner Postfach-Inhalte nach Art. 9 Abs. 2 lit. a DSGVO ein.", + "reminder_cta_consent": "Einwilligen", + "reminder_cta_later": "Später", + "reminder_cta_disconnect": "Verbindungen jetzt trennen", + "reminder_consent_error": "Einwilligung konnte nicht gespeichert werden. Bitte erneut versuchen." } }, "settings": { @@ -720,7 +736,26 @@ "none": "Keine Cooldowns in den letzten 8 Wochen", "count_one": "1 Cooldown in {{weeks}} Wochen", "count_other": "{{n}} Cooldowns in {{weeks}} Wochen", - "avg_last": "Ø 1 pro {{avg}} Wochen · zuletzt {{date}}" + "avg_last": "Ø 1 pro {{avg}} Wochen · zuletzt {{date}}", + "patterns": { + "toggle_label": "Mehr Infos", + "hour_heading": "Wann startest du Cooldowns?", + "day_heading": "An welchen Tagen?", + "reason_heading": "Häufige Begriffe", + "cancel_rate": "Cooldowns abgebrochen: {{pct}}%", + "not_enough": "Noch keine Muster erkannt", + "weekday_mon": "Mo", + "weekday_tue": "Di", + "weekday_wed": "Mi", + "weekday_thu": "Do", + "weekday_fri": "Fr", + "weekday_sat": "Sa", + "weekday_sun": "So", + "hour_morning": "Morgens", + "hour_afternoon": "Mittag", + "hour_evening": "Abend", + "hour_night": "Nacht" + } } }, "demographics": { diff --git a/apps/rebreak-native/locales/en.json b/apps/rebreak-native/locales/en.json index 4d1d557..5a0a544 100644 --- a/apps/rebreak-native/locales/en.json +++ b/apps/rebreak-native/locales/en.json @@ -423,6 +423,22 @@ "tls_error": "Secure connection to the mail server failed. Please contact your provider.", "rate_limited": "Too many connection attempts. Please wait a few minutes and try again.", "unknown": "Connection failed. Check your app password or write us at support@rebreak.org — we'll add your provider." + }, + "consent": { + "title": "Before you connect your inbox", + "intro": "Rebreak scans your inbox specifically for gambling promotional emails and deletes them automatically. This processing may allow conclusions to be drawn about a gambling disorder — we treat this as a special category of data under Art. 9 GDPR.", + "legal_text": "By connecting my email inbox, I expressly consent to Rebreak scanning my inbox specifically for gambling promotional emails and deleting them. I acknowledge that this processing may allow conclusions to be drawn about a gambling disorder, and I expressly consent to this processing of health-related data pursuant to Art. 9(2)(a) GDPR. I may withdraw this consent at any time with future effect by disconnecting the mail connection in the app settings.", + "checkbox_label": "I expressly consent", + "cta_next": "Continue", + "more_link": "More about this processing", + "reminder_title": "Important privacy update", + "reminder_body_one": "We have updated our consent wording for mail processing. Your existing mailbox connection requires your renewed explicit consent — otherwise we will pause automatic deletion.", + "reminder_body_other": "We have updated our consent wording for mail processing. Your %{count} existing mailbox connections require your renewed explicit consent — otherwise we will pause automatic deletion.", + "reminder_legal_short": "I consent to the processing of my mailbox contents under Art. 9(2)(a) GDPR.", + "reminder_cta_consent": "I consent", + "reminder_cta_later": "Later", + "reminder_cta_disconnect": "Disconnect now", + "reminder_consent_error": "Failed to save consent. Please try again." } }, "settings": { @@ -720,7 +736,26 @@ "none": "No cooldowns in the last 8 weeks", "count_one": "1 cooldown over {{weeks}} weeks", "count_other": "{{n}} cooldowns over {{weeks}} weeks", - "avg_last": "Ø 1 every {{avg}} weeks · last {{date}}" + "avg_last": "Ø 1 every {{avg}} weeks · last {{date}}", + "patterns": { + "toggle_label": "More insights", + "hour_heading": "When do you start cooldowns?", + "day_heading": "Which days?", + "reason_heading": "Common terms", + "cancel_rate": "Cooldowns cancelled: {{pct}}%", + "not_enough": "Not enough patterns yet", + "weekday_mon": "Mon", + "weekday_tue": "Tue", + "weekday_wed": "Wed", + "weekday_thu": "Thu", + "weekday_fri": "Fri", + "weekday_sat": "Sat", + "weekday_sun": "Sun", + "hour_morning": "Morning", + "hour_afternoon": "Afternoon", + "hour_evening": "Evening", + "hour_night": "Night" + } } }, "demographics": { diff --git a/apps/rebreak-native/stores/mailConnectDraft.ts b/apps/rebreak-native/stores/mailConnectDraft.ts new file mode 100644 index 0000000..75a1b16 --- /dev/null +++ b/apps/rebreak-native/stores/mailConnectDraft.ts @@ -0,0 +1,46 @@ +import { create } from 'zustand'; +import type { MailProvider } from '../hooks/useMailConnect'; + +type ProviderSnapshot = { + id: MailProvider; + labelKey: string; + icon: string; + color: string; + guideKey: string; + guideUrl: string; + disabled?: boolean; + disabledLabelKey?: string; +}; + +type MailConnectDraftState = { + view: 'consent' | 'grid' | 'form'; + consentGiven: boolean; + selectedProvider: ProviderSnapshot | null; + email: string; + + setView: (view: 'consent' | 'grid' | 'form') => void; + setConsentGiven: (v: boolean) => void; + setSelectedProvider: (p: ProviderSnapshot | null) => void; + setEmail: (email: string) => void; + reset: () => void; +}; + +const INITIAL: Pick< + MailConnectDraftState, + 'view' | 'consentGiven' | 'selectedProvider' | 'email' +> = { + view: 'consent', + consentGiven: false, + selectedProvider: null, + email: '', +}; + +export const useMailConnectDraft = create((set) => ({ + ...INITIAL, + + setView: (view) => set({ view }), + setConsentGiven: (consentGiven) => set({ consentGiven }), + setSelectedProvider: (selectedProvider) => set({ selectedProvider }), + setEmail: (email) => set({ email }), + reset: () => set(INITIAL), +})); diff --git a/apps/rebreak-native/stores/mailConsent.ts b/apps/rebreak-native/stores/mailConsent.ts new file mode 100644 index 0000000..59e6f11 --- /dev/null +++ b/apps/rebreak-native/stores/mailConsent.ts @@ -0,0 +1,23 @@ +import { create } from 'zustand'; + +export type PendingConsentConnection = { + id: string; + email: string; +}; + +type MailConsentState = { + visible: boolean; + connections: PendingConsentConnection[]; + show: (connections: PendingConsentConnection[]) => void; + hide: () => void; + markConsented: () => void; +}; + +export const useMailConsentStore = create((set) => ({ + visible: false, + connections: [], + + show: (connections) => set({ visible: true, connections }), + hide: () => set({ visible: false }), + markConsented: () => set({ visible: false, connections: [] }), +})); diff --git a/backend/docs/consent-gap-plan.md b/backend/docs/consent-gap-plan.md new file mode 100644 index 0000000..6fa9ddf --- /dev/null +++ b/backend/docs/consent-gap-plan.md @@ -0,0 +1,187 @@ +# Consent-Gap-Plan — Art. 9 DSGVO Mail-Auto-Delete + +Stand: 2026-05-13 +Autor: rebreak-backend-agent +Status: Implementiert (Backend), TODOs für mo + rebreak-native-ui + +--- + +## Was wurde implementiert + +### Schema (Migration 20260513_art9_consent_log) + +Neue Spalten in `mail_connections`: +- `consent_at TIMESTAMPTZ NULL` — wann eingewilligt, NULL = "Re-Consent pending" +- `consent_version TEXT NULL` — z.B. "art9-mail-v1-2026-05-13" +- `consent_ip_address TEXT NULL` — IP zum Zeitpunkt der Einwilligung + +Neue Tabelle `consent_logs`: +- Append-only Audit-Trail für alle Einwilligungen und Widerrufe +- Wird NIEMALS gelöscht (Beweispflicht Art. 7 Abs. 1 DSGVO) + +### Backend-Dateien + +| Datei | Zweck | +|---|---| +| `server/utils/consent-texts.ts` | Versionierte Consent-Texte (DE + EN) | +| `server/db/consent.ts` | DB-Layer: writeConsentGrant, writeConsentRevoke, getConsentLogsByUser, setMailConnectionConsent | +| `server/api/mail-connections/consent.post.ts` | POST /api/mail-connections/consent | +| `server/api/mail-connections/[id].post.ts` | POST /api/mail-connections/:id (mit Consent-Gate 412) | +| `server/api/mail-connections/[id].delete.ts` | DELETE /api/mail-connections/:id (mit Widerruf-Log) | +| `server/api/user/delete.delete.ts` | Erweitert: schreibt Widerruf für alle Connections bei Account-Löschung | + +### Aktuelle Consent-Version + +`"art9-mail-v1-2026-05-13"` — definiert in `server/utils/consent-texts.ts`. + +--- + +## TODO #1 — mo (Mail-Stack / Daemon) + +**Daemon pausiert Verarbeitung wenn consent_at = NULL** + +Kontext: Alle Bestandsrows nach Migration haben `consent_at = NULL`. Das bedeutet +"Re-Consent pending". Der Daemon darf für diese Connections KEIN Auto-Delete +ausführen bis der User explizit eingewilligt hat. + +Implementierung in `imap-idle/index.mjs` (oder wherever der Scan-Loop läuft): + +```js +// Beim Laden einer Connection für den Scan-Loop: +if (!connection.consent_at) { + log(`[consent] Skipping ${connection.email}: Re-Consent pending (consent_at = NULL)`); + // Verbindung aus dem aktiven Scan-Pool auslassen — KEIN Fehler, kein Error-State. + // isActive bleibt true. Wenn User Re-Consent gibt, wird consent_at gesetzt + // und die Connection beim nächsten Loop-Cycle wieder aufgenommen. + continue; +} +``` + +Checklist: +- [ ] mo — `imap-idle/index.mjs`: consent_at-Check im Connection-Load +- [ ] mo — `scan.post.ts` / `scan-internal.post.ts`: gleiches Check (On-Demand-Scans) + +--- + +## TODO #2 — mo (OAuth Token-Revoke bei Disconnect) + +**Wenn OAuth-Connections getrennt werden: Token bei Microsoft revoken** + +Kontext: Wenn `authMethod === 'oauth2_microsoft'` (Outlook-OAuth, noch nicht live), +muss beim Disconnect der Refresh-Token bei Microsoft widerrufen werden. + +Placeholder-Comments existieren bereits in: +- `server/api/mail-connections/[id].delete.ts` (User-Disconnect) +- `server/api/user/delete.delete.ts` (Account-Löschung) + +Implementierung wenn OAuth-Phase startet: + +```ts +if (conn.authMethod === 'oauth2_microsoft') { + const refreshToken = decrypt(conn.oauthRefreshToken); + let revoked = false; + for (let attempt = 0; attempt < 3; attempt++) { + try { + await fetch('https://login.microsoftonline.com/common/oauth2/v2.0/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'revoke', + token: refreshToken, + client_id: process.env.MS_OAUTH_CLIENT_ID, + client_secret: process.env.MS_OAUTH_CLIENT_SECRET, + }), + }); + revoked = true; + break; + } catch (e) { + // Retry + } + } + // Audit-Log: Token-Revoke success/failure + // Dann trotzdem DB-Row löschen (DSB-Memo Abschnitt 5.1) +} +``` + +--- + +## TODO #3 — Datenexport (Art. 15 DSGVO) + +**consent_logs für den User in den Datenexport aufnehmen** + +Es gibt aktuell keinen `/api/data-export`-Endpoint im Backend. Wenn er gebaut +wird (separater Sprint), muss er enthalten: +- `consent_logs` für den User (aus `getConsentLogsByUser(userId)` in `server/db/consent.ts`) +- Für jede MailConnection: Provider, verbundene E-Mail-Adresse, Verbindungs-Zeitpunkt, + erteilte OAuth-Scopes (lesbar, kein Token-Inhalt), Token-Ablaufdatum + +DSB-Memo Abschnitt 5.2 + Hans-Müller-To-Do-Liste Item #6. + +--- + +## Frontend-Spec für rebreak-native-ui (UI-Agent) + +### Re-Consent-Modal (für Bestandsuser) + +Trigger: App-Open + Auth-User eingeloggt + mindestens eine MailConnection mit +`consent_at = NULL` (Backend: GET /api/mail/status liefert connections mit +`consentAt`-Feld — Null-Check im Frontend). + +Alternativ: neuer Endpoint `GET /api/mail-connections/pending-consent` der nur +die IDs der Connections ohne Consent zurückgibt. + +Modal-Inhalt: +- Titel: aus `getConsentText("art9-mail-v1-2026-05-13").de` (oder .en je nach Locale) +- Zwei Buttons: "Einwilligen" → POST /api/mail-connections/consent, "Verbindung trennen" → DELETE /api/mail-connections/:id + +Nach "Einwilligen": +- Pro Connection ein POST /api/mail-connections/consent senden +- Body: `{ mailConnectionId: "", consentVersion: "art9-mail-v1-2026-05-13" }` +- Bei 200: Modal schließen, Toast "Einwilligung erteilt" +- Bei 409 (version_mismatch): sollte nicht passieren wenn Frontend aktuelle Version nutzt + +### ConnectMailSheet — Consent-Gate + +Neuer Flow: +1. User gibt Email + Passwort ein +2. Vor dem Abschicken: Consent-Text anzeigen (Checkbox oder expliziter Button) +3. POST /api/mail-connections/:id mit `consentVersion` im Body +4. Bei 412 (`consent_required`): Consent-Modal anzeigen (sollte nicht vorkommen + wenn Schritt 2 korrekt implementiert ist) +5. Bei 200: Connection verbunden + +Der `consentVersion`-Wert muss das Frontend kennen. Empfehlung: Backend liefert +ihn via `GET /api/mail/status` oder einem neuen `GET /api/mail-connections/consent-version`- +Endpoint. Alternativ: hardcoded im Frontend, aber dann muss er bei jedem Text-Bump +synchronisiert werden. + +### Empfehlung: `GET /api/mail-connections/consent-version` + +Einfacher Endpoint ohne Auth: +``` +GET /api/mail-connections/consent-version +→ { version: "art9-mail-v1-2026-05-13", texts: { de: "...", en: "..." } } +``` + +Damit muss das Frontend die Version nie hardcoden. Noch nicht implementiert — +als TODO für rebreak-backend falls UI-Agent es braucht. + +--- + +## Consent-Text-Bump-Workflow (für künftige DSB-Updates) + +1. Hans-Müller gibt neuen Text frei +2. Neuen Eintrag in `server/utils/consent-texts.ts` hinzufügen +3. `CURRENT_ART9_MAIL_VERSION` auf neue Version setzen +4. Alle bestehenden Connections mit alter Version bekommen automatisch Re-Consent-Modal + (Daemon-Check: `connection.consent_version !== CURRENT_ART9_MAIL_VERSION` → pausieren) +5. Deploy via GitHub Actions Pipeline + +--- + +## Nicht gemacht (explizit aus Scope ausgeschlossen) + +- Migration lokal ausgeführt — nein, Pipeline deployt +- Frontend-Änderungen — UI-Agent-Task +- Daemon-Logik angefasst — mo's Domain +- git push — kein User-GO erteilt diff --git a/backend/docs/mail-custom-keywords-plan.md b/backend/docs/mail-custom-keywords-plan.md new file mode 100644 index 0000000..65cdd80 --- /dev/null +++ b/backend/docs/mail-custom-keywords-plan.md @@ -0,0 +1,350 @@ +# 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 | diff --git a/backend/docs/mail-outlook-oauth-dsgvo-review.md b/backend/docs/mail-outlook-oauth-dsgvo-review.md new file mode 100644 index 0000000..95173a9 --- /dev/null +++ b/backend/docs/mail-outlook-oauth-dsgvo-review.md @@ -0,0 +1,223 @@ +# Datenschutz-Memo: Microsoft als Sub-Auftragsverarbeiter (Outlook-IMAP-OAuth) + +**An:** Chahine Brini (Geschäftsführung, Rebreak) +**Von:** Hans Müller, externer Datenschutzbeauftragter +**Datum:** 2026-05-13 +**Betreff:** DSGVO-Review Outlook-IMAP-OAuth-Integration (Mail-Auto-Delete-Feature) +**Klassifikation:** Internes Compliance-Memo, kein Rechtsrat + +--- + +## 1. Gesamteinschätzung (Executive Summary) + +**Risiko-Klasse: Mittel** — vorausgesetzt, die unten genannten Pflichten werden vor Live-Schaltung erledigt. + +- Die OAuth2-Integration ist datenschutzrechtlich **günstiger als die bisherige App-Passwort-Lösung** (granular widerrufbare Scopes, Token-Rotation, kein Klartext-Credential bei Rebreak). +- Microsoft ist als Auftragsverarbeiter für Exchange Online seit Februar 2025 vollständig im **EU Data Boundary** — der Drittland-Transfer ist damit deutlich entschärft, aber **nicht vollständig eliminiert** (Support-Zugriffe, Telemetrie, Identity-Platform-Subsysteme können noch US-Routing enthalten). +- Microsofts Standard-**DPA** (Stand September 2025) erfüllt die Art. 28-Anforderungen formal — eine eigenständige Vertragsunterzeichnung ist für Consumer-/Free-Tier-OAuth in der Regel **nicht möglich**; die DPA gilt qua Akzeptanz der Microsoft Services Agreement bzw. App-Registration-Bedingungen. Dies sollten Sie einmal anwaltlich verifizieren lassen, da Rebreak hier als „Partner" und nicht als zahlender M365-Tenant agiert. +- **Kein Blocker** für Go-Live, aber Pflicht-Aufgaben (VVT, Datenschutzerklärung, User-Einwilligungstext, Token-Revoke-Logik beim Löschen) **vor** Aktivierung. + +--- + +## 2. Sub-Auftragsverarbeiter-Konstellation (Art. 28 DSGVO) + +### 2.1 Rolle von Microsoft + +| Datenfluss | Rolle Microsoft | Rolle Rebreak | +|---|---|---| +| User loggt sich bei `login.microsoftonline.com` ein (Browser → MS) | **Verantwortlicher** für das Microsoft-Konto des Users (eigene Privacy-Policy) | nicht beteiligt | +| MS gibt `auth_code` an Rebreak-Backend zurück | **Verantwortlicher** (User → MS, MS-Datenschutzerklärung) | wird hier zum Empfänger | +| Token-Storage + IMAP-Calls Rebreak → `outlook.office365.com` mit XOAUTH2 | **Auftragsverarbeiter** (verarbeitet Mailbox-Inhalt im Auftrag Rebreaks) | **Verantwortlicher** | +| Rebreak liest/löscht Glücksspiel-Mails | reine Speicher-/Übermittlungsfunktion | Verantwortlicher mit Art. 9-Daten-Verarbeitung | + +**Konsequenz:** Microsoft ist im Sinne des Mail-Auto-Delete-Features ein **Auftragsverarbeiter Rebreaks** (Art. 28) — aber **nur** für die IMAP-/Postfach-Verarbeitung. Der OAuth-Identity-Vorgang selbst ist ein **eigenverantwortliches** Microsoft-Geschäft gegenüber dem Endnutzer. + +### 2.2 Rechtsgrundlage für die Sub-AV-Beauftragung + +Microsofts „[Products and Services Data Protection Addendum (DPA)](https://aka.ms/DPA)" in der Version **September 2025** erfüllt die Mindestanforderungen aus Art. 28 Abs. 3 DSGVO formal. + +**Wichtige Einschränkung — anwaltliche Klärung empfohlen:** Die DPA bindet Microsoft typischerweise nur gegenüber **lizenzierten Customers** (M365-Tenants). Für eine reine **OAuth-App-Registration** ohne kommerzielle MS-Lizenz greifen die [Microsoft Services Agreement](https://www.microsoft.com/servicesagreement/) sowie die [API Terms of Use](https://learn.microsoft.com/en-us/legal/microsoft-apis/terms-of-use). Hier ist **nicht trivial**, ob Rebreak einen DPA-Anspruch für die IMAP-Verarbeitung der **Consumer-Postfächer** der Endnutzer hat. + +→ **Empfehlung:** Diese spezifische Konstellation („Anti-Sucht-App liest fremde Consumer-Postfächer via OAuth") **anwaltlich prüfen lassen** (1-2 Stunden Aufwand). + +### 2.3 Drittland-Transfer (Kapitel V DSGVO) + +**Gute Nachricht:** Microsoft hat das [**EU Data Boundary**](https://learn.microsoft.com/en-us/privacy/eudb/eu-data-boundary-learn) im Februar 2025 abgeschlossen. Exchange Online (und damit `outlook.office365.com`) speichert und verarbeitet Kunden- und pseudonymisierte personenbezogene Daten innerhalb der EU/EFTA. + +**Verbleibende Drittland-Aspekte:** +- **Microsoft Identity Platform** (`login.microsoftonline.com`) ist eine globale Identitäts-Infrastruktur. Token-Refresh-Calls können je nach geografischer Routing-Policy auch US-Endpoints erreichen. +- **Support-/Engineering-Zugriffe** durch nicht-EU-Personal sind durch das im November 2025 eingeführte **Data Guardian**-Programm zwar EU-überwacht, aber nicht ausgeschlossen. +- **Telemetrie/Diagnostik** kann residuale US-Übermittlungen enthalten. + +**Konsequenz:** +- [EU-Standardvertragsklauseln (SCC) 2021/914](https://commission.europa.eu/publications/standard-contractual-clauses-controllers-and-processors-eueea_en) als Anhang zur DPA — Modul 2/3. +- **EU-US Data Privacy Framework** (DPF), Microsoft ist gelistet. +- Ein eigenständiges **Transfer-Impact-Assessment (TIA)** ist **erforderlich, aber leicht erstellbar**. + +→ **Empfehlung TIA:** 2-3 Seiten, Template nach [EDSA Recommendations 01/2020](https://www.edpb.europa.eu/our-work-tools/our-documents/recommendations/recommendations-012020-measures-supplement-transfer_en). + +--- + +## 3. Verarbeitungsverzeichnis (Art. 30 DSGVO) + +### 3.1 Neue VVT-Zeile „Outlook-Mail-Anbindung via OAuth2" + +| Feld | Inhalt | +|---|---| +| **Bezeichnung** | Outlook-IMAP-Anbindung via Microsoft Identity Platform (OAuth2) | +| **Zweck** | Erkennung und Löschung von Glücksspiel-Werbemails im Nutzer-Postfach (Sucht-Trigger-Minimierung) | +| **Rechtsgrundlage** | Art. 6 Abs. 1 lit. b DSGVO (Vertragserfüllung) i.V.m. Art. 9 Abs. 2 lit. a DSGVO (ausdrückliche Einwilligung) — Wechsel auf Art. 9 Abs. 2 lit. h bei DiGA-Listung | +| **Betroffene Personen** | Registrierte Rebreak-Nutzer mit Microsoft-Consumer-Postfach (outlook.com, hotmail.com, live.com, msn.com) | +| **Datenkategorien (Art. 6)** | E-Mail-Adresse, OAuth-Access-Token, OAuth-Refresh-Token, Token-Ablaufdatum, technische Verbindungs-Metadaten (IMAP-Session-Logs) | +| **Datenkategorien (Art. 9)** | Indirekt: Verbindung „MS-Account-Inhaber X nutzt Anti-Glücksspiel-App" → Rückschluss auf Suchterkrankung möglich (siehe Abschnitt 6) | +| **Empfänger / Sub-AV** | Microsoft Ireland Operations Ltd., One Microsoft Place, South County Business Park, Leopardstown, Dublin 18, Irland | +| **Drittland-Transfer** | Primär EU (EU Data Boundary, Exchange Online), residuale Transfers in USA für Identity-Platform/Support (SCCs 2021/914 + EU-US DPF) | +| **Speicherdauer** | Tokens: bis User-Disconnect oder 90 Tage Inaktivität (refresh-token-TTL); Verbindungs-Logs: 30 Tage rolling | +| **TOMs** | AES-256-Encryption-at-rest für Tokens, TLS 1.2+ in Transit, Zugriff auf Token-Tabelle nur durch Backend-Service-Account, Hetzner DE Hosting | +| **Löschkonzept** | Bei User-Disconnect oder Account-Löschung: aktiver Token-Revoke bei MS (`/oauth2/v2.0/logout` bzw. revoke-endpoint) + DB-Row-Löschung | + +### 3.2 Sub-AV-Liste aktualisieren + +**Ja, Microsoft muss als Sub-AV ergänzt werden.** Aktuelle Sub-AV-Liste sollte umfassen: + +1. Hetzner Online GmbH (DE) — Hosting +2. Groq Inc. (USA) — Lyra-LLM-Inferenz +3. Stripe Payments Europe Ltd. (IE) / Stripe Inc. (USA) — Zahlungsabwicklung +4. Cloudflare Germany GmbH / Cloudflare Inc. (USA) — DNS/CDN +5. **NEU: Microsoft Ireland Operations Ltd. (IE) — IMAP-Postfach-Verarbeitung bei Outlook-Mail-Anbindung** +6. ggf. weitere Mail-Provider (Google, Apple, GMX, Yahoo) — hier bitte prüfen, ob diese ebenfalls noch nicht im VVT/Sub-AV-Liste stehen + +→ **Wichtiger Hinweis:** Bei den anderen IMAP-Providern (Gmail, iCloud) stellt sich **dieselbe Frage** wie bei MS. Diese Inkonsistenz im aktuellen VVT sollte mit dieser Gelegenheit **mit aufgeräumt** werden. + +--- + +## 4. Datenschutzerklärung — Update-Pflicht (Art. 13 DSGVO) + +### 4.1 Neuer Textbaustein (Vorschlag, anwaltlich final reviewen lassen) + +> **Verbindung mit Microsoft Outlook (consumer)** +> +> Wenn Sie Ihr persönliches Microsoft-Postfach (outlook.com, hotmail.com, live.com, msn.com) mit Rebreak verbinden, nutzen wir das standardisierte OAuth2-Verfahren der Microsoft Identity Platform. Sie loggen sich dabei direkt bei Microsoft ein — Ihre Zugangsdaten erreichen Rebreak zu keinem Zeitpunkt. +> +> Nach erfolgreichem Login speichern wir verschlüsselt: +> - einen **Access-Token** (Lebensdauer ca. 1 Stunde), +> - einen **Refresh-Token** (Lebensdauer bis zu 90 Tage, ohne Nutzung). +> +> Diese Tokens berechtigen Rebreak ausschließlich zu folgenden Zugriffen (OAuth-Scopes, nach Prinzip der Datenminimierung): +> - `IMAP.AccessAsUser.All` (Lese-/Lösch-Zugriff auf Ihre E-Mail-Inbox) +> - `offline_access` (technischer Refresh-Token-Bezug) +> - `openid` (OAuth-Mindesthygiene) +> +> Wir lesen **keine** weiteren Daten: keine Kontakte, keine Kalender, kein Profil, keine Fotos. +> +> Sub-Auftragsverarbeiter ist Microsoft Ireland Operations Ltd. (Dublin, Irland). Die Postfach-Verarbeitung erfolgt innerhalb des Microsoft EU Data Boundary (EU/EFTA). In Einzelfällen (Identity-Platform, Support) können Restdaten in die USA übermittelt werden — abgesichert durch die EU-Standardvertragsklauseln (Modul 2/3) und das EU-US Data Privacy Framework. +> +> Sie können diese Verbindung jederzeit in den Rebreak-Einstellungen trennen. Wir widerrufen den Token in diesem Fall aktiv bei Microsoft. + +### 4.2 Unterschied „OAuth-Token-Storage vs App-Passwort-Storage" + +> Bei den Anbietern, die wir per App-Passwort anbinden (z.B. GMX, Yahoo, ggf. iCloud), speichern wir ein vom Nutzer im Anbieter-Account erzeugtes Anwendungs-Passwort verschlüsselt in unserer Datenbank. Bei den Anbietern mit OAuth2-Verfahren (Google, Microsoft Outlook) speichern wir stattdessen die zeitlich begrenzten OAuth-Tokens. OAuth ist hierbei das aus Datenschutzsicht **vorzugswürdige** Verfahren, da die Berechtigungen feiner granuliert, jederzeit per Mausklick im Anbieter-Account widerrufbar und automatisch nach Inaktivität ablaufend sind. + +--- + +## 5. Betroffenenrechte + +### 5.1 Recht auf Löschung (Art. 17) + +**Pflicht, nicht Best-Practice:** Bei User-Löschung MÜSSEN Sie den Refresh-Token **aktiv bei Microsoft revoken**, bevor Sie die DB-Row entfernen. + +→ **Spec für rebreak-backend:** Beim Disconnect/Delete-Flow: +1. Call `POST https://login.microsoftonline.com/common/oauth2/v2.0/logout` mit Refresh-Token (best-effort, mit Retry) +2. Error-Logging falls revoke fehlschlägt — aber DB-Row trotzdem nach max. 3 Retries löschen +3. Audit-Log-Eintrag „token revoked at MS: success/failure" + +### 5.2 Auskunftspflicht (Art. 15) + +Im Datenexport-Endpoint muss enthalten sein: +- Für jede aktive Mail-Verbindung: Provider (Outlook), verbundene E-Mail-Adresse, Verbindungs-Zeitpunkt, **die erteilten OAuth-Scopes** (als lesbare Liste, nicht die kryptischen Scope-Strings), Token-Ablaufdatum. +- **NICHT** den Token-Inhalt selbst. + +--- + +## 6. Art. 9 DSGVO — Besondere Kategorie (Suchterkrankung) + +### 6.1 Der „Outing-Effekt" gegenüber Microsoft + +Wenn ein User im OAuth-Consent-Screen liest „Rebreak (Anti-Glücksspiel-App) möchte auf Ihr Postfach zugreifen", **erfährt Microsoft** mittelbar, dass dieser MS-Account-Inhaber eine Anti-Sucht-App nutzt. + +**Bewertung:** Keine Verarbeitung im Sinne von Art. 9 Abs. 1 durch Microsoft, weil MS keinen inhaltlichen Bezug herstellt. ABER: für den User ist die Sichtbarkeit relevant. + +→ **Empfehlung — User-Information vor Consent:** + +> Hinweis: Microsoft zeigt Ihnen im nächsten Schritt einen Berechtigungsdialog. Der App-Name „Rebreak" erscheint dort und wird in Ihrer Microsoft-Konto-Übersicht unter „App-Berechtigungen" sichtbar. Falls Ihr Microsoft-Konto von anderen Personen mitgenutzt wird, sollten Sie das berücksichtigen. + +### 6.2 Rechtsgrundlage Art. 9 Abs. 2 + +**Status quo (vor DiGA-Listung) — Empfehlung: Art. 9 Abs. 2 lit. a (ausdrückliche Einwilligung)** + +→ **Praxis-Frage:** Wird die Art. 9-Einwilligung schon **explizit** für Gmail/iCloud/GMX-Connections eingeholt? Wenn nein, ist das ein **bestehender Compliance-Gap**. + +**Vorschlag Consent-Text (anwaltlich review):** + +> Mit der Verbindung meines E-Mail-Postfachs willige ich ausdrücklich ein, dass Rebreak in meinem Postfach gezielt nach Glücksspiel-Werbemails sucht und diese löscht. Mir ist bewusst, dass aus dieser Verarbeitung Rückschlüsse auf eine Suchterkrankung möglich sind, und ich willige in diese Verarbeitung von Gesundheitsdaten gem. Art. 9 Abs. 2 lit. a DSGVO ausdrücklich ein. Diese Einwilligung kann ich jederzeit für die Zukunft widerrufen, indem ich die Mail-Verbindung in den Einstellungen trenne. + +**Zukunft (bei DiGA-Listung) — Wechsel auf Art. 9 Abs. 2 lit. h DSGVO**. + +--- + +## 7. DiGA-Aspekte + +**Kurzantwort:** Microsoft als zusätzlicher Sub-AV macht den BfArM-Antrag **marginal komplexer, nicht qualitativ anders.** + +- Microsoft ist datenschutzrechtlich „besser dokumentiert" als Groq und Stripe. +- ISO 27001, ISO 27018, BSI-C5-zertifiziert für Exchange Online ([Microsoft Trust Center](https://www.microsoft.com/en-us/trust-center)). +- DPA + DPF + EU Data Boundary erfüllen die „Stand der Technik"-Anforderung für einen Sub-AV. + +→ **Aktion:** Microsoft als Sub-AV in das spätere DiGA-Datenschutz-Konzept-Dokument aufnehmen. + +--- + +## 8. Konkrete To-Do-Liste, priorisiert + +| # | Aktion | Owner | Frist | Blocker für Outlook-Go-Live? | +|---|---|---|---|---| +| 1 | VVT-Zeile „Outlook-IMAP via MS Identity Platform" ergänzen | Brini (DSB-Vorlage liefere ich) | **vor Go-Live** | **Ja** | +| 2 | Sub-AV-Liste in Datenschutzerklärung um Microsoft Ireland erweitern | Brini + Anwalt-Review | **vor Go-Live** | **Ja** | +| 3 | Datenschutzerklärungs-Textbaustein „Outlook-Anbindung" + „OAuth vs App-Passwort" einfügen | Brini + Anwalt-Review | **vor Go-Live** | **Ja** | +| 4 | Art. 9-Einwilligungs-Flow im Mail-Connect-Onboarding implementieren (sofern noch nicht für andere Provider vorhanden) | rebreak-native + rebreak-backend | **vor Go-Live** | **Ja** | +| 5 | Token-Revoke-Logik (`/oauth2/v2.0/logout`) bei Disconnect + Account-Löschung implementieren | rebreak-backend | **vor Go-Live** | **Ja** | +| 6 | Datenexport-Endpoint (Art. 15) um `mail_connections`-Block ergänzen, falls nicht vorhanden | rebreak-backend | binnen 30 Tagen nach Go-Live | Nein | +| 7 | TIA (Transfer Impact Assessment, 2-3 Seiten) für MS-Sub-AV erstellen | DSB-Draft, Brini-Freigabe | binnen 30 Tagen nach Go-Live | Nein (aber dringend) | +| 8 | Anwaltliche Klärung „greift MS-DPA bei reiner OAuth-App-Registration?" | Anwalt | binnen 60 Tagen | Nein, aber Risiko-Minderung | +| 9 | Microsoft-Sub-AV in DiGA-Datenschutz-Konzept einbauen | DSB + rebreak-strategist | wenn DiGA-Antrag aktuell wird | Nein | +| 10 | Bestehenden VVT auf Konsistenz prüfen (Gmail/iCloud/GMX als Sub-AV?) | DSB-Audit | binnen 60 Tagen | Nein (aber wichtig für Konsistenz) | + +--- + +## 9. Was ich **nicht** entscheiden kann (Anwalts-Themen) + +1. **Vertragsrechtliche Bindung der MS-DPA an OAuth-App-Registrationen ohne kommerzielle Lizenz** — gehört in eine kurze juristische Stellungnahme. +2. **Finaler Wortlaut der Einwilligungserklärung Art. 9** — Einwilligungstexte sollten anwaltlich gegen UWG/AGB-Recht geprüft sein. +3. **Finaler Wortlaut der Datenschutzerklärungs-Änderungen** — Ich liefere DSB-Vorlagen, die juristische Abnahme bleibt Anwalt. +4. **AGB-Anpassung** für das veränderte Verfahren (App-Passwort → OAuth). + +--- + +## 10. Quellen + +- [Microsoft Products and Services Data Protection Addendum (DPA), Sept 2025 — aka.ms/DPA](https://aka.ms/DPA) +- [Microsoft EU Data Boundary — Microsoft Learn](https://learn.microsoft.com/en-us/privacy/eudb/eu-data-boundary-learn) +- [Microsoft Trust Center — EU Data Boundary Overview](https://www.microsoft.com/en-us/trust-center/privacy/european-data-boundary-eudb) +- [Microsoft EU Data Boundary Completion — Microsoft On the Issues, 26.02.2025](https://blogs.microsoft.com/on-the-issues/2025/02/26/microsoft-completes-landmark-eu-data-boundary-offering-enhanced-data-residency-and-transparency/) +- [Microsoft European Digital Commitments — One Year On, 29.04.2026](https://blogs.microsoft.com/on-the-issues/2026/04/29/one-year-on-progress-on-our-european-digital-commitments/) +- [Microsoft Identity Platform — OIDC Single Sign-Out / Token Revocation](https://learn.microsoft.com/en-us/entra/identity-platform/v2-protocols-oidc#single-sign-out) +- [EU-Standardvertragsklauseln 2021/914 — Europäische Kommission](https://commission.europa.eu/publications/standard-contractual-clauses-controllers-and-processors-eueea_en) +- [EDPB Recommendations 01/2020 — Supplementary Measures (TIA-Grundlage)](https://www.edpb.europa.eu/our-work-tools/our-documents/recommendations/recommendations-012020-measures-supplement-transfer_en) +- [BfArM DiGA-Leitfaden (Datenschutz-Anforderungen)](https://www.bfarm.de/DE/Medizinprodukte/Aufgaben/DiGA/_node.html) +- DSGVO: Art. 6, 9, 13, 15, 17, 28, 30, 32, 33, 35 sowie Kapitel V + +--- + +Mit freundlichen Grüßen +Hans Müller +Externer Datenschutzbeauftragter, Rebreak diff --git a/backend/prisma/migrations/20260513_art9_consent_log/migration.sql b/backend/prisma/migrations/20260513_art9_consent_log/migration.sql new file mode 100644 index 0000000..500673e --- /dev/null +++ b/backend/prisma/migrations/20260513_art9_consent_log/migration.sql @@ -0,0 +1,46 @@ +-- Migration: art9_consent_log +-- DSGVO Art. 9 Compliance — Mail-Auto-Delete-Einwilligung +-- +-- 1. Drei additiv hinzugefügte Spalten in mail_connections: +-- - consent_at TIMESTAMPTZ NULL → wann explizit eingewilligt wurde +-- - consent_version TEXT NULL → Versionierter Einwilligungs-Text +-- - consent_ip_address TEXT NULL → IP zum Beweiszweck (Art. 7 Abs. 1 DSGVO) +-- +-- Bestandsrows erhalten DEFAULT NULL (= "Re-Consent pending"). +-- KEIN automatisches Backfill auf now() — das wäre rechtlich falsch. +-- +-- 2. Neue Tabelle consent_logs (append-only Audit-Trail): +-- Jede Einwilligung und jeder Widerruf landet hier. +-- Niemals löschen — nur archivieren. +-- +-- Breaking-change: NONE. Alle neuen Spalten sind nullable. +-- Deploy: pnpm prisma migrate deploy (via GitHub Actions Pipeline) + +-- ── 1. mail_connections: Art. 9 Consent-Spalten ──────────────────────────── + +ALTER TABLE "rebreak"."mail_connections" + ADD COLUMN "consent_at" TIMESTAMPTZ, + ADD COLUMN "consent_version" TEXT, + ADD COLUMN "consent_ip_address" TEXT; + +-- Kein UPDATE/Backfill: NULL = "Re-Consent pending" (semantisch korrekt). + +-- ── 2. consent_logs: Append-only Audit-Tabelle ───────────────────────────── + +CREATE TABLE "rebreak"."consent_logs" ( + "id" TEXT NOT NULL, + "user_id" UUID NOT NULL, + "consent_type" TEXT NOT NULL, + "consent_version" TEXT NOT NULL, + "consent_at" TIMESTAMPTZ NOT NULL, + "ip_address" TEXT, + "user_agent" TEXT, + "mail_connection_id" UUID, + "revoked_at" TIMESTAMPTZ, + "revoke_reason" TEXT, + + CONSTRAINT "consent_logs_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "consent_logs_user_id_consent_type_idx" + ON "rebreak"."consent_logs" ("user_id", "consent_type"); diff --git a/backend/prisma/migrations/20260513_mail_connection_auth_method/migration.sql b/backend/prisma/migrations/20260513_mail_connection_auth_method/migration.sql new file mode 100644 index 0000000..cda368b --- /dev/null +++ b/backend/prisma/migrations/20260513_mail_connection_auth_method/migration.sql @@ -0,0 +1,27 @@ +-- Migration: mail_connection_auth_method +-- Adds OAuth2 token storage fields to mail_connections. +-- Enables generic auth-method framework: 'app_password' (existing) | 'oauth2_microsoft' (new) | future providers. +-- +-- Breaking-change status: NONE. +-- All existing rows receive auth_method='app_password' via DEFAULT. +-- OAuth columns are nullable — existing Gmail/iCloud/GMX/Yahoo connections unaffected. +-- +-- Deploy: pnpm prisma migrate deploy (on server via GitHub Actions pipeline) + +ALTER TABLE "rebreak"."mail_connections" + ADD COLUMN "auth_method" TEXT NOT NULL DEFAULT 'app_password', + ADD COLUMN "oauth_access_token" TEXT, + ADD COLUMN "oauth_refresh_token" TEXT, + ADD COLUMN "oauth_token_expiry" TIMESTAMPTZ, + ADD COLUMN "oauth_scope" TEXT; + +-- Explicit backfill: belt-and-suspenders in case DEFAULT did not apply +-- (should be a no-op since DEFAULT covers all rows at ALTER time, but documents intent). +UPDATE "rebreak"."mail_connections" + SET "auth_method" = 'app_password' + WHERE "auth_method" IS NULL OR "auth_method" = ''; + +-- No index on oauth_token_expiry: +-- Daemon checks expiry on-connect (O(1) per connection record already in memory), +-- not via a scheduled batch query. Index would be unused overhead. +-- Revisit if a background sweep query is introduced. diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 0b06a3b..2f3e88f 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -500,6 +500,35 @@ model MailConnection { lastIdleHeartbeatAt DateTime? @map("last_idle_heartbeat_at") createdAt DateTime @default(now()) @map("created_at") + // ─── OAuth2-Auth-Framework (additiv, Phase 0) ──────────────────────────── + // authMethod: 'app_password' (default, alle bestehenden Connections) + // | 'oauth2_microsoft' (Outlook/Hotmail/Live — Phase 1) + // | 'oauth2_google' (Gmail, future — wenn Google Basic-Auth deprecated) + // Bestehende Connections: app_password per DEFAULT, keine Migration nötig. + // OAuth-Felder bleiben NULL für app_password-Connections. + authMethod String @default("app_password") @map("auth_method") + /// AES-256-GCM encrypted (gleiches Verfahren wie passwordEncrypted, gleicher ENCRYPTION_KEY). + /// Format: iv(24hex):tag(32hex):ciphertext(hex) — via server/utils/crypto.ts encrypt() + oauthAccessToken String? @map("oauth_access_token") + /// AES-256-GCM encrypted. Microsoft kann bei Refresh neues refresh_token liefern + /// (Token-Rotation) — bei jedem Refresh-Call persistieren. + oauthRefreshToken String? @map("oauth_refresh_token") + /// UTC-Zeitstempel wann access_token abläuft. Daemon prüft on-connect: + /// wenn expiry < now+5min → refresh vor IMAP-Connect. + oauthTokenExpiry DateTime? @map("oauth_token_expiry") + /// Gespeicherter Scope-String des erteilten Konsents (z.B. "IMAP.AccessAsUser.All offline_access openid"). + /// Für Audit und zukünftige Scope-Vergleiche bei Re-Auth. + oauthScope String? @map("oauth_scope") + + // ─── Art. 9-Einwilligung (DSGVO-Compliance, Mail-Auto-Delete) ─────────── + // consentAt=NULL für Bestandsrows → "Re-Consent pending". + // Daemon pausiert Mail-Verarbeitung wenn consentAt=NULL (kein Auto-Delete). + // consentVersion: z.B. "art9-mail-v1-2026-05-13". Bump → alle Consents invalid. + // consentIpAddress: Beweispflicht Art. 7 Abs. 1 DSGVO. + consentAt DateTime? @map("consent_at") + consentVersion String? @map("consent_version") + consentIpAddress String? @map("consent_ip_address") + blockedMails MailBlocked[] @@unique([userId, email]) @@ -706,6 +735,31 @@ model ModerationAction { @@schema("rebreak") } +/// Append-only Audit-Log für DSGVO-Einwilligungen und -Widerrufe. +/// Jede Einwilligung (grant) UND jeder Widerruf (revoke) wird als eigener Eintrag geschrieben. +/// Beweispflicht Art. 7 Abs. 1 DSGVO — niemals löschen, nur archivieren. +model ConsentLog { + id String @id @default(cuid()) + userId String @map("user_id") @db.Uuid + /// 'art9-mail' | künftige Consent-Typen (z.B. 'art9-lyra-memory') + consentType String @map("consent_type") + /// z.B. "art9-mail-v1-2026-05-13". Gleich der consentVersion in MailConnection. + consentVersion String @map("consent_version") + consentAt DateTime @map("consent_at") + ipAddress String? @map("ip_address") + userAgent String? @map("user_agent") + /// Optional: welche MailConnection-Row dieser Consent betrifft. + mailConnectionId String? @map("mail_connection_id") @db.Uuid + /// Gesetzt wenn Einwilligung widerrufen wurde. + revokedAt DateTime? @map("revoked_at") + /// 'user_disconnect' | 'account_deleted' | 'text_version_updated' + revokeReason String? @map("revoke_reason") + + @@index([userId, consentType]) + @@map("consent_logs") + @@schema("rebreak") +} + // Device-Binding pro User: Free=1, Pro=1, Legend=3 (siehe plan-features.ts maxDevices). // Frontend liefert via Capacitor Device.getId() eine persistente UUID — diese wird // bei jedem authentifizierten Request via x-device-id Header geprüft. diff --git a/backend/server/api/mail-connections/[id].delete.ts b/backend/server/api/mail-connections/[id].delete.ts new file mode 100644 index 0000000..4304726 --- /dev/null +++ b/backend/server/api/mail-connections/[id].delete.ts @@ -0,0 +1,87 @@ +import { writeConsentRevoke } from "../../db/consent"; +import { deleteMailConnection } from "../../db/mail"; +import { usePrisma } from "../../utils/prisma"; + +/** + * DELETE /api/mail-connections/:id + * + * Trennt eine MailConnection mit korrekter DSGVO-Compliance: + * 1. Widerruf-Eintrag in consent_logs (Art. 7 Abs. 1 DSGVO — Beweislog) + * 2. Für OAuth-Connections (Outlook): Token-Revoke bei MS — best-effort, + * max 3 Retries, dann trotzdem löschen (DSB-Memo Abschnitt 5.1). + * NOCH NICHT implementiert — Placeholder für OAuth-Phase. + * Tracking: TODO mo — OAuth Token-Revoke, siehe consent-gap-plan.md + * 3. DB-Row löschen + * + * Param: :id = MailConnection.id (UUID) + * + * Response: + * 200: { ok: true } + * 404: { error: 'connection_not_found' } + */ +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + const connectionId = getRouterParam(event, "id"); + + if (!connectionId) { + throw createError({ + statusCode: 400, + data: { error: "missing_id" }, + }); + } + + // Verbindung holen (brauchen wir für Consent-Version + authMethod) + const db = usePrisma(); + const connection = await db.mailConnection.findFirst({ + where: { id: connectionId, userId: user.id }, + select: { + id: true, + consentVersion: true, + authMethod: true, + email: true, + }, + }); + + if (!connection) { + throw createError({ + statusCode: 404, + data: { error: "connection_not_found" }, + }); + } + + const now = new Date(); + const ipAddress = + getHeader(event, "x-forwarded-for")?.split(",")[0]?.trim() ?? + getHeader(event, "x-real-ip") ?? + null; + const userAgent = getHeader(event, "user-agent") ?? null; + + // ── Widerruf in consent_logs (Art. 7) ──────────────────────────────────── + // Nur wenn jemals eine Consent-Version gesetzt war (Bestandsrows ohne Consent + // haben consentVersion=null — wir loggen mit Marker-Version "none"). + await writeConsentRevoke({ + userId: user.id, + consentType: "art9-mail", + consentVersion: connection.consentVersion ?? "none", + revokedAt: now, + revokeReason: "user_disconnect", + mailConnectionId: connection.id, + ipAddress, + userAgent, + }); + + // ── OAuth Token-Revoke (Placeholder für MS-OAuth-Phase) ────────────────── + // TODO (mo — Mail-Stack): Wenn authMethod === 'oauth2_microsoft': + // 1. oauthRefreshToken aus DB lesen (decrypt) + // 2. POST https://login.microsoftonline.com/common/oauth2/v2.0/logout + // mit grant_type=revoke, token=, client_id, client_secret + // 3. Max 3 Retries mit Exponential-Backoff + // 4. Audit-Log-Eintrag "token_revoked_at_ms: success/failure" + // 5. Trotzdem löschen wenn Revoke fehlschlägt (DSB-Memo Abschnitt 5.1) + // Tracking: consent-gap-plan.md TODO #2 + + // ── DB-Row löschen ──────────────────────────────────────────────────────── + await deleteMailConnection(user.id, connectionId); + + return { ok: true }; +}); diff --git a/backend/server/api/mail-connections/[id].post.ts b/backend/server/api/mail-connections/[id].post.ts new file mode 100644 index 0000000..9704599 --- /dev/null +++ b/backend/server/api/mail-connections/[id].post.ts @@ -0,0 +1,195 @@ +import { CURRENT_ART9_MAIL_VERSION } from "../../utils/consent-texts"; +import { + writeConsentGrant, + setMailConnectionConsent, +} from "../../db/consent"; +import { + countMailConnections, + upsertMailConnection, +} from "../../db/mail"; +import { getProfile } from "../../db/profile"; +import { getPlanLimits } from "../../utils/plan-features"; +import { detectImapProviderAsync } from "../../utils/imap-providers"; +import { ImapFlow } from "imapflow"; + +/** + * POST /api/mail-connections/:id + * + * Gateway-Endpoint für das Anlegen einer neuen MailConnection — mit Art. 9-Consent-Check. + * + * Wenn kein gültiger Consent vorliegt, antwortet der Endpoint mit 412 BEFORE + * jede IMAP-Verbindung versucht wird. Das Frontend muss dann: + * 1. Das Consent-Modal anzeigen (Art. 9-Text) + * 2. User bestätigt → POST /api/mail-connections/consent + * 3. Danach diesen Endpoint erneut aufrufen (mit consentVersion im Body) + * + * Body: + * email: string (required) + * password: string (required) + * consentVersion: string (required — muss CURRENT_ART9_MAIL_VERSION entsprechen) + * imapHost?: string + * imapPort?: number + * useTls?: boolean + * rejectUnauthorized?: boolean + * + * Response: + * 200: { connected: true, email, provider, custom } + * 412: { error: 'consent_required', consentVersion: string } ← Frontend zeigt Modal + * 400: { error: 'invalid_body' } + * 401: { error: 'imap_auth_failed' } + * 403: { error: 'plan_limit', ... } + * + * HINWEIS: Dieser Endpoint ersetzt NICHT connect.post.ts — er ist ein paralleler + * Pfad mit explizitem Consent-Gate. Der bestehende /api/mail/connect bleibt + * vorerst aktiv (Abwärtskompatibilität), sollte aber mittelfristig auf diesen + * Endpoint migriert werden. + */ +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + + const body = await readBody(event).catch(() => null); + + if (!body) { + throw createError({ + statusCode: 400, + data: { error: "invalid_body" }, + }); + } + + const { + email, + password, + consentVersion, + imapHost: customImapHost, + imapPort: customImapPort, + useTls, + rejectUnauthorized, + } = body as { + email?: string; + password?: string; + consentVersion?: string; + imapHost?: string; + imapPort?: number; + useTls?: boolean; + rejectUnauthorized?: boolean; + }; + + if (!email || !password) { + throw createError({ + statusCode: 400, + data: { error: "invalid_body" }, + }); + } + + // ── Art. 9-Consent-Gateway ──────────────────────────────────────────────── + // Keine Einwilligung → sofort 412, bevor IMAP-Verbindung aufgebaut wird. + if (!consentVersion || consentVersion !== CURRENT_ART9_MAIL_VERSION) { + throw createError({ + statusCode: 412, + data: { + error: "consent_required", + consentVersion: CURRENT_ART9_MAIL_VERSION, + }, + }); + } + + // ── Plan-Limit prüfen ───────────────────────────────────────────────────── + const profile = await getProfile(user.id); + const limits = getPlanLimits(profile?.plan ?? "free"); + + if (limits.mailAgents !== Infinity) { + const count = await countMailConnections(user.id); + if (count >= limits.mailAgents) { + throw createError({ + statusCode: 403, + data: { + error: "plan_limit", + resource: "mail_accounts", + current: count, + limit: limits.mailAgents, + }, + }); + } + } + + // ── IMAP-Provider-Detection ─────────────────────────────────────────────── + const provider = await detectImapProviderAsync(email); + const resolvedHost = customImapHost?.trim() || provider.host; + const resolvedPort = customImapPort ?? provider.port; + + const useImplicitTls = useTls !== false; + const tlsRejectUnauthorized = rejectUnauthorized !== false; + const useStarttls = useTls === false; + + // ── IMAP-Verbindung testen ──────────────────────────────────────────────── + const client = new ImapFlow({ + host: resolvedHost, + port: resolvedPort, + secure: useImplicitTls, + ...(useStarttls ? { requireTLS: true } : {}), + auth: { user: email, pass: password }, + logger: false, + tls: { rejectUnauthorized: tlsRejectUnauthorized }, + }); + + try { + await client.connect(); + await client.logout(); + } catch (err: any) { + throw createError({ + statusCode: 401, + data: { + error: "imap_auth_failed", + detail: err.message ?? "connection_failed", + }, + }); + } + + // ── Consent-Zeitstempel & Audit-Log VOR dem Upsert ─────────────────────── + const now = new Date(); + const ipAddress = + getHeader(event, "x-forwarded-for")?.split(",")[0]?.trim() ?? + getHeader(event, "x-real-ip") ?? + null; + const userAgent = getHeader(event, "user-agent") ?? null; + + // MailConnection anlegen/updaten + const connection = await upsertMailConnection({ + userId: user.id, + email, + provider: "imap", + providerName: customImapHost ? resolvedHost : provider.name, + imapHost: resolvedHost, + imapPort: resolvedPort, + passwordEncrypted: encrypt(password), + rejectUnauthorized: tlsRejectUnauthorized, + useStarttls, + }); + + // consent_at + version auf der Connection setzen + await setMailConnectionConsent({ + connectionId: connection.id, + userId: user.id, + consentAt: now, + consentVersion, + consentIpAddress: ipAddress, + }); + + // Append-only Audit-Log + await writeConsentGrant({ + userId: user.id, + consentType: "art9-mail", + consentVersion, + consentAt: now, + ipAddress, + userAgent, + mailConnectionId: connection.id, + }); + + return { + connected: true, + email, + provider: customImapHost ? resolvedHost : provider.name, + custom: !!customImapHost, + }; +}); diff --git a/backend/server/api/mail-connections/consent.post.ts b/backend/server/api/mail-connections/consent.post.ts new file mode 100644 index 0000000..3f4a89a --- /dev/null +++ b/backend/server/api/mail-connections/consent.post.ts @@ -0,0 +1,103 @@ +import { CURRENT_ART9_MAIL_VERSION } from "../../utils/consent-texts"; +import { + writeConsentGrant, + setMailConnectionConsent, + getMailConnectionWithConsent, +} from "../../db/consent"; + +/** + * POST /api/mail-connections/consent + * + * Erteilt Art. 9-Einwilligung für eine oder mehrere bestehende MailConnections. + * Schreibt pro Connection: + * 1. Eintrag in consent_logs (append-only Beweislog, Art. 7 Abs. 1 DSGVO) + * 2. consent_at + consent_version + consent_ip_address auf mail_connections + * + * Body: { mailConnectionId: string | string[], consentVersion: string } + * + * Response: + * 200: { success: true, consentAt: ISO-string, updated: number } + * 400: { error: 'invalid_body' } + * 404: { error: 'connection_not_found' } — wenn eine ID nicht zum User gehört + * 409: { error: 'consent_version_mismatch', expected: string } + */ +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + + const body = await readBody(event).catch(() => null); + const rawId = body?.mailConnectionId as string | string[] | undefined; + const consentVersion = body?.consentVersion as string | undefined; + + if (!rawId || !consentVersion) { + throw createError({ + statusCode: 400, + data: { error: "invalid_body" }, + }); + } + + // Nur die aktuelle Version ist akzeptabel. + if (consentVersion !== CURRENT_ART9_MAIL_VERSION) { + throw createError({ + statusCode: 409, + data: { + error: "consent_version_mismatch", + expected: CURRENT_ART9_MAIL_VERSION, + received: consentVersion, + }, + }); + } + + // Normalisieren auf Array — erlaubt single-string und bulk-array + const ids = Array.isArray(rawId) ? rawId : [rawId]; + + if (ids.length === 0) { + throw createError({ + statusCode: 400, + data: { error: "invalid_body" }, + }); + } + + const now = new Date(); + const ipAddress = + getHeader(event, "x-forwarded-for")?.split(",")[0]?.trim() ?? + getHeader(event, "x-real-ip") ?? + null; + const userAgent = getHeader(event, "user-agent") ?? null; + + // Alle Connections validieren (müssen dem User gehören) — dann in Serie verarbeiten. + // Serie statt Promise.all: vermeidet Race-Conditions auf consent_logs primary key. + for (const mailConnectionId of ids) { + const connection = await getMailConnectionWithConsent( + mailConnectionId, + user.id, + ); + if (!connection) { + throw createError({ + statusCode: 404, + data: { error: "connection_not_found", mailConnectionId }, + }); + } + + // 1. Append-only Audit-Log + await writeConsentGrant({ + userId: user.id, + consentType: "art9-mail", + consentVersion, + consentAt: now, + ipAddress, + userAgent, + mailConnectionId, + }); + + // 2. MailConnection-Row updaten + await setMailConnectionConsent({ + connectionId: mailConnectionId, + userId: user.id, + consentAt: now, + consentVersion, + consentIpAddress: ipAddress, + }); + } + + return { success: true, consentAt: now.toISOString(), updated: ids.length }; +}); diff --git a/backend/server/api/mail-connections/pending-consent.get.ts b/backend/server/api/mail-connections/pending-consent.get.ts new file mode 100644 index 0000000..b1b19eb --- /dev/null +++ b/backend/server/api/mail-connections/pending-consent.get.ts @@ -0,0 +1,19 @@ +import { getPendingConsentConnections } from "../../db/mail"; + +/** + * GET /api/mail-connections/pending-consent + * + * Gibt alle MailConnections des eingeloggten Users zurück bei denen + * consent_at IS NULL — d.h. die noch keine Art. 9-Einwilligung haben. + * + * Wird beim App-Open aufgerufen um den Re-Consent-Modal zu triggern + * (z.B. wenn eine Connection vor Einführung des Consent-Gates angelegt wurde). + * + * Response: + * 200: { id: string, email: string }[] — leeres Array wenn nichts pending + * 401: wenn nicht eingeloggt + */ +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + return getPendingConsentConnections(user.id); +}); diff --git a/backend/server/api/user/delete.delete.ts b/backend/server/api/user/delete.delete.ts index 8bcfe4b..d6ee5f4 100644 --- a/backend/server/api/user/delete.delete.ts +++ b/backend/server/api/user/delete.delete.ts @@ -9,12 +9,40 @@ import { deleteUserCoachSessions, } from "../../db/user"; import { deleteProfile } from "../../db/profile"; +import { deleteAllMailConnections } from "../../db/mail"; +import { writeConsentRevoke } from "../../db/consent"; +import { usePrisma } from "../../utils/prisma"; export default defineEventHandler(async (event) => { const user = await requireUser(event); const supabase = serverSupabaseServiceRole(event); const userId = user.id; + // DSGVO Art. 9: Consent-Widerruf für alle MailConnections vor Löschung + // (append-only — consent_logs-Rows bleiben für Beweiszwecke erhalten) + const db = usePrisma(); + const mailConnections = await db.mailConnection.findMany({ + where: { userId }, + select: { id: true, consentVersion: true, authMethod: true }, + }); + + const now = new Date(); + for (const conn of mailConnections) { + // Widerruf-Log schreiben — fire-and-forget, kein throw wenn es fehlschlägt + writeConsentRevoke({ + userId, + consentType: "art9-mail", + consentVersion: conn.consentVersion ?? "none", + revokedAt: now, + revokeReason: "account_deleted", + mailConnectionId: conn.id, + }).catch(() => {}); + + // TODO (mo — Mail-Stack): OAuth Token-Revoke bei MS bevor Row gelöscht wird. + // Wenn conn.authMethod === 'oauth2_microsoft': Token-Revoke best-effort. + // Tracking: consent-gap-plan.md TODO #2 + } + // Delete all user data (DSGVO Art. 17) await Promise.all([ deleteUserUrgeLogs(userId), @@ -24,6 +52,7 @@ export default defineEventHandler(async (event) => { deleteAllUserCustomDomains(userId), deleteUserTrustedContacts(userId), deleteUserCoachSessions(userId), + deleteAllMailConnections(userId), ]); // Profil zuletzt löschen (FK-Abhängigkeiten sind bereits entfernt) diff --git a/backend/server/db/consent.ts b/backend/server/db/consent.ts new file mode 100644 index 0000000..99731b2 --- /dev/null +++ b/backend/server/db/consent.ts @@ -0,0 +1,110 @@ +import { usePrisma } from "../utils/prisma"; + +/** Schreibt einen neuen Consent-Eintrag (Einwilligung). */ +export async function writeConsentGrant(params: { + userId: string; + consentType: string; + consentVersion: string; + consentAt: Date; + ipAddress?: string | null; + userAgent?: string | null; + mailConnectionId?: string | null; +}) { + const db = usePrisma(); + return db.consentLog.create({ + data: { + userId: params.userId, + consentType: params.consentType, + consentVersion: params.consentVersion, + consentAt: params.consentAt, + ipAddress: params.ipAddress ?? null, + userAgent: params.userAgent ?? null, + mailConnectionId: params.mailConnectionId ?? null, + }, + }); +} + +/** + * Schreibt einen Widerruf-Eintrag. + * Öffnet KEINE neue Row für die ursprüngliche Einwilligung — der Widerruf ist + * ein eigenständiger Eintrag (revokedAt gesetzt, consentAt = Zeitpunkt des Widerrufs). + */ +export async function writeConsentRevoke(params: { + userId: string; + consentType: string; + consentVersion: string; + revokedAt: Date; + revokeReason: string; + mailConnectionId?: string | null; + ipAddress?: string | null; + userAgent?: string | null; +}) { + const db = usePrisma(); + return db.consentLog.create({ + data: { + userId: params.userId, + consentType: params.consentType, + consentVersion: params.consentVersion, + // consentAt = Zeitpunkt des Widerruf-Events (Eintrag-Erstellung) + consentAt: params.revokedAt, + revokedAt: params.revokedAt, + revokeReason: params.revokeReason, + mailConnectionId: params.mailConnectionId ?? null, + ipAddress: params.ipAddress ?? null, + userAgent: params.userAgent ?? null, + }, + }); +} + +/** Liest alle Consent-Log-Einträge eines Users — für Datenexport (Art. 15 DSGVO). */ +export async function getConsentLogsByUser(userId: string) { + const db = usePrisma(); + return db.consentLog.findMany({ + where: { userId }, + orderBy: { consentAt: "desc" }, + }); +} + +/** + * Setzt consent_at + consent_version + consent_ip_address auf der MailConnection. + * Wird nach writeConsentGrant aufgerufen — beide Writes sind nötig: + * - consent_logs: append-only Beweislog + * - mail_connections.consent_at: Gateway-Check (Daemon + connect.post.ts) + */ +export async function setMailConnectionConsent(params: { + connectionId: string; + userId: string; + consentAt: Date; + consentVersion: string; + consentIpAddress: string | null; +}) { + const db = usePrisma(); + return db.mailConnection.updateMany({ + where: { id: params.connectionId, userId: params.userId }, + data: { + consentAt: params.consentAt, + consentVersion: params.consentVersion, + consentIpAddress: params.consentIpAddress, + }, + }); +} + +/** + * Gibt die aktive (nicht-widerrufene) MailConnection zurück und prüft + * ob Consent vorhanden ist. + * Wird in connect.post.ts als Gateway-Check verwendet. + */ +export async function getMailConnectionWithConsent( + connectionId: string, + userId: string, +) { + const db = usePrisma(); + return db.mailConnection.findFirst({ + where: { id: connectionId, userId }, + select: { + id: true, + consentAt: true, + consentVersion: true, + }, + }); +} diff --git a/backend/server/db/mail.ts b/backend/server/db/mail.ts index beb5d46..e4a5728 100644 --- a/backend/server/db/mail.ts +++ b/backend/server/db/mail.ts @@ -184,6 +184,21 @@ export async function insertMailBlocked( await db.mailBlocked.createMany({ data: entries, skipDuplicates: true }); } +/** + * Gibt alle MailConnections eines Users zurück bei denen consent_at noch NULL ist. + * Wird vom pending-consent.get.ts Endpoint für den Re-Consent-Modal-Trigger genutzt. + */ +export async function getPendingConsentConnections( + userId: string, +): Promise<{ id: string; email: string }[]> { + const db = usePrisma(); + return db.mailConnection.findMany({ + where: { userId, consentAt: null }, + select: { id: true, email: true }, + orderBy: { createdAt: "asc" }, + }); +} + export async function getImapProxyAccounts(userId: string) { const db = usePrisma(); return db.imapProxyAccount.findMany({ where: { userId } }); diff --git a/backend/server/utils/consent-texts.ts b/backend/server/utils/consent-texts.ts new file mode 100644 index 0000000..dcdd706 --- /dev/null +++ b/backend/server/utils/consent-texts.ts @@ -0,0 +1,50 @@ +/** + * Consent-Text-Versionierung — Art. 9 DSGVO (Gesundheitsdaten / Mail-Auto-Delete) + * + * WORKFLOW: + * 1. Hans-Müller (DSB) gibt neuen Text frei. + * 2. Neue Version hier eintragen, CURRENT_ART9_MAIL_VERSION bumpen. + * 3. Nach Migration: alle MailConnection.consentVersion !== CURRENT werden + * als "Re-Consent pending" behandelt (consentAt bleibt, aber Daemon pausiert). + * 4. Frontend zeigt Re-Consent-Modal beim App-Open (UI-Agent-Task). + * + * WICHTIG: Texts sind NICHT für LLM-Prompts — das ist reiner Compliance-Text. + * Formulierung liegt bei Hans-Müller (DSB) + Anwalt-Review. + * Dieser File ist Backend-Infrastruktur, kein Stil-File. + */ + +export const CURRENT_ART9_MAIL_VERSION = "art9-mail-v1-2026-05-13"; + +interface ConsentTextEntry { + de: string; + en: string; +} + +const CONSENT_TEXTS: Record = { + "art9-mail-v1-2026-05-13": { + de: `Mit der Verbindung meines E-Mail-Postfachs willige ich ausdrücklich ein, dass Rebreak in meinem Postfach gezielt nach Glücksspiel-Werbemails sucht und diese löscht. Mir ist bewusst, dass aus dieser Verarbeitung Rückschlüsse auf eine Suchterkrankung möglich sind, und ich willige in diese Verarbeitung von Gesundheitsdaten gem. Art. 9 Abs. 2 lit. a DSGVO ausdrücklich ein. Diese Einwilligung kann ich jederzeit für die Zukunft widerrufen, indem ich die Mail-Verbindung in den Einstellungen trenne.`, + en: `By connecting my email mailbox, I expressly consent to Rebreak searching my mailbox for gambling promotional emails and deleting them. I am aware that this processing may allow inferences about an addiction disorder, and I expressly consent to the processing of health data pursuant to Art. 9(2)(a) GDPR. I may revoke this consent at any time with future effect by disconnecting the mail connection in the settings.`, + }, +}; + +/** + * Gibt den Consent-Text für eine gegebene Version zurück. + * Wirft wenn die Version unbekannt ist — das ist ein Programmierfehler, + * kein User-Fehler (Frontend sollte nie eine unbekannte Version schicken). + */ +export function getConsentText(version: string): ConsentTextEntry { + const entry = CONSENT_TEXTS[version]; + if (!entry) { + throw new Error( + `Unknown consent version: "${version}". Known versions: ${Object.keys(CONSENT_TEXTS).join(", ")}`, + ); + } + return entry; +} + +/** + * Gibt alle bekannten Consent-Versionen zurück — für interne Konsistenz-Checks. + */ +export function getKnownConsentVersions(): string[] { + return Object.keys(CONSENT_TEXTS); +}