diff --git a/apps/rebreak-native/app/(app)/mail.tsx b/apps/rebreak-native/app/(app)/mail.tsx index 15ec366..b1ce2fa 100644 --- a/apps/rebreak-native/app/(app)/mail.tsx +++ b/apps/rebreak-native/app/(app)/mail.tsx @@ -152,17 +152,6 @@ function MoreInfosSection({ > {t('mail.more_infos_title')} - - {t('mail.more_infos_subtitle')} - )} - {/* 2. ACCOUNT LIST */} + {/* 2. COLLAPSIBLE "MEHR INFOS" — Bar-Chart + nested Kürzlich blockiert */} + {hasAccounts && ( + + setMoreInfosExpanded((p) => !p)} + blockedByDay={blockedByDay} + providers={distinctProviders} + colors={colors} + /> + + )} + + {/* 3. ACCOUNT LIST */} {hasAccounts && ( @@ -511,24 +513,12 @@ export default function MailScreen() { onEditSuccess={handleConnectSuccess} disconnecting={disconnectingId === account.id && disconnecting} blockedLast30d={connStat?.count} + onScanSuccess={refresh} /> ); })} )} - - {/* 3. COLLAPSIBLE "MEHR INFOS" — Bar-Chart + nested Kürzlich blockiert */} - {hasAccounts && ( - - setMoreInfosExpanded((p) => !p)} - blockedByDay={blockedByDay} - providers={distinctProviders} - colors={colors} - /> - - )} 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(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('/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,32 +430,82 @@ export function MailAccountCard({ )} - {/* Einstellungen tap-row */} - setSettingsVisible(true)} + {/* Scan-Button + Einstellungen — horizontal nebeneinander */} + - - {t('mail.settings_section_label')} - - - + {scanning ? ( + + ) : ( + + )} + + {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')} + + + + setSettingsVisible(true)} + style={{ + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 14, + paddingVertical: 13, + gap: 4, + }} + > + + {t('mail.settings_section_label')} + + + + )} diff --git a/apps/rebreak-native/components/mail/MailDistributionChart.tsx b/apps/rebreak-native/components/mail/MailDistributionChart.tsx index 093d19b..53ef497 100644 --- a/apps/rebreak-native/components/mail/MailDistributionChart.tsx +++ b/apps/rebreak-native/components/mail/MailDistributionChart.tsx @@ -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, }} > - - - - - {isLegend ? t('mail.live') : t('mail.scheduled')} - - - - 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( - `/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; diff --git a/apps/rebreak-native/locales/de.json b/apps/rebreak-native/locales/de.json index c3b0bc4..c5c56e0 100644 --- a/apps/rebreak-native/locales/de.json +++ b/apps/rebreak-native/locales/de.json @@ -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": { diff --git a/apps/rebreak-native/locales/en.json b/apps/rebreak-native/locales/en.json index 89652e1..8d072d6 100644 --- a/apps/rebreak-native/locales/en.json +++ b/apps/rebreak-native/locales/en.json @@ -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": { diff --git a/backend/imap-idle/index.mjs b/backend/imap-idle/index.mjs index 8c5bef5..eb1dbfb 100644 --- a/backend/imap-idle/index.mjs +++ b/backend/imap-idle/index.mjs @@ -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 diff --git a/backend/server/api/mail/scan-internal.post.ts b/backend/server/api/mail/scan-internal.post.ts index f1b61d7..afacc6e 100644 --- a/backend/server/api/mail/scan-internal.post.ts +++ b/backend/server/api/mail/scan-internal.post.ts @@ -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 }, }); diff --git a/backend/server/api/mail/scan.post.ts b/backend/server/api/mail/scan.post.ts index d758123..b031f23 100644 --- a/backend/server/api/mail/scan.post.ts +++ b/backend/server/api/mail/scan.post.ts @@ -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 }, }); diff --git a/backend/server/utils/mail-auth.ts b/backend/server/utils/mail-auth.ts new file mode 100644 index 0000000..be6a3ef --- /dev/null +++ b/backend/server/utils/mail-auth.ts @@ -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 { + 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 }; +}