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:
chahinebrini 2026-05-13 23:55:18 +02:00
parent fd1cb912f7
commit 8075c8e79c
10 changed files with 238 additions and 98 deletions

View File

@ -152,17 +152,6 @@ function MoreInfosSection({
>
{t('mail.more_infos_title')}
</Text>
<Text
style={{
fontSize: 11,
fontFamily: 'Nunito_400Regular',
color: colors.textMuted,
marginTop: 2,
}}
numberOfLines={1}
>
{t('mail.more_infos_subtitle')}
</Text>
</View>
<Ionicons
name={expanded ? 'chevron-up' : 'chevron-down'}
@ -434,7 +423,20 @@ export default function MailScreen() {
</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 && (
<View style={{ marginBottom: 10, paddingHorizontal: 2 }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
@ -511,24 +513,12 @@ export default function MailScreen() {
onEditSuccess={handleConnectSuccess}
disconnecting={disconnectingId === account.id && disconnecting}
blockedLast30d={connStat?.count}
onScanSuccess={refresh}
/>
);
})}
</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>
<ConnectMailSheet

View File

@ -1,5 +1,6 @@
import { useState } from 'react';
import {
ActivityIndicator,
LayoutAnimation,
Linking,
Modal,
@ -15,12 +16,15 @@ import { ConfirmAlert } from '../ConfirmAlert';
import { MailAccountSettingsSheet } from './MailAccountSettingsSheet';
import { MailBlockedByDayChart } from './MailBlockedByDayChart';
import { useMailConnectionStats } from '../../hooks/useMailStats';
import { apiFetch } from '../../lib/api';
import type { MailAccount } from '../../hooks/useMailStatus';
if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) {
UIManager.setLayoutAnimationEnabledExperimental(true);
}
type ScanResult = { ok: boolean; scanned: number; blocked: number };
type Props = {
account: MailAccount;
plan: 'free' | 'pro' | 'legend';
@ -31,6 +35,7 @@ type Props = {
onEditSuccess: () => void;
disconnecting?: boolean;
blockedLast30d?: number;
onScanSuccess?: () => void;
};
function OAuthDisconnectHintModal({
@ -256,14 +261,17 @@ export function MailAccountCard({
onEditSuccess,
disconnecting,
blockedLast30d,
onScanSuccess,
}: Props) {
const { t } = useTranslation();
const [settingsVisible, setSettingsVisible] = useState(false);
const [confirmVisible, setConfirmVisible] = useState(false);
const [oauthDisconnectHintVisible, setOauthDisconnectHintVisible] = useState(false);
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 { data: connStats, granularity, loading: statsLoading } = useMailConnectionStats(
const { data: connStats, granularity, loading: statsLoading, refresh: refreshStats } = useMailConnectionStats(
account.id,
account.createdAt ?? null,
expanded,
@ -281,6 +289,24 @@ export function MailAccountCard({
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) {
setLocalTitle(newTitle);
onEditSuccess();
@ -404,7 +430,59 @@ export function MailAccountCard({
)}
</View>
{/* Einstellungen tap-row */}
{/* Scan-Button + Einstellungen — horizontal nebeneinander */}
<View
style={{
flexDirection: 'row',
alignItems: 'center',
borderTopWidth: 1,
borderTopColor: '#f5f5f5',
marginTop: 8,
}}
>
<TouchableOpacity
activeOpacity={scanning ? 1 : 0.7}
onPress={scanning ? undefined : handleScan}
style={{
flex: 1,
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 14,
paddingVertical: 13,
gap: 6,
borderRightWidth: 1,
borderRightColor: '#f5f5f5',
}}
>
{scanning ? (
<ActivityIndicator size="small" color="#a3a3a3" />
) : (
<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)}
@ -413,24 +491,22 @@ export function MailAccountCard({
alignItems: 'center',
paddingHorizontal: 14,
paddingVertical: 13,
borderTopWidth: 1,
borderTopColor: '#f5f5f5',
marginTop: 8,
gap: 4,
}}
>
<Text
style={{
flex: 1,
fontSize: 14,
fontSize: 13,
fontFamily: 'Nunito_600SemiBold',
color: '#0a0a0a',
}}
>
{t('mail.settings_section_label')}
</Text>
<Ionicons name="chevron-forward" size={16} color="#a3a3a3" />
<Ionicons name="chevron-forward" size={15} color="#a3a3a3" />
</TouchableOpacity>
</View>
</View>
)}
</View>

View File

@ -18,7 +18,7 @@ const OTHER_COLOR = '#a3a3a3';
const MAX_LEGEND_ENTRIES = 3;
const DONUT_WIDTH = 240;
const DONUT_WIDTH = 200;
function formatCompact(n: number): string {
if (n < 1000) return n.toLocaleString();
@ -94,49 +94,10 @@ export function MailDistributionChart({ data, hero, totalBlocked, isLegend }: Pr
borderWidth: 1,
borderColor: colors.border,
paddingHorizontal: 16,
paddingTop: 10,
paddingTop: 13,
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 }}>
<HalfDonut
segments={segments}

View File

@ -39,14 +39,6 @@ type ConnectionStatsState = {
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,
@ -65,23 +57,36 @@ export function useMailConnectionStats(
if (!enabled || !connectionId) return;
setState((s) => ({ ...s, loading: true }));
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[]>(
`/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') {
data = aggregateToWeeks(raw);
} else if (granularity === 'month') {
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 });
} catch {
setState((s) => ({ ...s, loading: false }));
}
}, [enabled, connectionId, granularity]);
}, [enabled, connectionId, granularity, createdAt]);
useEffect(() => {
if (!enabled) return;

View File

@ -485,6 +485,10 @@
},
"account_chart_collecting_title": "Daten werden gesammelt",
"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"
},
"settings": {

View File

@ -485,6 +485,10 @@
},
"account_chart_collecting_title": "Collecting data",
"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"
},
"settings": {

View File

@ -485,6 +485,14 @@ async function runSession(conn) {
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
// wenn der Server in einen ungültigen Zustand kommt — die Session
// bleibt offen ohne Fortschritt bis der Renew-Timer (10min) ein

View File

@ -11,6 +11,7 @@ import { getBlocklistedDomainsSet } from "../../db/domains";
import { getProfile } from "../../db/profile";
import { getPlanLimits } from "../../utils/plan-features";
import { resolveProviderMeta } from "../../utils/imap-providers";
import { resolveImapAuth } from "../../utils/mail-auth";
// Single-Source-of-Truth (Mo's Finding #4)
// @ts-expect-error — .mjs ohne types, GAMBLING_KEYWORDS ist string[]
import { GAMBLING_KEYWORDS } from "../../utils/gambling-keywords.mjs";
@ -66,10 +67,20 @@ export default defineEventHandler(async (event) => {
let totalScanned = 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) {
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 {
password = decrypt(connection.passwordEncrypted);
imapAuth = await resolveImapAuth(connection, msClientId);
} catch {
continue;
}
@ -82,7 +93,7 @@ export default defineEventHandler(async (event) => {
port: connection.imapPort,
secure: useImplicitTls,
...(connection.useStarttls ? { requireTLS: true } : {}),
auth: { user: connection.email, pass: password },
auth: imapAuth,
logger: false,
tls: { rejectUnauthorized: connection.rejectUnauthorized ?? true },
});

View File

@ -11,6 +11,7 @@ import { getBlocklistedDomainsSet } from "../../db/domains";
import { getProfile } from "../../db/profile";
import { getPlanLimits } from "../../utils/plan-features";
import { resolveProviderMeta } from "../../utils/imap-providers";
import { resolveImapAuth } from "../../utils/mail-auth";
// Single-Source-of-Truth (Mo's Finding #4)
// @ts-expect-error — .mjs ohne types, GAMBLING_KEYWORDS ist string[]
import { GAMBLING_KEYWORDS } from "../../utils/gambling-keywords.mjs";
@ -56,10 +57,16 @@ export default defineEventHandler(async (event) => {
let totalScanned = 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) {
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 {
password = decrypt(connection.passwordEncrypted);
imapAuth = await resolveImapAuth(connection, msClientId);
} catch {
continue;
}
@ -72,7 +79,7 @@ export default defineEventHandler(async (event) => {
port: connection.imapPort,
secure: useImplicitTls,
...(connection.useStarttls ? { requireTLS: true } : {}),
auth: { user: connection.email, pass: password },
auth: imapAuth,
logger: false,
tls: { rejectUnauthorized: connection.rejectUnauthorized ?? true },
});

View 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 };
}