feat(mail-page): polish v3 + shared HalfDonut + status-dot heartbeat-aware

User-Feedback nach Live-Test:

Frontend (mail page):
- HalfDonut als shared component in components/common/HalfDonut.tsx
  extrahiert (vorher local in ProtectionDetailsSheet). Mail-Page nutzt
  jetzt dieselbe SVG-Math, Animation und Stroke-Style wie der
  Blocker-Schutz-Details-Sheet — visuelle Konsistenz auf einen Blick.
  Mail-Donut: width=168 (kompakter als die 220 in Blocker, weil Legend
  rechts daneben sitzt).
- Donut zeigt Total in der Mitte mit kompaktem Format:
  < 1000 → "999", >=1000 → "1.2k+" / "12k+" / "27k+"
  Headline-Zahl oben links entfällt — Total ist im Donut-Center.
- "Mehr Infos" + "Kürzlich blockiert" zu EINER Top-Level-Collapsible
  zusammengefasst. Beim Aufklappen: Bar-Chart direkt sichtbar, nested
  Collapsible "Kürzlich blockiert" darunter (default zu).
- Account-Card Expanded: per-Connection-Bar-Chart mit adaptive
  Granularität nach Connection-Age:
  · <24h → Empty-State "Daten werden gesammelt, Auswertung nach 24h"
  · 1-14d → Day-Buckets (echte Daten via /api/mail/stats/blocked-by-day
    ?connectionId=)
  · 15-90d → Week-Buckets (client-aggregiert)
  · >90d → Month-Buckets (client-aggregiert)
- Settings-Sheet komplett refactored: State-Machine `mode: 'list' |
  'edit-title' | 'edit-email' | 'edit-password'` mit Back-Pfeil. Inline-
  Edit im selben Sheet statt Sub-Sheet öffnen (FormSheet-Pattern).
  Email-Edit-Row vorbereitet (Backend-PATCH-Endpoint kommt separat).
- Pen-Icons app-weit entfernt: SheetFieldStack-Row, alle Settings-Rows
  auf chevron-forward (Memory-Konvention).

Frontend (MailAccountCard status fix):
- resolveStatusDot nutzt jetzt heartbeat-as-fallback. Vorher: "waiting"
  wenn lastScannedAt=null, egal ob Daemon längst connected war. Jetzt:
  "waiting" nur wenn weder lebendiger Heartbeat noch vergangener Scan
  existiert → frisch verbundene Connections (z.B. OAuth-Outlook 5s nach
  Connect) zeigen direkt "live".
- Behebt User-Beobachtung: "wartet auf erste verbindung" bei Outlook
  obwohl Daemon-Log "connected, auth=xoauth2" zeigt.

Backend (imap-idle daemon):
- getMailboxLock("INBOX") jetzt mit 30s Promise.race-Timeout gewrappt.
- Outlook/XOAUTH2 hat den Edge-Case, dass der Mailbox-Lock lautlos
  hängt nach erfolgreichem connect — die Session bleibt offen ohne
  Fortschritt bis der Renew-Timer (10min) ein imap.close() schickt.
  Mit Timeout wird das Failure-Mode explizit → Auth-Retry-Loop greift
  sauber + last_connect_error mit klarem Text (statt stiller Hänger).
- Root-Cause "warum hängt es" noch nicht behoben — Diagnose nach
  Deploy in Logs (mo).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-05-13 23:23:45 +02:00
parent 206941e5e1
commit 1dfb0c647c
14 changed files with 959 additions and 503 deletions

View File

@ -16,7 +16,7 @@ import { Ionicons } from '@expo/vector-icons';
import { AppHeader } from '../../components/AppHeader';
import { MailAccountCard } from '../../components/mail/MailAccountCard';
import { MailEmptyState } from '../../components/mail/MailEmptyState';
import { MailActivityLog } from '../../components/mail/MailActivityLog';
import { MailActivityLogBody } from '../../components/mail/MailActivityLog';
import { MailBlockedByDayChart } from '../../components/mail/MailBlockedByDayChart';
import { MailDistributionChart } from '../../components/mail/MailDistributionChart';
import { ConnectMailSheet } from '../../components/mail/ConnectMailSheet';
@ -91,20 +91,28 @@ function MoreInfosSection({
expanded,
onToggle,
blockedByDay,
providers,
colors,
}: {
expanded: boolean;
onToggle: () => void;
blockedByDay: import('../../hooks/useMailStats').BlockedByDayEntry[];
providers: string[];
colors: import('../../lib/theme').ColorScheme;
}) {
const { t } = useTranslation();
const [activityExpanded, setActivityExpanded] = useState(false);
function handleToggle() {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
onToggle();
}
function handleActivityToggle() {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
setActivityExpanded((p) => !p);
}
return (
<View
style={{
@ -170,10 +178,77 @@ function MoreInfosSection({
borderTopWidth: 1,
borderTopColor: colors.border,
paddingHorizontal: 12,
paddingTop: 12,
paddingBottom: 12,
gap: 10,
}}
>
{/* Bar-Chart direkt sichtbar */}
<MailBlockedByDayChart data={blockedByDay} />
{/* Nested: Kürzlich blockiert — default collapsed */}
<View
style={{
backgroundColor: colors.surfaceElevated ?? colors.surface,
borderRadius: 12,
borderWidth: 1,
borderColor: colors.border,
overflow: 'hidden',
}}
>
<TouchableOpacity onPress={handleActivityToggle} activeOpacity={0.85}>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 12,
paddingVertical: 12,
}}
>
<MailBlockedByDayChart data={blockedByDay} />
<View
style={{
width: 28,
height: 28,
borderRadius: 7,
backgroundColor: '#fef2f2',
alignItems: 'center',
justifyContent: 'center',
marginRight: 10,
}}
>
<Ionicons name="trash" size={13} color="#dc2626" />
</View>
<View style={{ flex: 1, minWidth: 0, marginRight: 8 }}>
<Text
style={{ fontSize: 13, fontFamily: 'Nunito_700Bold', color: colors.text }}
numberOfLines={1}
>
{t('mail.activity_log_title')}
</Text>
<Text
style={{
fontSize: 11,
fontFamily: 'Nunito_400Regular',
color: colors.textMuted,
marginTop: 1,
}}
numberOfLines={1}
>
{t('mail.activity_log_subtitle')}
</Text>
</View>
<Ionicons
name={activityExpanded ? 'chevron-up' : 'chevron-down'}
size={16}
color={colors.textMuted}
/>
</View>
</TouchableOpacity>
{activityExpanded && (
<MailActivityLogBody providers={providers} colors={colors} />
)}
</View>
</View>
)}
</View>
@ -197,7 +272,6 @@ export default function MailScreen() {
const [successVisible, setSuccessVisible] = useState(false);
const [disconnectingId, setDisconnectingId] = useState<string | null>(null);
const [expandedAccount, setExpandedAccount] = useState<string | null>(null);
const [activityLogExpanded, setActivityLogExpanded] = useState(false);
const [moreInfosExpanded, setMoreInfosExpanded] = useState(false);
const [oauthTitleSheetConnectionId, setOauthTitleSheetConnectionId] = useState<string | null>(null);
@ -443,25 +517,15 @@ export default function MailScreen() {
</View>
)}
{/* 3. COLLAPSIBLE "MEHR INFOS" — Bar-Chart letzte 30 Tage */}
{/* 3. COLLAPSIBLE "MEHR INFOS" — Bar-Chart + nested Kürzlich blockiert */}
{hasAccounts && (
<View style={{ marginTop: 14 }}>
<MoreInfosSection
expanded={moreInfosExpanded}
onToggle={() => setMoreInfosExpanded((p) => !p)}
blockedByDay={blockedByDay}
colors={colors}
/>
</View>
)}
{/* 4. ACTIVITY LOG */}
{hasAccounts && (
<View style={{ marginTop: 14 }}>
<MailActivityLog
expanded={activityLogExpanded}
onToggle={() => setActivityLogExpanded((p) => !p)}
providers={distinctProviders}
colors={colors}
/>
</View>
)}

