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')}
|
||||
</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
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 },
|
||||
});
|
||||
|
||||
@ -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 },
|
||||
});
|
||||
|
||||
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