chahinebrini 96597ffaff feat(mail): Gmail OAuth2 (XOAUTH2/PKCE) — replaces App-Password for Gmail
Reason: App-Passwords sind für manche Gmail-Accounts faktisch unreliable
(silent server-side revoke trotz aktiver 2FA). Empirisch verifiziert
2026-05-28 — iOS Mail (Apple's eigener Client) fail't mit identischen
App-Passwords. OAuth ist Google's stable Pfad. Pattern 1:1 von bestehender
Microsoft-OAuth-Integration übernommen.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 15:13:21 +02:00

93 lines
3.4 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 };
export interface OauthClientIds {
/** Azure App Registration Client ID (MS_OAUTH_CLIENT_ID). */
msClientId: string;
/** Google Cloud OAuth Client ID (GOOGLE_OAUTH_CLIENT_ID). */
googleClientId: 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).
* - oauth2_google: Analog zu oauth2_microsoft. Google rotiert refresh_tokens nicht,
* aber der 5-Minuten-Puffer-Refresh gilt trotzdem (access_token ist 1h gültig).
* - 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, Provider-Endpoint-Fehler)
*
* @param connection MailConnection-Felder (Subset)
* @param clientIds Beide OAuth Client-IDs; die richtige wird anhand authMethod gewählt.
*/
export async function resolveImapAuth(
connection: MailConnectionAuthFields,
clientIds: OauthClientIds,
): Promise<ImapAuth> {
if (
connection.authMethod === "oauth2_microsoft" ||
connection.authMethod === "oauth2_google"
) {
// Wähle den richtigen Client-ID anhand des authMethod-Diskriminators.
const clientId =
connection.authMethod === "oauth2_google"
? clientIds.googleClientId
: clientIds.msClientId;
// 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 Provider-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(
`${connection.authMethod} connection ${connection.id} has no oauthAccessToken stored`,
);
}
accessToken = decrypt(connection.oauthAccessToken);
}
return { user: connection.email, accessToken };
}
// App-Password-Pfad (gmail app-password, 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 };
}