View File

@ -142,7 +142,7 @@ export function SheetFieldStack({ fields, intro, children, onComplete }: Props)
{field.secureTextEntry ? '••••••••' : field.value}
</Text>
</View>
<Ionicons name="pencil-outline" size={14} color={colors.textMuted} />
<Ionicons name="chevron-forward" size={14} color={colors.textMuted} />
</TouchableOpacity>
))}

View File

@ -11,11 +11,11 @@ import {
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import Svg, { Path, Circle } from 'react-native-svg';
import type { ProtectionState } from '../../lib/protection';
import { apiFetch } from '../../lib/api';
import { useColors } from '../../lib/theme';
import { FormSheet } from '../FormSheet';
import { HalfDonut } from '../common/HalfDonut';
type Props = {
visible: boolean;
@ -411,115 +411,6 @@ function LegendItem({
);
}
// ─── Half Donut (multi-segment) ────────────────────────────────────────────
function HalfDonut({
segments,
centerValue,
centerLabel,
}: {
segments: { value: number; color: string }[];
centerValue: number;
centerLabel: string;
}) {
const colors = useColors();
const total = Math.max(1, segments.reduce((s, x) => s + x.value, 0));
const W = 220;
const H = 130;
const cx = W / 2;
const cy = H - 8;
const r = 90;
const stroke = 18;
// Compute cumulative angles in [180, 360]
let cumAngle = 180;
const arcs = segments.map((seg) => {
const startAngle = cumAngle;
const endAngle = cumAngle + 180 * (seg.value / total);
cumAngle = endAngle;
return { ...seg, startAngle, endAngle };
});
const animProgress = useRef(new Animated.Value(0)).current;
const [progress, setProgress] = useState(0);
useEffect(() => {
animProgress.setValue(0);
const l = animProgress.addListener(({ value }) => setProgress(value));
Animated.timing(animProgress, {
toValue: 1,
duration: 1100,
easing: Easing.out(Easing.cubic),
useNativeDriver: false,
}).start();
return () => animProgress.removeListener(l);
}, [centerValue, animProgress]);
return (
<View style={{ alignItems: 'center', justifyContent: 'center' }}>
<Svg width={W} height={H}>
{/* Background track */}
<Path
d={arcPath(cx, cy, r, 180, 360)}
stroke={colors.surfaceElevated}
strokeWidth={stroke}
fill="none"
strokeLinecap="round"
/>
{arcs.map((a, i) => {
const animatedEnd =
a.startAngle + (a.endAngle - a.startAngle) * progress;
if (animatedEnd <= a.startAngle + 0.5) return null;
return (
<Path
key={i}
d={arcPath(cx, cy, r, a.startAngle, animatedEnd)}
stroke={a.color}
strokeWidth={stroke}
fill="none"
strokeLinecap="round"
/>
);
})}
{centerValue === 0 && (
<Circle cx={cx} cy={cy - r + stroke / 2} r={3} fill="#d4d4d8" />
)}
</Svg>
{/* Center number — exactly centered horizontally + vertically inside semicircle */}
<View
pointerEvents="none"
style={{
position: 'absolute',
left: 0,
right: 0,
top: H / 2 + 4,
alignItems: 'center',
}}
>
<Text style={{ fontSize: 30, fontFamily: 'Nunito_900Black', color: colors.text, letterSpacing: -0.5 }}>
{centerValue}
</Text>
<Text style={{ fontSize: 10, color: colors.textMuted, fontFamily: 'Nunito_400Regular', marginTop: -2 }}>
{centerLabel}
</Text>
</View>
</View>
);
}
function arcPath(cx: number, cy: number, r: number, startDeg: number, endDeg: number) {
const start = polar(cx, cy, r, startDeg);
const end = polar(cx, cy, r, endDeg);
const largeArc = endDeg - startDeg > 180 ? 1 : 0;
return `M ${start.x} ${start.y} A ${r} ${r} 0 ${largeArc} 1 ${end.x} ${end.y}`;
}
function polar(cx: number, cy: number, r: number, angleDeg: number) {
const rad = (angleDeg * Math.PI) / 180;
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) };
}
// ─── FAQ Item (chevron AT END of header row, on right) ─────────────────────
function FaqItem({ question, answer }: { question: string; answer: string }) {
const colors = useColors();

View File

@ -0,0 +1,120 @@
import { useEffect, useRef, useState } from 'react';
import { View, Text, Animated, Easing } from 'react-native';
import Svg, { Path, Circle } from 'react-native-svg';
import { useColors } from '../../lib/theme';
export type HalfDonutSegment = { value: number; color: string };
type Props = {
segments: HalfDonutSegment[];
centerValue: number | string;
centerLabel: string;
width?: number;
};
const W_DEFAULT = 220;
const H_DEFAULT = 130;
const R = 90;
const STROKE = 18;
function polar(cx: number, cy: number, r: number, angleDeg: number) {
const rad = (angleDeg * Math.PI) / 180;
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) };
}
function arcPath(cx: number, cy: number, r: number, startDeg: number, endDeg: number) {
const start = polar(cx, cy, r, startDeg);
const end = polar(cx, cy, r, endDeg);
const largeArc = endDeg - startDeg > 180 ? 1 : 0;
return `M ${start.x} ${start.y} A ${r} ${r} 0 ${largeArc} 1 ${end.x} ${end.y}`;
}
export function HalfDonut({ segments, centerValue, centerLabel, width = W_DEFAULT }: Props) {
const colors = useColors();
const scale = width / W_DEFAULT;
const W = width;
const H = Math.round(H_DEFAULT * scale);
const cx = W / 2;
const cy = H - Math.round(8 * scale);
const r = Math.round(R * scale);
const stroke = Math.round(STROKE * scale);
const total = Math.max(1, segments.reduce((s, x) => s + x.value, 0));
let cumAngle = 180;
const arcs = segments.map((seg) => {
const startAngle = cumAngle;
const endAngle = cumAngle + 180 * (seg.value / total);
cumAngle = endAngle;
return { ...seg, startAngle, endAngle };
});
const animProgress = useRef(new Animated.Value(0)).current;
const [progress, setProgress] = useState(0);
const animKey = typeof centerValue === 'number' ? centerValue : centerValue;
useEffect(() => {
animProgress.setValue(0);
const l = animProgress.addListener(({ value }) => setProgress(value));
Animated.timing(animProgress, {
toValue: 1,
duration: 1100,
easing: Easing.out(Easing.cubic),
useNativeDriver: false,
}).start();
return () => animProgress.removeListener(l);
}, [animKey, animProgress]);
const isEmpty = typeof centerValue === 'number' ? centerValue === 0 : centerValue === '0';
return (
<View style={{ alignItems: 'center', justifyContent: 'center' }}>
<Svg width={W} height={H}>
<Path
d={arcPath(cx, cy, r, 180, 360)}
stroke={colors.surfaceElevated}
strokeWidth={stroke}
fill="none"
strokeLinecap="round"
/>
{arcs.map((a, i) => {
const animatedEnd = a.startAngle + (a.endAngle - a.startAngle) * progress;
if (animatedEnd <= a.startAngle + 0.5) return null;
return (
<Path
key={i}
d={arcPath(cx, cy, r, a.startAngle, animatedEnd)}
stroke={a.color}
strokeWidth={stroke}
fill="none"
strokeLinecap="round"
/>
);
})}
{isEmpty && (
<Circle cx={cx} cy={cy - r + stroke / 2} r={3} fill="#d4d4d8" />
)}
</Svg>
<View
pointerEvents="none"
style={{
position: 'absolute',
left: 0,
right: 0,
top: H / 2 + 4,
alignItems: 'center',
}}
>
<Text style={{ fontSize: 30, fontFamily: 'Nunito_900Black', color: colors.text, letterSpacing: -0.5 }}>
{centerValue}
</Text>
<Text style={{ fontSize: 10, color: colors.textMuted, fontFamily: 'Nunito_400Regular', marginTop: -2 }}>
{centerLabel}
</Text>
</View>
</View>
);
}

