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