chahinebrini 8075c8e79c 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>
2026-05-13 23:55:18 +02:00

75 lines
2.7 KiB
TypeScript

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