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>
93 lines
3.4 KiB
TypeScript
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 };
|
|
}
|