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 {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Alert,
|
Alert,
|
||||||
Pressable,
|
|
||||||
ScrollView,
|
ScrollView,
|
||||||
Text,
|
Text,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
@ -16,10 +15,13 @@ import { MailStatsRow } from '../../components/mail/MailStatsRow';
|
|||||||
import { MailAccountCard } from '../../components/mail/MailAccountCard';
|
import { MailAccountCard } from '../../components/mail/MailAccountCard';
|
||||||
import { MailEmptyState } from '../../components/mail/MailEmptyState';
|
import { MailEmptyState } from '../../components/mail/MailEmptyState';
|
||||||
import { MailActivityLog } from '../../components/mail/MailActivityLog';
|
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 { ConnectMailSheet } from '../../components/mail/ConnectMailSheet';
|
||||||
import { SuccessAlert } from '../../components/SuccessAlert';
|
import { SuccessAlert } from '../../components/SuccessAlert';
|
||||||
import { useMailStatus } from '../../hooks/useMailStatus';
|
import { useMailStatus } from '../../hooks/useMailStatus';
|
||||||
import { useMailDisconnect } from '../../hooks/useMailDisconnect';
|
import { useMailDisconnect } from '../../hooks/useMailDisconnect';
|
||||||
|
import { useMailStats } from '../../hooks/useMailStats';
|
||||||
import { useUserPlan } from '../../hooks/useUserPlan';
|
import { useUserPlan } from '../../hooks/useUserPlan';
|
||||||
import { useColors } from '../../lib/theme';
|
import { useColors } from '../../lib/theme';
|
||||||
|
|
||||||
@ -87,6 +89,8 @@ export default function MailScreen() {
|
|||||||
const { connected, accounts, totalBlocked, maxAccounts, loading, refresh } =
|
const { connected, accounts, totalBlocked, maxAccounts, loading, refresh } =
|
||||||
useMailStatus(plan);
|
useMailStatus(plan);
|
||||||
const { disconnect, disconnecting } = useMailDisconnect();
|
const { disconnect, disconnecting } = useMailDisconnect();
|
||||||
|
const hasAccounts = accounts.length > 0;
|
||||||
|
const { blockedByDay, blockedByConnection } = useMailStats(hasAccounts);
|
||||||
|
|
||||||
const [sheetVisible, setSheetVisible] = useState(false);
|
const [sheetVisible, setSheetVisible] = useState(false);
|
||||||
const [successVisible, setSuccessVisible] = useState(false);
|
const [successVisible, setSuccessVisible] = useState(false);
|
||||||
@ -104,6 +108,10 @@ export default function MailScreen() {
|
|||||||
const overLimit = maxAccounts !== Infinity && accounts.length > maxAccounts;
|
const overLimit = maxAccounts !== Infinity && accounts.length > maxAccounts;
|
||||||
const limitReached = maxAccounts !== Infinity && accounts.length >= maxAccounts;
|
const limitReached = maxAccounts !== Infinity && accounts.length >= maxAccounts;
|
||||||
|
|
||||||
|
const distinctProviders = [
|
||||||
|
...new Set(accounts.map((a) => a.provider.toLowerCase())),
|
||||||
|
];
|
||||||
|
|
||||||
function handleAddPress() {
|
function handleAddPress() {
|
||||||
if (limitReached) {
|
if (limitReached) {
|
||||||
Alert.alert(t('mail.upgrade_alert_title'), t('mail.upgrade_alert_desc'));
|
Alert.alert(t('mail.upgrade_alert_title'), t('mail.upgrade_alert_desc'));
|
||||||
@ -152,7 +160,7 @@ export default function MailScreen() {
|
|||||||
}}
|
}}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
{/* Über-Limit-Banner: nur wenn Backend paused-Feld liefert + over limit */}
|
{/* Über-Limit-Banner */}
|
||||||
{overLimit && pausedAccounts.length > 0 && (
|
{overLimit && pausedAccounts.length > 0 && (
|
||||||
<MailOverLimitBanner
|
<MailOverLimitBanner
|
||||||
usedCount={accounts.length}
|
usedCount={accounts.length}
|
||||||
@ -164,7 +172,7 @@ export default function MailScreen() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Stats card */}
|
{/* Stats card */}
|
||||||
{accounts.length > 0 && (
|
{hasAccounts && (
|
||||||
<View style={{ marginBottom: 14 }}>
|
<View style={{ marginBottom: 14 }}>
|
||||||
<MailStatsRow
|
<MailStatsRow
|
||||||
totalBlocked={totalBlocked}
|
totalBlocked={totalBlocked}
|
||||||
@ -174,8 +182,8 @@ export default function MailScreen() {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Section header with prominent + button — hidden in empty state (CTA lives there) */}
|
{/* Section header + add button */}
|
||||||
{accounts.length > 0 && (
|
{hasAccounts && (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@ -274,12 +282,21 @@ export default function MailScreen() {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Charts — nur wenn Accounts vorhanden */}
|
||||||
|
{hasAccounts && (
|
||||||
|
<View style={{ gap: 12, marginTop: 14 }}>
|
||||||
|
<MailBlockedByDayChart data={blockedByDay} />
|
||||||
|
<MailDistributionChart data={blockedByConnection} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Activity log */}
|
{/* Activity log */}
|
||||||
{accounts.length > 0 && (
|
{hasAccounts && (
|
||||||
<View style={{ marginTop: 14 }}>
|
<View style={{ marginTop: 14 }}>
|
||||||
<MailActivityLog
|
<MailActivityLog
|
||||||
expanded={activityLogExpanded}
|
expanded={activityLogExpanded}
|
||||||
onToggle={() => setActivityLogExpanded((p) => !p)}
|
onToggle={() => setActivityLogExpanded((p) => !p)}
|
||||||
|
providers={distinctProviders}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -109,10 +109,12 @@ export function ConnectMailSheet({ visible, onClose, onSuccess }: Props) {
|
|||||||
consentGiven,
|
consentGiven,
|
||||||
selectedProvider,
|
selectedProvider,
|
||||||
email,
|
email,
|
||||||
|
title,
|
||||||
setView,
|
setView,
|
||||||
setConsentGiven,
|
setConsentGiven,
|
||||||
setSelectedProvider,
|
setSelectedProvider,
|
||||||
setEmail,
|
setEmail,
|
||||||
|
setTitle,
|
||||||
reset: resetDraft,
|
reset: resetDraft,
|
||||||
} = useMailConnectDraft();
|
} = useMailConnectDraft();
|
||||||
|
|
||||||
@ -130,6 +132,19 @@ export function ConnectMailSheet({ visible, onClose, onSuccess }: Props) {
|
|||||||
onClose();
|
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() {
|
function handleConsentNext() {
|
||||||
setView('grid');
|
setView('grid');
|
||||||
}
|
}
|
||||||
@ -138,6 +153,7 @@ export function ConnectMailSheet({ visible, onClose, onSuccess }: Props) {
|
|||||||
setSelectedProvider(provider);
|
setSelectedProvider(provider);
|
||||||
setEmail('');
|
setEmail('');
|
||||||
setPassword('');
|
setPassword('');
|
||||||
|
setTitle(defaultTitleForProvider(provider));
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
setFieldsComplete(false);
|
setFieldsComplete(false);
|
||||||
setView('form');
|
setView('form');
|
||||||
@ -157,6 +173,20 @@ export function ConnectMailSheet({ visible, onClose, onSuccess }: Props) {
|
|||||||
|
|
||||||
const result = await connect({ email: email.trim(), password });
|
const result = await connect({ email: email.trim(), password });
|
||||||
if (result.ok) {
|
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();
|
handleClose();
|
||||||
onSuccess();
|
onSuccess();
|
||||||
} else if (result.error?.includes('412') || result.error?.includes('consent_required')) {
|
} else if (result.error?.includes('412') || result.error?.includes('consent_required')) {
|
||||||
@ -205,6 +235,15 @@ export function ConnectMailSheet({ visible, onClose, onSuccess }: Props) {
|
|||||||
validate: (v) =>
|
validate: (v) =>
|
||||||
v.trim().length === 0 ? t('mail.form_fields_required') : undefined,
|
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',
|
key: 'password',
|
||||||
label: t('mail.form_password_label'),
|
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,
|
ActivityIndicator,
|
||||||
LayoutAnimation,
|
LayoutAnimation,
|
||||||
Platform,
|
Platform,
|
||||||
Pressable,
|
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
Text,
|
Text,
|
||||||
UIManager,
|
UIManager,
|
||||||
@ -13,6 +12,7 @@ import { Ionicons } from '@expo/vector-icons';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { ConfirmAlert } from '../ConfirmAlert';
|
import { ConfirmAlert } from '../ConfirmAlert';
|
||||||
import { EditMailAccountSheet } from './EditMailAccountSheet';
|
import { EditMailAccountSheet } from './EditMailAccountSheet';
|
||||||
|
import { EditMailTitleSheet } from './EditMailTitleSheet';
|
||||||
import { useMailInterval } from '../../hooks/useMailInterval';
|
import { useMailInterval } from '../../hooks/useMailInterval';
|
||||||
import type { MailAccount } from '../../hooks/useMailStatus';
|
import type { MailAccount } from '../../hooks/useMailStatus';
|
||||||
|
|
||||||
@ -105,7 +105,6 @@ function StatusBadgeRow({
|
|||||||
isLegend: boolean;
|
isLegend: boolean;
|
||||||
t: (k: string, opts?: Record<string, string | number>) => string;
|
t: (k: string, opts?: Record<string, string | number>) => string;
|
||||||
}) {
|
}) {
|
||||||
// Priority 1 — auth / connect error
|
|
||||||
if (account.lastConnectError) {
|
if (account.lastConnectError) {
|
||||||
const isAuthError =
|
const isAuthError =
|
||||||
account.lastConnectError.toLowerCase().includes('invalid credentials') ||
|
account.lastConnectError.toLowerCase().includes('invalid credentials') ||
|
||||||
@ -138,7 +137,6 @@ function StatusBadgeRow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority 5 — never connected
|
|
||||||
if (!account.lastScannedAt) {
|
if (!account.lastScannedAt) {
|
||||||
return (
|
return (
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'center', marginTop: 3 }}>
|
<View style={{ flexDirection: 'row', alignItems: 'center', marginTop: 3 }}>
|
||||||
@ -155,7 +153,6 @@ function StatusBadgeRow({
|
|||||||
const scannedAgo = Date.now() - lastScannedTs.getTime();
|
const scannedAgo = Date.now() - lastScannedTs.getTime();
|
||||||
const scannedRelAbs = formatRelativeAbsolute(lastScannedTs);
|
const scannedRelAbs = formatRelativeAbsolute(lastScannedTs);
|
||||||
|
|
||||||
// Priority 4 — stale: heartbeat missing/expired AND scan is old
|
|
||||||
if (!heartbeatAlive && scannedAgo > STALE_THRESHOLD_MS) {
|
if (!heartbeatAlive && scannedAgo > STALE_THRESHOLD_MS) {
|
||||||
return (
|
return (
|
||||||
<View style={{ marginTop: 3 }}>
|
<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) {
|
if (heartbeatAlive) {
|
||||||
const heartbeatTs = new Date(account.lastIdleHeartbeatAt!);
|
const heartbeatTs = new Date(account.lastIdleHeartbeatAt!);
|
||||||
const heartbeatMin = Math.floor((Date.now() - heartbeatTs.getTime()) / 60_000);
|
const heartbeatMin = Math.floor((Date.now() - heartbeatTs.getTime()) / 60_000);
|
||||||
const idleSince = heartbeatMin < 1 ? 'gerade eben' : `${heartbeatMin} min`;
|
const idleSince = heartbeatMin < 1 ? 'gerade eben' : `${heartbeatMin} min`;
|
||||||
|
|
||||||
if (scannedAgo > NO_NEW_MAIL_THRESHOLD_MS) {
|
if (scannedAgo > NO_NEW_MAIL_THRESHOLD_MS) {
|
||||||
// Priority 3 — connected but no new mail for >1h
|
|
||||||
return (
|
return (
|
||||||
<View style={{ marginTop: 3 }}>
|
<View style={{ marginTop: 3 }}>
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||||
@ -205,7 +200,6 @@ function StatusBadgeRow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority 2 — live + heartbeat recent + scan recent
|
|
||||||
return (
|
return (
|
||||||
<View style={{ marginTop: 3 }}>
|
<View style={{ marginTop: 3 }}>
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||||
@ -226,7 +220,6 @@ function StatusBadgeRow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback — scan recent, backend without heartbeat field
|
|
||||||
return (
|
return (
|
||||||
<View style={{ marginTop: 3 }}>
|
<View style={{ marginTop: 3 }}>
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||||
@ -241,7 +234,7 @@ function StatusBadgeRow({
|
|||||||
style={{ fontSize: 10, fontFamily: 'Nunito_400Regular', color: '#a3a3a3', marginTop: 1 }}
|
style={{ fontSize: 10, fontFamily: 'Nunito_400Regular', color: '#a3a3a3', marginTop: 1 }}
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
>
|
>
|
||||||
{scannedRelAbs}
|
{formatRelativeAbsolute(new Date(account.lastScannedAt!))}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@ -253,21 +246,78 @@ const INTERVAL_OPTIONS_BY_PLAN: Record<'free' | 'pro' | 'legend', number[]> = {
|
|||||||
legend: [1, 4, 8],
|
legend: [1, 4, 8],
|
||||||
};
|
};
|
||||||
|
|
||||||
const HEADER_ROW = {
|
function maskEmail(email: string): string {
|
||||||
flexDirection: 'row' as const,
|
const [local, domain] = email.split('@');
|
||||||
alignItems: 'center' as const,
|
if (!local || !domain) return email;
|
||||||
paddingHorizontal: 14,
|
if (local.length <= 3) return `${local[0]}***@${domain}`;
|
||||||
paddingVertical: 14,
|
return `${local.slice(0, 3)}***@${domain}`;
|
||||||
};
|
}
|
||||||
|
|
||||||
const ACTION_BTN_BASE = {
|
function domainFromEmail(email: string): string {
|
||||||
flex: 1,
|
return email.split('@')[1] ?? email;
|
||||||
flexDirection: 'row' as const,
|
}
|
||||||
alignItems: 'center' as const,
|
|
||||||
justifyContent: 'center' as const,
|
function SettingsRow({
|
||||||
paddingVertical: 12,
|
icon,
|
||||||
borderRadius: 10,
|
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({
|
export function MailAccountCard({
|
||||||
account,
|
account,
|
||||||
@ -281,7 +331,9 @@ export function MailAccountCard({
|
|||||||
}: Props) {
|
}: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [confirmVisible, setConfirmVisible] = useState(false);
|
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 { setInterval, updating } = useMailInterval();
|
||||||
const { icon, color } = resolveProviderIcon(account.provider);
|
const { icon, color } = resolveProviderIcon(account.provider);
|
||||||
|
|
||||||
@ -289,9 +341,12 @@ export function MailAccountCard({
|
|||||||
const isPaused = account.paused === true;
|
const isPaused = account.paused === true;
|
||||||
const intervalOptions = INTERVAL_OPTIONS_BY_PLAN[plan];
|
const intervalOptions = INTERVAL_OPTIONS_BY_PLAN[plan];
|
||||||
|
|
||||||
|
const displayTitle = localTitle ?? domainFromEmail(account.email);
|
||||||
|
const subEmail = maskEmail(account.email);
|
||||||
|
|
||||||
function handleToggle() {
|
function handleToggle() {
|
||||||
if (account.lastConnectError) {
|
if (account.lastConnectError) {
|
||||||
setEditVisible(true);
|
setEditPasswordVisible(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
|
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
|
||||||
@ -303,6 +358,11 @@ export function MailAccountCard({
|
|||||||
if (res.ok) onIntervalChanged();
|
if (res.ok) onIntervalChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleTitleSaved(newTitle: string | null) {
|
||||||
|
setLocalTitle(newTitle);
|
||||||
|
onEditSuccess();
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<View
|
<View
|
||||||
@ -315,9 +375,16 @@ export function MailAccountCard({
|
|||||||
opacity: isPaused ? 0.75 : 1,
|
opacity: isPaused ? 0.75 : 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header — always visible, tap to expand settings */}
|
||||||
<Pressable onPress={handleToggle} android_ripple={{ color: '#f5f5f5' }}>
|
<TouchableOpacity onPress={handleToggle} activeOpacity={0.85}>
|
||||||
<View style={HEADER_ROW}>
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
width: 40,
|
width: 40,
|
||||||
@ -333,11 +400,28 @@ export function MailAccountCard({
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={{ flex: 1, minWidth: 0, marginRight: 8 }}>
|
<View style={{ flex: 1, minWidth: 0, marginRight: 8 }}>
|
||||||
|
{/* Title — prominent */}
|
||||||
<Text
|
<Text
|
||||||
style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: isPaused ? '#a3a3a3' : '#0a0a0a' }}
|
style={{
|
||||||
|
fontSize: 15,
|
||||||
|
fontFamily: 'Nunito_700Bold',
|
||||||
|
color: isPaused ? '#a3a3a3' : '#0a0a0a',
|
||||||
|
}}
|
||||||
numberOfLines={1}
|
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>
|
</Text>
|
||||||
{isPaused
|
{isPaused
|
||||||
? <PausedBadge t={t} />
|
? <PausedBadge t={t} />
|
||||||
@ -351,16 +435,19 @@ export function MailAccountCard({
|
|||||||
color="#a3a3a3"
|
color="#a3a3a3"
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</TouchableOpacity>
|
||||||
|
|
||||||
{/* Body */}
|
{/* Collapsible: Settings section */}
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<View style={{ borderTopWidth: 1, borderTopColor: '#f5f5f5', padding: 14 }}>
|
<View style={{ borderTopWidth: 1, borderTopColor: '#f5f5f5' }}>
|
||||||
|
{/* Stats banner */}
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
backgroundColor: '#fef2f2',
|
backgroundColor: '#fef2f2',
|
||||||
|
marginHorizontal: 14,
|
||||||
|
marginTop: 14,
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
paddingHorizontal: 14,
|
paddingHorizontal: 14,
|
||||||
paddingVertical: 12,
|
paddingVertical: 12,
|
||||||
@ -390,6 +477,7 @@ export function MailAccountCard({
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* Scan interval (non-legend) */}
|
||||||
{isLegend ? (
|
{isLegend ? (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
@ -397,6 +485,7 @@ export function MailAccountCard({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
backgroundColor: '#f0fdf4',
|
backgroundColor: '#f0fdf4',
|
||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
|
marginHorizontal: 14,
|
||||||
paddingHorizontal: 12,
|
paddingHorizontal: 12,
|
||||||
paddingVertical: 10,
|
paddingVertical: 10,
|
||||||
marginBottom: 12,
|
marginBottom: 12,
|
||||||
@ -415,7 +504,7 @@ export function MailAccountCard({
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View style={{ marginBottom: 12 }}>
|
<View style={{ marginHorizontal: 14, marginBottom: 12 }}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
@ -476,64 +565,88 @@ export function MailAccountCard({
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<View style={{ flexDirection: 'row' }}>
|
{/* Settings separator label */}
|
||||||
<TouchableOpacity
|
<View
|
||||||
activeOpacity={0.7}
|
style={{
|
||||||
onPress={() => setEditVisible(true)}
|
paddingHorizontal: 14,
|
||||||
style={{ ...ACTION_BTN_BASE, backgroundColor: '#f5f5f5', marginRight: 6 }}
|
paddingBottom: 4,
|
||||||
>
|
borderTopWidth: 1,
|
||||||
<Ionicons
|
borderTopColor: '#f5f5f5',
|
||||||
name="key-outline"
|
paddingTop: 10,
|
||||||
size={14}
|
}}
|
||||||
color="#525252"
|
>
|
||||||
style={{ marginRight: 6 }}
|
<Text
|
||||||
/>
|
|
||||||
<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}
|
|
||||||
style={{
|
style={{
|
||||||
...ACTION_BTN_BASE,
|
fontSize: 11,
|
||||||
backgroundColor: '#fef2f2',
|
fontFamily: 'Nunito_700Bold',
|
||||||
marginLeft: 6,
|
color: '#a3a3a3',
|
||||||
opacity: disconnecting ? 0.6 : 1,
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 0.6,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{disconnecting ? (
|
{t('mail.settings_section_label')}
|
||||||
<ActivityIndicator size="small" color="#dc2626" />
|
</Text>
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<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>
|
|
||||||
</View>
|
</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>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<ConfirmAlert
|
<ConfirmAlert
|
||||||
visible={confirmVisible}
|
visible={confirmVisible}
|
||||||
title={t('mail.account_disconnect_confirm_title')}
|
title={t('mail.disconnect_confirm_title')}
|
||||||
message={t('mail.account_disconnect_confirm_message', { email: account.email })}
|
message={t('mail.disconnect_confirm_body', { email: account.email })}
|
||||||
confirmLabel={t('mail.account_disconnect_confirm_btn')}
|
confirmLabel={t('mail.account_disconnect_confirm_btn')}
|
||||||
destructive
|
destructive
|
||||||
icon="trash"
|
icon="trash"
|
||||||
@ -546,11 +659,19 @@ export function MailAccountCard({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<EditMailAccountSheet
|
<EditMailAccountSheet
|
||||||
visible={editVisible}
|
visible={editPasswordVisible}
|
||||||
email={account.email}
|
email={account.email}
|
||||||
onClose={() => setEditVisible(false)}
|
onClose={() => setEditPasswordVisible(false)}
|
||||||
onSuccess={onEditSuccess}
|
onSuccess={onEditSuccess}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<EditMailTitleSheet
|
||||||
|
visible={editTitleVisible}
|
||||||
|
connectionId={account.id}
|
||||||
|
currentTitle={localTitle}
|
||||||
|
onClose={() => setEditTitleVisible(false)}
|
||||||
|
onSuccess={handleTitleSaved}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
LayoutAnimation,
|
LayoutAnimation,
|
||||||
Platform,
|
Platform,
|
||||||
Pressable,
|
ScrollView,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
Text,
|
Text,
|
||||||
UIManager,
|
UIManager,
|
||||||
@ -10,6 +11,7 @@ import {
|
|||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useMailResults, type MailBlockedItem } from '../../hooks/useMailResults';
|
import { useMailResults, type MailBlockedItem } from '../../hooks/useMailResults';
|
||||||
|
import { useColors } from '../../lib/theme';
|
||||||
|
|
||||||
if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) {
|
if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) {
|
||||||
UIManager.setLayoutAnimationEnabledExperimental(true);
|
UIManager.setLayoutAnimationEnabledExperimental(true);
|
||||||
@ -18,6 +20,7 @@ if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental
|
|||||||
type Props = {
|
type Props = {
|
||||||
expanded: boolean;
|
expanded: boolean;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
|
providers?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatDate(iso: string, t: (k: string) => string): 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`;
|
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 { 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() {
|
function handleToggle() {
|
||||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
|
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
|
||||||
onToggle();
|
onToggle();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleProviderFilter(p: string) {
|
||||||
|
setActiveProvider(p);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: '#fff',
|
backgroundColor: colors.surface,
|
||||||
borderRadius: 16,
|
borderRadius: 16,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: '#e5e5e5',
|
borderColor: colors.border,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Pressable onPress={handleToggle} android_ripple={{ color: '#f5f5f5' }}>
|
<TouchableOpacity onPress={handleToggle} activeOpacity={0.85}>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@ -73,7 +101,7 @@ export function MailActivityLog({ expanded, onToggle }: Props) {
|
|||||||
</View>
|
</View>
|
||||||
<View style={{ flex: 1, minWidth: 0, marginRight: 8 }}>
|
<View style={{ flex: 1, minWidth: 0, marginRight: 8 }}>
|
||||||
<Text
|
<Text
|
||||||
style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: '#0a0a0a' }}
|
style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: colors.text }}
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
>
|
>
|
||||||
{t('mail.activity_log_title')}
|
{t('mail.activity_log_title')}
|
||||||
@ -82,7 +110,7 @@ export function MailActivityLog({ expanded, onToggle }: Props) {
|
|||||||
style={{
|
style={{
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontFamily: 'Nunito_400Regular',
|
fontFamily: 'Nunito_400Regular',
|
||||||
color: '#a3a3a3',
|
color: colors.textMuted,
|
||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
}}
|
}}
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
@ -93,27 +121,65 @@ export function MailActivityLog({ expanded, onToggle }: Props) {
|
|||||||
<Ionicons
|
<Ionicons
|
||||||
name={expanded ? 'chevron-up' : 'chevron-down'}
|
name={expanded ? 'chevron-up' : 'chevron-down'}
|
||||||
size={18}
|
size={18}
|
||||||
color="#a3a3a3"
|
color={colors.textMuted}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</TouchableOpacity>
|
||||||
|
|
||||||
{expanded && (
|
{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 ? (
|
{loading && results.length === 0 ? (
|
||||||
<View style={{ padding: 20, alignItems: 'center' }}>
|
<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')}
|
{t('mail.loading')}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
) : results.length === 0 ? (
|
) : results.length === 0 ? (
|
||||||
<View style={{ padding: 24, alignItems: 'center' }}>
|
<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
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontFamily: 'Nunito_600SemiBold',
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
color: '#525252',
|
color: colors.textMuted,
|
||||||
marginTop: 6,
|
marginTop: 6,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -123,7 +189,7 @@ export function MailActivityLog({ expanded, onToggle }: Props) {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{results.slice(0, 10).map((item) => (
|
{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
|
<View
|
||||||
style={{
|
style={{
|
||||||
@ -133,16 +199,16 @@ export function MailActivityLog({ expanded, onToggle }: Props) {
|
|||||||
paddingHorizontal: 14,
|
paddingHorizontal: 14,
|
||||||
paddingVertical: 10,
|
paddingVertical: 10,
|
||||||
borderTopWidth: 1,
|
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
|
{total > 10
|
||||||
? t('mail.activity_log_more', { count: total - 10 })
|
? t('mail.activity_log_more', { count: total - 10 })
|
||||||
: t('mail.activity_log_count', { count: total })}
|
: t('mail.activity_log_count', { count: total })}
|
||||||
</Text>
|
</Text>
|
||||||
<TouchableOpacity activeOpacity={0.7} onPress={refresh} hitSlop={8}>
|
<TouchableOpacity activeOpacity={0.7} onPress={refresh} hitSlop={8}>
|
||||||
<Ionicons name="refresh" size={14} color="#737373" />
|
<Ionicons name="refresh" size={14} color={colors.textMuted} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</>
|
</>
|
||||||
@ -156,10 +222,16 @@ export function MailActivityLog({ expanded, onToggle }: Props) {
|
|||||||
function ActivityItem({
|
function ActivityItem({
|
||||||
item,
|
item,
|
||||||
t,
|
t,
|
||||||
|
colors,
|
||||||
}: {
|
}: {
|
||||||
item: MailBlockedItem;
|
item: MailBlockedItem;
|
||||||
t: (k: string, opts?: any) => string;
|
t: (k: string, opts?: any) => string;
|
||||||
|
colors: ReturnType<typeof useColors>;
|
||||||
}) {
|
}) {
|
||||||
|
const accountLabel = item.connection_title ?? (
|
||||||
|
item.sender_email ? domainFromEmail(item.sender_email) : null
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
@ -168,7 +240,7 @@ function ActivityItem({
|
|||||||
paddingHorizontal: 14,
|
paddingHorizontal: 14,
|
||||||
paddingVertical: 10,
|
paddingVertical: 10,
|
||||||
borderTopWidth: 1,
|
borderTopWidth: 1,
|
||||||
borderTopColor: '#fafafa',
|
borderTopColor: colors.border,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
@ -186,17 +258,40 @@ function ActivityItem({
|
|||||||
<Ionicons name="close" size={12} color="#dc2626" />
|
<Ionicons name="close" size={12} color="#dc2626" />
|
||||||
</View>
|
</View>
|
||||||
<View style={{ flex: 1, minWidth: 0, marginRight: 8 }}>
|
<View style={{ flex: 1, minWidth: 0, marginRight: 8 }}>
|
||||||
<Text
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
||||||
style={{ fontSize: 13, fontFamily: 'Nunito_600SemiBold', color: '#0a0a0a' }}
|
<Text
|
||||||
numberOfLines={1}
|
style={{ fontSize: 13, fontFamily: 'Nunito_600SemiBold', color: colors.text, flexShrink: 1 }}
|
||||||
>
|
numberOfLines={1}
|
||||||
{item.subject || t('mail.activity_no_subject')}
|
>
|
||||||
</Text>
|
{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
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontFamily: 'Nunito_400Regular',
|
fontFamily: 'Nunito_400Regular',
|
||||||
color: '#737373',
|
color: colors.textMuted,
|
||||||
marginTop: 1,
|
marginTop: 1,
|
||||||
}}
|
}}
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
@ -208,7 +303,7 @@ function ActivityItem({
|
|||||||
style={{
|
style={{
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
fontFamily: 'Nunito_400Regular',
|
fontFamily: 'Nunito_400Regular',
|
||||||
color: '#a3a3a3',
|
color: colors.textMuted,
|
||||||
marginTop: 2,
|
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;
|
sender_name: string | null;
|
||||||
received_at: string;
|
received_at: string;
|
||||||
connection_id: string;
|
connection_id: string;
|
||||||
|
connection_title?: string | null;
|
||||||
|
provider?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MailResultsResponse = {
|
export type MailResultsResponse = {
|
||||||
@ -18,10 +20,11 @@ export type MailResultsResponse = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/mail/results — Liste der in den letzten 24h gelöschten Mails.
|
* GET /api/mail/results — Liste der blockierten Mails mit optionalem Provider-Filter.
|
||||||
* Backend räumt selbst nach 24h auf (deleteOldMailBlocked).
|
* 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 [results, setResults] = useState<MailBlockedItem[]>([]);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@ -31,8 +34,9 @@ export function useMailResults(enabled: boolean = true) {
|
|||||||
if (!enabled) return;
|
if (!enabled) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
|
const qs = provider !== 'all' ? `?provider=${encodeURIComponent(provider)}` : '';
|
||||||
const res = await apiFetch<MailResultsResponse>(
|
const res = await apiFetch<MailResultsResponse>(
|
||||||
"/api/mail/results?page=1",
|
`/api/mail/results${qs}`,
|
||||||
);
|
);
|
||||||
setResults(res.results ?? []);
|
setResults(res.results ?? []);
|
||||||
setTotal(res.total ?? 0);
|
setTotal(res.total ?? 0);
|
||||||
@ -42,7 +46,7 @@ export function useMailResults(enabled: boolean = true) {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [enabled]);
|
}, [enabled, provider]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (enabled) refresh();
|
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;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
provider: string;
|
provider: string;
|
||||||
|
title?: string | null;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
paused?: boolean;
|
paused?: boolean;
|
||||||
lastScannedAt: string | null;
|
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_later": "Später",
|
||||||
"reminder_cta_disconnect": "Verbindungen jetzt trennen",
|
"reminder_cta_disconnect": "Verbindungen jetzt trennen",
|
||||||
"reminder_consent_error": "Einwilligung konnte nicht gespeichert werden. Bitte erneut versuchen."
|
"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": {
|
"settings": {
|
||||||
|
|||||||
@ -439,6 +439,28 @@
|
|||||||
"reminder_cta_later": "Later",
|
"reminder_cta_later": "Later",
|
||||||
"reminder_cta_disconnect": "Disconnect now",
|
"reminder_cta_disconnect": "Disconnect now",
|
||||||
"reminder_consent_error": "Failed to save consent. Please try again."
|
"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": {
|
"settings": {
|
||||||
|
|||||||
@ -17,22 +17,25 @@ type MailConnectDraftState = {
|
|||||||
consentGiven: boolean;
|
consentGiven: boolean;
|
||||||
selectedProvider: ProviderSnapshot | null;
|
selectedProvider: ProviderSnapshot | null;
|
||||||
email: string;
|
email: string;
|
||||||
|
title: string;
|
||||||
|
|
||||||
setView: (view: 'consent' | 'grid' | 'form') => void;
|
setView: (view: 'consent' | 'grid' | 'form') => void;
|
||||||
setConsentGiven: (v: boolean) => void;
|
setConsentGiven: (v: boolean) => void;
|
||||||
setSelectedProvider: (p: ProviderSnapshot | null) => void;
|
setSelectedProvider: (p: ProviderSnapshot | null) => void;
|
||||||
setEmail: (email: string) => void;
|
setEmail: (email: string) => void;
|
||||||
|
setTitle: (title: string) => void;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const INITIAL: Pick<
|
const INITIAL: Pick<
|
||||||
MailConnectDraftState,
|
MailConnectDraftState,
|
||||||
'view' | 'consentGiven' | 'selectedProvider' | 'email'
|
'view' | 'consentGiven' | 'selectedProvider' | 'email' | 'title'
|
||||||
> = {
|
> = {
|
||||||
view: 'consent',
|
view: 'consent',
|
||||||
consentGiven: false,
|
consentGiven: false,
|
||||||
selectedProvider: null,
|
selectedProvider: null,
|
||||||
email: '',
|
email: '',
|
||||||
|
title: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useMailConnectDraft = create<MailConnectDraftState>((set) => ({
|
export const useMailConnectDraft = create<MailConnectDraftState>((set) => ({
|
||||||
@ -42,5 +45,6 @@ export const useMailConnectDraft = create<MailConnectDraftState>((set) => ({
|
|||||||
setConsentGiven: (consentGiven) => set({ consentGiven }),
|
setConsentGiven: (consentGiven) => set({ consentGiven }),
|
||||||
setSelectedProvider: (selectedProvider) => set({ selectedProvider }),
|
setSelectedProvider: (selectedProvider) => set({ selectedProvider }),
|
||||||
setEmail: (email) => set({ email }),
|
setEmail: (email) => set({ email }),
|
||||||
|
setTitle: (title) => set({ title }),
|
||||||
reset: () => set(INITIAL),
|
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.
|
/// Für Audit und zukünftige Scope-Vergleiche bei Re-Auth.
|
||||||
oauthScope String? @map("oauth_scope")
|
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) ───────────
|
// ─── Art. 9-Einwilligung (DSGVO-Compliance, Mail-Auto-Delete) ───────────
|
||||||
// consentAt=NULL für Bestandsrows → "Re-Consent pending".
|
// consentAt=NULL für Bestandsrows → "Re-Consent pending".
|
||||||
// Daemon pausiert Mail-Verarbeitung wenn consentAt=NULL (kein Auto-Delete).
|
// 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 { deleteOldMailBlocked, getMailBlockedPaginated } from "../../db/mail";
|
||||||
|
import { resolveProviderMeta } from "../../utils/imap-providers";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/mail/results
|
* GET /api/mail/results
|
||||||
* Gibt die letzten blockierten Gambling-Mails zurück (paginiert).
|
* 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) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const user = await requireUser(event);
|
const user = await requireUser(event);
|
||||||
|
|
||||||
const query = getQuery(event);
|
const query = getQuery(event);
|
||||||
const page = Math.max(1, parseInt((query.page as string) || "1"));
|
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);
|
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 { getMailConnections, getMailBlockedStats } from "../../db/mail";
|
||||||
|
import { resolveProviderMeta } from "../../utils/imap-providers";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/mail/status
|
* GET /api/mail/status
|
||||||
@ -9,24 +10,30 @@ export default defineEventHandler(async (event) => {
|
|||||||
|
|
||||||
const connections = await getMailConnections(user.id);
|
const connections = await getMailConnections(user.id);
|
||||||
|
|
||||||
const list = connections.map((c) => ({
|
const list = connections.map((c) => {
|
||||||
id: c.id,
|
const providerMeta = resolveProviderMeta(c.imapHost);
|
||||||
email: c.email,
|
return {
|
||||||
provider: c.providerName ?? "IMAP",
|
id: c.id,
|
||||||
isActive: c.isActive,
|
email: c.email,
|
||||||
lastScannedAt: c.lastScannedAt?.toISOString() ?? null,
|
title: c.title ?? null,
|
||||||
nextScanAt: c.nextScanAt?.toISOString() ?? null,
|
provider: providerMeta.provider,
|
||||||
totalBlocked: c.emailsBlocked,
|
providerLabel: providerMeta.providerLabel,
|
||||||
totalScanned: c.emailsScanned,
|
isCustomDomain: providerMeta.isCustomDomain,
|
||||||
scanInterval: c.scanInterval,
|
isActive: c.isActive,
|
||||||
blockRate:
|
lastScannedAt: c.lastScannedAt?.toISOString() ?? null,
|
||||||
c.emailsScanned > 0
|
nextScanAt: c.nextScanAt?.toISOString() ?? null,
|
||||||
? Math.round((c.emailsBlocked / c.emailsScanned) * 100)
|
totalBlocked: c.emailsBlocked,
|
||||||
: 0,
|
totalScanned: c.emailsScanned,
|
||||||
lastConnectError: c.lastConnectError ?? null,
|
scanInterval: c.scanInterval,
|
||||||
lastConnectErrorAt: c.lastConnectErrorAt?.toISOString() ?? null,
|
blockRate:
|
||||||
lastIdleHeartbeatAt: c.lastIdleHeartbeatAt?.toISOString() ?? null,
|
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);
|
const blocked7d = await getMailBlockedStats(user.id);
|
||||||
|
|
||||||
|
|||||||
@ -18,8 +18,12 @@ export async function getAllMailConnections(userId: string) {
|
|||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
email: true,
|
email: true,
|
||||||
|
title: true,
|
||||||
provider: true,
|
provider: true,
|
||||||
providerName: true,
|
providerName: true,
|
||||||
|
imapHost: true,
|
||||||
|
authMethod: true,
|
||||||
|
consentAt: true,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
pausedAt: true,
|
pausedAt: true,
|
||||||
pausedReason: true,
|
pausedReason: true,
|
||||||
@ -230,17 +234,117 @@ export async function getMailBlockedPaginated(
|
|||||||
userId: string,
|
userId: string,
|
||||||
page: number,
|
page: number,
|
||||||
limit = 20,
|
limit = 20,
|
||||||
|
providerFilter?: string[],
|
||||||
) {
|
) {
|
||||||
const db = usePrisma();
|
const db = usePrisma();
|
||||||
const offset = (page - 1) * limit;
|
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([
|
const [results, total] = await Promise.all([
|
||||||
db.mailBlocked.findMany({
|
db.mailBlocked.findMany({
|
||||||
where: { userId },
|
where: whereBase,
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
skip: offset,
|
skip: offset,
|
||||||
take: limit,
|
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) };
|
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 } {
|
export function detectSmtpProvider(imapHost: string): { host: string; port: number } {
|
||||||
return SMTP_MAP[imapHost] ?? { host: imapHost.replace("imap.", "smtp."), port: 587 };
|
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