From b7909d77e4d9a33566c508dc203da287f9e57af5 Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Wed, 13 May 2026 19:06:01 +0200 Subject: [PATCH] feat(mail): custom title + settings collapsible + stats charts + provider filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mail-Page-Refactor — Privacy-friendly + DiGA-tauglich: - Custom title pro mail-connection (z.B. "Privat-Gmail" statt voller E-Mail). Memory-Pattern: Anonymität via Nickname jetzt auch für Mail-Adressen sichtbar, Datenminimierung. Title nullable, Fallback auf Email-Domain. - Schema-Migration mail_connection_title (additiv, NULL default für Bestand) - Endpoint PATCH /api/mail-connections/:id mit title-Validation (max 60, trim, leerer String → NULL) - "Passwort ändern"-Collapsible → vollwertige "Einstellungen"-Sektion: Title editieren · Email read-only · Passwort neu setzen · Verbindung trennen (mit Confirm-Dialog) - EditMailTitleSheet als FormSheet-Pattern für Title-Edit - mailConnectDraft-Store kriegt Title-Feld für Pre-Fill bei Re-Open Zwei neue Stats-Charts auf der Mail-Page: - MailBlockedByDayChart — 30-Tage-Bar-Chart, Plain-View-Bars (Pattern wie Sparkline-Profile), Empty-State bei 0 Cooldowns · Backend: GET /api/mail/stats/blocked-by-day?days=30 - MailDistributionChart — Half-Donut via react-native-svg, Top-5 Connections + "Sonstige", rendert nicht bei ≤1 Connection · Backend: GET /api/mail/stats/blocked-by-connection Activity-Log mit Provider-Filter: - Filter-Chips Mo Gmail/Outlook/iCloud/etc. über bestehendem Activity-Log - GET /api/mail/results?provider=X (war vorher hardcoded all) - Endpoint-Naming-Fix in useMailResults (war /api/mail/blocked, jetzt korrekt /api/mail/results — UI-Agent hatte falschen Path geraten) Backend-Side-Effects: - imap-providers util resolveProviderMeta(host) — gibt {provider, label, isCustomDomain} zurück, von 3 Endpoints konsumiert - /api/mail/status erweitert: title, provider, providerLabel, isCustomDomain im Account-Shape - /api/mail/results erweitert: connection-Sub-Objekt pro Entry + provider-Filter-Query Open follow-ups (TODOs): - deleteOldMailBlocked-Cron löscht <24h → Bar-Chart-Daten weg. Retention auf 90 Tage hochsetzen oder Cron stoppen. - POST /api/mail/connect könnte die neue connection.id im Response mitliefern → Title-PATCH direkt ohne Extra-GET (UI-Agent-Empfehlung). - /api/mail/status zeigt nur active Connections — paused mit Title wären unsichtbar. Entscheiden. 18 neue i18n-Keys (mail.title_*, mail.settings_*, mail.row_*, mail.disconnect_confirm_*, mail.stats.*, mail.filter.all) in DE + EN. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/rebreak-native/app/(app)/mail.tsx | 29 +- .../components/mail/ConnectMailSheet.tsx | 39 +++ .../components/mail/EditMailTitleSheet.tsx | 101 ++++++ .../components/mail/MailAccountCard.tsx | 289 +++++++++++++----- .../components/mail/MailActivityLog.tsx | 149 +++++++-- .../components/mail/MailBlockedByDayChart.tsx | 148 +++++++++ .../components/mail/MailDistributionChart.tsx | 172 +++++++++++ apps/rebreak-native/hooks/useMailResults.ts | 14 +- apps/rebreak-native/hooks/useMailStats.ts | 49 +++ apps/rebreak-native/hooks/useMailStatus.ts | 1 + apps/rebreak-native/hooks/useMailTitleEdit.ts | 26 ++ apps/rebreak-native/locales/de.json | 22 ++ apps/rebreak-native/locales/en.json | 22 ++ .../rebreak-native/stores/mailConnectDraft.ts | 6 +- .../migration.sql | 12 + backend/prisma/schema.prisma | 5 + .../server/api/mail-connections/[id].patch.ts | 47 +++ backend/server/api/mail/results.get.ts | 82 ++++- .../mail/stats/blocked-by-connection.get.ts | 32 ++ .../api/mail/stats/blocked-by-day.get.ts | 25 ++ backend/server/api/mail/status.get.ts | 43 +-- backend/server/db/mail.ts | 108 ++++++- backend/server/utils/imap-providers.ts | 33 ++ 23 files changed, 1310 insertions(+), 144 deletions(-) create mode 100644 apps/rebreak-native/components/mail/EditMailTitleSheet.tsx create mode 100644 apps/rebreak-native/components/mail/MailBlockedByDayChart.tsx create mode 100644 apps/rebreak-native/components/mail/MailDistributionChart.tsx create mode 100644 apps/rebreak-native/hooks/useMailStats.ts create mode 100644 apps/rebreak-native/hooks/useMailTitleEdit.ts create mode 100644 backend/prisma/migrations/20260513_mail_connection_title/migration.sql create mode 100644 backend/server/api/mail-connections/[id].patch.ts create mode 100644 backend/server/api/mail/stats/blocked-by-connection.get.ts create mode 100644 backend/server/api/mail/stats/blocked-by-day.get.ts diff --git a/apps/rebreak-native/app/(app)/mail.tsx b/apps/rebreak-native/app/(app)/mail.tsx index 9a29a52..ba8db1b 100644 --- a/apps/rebreak-native/app/(app)/mail.tsx +++ b/apps/rebreak-native/app/(app)/mail.tsx @@ -2,7 +2,6 @@ import { useState } from 'react'; import { ActivityIndicator, Alert, - Pressable, ScrollView, Text, TouchableOpacity, @@ -16,10 +15,13 @@ import { MailStatsRow } from '../../components/mail/MailStatsRow'; import { MailAccountCard } from '../../components/mail/MailAccountCard'; import { MailEmptyState } from '../../components/mail/MailEmptyState'; import { MailActivityLog } from '../../components/mail/MailActivityLog'; +import { MailBlockedByDayChart } from '../../components/mail/MailBlockedByDayChart'; +import { MailDistributionChart } from '../../components/mail/MailDistributionChart'; import { ConnectMailSheet } from '../../components/mail/ConnectMailSheet'; import { SuccessAlert } from '../../components/SuccessAlert'; import { useMailStatus } from '../../hooks/useMailStatus'; import { useMailDisconnect } from '../../hooks/useMailDisconnect'; +import { useMailStats } from '../../hooks/useMailStats'; import { useUserPlan } from '../../hooks/useUserPlan'; import { useColors } from '../../lib/theme'; @@ -87,6 +89,8 @@ export default function MailScreen() { const { connected, accounts, totalBlocked, maxAccounts, loading, refresh } = useMailStatus(plan); const { disconnect, disconnecting } = useMailDisconnect(); + const hasAccounts = accounts.length > 0; + const { blockedByDay, blockedByConnection } = useMailStats(hasAccounts); const [sheetVisible, setSheetVisible] = useState(false); const [successVisible, setSuccessVisible] = useState(false); @@ -104,6 +108,10 @@ export default function MailScreen() { const overLimit = maxAccounts !== Infinity && accounts.length > maxAccounts; const limitReached = maxAccounts !== Infinity && accounts.length >= maxAccounts; + const distinctProviders = [ + ...new Set(accounts.map((a) => a.provider.toLowerCase())), + ]; + function handleAddPress() { if (limitReached) { Alert.alert(t('mail.upgrade_alert_title'), t('mail.upgrade_alert_desc')); @@ -152,7 +160,7 @@ export default function MailScreen() { }} showsVerticalScrollIndicator={false} > - {/* Über-Limit-Banner: nur wenn Backend paused-Feld liefert + over limit */} + {/* Über-Limit-Banner */} {overLimit && pausedAccounts.length > 0 && ( 0 && ( + {hasAccounts && ( )} - {/* Section header with prominent + button — hidden in empty state (CTA lives there) */} - {accounts.length > 0 && ( + {/* Section header + add button */} + {hasAccounts && ( )} + {/* Charts — nur wenn Accounts vorhanden */} + {hasAccounts && ( + + + + + )} + {/* Activity log */} - {accounts.length > 0 && ( + {hasAccounts && ( setActivityLogExpanded((p) => !p)} + providers={distinctProviders} /> )} diff --git a/apps/rebreak-native/components/mail/ConnectMailSheet.tsx b/apps/rebreak-native/components/mail/ConnectMailSheet.tsx index eee56b7..640109f 100644 --- a/apps/rebreak-native/components/mail/ConnectMailSheet.tsx +++ b/apps/rebreak-native/components/mail/ConnectMailSheet.tsx @@ -109,10 +109,12 @@ export function ConnectMailSheet({ visible, onClose, onSuccess }: Props) { consentGiven, selectedProvider, email, + title, setView, setConsentGiven, setSelectedProvider, setEmail, + setTitle, reset: resetDraft, } = useMailConnectDraft(); @@ -130,6 +132,19 @@ export function ConnectMailSheet({ visible, onClose, onSuccess }: Props) { onClose(); } + function defaultTitleForProvider(provider: ProviderConfig | null): string { + if (!provider) return ''; + const labelMap: Record = { + gmail: 'Mein Gmail', + icloud: 'Mein iCloud', + outlook: 'Mein Outlook', + yahoo: 'Mein Yahoo', + gmx: 'Mein GMX', + other: '', + }; + return labelMap[provider.id] ?? ''; + } + function handleConsentNext() { setView('grid'); } @@ -138,6 +153,7 @@ export function ConnectMailSheet({ visible, onClose, onSuccess }: Props) { setSelectedProvider(provider); setEmail(''); setPassword(''); + setTitle(defaultTitleForProvider(provider)); setFormError(null); setFieldsComplete(false); setView('form'); @@ -157,6 +173,20 @@ export function ConnectMailSheet({ visible, onClose, onSuccess }: Props) { const result = await connect({ email: email.trim(), password }); if (result.ok) { + if (title.trim()) { + try { + const connections = await apiFetch<{ id: string; email: string }[]>('/api/mail-connections'); + const match = connections.find((c) => c.email === email.trim()); + if (match) { + await apiFetch(`/api/mail-connections/${match.id}`, { + method: 'PATCH', + body: { title: title.trim() }, + }); + } + } catch { + // Title-PATCH ist best-effort — Connection selbst ist OK + } + } handleClose(); onSuccess(); } else if (result.error?.includes('412') || result.error?.includes('consent_required')) { @@ -205,6 +235,15 @@ export function ConnectMailSheet({ visible, onClose, onSuccess }: Props) { validate: (v) => v.trim().length === 0 ? t('mail.form_fields_required') : undefined, }, + { + key: 'title', + label: t('mail.title_label'), + placeholder: t('mail.title_placeholder'), + value: title, + onChangeText: setTitle, + autoCapitalize: 'sentences', + autoCorrect: false, + }, { key: 'password', label: t('mail.form_password_label'), diff --git a/apps/rebreak-native/components/mail/EditMailTitleSheet.tsx b/apps/rebreak-native/components/mail/EditMailTitleSheet.tsx new file mode 100644 index 0000000..85bbcc3 --- /dev/null +++ b/apps/rebreak-native/components/mail/EditMailTitleSheet.tsx @@ -0,0 +1,101 @@ +import { useState } from 'react'; +import { ActivityIndicator, Text, TouchableOpacity, View } from 'react-native'; +import { useTranslation } from 'react-i18next'; +import { useMailTitleEdit } from '../../hooks/useMailTitleEdit'; +import { FormSheet } from '../FormSheet'; +import { SheetFieldStack } from '../SheetFieldStack'; + +type Props = { + visible: boolean; + connectionId: string; + currentTitle: string | null; + onClose: () => void; + onSuccess: (newTitle: string | null) => void; +}; + +export function EditMailTitleSheet({ + visible, + connectionId, + currentTitle, + onClose, + onSuccess, +}: Props) { + const { t } = useTranslation(); + const { saveTitle, saving, error } = useMailTitleEdit(); + const [title, setTitle] = useState(currentTitle ?? ''); + + function handleClose() { + setTitle(currentTitle ?? ''); + onClose(); + } + + async function handleSave() { + const ok = await saveTitle(connectionId, title); + if (ok) { + onSuccess(title.trim() || null); + onClose(); + } + } + + return ( + + {}} + > + {error && ( + + {error} + + )} + + + + {saving ? ( + + ) : ( + + {t('mail.title_save')} + + )} + + + + + ); +} diff --git a/apps/rebreak-native/components/mail/MailAccountCard.tsx b/apps/rebreak-native/components/mail/MailAccountCard.tsx index fcda189..f10755e 100644 --- a/apps/rebreak-native/components/mail/MailAccountCard.tsx +++ b/apps/rebreak-native/components/mail/MailAccountCard.tsx @@ -3,7 +3,6 @@ import { ActivityIndicator, LayoutAnimation, Platform, - Pressable, TouchableOpacity, Text, UIManager, @@ -13,6 +12,7 @@ import { Ionicons } from '@expo/vector-icons'; import { useTranslation } from 'react-i18next'; import { ConfirmAlert } from '../ConfirmAlert'; import { EditMailAccountSheet } from './EditMailAccountSheet'; +import { EditMailTitleSheet } from './EditMailTitleSheet'; import { useMailInterval } from '../../hooks/useMailInterval'; import type { MailAccount } from '../../hooks/useMailStatus'; @@ -105,7 +105,6 @@ function StatusBadgeRow({ isLegend: boolean; t: (k: string, opts?: Record) => string; }) { - // Priority 1 — auth / connect error if (account.lastConnectError) { const isAuthError = account.lastConnectError.toLowerCase().includes('invalid credentials') || @@ -138,7 +137,6 @@ function StatusBadgeRow({ ); } - // Priority 5 — never connected if (!account.lastScannedAt) { return ( @@ -155,7 +153,6 @@ function StatusBadgeRow({ const scannedAgo = Date.now() - lastScannedTs.getTime(); const scannedRelAbs = formatRelativeAbsolute(lastScannedTs); - // Priority 4 — stale: heartbeat missing/expired AND scan is old if (!heartbeatAlive && scannedAgo > STALE_THRESHOLD_MS) { return ( @@ -177,14 +174,12 @@ function StatusBadgeRow({ ); } - // Priority 2 + 3 — heartbeat alive (or scan recent enough for pre-migration backend) if (heartbeatAlive) { const heartbeatTs = new Date(account.lastIdleHeartbeatAt!); const heartbeatMin = Math.floor((Date.now() - heartbeatTs.getTime()) / 60_000); const idleSince = heartbeatMin < 1 ? 'gerade eben' : `${heartbeatMin} min`; if (scannedAgo > NO_NEW_MAIL_THRESHOLD_MS) { - // Priority 3 — connected but no new mail for >1h return ( @@ -205,7 +200,6 @@ function StatusBadgeRow({ ); } - // Priority 2 — live + heartbeat recent + scan recent return ( @@ -226,7 +220,6 @@ function StatusBadgeRow({ ); } - // Fallback — scan recent, backend without heartbeat field return ( @@ -241,7 +234,7 @@ function StatusBadgeRow({ style={{ fontSize: 10, fontFamily: 'Nunito_400Regular', color: '#a3a3a3', marginTop: 1 }} numberOfLines={1} > - {scannedRelAbs} + {formatRelativeAbsolute(new Date(account.lastScannedAt!))} ); @@ -253,21 +246,78 @@ const INTERVAL_OPTIONS_BY_PLAN: Record<'free' | 'pro' | 'legend', number[]> = { legend: [1, 4, 8], }; -const HEADER_ROW = { - flexDirection: 'row' as const, - alignItems: 'center' as const, - paddingHorizontal: 14, - paddingVertical: 14, -}; +function maskEmail(email: string): string { + const [local, domain] = email.split('@'); + if (!local || !domain) return email; + if (local.length <= 3) return `${local[0]}***@${domain}`; + return `${local.slice(0, 3)}***@${domain}`; +} -const ACTION_BTN_BASE = { - flex: 1, - flexDirection: 'row' as const, - alignItems: 'center' as const, - justifyContent: 'center' as const, - paddingVertical: 12, - borderRadius: 10, -}; +function domainFromEmail(email: string): string { + return email.split('@')[1] ?? email; +} + +function SettingsRow({ + icon, + label, + value, + onPress, + destructive, +}: { + icon: React.ComponentProps['name']; + label: string; + value?: string; + onPress?: () => void; + destructive?: boolean; +}) { + const color = destructive ? '#dc2626' : '#0a0a0a'; + const Wrapper = onPress ? TouchableOpacity : View; + const wrapperProps = onPress + ? { activeOpacity: 0.7, onPress } + : {}; + + return ( + + + + {label} + + {value !== undefined && ( + + {value} + + )} + {onPress && !destructive && ( + + )} + + ); +} export function MailAccountCard({ account, @@ -281,7 +331,9 @@ export function MailAccountCard({ }: Props) { const { t } = useTranslation(); const [confirmVisible, setConfirmVisible] = useState(false); - const [editVisible, setEditVisible] = useState(false); + const [editPasswordVisible, setEditPasswordVisible] = useState(false); + const [editTitleVisible, setEditTitleVisible] = useState(false); + const [localTitle, setLocalTitle] = useState(account.title ?? null); const { setInterval, updating } = useMailInterval(); const { icon, color } = resolveProviderIcon(account.provider); @@ -289,9 +341,12 @@ export function MailAccountCard({ const isPaused = account.paused === true; const intervalOptions = INTERVAL_OPTIONS_BY_PLAN[plan]; + const displayTitle = localTitle ?? domainFromEmail(account.email); + const subEmail = maskEmail(account.email); + function handleToggle() { if (account.lastConnectError) { - setEditVisible(true); + setEditPasswordVisible(true); return; } LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); @@ -303,6 +358,11 @@ export function MailAccountCard({ if (res.ok) onIntervalChanged(); } + function handleTitleSaved(newTitle: string | null) { + setLocalTitle(newTitle); + onEditSuccess(); + } + return ( <> - {/* Header */} - - + {/* Header — always visible, tap to expand settings */} + + + {/* Title — prominent */} - {account.email} + {displayTitle} + + {/* Email — small sub-label */} + + {subEmail} {isPaused ? @@ -351,16 +435,19 @@ export function MailAccountCard({ color="#a3a3a3" /> - + - {/* Body */} + {/* Collapsible: Settings section */} {expanded && ( - + + {/* Stats banner */} + {/* Scan interval (non-legend) */} {isLegend ? ( ) : ( - + )} - - setEditVisible(true)} - style={{ ...ACTION_BTN_BASE, backgroundColor: '#f5f5f5', marginRight: 6 }} - > - - - {t('mail.account_change_password')} - - - setConfirmVisible(true)} - disabled={disconnecting} + {/* Settings separator label */} + + - {disconnecting ? ( - - ) : ( - <> - - - {t('mail.disconnect')} - - - )} - + {t('mail.settings_section_label')} + + + {/* Settings rows */} + setEditTitleVisible(true)} + /> + + + + setEditPasswordVisible(true)} + /> + + setConfirmVisible(true)} + disabled={disconnecting} + style={{ + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 12, + paddingHorizontal: 14, + borderTopWidth: 1, + borderTopColor: '#f5f5f5', + opacity: disconnecting ? 0.5 : 1, + }} + > + {disconnecting ? ( + + ) : ( + + )} + + {t('mail.row_disconnect')} + + )} setEditVisible(false)} + onClose={() => setEditPasswordVisible(false)} onSuccess={onEditSuccess} /> + + setEditTitleVisible(false)} + onSuccess={handleTitleSaved} + /> ); } diff --git a/apps/rebreak-native/components/mail/MailActivityLog.tsx b/apps/rebreak-native/components/mail/MailActivityLog.tsx index 72e3ce7..4a152e3 100644 --- a/apps/rebreak-native/components/mail/MailActivityLog.tsx +++ b/apps/rebreak-native/components/mail/MailActivityLog.tsx @@ -1,7 +1,8 @@ +import { useState } from 'react'; import { LayoutAnimation, Platform, - Pressable, + ScrollView, TouchableOpacity, Text, UIManager, @@ -10,6 +11,7 @@ import { import { Ionicons } from '@expo/vector-icons'; import { useTranslation } from 'react-i18next'; import { useMailResults, type MailBlockedItem } from '../../hooks/useMailResults'; +import { useColors } from '../../lib/theme'; if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) { UIManager.setLayoutAnimationEnabledExperimental(true); @@ -18,6 +20,7 @@ if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental type Props = { expanded: boolean; onToggle: () => void; + providers?: string[]; }; function formatDate(iso: string, t: (k: string) => string): string { @@ -30,26 +33,51 @@ function formatDate(iso: string, t: (k: string) => string): string { return `${Math.floor(hours / 24)}d`; } -export function MailActivityLog({ expanded, onToggle }: Props) { +function domainFromEmail(email: string): string { + return email.split('@')[1] ?? email; +} + +function providerDisplayName(provider: string): string { + const map: Record = { + gmail: 'Gmail', + icloud: 'iCloud', + outlook: 'Outlook', + yahoo: 'Yahoo', + gmx: 'GMX', + other: 'Andere', + }; + return map[provider.toLowerCase()] ?? provider; +} + +export function MailActivityLog({ expanded, onToggle, providers = [] }: Props) { const { t } = useTranslation(); - const { results, total, loading, refresh } = useMailResults(expanded); + const colors = useColors(); + const [activeProvider, setActiveProvider] = useState('all'); + + const { results, total, loading, refresh } = useMailResults(expanded, activeProvider); + + const filterOptions = ['all', ...providers]; function handleToggle() { LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); onToggle(); } + function handleProviderFilter(p: string) { + setActiveProvider(p); + } + return ( - + {t('mail.activity_log_title')} @@ -82,7 +110,7 @@ export function MailActivityLog({ expanded, onToggle }: Props) { style={{ fontSize: 11, fontFamily: 'Nunito_400Regular', - color: '#a3a3a3', + color: colors.textMuted, marginTop: 2, }} numberOfLines={1} @@ -93,27 +121,65 @@ export function MailActivityLog({ expanded, onToggle }: Props) { - + {expanded && ( - + + {/* Provider filter chips */} + {filterOptions.length > 1 && ( + + {filterOptions.map((p) => { + const active = activeProvider === p; + return ( + handleProviderFilter(p)} + style={{ + paddingHorizontal: 12, + paddingVertical: 5, + borderRadius: 999, + backgroundColor: active ? '#007AFF' : colors.surfaceElevated, + borderWidth: active ? 0 : 1, + borderColor: colors.border, + }} + > + + {p === 'all' ? t('mail.filter.all') : providerDisplayName(p)} + + + ); + })} + + )} + {loading && results.length === 0 ? ( - + {t('mail.loading')} ) : results.length === 0 ? ( - + @@ -123,7 +189,7 @@ export function MailActivityLog({ expanded, onToggle }: Props) { ) : ( <> {results.slice(0, 10).map((item) => ( - + ))} - + {total > 10 ? t('mail.activity_log_more', { count: total - 10 }) : t('mail.activity_log_count', { count: total })} - + @@ -156,10 +222,16 @@ export function MailActivityLog({ expanded, onToggle }: Props) { function ActivityItem({ item, t, + colors, }: { item: MailBlockedItem; t: (k: string, opts?: any) => string; + colors: ReturnType; }) { + const accountLabel = item.connection_title ?? ( + item.sender_email ? domainFromEmail(item.sender_email) : null + ); + return ( - - {item.subject || t('mail.activity_no_subject')} - + + + {item.subject || t('mail.activity_no_subject')} + + {accountLabel && ( + + + {accountLabel} + + + )} + diff --git a/apps/rebreak-native/components/mail/MailBlockedByDayChart.tsx b/apps/rebreak-native/components/mail/MailBlockedByDayChart.tsx new file mode 100644 index 0000000..e8cae7c --- /dev/null +++ b/apps/rebreak-native/components/mail/MailBlockedByDayChart.tsx @@ -0,0 +1,148 @@ +import { useMemo } from 'react'; +import { Text, View } from 'react-native'; +import { useTranslation } from 'react-i18next'; +import { useColors } from '../../lib/theme'; +import type { BlockedByDayEntry } from '../../hooks/useMailStats'; + +type Props = { + data: BlockedByDayEntry[]; +}; + +const BAR_AREA_HEIGHT = 64; +const MIN_BAR_HEIGHT = 3; + +function formatAxisLabel(dateStr: string): string { + const d = new Date(dateStr + 'T00:00:00'); + return `${d.getDate()}.${d.getMonth() + 1}.`; +} + +export function MailBlockedByDayChart({ data }: Props) { + const { t } = useTranslation(); + const colors = useColors(); + + const allZero = data.every((d) => d.count === 0); + const total = data.reduce((s, d) => s + d.count, 0); + const weekAvg = data.length >= 7 + ? Math.round(data.slice(-7).reduce((s, d) => s + d.count, 0)) + : total; + + const maxCount = useMemo(() => Math.max(...data.map((d) => d.count), 1), [data]); + + const axisIndices = useMemo(() => { + if (data.length === 0) return []; + const step = Math.floor(data.length / 4); + return [0, step, step * 2, step * 3, data.length - 1].filter( + (v, i, arr) => arr.indexOf(v) === i, + ); + }, [data]); + + return ( + + + {t('mail.stats.blocked_per_day_heading')} + + + {allZero ? ( + + + {t('mail.stats.empty_title')} + + + {t('mail.stats.empty_body')} + + + ) : ( + <> + {/* Bar chart */} + + {data.map((entry) => { + const barH = + entry.count > 0 + ? Math.max(MIN_BAR_HEIGHT, Math.round((entry.count / maxCount) * BAR_AREA_HEIGHT)) + : MIN_BAR_HEIGHT; + return ( + 0 ? colors.error : colors.border, + }} + /> + ); + })} + + + {/* Axis labels */} + + {axisIndices.map((idx) => { + const pct = data.length > 1 ? idx / (data.length - 1) : 0; + return ( + + {formatAxisLabel(data[idx].date)} + + ); + })} + + + {/* Summary line */} + + {t('mail.stats.blocked_per_day_sublabel', { total, avg: weekAvg })} + + + )} + + ); +} diff --git a/apps/rebreak-native/components/mail/MailDistributionChart.tsx b/apps/rebreak-native/components/mail/MailDistributionChart.tsx new file mode 100644 index 0000000..0de308e --- /dev/null +++ b/apps/rebreak-native/components/mail/MailDistributionChart.tsx @@ -0,0 +1,172 @@ +import { useMemo } from 'react'; +import { Text, View } from 'react-native'; +import Svg, { Path, Circle } from 'react-native-svg'; +import { useTranslation } from 'react-i18next'; +import { useColors } from '../../lib/theme'; +import type { BlockedByConnectionEntry } from '../../hooks/useMailStats'; + +type Props = { + data: BlockedByConnectionEntry[]; +}; + +const SLICE_COLORS = ['#ef4444', '#3b82f6', '#f59e0b', '#8b5cf6', '#10b981']; +const OTHER_COLOR = '#a3a3a3'; +const MAX_SLICES = 5; + +const R_OUTER = 54; +const R_INNER = 32; +const CX = 64; +const CY = 64; + +function domainFromEmail(email: string): string { + return email.split('@')[1] ?? email; +} + +function displayLabel(entry: BlockedByConnectionEntry): string { + return entry.title ?? domainFromEmail(entry.email); +} + +function polarToXY(cx: number, cy: number, r: number, angleDeg: number) { + const rad = ((angleDeg - 90) * Math.PI) / 180; + return { + x: cx + r * Math.cos(rad), + y: cy + r * Math.sin(rad), + }; +} + +function arcPath( + cx: number, + cy: number, + rOuter: number, + rInner: number, + startDeg: number, + endDeg: number, +): string { + const clampedEnd = Math.min(endDeg, startDeg + 179.99); + const outerStart = polarToXY(cx, cy, rOuter, startDeg); + const outerEnd = polarToXY(cx, cy, rOuter, clampedEnd); + const innerEnd = polarToXY(cx, cy, rInner, clampedEnd); + const innerStart = polarToXY(cx, cy, rInner, startDeg); + const large = clampedEnd - startDeg > 90 ? 1 : 0; + + return [ + `M ${outerStart.x} ${outerStart.y}`, + `A ${rOuter} ${rOuter} 0 ${large} 1 ${outerEnd.x} ${outerEnd.y}`, + `L ${innerEnd.x} ${innerEnd.y}`, + `A ${rInner} ${rInner} 0 ${large} 0 ${innerStart.x} ${innerStart.y}`, + 'Z', + ].join(' '); +} + +export function MailDistributionChart({ data }: Props) { + const { t } = useTranslation(); + const colors = useColors(); + + const total = data.reduce((s, d) => s + d.count, 0); + + const slices = useMemo(() => { + if (data.length === 0 || total === 0) return []; + + const sorted = [...data].sort((a, b) => b.count - a.count); + const top = sorted.slice(0, MAX_SLICES); + const rest = sorted.slice(MAX_SLICES); + + const items: { label: string; count: number; color: string }[] = top.map((e, i) => ({ + label: displayLabel(e), + count: e.count, + color: SLICE_COLORS[i], + })); + + if (rest.length > 0) { + items.push({ + label: t('mail.stats.distribution_other'), + count: rest.reduce((s, e) => s + e.count, 0), + color: OTHER_COLOR, + }); + } + + return items; + }, [data, total, t]); + + if (data.length <= 1 || total === 0) return null; + + let cursor = 0; + + return ( + + + {t('mail.stats.distribution_heading')} + + + + {/* Half-donut — upper half of a donut ring, 180° arc from left to right */} + + {slices.map((slice) => { + const sweep = (slice.count / total) * 180; + const startDeg = cursor; + cursor += sweep; + return ( + + ); + })} + {/* Center circle to keep donut look consistent */} + + + + {/* Legend */} + + {slices.map((slice) => ( + + + + {slice.label} + + + {slice.count} + + + ))} + + + + ); +} diff --git a/apps/rebreak-native/hooks/useMailResults.ts b/apps/rebreak-native/hooks/useMailResults.ts index 3f0cd4f..8360338 100644 --- a/apps/rebreak-native/hooks/useMailResults.ts +++ b/apps/rebreak-native/hooks/useMailResults.ts @@ -8,6 +8,8 @@ export type MailBlockedItem = { sender_name: string | null; received_at: string; connection_id: string; + connection_title?: string | null; + provider?: string | null; }; export type MailResultsResponse = { @@ -18,10 +20,11 @@ export type MailResultsResponse = { }; /** - * GET /api/mail/results — Liste der in den letzten 24h gelöschten Mails. - * Backend räumt selbst nach 24h auf (deleteOldMailBlocked). + * GET /api/mail/results — Liste der blockierten Mails mit optionalem Provider-Filter. + * Backend räumt selbst nach 24h auf (deleteOldMailBlocked) — Retention sollte für + * den 30-Tage-Bar-Chart auf 90 Tage hochgesetzt werden, sonst sind die Stats leer. */ -export function useMailResults(enabled: boolean = true) { +export function useMailResults(enabled: boolean = true, provider: string = 'all') { const [results, setResults] = useState([]); const [total, setTotal] = useState(0); const [loading, setLoading] = useState(false); @@ -31,8 +34,9 @@ export function useMailResults(enabled: boolean = true) { if (!enabled) return; setLoading(true); try { + const qs = provider !== 'all' ? `?provider=${encodeURIComponent(provider)}` : ''; const res = await apiFetch( - "/api/mail/results?page=1", + `/api/mail/results${qs}`, ); setResults(res.results ?? []); setTotal(res.total ?? 0); @@ -42,7 +46,7 @@ export function useMailResults(enabled: boolean = true) { } finally { setLoading(false); } - }, [enabled]); + }, [enabled, provider]); useEffect(() => { if (enabled) refresh(); diff --git a/apps/rebreak-native/hooks/useMailStats.ts b/apps/rebreak-native/hooks/useMailStats.ts new file mode 100644 index 0000000..ab36de3 --- /dev/null +++ b/apps/rebreak-native/hooks/useMailStats.ts @@ -0,0 +1,49 @@ +import { useCallback, useEffect, useState } from 'react'; +import { apiFetch } from '../lib/api'; + +export type BlockedByDayEntry = { + date: string; + count: number; +}; + +export type BlockedByConnectionEntry = { + connectionId: string; + title: string | null; + email: string; + providerLabel: string; + count: number; +}; + +type MailStatsState = { + blockedByDay: BlockedByDayEntry[]; + blockedByConnection: BlockedByConnectionEntry[]; + loading: boolean; +}; + +export function useMailStats(enabled: boolean) { + const [state, setState] = useState({ + blockedByDay: [], + blockedByConnection: [], + loading: false, + }); + + const fetch = useCallback(async () => { + if (!enabled) return; + setState((s) => ({ ...s, loading: true })); + try { + const [byDay, byConn] = await Promise.all([ + apiFetch('/api/mail/stats/blocked-by-day?days=30'), + apiFetch('/api/mail/stats/blocked-by-connection'), + ]); + setState({ blockedByDay: byDay, blockedByConnection: byConn, loading: false }); + } catch { + setState((s) => ({ ...s, loading: false })); + } + }, [enabled]); + + useEffect(() => { + fetch(); + }, [fetch]); + + return { ...state, refresh: fetch }; +} diff --git a/apps/rebreak-native/hooks/useMailStatus.ts b/apps/rebreak-native/hooks/useMailStatus.ts index a049cc0..5316e98 100644 --- a/apps/rebreak-native/hooks/useMailStatus.ts +++ b/apps/rebreak-native/hooks/useMailStatus.ts @@ -6,6 +6,7 @@ export type MailAccount = { id: string; email: string; provider: string; + title?: string | null; isActive: boolean; paused?: boolean; lastScannedAt: string | null; diff --git a/apps/rebreak-native/hooks/useMailTitleEdit.ts b/apps/rebreak-native/hooks/useMailTitleEdit.ts new file mode 100644 index 0000000..33c0ec8 --- /dev/null +++ b/apps/rebreak-native/hooks/useMailTitleEdit.ts @@ -0,0 +1,26 @@ +import { useState } from 'react'; +import { apiFetch } from '../lib/api'; + +export function useMailTitleEdit() { + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + async function saveTitle(connectionId: string, title: string): Promise { + setSaving(true); + setError(null); + try { + await apiFetch(`/api/mail-connections/${connectionId}`, { + method: 'PATCH', + body: { title: title.trim() || null }, + }); + return true; + } catch (e: any) { + setError(e?.message ?? 'unknown'); + return false; + } finally { + setSaving(false); + } + } + + return { saveTitle, saving, error }; +} diff --git a/apps/rebreak-native/locales/de.json b/apps/rebreak-native/locales/de.json index a62c27b..d2b8b84 100644 --- a/apps/rebreak-native/locales/de.json +++ b/apps/rebreak-native/locales/de.json @@ -439,6 +439,28 @@ "reminder_cta_later": "Später", "reminder_cta_disconnect": "Verbindungen jetzt trennen", "reminder_consent_error": "Einwilligung konnte nicht gespeichert werden. Bitte erneut versuchen." + }, + "title_label": "Bezeichnung", + "title_placeholder": "z.B. Privat-Gmail, Arbeit", + "title_edit_title": "Bezeichnung bearbeiten", + "title_save": "Speichern", + "settings_section_label": "Einstellungen", + "row_title": "Bezeichnung", + "row_email": "E-Mail", + "row_password": "Passwort", + "row_disconnect": "Verbindung trennen", + "disconnect_confirm_title": "Verbindung trennen?", + "disconnect_confirm_body": "%{email} wird getrennt und alle Scan-Daten gelöscht.", + "stats": { + "blocked_per_day_heading": "Blockiert — letzte 30 Tage", + "blocked_per_day_sublabel": "%{total} Mails blockiert · %{avg} letzte Woche", + "distribution_heading": "Verteilung nach Postfach", + "distribution_other": "Sonstige", + "empty_title": "Noch keine Mails blockiert", + "empty_body": "Sobald Mails blockiert werden, erscheint hier ein Überblick." + }, + "filter": { + "all": "Alle" } }, "settings": { diff --git a/apps/rebreak-native/locales/en.json b/apps/rebreak-native/locales/en.json index 5a0a544..419fffc 100644 --- a/apps/rebreak-native/locales/en.json +++ b/apps/rebreak-native/locales/en.json @@ -439,6 +439,28 @@ "reminder_cta_later": "Later", "reminder_cta_disconnect": "Disconnect now", "reminder_consent_error": "Failed to save consent. Please try again." + }, + "title_label": "Label", + "title_placeholder": "e.g. Personal Gmail, Work", + "title_edit_title": "Edit label", + "title_save": "Save", + "settings_section_label": "Settings", + "row_title": "Label", + "row_email": "Email", + "row_password": "Password", + "row_disconnect": "Disconnect", + "disconnect_confirm_title": "Disconnect mailbox?", + "disconnect_confirm_body": "%{email} will be disconnected and all scan data deleted.", + "stats": { + "blocked_per_day_heading": "Blocked — last 30 days", + "blocked_per_day_sublabel": "%{total} mails blocked · %{avg} last week", + "distribution_heading": "Distribution by mailbox", + "distribution_other": "Others", + "empty_title": "No mails blocked yet", + "empty_body": "Once mails are blocked, an overview will appear here." + }, + "filter": { + "all": "All" } }, "settings": { diff --git a/apps/rebreak-native/stores/mailConnectDraft.ts b/apps/rebreak-native/stores/mailConnectDraft.ts index 75a1b16..cc81ae2 100644 --- a/apps/rebreak-native/stores/mailConnectDraft.ts +++ b/apps/rebreak-native/stores/mailConnectDraft.ts @@ -17,22 +17,25 @@ type MailConnectDraftState = { consentGiven: boolean; selectedProvider: ProviderSnapshot | null; email: string; + title: string; setView: (view: 'consent' | 'grid' | 'form') => void; setConsentGiven: (v: boolean) => void; setSelectedProvider: (p: ProviderSnapshot | null) => void; setEmail: (email: string) => void; + setTitle: (title: string) => void; reset: () => void; }; const INITIAL: Pick< MailConnectDraftState, - 'view' | 'consentGiven' | 'selectedProvider' | 'email' + 'view' | 'consentGiven' | 'selectedProvider' | 'email' | 'title' > = { view: 'consent', consentGiven: false, selectedProvider: null, email: '', + title: '', }; export const useMailConnectDraft = create((set) => ({ @@ -42,5 +45,6 @@ export const useMailConnectDraft = create((set) => ({ setConsentGiven: (consentGiven) => set({ consentGiven }), setSelectedProvider: (selectedProvider) => set({ selectedProvider }), setEmail: (email) => set({ email }), + setTitle: (title) => set({ title }), reset: () => set(INITIAL), })); diff --git a/backend/prisma/migrations/20260513_mail_connection_title/migration.sql b/backend/prisma/migrations/20260513_mail_connection_title/migration.sql new file mode 100644 index 0000000..053b6ad --- /dev/null +++ b/backend/prisma/migrations/20260513_mail_connection_title/migration.sql @@ -0,0 +1,12 @@ +-- Migration: mail_connection_title +-- Adds optional user-defined display title to mail_connections. +-- Shown in place of full email address for UI anonymity + UX. +-- +-- Breaking-change status: NONE. +-- Nullable column — existing rows default to NULL (frontend falls back to email-domain). +-- Max-length enforcement is on API-level only (60 chars), not DB-level. +-- +-- Deploy: pnpm prisma migrate deploy (on server via GitHub Actions pipeline) + +ALTER TABLE "rebreak"."mail_connections" + ADD COLUMN "title" TEXT; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 2f3e88f..a4113d5 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -520,6 +520,11 @@ model MailConnection { /// Für Audit und zukünftige Scope-Vergleiche bei Re-Auth. oauthScope String? @map("oauth_scope") + // ─── User-definierter Anzeige-Titel (optional, max 60 chars auf API-Ebene) ── + // Wird statt der vollen Email-Adresse angezeigt (z.B. "Privat-Gmail"). + // NULL → Frontend fällt auf Email-Domain zurück. + title String? + // ─── 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). diff --git a/backend/server/api/mail-connections/[id].patch.ts b/backend/server/api/mail-connections/[id].patch.ts new file mode 100644 index 0000000..09c33f0 --- /dev/null +++ b/backend/server/api/mail-connections/[id].patch.ts @@ -0,0 +1,47 @@ +import { updateMailConnectionTitle } from "../../db/mail"; + +/** + * PATCH /api/mail-connections/:id + * + * Setzt den user-definierten Anzeige-Titel einer MailConnection. + * + * Body: + * title?: string | null — max 60 chars; leerer String → NULL (reset); null → NULL + * + * Response: + * 200: { id, email, title } + * 400: { error: 'TITLE_TOO_LONG' } + * 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" } }); + } + + const body = await readBody(event).catch(() => null); + if (!body || !("title" in body)) { + throw createError({ statusCode: 400, data: { error: "INVALID_BODY" } }); + } + + // Normalize: leerer String oder Whitespace-only → null (reset) + let title: string | null = null; + if (typeof body.title === "string") { + const trimmed = body.title.trim(); + title = trimmed.length > 0 ? trimmed : null; + } + + if (title !== null && title.length > 60) { + throw createError({ statusCode: 400, data: { error: "TITLE_TOO_LONG", max: 60 } }); + } + + const result = await updateMailConnectionTitle(user.id, connectionId, title); + + if (!result) { + throw createError({ statusCode: 404, data: { error: "CONNECTION_NOT_FOUND" } }); + } + + return { success: true, data: result }; +}); diff --git a/backend/server/api/mail/results.get.ts b/backend/server/api/mail/results.get.ts index 2491662..4b55e6f 100644 --- a/backend/server/api/mail/results.get.ts +++ b/backend/server/api/mail/results.get.ts @@ -1,16 +1,96 @@ import { deleteOldMailBlocked, getMailBlockedPaginated } from "../../db/mail"; +import { resolveProviderMeta } from "../../utils/imap-providers"; /** * GET /api/mail/results * Gibt die letzten blockierten Gambling-Mails zurück (paginiert). + * + * Query params: + * page? number — Seite (default 1) + * provider? string — Provider-Filter als Komma-separierte Slugs (z.B. "gmail,outlook"). + * Wird intern auf imapHost-Liste aufgelöst. + * Mehrfach-Werte: ?provider=gmail,outlook ODER ?provider=gmail&provider=outlook + * + * Response-Shape pro Entry (results[]): + * { id, userId, connectionId, gmailMessageId, senderEmail, senderName, subject, + * receivedAt, action, createdAt, + * connection: { id, email, title, providerName, provider, providerLabel, isCustomDomain } } */ + +// Slug → imapHost Mapping (invers zu resolveProviderMeta) +const PROVIDER_SLUG_TO_HOST: Record = { + gmail: "imap.gmail.com", + outlook: "outlook.office365.com", + icloud: "imap.mail.me.com", + gmx: "imap.gmx.net", + webde: "imap.web.de", + yahoo: "imap.mail.yahoo.com", + tonline: "secureimap.t-online.de", + freenet: "mx.freenet.de", + posteo: "posteo.de", + ionos: "imap.ionos.de", +}; + export default defineEventHandler(async (event) => { const user = await requireUser(event); const query = getQuery(event); const page = Math.max(1, parseInt((query.page as string) || "1")); + // Provider-Filter parsen: ?provider=gmail,outlook oder ?provider=gmail&provider=outlook + const rawProvider = query.provider; + let providerHosts: string[] | undefined; + + if (rawProvider) { + const slugs = (Array.isArray(rawProvider) ? rawProvider : [rawProvider as string]) + .flatMap((s) => s.split(",")) + .map((s) => s.trim().toLowerCase()) + .filter(Boolean); + + if (slugs.length > 0) { + const hosts = slugs.map((slug) => PROVIDER_SLUG_TO_HOST[slug]).filter(Boolean); + if (hosts.length > 0) providerHosts = hosts; + // Unbekannte Slugs → ignorieren (kein Fehler, gibt leere Ergebnisse für unbekannte Provider) + } + } + await deleteOldMailBlocked(user.id); - return getMailBlockedPaginated(user.id, page); + const { results, total, pages } = await getMailBlockedPaginated( + user.id, + page, + 20, + providerHosts, + ); + + // Connection-Metadaten anreichern + const enriched = results.map((r) => { + const conn = r.connection; + const providerMeta = conn ? resolveProviderMeta(conn.imapHost) : null; + return { + id: r.id, + userId: r.userId, + connectionId: r.connectionId, + gmailMessageId: r.gmailMessageId, + senderEmail: r.senderEmail, + senderName: r.senderName, + subject: r.subject, + receivedAt: r.receivedAt, + action: r.action, + createdAt: r.createdAt, + connection: conn + ? { + id: conn.id, + email: conn.email, + title: conn.title ?? null, + providerName: conn.providerName, + provider: providerMeta?.provider ?? "custom", + providerLabel: providerMeta?.providerLabel ?? conn.providerName ?? conn.imapHost, + isCustomDomain: providerMeta?.isCustomDomain ?? true, + } + : null, + }; + }); + + return { results: enriched, total, page, pages }; }); diff --git a/backend/server/api/mail/stats/blocked-by-connection.get.ts b/backend/server/api/mail/stats/blocked-by-connection.get.ts new file mode 100644 index 0000000..022e4e8 --- /dev/null +++ b/backend/server/api/mail/stats/blocked-by-connection.get.ts @@ -0,0 +1,32 @@ +import { getBlockedMailsByConnection } from "../../../db/mail"; +import { resolveProviderMeta } from "../../../utils/imap-providers"; + +/** + * GET /api/mail/stats/blocked-by-connection + * + * Verteilung blockierter Mails per MailConnection — Half-Donut-Chart-Datenquelle. + * + * Response: { connectionId, title, email, provider, providerLabel, isCustomDomain, count }[] + * — Sortiert nach count DESC. + * — Connections ohne blocked emails sind NICHT included. + */ +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + + const rows = await getBlockedMailsByConnection(user.id); + + const data = rows.map((r) => { + const meta = resolveProviderMeta(r.imapHost); + return { + connectionId: r.connectionId, + title: r.title, + email: r.email, + provider: meta.provider, + providerLabel: meta.providerLabel, + isCustomDomain: meta.isCustomDomain, + count: r.count, + }; + }); + + return { success: true, data }; +}); diff --git a/backend/server/api/mail/stats/blocked-by-day.get.ts b/backend/server/api/mail/stats/blocked-by-day.get.ts new file mode 100644 index 0000000..00b7cfb --- /dev/null +++ b/backend/server/api/mail/stats/blocked-by-day.get.ts @@ -0,0 +1,25 @@ +import { getBlockedMailsByDay } from "../../../db/mail"; + +/** + * GET /api/mail/stats/blocked-by-day?days=30 + * + * Blockierte Mails pro Tag (UTC) — Bar-Chart-Datenquelle. + * + * Query params: + * days? number — Anzahl Tage zurück (default 30, max 90) + * + * Response: { date: 'YYYY-MM-DD', count: number }[] + * — Alle N Tage sind enthalten, auch wenn count=0 (Frontend zeichnet flatline statt Lücken). + * — Timestamps sind UTC. + */ +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + + const query = getQuery(event); + const rawDays = parseInt((query.days as string) || "30"); + const days = Math.min(Math.max(1, isNaN(rawDays) ? 30 : rawDays), 90); + + const data = await getBlockedMailsByDay(user.id, days); + + return { success: true, data }; +}); diff --git a/backend/server/api/mail/status.get.ts b/backend/server/api/mail/status.get.ts index ec21f27..ca6566a 100644 --- a/backend/server/api/mail/status.get.ts +++ b/backend/server/api/mail/status.get.ts @@ -1,4 +1,5 @@ import { getMailConnections, getMailBlockedStats } from "../../db/mail"; +import { resolveProviderMeta } from "../../utils/imap-providers"; /** * GET /api/mail/status @@ -9,24 +10,30 @@ export default defineEventHandler(async (event) => { const connections = await getMailConnections(user.id); - const list = connections.map((c) => ({ - id: c.id, - email: c.email, - provider: c.providerName ?? "IMAP", - isActive: c.isActive, - lastScannedAt: c.lastScannedAt?.toISOString() ?? null, - nextScanAt: c.nextScanAt?.toISOString() ?? null, - totalBlocked: c.emailsBlocked, - totalScanned: c.emailsScanned, - scanInterval: c.scanInterval, - blockRate: - c.emailsScanned > 0 - ? Math.round((c.emailsBlocked / c.emailsScanned) * 100) - : 0, - lastConnectError: c.lastConnectError ?? null, - lastConnectErrorAt: c.lastConnectErrorAt?.toISOString() ?? null, - lastIdleHeartbeatAt: c.lastIdleHeartbeatAt?.toISOString() ?? null, - })); + const list = connections.map((c) => { + const providerMeta = resolveProviderMeta(c.imapHost); + return { + id: c.id, + email: c.email, + title: c.title ?? null, + provider: providerMeta.provider, + providerLabel: providerMeta.providerLabel, + isCustomDomain: providerMeta.isCustomDomain, + isActive: c.isActive, + lastScannedAt: c.lastScannedAt?.toISOString() ?? null, + nextScanAt: c.nextScanAt?.toISOString() ?? null, + totalBlocked: c.emailsBlocked, + totalScanned: c.emailsScanned, + scanInterval: c.scanInterval, + blockRate: + c.emailsScanned > 0 + ? Math.round((c.emailsBlocked / c.emailsScanned) * 100) + : 0, + lastConnectError: c.lastConnectError ?? null, + lastConnectErrorAt: c.lastConnectErrorAt?.toISOString() ?? null, + lastIdleHeartbeatAt: c.lastIdleHeartbeatAt?.toISOString() ?? null, + }; + }); const blocked7d = await getMailBlockedStats(user.id); diff --git a/backend/server/db/mail.ts b/backend/server/db/mail.ts index e4a5728..7591639 100644 --- a/backend/server/db/mail.ts +++ b/backend/server/db/mail.ts @@ -18,8 +18,12 @@ export async function getAllMailConnections(userId: string) { select: { id: true, email: true, + title: true, provider: true, providerName: true, + imapHost: true, + authMethod: true, + consentAt: true, isActive: true, pausedAt: true, pausedReason: true, @@ -230,17 +234,117 @@ export async function getMailBlockedPaginated( userId: string, page: number, limit = 20, + providerFilter?: string[], ) { const db = usePrisma(); const offset = (page - 1) * limit; + + // Bei Provider-Filter: JOINen via connectionId → imapHost für Vergleich + const whereBase = providerFilter && providerFilter.length > 0 + ? { userId, connection: { imapHost: { in: providerFilter } } } + : { userId }; + const [results, total] = await Promise.all([ db.mailBlocked.findMany({ - where: { userId }, + where: whereBase, orderBy: { createdAt: "desc" }, skip: offset, take: limit, + include: { + connection: { + select: { id: true, email: true, title: true, providerName: true, imapHost: true }, + }, + }, }), - db.mailBlocked.count({ where: { userId } }), + db.mailBlocked.count({ where: whereBase }), ]); return { results, total, page, pages: Math.ceil(total / limit) }; } + +/** Title einer MailConnection setzen (nullable — reset auf NULL möglich). */ +export async function updateMailConnectionTitle( + userId: string, + connectionId: string, + title: string | null, +) { + const db = usePrisma(); + const updated = await db.mailConnection.updateMany({ + where: { id: connectionId, userId }, + data: { title }, + }); + if (updated.count === 0) return null; + return db.mailConnection.findFirst({ + where: { id: connectionId, userId }, + select: { id: true, email: true, title: true }, + }); +} + +/** + * Geblockte Mails pro Tag (UTC) für die letzten N Tage — für Bar-Chart. + * Fehlende Tage werden mit count=0 aufgefüllt. + */ +export async function getBlockedMailsByDay( + userId: string, + days: number, +): Promise<{ date: string; count: number }[]> { + const db = usePrisma(); + const since = new Date(Date.now() - days * 86_400_000); + + // Prisma hat kein groupBy auf DATE-Funktionen → raw query + const rows = await db.$queryRaw<{ date: string; count: bigint }[]>` + SELECT TO_CHAR(DATE("created_at"), 'YYYY-MM-DD') AS date, COUNT(*) AS count + FROM "rebreak"."mail_blocked" + WHERE "user_id" = ${userId}::uuid + AND "created_at" >= ${since} + GROUP BY DATE("created_at") + ORDER BY DATE("created_at") ASC + `; + + const map: Record = {}; + for (const row of rows) { + map[row.date] = Number(row.count); + } + + // Alle N Tage auffüllen (neueste zuletzt) + return Array.from({ length: days }, (_, i) => { + const d = new Date(Date.now() - (days - 1 - i) * 86_400_000); + const key = d.toISOString().slice(0, 10); + return { date: key, count: map[key] ?? 0 }; + }); +} + +/** + * Anzahl blockierter Mails pro MailConnection — für Half-Donut-Chart. + * Connections ohne blocked emails werden NICHT included. + */ +export async function getBlockedMailsByConnection(userId: string) { + const db = usePrisma(); + const rows = await db.mailBlocked.groupBy({ + by: ["connectionId"], + where: { userId }, + _count: { id: true }, + orderBy: { _count: { id: "desc" } }, + }); + + if (rows.length === 0) return []; + + const connectionIds = rows.map((r) => r.connectionId); + const connections = await db.mailConnection.findMany({ + where: { id: { in: connectionIds } }, + select: { id: true, email: true, title: true, providerName: true, imapHost: true }, + }); + + const connMap = new Map(connections.map((c) => [c.id, c])); + + return rows.map((r) => { + const conn = connMap.get(r.connectionId); + return { + connectionId: r.connectionId, + title: conn?.title ?? null, + email: conn?.email ?? "", + providerName: conn?.providerName ?? null, + imapHost: conn?.imapHost ?? "", + count: r._count.id, + }; + }); +} diff --git a/backend/server/utils/imap-providers.ts b/backend/server/utils/imap-providers.ts index ce7301e..e45b3df 100644 --- a/backend/server/utils/imap-providers.ts +++ b/backend/server/utils/imap-providers.ts @@ -136,3 +136,36 @@ const SMTP_MAP: Record = { export function detectSmtpProvider(imapHost: string): { host: string; port: number } { return SMTP_MAP[imapHost] ?? { host: imapHost.replace("imap.", "smtp."), port: 587 }; } + +/** + * Leitet einen normalisierten Provider-Key + Label aus dem gespeicherten + * providerName/imapHost einer MailConnection ab. + * Wird in List-Endpoints + Stats-Endpoints für Filterung und Donut-Chart genutzt. + * + * Gibt { provider, providerLabel, isCustomDomain } zurück: + * provider = slug (z.B. "gmail", "outlook", "icloud", "custom") + * providerLabel = human-readable (z.B. "Gmail", "Outlook", "iCloud Mail") + * isCustomDomain = true wenn kein bekannter großer Provider + */ +const HOST_TO_PROVIDER: Record = { + "imap.gmail.com": { provider: "gmail", providerLabel: "Gmail" }, + "outlook.office365.com": { provider: "outlook", providerLabel: "Outlook" }, + "imap.mail.me.com": { provider: "icloud", providerLabel: "iCloud Mail" }, + "imap.gmx.net": { provider: "gmx", providerLabel: "GMX" }, + "imap.web.de": { provider: "webde", providerLabel: "Web.de" }, + "imap.mail.yahoo.com": { provider: "yahoo", providerLabel: "Yahoo" }, + "secureimap.t-online.de": { provider: "tonline", providerLabel: "T-Online" }, + "mx.freenet.de": { provider: "freenet", providerLabel: "Freenet" }, + "posteo.de": { provider: "posteo", providerLabel: "Posteo" }, + "imap.ionos.de": { provider: "ionos", providerLabel: "IONOS" }, +}; + +export function resolveProviderMeta(imapHost: string): { + provider: string; + providerLabel: string; + isCustomDomain: boolean; +} { + const match = HOST_TO_PROVIDER[imapHost]; + if (match) return { ...match, isCustomDomain: false }; + return { provider: "custom", providerLabel: imapHost, isCustomDomain: true }; +}