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