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:
parent
0ab635c74a
commit
b7909d77e4
@ -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>
|
||||
)}
|
||||
|
||||
@ -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'),
|
||||
|
||||
101
apps/rebreak-native/components/mail/EditMailTitleSheet.tsx
Normal file
101
apps/rebreak-native/components/mail/EditMailTitleSheet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
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,
|
||||
borderRadius: 10,
|
||||
};
|
||||
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 }}
|
||||
{/* Settings separator label */}
|
||||
<View
|
||||
style={{
|
||||
paddingHorizontal: 14,
|
||||
paddingBottom: 4,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#f5f5f5',
|
||||
paddingTop: 10,
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name="key-outline"
|
||||
size={14}
|
||||
color="#525252"
|
||||
style={{ marginRight: 6 }}
|
||||
/>
|
||||
<Text
|
||||
style={{ fontSize: 13, fontFamily: 'Nunito_700Bold', color: '#525252' }}
|
||||
numberOfLines={1}
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontFamily: 'Nunito_700Bold',
|
||||
color: '#a3a3a3',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.6,
|
||||
}}
|
||||
>
|
||||
{t('mail.account_change_password')}
|
||||
{t('mail.settings_section_label')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</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={{
|
||||
...ACTION_BTN_BASE,
|
||||
backgroundColor: '#fef2f2',
|
||||
marginLeft: 6,
|
||||
opacity: disconnecting ? 0.6 : 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 14,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#f5f5f5',
|
||||
opacity: disconnecting ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
{disconnecting ? (
|
||||
<ActivityIndicator size="small" color="#dc2626" />
|
||||
<ActivityIndicator size="small" color="#dc2626" style={{ marginRight: 12, width: 20 }} />
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
<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>
|
||||
)}
|
||||
</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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 }}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
||||
<Text
|
||||
style={{ fontSize: 13, fontFamily: 'Nunito_600SemiBold', color: '#0a0a0a' }}
|
||||
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,
|
||||
}}
|
||||
>
|
||||
|
||||
148
apps/rebreak-native/components/mail/MailBlockedByDayChart.tsx
Normal file
148
apps/rebreak-native/components/mail/MailBlockedByDayChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
172
apps/rebreak-native/components/mail/MailDistributionChart.tsx
Normal file
172
apps/rebreak-native/components/mail/MailDistributionChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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();
|
||||
|
||||
49
apps/rebreak-native/hooks/useMailStats.ts
Normal file
49
apps/rebreak-native/hooks/useMailStats.ts
Normal 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 };
|
||||
}
|
||||
@ -6,6 +6,7 @@ export type MailAccount = {
|
||||
id: string;
|
||||
email: string;
|
||||
provider: string;
|
||||
title?: string | null;
|
||||
isActive: boolean;
|
||||
paused?: boolean;
|
||||
lastScannedAt: string | null;
|
||||
|
||||
26
apps/rebreak-native/hooks/useMailTitleEdit.ts
Normal file
26
apps/rebreak-native/hooks/useMailTitleEdit.ts
Normal 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 };
|
||||
}
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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),
|
||||
}));
|
||||
|
||||
@ -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;
|
||||
@ -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).
|
||||
|
||||
47
backend/server/api/mail-connections/[id].patch.ts
Normal file
47
backend/server/api/mail-connections/[id].patch.ts
Normal 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 };
|
||||
});
|
||||
@ -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 };
|
||||
});
|
||||
|
||||
32
backend/server/api/mail/stats/blocked-by-connection.get.ts
Normal file
32
backend/server/api/mail/stats/blocked-by-connection.get.ts
Normal 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 };
|
||||
});
|
||||
25
backend/server/api/mail/stats/blocked-by-day.get.ts
Normal file
25
backend/server/api/mail/stats/blocked-by-day.get.ts
Normal 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 };
|
||||
});
|
||||
@ -1,4 +1,5 @@
|
||||
import { getMailConnections, getMailBlockedStats } from "../../db/mail";
|
||||
import { resolveProviderMeta } from "../../utils/imap-providers";
|
||||
|
||||
/**
|
||||
* GET /api/mail/status
|
||||
@ -9,10 +10,15 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
const connections = await getMailConnections(user.id);
|
||||
|
||||
const list = connections.map((c) => ({
|
||||
const list = connections.map((c) => {
|
||||
const providerMeta = resolveProviderMeta(c.imapHost);
|
||||
return {
|
||||
id: c.id,
|
||||
email: c.email,
|
||||
provider: c.providerName ?? "IMAP",
|
||||
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,
|
||||
@ -26,7 +32,8 @@ export default defineEventHandler(async (event) => {
|
||||
lastConnectError: c.lastConnectError ?? null,
|
||||
lastConnectErrorAt: c.lastConnectErrorAt?.toISOString() ?? null,
|
||||
lastIdleHeartbeatAt: c.lastIdleHeartbeatAt?.toISOString() ?? null,
|
||||
}));
|
||||
};
|
||||
});
|
||||
|
||||
const blocked7d = await getMailBlockedStats(user.id);
|
||||
|
||||
|
||||
@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user