feat: art-9 consent flow + outlook-oauth schema + cooldown patterns + mail draft persist

DSGVO Art. 9 — Compliance-Gap im Mail-Connect-Flow geschlossen (Hans-Müller-DSB
hat den Gap für Gmail/iCloud/GMX identifiziert, schon vor Outlook-OAuth-Pflicht):

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

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

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

Profile — Cooldown-Pattern-Analysis als Collapsible:

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

Plan-Docs (kein Code):

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-05-13 16:35:18 +02:00
parent 01d515d137
commit 0ab635c74a
25 changed files with 2481 additions and 15 deletions

View File

@ -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 && (
<MailConsentReminderSheet
connections={consentConnections}
onDismiss={hideConsent}
onConsented={markConsented}
/>
)}
<NativeTabs
sidebarAdaptable
hapticFeedbackEnabled
@ -221,5 +245,6 @@ export default function AppLayout() {
/>
<NativeTabs.Screen name="notifications" options={{ href: null }} />
</NativeTabs>
</>
);
}

View File

@ -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}
/>
<UrgeStatsCard

View File

@ -3,6 +3,7 @@ import {
ActivityIndicator,
Linking,
ScrollView,
Switch,
Text,
TouchableOpacity,
View,
@ -11,9 +12,14 @@ import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import { useMailConnect, detectProvider, type MailProvider } from '../../hooks/useMailConnect';
import { humanizeMailError } from '../../lib/mailErrors';
import { apiFetch } from '../../lib/api';
import { useColors } from '../../lib/theme';
import { FormSheet } from '../FormSheet';
import { SheetFieldStack } from '../SheetFieldStack';
import { useMailConnectDraft } from '../../stores/mailConnectDraft';
const CONSENT_VERSION = 'art9-mail-v1-2026-05-13';
const PRIVACY_URL = 'https://rebreak.org/datenschutz';
type Props = {
visible: boolean;
@ -88,28 +94,35 @@ const PROVIDERS: ProviderConfig[] = [
/**
* Bottom-Sheet zum Verbinden eines Postfachs.
*
* Zwei Ansichten im selben Sheet (kein Navigations-Header):
* 1. Provider-Grid (6 Tiles) Schließen via Swipe/Backdrop
* 2. Formular: Email + App-Passwort als SheetFieldStack,
* dann Datenschutz-Hinweis + Connect-Button
* Drei Ansichten im selben Sheet (kein Navigations-Header):
* 1. Consent-Screen (Art. 9 DSGVO) MUSS zuerst bestätigt werden
* 2. Provider-Grid (6 Tiles) nach Consent-Bestätigung freigeschaltet
* 3. Formular: Email + App-Passwort als SheetFieldStack
*/
export function ConnectMailSheet({ visible, onClose, onSuccess }: Props) {
const { t } = useTranslation();
const colors = useColors();
const { connect, connecting, error: connectError } = useMailConnect();
const [view, setView] = useState<'grid' | 'form'>('grid');
const [selectedProvider, setSelectedProvider] = useState<ProviderConfig | null>(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<string | null>(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' ? (
<ConsentStep
consentGiven={consentGiven}
onToggleConsent={setConsentGiven}
onNext={handleConsentNext}
t={t}
colors={colors}
/>
) : view === 'grid' ? (
<ProviderGrid providers={PROVIDERS} onSelect={handleProviderSelect} t={t} />
) : (
<SheetFieldStack
@ -324,6 +362,144 @@ export function ConnectMailSheet({ visible, onClose, onSuccess }: Props) {
);
}
// ---------------------------------------------------------------------------
// Sub-View: Consent (Art. 9 DSGVO) — muss als erster Schritt bestätigt werden
// ---------------------------------------------------------------------------
function ConsentStep({
consentGiven,
onToggleConsent,
onNext,
t,
colors,
}: {
consentGiven: boolean;
onToggleConsent: (v: boolean) => void;
onNext: () => void;
t: (key: string) => string;
colors: ReturnType<typeof useColors>;
}) {
return (
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{ padding: 20, gap: 16 }}
showsVerticalScrollIndicator={false}
>
<Text
style={{
fontSize: 14,
fontFamily: 'Nunito_400Regular',
color: colors.text,
lineHeight: 21,
}}
>
{t('mail.consent.intro')}
</Text>
<View
style={{
backgroundColor: '#fefce8',
borderRadius: 12,
borderWidth: 1,
borderColor: '#fde68a',
padding: 14,
gap: 12,
}}
>
<View style={{ flexDirection: 'row', alignItems: 'flex-start', gap: 10 }}>
<Ionicons
name="document-text-outline"
size={18}
color="#92400e"
style={{ marginTop: 1 }}
/>
<Text
style={{
flex: 1,
fontSize: 13,
fontFamily: 'Nunito_400Regular',
color: '#78350f',
lineHeight: 19,
}}
>
{t('mail.consent.legal_text')}
</Text>
</View>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 12,
paddingTop: 4,
borderTopWidth: 1,
borderTopColor: '#fde68a',
}}
>
<Switch
value={consentGiven}
onValueChange={onToggleConsent}
trackColor={{ false: '#d1d5db', true: '#16a34a' }}
thumbColor="#fff"
/>
<TouchableOpacity
activeOpacity={0.7}
onPress={() => onToggleConsent(!consentGiven)}
style={{ flex: 1 }}
>
<Text
style={{
fontSize: 13,
fontFamily: 'Nunito_600SemiBold',
color: '#78350f',
lineHeight: 18,
}}
>
{t('mail.consent.checkbox_label')}
</Text>
</TouchableOpacity>
</View>
</View>
<TouchableOpacity
activeOpacity={0.7}
onPress={() => Linking.openURL(PRIVACY_URL)}
>
<Text
style={{
fontSize: 13,
fontFamily: 'Nunito_600SemiBold',
color: '#007AFF',
textAlign: 'center',
}}
>
{t('mail.consent.more_link')}
</Text>
</TouchableOpacity>
<TouchableOpacity
activeOpacity={consentGiven ? 0.85 : 1}
onPress={consentGiven ? onNext : undefined}
disabled={!consentGiven}
style={{ marginTop: 4, marginBottom: 12 }}
>
<View
style={{
backgroundColor: consentGiven ? '#007AFF' : '#d4d4d4',
borderRadius: 14,
paddingVertical: 14,
alignItems: 'center',
}}
>
<Text style={{ fontSize: 15, fontFamily: 'Nunito_700Bold', color: '#fff' }}>
{t('mail.consent.cta_next')}
</Text>
</View>
</TouchableOpacity>
</ScrollView>
);
}
// ---------------------------------------------------------------------------
// Sub-View: Provider-Grid
// ---------------------------------------------------------------------------

View File

@ -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<TrueSheet>(null);
const [consentGiven, setConsentGiven] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(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 (
<TrueSheet
ref={sheetRef}
detents={['auto', 1] satisfies SheetDetent[]}
cornerRadius={20}
grabber
onDidDismiss={onDismiss}
>
<View style={{ paddingHorizontal: 20, paddingTop: 8, paddingBottom: 40, gap: 16 }}>
<View
style={{
width: 44,
height: 44,
borderRadius: 12,
backgroundColor: 'rgba(245,158,11,0.12)',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons name="lock-closed-outline" size={22} color="#b45309" />
</View>
<View style={{ gap: 6 }}>
<Text
style={{
fontSize: 20,
fontFamily: 'Nunito_700Bold',
color: colors.text,
}}
>
{t('mail.consent.reminder_title')}
</Text>
<Text
style={{
fontSize: 14,
fontFamily: 'Nunito_400Regular',
color: colors.textMuted,
lineHeight: 20,
}}
>
{bodyText}
</Text>
</View>
<View
style={{
backgroundColor: '#fefce8',
borderRadius: 12,
borderWidth: 1,
borderColor: '#fde68a',
padding: 14,
gap: 12,
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 12 }}>
<Switch
value={consentGiven}
onValueChange={setConsentGiven}
trackColor={{ false: '#d1d5db', true: '#16a34a' }}
thumbColor="#fff"
/>
<TouchableOpacity
activeOpacity={0.7}
onPress={() => setConsentGiven((v) => !v)}
style={{ flex: 1 }}
>
<Text
style={{
fontSize: 13,
fontFamily: 'Nunito_400Regular',
color: '#78350f',
lineHeight: 18,
}}
>
{t('mail.consent.reminder_legal_short')}
</Text>
</TouchableOpacity>
</View>
</View>
{error ? (
<Text
style={{
fontSize: 13,
fontFamily: 'Nunito_400Regular',
color: colors.error,
}}
>
{error}
</Text>
) : null}
<TouchableOpacity
activeOpacity={consentGiven && !submitting ? 0.85 : 1}
onPress={handleConsent}
disabled={!consentGiven || submitting}
>
<View
style={{
backgroundColor: consentGiven && !submitting ? '#007AFF' : '#d4d4d4',
borderRadius: 14,
paddingVertical: 14,
alignItems: 'center',
}}
>
{submitting ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={{ fontSize: 15, fontFamily: 'Nunito_700Bold', color: '#fff' }}>
{t('mail.consent.reminder_cta_consent')}
</Text>
)}
</View>
</TouchableOpacity>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', gap: 12 }}>
<TouchableOpacity activeOpacity={0.7} onPress={handleLater} style={{ flex: 1 }}>
<View
style={{
borderRadius: 14,
paddingVertical: 12,
alignItems: 'center',
backgroundColor: colors.surfaceElevated,
}}
>
<Text
style={{
fontSize: 14,
fontFamily: 'Nunito_600SemiBold',
color: colors.textMuted,
}}
>
{t('mail.consent.reminder_cta_later')}
</Text>
</View>
</TouchableOpacity>
<TouchableOpacity activeOpacity={0.7} onPress={handleDisconnect} style={{ flex: 1 }}>
<View
style={{
borderRadius: 14,
paddingVertical: 12,
alignItems: 'center',
backgroundColor: 'rgba(220,38,38,0.08)',
}}
>
<Text
style={{
fontSize: 14,
fontFamily: 'Nunito_600SemiBold',
color: colors.error,
}}
>
{t('mail.consent.reminder_cta_disconnect')}
</Text>
</View>
</TouchableOpacity>
</View>
</View>
</TrueSheet>
);
}

View File

@ -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<string, number> = {};
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<typeof import('../../lib/theme').useColors>;
};
function BarChart({ buckets, labels, highlightIndices, colors }: BarChartProps) {
const maxCount = Math.max(...buckets, 1);
return (
<View style={{ flexDirection: 'row', alignItems: 'flex-end', gap: 2 }}>
{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 (
<View key={i} style={{ flex: 1, alignItems: 'center' }}>
<View
style={{
width: '100%',
height: barHeight,
borderRadius: 3,
backgroundColor: isEmpty
? colors.border
: isHighlight
? colors.brandOrange
: colors.brandOrange + 'aa',
}}
/>
{labels[i] !== '' ? (
<Text
style={{
marginTop: 4,
fontSize: 8,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
textAlign: 'center',
}}
numberOfLines={1}
>
{labels[i]}
</Text>
) : (
<View style={{ height: 12 }} />
)}
</View>
);
})}
</View>
);
}
type SectionHeadingProps = { label: string; colors: ReturnType<typeof import('../../lib/theme').useColors> };
function SectionHeading({ label, colors }: SectionHeadingProps) {
return (
<Text
style={{
fontSize: 11,
color: colors.textMuted,
fontFamily: 'Nunito_700Bold',
letterSpacing: 0.6,
marginBottom: 10,
}}
>
{label.toUpperCase()}
</Text>
);
}
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 (
<View style={{ marginTop: 8 }}>
<TouchableOpacity onPress={toggle} activeOpacity={0.7}>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 10,
paddingHorizontal: 2,
}}
>
<Text
style={{
fontSize: 12,
color: colors.textMuted,
fontFamily: 'Nunito_600SemiBold',
}}
>
{t('profile.cooldown.patterns.toggle_label')}
</Text>
<Ionicons
name={expanded ? 'chevron-down' : 'chevron-forward'}
size={14}
color={colors.textMuted}
/>
</View>
</TouchableOpacity>
{expanded ? (
<View
style={{
backgroundColor: colors.card,
borderWidth: 1,
borderColor: colors.border,
borderRadius: 14,
padding: 16,
gap: 20,
}}
>
{!hasData ? (
<Text
style={{
fontSize: 13,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
textAlign: 'center',
paddingVertical: 8,
}}
>
{t('profile.cooldown.patterns.not_enough')}
</Text>
) : (
<>
<View>
<SectionHeading
label={t('profile.cooldown.patterns.hour_heading')}
colors={colors}
/>
<BarChart
buckets={hourBuckets}
labels={hourLabels}
highlightIndices={peakHour >= 0 ? [peakHour] : []}
colors={colors}
/>
</View>
<View
style={{
height: 1,
backgroundColor: colors.border,
}}
/>
<View>
<SectionHeading
label={t('profile.cooldown.patterns.day_heading')}
colors={colors}
/>
<BarChart
buckets={weekdayBuckets}
labels={weekdayLabels}
highlightIndices={peakDay >= 0 ? [peakDay] : []}
colors={colors}
/>
</View>
<View
style={{
height: 1,
backgroundColor: colors.border,
}}
/>
<View>
<SectionHeading
label={t('profile.cooldown.patterns.reason_heading')}
colors={colors}
/>
{topWords.length >= 3 ? (
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginBottom: 12 }}>
{topWords.map(({ word, count }) => (
<View
key={word}
style={{
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.border,
borderRadius: 999,
paddingVertical: 5,
paddingHorizontal: 12,
gap: 6,
}}
>
<Text
style={{
fontSize: 12,
color: colors.text,
fontFamily: 'Nunito_600SemiBold',
}}
>
{word}
</Text>
<Text
style={{
fontSize: 11,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
}}
>
({count})
</Text>
</View>
))}
</View>
) : (
<Text
style={{
fontSize: 12,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
marginBottom: 12,
}}
>
{t('profile.cooldown.patterns.not_enough')}
</Text>
)}
<Text
style={{
fontSize: 12,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
}}
>
{t('profile.cooldown.patterns.cancel_rate', { pct })}
</Text>
</View>
</>
)}
</View>
) : null}
</View>
);
}

