From 0620e9d79af0e39d592c0d9603df637eabc9bd71 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Sat, 26 Sep 2026 18:17:33 +0200 Subject: [PATCH] feat(mail): Microsoft 365 sending via OAuth 2.0 (client credentials) Sending no longer needs a user password for Microsoft 365. When SMTP_OAUTH_TENANT_ID, SMTP_OAUTH_CLIENT_ID and SMTP_OAUTH_CLIENT_SECRET are set, the shop fetches an app-only token from Entra ID and signs in to smtp.office365.com with XOAUTH2. This works with security defaults and MFA, survives the end of SMTP basic auth (default off from end of December 2026) and limits the app to the one mailbox Exchange grants it (SMTP.SendAsApp + Add-MailboxPermission). Password login stays available for other providers; a partial OAuth setup never silently falls back. - lib/ms365-oauth.ts: token cached until 5 minutes before expiry, concurrent requests share one fetch, forced refresh after a server rejection, 15 s timeout, tenant id validated before it enters the URL, client secret redacted from every error. - lib/smtp-diagnose.ts: explains AADSTS7000215 (secret ID instead of value), 7000222 (expired), 700016 (unknown app), 90002/900023 (tenant) and maps a rejected XOAUTH2 login to missing mailbox access. - Admin e-mail test shows the auth mode and lists missing settings. - k8s manifests, preflight check and README (step-by-step Entra and Exchange setup) updated; misplaced SMTP_PORT comment moved. Verified against Entra ID and smtp.office365.com with fake values: AADSTS90002 is classified as tenant error without leaking the secret, an invalid token yields 535 5.7.3 and is classified as mailbox access. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 25 ++ k8s/01-configmap.yaml | 15 +- k8s/02-secret.example.yaml | 14 ++ scripts/preflight.ts | 8 +- src/app/admin/e-mail-test/smtp-test-panel.tsx | 9 +- src/i18n/dictionaries/de.ts | 5 +- src/i18n/dictionaries/en.ts | 5 +- src/i18n/dictionary-type.ts | 3 + src/lib/mail.ts | 227 ++++++++++++------ src/lib/ms365-oauth.test.ts | 84 +++++++ src/lib/ms365-oauth.ts | 107 +++++++++ src/lib/smtp-diagnose.test.ts | 50 +++- src/lib/smtp-diagnose.ts | 47 +++- 13 files changed, 511 insertions(+), 88 deletions(-) create mode 100644 src/lib/ms365-oauth.test.ts create mode 100644 src/lib/ms365-oauth.ts diff --git a/README.md b/README.md index 54fcbb9..5e98032 100644 --- a/README.md +++ b/README.md @@ -262,6 +262,31 @@ Bestellbestätigung, Versand-/Abholbereit-Benachrichtigung, E-Mail-Verifizierung Funktioniert mit jedem SMTP-Anbieter, z.B. dem E-Mail-Postfach eures Domain-Hosters (IONOS, Strato, …) oder Diensten wie Resend/Postmark im SMTP-Modus. `SMTP_FROM` sollte zur `SMTP_USER`-Domain passen, sonst landen Mails leicht im Spam. +Ob der Versand funktioniert, zeigt **Admin → E-Mail-Test**: Anmeldung, Test-Mail an die eigene Admin-Adresse und bei einem Fehler die Meldung des Mailservers samt Ursache. + +### Microsoft 365 mit OAuth 2.0 (empfohlen) + +Statt eines Benutzerpassworts meldet sich der Shop als eigene App-Registrierung an (Client-Credentials-Flow, SMTP mit XOAUTH2). Das funktioniert mit aktivierten Sicherheitsstandards und MFA, übersteht das Ende der Passwort-Anmeldung (Basic Auth) bei Exchange Online und gibt der App Zugriff auf genau ein Postfach. Aktiv, sobald `SMTP_OAUTH_TENANT_ID`, `SMTP_OAUTH_CLIENT_ID` und `SMTP_OAUTH_CLIENT_SECRET` gesetzt sind; `SMTP_PASSWORD` wird dann nicht gebraucht, `SMTP_HOST` ist standardmäßig `smtp.office365.com`. + +1. **Absender-Postfach:** Am besten ein freigegebenes Postfach (z. B. `hallo@wabenhain.com`) — braucht keine Lizenz und hat kein Passwort, mit dem sich jemand anmelden könnte. `SMTP_USER` und die Adresse in `SMTP_FROM` sind dieses Postfach. +2. **App registrieren:** Entra Admin Center → App-Registrierungen → Neue Registrierung, z. B. „Wabenhain Shop Mail“, nur dieses Verzeichnis, keine Umleitungs-URI. Aus der Übersicht notieren: **Anwendungs-ID (Client-ID)** und **Verzeichnis-ID (Mandanten-ID)**. +3. **Berechtigung:** API-Berechtigungen → Berechtigung hinzufügen → „Von meiner Organisation verwendete APIs“ → **Office 365 Exchange Online** → Anwendungsberechtigungen → **SMTP.SendAsApp** → danach „Administratorzustimmung erteilen“. +4. **Geheimer Schlüssel:** Zertifikate & Geheimnisse → Neuer geheimer Clientschlüssel → den **Wert** kopieren (nicht die Geheimnis-ID; der Wert wird nur einmal angezeigt). Ablaufdatum im Kalender eintragen. +5. **Objekt-ID der Unternehmensanwendung:** Entra → Unternehmensanwendungen → die App → **Objekt-ID** (nicht die der App-Registrierung). +6. **Exchange-Zugriff auf genau dieses Postfach** (Exchange Online PowerShell): + ```powershell + Install-Module ExchangeOnlineManagement # einmalig + Connect-ExchangeOnline + New-ServicePrincipal -AppId -ObjectId -DisplayName "Wabenhain Shop Mail" + Add-MailboxPermission -Identity "hallo@wabenhain.com" -User -AccessRights FullAccess + Set-CASMailbox -Identity "hallo@wabenhain.com" -SmtpClientAuthenticationDisabled $false + ``` + Die letzte Zeile ist nötig, weil auch OAuth über „authentifiziertes SMTP“ läuft. Änderungen brauchen bis zu 30 Minuten. +7. **Kubernetes:** `SMTP_OAUTH_TENANT_ID` und `SMTP_OAUTH_CLIENT_ID` in `k8s/01-configmap.yaml`, `SMTP_OAUTH_CLIENT_SECRET` und `SMTP_USER` im Secret, `SMTP_PASSWORD` leer. Danach `kubectl rollout restart deploy/wabenhain-app -n wabenhain` und **Admin → E-Mail-Test**. +8. **Aufräumen:** Ein zuvor für SMTP genutztes Benutzerpasswort ändern und aus dem Secret entfernen. + +Läuft der Schlüssel ab, meldet der E-Mail-Test „Das Client-Secret ist abgelaufen“ (AADSTS7000222): neuen Schlüssel anlegen, Secret ersetzen, Pod neu starten. + ## CAPTCHA (Cloudflare Turnstile) **Alle öffentlichen Eingabeformulare** sind durch [Cloudflare Turnstile](https://developers.cloudflare.com/turnstile/) gegen automatisierten Missbrauch geschützt — zusätzlich zum bestehenden Rate-Limiting: diff --git a/k8s/01-configmap.yaml b/k8s/01-configmap.yaml index 2675390..da72632 100644 --- a/k8s/01-configmap.yaml +++ b/k8s/01-configmap.yaml @@ -30,6 +30,17 @@ data: # Domain liegen, sonst landen die Mails regelmaessig im Spam-Ordner. SMTP_FROM: "Wabenhain " + # Microsoft 365 per OAuth 2.0 (empfohlen, siehe README "E-Mail-Versand"). + # Beide Werte sind nicht geheim und stehen im Entra Admin Center unter + # App-Registrierungen → → Uebersicht. Zusammen mit + # SMTP_OAUTH_CLIENT_SECRET im Secret schaltet das den Versand auf OAuth um + # (SMTP_PASSWORD wird dann nicht gebraucht, SMTP_HOST ist optional). + # Leer lassen fuer Anmeldung per Passwort bei einem anderen Anbieter. + # Verzeichnis-ID (Mandanten-ID) oder z. B. wabenhain.onmicrosoft.com: + SMTP_OAUTH_TENANT_ID: "" + # Anwendungs-ID (Client-ID) der App-Registrierung: + SMTP_OAUTH_CLIENT_ID: "" + # --------------------------------------------------------------------- # VORGABEN — koennen so bleiben # --------------------------------------------------------------------- @@ -42,8 +53,6 @@ data: # Prisma-Client-Varianten, siehe src/lib/prisma.ts. DATABASE_PROVIDER: "postgresql" - # Port des SMTP-Anbieters. 587 ist STARTTLS (der Normalfall), 465 waere - # implizites TLS. # Gleichzeitig offene Datenbankverbindungen JE INSTANZ. Leer = 20. # # Beim Hochskalieren mitdenken: der Wert multipliziert sich mit "replicas" @@ -53,6 +62,8 @@ data: # welche. Entweder diesen Wert senken oder max_connections anheben. DATABASE_POOL_SIZE: "20" + # Port des SMTP-Anbieters. 587 ist STARTTLS (der Normalfall, auch fuer + # Microsoft 365), 465 waere implizites TLS. SMTP_PORT: "587" # Verzeichnis fuer hochgeladene Bilder und Videos. MUSS exakt dem mountPath diff --git a/k8s/02-secret.example.yaml b/k8s/02-secret.example.yaml index 3909531..21404cb 100644 --- a/k8s/02-secret.example.yaml +++ b/k8s/02-secret.example.yaml @@ -72,9 +72,23 @@ stringData: # keine Bestellbestaetigung, keine Versandmeldung, keine # Passwort-Zuruecksetzung. Funktioniert mit jedem SMTP-Anbieter; Port und # Absenderadresse stehen als SMTP_PORT/SMTP_FROM in 01-configmap.yaml. + # + # SMTP_USER ist immer das Postfach, mit dem gesendet wird (z. B. + # hallo@wabenhain.com) — und sollte dieselbe Adresse wie SMTP_FROM sein. + # + # Microsoft 365 mit OAuth 2.0 (empfohlen): SMTP_OAUTH_CLIENT_SECRET setzen, + # dazu SMTP_OAUTH_TENANT_ID/SMTP_OAUTH_CLIENT_ID in 01-configmap.yaml. + # SMTP_HOST darf leer bleiben (Standard smtp.office365.com), SMTP_PASSWORD + # wird nicht gebraucht. Das Secret laeuft ab (hoechstens 24 Monate) — + # Ablaufdatum im Kalender eintragen. + # + # Andere Anbieter: SMTP_HOST und SMTP_PASSWORD setzen, + # SMTP_OAUTH_CLIENT_SECRET leer lassen. SMTP_HOST: "CHANGE-ME" SMTP_USER: "CHANGE-ME" SMTP_PASSWORD: "CHANGE-ME" + # "Wert" (nicht "Geheimnis-ID") des geheimen Clientschluessels der App + SMTP_OAUTH_CLIENT_SECRET: "" # --------------------------------------------------------------------- # EMPFOHLEN — der Shop laeuft auch ohne, aber ungeschuetzt diff --git a/scripts/preflight.ts b/scripts/preflight.ts index 2d4645a..be6c95b 100644 --- a/scripts/preflight.ts +++ b/scripts/preflight.ts @@ -146,9 +146,13 @@ mustWhenSet( ); // --- E-Mail ---------------------------------------------------------------- +// Zwei gueltige Wege (siehe src/lib/mail.ts): Microsoft 365 per OAuth 2.0 +// oder klassisch mit Passwort. must( - "SMTP_HOST, SMTP_USER und SMTP_PASSWORD gesetzt", - isSet("SMTP_HOST") && isSet("SMTP_USER") && isSet("SMTP_PASSWORD"), + "SMTP eingerichtet (SMTP_USER + OAuth 2.0 oder SMTP_HOST/SMTP_PASSWORD)", + isSet("SMTP_USER") && + ((isSet("SMTP_OAUTH_TENANT_ID") && isSet("SMTP_OAUTH_CLIENT_ID") && isSet("SMTP_OAUTH_CLIENT_SECRET")) || + (isSet("SMTP_HOST") && isSet("SMTP_PASSWORD"))), "Ohne SMTP werden E-Mails nur ins Server-Log geschrieben (siehe src/lib/mail.ts): keine Bestellbestaetigung, keine Versandmeldung, keine Passwort-Zuruecksetzung." ); // Ohne SMTP_FROM greift der Standardabsender aus lib/mail.ts — deshalb nur diff --git a/src/app/admin/e-mail-test/smtp-test-panel.tsx b/src/app/admin/e-mail-test/smtp-test-panel.tsx index c1a1010..b6e7ed7 100644 --- a/src/app/admin/e-mail-test/smtp-test-panel.tsx +++ b/src/app/admin/e-mail-test/smtp-test-panel.tsx @@ -26,7 +26,12 @@ export function SmtpTestPanel() {