View File

@ -12,12 +12,10 @@ import {
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import { ConfirmAlert } from '../ConfirmAlert';
import { EditMailAccountSheet } from './EditMailAccountSheet';
import { EditMailTitleSheet } from './EditMailTitleSheet';
import { MailAccountSettingsSheet } from './MailAccountSettingsSheet';
import { MailBlockedByDayChart } from './MailBlockedByDayChart';
import { useMailConnectionStats } from '../../hooks/useMailStats';
import type { MailAccount } from '../../hooks/useMailStatus';
import type { BlockedByDayEntry } from '../../hooks/useMailStats';
if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) {
UIManager.setLayoutAnimationEnabledExperimental(true);
@ -33,7 +31,6 @@ type Props = {
onEditSuccess: () => void;
disconnecting?: boolean;
blockedLast30d?: number;
connectionBlockedByDay?: BlockedByDayEntry[];
};
function OAuthDisconnectHintModal({
@ -186,10 +183,17 @@ type StatusDot = 'live' | 'stale' | 'error' | 'waiting';
function resolveStatusDot(account: MailAccount): StatusDot {
if (account.lastConnectError) return 'error';
if (!account.lastScannedAt) return 'waiting';
// 'waiting' nur wenn weder ein lebendiger Heartbeat noch ein vergangener Scan
// existiert. Bei frisch verbundenen Connections (z.B. OAuth-Outlook nach
// ersten 5-30s) hat der Daemon schon einen Heartbeat geschrieben, aber
// lastScannedAt bleibt NULL bis die erste Gambling-Mail trifft. Wir wollen
// dann 'live' anzeigen, nicht 'waiting'.
const heartbeatAlive = idleHeartbeatAlive(account.lastIdleHeartbeatAt);
if (!account.lastScannedAt && !heartbeatAlive) return 'waiting';
if (account.lastScannedAt) {
const scannedAgo = Date.now() - new Date(account.lastScannedAt).getTime();
if (!heartbeatAlive && scannedAgo > STALE_THRESHOLD_MS) return 'stale';
}
return 'live';
}
@ -252,16 +256,18 @@ export function MailAccountCard({
onEditSuccess,
disconnecting,
blockedLast30d,
connectionBlockedByDay,
}: Props) {
const { t } = useTranslation();
const [settingsVisible, setSettingsVisible] = useState(false);
const [editPasswordVisible, setEditPasswordVisible] = useState(false);
const [editTitleVisible, setEditTitleVisible] = useState(false);
const [confirmVisible, setConfirmVisible] = useState(false);
const [oauthDisconnectHintVisible, setOauthDisconnectHintVisible] = useState(false);
const [localTitle, setLocalTitle] = useState<string | null>(account.title ?? null);
const { icon, color } = resolveProviderIcon(account.provider);
const { data: connStats, granularity, loading: statsLoading } = useMailConnectionStats(
account.id,
account.createdAt ?? null,
expanded,
);
const isOAuth = isOAuthProvider(account.provider);
const isLegend = plan === 'legend';
@ -271,10 +277,6 @@ export function MailAccountCard({
const displayTitle = localTitle ?? domainFromEmail(account.email);
function handleToggle() {
if (hasError) {
setEditPasswordVisible(true);
return;
}
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
onToggle();
}
@ -361,9 +363,27 @@ export function MailAccountCard({
<View style={{ borderTopWidth: 1, borderTopColor: '#f5f5f5' }}>
{/* Per-connection bar chart */}
<View style={{ paddingHorizontal: 14, paddingTop: 14, paddingBottom: 4 }}>
{connectionBlockedByDay && connectionBlockedByDay.length > 0 ? (
<MailBlockedByDayChart data={connectionBlockedByDay} />
) : (
{granularity === 'too-new' ? (
<View
style={{
borderRadius: 12,
backgroundColor: '#f9fafb',
borderWidth: 1,
borderColor: '#e5e5e5',
paddingHorizontal: 14,
paddingVertical: 20,
alignItems: 'center',
gap: 4,
}}
>
<Text style={{ fontSize: 13, fontFamily: 'Nunito_600SemiBold', color: '#525252' }}>
{t('mail.account_chart_collecting_title')}
</Text>
<Text style={{ fontSize: 12, fontFamily: 'Nunito_400Regular', color: '#a3a3a3', textAlign: 'center' }}>
{t('mail.account_chart_collecting_body')}
</Text>
</View>
) : statsLoading && connStats.length === 0 ? (
<View
style={{
borderRadius: 12,
@ -376,9 +396,11 @@ export function MailAccountCard({
}}
>
<Text style={{ fontSize: 12, fontFamily: 'Nunito_400Regular', color: '#a3a3a3' }}>
{t('mail.account_chart_unavailable')}
{t('mail.loading')}
</Text>
</View>
) : (
<MailBlockedByDayChart data={connStats} granularity={granularity} />
)}
</View>
@ -412,7 +434,7 @@ export function MailAccountCard({
)}
</View>
{/* Settings sub-sheet */}
{/* Settings sub-sheet — inline edit, no nested sheets */}
<MailAccountSettingsSheet
visible={settingsVisible}
account={account}
@ -421,8 +443,8 @@ export function MailAccountCard({
plan={plan}
disconnecting={disconnecting}
onClose={() => setSettingsVisible(false)}
onEditTitle={() => { setSettingsVisible(false); setEditTitleVisible(true); }}
onEditPassword={() => { setSettingsVisible(false); setEditPasswordVisible(true); }}
onTitleSaved={handleTitleSaved}
onPasswordSaved={onEditSuccess}
onDisconnectRequest={() => { setSettingsVisible(false); setConfirmVisible(true); }}
onIntervalChanged={onIntervalChanged}
/>
@ -448,23 +470,6 @@ export function MailAccountCard({
onClose={() => setOauthDisconnectHintVisible(false)}
t={t}
/>
{!isOAuth && (
<EditMailAccountSheet
visible={editPasswordVisible}
email={account.email}
onClose={() => setEditPasswordVisible(false)}
onSuccess={onEditSuccess}
/>
)}
<EditMailTitleSheet
visible={editTitleVisible}
connectionId={account.id}
currentTitle={localTitle}
onClose={() => setEditTitleVisible(false)}
onSuccess={handleTitleSaved}
/>
</>
);
}

View File

@ -1,10 +1,22 @@
import { Text, TouchableOpacity, View } from 'react-native';
import { useState } from 'react';
import {
ActivityIndicator,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import { FormSheet } from '../FormSheet';
import { useMailInterval } from '../../hooks/useMailInterval';
import { useMailTitleEdit } from '../../hooks/useMailTitleEdit';
import { useMailConnect } from '../../hooks/useMailConnect';
import { useColors } from '../../lib/theme';
import type { MailAccount } from '../../hooks/useMailStatus';
type EditMode = 'list' | 'edit-title' | 'edit-email' | 'edit-password';
type Props = {
visible: boolean;
account: MailAccount;
@ -13,8 +25,8 @@ type Props = {
plan: 'free' | 'pro' | 'legend';
disconnecting?: boolean;
onClose: () => void;
onEditTitle: () => void;
onEditPassword: () => void;
onTitleSaved: (newTitle: string | null) => void;
onPasswordSaved: () => void;
onDisconnectRequest: () => void;
onIntervalChanged: () => void;
};
@ -30,13 +42,11 @@ function domainFromEmail(email: string): string {
}
function SettingsRow({
icon,
label,
value,
onPress,
destructive,
}: {
icon: React.ComponentProps<typeof Ionicons>['name'];
label: string;
value?: string;
onPress?: () => void;
@ -58,12 +68,6 @@ function SettingsRow({
borderBottomColor: '#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: labelColor }}
>
@ -90,6 +94,118 @@ function SettingsRow({
);
}
function EditView({
label,
value,
onChangeText,
onSave,
onBack,
saving,
error,
secureTextEntry,
keyboardType,
autoCapitalize,
placeholder,
}: {
label: string;
value: string;
onChangeText: (v: string) => void;
onSave: () => void;
onBack: () => void;
saving: boolean;
error: string | null;
secureTextEntry?: boolean;
keyboardType?: TextInput['props']['keyboardType'];
autoCapitalize?: TextInput['props']['autoCapitalize'];
placeholder?: string;
}) {
const { t } = useTranslation();
const colors = useColors();
return (
<View style={{ flex: 1, paddingHorizontal: 16, paddingTop: 4 }}>
{/* Back row */}
<TouchableOpacity
activeOpacity={0.7}
onPress={onBack}
style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 20, gap: 4 }}
>
<Ionicons name="chevron-back" size={18} color="#007AFF" />
<Text style={{ fontSize: 15, fontFamily: 'Nunito_600SemiBold', color: '#007AFF' }}>
{label}
</Text>
</TouchableOpacity>
{/* Input */}
<View
style={{
backgroundColor: colors.surfaceElevated,
borderRadius: 12,
paddingHorizontal: 14,
marginBottom: error ? 6 : 16,
}}
>
<TextInput
autoFocus
value={value}
onChangeText={onChangeText}
placeholder={placeholder}
placeholderTextColor={colors.textMuted}
secureTextEntry={secureTextEntry}
keyboardType={keyboardType ?? 'default'}
autoCapitalize={autoCapitalize ?? 'sentences'}
autoCorrect={false}
returnKeyType="done"
onSubmitEditing={onSave}
style={{
paddingVertical: 13,
fontSize: 15,
fontFamily: 'Nunito_400Regular',
color: colors.text,
}}
/>
</View>
{error && (
<Text
style={{
fontSize: 12,
fontFamily: 'Nunito_400Regular',
color: '#dc2626',
marginBottom: 12,
}}
>
{error}
</Text>
)}
{/* Save button */}
<TouchableOpacity
activeOpacity={0.85}
onPress={onSave}
disabled={saving}
>
<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>
</View>
);
}
export function MailAccountSettingsSheet({
visible,
account,
@ -98,54 +214,105 @@ export function MailAccountSettingsSheet({
plan,
disconnecting,
onClose,
onEditTitle,
onEditPassword,
onTitleSaved,
onPasswordSaved,
onDisconnectRequest,
onIntervalChanged,
}: Props) {
const { t } = useTranslation();
const { setInterval, updating } = useMailInterval();
const { saveTitle, saving: savingTitle, error: titleError } = useMailTitleEdit();
const { connect, connecting: connectingPassword, error: connectError } = useMailConnect();
const isLegend = plan === 'legend';
const intervalOptions = INTERVAL_OPTIONS_BY_PLAN[plan];
const displayTitle = localTitle ?? domainFromEmail(account.email);
const [mode, setMode] = useState<EditMode>('list');
const [titleDraft, setTitleDraft] = useState(localTitle ?? '');
const [passwordDraft, setPasswordDraft] = useState('');
const [passwordVisible, setPasswordVisible] = useState(false);
const [localError, setLocalError] = useState<string | null>(null);
function handleClose() {
setMode('list');
setTitleDraft(localTitle ?? '');
setPasswordDraft('');
setPasswordVisible(false);
setLocalError(null);
onClose();
}
function goBack() {
setMode('list');
setLocalError(null);
}
async function handleSetInterval(value: number) {
const res = await setInterval(account.id, value);
if (res.ok) onIntervalChanged();
}
async function handleSaveTitle() {
const ok = await saveTitle(account.id, titleDraft);
if (ok) {
onTitleSaved(titleDraft.trim() || null);
setMode('list');
}
}
async function handleSavePassword() {
setLocalError(null);
const result = await connect({ email: account.email, password: passwordDraft });
if (result.ok) {
setPasswordDraft('');
setPasswordVisible(false);
onPasswordSaved();
setMode('list');
} else {
setLocalError(result.error ?? t('mail.connect_failed'));
}
}
const sheetTitle = mode === 'list'
? displayTitle
: mode === 'edit-title'
? t('mail.row_title')
: mode === 'edit-email'
? t('mail.row_email')
: t('mail.row_password');
return (
<FormSheet
visible={visible}
onClose={onClose}
title={displayTitle}
initialHeightPct={0.55}
growWithKeyboard={false}
onClose={handleClose}
title={sheetTitle}
initialHeightPct={mode === 'list' ? 0.55 : 0.5}
growWithKeyboard={mode !== 'list'}
>
{mode === 'list' && (
<View style={{ paddingTop: 8 }}>
{/* Bezeichnung */}
<SettingsRow
icon="pencil-outline"
label={t('mail.row_title')}
value={localTitle ?? '—'}
onPress={onEditTitle}
onPress={() => { setTitleDraft(localTitle ?? ''); setMode('edit-title'); }}
/>
{/* E-Mail (read-only) */}
{/* E-Mail */}
<SettingsRow
icon="mail-outline"
label={t('mail.row_email')}
value={account.email}
onPress={!isOAuth ? () => setMode('edit-email') : undefined}
/>
{/* Passwort — nur für IMAP-Accounts */}
{/* Passwort — nur IMAP */}
{!isOAuth && (
<SettingsRow
icon="key-outline"
label={t('mail.row_password')}
value="••••••••"
onPress={onEditPassword}
onPress={() => { setPasswordDraft(''); setPasswordVisible(false); setMode('edit-password'); }}
/>
)}
@ -238,7 +405,6 @@ export function MailAccountSettingsSheet({
</View>
)}
{/* Separator */}
<View style={{ height: 20 }} />
{/* Verbindung trennen */}
@ -269,6 +435,131 @@ export function MailAccountSettingsSheet({
</Text>
</TouchableOpacity>
</View>
)}
{mode === 'edit-title' && (
<EditView
label={t('mail.row_title')}
value={titleDraft}
onChangeText={setTitleDraft}
onSave={handleSaveTitle}
onBack={goBack}
saving={savingTitle}
error={titleError}
placeholder={t('mail.title_placeholder')}
autoCapitalize="sentences"
/>
)}
{mode === 'edit-email' && (
<EditView
label={t('mail.row_email')}
value={account.email}
onChangeText={() => {}}
onSave={() => {}}
onBack={goBack}
saving={false}
error={t('mail.email_change_not_supported')}
keyboardType="email-address"
autoCapitalize="none"
placeholder={account.email}
/>
)}
{mode === 'edit-password' && (
<View style={{ flex: 1, paddingHorizontal: 16, paddingTop: 4 }}>
{/* Back row */}
<TouchableOpacity
activeOpacity={0.7}
onPress={goBack}
style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 20, gap: 4 }}
>
<Ionicons name="chevron-back" size={18} color="#007AFF" />
<Text style={{ fontSize: 15, fontFamily: 'Nunito_600SemiBold', color: '#007AFF' }}>
{t('mail.row_password')}
</Text>
</TouchableOpacity>
{/* Password input with visibility toggle */}
<View
style={{
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#f5f5f5',
borderRadius: 12,
paddingHorizontal: 14,
marginBottom: (localError ?? connectError) ? 6 : 16,
}}
>
<TextInput
autoFocus
value={passwordDraft}
onChangeText={(v) => { setPasswordDraft(v); setLocalError(null); }}
placeholder={t('mail.app_password_placeholder')}
placeholderTextColor="#a3a3a3"
secureTextEntry={!passwordVisible}
autoCapitalize="none"
autoCorrect={false}
returnKeyType="done"
onSubmitEditing={handleSavePassword}
style={{
flex: 1,
paddingVertical: 13,
fontSize: 15,
fontFamily: 'Nunito_400Regular',
color: '#0a0a0a',
}}
/>
<TouchableOpacity
activeOpacity={0.7}
onPress={() => setPasswordVisible((p) => !p)}
hitSlop={8}
>
<Ionicons
name={passwordVisible ? 'eye-off-outline' : 'eye-outline'}
size={18}
color="#737373"
/>
</TouchableOpacity>
</View>
{(localError ?? connectError) && (
<Text
style={{
fontSize: 12,
fontFamily: 'Nunito_400Regular',
color: '#dc2626',
marginBottom: 12,
}}
>
{localError ?? connectError}
</Text>
)}
<TouchableOpacity
activeOpacity={0.85}
onPress={handleSavePassword}
disabled={connectingPassword}
>
<View
style={{
paddingVertical: 14,
borderRadius: 12,
backgroundColor: connectingPassword ? '#d4d4d4' : '#007AFF',
alignItems: 'center',
}}
>
{connectingPassword ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={{ fontSize: 15, fontFamily: 'Nunito_700Bold', color: '#fff' }}>
{t('mail.title_save')}
</Text>
)}
</View>
</TouchableOpacity>
</View>
)}
</FormSheet>
);
}

View File

@ -54,21 +54,12 @@ function providerDisplayName(provider: string): string {
export function MailActivityLog({ expanded, onToggle, providers = [] }: Props) {
const { t } = useTranslation();
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={{
@ -129,6 +120,32 @@ export function MailActivityLog({ expanded, onToggle, providers = [] }: Props) {
</TouchableOpacity>
{expanded && (
<View style={{ borderTopWidth: 1, borderTopColor: colors.border }}>
<MailActivityLogBody providers={providers} colors={colors} />
</View>
)}
</View>
);
}
/**
* Only the expandable body used standalone by MoreInfosSection as nested collapsible.
*/
export function MailActivityLogBody({
providers = [],
colors,
}: {
providers?: string[];
colors: ReturnType<typeof useColors>;
}) {
const { t } = useTranslation();
const [activeProvider, setActiveProvider] = useState('all');
const { results, total, loading, refresh } = useMailResults(true, activeProvider);
const filterOptions = ['all', ...providers];
return (
<View style={{ borderTopWidth: 1, borderTopColor: colors.border }}>
{/* Provider filter chips */}
{filterOptions.length > 1 && (
@ -143,7 +160,7 @@ export function MailActivityLog({ expanded, onToggle, providers = [] }: Props) {
<TouchableOpacity
key={p}
activeOpacity={0.7}
onPress={() => handleProviderFilter(p)}
onPress={() => setActiveProvider(p)}
style={{
paddingHorizontal: 12,
paddingVertical: 5,
@ -216,8 +233,6 @@ export function MailActivityLog({ expanded, onToggle, providers = [] }: Props) {
</>
)}
</View>
)}
</View>
);
}

View File

@ -6,6 +6,7 @@ import type { BlockedByDayEntry } from '../../hooks/useMailStats';
type Props = {
data: BlockedByDayEntry[];
granularity?: 'day' | 'week' | 'month';
};
const BAR_AREA_HEIGHT = 64;
@ -16,7 +17,13 @@ function formatAxisLabel(dateStr: string): string {
return `${d.getDate()}.${d.getMonth() + 1}.`;
}
export function MailBlockedByDayChart({ data }: Props) {
function headingKey(granularity: 'day' | 'week' | 'month'): string {
if (granularity === 'week') return 'mail.stats.blocked_per_week_heading';
if (granularity === 'month') return 'mail.stats.blocked_per_month_heading';
return 'mail.stats.blocked_per_day_heading';
}
export function MailBlockedByDayChart({ data, granularity = 'day' }: Props) {
const { t } = useTranslation();
const colors = useColors();
@ -58,7 +65,7 @@ export function MailBlockedByDayChart({ data }: Props) {
marginBottom: 10,
}}
>
{t('mail.stats.blocked_per_day_heading')}
{t(headingKey(granularity))}
</Text>
{allZero ? (

View File

@ -1,13 +1,12 @@
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 { HalfDonut } from '../common/HalfDonut';
import type { BlockedByConnectionEntry } from '../../hooks/useMailStats';
type Props = {
data: BlockedByConnectionEntry[];
/** When true: renders as full-width hero card with integrated title row */
hero?: boolean;
totalBlocked?: number;
accountCount?: number;
@ -17,17 +16,16 @@ type Props = {
const SLICE_COLORS = ['#ef4444', '#3b82f6', '#f59e0b', '#8b5cf6'];
const OTHER_COLOR = '#a3a3a3';
// Legend cap: show max 3 named entries + optional "others" row
const MAX_LEGEND_ENTRIES = 3;
const R_OUTER = 54;
const R_INNER = 34;
const CX = 64;
const CY = 64;
const DONUT_WIDTH = 168;
// Half-donut: upper semicircle, flat edge at bottom.
// Slices sweep from -90° (left) to +90° (right) = 180° total.
const HALF_DONUT_START_DEG = -90;
function formatCompact(n: number): string {
if (n < 1000) return n.toLocaleString();
const k = n / 1000;
if (k < 10) return `${Math.floor(k * 10) / 10}k+`;
return `${Math.floor(k)}k+`;
}
function domainFromEmail(email: string): string {
return email.split('@')[1] ?? email;
@ -37,45 +35,12 @@ 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 outerStart = polarToXY(cx, cy, rOuter, startDeg);
const outerEnd = polarToXY(cx, cy, rOuter, endDeg);
const innerEnd = polarToXY(cx, cy, rInner, endDeg);
const innerStart = polarToXY(cx, cy, rInner, startDeg);
const large = endDeg - startDeg > 180 ? 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, hero, totalBlocked, accountCount, isLegend }: Props) {
export function MailDistributionChart({ data, hero, totalBlocked, isLegend }: Props) {
const { t } = useTranslation();
const colors = useColors();
const total = data.reduce((s, d) => s + d.count, 0);
// Build donut slices: hard-cap at 3 named entries + "Sonstige" bucket.
// ≤3 accounts → show all (no grouping). 4+ → Top-3 + Sonstige.
const slices = useMemo(() => {
if (data.length === 0 || total === 0) return [];
@ -87,11 +52,9 @@ export function MailDistributionChart({ data, hero, totalBlocked, accountCount,
count: e.count,
color: SLICE_COLORS[i] ?? OTHER_COLOR,
isOther: false,
hiddenCount: 0,
}));
}
// 4+ connections: Top-3 + Sonstige bucket
const top3 = sorted.slice(0, MAX_LEGEND_ENTRIES);
const rest = sorted.slice(MAX_LEGEND_ENTRIES);
const restCount = rest.reduce((s, e) => s + e.count, 0);
@ -102,7 +65,6 @@ export function MailDistributionChart({ data, hero, totalBlocked, accountCount,
count: e.count,
color: SLICE_COLORS[i],
isOther: false,
hiddenCount: 0,
}));
items.push({
@ -110,7 +72,6 @@ export function MailDistributionChart({ data, hero, totalBlocked, accountCount,
count: restCount,
color: OTHER_COLOR,
isOther: true,
hiddenCount: restConnectionCount,
});
return items;
@ -118,9 +79,11 @@ export function MailDistributionChart({ data, hero, totalBlocked, accountCount,
if (data.length <= 1 || total === 0) return null;
let cursor = HALF_DONUT_START_DEG;
const displayTotal = totalBlocked ?? total;
const centerValue = formatCompact(displayTotal);
const centerLabel = t('mail.stats.distribution_center_label');
const segments = slices.map((s) => ({ value: s.count, color: s.color }));
if (hero) {
return (
@ -135,7 +98,6 @@ export function MailDistributionChart({ data, hero, totalBlocked, accountCount,
paddingBottom: 16,
}}
>
{/* Integrated title row */}
<View
style={{
flexDirection: 'row',
@ -143,20 +105,19 @@ export function MailDistributionChart({ data, hero, totalBlocked, accountCount,
marginBottom: 14,
}}
>
<View style={{ flex: 1 }}>
<Text
style={{
fontSize: 22,
fontFamily: 'Nunito_800ExtraBold',
color: colors.error,
lineHeight: 26,
flex: 1,
fontSize: 13,
fontFamily: 'Nunito_700Bold',
color: colors.textMuted,
textTransform: 'uppercase',
letterSpacing: 0.7,
}}
>
{displayTotal.toLocaleString()}
{t('mail.stats.distribution_heading')}
</Text>
</View>
{/* Live / Scheduled pill */}
<View
style={{
flexDirection: 'row',
@ -188,24 +149,13 @@ export function MailDistributionChart({ data, hero, totalBlocked, accountCount,
</View>
</View>
{/* Donut + Legend */}
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 16 }}>
<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}
<HalfDonut
segments={segments}
centerValue={centerValue}
centerLabel={centerLabel}
width={DONUT_WIDTH}
/>
);
})}
<Circle cx={CX} cy={CY} r={R_INNER - 1} fill={colors.surface} />
</Svg>
<View style={{ flex: 1, gap: 5 }}>
{slices.map((slice) => (
<LegendRow key={slice.label} slice={slice} colors={colors} />
@ -216,7 +166,6 @@ export function MailDistributionChart({ data, hero, totalBlocked, accountCount,
);
}
// Standard (non-hero) card — kept for potential reuse
return (
<View
style={{
@ -243,22 +192,12 @@ export function MailDistributionChart({ data, hero, totalBlocked, accountCount,
</Text>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 16 }}>
<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}
<HalfDonut
segments={segments}
centerValue={centerValue}
centerLabel={centerLabel}
width={DONUT_WIDTH}
/>
);
})}
<Circle cx={CX} cy={CY} r={R_INNER - 1} fill={colors.surface} />
</Svg>
<View style={{ flex: 1, gap: 6 }}>
{slices.map((slice) => (
<LegendRow key={slice.label} slice={slice} colors={colors} />

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { apiFetch } from '../lib/api';
export type BlockedByDayEntry = {
@ -20,6 +20,106 @@ type MailStatsState = {
loading: boolean;
};
type ConnectionAgeGranularity = 'too-new' | 'day' | 'week' | 'month';
function resolveGranularity(createdAt: string | null | undefined): ConnectionAgeGranularity {
if (!createdAt) return 'day';
const ageMs = Date.now() - new Date(createdAt).getTime();
const ageH = ageMs / 3_600_000;
if (ageH < 24) return 'too-new';
const ageD = ageH / 24;
if (ageD <= 14) return 'day';
if (ageD <= 90) return 'week';
return 'month';
}
type ConnectionStatsState = {
data: BlockedByDayEntry[];
granularity: ConnectionAgeGranularity;
loading: boolean;
};
/**
* Fetches per-connection blocked-by-day stats lazily (only when `enabled` flips true).
* Adaptive heading granularity based on connection age:
* < 24h 'too-new' (empty-state, data collection in progress)
* 1-14d 'day' (30-day bars)
* 15-90d 'week' (client-aggregated into week buckets)
* > 90d 'month' (client-aggregated into month buckets)
*/
export function useMailConnectionStats(
connectionId: string,
createdAt: string | null | undefined,
enabled: boolean,
) {
const fetchedRef = useRef(false);
const [state, setState] = useState<ConnectionStatsState>({
data: [],
granularity: resolveGranularity(createdAt),
loading: false,
});
const granularity = resolveGranularity(createdAt);
const fetch = useCallback(async () => {
if (!enabled || !connectionId) return;
setState((s) => ({ ...s, loading: true }));
try {
const raw = await apiFetch<BlockedByDayEntry[]>(
`/api/mail/stats/blocked-by-day?days=30&connectionId=${connectionId}`,
);
let data = raw;
if (granularity === 'week') {
data = aggregateToWeeks(raw);
} else if (granularity === 'month') {
data = aggregateToMonths(raw);
}
setState({ data, granularity, loading: false });
} catch {
setState((s) => ({ ...s, loading: false }));
}
}, [enabled, connectionId, granularity]);
useEffect(() => {
if (!enabled) return;
if (fetchedRef.current) return;
fetchedRef.current = true;
fetch();
}, [enabled, fetch]);
return { ...state, refresh: fetch };
}
function aggregateToWeeks(entries: BlockedByDayEntry[]): BlockedByDayEntry[] {
const buckets: Map<string, number> = new Map();
for (const e of entries) {
const d = new Date(e.date + 'T00:00:00');
const day = d.getDay();
const diff = d.getDate() - day + (day === 0 ? -6 : 1);
const monday = new Date(d);
monday.setDate(diff);
const key = monday.toISOString().slice(0, 10);
buckets.set(key, (buckets.get(key) ?? 0) + e.count);
}
return [...buckets.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([date, count]) => ({ date, count }));
}
function aggregateToMonths(entries: BlockedByDayEntry[]): BlockedByDayEntry[] {
const buckets: Map<string, number> = new Map();
for (const e of entries) {
const key = e.date.slice(0, 7);
buckets.set(key, (buckets.get(key) ?? 0) + e.count);
}
return [...buckets.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([date, count]) => ({ date, count }));
}
export function useMailStats(enabled: boolean) {
const [state, setState] = useState<MailStatsState>({
blockedByDay: [],

View File

@ -18,6 +18,8 @@ export type MailAccount = {
lastConnectError?: string | null;
lastConnectErrorAt?: string | null;
lastIdleHeartbeatAt?: string | null;
/** ISO-date — present when backend includes it. Used for adaptive chart granularity. */
createdAt?: string | null;
};
export type DailyStat = {

View File

@ -457,7 +457,10 @@
"stats": {
"blocked_per_day_heading": "Blockiert — letzte 30 Tage",
"blocked_per_day_sublabel": "%{total} Mails blockiert · %{avg} letzte Woche",
"blocked_per_week_heading": "Blockiert — letzte Wochen",
"blocked_per_month_heading": "Blockiert — letzte Monate",
"distribution_heading": "Verteilung nach Postfach",
"distribution_center_label": "insgesamt",
"distribution_other": "Sonstige",
"distribution_other_n": "+%{n} weitere",
"empty_title": "Noch keine Mails blockiert",
@ -479,7 +482,10 @@
"disconnect_hint_title": "Verbindung getrennt",
"disconnect_hint_body": "Die Tokens wurden aus unserer Datenbank gelöscht. Microsoft unterstützt leider keinen serverseitigen Widerruf durch Drittanbieter-Apps. Für eine vollständige Entfernung der Rebreak-Berechtigung in deinem Microsoft-Konto: account.microsoft.com → Sicherheit → Berechtigungen für Apps → Rebreak suchen → Entfernen.",
"disconnect_hint_open_ms": "Microsoft öffnen"
}
},
"account_chart_collecting_title": "Daten werden gesammelt",
"account_chart_collecting_body": "Auswertung verfügbar nach 24h",
"email_change_not_supported": "E-Mail-Änderung kommt bald"
},
"settings": {
"title": "Einstellungen",

View File

@ -457,7 +457,10 @@
"stats": {
"blocked_per_day_heading": "Blocked — last 30 days",
"blocked_per_day_sublabel": "%{total} mails blocked · %{avg} last week",
"blocked_per_week_heading": "Blocked — recent weeks",
"blocked_per_month_heading": "Blocked — recent months",
"distribution_heading": "Distribution by mailbox",
"distribution_center_label": "total",
"distribution_other": "Others",
"distribution_other_n": "+%{n} more",
"empty_title": "No mails blocked yet",
@ -479,7 +482,10 @@
"disconnect_hint_title": "Connection removed",
"disconnect_hint_body": "The tokens have been deleted from our database. Unfortunately Microsoft does not support server-side revocation by third-party apps. To fully remove Rebreak's permission from your Microsoft account: account.microsoft.com → Security → App permissions → find Rebreak → Remove.",
"disconnect_hint_open_ms": "Open Microsoft"
}
},
"account_chart_collecting_title": "Collecting data",
"account_chart_collecting_body": "Analysis available after 24h",
"email_change_not_supported": "Email change coming soon"
},
"settings": {
"title": "Settings",

View File

@ -480,7 +480,17 @@ async function runSession(conn) {
clearConnectionError(conn.id).catch(() => {}),
]);
await imap.getMailboxLock("INBOX");
// Outlook/XOAUTH2 hat den Edge-Case dass getMailboxLock lautlos hängt
// wenn der Server in einen ungültigen Zustand kommt — die Session
// bleibt offen ohne Fortschritt bis der Renew-Timer (10min) ein
// imap.close() schickt. Timeout-wrap macht das Failure-Mode explizit
// → Auth-Retry-Loop greift sauber.
await Promise.race([
imap.getMailboxLock("INBOX"),
new Promise((_, reject) =>
setTimeout(() => reject(new Error("getMailboxLock timeout (30s)")), 30_000),
),
]);
// Consent-Gate-Log: einmalig beim Connect — nur wenn consentAt fehlt
if (!conn.consentAt) {