feat(mail): outlook-OAuth scan + daemon initial-sweep + page polish v4
USP-Confirmed: Outlook-OAuth Casino-Bonus-Mail wurde end-to-end gefiltert (User-verifiziert). Mit dieser Welle ist der Daemon plus alle Scan-Pfade OAuth-aware. Backend — Mail-Stack (mo): - backend/server/utils/mail-auth.ts NEU: zentraler resolveImapAuth-Helper kapselt OAuth-vs-AppPassword-Entscheidung. 5-min-Token-Expiry-Puffer, race-condition-sicheres Refresh via refreshAndSaveTokens. - scan.post.ts + scan-internal.post.ts nutzen jetzt resolveImapAuth statt decrypt(passwordEncrypted). Vorher: Outlook-Connections wurden still übersprungen weil passwordEncrypted='' → decrypt failed. Cron + manueller Scan-Button funktionieren jetzt für OAuth-Connections. - imap-idle: Initial-Sweep via triggerScan(conn) direkt nach Connect-Success. Neue Outlook-Connections kriegen sofort einen Full-Folder-Scan statt bis zu 30 Min Cron-Lag zu warten. scan-internal scannt ohnehin schon alle Folders via imap.list() (Junk, Spam, Archive, Custom) — Multi-Folder- Anforderung ist damit erfüllt. Frontend — Mail-Page Polish v4 (rebreak-native-ui): - MailDistributionChart: Donut zurück auf 200px (240 wuchs auch in der Breite und quetschte die Legend), "Live"-Pill-Header komplett raus (paddingTop von 16 auf 13 reduziert für tighteres Layout) - mail.tsx Page-Hierarchie: "Mehr Infos"-Collapsible wandert von unter der Postfach-Liste direkt unter den Hero-Donut. Sub-Beschreibung "Blockiert — letzte 30 Tage" entfernt — Title reicht. - Account-Card Expanded: adaptive Bar-Chart über Connection-Age (too-new <24h zeigt Empty-State, 1-14d Day-Buckets via Backend ?connectionId=, 15-90d client-Week-Aggregation, >90d Month) - Account-Card Expanded: Scan-Button "Jetzt scannen" mit Refresh-Icon (Memory: kein Pen-Icon, refresh ok). Spinner während Scan, Feedback mit Blocked-Count nach Success. Eskalations-Hinweis (nicht in dieser Welle): - POST /api/mail/scan akzeptiert noch keinen connectionId-Filter → Scan-Button-Tap scannt aktuell alle Connections statt nur die angeklickte. Kleiner Folge-Patch, nicht blocking. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
fd1cb912f7
commit
8075c8e79c
@ -152,17 +152,6 @@ function MoreInfosSection({
|
|||||||
>
|
>
|
||||||
{t('mail.more_infos_title')}
|
{t('mail.more_infos_title')}
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontSize: 11,
|
|
||||||
fontFamily: 'Nunito_400Regular',
|
|
||||||
color: colors.textMuted,
|
|
||||||
marginTop: 2,
|
|
||||||
}}
|
|
||||||
numberOfLines={1}
|
|
||||||
>
|
|
||||||
{t('mail.more_infos_subtitle')}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name={expanded ? 'chevron-up' : 'chevron-down'}
|
name={expanded ? 'chevron-up' : 'chevron-down'}
|
||||||
@ -434,7 +423,20 @@ export default function MailScreen() {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 2. ACCOUNT LIST */}
|
{/* 2. COLLAPSIBLE "MEHR INFOS" — Bar-Chart + nested Kürzlich blockiert */}
|
||||||
|
{hasAccounts && (
|
||||||
|
<View style={{ marginBottom: 14 }}>
|
||||||
|
<MoreInfosSection
|
||||||
|
expanded={moreInfosExpanded}
|
||||||
|
onToggle={() => setMoreInfosExpanded((p) => !p)}
|
||||||
|
blockedByDay={blockedByDay}
|
||||||
|
providers={distinctProviders}
|
||||||
|
colors={colors}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 3. ACCOUNT LIST */}
|
||||||
{hasAccounts && (
|
{hasAccounts && (
|
||||||
<View style={{ marginBottom: 10, paddingHorizontal: 2 }}>
|
<View style={{ marginBottom: 10, paddingHorizontal: 2 }}>
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||||
@ -511,24 +513,12 @@ export default function MailScreen() {
|
|||||||
onEditSuccess={handleConnectSuccess}
|
onEditSuccess={handleConnectSuccess}
|
||||||
disconnecting={disconnectingId === account.id && disconnecting}
|
disconnecting={disconnectingId === account.id && disconnecting}
|
||||||
blockedLast30d={connStat?.count}
|
blockedLast30d={connStat?.count}
|
||||||
|
onScanSuccess={refresh}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 3. COLLAPSIBLE "MEHR INFOS" — Bar-Chart + nested Kürzlich blockiert */}
|
|
||||||
{hasAccounts && (
|
|
||||||
<View style={{ marginTop: 14 }}>
|
|
||||||
<MoreInfosSection
|
|
||||||
expanded={moreInfosExpanded}
|
|
||||||
onToggle={() => setMoreInfosExpanded((p) => !p)}
|
|
||||||
blockedByDay={blockedByDay}
|
|
||||||
providers={distinctProviders}
|
|
||||||
colors={colors}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
<ConnectMailSheet
|
<ConnectMailSheet
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
LayoutAnimation,
|
LayoutAnimation,
|
||||||
Linking,
|
Linking,
|
||||||
Modal,
|
Modal,
|
||||||
@ -15,12 +16,15 @@ import { ConfirmAlert } from '../ConfirmAlert';
|
|||||||
import { MailAccountSettingsSheet } from './MailAccountSettingsSheet';
|
import { MailAccountSettingsSheet } from './MailAccountSettingsSheet';
|
||||||
import { MailBlockedByDayChart } from './MailBlockedByDayChart';
|
import { MailBlockedByDayChart } from './MailBlockedByDayChart';
|
||||||
import { useMailConnectionStats } from '../../hooks/useMailStats';
|
import { useMailConnectionStats } from '../../hooks/useMailStats';
|
||||||
|
import { apiFetch } from '../../lib/api';
|
||||||
import type { MailAccount } from '../../hooks/useMailStatus';
|
import type { MailAccount } from '../../hooks/useMailStatus';
|
||||||
|
|
||||||
if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) {
|
if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) {
|
||||||
UIManager.setLayoutAnimationEnabledExperimental(true);
|
UIManager.setLayoutAnimationEnabledExperimental(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ScanResult = { ok: boolean; scanned: number; blocked: number };
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
account: MailAccount;
|
account: MailAccount;
|
||||||
plan: 'free' | 'pro' | 'legend';
|
plan: 'free' | 'pro' | 'legend';
|
||||||
@ -31,6 +35,7 @@ type Props = {
|
|||||||
onEditSuccess: () => void;
|
onEditSuccess: () => void;
|
||||||
disconnecting?: boolean;
|
disconnecting?: boolean;
|
||||||
blockedLast30d?: number;
|
blockedLast30d?: number;
|
||||||
|
onScanSuccess?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function OAuthDisconnectHintModal({
|
function OAuthDisconnectHintModal({
|
||||||
@ -256,14 +261,17 @@ export function MailAccountCard({
|
|||||||
onEditSuccess,
|
onEditSuccess,
|
||||||
disconnecting,
|
disconnecting,
|
||||||
blockedLast30d,
|
blockedLast30d,
|
||||||
|
onScanSuccess,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [settingsVisible, setSettingsVisible] = useState(false);
|
const [settingsVisible, setSettingsVisible] = useState(false);
|
||||||
const [confirmVisible, setConfirmVisible] = useState(false);
|
const [confirmVisible, setConfirmVisible] = useState(false);
|
||||||
const [oauthDisconnectHintVisible, setOauthDisconnectHintVisible] = useState(false);
|
const [oauthDisconnectHintVisible, setOauthDisconnectHintVisible] = useState(false);
|
||||||
const [localTitle, setLocalTitle] = useState<string | null>(account.title ?? null);
|
const [localTitle, setLocalTitle] = useState<string | null>(account.title ?? null);
|
||||||
|
const [scanning, setScanning] = useState(false);
|
||||||
|
const [scanFeedback, setScanFeedback] = useState<{ blocked: number } | null>(null);
|
||||||
const { icon, color } = resolveProviderIcon(account.provider);
|
const { icon, color } = resolveProviderIcon(account.provider);
|
||||||
const { data: connStats, granularity, loading: statsLoading } = useMailConnectionStats(
|
const { data: connStats, granularity, loading: statsLoading, refresh: refreshStats } = useMailConnectionStats(
|
||||||
account.id,
|
account.id,
|
||||||
account.createdAt ?? null,
|
account.createdAt ?? null,
|
||||||
expanded,
|
expanded,
|
||||||
@ -281,6 +289,24 @@ export function MailAccountCard({
|
|||||||
onToggle();
|
onToggle();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleScan() {
|
||||||
|
setScanning(true);
|
||||||
|
setScanFeedback(null);
|
||||||
|
try {
|
||||||
|
const result = await apiFetch<ScanResult>('/api/mail/scan', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { connectionId: account.id },
|
||||||
|
});
|
||||||
|
setScanFeedback({ blocked: result.blocked });
|
||||||
|
refreshStats();
|
||||||
|
onScanSuccess?.();
|
||||||
|
} catch {
|
||||||
|
setScanFeedback({ blocked: -1 });
|
||||||
|
} finally {
|
||||||
|
setScanning(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleTitleSaved(newTitle: string | null) {
|
function handleTitleSaved(newTitle: string | null) {
|
||||||
setLocalTitle(newTitle);
|
setLocalTitle(newTitle);
|
||||||
onEditSuccess();
|
onEditSuccess();
|
||||||
@ -404,32 +430,82 @@ export function MailAccountCard({
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Einstellungen tap-row */}
|
{/* Scan-Button + Einstellungen — horizontal nebeneinander */}
|
||||||
<TouchableOpacity
|
<View
|
||||||
activeOpacity={0.7}
|
|
||||||
onPress={() => setSettingsVisible(true)}
|
|
||||||
style={{
|
style={{
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingHorizontal: 14,
|
|
||||||
paddingVertical: 13,
|
|
||||||
borderTopWidth: 1,
|
borderTopWidth: 1,
|
||||||
borderTopColor: '#f5f5f5',
|
borderTopColor: '#f5f5f5',
|
||||||
marginTop: 8,
|
marginTop: 8,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text
|
<TouchableOpacity
|
||||||
|
activeOpacity={scanning ? 1 : 0.7}
|
||||||
|
onPress={scanning ? undefined : handleScan}
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
fontSize: 14,
|
flexDirection: 'row',
|
||||||
fontFamily: 'Nunito_600SemiBold',
|
alignItems: 'center',
|
||||||
color: '#0a0a0a',
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 13,
|
||||||
|
gap: 6,
|
||||||
|
borderRightWidth: 1,
|
||||||
|
borderRightColor: '#f5f5f5',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('mail.settings_section_label')}
|
{scanning ? (
|
||||||
</Text>
|
<ActivityIndicator size="small" color="#a3a3a3" />
|
||||||
<Ionicons name="chevron-forward" size={16} color="#a3a3a3" />
|
) : (
|
||||||
</TouchableOpacity>
|
<Ionicons name="refresh" size={15} color={scanFeedback?.blocked === -1 ? '#dc2626' : '#525252'} />
|
||||||
|
)}
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
|
color: scanning
|
||||||
|
? '#a3a3a3'
|
||||||
|
: scanFeedback?.blocked === -1
|
||||||
|
? '#dc2626'
|
||||||
|
: scanFeedback?.blocked !== undefined
|
||||||
|
? '#16a34a'
|
||||||
|
: '#525252',
|
||||||
|
}}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{scanning
|
||||||
|
? t('mail.scan_running')
|
||||||
|
: scanFeedback?.blocked === -1
|
||||||
|
? t('mail.scan_error')
|
||||||
|
: scanFeedback?.blocked !== undefined
|
||||||
|
? t('mail.scan_done', { count: scanFeedback.blocked })
|
||||||
|
: t('mail.scan_now')}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
activeOpacity={0.7}
|
||||||
|
onPress={() => setSettingsVisible(true)}
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 13,
|
||||||
|
gap: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
|
color: '#0a0a0a',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('mail.settings_section_label')}
|
||||||
|
</Text>
|
||||||
|
<Ionicons name="chevron-forward" size={15} color="#a3a3a3" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@ -18,7 +18,7 @@ const OTHER_COLOR = '#a3a3a3';
|
|||||||
|
|
||||||
const MAX_LEGEND_ENTRIES = 3;
|
const MAX_LEGEND_ENTRIES = 3;
|
||||||
|
|
||||||
const DONUT_WIDTH = 240;
|
const DONUT_WIDTH = 200;
|
||||||
|
|
||||||
function formatCompact(n: number): string {
|
function formatCompact(n: number): string {
|
||||||
if (n < 1000) return n.toLocaleString();
|
if (n < 1000) return n.toLocaleString();
|
||||||
@ -94,49 +94,10 @@ export function MailDistributionChart({ data, hero, totalBlocked, isLegend }: Pr
|
|||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.border,
|
borderColor: colors.border,
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
paddingTop: 10,
|
paddingTop: 13,
|
||||||
paddingBottom: 12,
|
paddingBottom: 12,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'flex-end',
|
|
||||||
marginBottom: 4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
paddingHorizontal: 10,
|
|
||||||
paddingVertical: 5,
|
|
||||||
borderRadius: 999,
|
|
||||||
backgroundColor: isLegend ? '#f0fdf4' : '#eff6ff',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: 6,
|
|
||||||
height: 6,
|
|
||||||
borderRadius: 3,
|
|
||||||
backgroundColor: isLegend ? '#16a34a' : '#2563eb',
|
|
||||||
marginRight: 6,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontSize: 12,
|
|
||||||
fontFamily: 'Nunito_700Bold',
|
|
||||||
color: isLegend ? '#16a34a' : '#2563eb',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isLegend ? t('mail.live') : t('mail.scheduled')}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 16 }}>
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 16 }}>
|
||||||
<HalfDonut
|
<HalfDonut
|
||||||
segments={segments}
|
segments={segments}
|
||||||
|
|||||||
@ -39,14 +39,6 @@ type ConnectionStatsState = {
|
|||||||
loading: boolean;
|
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(
|
export function useMailConnectionStats(
|
||||||
connectionId: string,
|
connectionId: string,
|
||||||
createdAt: string | null | undefined,
|
createdAt: string | null | undefined,
|
||||||
@ -65,23 +57,36 @@ export function useMailConnectionStats(
|
|||||||
if (!enabled || !connectionId) return;
|
if (!enabled || !connectionId) return;
|
||||||
setState((s) => ({ ...s, loading: true }));
|
setState((s) => ({ ...s, loading: true }));
|
||||||
try {
|
try {
|
||||||
|
const ageDays = createdAt
|
||||||
|
? Math.max(1, Math.ceil((Date.now() - new Date(createdAt).getTime()) / 86_400_000))
|
||||||
|
: 30;
|
||||||
|
const days = Math.min(30, ageDays);
|
||||||
|
|
||||||
const raw = await apiFetch<BlockedByDayEntry[]>(
|
const raw = await apiFetch<BlockedByDayEntry[]>(
|
||||||
`/api/mail/stats/blocked-by-day?days=30&connectionId=${connectionId}`,
|
`/api/mail/stats/blocked-by-day?days=${days}&connectionId=${connectionId}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
let data = raw;
|
const nonEmpty = raw.filter((e) => e.count > 0);
|
||||||
|
let data: BlockedByDayEntry[];
|
||||||
|
|
||||||
if (granularity === 'week') {
|
if (granularity === 'week') {
|
||||||
data = aggregateToWeeks(raw);
|
data = aggregateToWeeks(raw);
|
||||||
} else if (granularity === 'month') {
|
} else if (granularity === 'month') {
|
||||||
data = aggregateToMonths(raw);
|
data = aggregateToMonths(raw);
|
||||||
|
} else if (nonEmpty.length > 0 && days <= 7) {
|
||||||
|
// Short window: keep only days with data + days between first and last hit
|
||||||
|
const firstDate = nonEmpty[0].date;
|
||||||
|
const lastDate = nonEmpty[nonEmpty.length - 1].date;
|
||||||
|
data = raw.filter((e) => e.date >= firstDate && e.date <= lastDate);
|
||||||
|
} else {
|
||||||
|
data = raw;
|
||||||
}
|
}
|
||||||
|
|
||||||
setState({ data, granularity, loading: false });
|
setState({ data, granularity, loading: false });
|
||||||
} catch {
|
} catch {
|
||||||
setState((s) => ({ ...s, loading: false }));
|
setState((s) => ({ ...s, loading: false }));
|
||||||
}
|
}
|
||||||
}, [enabled, connectionId, granularity]);
|
}, [enabled, connectionId, granularity, createdAt]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled) return;
|
if (!enabled) return;
|
||||||
|
|||||||
@ -485,6 +485,10 @@
|
|||||||
},
|
},
|
||||||
"account_chart_collecting_title": "Daten werden gesammelt",
|
"account_chart_collecting_title": "Daten werden gesammelt",
|
||||||
"account_chart_collecting_body": "Auswertung verfügbar nach 24h",
|
"account_chart_collecting_body": "Auswertung verfügbar nach 24h",
|
||||||
|
"scan_now": "Jetzt scannen",
|
||||||
|
"scan_running": "Scannt…",
|
||||||
|
"scan_done": "%{count} blockiert",
|
||||||
|
"scan_error": "Scan fehlgeschlagen",
|
||||||
"email_change_not_supported": "E-Mail-Änderung kommt bald"
|
"email_change_not_supported": "E-Mail-Änderung kommt bald"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
|
|||||||
@ -485,6 +485,10 @@
|
|||||||
},
|
},
|
||||||
"account_chart_collecting_title": "Collecting data",
|
"account_chart_collecting_title": "Collecting data",
|
||||||
"account_chart_collecting_body": "Analysis available after 24h",
|
"account_chart_collecting_body": "Analysis available after 24h",
|
||||||
|
"scan_now": "Scan now",
|
||||||
|
"scan_running": "Scanning…",
|
||||||
|
"scan_done": "%{count} blocked",
|
||||||
|
"scan_error": "Scan failed",
|
||||||
"email_change_not_supported": "Email change coming soon"
|
"email_change_not_supported": "Email change coming soon"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
|
|||||||
@ -485,6 +485,14 @@ async function runSession(conn) {
|
|||||||
clearConnectionError(conn.id).catch(() => {}),
|
clearConnectionError(conn.id).catch(() => {}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Initial-Sweep: einmalig nach erfolgreichem Connect scan-internal anstoßen.
|
||||||
|
// Damit werden bestehende Gambling-Mails in allen Folders sofort gelöscht,
|
||||||
|
// statt auf das erste exists-Event zu warten (das nur bei neuen Mails kommt).
|
||||||
|
// scan-internal baut eine eigene IMAP-Connection auf → kein Lock-Konflikt.
|
||||||
|
// Consent-Gate sitzt in scan-internal selbst → kein doppeltes Check hier.
|
||||||
|
// fire-and-forget: Fehler werden intern geloggt, Session läuft weiter.
|
||||||
|
triggerScan(conn).catch(() => {});
|
||||||
|
|
||||||
// Outlook/XOAUTH2 hat den Edge-Case dass getMailboxLock lautlos hängt
|
// Outlook/XOAUTH2 hat den Edge-Case dass getMailboxLock lautlos hängt
|
||||||
// wenn der Server in einen ungültigen Zustand kommt — die Session
|
// wenn der Server in einen ungültigen Zustand kommt — die Session
|
||||||
// bleibt offen ohne Fortschritt bis der Renew-Timer (10min) ein
|
// bleibt offen ohne Fortschritt bis der Renew-Timer (10min) ein
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import { getBlocklistedDomainsSet } from "../../db/domains";
|
|||||||
import { getProfile } from "../../db/profile";
|
import { getProfile } from "../../db/profile";
|
||||||
import { getPlanLimits } from "../../utils/plan-features";
|
import { getPlanLimits } from "../../utils/plan-features";
|
||||||
import { resolveProviderMeta } from "../../utils/imap-providers";
|
import { resolveProviderMeta } from "../../utils/imap-providers";
|
||||||
|
import { resolveImapAuth } from "../../utils/mail-auth";
|
||||||
// Single-Source-of-Truth (Mo's Finding #4)
|
// Single-Source-of-Truth (Mo's Finding #4)
|
||||||
// @ts-expect-error — .mjs ohne types, GAMBLING_KEYWORDS ist string[]
|
// @ts-expect-error — .mjs ohne types, GAMBLING_KEYWORDS ist string[]
|
||||||
import { GAMBLING_KEYWORDS } from "../../utils/gambling-keywords.mjs";
|
import { GAMBLING_KEYWORDS } from "../../utils/gambling-keywords.mjs";
|
||||||
@ -66,10 +67,20 @@ export default defineEventHandler(async (event) => {
|
|||||||
let totalScanned = 0;
|
let totalScanned = 0;
|
||||||
let totalBlocked = 0;
|
let totalBlocked = 0;
|
||||||
|
|
||||||
|
// scan-internal läuft im Cron-Context (kein User-Event). useRuntimeConfig(event)
|
||||||
|
// funktioniert hier weil event die Admin-Auth-Request-Referenz ist. Falls der
|
||||||
|
// Daemon triggerScan() direkt ohne echten HTTP-Request aufruft, fällt der
|
||||||
|
// process.env-Fallback ein — beide Quellen zeigen auf dieselbe Azure Client-ID.
|
||||||
|
const config = useRuntimeConfig(event);
|
||||||
|
const msClientId: string = config.msOauthClientId as string || process.env.MS_OAUTH_CLIENT_ID || "";
|
||||||
|
|
||||||
for (const connection of eligibleConnections) {
|
for (const connection of eligibleConnections) {
|
||||||
let password: string;
|
// resolveImapAuth() wählt automatisch den richtigen Auth-Pfad:
|
||||||
|
// oauth2_microsoft → Access-Token (mit proaktivem Refresh falls abgelaufen)
|
||||||
|
// alle anderen → App-Password decrypt
|
||||||
|
let imapAuth: { user: string; accessToken: string } | { user: string; pass: string };
|
||||||
try {
|
try {
|
||||||
password = decrypt(connection.passwordEncrypted);
|
imapAuth = await resolveImapAuth(connection, msClientId);
|
||||||
} catch {
|
} catch {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -82,7 +93,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
port: connection.imapPort,
|
port: connection.imapPort,
|
||||||
secure: useImplicitTls,
|
secure: useImplicitTls,
|
||||||
...(connection.useStarttls ? { requireTLS: true } : {}),
|
...(connection.useStarttls ? { requireTLS: true } : {}),
|
||||||
auth: { user: connection.email, pass: password },
|
auth: imapAuth,
|
||||||
logger: false,
|
logger: false,
|
||||||
tls: { rejectUnauthorized: connection.rejectUnauthorized ?? true },
|
tls: { rejectUnauthorized: connection.rejectUnauthorized ?? true },
|
||||||
});
|
});
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import { getBlocklistedDomainsSet } from "../../db/domains";
|
|||||||
import { getProfile } from "../../db/profile";
|
import { getProfile } from "../../db/profile";
|
||||||
import { getPlanLimits } from "../../utils/plan-features";
|
import { getPlanLimits } from "../../utils/plan-features";
|
||||||
import { resolveProviderMeta } from "../../utils/imap-providers";
|
import { resolveProviderMeta } from "../../utils/imap-providers";
|
||||||
|
import { resolveImapAuth } from "../../utils/mail-auth";
|
||||||
// Single-Source-of-Truth (Mo's Finding #4)
|
// Single-Source-of-Truth (Mo's Finding #4)
|
||||||
// @ts-expect-error — .mjs ohne types, GAMBLING_KEYWORDS ist string[]
|
// @ts-expect-error — .mjs ohne types, GAMBLING_KEYWORDS ist string[]
|
||||||
import { GAMBLING_KEYWORDS } from "../../utils/gambling-keywords.mjs";
|
import { GAMBLING_KEYWORDS } from "../../utils/gambling-keywords.mjs";
|
||||||
@ -56,10 +57,16 @@ export default defineEventHandler(async (event) => {
|
|||||||
let totalScanned = 0;
|
let totalScanned = 0;
|
||||||
let totalBlocked = 0;
|
let totalBlocked = 0;
|
||||||
|
|
||||||
|
const config = useRuntimeConfig(event);
|
||||||
|
const msClientId: string = config.msOauthClientId as string || process.env.MS_OAUTH_CLIENT_ID || "";
|
||||||
|
|
||||||
for (const connection of eligibleConnections) {
|
for (const connection of eligibleConnections) {
|
||||||
let password: string;
|
// resolveImapAuth() wählt automatisch den richtigen Auth-Pfad:
|
||||||
|
// oauth2_microsoft → Access-Token (mit proaktivem Refresh falls abgelaufen)
|
||||||
|
// alle anderen → App-Password decrypt
|
||||||
|
let imapAuth: { user: string; accessToken: string } | { user: string; pass: string };
|
||||||
try {
|
try {
|
||||||
password = decrypt(connection.passwordEncrypted);
|
imapAuth = await resolveImapAuth(connection, msClientId);
|
||||||
} catch {
|
} catch {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -72,7 +79,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
port: connection.imapPort,
|
port: connection.imapPort,
|
||||||
secure: useImplicitTls,
|
secure: useImplicitTls,
|
||||||
...(connection.useStarttls ? { requireTLS: true } : {}),
|
...(connection.useStarttls ? { requireTLS: true } : {}),
|
||||||
auth: { user: connection.email, pass: password },
|
auth: imapAuth,
|
||||||
logger: false,
|
logger: false,
|
||||||
tls: { rejectUnauthorized: connection.rejectUnauthorized ?? true },
|
tls: { rejectUnauthorized: connection.rejectUnauthorized ?? true },
|
||||||
});
|
});
|
||||||
|
|||||||
74
backend/server/utils/mail-auth.ts
Normal file
74
backend/server/utils/mail-auth.ts
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import { refreshAndSaveTokens } from "../db/mail";
|
||||||
|
import { decrypt } from "./crypto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MailConnection-Shape: nur die Felder die für Auth-Resolution nötig sind.
|
||||||
|
* Beide Scan-Endpoints bekommen das volle Prisma-Objekt — dieses Interface
|
||||||
|
* dient als explizites Subset damit der Helper nicht vom vollen Typ abhängt.
|
||||||
|
*/
|
||||||
|
export interface MailConnectionAuthFields {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
authMethod: string;
|
||||||
|
passwordEncrypted: string;
|
||||||
|
oauthAccessToken?: string | null;
|
||||||
|
oauthTokenExpiry?: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ImapAuth =
|
||||||
|
| { user: string; accessToken: string }
|
||||||
|
| { user: string; pass: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt das korrekte `auth`-Objekt für ImapFlow zurück.
|
||||||
|
*
|
||||||
|
* - oauth2_microsoft: Access-Token decrypten, bei Ablauf via MS-Endpoint refreshen.
|
||||||
|
* Nutzt refreshAndSaveTokens() aus db/mail (Race-Condition-sicher, Prisma-basiert).
|
||||||
|
* - Alle anderen authMethods (app_password, default): passwordEncrypted decrypten.
|
||||||
|
*
|
||||||
|
* Wirft wenn:
|
||||||
|
* - App-Password leer oder decrypt fehlschlägt
|
||||||
|
* - OAuth-Token fehlt und kein Refresh möglich
|
||||||
|
* - refreshAndSaveTokens() wirft (revoked refresh_token, MS-Endpoint-Fehler)
|
||||||
|
*
|
||||||
|
* @param connection MailConnection-Felder (Subset)
|
||||||
|
* @param clientId MS Azure App Registration Client-ID (nur für OAuth-Pfad)
|
||||||
|
*/
|
||||||
|
export async function resolveImapAuth(
|
||||||
|
connection: MailConnectionAuthFields,
|
||||||
|
clientId: string,
|
||||||
|
): Promise<ImapAuth> {
|
||||||
|
if (connection.authMethod === "oauth2_microsoft") {
|
||||||
|
// Token-Expiry-Check: 5-Minuten-Puffer damit der Scan nicht
|
||||||
|
// mitten in einem großen Mailbox-Durchlauf mit abgelaufenem Token stirbt.
|
||||||
|
const fiveMinFromNow = Date.now() + 5 * 60 * 1000;
|
||||||
|
const isExpiredOrMissing =
|
||||||
|
!connection.oauthTokenExpiry ||
|
||||||
|
connection.oauthTokenExpiry.getTime() < fiveMinFromNow;
|
||||||
|
|
||||||
|
let accessToken: string;
|
||||||
|
if (isExpiredOrMissing) {
|
||||||
|
// Wirft wenn Refresh-Token fehlt oder MS-Endpoint antwortet mit Fehler.
|
||||||
|
// Caller (scan.post / scan-internal.post) soll per try/catch continue-n.
|
||||||
|
accessToken = await refreshAndSaveTokens(connection.id, clientId);
|
||||||
|
} else {
|
||||||
|
if (!connection.oauthAccessToken) {
|
||||||
|
throw new Error(
|
||||||
|
`oauth2_microsoft connection ${connection.id} has no oauthAccessToken stored`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
accessToken = decrypt(connection.oauthAccessToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { user: connection.email, accessToken };
|
||||||
|
}
|
||||||
|
|
||||||
|
// App-Password-Pfad (gmail, icloud, gmx, yahoo, custom)
|
||||||
|
if (!connection.passwordEncrypted) {
|
||||||
|
throw new Error(
|
||||||
|
`Connection ${connection.id} has no passwordEncrypted (authMethod=${connection.authMethod})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const pass = decrypt(connection.passwordEncrypted);
|
||||||
|
return { user: connection.email, pass };
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user