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

116 lines
3.5 KiB
TypeScript

import { ImapFlow } from "imapflow";
import { getProfile } from "../../db/profile";
import { getPlanLimits } from "../../utils/plan-features";
import { countMailConnections, upsertMailConnection } from "../../db/mail";
import { detectImapProviderAsync } from "../../utils/imap-providers";
/**
* POST /api/mail/connect
* Body: { email, password }
* Testet IMAP-Verbindung und speichert Credentials verschlüsselt.
*/
export default defineEventHandler(async (event) => {
const user = await requireUser(event);
const {
email,
password,
// Custom-IMAP-Felder (optional, nur wenn User eigenen Server konfiguriert)
imapHost: customImapHost,
imapPort: customImapPort,
useTls,
rejectUnauthorized,
} = await readBody(event);
if (!email || !password) {
throw createError({
statusCode: 400,
message: "Email und Passwort erforderlich",
});
}
// Plan-Limit prüfen
const profile = await getProfile(user.id);
const limits = getPlanLimits(profile?.plan ?? "free");
if (limits.mailAgents !== Infinity) {
const count = await countMailConnections(user.id);
if (count >= limits.mailAgents) {
throw createError({
statusCode: 403,
data: {
error: "plan_limit",
resource: "mail_accounts",
current: count,
limit: limits.mailAgents,
},
});
}
}
// Custom-IMAP: wenn imapHost explizit gesetzt → Provider-Detection überspringen.
// Sonst: automatisch via Email-Domain erkennen (inkl. MX-Lookup-Fallback für Custom-Domains).
const provider = await detectImapProviderAsync(email);
const resolvedHost = customImapHost?.trim() || provider.host;
const resolvedPort = customImapPort ?? provider.port;
// TLS-Konfiguration ableiten
// useTls=false → STARTTLS (secure=false, requireTLS=true bei ImapFlow)
// useTls=true oder nicht gesetzt → implicit TLS (secure=true)
const useImplicitTls = useTls !== false; // default: true
const tlsRejectUnauthorized = rejectUnauthorized !== false; // default: true
// STARTTLS nur wenn explizit angefordert (useTls === false)
const useStarttls = useTls === false;
// IMAP-Verbindung testen
const client = new ImapFlow({
host: resolvedHost,
port: resolvedPort,
secure: useImplicitTls,
...(useStarttls ? { requireTLS: true } : {}),
auth: { user: email, pass: password },
logger: false,
tls: { rejectUnauthorized: tlsRejectUnauthorized },
});
try {
await client.connect();
await client.logout();
} catch (err: any) {
console.error("[mail/connect] IMAP-Fehler", {
email,
host: resolvedHost,
port: resolvedPort,
provider: provider.name,
authCode: err.authenticationFailed ? "AUTHENTICATIONFAILED" : err.code,
message: err.message,
response: err.response,
responseStatus: err.responseStatus,
});
throw createError({
statusCode: 401,
message: `Verbindung fehlgeschlagen: ${err.message ?? "Ungültige Zugangsdaten"}`,
});
}
// Credentials verschlüsselt speichern
await upsertMailConnection({
userId: user.id,
email,
provider: "imap",
// Bei Custom-Host: Host als providerName, sonst auto-erkannter Name
providerName: customImapHost ? resolvedHost : provider.name,
imapHost: resolvedHost,
imapPort: resolvedPort,
passwordEncrypted: encrypt(password),
rejectUnauthorized: tlsRejectUnauthorized,
useStarttls,
});
return {
connected: true,
email,
provider: customImapHost ? resolvedHost : provider.name,
custom: !!customImapHost,
};
});