feat(mail): custom title + settings collapsible + stats charts + provider filter

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) <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-05-13 19:06:01 +02:00
parent 0ab635c74a
commit b7909d77e4
23 changed files with 1310 additions and 144 deletions

View File

@ -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 && (
<MailOverLimitBanner
usedCount={accounts.length}
@ -164,7 +172,7 @@ export default function MailScreen() {
)}
{/* Stats card */}
{accounts.length > 0 && (
{hasAccounts && (
<View style={{ marginBottom: 14 }}>
<MailStatsRow
totalBlocked={totalBlocked}
@ -174,8 +182,8 @@ export default function MailScreen() {
</View>
)}
{/* Section header with prominent + button — hidden in empty state (CTA lives there) */}
{accounts.length > 0 && (
{/* Section header + add button */}
{hasAccounts && (
<View
style={{
flexDirection: 'row',
@ -274,12 +282,21 @@ export default function MailScreen() {
</View>
)}
{/* Charts — nur wenn Accounts vorhanden */}
{hasAccounts && (
<View style={{ gap: 12, marginTop: 14 }}>
<MailBlockedByDayChart data={blockedByDay} />
<MailDistributionChart data={blockedByConnection} />
</View>
)}
{/* Activity log */}
{accounts.length > 0 && (
{hasAccounts && (
<View style={{ marginTop: 14 }}>
<MailActivityLog
expanded={activityLogExpanded}
onToggle={() => setActivityLogExpanded((p) => !p)}
providers={distinctProviders}
/>
</View>
)}

View File

@ -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<string, string> = {
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'),

View File

@ -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 (
<FormSheet
visible={visible}
onClose={handleClose}
title={t('mail.title_edit_title')}
initialHeightPct={0.45}
growWithKeyboard
>
<SheetFieldStack
fields={[
{
key: 'title',
label: t('mail.title_label'),
placeholder: t('mail.title_placeholder'),
value: title,
onChangeText: setTitle,
autoCapitalize: 'sentences',
autoCorrect: false,
},
]}
onComplete={() => {}}
>
{error && (
<Text
style={{
fontSize: 13,
fontFamily: 'Nunito_400Regular',
color: '#dc2626',
marginBottom: 10,
}}
>
{error}
</Text>
)}
<TouchableOpacity
activeOpacity={0.85}
onPress={handleSave}
disabled={saving}
style={{ marginTop: 4, marginBottom: 12 }}
>
<View
style={{
paddingVertical: 14,
borderRadius: 12,
backgroundColor: saving ? '#d4d4d4' : '#007AFF',
alignItems: 'center',
}}
>
{saving ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={{ fontSize: 15, fontFamily: 'Nunito_700Bold', color: '#fff' }}>
{t('mail.title_save')}
</Text>
)}
</View>
</TouchableOpacity>
</SheetFieldStack>
</FormSheet>
);
}

View File

@ -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, string | number>) => 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 (
<View style={{ flexDirection: 'row', alignItems: 'center', marginTop: 3 }}>
@ -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 (
<View style={{ marginTop: 3 }}>
@ -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 (
<View style={{ marginTop: 3 }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
@ -205,7 +200,6 @@ function StatusBadgeRow({
);
}
// Priority 2 — live + heartbeat recent + scan recent
return (
<View style={{ marginTop: 3 }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
@ -226,7 +220,6 @@ function StatusBadgeRow({
);
}
// Fallback — scan recent, backend without heartbeat field
return (
<View style={{ marginTop: 3 }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
@ -241,7 +234,7 @@ function StatusBadgeRow({
style={{ fontSize: 10, fontFamily: 'Nunito_400Regular', color: '#a3a3a3', marginTop: 1 }}
numberOfLines={1}
>
{scannedRelAbs}
{formatRelativeAbsolute(new Date(account.lastScannedAt!))}
</Text>
</View>
);
@ -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<typeof Ionicons>['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 (
<Wrapper
{...wrapperProps}
style={{
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 12,
paddingHorizontal: 14,
borderTopWidth: 1,
borderTopColor: '#f5f5f5',
}}
>
<Ionicons name={icon} size={16} color={destructive ? '#dc2626' : '#737373'} style={{ marginRight: 12, width: 20 }} />
<Text
style={{
flex: 1,
fontSize: 14,
fontFamily: 'Nunito_600SemiBold',
color,
}}
>
{label}
</Text>
{value !== undefined && (
<Text
style={{
fontSize: 13,
fontFamily: 'Nunito_400Regular',
color: '#a3a3a3',
marginRight: onPress ? 4 : 0,
}}
numberOfLines={1}
>
{value}
</Text>
)}
{onPress && !destructive && (
<Ionicons name="chevron-forward" size={14} color="#d4d4d4" />
)}
</Wrapper>
);
}
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<string | null>(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 (
<>
<View
@ -315,9 +375,16 @@ export function MailAccountCard({
opacity: isPaused ? 0.75 : 1,
}}
>
{/* Header */}
<Pressable onPress={handleToggle} android_ripple={{ color: '#f5f5f5' }}>
<View style={HEADER_ROW}>
{/* Header — always visible, tap to expand settings */}
<TouchableOpacity onPress={handleToggle} activeOpacity={0.85}>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 14,
paddingVertical: 14,
}}
>
<View
style={{
width: 40,
@ -333,11 +400,28 @@ export function MailAccountCard({
</View>
<View style={{ flex: 1, minWidth: 0, marginRight: 8 }}>
{/* Title — prominent */}
<Text
style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: isPaused ? '#a3a3a3' : '#0a0a0a' }}
style={{
fontSize: 15,
fontFamily: 'Nunito_700Bold',
color: isPaused ? '#a3a3a3' : '#0a0a0a',
}}
numberOfLines={1}
>
{account.email}
{displayTitle}
</Text>
{/* Email — small sub-label */}
<Text
style={{
fontSize: 11,
fontFamily: 'Nunito_400Regular',
color: '#a3a3a3',
marginTop: 1,
}}
numberOfLines={1}
>
{subEmail}
</Text>
{isPaused
? <PausedBadge t={t} />
@ -351,16 +435,19 @@ export function MailAccountCard({
color="#a3a3a3"
/>
</View>
</Pressable>
</TouchableOpacity>
{/* Body */}
{/* Collapsible: Settings section */}
{expanded && (
<View style={{ borderTopWidth: 1, borderTopColor: '#f5f5f5', padding: 14 }}>
<View style={{ borderTopWidth: 1, borderTopColor: '#f5f5f5' }}>
{/* Stats banner */}
<View
style={{
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#fef2f2',
marginHorizontal: 14,
marginTop: 14,
borderRadius: 12,
paddingHorizontal: 14,
paddingVertical: 12,
@ -390,6 +477,7 @@ export function MailAccountCard({
</Text>
</View>
{/* Scan interval (non-legend) */}
{isLegend ? (
<View
style={{
@ -397,6 +485,7 @@ export function MailAccountCard({
alignItems: 'center',
backgroundColor: '#f0fdf4',
borderRadius: 10,
marginHorizontal: 14,
paddingHorizontal: 12,
paddingVertical: 10,
marginBottom: 12,
@ -415,7 +504,7 @@ export function MailAccountCard({
</Text>
</View>
) : (
<View style={{ marginBottom: 12 }}>
<View style={{ marginHorizontal: 14, marginBottom: 12 }}>
<Text
style={{
fontSize: 11,
@ -476,64 +565,88 @@ export function MailAccountCard({
</View>
)}
<View style={{ flexDirection: 'row' }}>
<TouchableOpacity
activeOpacity={0.7}
onPress={() => setEditVisible(true)}
style={{ ...ACTION_BTN_BASE, backgroundColor: '#f5f5f5', marginRight: 6 }}
>
<Ionicons
name="key-outline"
size={14}
color="#525252"
style={{ marginRight: 6 }}
/>
<Text
style={{ fontSize: 13, fontFamily: 'Nunito_700Bold', color: '#525252' }}
numberOfLines={1}
>
{t('mail.account_change_password')}
</Text>
</TouchableOpacity>
<TouchableOpacity
activeOpacity={0.7}
onPress={() => setConfirmVisible(true)}
disabled={disconnecting}
{/* Settings separator label */}
<View
style={{
paddingHorizontal: 14,
paddingBottom: 4,
borderTopWidth: 1,
borderTopColor: '#f5f5f5',
paddingTop: 10,
}}
>
<Text
style={{
...ACTION_BTN_BASE,
backgroundColor: '#fef2f2',
marginLeft: 6,
opacity: disconnecting ? 0.6 : 1,
fontSize: 11,
fontFamily: 'Nunito_700Bold',
color: '#a3a3a3',
textTransform: 'uppercase',
letterSpacing: 0.6,
}}
>
{disconnecting ? (
<ActivityIndicator size="small" color="#dc2626" />
) : (
<>
<Ionicons
name="trash-outline"
size={14}
color="#dc2626"
style={{ marginRight: 6 }}
/>
<Text
style={{ fontSize: 13, fontFamily: 'Nunito_700Bold', color: '#dc2626' }}
numberOfLines={1}
>
{t('mail.disconnect')}
</Text>
</>
)}
</TouchableOpacity>
{t('mail.settings_section_label')}
</Text>
</View>
{/* Settings rows */}
<SettingsRow
icon="pencil-outline"
label={t('mail.row_title')}
value={localTitle ?? '—'}
onPress={() => setEditTitleVisible(true)}
/>
<SettingsRow
icon="mail-outline"
label={t('mail.row_email')}
value={account.email}
/>
<SettingsRow
icon="key-outline"
label={t('mail.row_password')}
value="••••••••"
onPress={() => setEditPasswordVisible(true)}
/>
<TouchableOpacity
activeOpacity={0.7}
onPress={() => setConfirmVisible(true)}
disabled={disconnecting}
style={{
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 12,
paddingHorizontal: 14,
borderTopWidth: 1,
borderTopColor: '#f5f5f5',
opacity: disconnecting ? 0.5 : 1,
}}
>
{disconnecting ? (
<ActivityIndicator size="small" color="#dc2626" style={{ marginRight: 12, width: 20 }} />
) : (
<Ionicons name="trash-outline" size={16} color="#dc2626" style={{ marginRight: 12, width: 20 }} />
)}
<Text
style={{
flex: 1,
fontSize: 14,
fontFamily: 'Nunito_600SemiBold',
color: '#dc2626',
}}
>
{t('mail.row_disconnect')}
</Text>
</TouchableOpacity>
</View>
)}
</View>
<ConfirmAlert
visible={confirmVisible}
title={t('mail.account_disconnect_confirm_title')}
message={t('mail.account_disconnect_confirm_message', { email: account.email })}
title={t('mail.disconnect_confirm_title')}
message={t('mail.disconnect_confirm_body', { email: account.email })}
confirmLabel={t('mail.account_disconnect_confirm_btn')}
destructive
icon="trash"
@ -546,11 +659,19 @@ export function MailAccountCard({
/>
<EditMailAccountSheet
visible={editVisible}
visible={editPasswordVisible}
email={account.email}
onClose={() => setEditVisible(false)}
onClose={() => setEditPasswordVisible(false)}
onSuccess={onEditSuccess}
/>
<EditMailTitleSheet
visible={editTitleVisible}
connectionId={account.id}
currentTitle={localTitle}
onClose={() => setEditTitleVisible(false)}
onSuccess={handleTitleSaved}
/>
</>
);
}

View File

@ -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<string, string> = {
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 (
<View
style={{
backgroundColor: '#fff',
backgroundColor: colors.surface,
borderRadius: 16,
borderWidth: 1,
borderColor: '#e5e5e5',
borderColor: colors.border,
overflow: 'hidden',
}}
>
<Pressable onPress={handleToggle} android_ripple={{ color: '#f5f5f5' }}>
<TouchableOpacity onPress={handleToggle} activeOpacity={0.85}>
<View
style={{
flexDirection: 'row',
@ -73,7 +101,7 @@ export function MailActivityLog({ expanded, onToggle }: Props) {
</View>
<View style={{ flex: 1, minWidth: 0, marginRight: 8 }}>
<Text
style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: '#0a0a0a' }}
style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: colors.text }}
numberOfLines={1}
>
{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) {
<Ionicons
name={expanded ? 'chevron-up' : 'chevron-down'}
size={18}
color="#a3a3a3"
color={colors.textMuted}
/>
</View>
</Pressable>
</TouchableOpacity>
{expanded && (
<View style={{ borderTopWidth: 1, borderTopColor: '#f5f5f5' }}>
<View style={{ borderTopWidth: 1, borderTopColor: colors.border }}>
{/* Provider filter chips */}
{filterOptions.length > 1 && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ paddingHorizontal: 14, paddingVertical: 10, gap: 6 }}
>
{filterOptions.map((p) => {
const active = activeProvider === p;
return (
<TouchableOpacity
key={p}
activeOpacity={0.7}
onPress={() => handleProviderFilter(p)}
style={{
paddingHorizontal: 12,
paddingVertical: 5,
borderRadius: 999,
backgroundColor: active ? '#007AFF' : colors.surfaceElevated,
borderWidth: active ? 0 : 1,
borderColor: colors.border,
}}
>
<Text
style={{
fontSize: 12,
fontFamily: 'Nunito_600SemiBold',
color: active ? '#fff' : colors.textMuted,
}}
>
{p === 'all' ? t('mail.filter.all') : providerDisplayName(p)}
</Text>
</TouchableOpacity>
);
})}
</ScrollView>
)}
{loading && results.length === 0 ? (
<View style={{ padding: 20, alignItems: 'center' }}>
<Text style={{ fontSize: 12, fontFamily: 'Nunito_400Regular', color: '#a3a3a3' }}>
<Text style={{ fontSize: 12, fontFamily: 'Nunito_400Regular', color: colors.textMuted }}>
{t('mail.loading')}
</Text>
</View>
) : results.length === 0 ? (
<View style={{ padding: 24, alignItems: 'center' }}>
<Ionicons name="checkmark-circle-outline" size={28} color="#16a34a" />
<Ionicons name="checkmark-circle-outline" size={28} color={colors.success} />
<Text
style={{
fontSize: 12,
fontFamily: 'Nunito_600SemiBold',
color: '#525252',
color: colors.textMuted,
marginTop: 6,
}}
>
@ -123,7 +189,7 @@ export function MailActivityLog({ expanded, onToggle }: Props) {
) : (
<>
{results.slice(0, 10).map((item) => (
<ActivityItem key={item.id} item={item} t={t} />
<ActivityItem key={item.id} item={item} t={t} colors={colors} />
))}
<View
style={{
@ -133,16 +199,16 @@ export function MailActivityLog({ expanded, onToggle }: Props) {
paddingHorizontal: 14,
paddingVertical: 10,
borderTopWidth: 1,
borderTopColor: '#fafafa',
borderTopColor: colors.border,
}}
>
<Text style={{ fontSize: 11, fontFamily: 'Nunito_400Regular', color: '#a3a3a3' }}>
<Text style={{ fontSize: 11, fontFamily: 'Nunito_400Regular', color: colors.textMuted }}>
{total > 10
? t('mail.activity_log_more', { count: total - 10 })
: t('mail.activity_log_count', { count: total })}
</Text>
<TouchableOpacity activeOpacity={0.7} onPress={refresh} hitSlop={8}>
<Ionicons name="refresh" size={14} color="#737373" />
<Ionicons name="refresh" size={14} color={colors.textMuted} />
</TouchableOpacity>
</View>
</>
@ -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<typeof useColors>;
}) {
const accountLabel = item.connection_title ?? (
item.sender_email ? domainFromEmail(item.sender_email) : null
);
return (
<View
style={{
@ -168,7 +240,7 @@ function ActivityItem({
paddingHorizontal: 14,
paddingVertical: 10,
borderTopWidth: 1,
borderTopColor: '#fafafa',
borderTopColor: colors.border,
}}
>
<View
@ -186,17 +258,40 @@ function ActivityItem({
<Ionicons name="close" size={12} color="#dc2626" />
</View>
<View style={{ flex: 1, minWidth: 0, marginRight: 8 }}>
<Text
style={{ fontSize: 13, fontFamily: 'Nunito_600SemiBold', color: '#0a0a0a' }}
numberOfLines={1}
>
{item.subject || t('mail.activity_no_subject')}
</Text>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
<Text
style={{ fontSize: 13, fontFamily: 'Nunito_600SemiBold', color: colors.text, flexShrink: 1 }}
numberOfLines={1}
>
{item.subject || t('mail.activity_no_subject')}
</Text>
{accountLabel && (
<View
style={{
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 4,
backgroundColor: colors.surfaceElevated,
}}
>
<Text
style={{
fontSize: 10,
fontFamily: 'Nunito_600SemiBold',
color: colors.textMuted,
}}
numberOfLines={1}
>
{accountLabel}
</Text>
</View>
)}
</View>
<Text
style={{
fontSize: 11,
fontFamily: 'Nunito_400Regular',
color: '#737373',
color: colors.textMuted,
marginTop: 1,
}}
numberOfLines={1}
@ -208,7 +303,7 @@ function ActivityItem({
style={{
fontSize: 10,
fontFamily: 'Nunito_400Regular',
color: '#a3a3a3',
color: colors.textMuted,
marginTop: 2,
}}
>

View File

@ -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 (
<View
style={{
backgroundColor: colors.surface,
borderRadius: 16,
borderWidth: 1,
borderColor: colors.border,
paddingHorizontal: 16,
paddingTop: 14,
paddingBottom: 14,
}}
>
<Text
style={{
fontSize: 11,
fontFamily: 'Nunito_700Bold',
color: colors.textMuted,
textTransform: 'uppercase',
letterSpacing: 0.7,
marginBottom: 10,
}}
>
{t('mail.stats.blocked_per_day_heading')}
</Text>
{allZero ? (
<View style={{ paddingVertical: 20, alignItems: 'center' }}>
<Text
style={{
fontSize: 13,
fontFamily: 'Nunito_600SemiBold',
color: colors.textMuted,
textAlign: 'center',
}}
>
{t('mail.stats.empty_title')}
</Text>
<Text
style={{
fontSize: 12,
fontFamily: 'Nunito_400Regular',
color: colors.textMuted,
textAlign: 'center',
marginTop: 4,
}}
>
{t('mail.stats.empty_body')}
</Text>
</View>
) : (
<>
{/* Bar chart */}
<View style={{ flexDirection: 'row', alignItems: 'flex-end', height: BAR_AREA_HEIGHT, gap: 2 }}>
{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 (
<View
key={entry.date}
style={{
flex: 1,
height: barH,
borderRadius: 2,
backgroundColor: entry.count > 0 ? colors.error : colors.border,
}}
/>
);
})}
</View>
{/* Axis labels */}
<View style={{ flexDirection: 'row', marginTop: 4, position: 'relative', height: 14 }}>
{axisIndices.map((idx) => {
const pct = data.length > 1 ? idx / (data.length - 1) : 0;
return (
<Text
key={idx}
style={{
position: 'absolute',
left: `${pct * 100}%` as any,
fontSize: 9,
fontFamily: 'Nunito_400Regular',
color: colors.textMuted,
transform: [{ translateX: idx === 0 ? 0 : idx === data.length - 1 ? -24 : -12 }],
}}
>
{formatAxisLabel(data[idx].date)}
</Text>
);
})}
</View>
{/* Summary line */}
<Text
style={{
fontSize: 12,
fontFamily: 'Nunito_400Regular',
color: colors.textMuted,
marginTop: 10,
}}
>
{t('mail.stats.blocked_per_day_sublabel', { total, avg: weekAvg })}
</Text>
</>
)}
</View>
);
}

View File

@ -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 (
<View
style={{
backgroundColor: colors.surface,
borderRadius: 16,
borderWidth: 1,
borderColor: colors.border,
paddingHorizontal: 16,
paddingTop: 14,
paddingBottom: 14,
}}
>
<Text
style={{
fontSize: 11,
fontFamily: 'Nunito_700Bold',
color: colors.textMuted,
textTransform: 'uppercase',
letterSpacing: 0.7,
marginBottom: 12,
}}
>
{t('mail.stats.distribution_heading')}
</Text>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 16 }}>
{/* Half-donut — upper half of a donut ring, 180° arc from left to right */}
<Svg width={128} height={68} viewBox="0 0 128 68">
{slices.map((slice) => {
const sweep = (slice.count / total) * 180;
const startDeg = cursor;
cursor += sweep;
return (
<Path
key={slice.label}
d={arcPath(CX, CY, R_OUTER, R_INNER, startDeg, startDeg + sweep)}
fill={slice.color}
/>
);
})}
{/* Center circle to keep donut look consistent */}
<Circle cx={CX} cy={CY} r={R_INNER - 1} fill={colors.surface} />
</Svg>
{/* Legend */}
<View style={{ flex: 1, gap: 6 }}>
{slices.map((slice) => (
<View key={slice.label} style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
<View
style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: slice.color }}
/>
<Text
style={{
flex: 1,
fontSize: 12,
fontFamily: 'Nunito_600SemiBold',
color: colors.text,
}}
numberOfLines={1}
>
{slice.label}
</Text>
<Text
style={{
fontSize: 12,
fontFamily: 'Nunito_700Bold',
color: colors.textMuted,
}}
>
{slice.count}
</Text>
</View>
))}
</View>
</View>
</View>
);
}

View File

@ -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<MailBlockedItem[]>([]);
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<MailResultsResponse>(
"/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();

View File

@ -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<MailStatsState>({
blockedByDay: [],
blockedByConnection: [],
loading: false,
});
const fetch = useCallback(async () => {
if (!enabled) return;
setState((s) => ({ ...s, loading: true }));
try {
const [byDay, byConn] = await Promise.all([
apiFetch<BlockedByDayEntry[]>('/api/mail/stats/blocked-by-day?days=30'),
apiFetch<BlockedByConnectionEntry[]>('/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 };
}

View File

@ -6,6 +6,7 @@ export type MailAccount = {
id: string;
email: string;
provider: string;
title?: string | null;
isActive: boolean;
paused?: boolean;
lastScannedAt: string | null;

View File

@ -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<string | null>(null);
async function saveTitle(connectionId: string, title: string): Promise<boolean> {
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 };
}

View File

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

View File

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

View File

@ -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<MailConnectDraftState>((set) => ({
@ -42,5 +45,6 @@ export const useMailConnectDraft = create<MailConnectDraftState>((set) => ({
setConsentGiven: (consentGiven) => set({ consentGiven }),
setSelectedProvider: (selectedProvider) => set({ selectedProvider }),
setEmail: (email) => set({ email }),
setTitle: (title) => set({ title }),
reset: () => set(INITIAL),
}));

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -136,3 +136,36 @@ const SMTP_MAP: Record<string, { host: string; port: number }> = {
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<string, { provider: string; providerLabel: string }> = {
"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 };
}