{s.success(state.to)}

) : null} {state && "ok" in state && !state.ok && !state.configured ? ( -

{s.notConfigured}

+
+

{s.notConfigured}

+

+ {s.missingLabel}: {state.missing.join(", ")} +

+
) : null} {state && "ok" in state && !state.ok && state.configured ? (
@@ -42,7 +47,7 @@ export function SmtpTestPanel() {

{s.configLabel}

- SMTP_HOST={state.host} · SMTP_USER={state.user} · SMTP_FROM={state.from} + {state.authMode === "oauth2" ? s.authModeOAuth : s.authModePassword} · SMTP_HOST={state.host} · SMTP_USER={state.user} · SMTP_FROM={state.from}

diff --git a/src/i18n/dictionaries/de.ts b/src/i18n/dictionaries/de.ts index 593d563..5b755fd 100644 --- a/src/i18n/dictionaries/de.ts +++ b/src/i18n/dictionaries/de.ts @@ -1263,11 +1263,14 @@ export const de: Dictionary = { button: "Test-Mail senden", running: "Wird getestet …", success: (to) => `Versand funktioniert. Die Test-Mail ist an ${to} unterwegs.`, - notConfigured: "SMTP ist nicht eingerichtet: SMTP_HOST, SMTP_USER oder SMTP_PASSWORD fehlen im Kubernetes-Secret. Ohne sie werden E-Mails nur ins Server-Log geschrieben.", + notConfigured: "Der Mailversand ist nicht vollständig eingerichtet. Ohne die unten genannten Werte (Kubernetes-ConfigMap bzw. -Secret) werden E-Mails nur ins Server-Log geschrieben.", failureHeading: "Versand fehlgeschlagen", causeLabel: "Was zu tun ist", detailLabel: "Meldung des Mailservers", configLabel: "Verwendete Einstellungen", + authModeOAuth: "OAuth 2.0 (App-Registrierung)", + authModePassword: "Passwort (Basic Auth)", + missingLabel: "Fehlende Einstellungen", }, adminWithdrawalsPage: { title: "Widerrufe", diff --git a/src/i18n/dictionaries/en.ts b/src/i18n/dictionaries/en.ts index 229d534..54233ac 100644 --- a/src/i18n/dictionaries/en.ts +++ b/src/i18n/dictionaries/en.ts @@ -1237,11 +1237,14 @@ export const en: Dictionary = { button: "Send test email", running: "Testing …", success: (to) => `Delivery works. The test email is on its way to ${to}.`, - notConfigured: "SMTP is not set up: SMTP_HOST, SMTP_USER or SMTP_PASSWORD is missing from the Kubernetes secret. Without them, emails are only written to the server log.", + notConfigured: "Email delivery is not fully set up. Without the values listed below (Kubernetes config map or secret), emails are only written to the server log.", failureHeading: "Delivery failed", causeLabel: "What to do", detailLabel: "Mail server message", configLabel: "Settings used", + authModeOAuth: "OAuth 2.0 (app registration)", + authModePassword: "Password (basic auth)", + missingLabel: "Missing settings", }, adminWithdrawalsPage: { title: "Withdrawals", diff --git a/src/i18n/dictionary-type.ts b/src/i18n/dictionary-type.ts index cc72f5c..ede4bd2 100644 --- a/src/i18n/dictionary-type.ts +++ b/src/i18n/dictionary-type.ts @@ -829,6 +829,9 @@ export interface Dictionary { causeLabel: string; detailLabel: string; configLabel: string; + authModeOAuth: string; + authModePassword: string; + missingLabel: string; }; adminWithdrawalsPage: { title: string; diff --git a/src/lib/mail.ts b/src/lib/mail.ts index 751f135..b22756a 100644 --- a/src/lib/mail.ts +++ b/src/lib/mail.ts @@ -1,5 +1,7 @@ -import nodemailer from "nodemailer"; -import { explainSmtpError, type SmtpDiagnosis } from "@/lib/smtp-diagnose"; +import nodemailer, { type Transporter } from "nodemailer"; +import type SMTPTransport from "nodemailer/lib/smtp-transport"; +import { explainSmtpError, type SmtpAuthMode, type SmtpDiagnosis } from "@/lib/smtp-diagnose"; +import { createMs365TokenProvider, type Ms365OAuthConfig, type Ms365TokenProvider } from "@/lib/ms365-oauth"; export interface MailAttachment { filename: string; @@ -21,6 +23,22 @@ interface SendMailInput { attachments?: MailAttachment[]; } +/** + * Zwei Anmeldearten am Mailserver: + * - oauth2: Microsoft 365 per App-Registrierung (SMTP_OAUTH_TENANT_ID, + * SMTP_OAUTH_CLIENT_ID, SMTP_OAUTH_CLIENT_SECRET). Bevorzugt — kein + * Benutzerpasswort, unabhaengig vom Ende der Basic-Auth bei Microsoft. + * - password: klassisch mit SMTP_PASSWORD, fuer jeden anderen Anbieter. + * Sobald einer der SMTP_OAUTH_*-Werte gesetzt ist, gilt oauth2 — ein + * unvollstaendiger Satz faellt NICHT still auf das Passwort zurueck. + */ +type SmtpConfig = + | { authMode: "oauth2"; host: string; port: number; user: string; oauth: Ms365OAuthConfig } + | { authMode: "password"; host: string; port: number; user: string; pass: string }; + +const MS365_SMTP_HOST = "smtp.office365.com"; +const TEST_TIMEOUT_MS = 15_000; + /** Maskiert eine E-Mail-Adresse fuer Log-Ausgaben, z.B. "ma***@example.com". */ function maskEmail(email: string): string { const [local, domain] = email.split("@"); @@ -28,64 +46,138 @@ function maskEmail(email: string): string { return `${local.slice(0, 2)}***@${domain}`; } -let cachedTransport: ReturnType | null = null; -// Nur einmal geprueft (nicht bei jedem sendMail-Aufruf) — macht ein in -// Produktion fehlendes SMTP-Setup im Server-Log sichtbar, statt dass -// Bestellbestaetigungen/Passwort-Resets fortlaufend lautlos verschwinden. -let warnedAboutMissingSmtpInProduction = false; - /** - * Zugangsdaten aus der Umgebung. Ein Zeilenumbruch am Ende entsteht leicht - * beim Anlegen des Kubernetes-Secrets (echo ohne -n vor base64) und laesst - * jede Anmeldung scheitern — er wird entfernt, alle anderen Zeichen bleiben. + * Liest die SMTP-Umgebung. Bei Geheimnissen wird nur ein Zeilenumbruch am + * Ende entfernt: er entsteht leicht beim Anlegen des Kubernetes-Secrets + * (echo ohne -n vor base64) und laesst jede Anmeldung scheitern. Sonstige + * Werte werden getrimmt. + * + * Bewusst woertliche Zugriffe auf process.env mit festem Namen: k8s-manifest-env.test.ts + * gleicht genau diese mit den Kubernetes-Manifesten ab. */ -function readSmtpConfig(): { host: string; port: number; user: string; pass: string } | null { - const host = process.env.SMTP_HOST?.trim(); - const user = process.env.SMTP_USER?.trim(); - const pass = process.env.SMTP_PASSWORD?.replace(/[\r\n]+$/, ""); - if (!host || !user || !pass) return null; - return { host, port: Number(process.env.SMTP_PORT ?? 587), user, pass }; +function readSmtpEnv() { + const secret = (value: string | undefined) => value?.replace(/[\r\n]+$/, "") ?? ""; + const plain = (value: string | undefined) => value?.trim() ?? ""; + return { + SMTP_HOST: plain(process.env.SMTP_HOST), + SMTP_USER: plain(process.env.SMTP_USER), + SMTP_PASSWORD: secret(process.env.SMTP_PASSWORD), + SMTP_OAUTH_TENANT_ID: plain(process.env.SMTP_OAUTH_TENANT_ID), + SMTP_OAUTH_CLIENT_ID: plain(process.env.SMTP_OAUTH_CLIENT_ID), + SMTP_OAUTH_CLIENT_SECRET: secret(process.env.SMTP_OAUTH_CLIENT_SECRET), + }; +} + +type SmtpEnvName = keyof ReturnType; + +const OAUTH_SETTINGS: SmtpEnvName[] = ["SMTP_OAUTH_TENANT_ID", "SMTP_OAUTH_CLIENT_ID", "SMTP_OAUTH_CLIENT_SECRET"]; + +/** Namen der Variablen, die fuer die gewaehlte Anmeldeart noch fehlen — leer, wenn alles da ist. */ +function missingSmtpSettings(): string[] { + const env = readSmtpEnv(); + const wantsOAuth = OAUTH_SETTINGS.some((name) => env[name] !== ""); + const required: SmtpEnvName[] = wantsOAuth ? ["SMTP_USER", ...OAUTH_SETTINGS] : ["SMTP_HOST", "SMTP_USER", "SMTP_PASSWORD"]; + return required.filter((name) => env[name] === ""); +} + +function readSmtpConfig(): SmtpConfig | null { + if (missingSmtpSettings().length > 0) return null; + const env = readSmtpEnv(); + const port = Number(process.env.SMTP_PORT ?? 587); + if (env.SMTP_OAUTH_CLIENT_SECRET) { + return { + authMode: "oauth2", + host: env.SMTP_HOST || MS365_SMTP_HOST, + port, + user: env.SMTP_USER, + oauth: { tenantId: env.SMTP_OAUTH_TENANT_ID, clientId: env.SMTP_OAUTH_CLIENT_ID, clientSecret: env.SMTP_OAUTH_CLIENT_SECRET }, + }; + } + return { authMode: "password", host: env.SMTP_HOST, port, user: env.SMTP_USER, pass: env.SMTP_PASSWORD }; } function getFromAddress(): string { return process.env.SMTP_FROM || "Wabenhain "; } -function getTransport() { - if (cachedTransport) return cachedTransport; +function secretsOf(config: SmtpConfig | null): string[] { + if (!config) return []; + return config.authMode === "oauth2" ? [config.oauth.clientSecret] : [config.pass]; +} +// Ein Token-Provider pro Prozess: das Token (ca. 60 Minuten gueltig) wird +// ueber alle Mails hinweg wiederverwendet statt je Mail neu geholt. +let tokenProvider: { key: string; provider: Ms365TokenProvider } | null = null; + +function getTokenProvider(oauth: Ms365OAuthConfig): Ms365TokenProvider { + const key = `${oauth.tenantId}|${oauth.clientId}|${oauth.clientSecret}`; + if (!tokenProvider || tokenProvider.key !== key) { + tokenProvider = { key, provider: createMs365TokenProvider(oauth) }; + } + return tokenProvider.provider; +} + +type SmtpTransporter = Transporter; + +function buildTransport(config: SmtpConfig, extra: { connectionTimeout?: number; greetingTimeout?: number } = {}): SmtpTransporter { + const auth: SMTPTransport.Options["auth"] = + config.authMode === "oauth2" + ? { + type: "OAuth2" as const, + user: config.user, + // nodemailer fragt hier nach einem Token; renew=true nach einer + // Ablehnung durch den Server → frisches Token statt des gecachten. + provisionCallback: (_user: string, renew: boolean, callback: (err: Error | null, accessToken: string, expires: number) => void) => { + // expires 0: nodemailer fragt bei jeder Anmeldung neu — das Cachen + // mit Ablaufreserve uebernimmt der Token-Provider. + getTokenProvider(config.oauth) + .getToken(renew) + .then((token) => callback(null, token, 0), (error: Error) => callback(error, "", 0)); + }, + } + : { user: config.user, pass: config.pass }; + const options: SMTPTransport.Options = { + host: config.host, + port: config.port, + secure: config.port === 465, + // Auf 587 MUSS die Verbindung per STARTTLS verschluesselt werden, bevor + // Passwort oder Token gesendet werden — ohne requireTLS wuerde nodemailer + // bei einem Server ohne STARTTLS-Angebot (Fehlkonfiguration, Angriff) die + // Zugangsdaten im Klartext schicken. + requireTLS: config.port !== 465, + auth, + ...extra, + }; + return nodemailer.createTransport(options); +} + +let cachedTransport: { config: SmtpConfig; transport: SmtpTransporter } | null = null; +// Nur einmal gemeldet (nicht bei jedem sendMail-Aufruf) — macht ein in +// Produktion fehlendes SMTP-Setup im Server-Log sichtbar, statt dass +// Bestellbestaetigungen/Passwort-Resets fortlaufend lautlos verschwinden. +let warnedAboutMissingSmtpInProduction = false; + +function getTransport(): { config: SmtpConfig; transport: SmtpTransporter } | null { const config = readSmtpConfig(); if (!config) { if (process.env.NODE_ENV === "production" && !warnedAboutMissingSmtpInProduction) { warnedAboutMissingSmtpInProduction = true; console.warn( - "SMTP_HOST/SMTP_USER/SMTP_PASSWORD sind in Produktion nicht gesetzt — Bestellbestaetigungen, Passwort-Resets und weitere Transaktions-E-Mails werden nur geloggt, nicht versendet." + `[mail] SMTP ist nicht vollstaendig eingerichtet (es fehlt: ${missingSmtpSettings().join(", ")}) — Transaktions-E-Mails werden nur geloggt, nicht versendet.` ); } return null; } - - cachedTransport = nodemailer.createTransport({ - host: config.host, - port: config.port, - secure: config.port === 465, - // Auf 587 MUSS die Verbindung per STARTTLS verschluesselt werden, bevor - // das Passwort gesendet wird — ohne requireTLS wuerde nodemailer bei - // einem Server ohne STARTTLS-Angebot (Fehlkonfiguration, Angriff) die - // Zugangsdaten im Klartext schicken. - requireTLS: config.port !== 465, - auth: { user: config.user, pass: config.pass }, - }); + if (!cachedTransport) cachedTransport = { config, transport: buildTransport(config) }; return cachedTransport; } /** - * Versendet eine E-Mail ueber den in SMTP_HOST/SMTP_USER/SMTP_PASSWORD - * konfigurierten Mailserver. Ohne Konfiguration wird die Mail NICHT - * gesendet, sondern nur geloggt — analog zum Stripe-Fallback ohne Keys, - * damit lokale Entwicklung und Deployments ohne Mail-Setup nicht abstuerzen. - * Wirft absichtlich keinen Fehler: ein Mail-Ausfall darf eine Bestellung - * oder Registrierung nicht verhindern. + * Versendet eine E-Mail ueber den konfigurierten Mailserver. Ohne + * Konfiguration wird die Mail NICHT gesendet, sondern nur geloggt — analog + * zum Stripe-Fallback ohne Keys, damit lokale Entwicklung und Deployments + * ohne Mail-Setup nicht abstuerzen. Wirft absichtlich keinen Fehler: ein + * Mail-Ausfall darf eine Bestellung oder Registrierung nicht verhindern. * * Liefert true, wenn der Mailserver die Nachricht angenommen hat, sonst * false (nicht konfiguriert oder Versand fehlgeschlagen). Aktionen mit @@ -94,19 +186,15 @@ function getTransport() { * Kundschaft auf eine Mail, die nie kommt. */ export async function sendMail(input: SendMailInput): Promise { - const transport = getTransport(); - const from = getFromAddress(); - - if (!transport) { - console.warn( - `[mail] SMTP nicht konfiguriert — E-Mail an ${maskEmail(input.to)} ("${input.subject}") wurde nicht gesendet.` - ); + const active = getTransport(); + if (!active) { + console.warn(`[mail] SMTP nicht konfiguriert — E-Mail an ${maskEmail(input.to)} ("${input.subject}") wurde nicht gesendet.`); return false; } try { - await transport.sendMail({ - from, + await active.transport.sendMail({ + from: getFromAddress(), to: input.to, subject: input.subject, html: input.html, @@ -115,52 +203,47 @@ export async function sendMail(input: SendMailInput): Promise { }); return true; } catch (error: unknown) { - const diagnosis = explainSmtpError(error, readSmtpConfig()?.pass); - console.error( - `[mail] Versand an ${maskEmail(input.to)} fehlgeschlagen (${diagnosis.cause}): ${diagnosis.detail} — ${diagnosis.hint}` - ); + const diagnosis = explainSmtpError(error, { secrets: secretsOf(active.config), authMode: active.config.authMode }); + console.error(`[mail] Versand an ${maskEmail(input.to)} fehlgeschlagen (${diagnosis.cause}): ${diagnosis.detail} — ${diagnosis.hint}`); return false; } } +interface SmtpTestBase { + host: string; + user: string; + from: string; + authMode: SmtpAuthMode; +} + export type SmtpTestResult = - | { ok: true; host: string; user: string; from: string } - | { ok: false; configured: false } - | { ok: false; configured: true; host: string; user: string; from: string; diagnosis: SmtpDiagnosis }; + | ({ ok: true } & SmtpTestBase) + | { ok: false; configured: false; missing: string[] } + | ({ ok: false; configured: true; diagnosis: SmtpDiagnosis } & SmtpTestBase); /** * Prueft die SMTP-Konfiguration im laufenden Betrieb: Verbindung + Anmeldung * (verify) und danach ein echter Versand an `to` — erst der Versand zeigt, * ob das Postfach mit der Absenderadresse (SMTP_FROM) senden darf. Baut - * bewusst einen frischen Transport statt des zwischengespeicherten, damit - * ein geaendertes Secret nach einem Neustart sofort getestet wird. Das - * Passwort erscheint nie im Ergebnis. + * bewusst einen frischen Transport statt des zwischengespeicherten. Weder + * Passwort noch Client-Secret erscheinen im Ergebnis. */ export async function testSmtpConnection(to: string): Promise { const config = readSmtpConfig(); - if (!config) return { ok: false, configured: false }; - const from = getFromAddress(); - const transport = nodemailer.createTransport({ - host: config.host, - port: config.port, - secure: config.port === 465, - requireTLS: config.port !== 465, - auth: { user: config.user, pass: config.pass }, - connectionTimeout: 15_000, - greetingTimeout: 15_000, - }); - const base = { host: config.host, user: config.user, from }; + if (!config) return { ok: false, configured: false, missing: missingSmtpSettings() }; + const base: SmtpTestBase = { host: config.host, user: config.user, from: getFromAddress(), authMode: config.authMode }; + const transport = buildTransport(config, { connectionTimeout: TEST_TIMEOUT_MS, greetingTimeout: TEST_TIMEOUT_MS }); try { await transport.verify(); await transport.sendMail({ - from, + from: base.from, to, subject: "Wabenhain: Test des E-Mail-Versands", - text: `Diese Test-Mail bestaetigt, dass der Shop ueber ${config.host} als ${config.user} mit dem Absender ${from} senden kann.`, + text: `Diese Test-Mail bestaetigt, dass der Shop ueber ${config.host} als ${config.user} (Anmeldung: ${config.authMode === "oauth2" ? "OAuth 2.0" : "Passwort"}) mit dem Absender ${base.from} senden kann.`, }); return { ok: true, ...base }; } catch (error: unknown) { - return { ok: false, configured: true, ...base, diagnosis: explainSmtpError(error, config.pass) }; + return { ok: false, configured: true, ...base, diagnosis: explainSmtpError(error, { secrets: secretsOf(config), authMode: config.authMode }) }; } finally { transport.close(); } diff --git a/src/lib/ms365-oauth.test.ts b/src/lib/ms365-oauth.test.ts new file mode 100644 index 0000000..1250008 --- /dev/null +++ b/src/lib/ms365-oauth.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it, vi } from "vitest"; +import { createMs365TokenProvider, Ms365TokenError } from "./ms365-oauth"; + +const CONFIG = { tenantId: "wabenhain.onmicrosoft.com", clientId: "11111111-2222-3333-4444-555555555555", clientSecret: "s3cr3t~value" }; + +function tokenResponse(token: string, expiresIn = 3600): Response { + return new Response(JSON.stringify({ token_type: "Bearer", expires_in: expiresIn, access_token: token }), { status: 200 }); +} + +describe("createMs365TokenProvider", () => { + it("requests a client-credentials token for Exchange Online", async () => { + const fetchImpl = vi.fn(async () => tokenResponse("tok-1")); + const provider = createMs365TokenProvider(CONFIG, { fetchImpl }); + + await expect(provider.getToken()).resolves.toBe("tok-1"); + + const [url, init] = fetchImpl.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe("https://login.microsoftonline.com/wabenhain.onmicrosoft.com/oauth2/v2.0/token"); + expect(init.method).toBe("POST"); + const body = new URLSearchParams(String(init.body)); + expect(body.get("grant_type")).toBe("client_credentials"); + expect(body.get("scope")).toBe("https://outlook.office365.com/.default"); + expect(body.get("client_id")).toBe(CONFIG.clientId); + expect(body.get("client_secret")).toBe(CONFIG.clientSecret); + }); + + it("reuses the cached token until shortly before it expires", async () => { + let now = 1_000_000; + const fetchImpl = vi.fn().mockResolvedValueOnce(tokenResponse("tok-1", 3600)).mockResolvedValueOnce(tokenResponse("tok-2", 3600)); + const provider = createMs365TokenProvider(CONFIG, { fetchImpl, now: () => now }); + + expect(await provider.getToken()).toBe("tok-1"); + now += 50 * 60 * 1000; // 50 Minuten: noch gueltig + expect(await provider.getToken()).toBe("tok-1"); + now += 6 * 60 * 1000; // 56 Minuten: innerhalb der 5-Minuten-Reserve + expect(await provider.getToken()).toBe("tok-2"); + expect(fetchImpl).toHaveBeenCalledTimes(2); + }); + + it("fetches a fresh token when a renewal is forced", async () => { + const fetchImpl = vi.fn().mockResolvedValueOnce(tokenResponse("tok-1")).mockResolvedValueOnce(tokenResponse("tok-2")); + const provider = createMs365TokenProvider(CONFIG, { fetchImpl }); + await provider.getToken(); + expect(await provider.getToken(true)).toBe("tok-2"); + }); + + it("shares one request between concurrent callers", async () => { + const fetchImpl = vi.fn(async () => tokenResponse("tok-1")); + const provider = createMs365TokenProvider(CONFIG, { fetchImpl }); + const tokens = await Promise.all([provider.getToken(), provider.getToken(), provider.getToken()]); + expect(tokens).toEqual(["tok-1", "tok-1", "tok-1"]); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + it("reports the AADSTS code and never the client secret", async () => { + const fetchImpl = vi.fn( + async () => + new Response( + JSON.stringify({ + error: "invalid_client", + error_description: `AADSTS7000215: Invalid client secret provided. Ensure the secret being sent is the client secret value, not the client secret ID, for a secret added to app '${CONFIG.clientId}'. Secret: ${CONFIG.clientSecret}`, + }), + { status: 401 } + ) + ); + const provider = createMs365TokenProvider(CONFIG, { fetchImpl }); + const error = await provider.getToken().catch((e: unknown) => e); + expect(error).toBeInstanceOf(Ms365TokenError); + expect((error as Ms365TokenError).aadCode).toBe("7000215"); + expect((error as Error).message).not.toContain(CONFIG.clientSecret); + }); + + it("does not cache a failed request", async () => { + const fetchImpl = vi.fn().mockRejectedValueOnce(new Error("fetch failed")).mockResolvedValueOnce(tokenResponse("tok-1")); + const provider = createMs365TokenProvider(CONFIG, { fetchImpl }); + await expect(provider.getToken()).rejects.toBeInstanceOf(Ms365TokenError); + await expect(provider.getToken()).resolves.toBe("tok-1"); + }); + + it("rejects a tenant id that could change the request URL", () => { + expect(() => createMs365TokenProvider({ ...CONFIG, tenantId: "evil.com/x?" })).toThrow(Ms365TokenError); + expect(() => createMs365TokenProvider({ ...CONFIG, tenantId: "../common" })).toThrow(Ms365TokenError); + }); +}); diff --git a/src/lib/ms365-oauth.ts b/src/lib/ms365-oauth.ts new file mode 100644 index 0000000..84f3984 --- /dev/null +++ b/src/lib/ms365-oauth.ts @@ -0,0 +1,107 @@ +/** + * OAuth 2.0 fuer den Mailversand ueber Microsoft 365 (Exchange Online SMTP + * mit XOAUTH2). Client-Credentials-Flow: der Shop meldet sich als eigene + * App-Registrierung in Entra ID an — kein Benutzerpasswort, kein MFA-Konflikt, + * unabhaengig vom Ende der Basic-Auth fuer SMTP. Die App darf nur mit dem + * Postfach senden, fuer das Exchange ihr ausdruecklich Zugriff gibt + * (Berechtigung SMTP.SendAsApp + Add-MailboxPermission, siehe README). + */ + +export interface Ms365OAuthConfig { + tenantId: string; + clientId: string; + clientSecret: string; +} + +export class Ms365TokenError extends Error { + /** Fehlernummer von Entra ID ("AADSTS7000215" → "7000215"), falls vorhanden. */ + readonly aadCode: string | null; + + constructor(message: string, aadCode: string | null = null) { + super(message); + this.name = "Ms365TokenError"; + this.aadCode = aadCode; + } +} + +export interface Ms365TokenProvider { + /** Liefert ein gueltiges Zugriffstoken; `forceRefresh` holt ein neues (nach einer Ablehnung durch den Mailserver). */ + getToken(forceRefresh?: boolean): Promise; +} + +interface ProviderOptions { + fetchImpl?: typeof fetch; + now?: () => number; +} + +const SCOPE = "https://outlook.office365.com/.default"; +/** Token 5 Minuten vor Ablauf erneuern — ein laufender Versand soll nie mit einem gerade abgelaufenen Token starten. */ +const EXPIRY_MARGIN_MS = 5 * 60 * 1000; +const REQUEST_TIMEOUT_MS = 15_000; +const GUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const DOMAIN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$/i; + +function redact(text: string, secret: string): string { + return secret ? text.split(secret).join("***") : text; +} + +export function createMs365TokenProvider(config: Ms365OAuthConfig, options: ProviderOptions = {}): Ms365TokenProvider { + // Die Tenant-ID landet im URL-Pfad: nur GUID oder Domainname, sonst + // koennte ein manipulierter Wert die Anfrage an einen anderen Endpunkt lenken. + if (!GUID.test(config.tenantId) && !DOMAIN.test(config.tenantId)) { + throw new Ms365TokenError("SMTP_OAUTH_TENANT_ID ist weder eine Tenant-GUID noch ein Domainname."); + } + const fetchImpl = options.fetchImpl ?? fetch; + const now = options.now ?? Date.now; + const url = `https://login.microsoftonline.com/${config.tenantId}/oauth2/v2.0/token`; + + let cached: { token: string; refreshAt: number } | null = null; + let inFlight: Promise | null = null; + + async function requestToken(): Promise { + const body = new URLSearchParams({ + grant_type: "client_credentials", + client_id: config.clientId, + client_secret: config.clientSecret, + scope: SCOPE, + }); + let response: Response; + try { + response = await fetchImpl(url, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: body.toString(), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : "Unbekannter Fehler"; + throw new Ms365TokenError(`Entra ID nicht erreichbar: ${redact(message, config.clientSecret)}`); + } + + const payload: unknown = await response.json().catch(() => null); + const data = (payload && typeof payload === "object" ? payload : {}) as Record; + if (!response.ok || typeof data.access_token !== "string") { + const description = typeof data.error_description === "string" ? data.error_description : `HTTP ${response.status}`; + const aadCode = /AADSTS(\d+)/.exec(description)?.[1] ?? null; + // Nur die erste Zeile: Entra haengt Trace- und Korrelations-IDs an. + throw new Ms365TokenError(`Token-Abruf abgelehnt: ${redact(description.split(/\r?\n/)[0], config.clientSecret)}`, aadCode); + } + + const expiresInMs = (typeof data.expires_in === "number" ? data.expires_in : 3600) * 1000; + cached = { token: data.access_token, refreshAt: now() + expiresInMs - EXPIRY_MARGIN_MS }; + return data.access_token; + } + + return { + async getToken(forceRefresh = false): Promise { + if (!forceRefresh && cached && now() < cached.refreshAt) return cached.token; + // Parallele Mails teilen sich einen Abruf, statt Entra gleichzeitig mehrfach zu fragen. + if (!inFlight) { + inFlight = requestToken().finally(() => { + inFlight = null; + }); + } + return inFlight; + }, + }; +} diff --git a/src/lib/smtp-diagnose.test.ts b/src/lib/smtp-diagnose.test.ts index efb4256..e71aeef 100644 --- a/src/lib/smtp-diagnose.test.ts +++ b/src/lib/smtp-diagnose.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { explainSmtpError } from "./smtp-diagnose"; +import { Ms365TokenError } from "./ms365-oauth"; function smtpError(message: string, extra: Record = {}): Error { return Object.assign(new Error(message), extra); @@ -55,9 +56,54 @@ describe("explainSmtpError", () => { }); it("never echoes a password that appears in the error", () => { - const result = explainSmtpError(smtpError("535 5.7.3 Authentication unsuccessful"), "geheim123"); + const result = explainSmtpError(smtpError("535 5.7.3 Authentication unsuccessful"), { secrets: ["geheim123"] }); expect(JSON.stringify(result)).not.toContain("geheim123"); - const leaking = explainSmtpError(smtpError("AUTH PLAIN failed for secret geheim123"), "geheim123"); + const leaking = explainSmtpError(smtpError("AUTH PLAIN failed for secret geheim123"), { secrets: ["geheim123"] }); expect(leaking.detail).not.toContain("geheim123"); }); }); + +describe("explainSmtpError with OAuth 2.0", () => { + it("explains an invalid client secret", () => { + const result = explainSmtpError(new Ms365TokenError("Token-Abruf abgelehnt: AADSTS7000215: Invalid client secret provided.", "7000215"), { + authMode: "oauth2", + }); + expect(result.cause).toBe("oauth-token"); + expect(result.hint).toContain("Wert"); + }); + + it("explains an expired client secret", () => { + const result = explainSmtpError(new Ms365TokenError("AADSTS7000222: The provided client secret keys are expired.", "7000222"), { + authMode: "oauth2", + }); + expect(result.cause).toBe("oauth-token"); + expect(result.hint).toContain("abgelaufen"); + }); + + it("explains an unknown app or tenant", () => { + expect(explainSmtpError(new Ms365TokenError("AADSTS700016: Application not found", "700016"), { authMode: "oauth2" }).hint).toContain( + "SMTP_OAUTH_CLIENT_ID" + ); + expect(explainSmtpError(new Ms365TokenError("AADSTS90002: Tenant not found", "90002"), { authMode: "oauth2" }).hint).toContain( + "SMTP_OAUTH_TENANT_ID" + ); + }); + + it("maps a rejected XOAUTH2 login to missing mailbox access", () => { + const result = explainSmtpError(smtpError("Invalid login: 535 5.7.3 Authentication unsuccessful", { code: "EAUTH" }), { authMode: "oauth2" }); + expect(result.cause).toBe("oauth-mailbox-access"); + expect(result.hint).toContain("Add-MailboxPermission"); + }); + + it("keeps SMTP AUTH disabled as its own cause in OAuth mode", () => { + expect( + explainSmtpError(smtpError("535 5.7.139 Authentication unsuccessful, SmtpClientAuthentication is disabled for the Mailbox."), { authMode: "oauth2" }).cause + ).toBe("smtp-auth-disabled"); + }); + + it("redacts the access token and client secret", () => { + const result = explainSmtpError(smtpError("AUTH XOAUTH2 eyJtoken failed secret-xyz"), { authMode: "oauth2", secrets: ["eyJtoken", "secret-xyz"] }); + expect(result.detail).not.toContain("eyJtoken"); + expect(result.detail).not.toContain("secret-xyz"); + }); +}); diff --git a/src/lib/smtp-diagnose.ts b/src/lib/smtp-diagnose.ts index fe0b55b..1488652 100644 --- a/src/lib/smtp-diagnose.ts +++ b/src/lib/smtp-diagnose.ts @@ -5,7 +5,13 @@ * nur "keine Mails kommen an". */ +import { Ms365TokenError } from "@/lib/ms365-oauth"; + +export type SmtpAuthMode = "password" | "oauth2"; + export type SmtpFailureCause = + | "oauth-token" + | "oauth-mailbox-access" | "basic-auth-retired" | "security-defaults" | "smtp-auth-disabled" @@ -24,6 +30,10 @@ export interface SmtpDiagnosis { } const HINTS: Record = { + "oauth-token": + "Microsoft Entra ID gibt kein Zugriffstoken aus. Prüfe SMTP_OAUTH_TENANT_ID, SMTP_OAUTH_CLIENT_ID und SMTP_OAUTH_CLIENT_SECRET.", + "oauth-mailbox-access": + "Das Token ist gültig, aber Exchange lässt die App nicht mit dem Postfach aus SMTP_USER senden. Prüfe in dieser Reihenfolge: (1) API-Berechtigung „Office 365 Exchange Online → SMTP.SendAsApp“ (Anwendung) mit Administratorzustimmung, (2) New-ServicePrincipal -AppId -ObjectId in Exchange Online PowerShell, (3) Add-MailboxPermission -Identity -User -AccessRights FullAccess. Nach Änderungen bis zu 30 Minuten warten.", "basic-auth-retired": "Microsoft nimmt für dieses Postfach keine Anmeldung per Passwort (Basic Auth) mehr an. Der Versand muss auf OAuth 2.0 umgestellt werden.", "security-defaults": @@ -42,6 +52,18 @@ const HINTS: Record = { unknown: "Unbekannter Fehler. Die Originalmeldung des Mailservers steht unten.", }; +/** Konkrete Anweisung zu den haeufigsten Entra-Fehlernummern beim Token-Abruf. */ +const AAD_HINTS: Record = { + "7000215": + "Das Client-Secret ist falsch. In SMTP_OAUTH_CLIENT_SECRET gehört der „Wert“ des geheimen Clientschlüssels, nicht die „Geheimnis-ID“. Der Wert wird nur direkt nach dem Anlegen angezeigt — sonst einen neuen Schlüssel erstellen.", + "7000222": + "Das Client-Secret ist abgelaufen. Entra ID → App-Registrierungen → Zertifikate & Geheimnisse → neuen geheimen Clientschlüssel anlegen, SMTP_OAUTH_CLIENT_SECRET im Kubernetes-Secret ersetzen, Pod neu starten. Ablaufdatum notieren.", + "700016": + "Die App-Registrierung wurde in diesem Tenant nicht gefunden. Prüfe SMTP_OAUTH_CLIENT_ID (Anwendungs-ID/Client-ID) und ob SMTP_OAUTH_TENANT_ID zum selben Tenant gehört.", + "90002": "Der Tenant existiert nicht. Prüfe SMTP_OAUTH_TENANT_ID (Verzeichnis-ID/Mandanten-ID oder z. B. wabenhain.onmicrosoft.com).", + "900023": "Die Tenant-Angabe ist ungültig. Prüfe SMTP_OAUTH_TENANT_ID (Verzeichnis-ID/Mandanten-ID).", +}; + const MAX_DETAIL_LENGTH = 300; function readError(error: unknown): { message: string; code: string } { @@ -52,14 +74,19 @@ function readError(error: unknown): { message: string; code: string } { return { message: typeof error === "string" ? error : "", code: "" }; } -function classify(message: string, code: string): SmtpFailureCause { +function classify(message: string, code: string, authMode: SmtpAuthMode, isTokenError: boolean): SmtpFailureCause { + if (isTokenError) return "oauth-token"; const text = message.toLowerCase(); if (text.includes("5.7.30") || text.includes("basic authentication is not supported")) return "basic-auth-retired"; if (text.includes("security defaults")) return "security-defaults"; if (text.includes("smtpclientauthentication is disabled")) return "smtp-auth-disabled"; if (text.includes("did not meet the criteria")) return "mfa-or-conditional-access"; if (text.includes("sendasdenied") || text.includes("5.2.252") || text.includes("5.7.60")) return "send-as-denied"; - if (code === "EAUTH" || text.includes("535") || text.includes("authentication unsuccessful")) return "wrong-credentials"; + if (code === "EAUTH" || text.includes("535") || text.includes("authentication unsuccessful")) { + // Mit OAuth gibt es kein Passwort, das falsch sein koennte: ein abgelehntes + // XOAUTH2-Token heisst fast immer fehlender Postfachzugriff der App. + return authMode === "oauth2" ? "oauth-mailbox-access" : "wrong-credentials"; + } if (["ETIMEDOUT", "ECONNREFUSED", "ECONNECTION", "EDNS", "ENOTFOUND", "ESOCKET", "ECONNRESET"].includes(code) || /etimedout|econnrefused|enotfound/.test(text)) { return "network"; } @@ -67,9 +94,17 @@ function classify(message: string, code: string): SmtpFailureCause { return "unknown"; } -export function explainSmtpError(error: unknown, secret?: string): SmtpDiagnosis { +interface ExplainOptions { + /** Werte, die nie in der Ausgabe erscheinen duerfen (Passwort, Client-Secret, Token). */ + secrets?: (string | undefined)[]; + authMode?: SmtpAuthMode; +} + +export function explainSmtpError(error: unknown, options: ExplainOptions = {}): SmtpDiagnosis { const { message, code } = readError(error); - const cause = classify(message, code); - const redacted = secret ? message.split(secret).join("***") : message; - return { cause, hint: HINTS[cause], detail: redacted.slice(0, MAX_DETAIL_LENGTH) }; + const isTokenError = error instanceof Ms365TokenError; + const cause = classify(message, code, options.authMode ?? "password", isTokenError); + const aadHint = isTokenError && error.aadCode ? AAD_HINTS[error.aadCode] : undefined; + const redacted = (options.secrets ?? []).reduce((text, secret) => (secret ? text.split(secret).join("***") : text), message); + return { cause, hint: aadHint ?? HINTS[cause], detail: redacted.slice(0, MAX_DETAIL_LENGTH) }; }