View File

@ -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 }
</Text>
) : null}
</View>
<CooldownPatternAnalysis rawCooldowns={rawCooldowns} />
</View>
</View>
);

View File

@ -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<SosInsightsData>(
'/api/profile/me/sos-insights',

View File

@ -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": {

View File

@ -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": {

View File

@ -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<MailConnectDraftState>((set) => ({
...INITIAL,
setView: (view) => set({ view }),
setConsentGiven: (consentGiven) => set({ consentGiven }),
setSelectedProvider: (selectedProvider) => set({ selectedProvider }),
setEmail: (email) => set({ email }),
reset: () => set(INITIAL),
}));

View File

@ -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<MailConsentState>((set) => ({
visible: false,
connections: [],
show: (connections) => set({ visible: true, connections }),
hide: () => set({ visible: false }),
markConsented: () => set({ visible: false, connections: [] }),
}));

View File

@ -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: "<uuid>", 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

View File

@ -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 10500 KB pro Mail sein
- Latenz: Provider drosseln große FETCH-Requests (Gmail: ~10 concurrent)
- Memory: 200 Mails × ∅ 50 KB = 10 MB pro Scan-Session
**Empfehlung für body-Match:** Nicht im Batch-Fetch. Stattdessen: erst `envelope`-Scan wie bisher. Wenn envelope-Match (statisch oder subject_sender-keyword) NICHT anschlägt UND User hat body-keywords: dann lazy FETCH TEXT für diese Mail einzeln, `imap.fetchOne()`. Das hält den Common-Path (header-only) schnell.
Alternativ: body-scope aus MVP raushalten, erst in Phase 2. Der subject_sender-scope deckt 90% der Fälle ab — Gambling-Anbieter setzen das Wichtigste ins Subject.
### 7.2 False-Positive-Risiken
**Kernproblem:** Substring-Match ist breit. "spiel" matcht "Spielzeug-Newsletter", "wettkampf" matcht nicht (Whitelist), aber "sport" würde "Sport Bild" treffen.
**Maßnahmen:**
| Maßnahme | Umsetzung | Schutz-Level |
|---|---|---|
| Min-Länge 4 Zeichen | Server-Validation beim Anlegen | Blockt "ca", "bet" als Standalone |
| Existierende GAMBLING_WHITELIST | Wird vor custom-keyword-check angewandt | Schützt "wetter", "wettkampf" etc. |
| User trägt Verantwortung | Terms of Service + UI-Hinweis | Verhaltens-Steuerung |
| Action im Log sichtbar | User sieht welches Keyword griff | User kann Keyword nachbessern |
**Kein Confidence-Score** für MVP. Zu komplex, zu wenig Mehrwert wenn User Kontrolle hat.
**Kein Trash-statt-Delete** für MVP. Begründung: Der bestehende Mechanismus löscht hart. Ein gemischtes Verhalten (statische Keywords → hart löschen, custom keywords → Trash) ist UX-inkonsistent und macht dem User das Modell schwerer verständlich. Wenn Trash generell gewünscht ist, ist das ein separates Feature für alle Löschungen.
### 7.3 Regex-Injection
User-Input wird vor Regex-Kompilierung escaped (`keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')`). Kein Injection-Risiko. Das ist Server-side Pflicht, nicht optional.
---
## 8. Aufwand-Schätzung
| Komponente | Aufwand | Abhängigkeit |
|---|---|---|
| Schema: neue Tabelle `user_mail_keywords` | 0.5 PT | rebreak-backend |
| Schema: `matchedKeyword`-Feld in `MailBlocked` (optional) | 0.25 PT | rebreak-backend |
| `PLAN_LIMITS` erweitern (`customKeywords`, `customKeywordBodyMatch`) | 0.25 PT | Mo |
| `getUserCustomKeywords()` in `server/db/mail.ts` | 0.25 PT | Mo |
| `scan-internal.post.ts` + `scan.post.ts` erweitern | 0.5 PT | Mo |
| Neue API-Endpoints (CRUD für Keywords) | 0.5 PT | Mo |
| UI: Keyword-Manager in Mail-Tab | 1.0 PT | rebreak-ui |
| **Gesamt MVP (subject_sender, kein body)** | **3.25 PT** | — |
| Body-Match (Legend, Phase 2) | +1.0 PT | Mo + rebreak-backend |
MVP-Definition: `subject_sender`-Scope, Pro 10 / Legend 50 Limit, CRUD-Endpoints, UI-Integration. Kein body-Match.
**Hauptaufwand liegt in der UI** (Tag-Input-Komponente, Plan-Gate-Anzeige, Activity-Log-Erweiterung). Der Backend-Teil ist überschaubar, da das Matching-Pattern aus `scan-internal.post.ts` minimal erweitert wird.
---
## 9. Open Questions
**Q1: Body-Match in Phase 1 oder Phase 2?**
Empfehlung: Phase 2. Subject_sender deckt den Hauptanwendungsfall. Body-Match bringt IMAP-Traffic-Overhead und neue Edge-Cases (MIME-decoding, encoding-Vielfalt). Nicht für MVP.
**Q2: matchedKeyword in MailBlocked speichern?**
Für User-Feedback wichtig (er sieht welches Keyword griff). Aber DSB-Review empfohlen (hans-mueller). Alternativvorschlag: im ersten MVP nur `action="deleted_custom_keyword"` ohne Keyword-Text. Das reicht für statistische Auswertung, lässt das Detail-Thema offen.
**Q3: Regex-OR vs. `.some()` + `.includes()`?**
Bei <= 10 Keywords: vernachlässigbarer Unterschied. Regex-OR ist trotzdem besser als Pattern, weil es skaliert und weil ein einzelner `regex.test()` call klarer ist als eine Schleife. Einigung vorab verhindert spätere Refactors.
**Q4: Keyword-Deaktivierung ohne Löschen?**
User möchte vielleicht ein Keyword temporär pausieren. Nicht für MVP — erhöht Schema-Komplexität (aktives `enabled`-Flag). User kann löschen und neu anlegen.
**Q5: Case-Sensitivity für Deutsche Umlaute**
JavaScript V8 `.toLowerCase()` auf String mit ä/ö/ü: `"Ö".toLowerCase() === "ö"` — korrekt. Kein ICU-Library-Problem in Node.js >= 13 (full-icu built-in). Kein Handlungsbedarf, aber in Integrations-Test verifizieren.
**Q6: Keyword-Import / Bulk-Add?**
Kein MVP-Feature. Pro-User kann 10 Keywords manuell eingeben. Bulk-CSV-Import ist Overkill für diesen Use-Case.
---
## Eskalationen
| Thema | Agent |
|---|---|
| Schema-Migration (`user_mail_keywords`, `MailBlocked.matchedKeyword`) | rebreak-backend |
| DSB-Review `matchedKeyword`-Feld | hans-mueller |
| UI-Implementierung (Tag-Input, Mail-Settings, Activity-Log) | rebreak-ui |
| Plan-Tier-Änderungen jenseits `customKeywords`-Feld | Orchestrator |

View File

@ -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

View File

@ -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");

View File

@ -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.

View File

@ -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.

View File

@ -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=<refresh_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 };
});

View File

@ -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,
};
});

View File

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

View File

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

View File

@ -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)

View File

@ -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,
},
});
}

View File

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

View File

@ -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<string, ConsentTextEntry> = {
"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);
}