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);
+}