From 11910dd6dd224bbfa92e324e038885423bf0507c Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Sat, 26 Sep 2026 19:23:05 +0200 Subject: [PATCH] refactor(mail): send via the SSO app registration, drop SMTP basic auth Mail now uses the same Entra ID app registration as "Sign in with Microsoft" (ENTRA_TENANT_ID, ENTRA_CLIENT_ID, ENTRA_CLIENT_SECRET), so only one enterprise application is needed. The app additionally gets the SMTP.SendAsApp permission; Exchange still limits it to the mailbox granted via Add-MailboxPermission. Password (basic auth) sending is removed entirely: SMTP_HOST, SMTP_PORT, SMTP_PASSWORD and the separate SMTP_OAUTH_* settings are gone. The transport is fixed to smtp.office365.com:587 with required STARTTLS. SMTP_USER (sending mailbox) and SMTP_FROM remain. - lib/entra.ts exposes readEntraAppCredentials(), shared by SSO and mail - diagnosis drops basic-auth-only causes; hints name the ENTRA_* values and warn that an expired secret breaks sign-in and mail together - admin e-mail test, .env.example, docker-compose, k8s manifests, preflight and README updated Verified against Entra ID with fake values: token error classified, client secret never exposed, missing ENTRA_CLIENT_SECRET reported. Co-Authored-By: Claude Opus 5.5 (1M context) --- .env.example | 23 ++- README.md | 34 ++-- docker-compose.yml | 9 +- k8s/01-configmap.yaml | 17 +- k8s/02-secret.example.yaml | 32 ++-- scripts/preflight.ts | 10 +- src/app/admin/e-mail-test/smtp-test-panel.tsx | 2 +- src/i18n/dictionaries/de.ts | 5 +- src/i18n/dictionaries/en.ts | 5 +- src/i18n/dictionary-type.ts | 1 - src/lib/entra.ts | 30 ++- src/lib/mail.ts | 176 +++++++----------- src/lib/ms365-oauth.ts | 5 +- src/lib/smtp-diagnose.test.ts | 102 ++++------ src/lib/smtp-diagnose.ts | 69 +++---- 15 files changed, 211 insertions(+), 309 deletions(-) diff --git a/.env.example b/.env.example index 9474205..8268a81 100644 --- a/.env.example +++ b/.env.example @@ -79,16 +79,16 @@ GOOGLE_PLACE_ID= TURNSTILE_SECRET_KEY= NEXT_PUBLIC_TURNSTILE_SITE_KEY= -# SMTP fuer Transaktions-E-Mails (Bestellbestaetigung, Newsletter-Opt-in, -# Passwort-Reset). Ohne diese drei Werte werden E-Mails nur geloggt statt -# versendet — praktisch fuer lokale Entwicklung ohne Mail-Setup. -# Funktioniert mit jedem SMTP-Anbieter (z.B. das E-Mail-Postfach eures -# Domain-Hosters wie IONOS/Strato, oder ein Dienst wie Resend/Postmark im -# SMTP-Modus). -SMTP_HOST= -SMTP_PORT=587 +# Transaktions-E-Mails (Bestellbestaetigung, Newsletter-Opt-in, +# Passwort-Reset) gehen ueber Microsoft 365 mit OAuth 2.0 — mit derselben +# App-Registrierung wie "Mit Microsoft anmelden" (ENTRA_* weiter unten, dort +# zusaetzlich die Berechtigung SMTP.SendAsApp, siehe README +# "E-Mail-Versand"). Ein Passwort gibt es nicht. Ohne SMTP_USER und die +# ENTRA_*-Werte werden E-Mails nur geloggt statt versendet — praktisch fuer +# lokale Entwicklung ohne Mail-Setup. +# SMTP_USER = das Postfach, mit dem gesendet wird; SMTP_FROM sollte dieselbe +# Adresse tragen. SMTP_USER= -SMTP_PASSWORD= SMTP_FROM="Wabenhain " # ApiCore-Synchronisierung (optional). Zwei Knoepfe nutzen sie: admin/ernten @@ -187,6 +187,11 @@ DHL_ENVIRONMENT=sandbox # als Unternehmensapplikation (wird beim App-Registration-Schritt # automatisch mit angelegt) — dort laesst sich optional der Nutzerkreis # (z.B. nur bestimmte Gruppen) einschraenken. +# 6. Dieselbe App versendet auch die Shop-E-Mails (SMTP_USER oben): dafuer +# die Anwendungsberechtigung "Office 365 Exchange Online -> SMTP.SendAsApp" +# mit Administratorzustimmung ergaenzen und in Exchange das Postfach +# freigeben (README "E-Mail-Versand"). Laeuft das Secret ab, sind +# Anmeldung UND Mailversand betroffen. ENTRA_TENANT_ID= ENTRA_CLIENT_ID= ENTRA_CLIENT_SECRET= diff --git a/README.md b/README.md index 5e98032..fc94e27 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ Regionale Imkerei-Website und Shop für Wabenhain (Altenmünster) — MVP-Stufe. - **Kundenkonto** unter `/konto`: Registrierung mit **Pflicht-E-Mail-Verifizierung** (siehe „Sicherheit" unten) und Login (Passwort-Hash + signiertes Session-Cookie), optionale **Zwei-Faktor-Authentifizierung** (TOTP-Authenticator-App + Backup-Codes, `/konto/zwei-faktor`), Konto-Sperre nach Fehlversuchen, "Alle Geräte abmelden", "Passwort vergessen"-Link mit E-Mail-Reset, Bestellhistorie mit Versand-/Abholstatus, "Bestellung erneut auslösen", Adressverwaltung (mehrere Adressen, Standardadresse), **Wunschliste**, Rechnungsansicht als HTML und PDF-Download (`/rechnung/[nummer]`), Newsletter-Verwaltung mit Double-Opt-In - **Admin-Backend** unter `/admin` (Zugriff über das normale Kundenkonto-Login + Rolle `ADMIN`, siehe „Admin-Zugang" unten): Dashboard mit Kennzahlen, Top-Produkten, Umsatz nach Kategorie, **Umsatzverlauf der letzten 14 Tage**, **Stammkund:innen-Zähler** und **meistgenutzten Gutscheinen**, Bestellliste mit Detailseite (`/admin/bestellungen/[id]`: vollständige Adresse, Artikel, Zahlung, Rabatt, Rechnungs-PDF) inkl. Status-Änderung (setzt automatisch Versand-/Abholdatum **und verschickt eine Versand-/Abholbereit-E-Mail**), Produkte/Ernten/Chargen jeweils mit Anlegen- und Bearbeiten-Formularen, Firmenanfragen-Inbox mit Statuspflege, **Benutzerverwaltung** (`/admin/benutzer`, siehe „Admin-Zugang" unten), **Versandkosten-Verwaltung** (`/admin/versand`, siehe „Versandkosten" unten), **Gutschein-Verwaltung** (`/admin/gutscheine`), **Bewertungsmoderation** (`/admin/bewertungen`: freischalten oder löschen), **Audit-Log** (`/admin/audit-log`, siehe „Sicherheit" unten) - **ApiCore-Synchronisierung**: Ernten, Chargen und der Produktkatalog lassen sich per Knopfdruck aus dem Imkerei-Verwaltungssystem ApiCore übernehmen, statt dieselben Daten zweimal einzutippen — jeweils in einen Entwurfszustand, den erst ein Mensch freigibt, und ohne je etwas zu überschreiben, was im Admin bewusst gesetzt wurde (siehe „ApiCore-Synchronisierung" unten) -- **Transaktions-E-Mails** (Bestellbestätigung, Versand-/Abholbereit-Benachrichtigung, E-Mail-Verifizierung, Newsletter-Opt-in, Passwort-Reset) über SMTP, siehe Abschnitt „E-Mail-Versand" unten +- **Transaktions-E-Mails** (Bestellbestätigung, Versand-/Abholbereit-Benachrichtigung, E-Mail-Verifizierung, Newsletter-Opt-in, Passwort-Reset) über Microsoft 365 mit OAuth 2.0 (dieselbe App wie die Microsoft-Anmeldung), siehe Abschnitt „E-Mail-Versand" unten - `sitemap.xml`, `robots.txt`, OpenGraph-Metadaten, Favicon (`app/icon.svg`) mit dem echten Wabenhain-Logo - **Barrierefreiheit (WCAG AA)**: die Honigfarbe gibt es jetzt als zwei kontraststarke Varianten (`text-honey-dark` für helle, `text-honey-light` für dunkle Abschnitte wie `bg-forest`) statt eines einzelnen Tons, der auf keinem der beiden Hintergründe zuverlässig 4.5:1 erreichte; dieselbe Kontrastprüfung wurde site-weit für alle `text-ink/NN`-Texttöne nachgezogen (Admin-Bereich, Kundenkonto, Checkout, Rechnungen, Produktkarten, Geschmacksskala — 190+ Stellen in 50 Dateien) sowie für die Bildergalerie auf der Produktseite korrigiert (Vorschaubild-Buttons hatten einen `aria-label`, der den sichtbaren Bildtext überschrieben statt eingeschlossen hat). Lighthouse-Accessibility-Audit steht jetzt bei 100/100 ohne offene Verstöße auf Start-, Shop- und Produktseite. - **Performance**: GSAP (inkl. ScrollTrigger-Plugin) wurde als Animationsbibliothek entfernt und durch `motion/react` ersetzt, das ohnehin schon für andere UI-Animationen geladen wird — spart ein komplettes zweites Animations-Framework (~45 KB gzip) ohne sichtbaren Unterschied im Scroll-Reveal- oder Hero-Effekt. Core-Web-Vitals-Messung (LCP/CLS) zeigt keine Auffälligkeiten. @@ -258,34 +258,28 @@ Die Antwort wird eine Stunde zwischengespeichert (`revalidate: 3600`), was die K ## E-Mail-Versand -Bestellbestätigung, Versand-/Abholbereit-Benachrichtigung, E-Mail-Verifizierung, Newsletter-Opt-in und Passwort-Reset werden über SMTP versendet (`src/lib/mail.ts`, `nodemailer`). Ohne `SMTP_HOST`/`SMTP_USER`/`SMTP_PASSWORD` in `.env` wird nicht gesendet, sondern nur geloggt — praktisch für lokale Entwicklung, aber vor Live-Gang unbedingt konfigurieren. +Bestellbestätigung, Versand-/Abholbereit-Benachrichtigung, E-Mail-Verifizierung, Newsletter-Opt-in und Passwort-Reset gehen über **Microsoft 365 mit OAuth 2.0** (`src/lib/mail.ts`, `nodemailer`, SMTP mit XOAUTH2 an `smtp.office365.com:587`). Als App dient **dieselbe App-Registrierung wie „Mit Microsoft anmelden“** (`ENTRA_TENANT_ID`, `ENTRA_CLIENT_ID`, `ENTRA_CLIENT_SECRET`) — eine Unternehmensanwendung statt zwei. Eine Anmeldung per Passwort (Basic Auth) gibt es nicht: kein Benutzerpasswort im Cluster, kein Konflikt mit MFA oder Sicherheitsstandards, nicht betroffen vom Ende der Basic Auth bei Exchange Online. Ohne `SMTP_USER` und die `ENTRA_*`-Werte wird nicht gesendet, sondern nur geloggt — praktisch für lokale Entwicklung, aber vor Live-Gang unbedingt einrichten. -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**: Token, Anmeldung, Test-Mail an die eigene Admin-Adresse und bei einem Fehler die Meldung von Microsoft samt Ursache. -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`. +**Einrichtung** (einmalig; die SSO-App-Registrierung aus `.env.example` muss schon bestehen): 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): +2. **Berechtigung an der SSO-App:** Entra Admin Center → App-Registrierungen → die SSO-App → API-Berechtigungen → Berechtigung hinzufügen → „Von meiner Organisation verwendete APIs“ → **Office 365 Exchange Online** → Anwendungsberechtigungen → **SMTP.SendAsApp** → danach „Administratorzustimmung erteilen“. +3. **Objekt-ID der Unternehmensanwendung:** Entra → Unternehmensanwendungen → die SSO-App → **Objekt-ID** (nicht die der App-Registrierung). +4. **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 + New-ServicePrincipal -AppId -ObjectId -DisplayName "Wabenhain Shop" + 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. +5. **Kubernetes:** `SMTP_USER` im Secret setzen; `SMTP_HOST`, `SMTP_PORT` und `SMTP_PASSWORD` aus bestehenden Secrets/ConfigMaps entfernen. Danach `kubectl rollout restart deploy/wabenhain-app -n wabenhain` und **Admin → E-Mail-Test**. +6. **Aufräumen:** Das bisher für SMTP genutzte Benutzerpasswort ändern; „Authentifiziertes SMTP“ bleibt nur für das Absender-Postfach an. -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. +**Sicherheit:** Das SSO-Client-Secret darf damit auch senden — Exchange beschränkt das aber auf das eine freigegebene Postfach. Läuft das Secret ab, fallen **Anmeldung und Mailversand gleichzeitig** aus; der E-Mail-Test meldet dann „Das Client-Secret ist abgelaufen“ (AADSTS7000222): neuen Schlüssel anlegen, `ENTRA_CLIENT_SECRET` ersetzen, Pod neu starten. Ablaufdatum im Kalender eintragen. ## CAPTCHA (Cloudflare Turnstile) @@ -363,7 +357,7 @@ Die App läuft danach hinter [Caddy](https://caddyserver.com/) unter `http://loc pnpm preflight ``` -**Geprüft wird die Umgebung, in der der Befehl läuft — nicht „das Projekt".** Auf einem Entwicklungsrechner liest er die lokale `.env`, und die enthält absichtlich einen `sk_test`-Schlüssel, kein SMTP und keine echte Domain. Er meldet dort also Blocker, die für den Live-Betrieb längst erledigt sind: `NEXT_PUBLIC_SITE_URL`, `SMTP_FROM` und die Steuersätze stehen in `k8s/01-configmap.yaml`, die Geheimnisse in `k8s/02-secret.yaml`. Wer das ein paarmal erlebt, gewöhnt sich an, über den Bericht hinwegzulesen — und übersieht dann den einen Blocker, der echt ist. +**Geprüft wird die Umgebung, in der der Befehl läuft — nicht „das Projekt".** Auf einem Entwicklungsrechner liest er die lokale `.env`, und die enthält absichtlich einen `sk_test`-Schlüssel, keinen Mailversand und keine echte Domain. Er meldet dort also Blocker, die für den Live-Betrieb längst erledigt sind: `NEXT_PUBLIC_SITE_URL`, `SMTP_FROM` und die Steuersätze stehen in `k8s/01-configmap.yaml`, die Geheimnisse in `k8s/02-secret.yaml`. Wer das ein paarmal erlebt, gewöhnt sich an, über den Bericht hinwegzulesen — und übersieht dann den einen Blocker, der echt ist. Für eine belastbare Aussage deshalb **im laufenden Pod** ausführen, wo ConfigMap und Secret tatsächlich anliegen: @@ -378,7 +372,7 @@ Prüft die Konfiguration gegen alles, was einen öffentlichen Shop still unbrauc - `SESSION_SECRET`, `ADMIN_EMAIL` und `ADMIN_PASSWORD` auf Länge und darauf, dass noch der Beispielwert aus `.env.example` darin steht (ein bekanntes Signiergeheimnis macht jede Sitzung fälschbar). - `NEXT_PUBLIC_SITE_URL` auf HTTPS und darauf, dass dort nicht `localhost` steht — genau dieser Wert landet in Bestellbestätigungen, in `sitemap.xml` und in allen Canonical-Tags. - Stripe: Schlüssel und Webhook-Secret vorhanden, und ob versehentlich noch ein `sk_test`-Schlüssel gesetzt ist. Ohne Webhook-Secret wird keine Zahlung bestätigt: Bestellungen bleiben auf „NEU" stehen, ohne Rechnung und ohne Bestätigungsmail. -- SMTP vollständig gesetzt — fehlt es, werden E-Mails nur ins Server-Log geschrieben statt versendet (siehe `lib/mail.ts`), also keine Bestellbestätigung und keine Passwort-Zurücksetzung. +- Mailversand eingerichtet (`SMTP_USER` + `ENTRA_*`) — fehlt es, werden E-Mails nur ins Server-Log geschrieben statt versendet (siehe `lib/mail.ts`), also keine Bestellbestätigung und keine Passwort-Zurücksetzung. - Rechtstexte (Impressum, Datenschutz, AGB, Widerrufsbelehrung) und Telefonnummer auf verbliebene `[PLATZHALTER]`/`[PLACEHOLDER]`-Markierungen — je Sprachfassung einzeln benannt. Ein unvollständiges Impressum nach § 5 TMG ist in Deutschland abmahnfähig. Diese Prüfung fragt die **Datenbank**, nicht den Quelltext: all diese Texte sind im Admin-Bereich unter „Inhalte" pflegbar (siehe `lib/content-registry.ts`), die Fassungen in `src/i18n/dictionaries/` sind nur der Rückfallwert, solange dort nichts gepflegt ist. Zum Ausfüllen des Impressums ist also **kein Deploy nötig** — und eine Prüfung, die nur in den Quelltext schaut, würde einen im Admin längst geschlossenen Blocker weiter melden (und umgekehrt schweigen, wenn jemand einen Platzhaltertext erst im Admin einträgt). diff --git a/docker-compose.yml b/docker-compose.yml index af83f48..8615091 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -120,11 +120,14 @@ services: SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET muss in .env gesetzt sein} GOOGLE_PLACES_API_KEY: ${GOOGLE_PLACES_API_KEY} GOOGLE_PLACE_ID: ${GOOGLE_PLACE_ID} - SMTP_HOST: ${SMTP_HOST} - SMTP_PORT: ${SMTP_PORT:-587} + # Mailversand ueber Microsoft 365 mit OAuth 2.0 — dieselbe + # App-Registrierung wie "Mit Microsoft anmelden" (siehe .env.example). SMTP_USER: ${SMTP_USER} - SMTP_PASSWORD: ${SMTP_PASSWORD} SMTP_FROM: ${SMTP_FROM:-Wabenhain } + ENTRA_TENANT_ID: ${ENTRA_TENANT_ID} + ENTRA_CLIENT_ID: ${ENTRA_CLIENT_ID} + ENTRA_CLIENT_SECRET: ${ENTRA_CLIENT_SECRET} + ENTRA_REDIRECT_URI: ${ENTRA_REDIRECT_URI} NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-http://localhost:3000} NODE_ENV: production diff --git a/k8s/01-configmap.yaml b/k8s/01-configmap.yaml index da72632..a77c940 100644 --- a/k8s/01-configmap.yaml +++ b/k8s/01-configmap.yaml @@ -28,19 +28,10 @@ data: # Absenderadresse aller Transaktions-E-Mails. Sollte auf einer eigenen # Domain liegen, sonst landen die Mails regelmaessig im Spam-Ordner. + # Muss die Adresse des Postfachs aus SMTP_USER (Secret) sein — der Versand + # laeuft ueber Microsoft 365 mit OAuth 2.0, siehe README "E-Mail-Versand". 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 # --------------------------------------------------------------------- @@ -62,10 +53,6 @@ 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 # des "uploads"-Volumes in 05-app-deployment.yaml entsprechen — die App # weiss nicht, was dort gemountet ist, sie schreibt nur in diesen Pfad. diff --git a/k8s/02-secret.example.yaml b/k8s/02-secret.example.yaml index 21404cb..cd579da 100644 --- a/k8s/02-secret.example.yaml +++ b/k8s/02-secret.example.yaml @@ -68,27 +68,15 @@ stringData: # https://wabenhain.com/api/checkout/webhook zeigt (whsec_...). STRIPE_WEBHOOK_SECRET: "CHANGE-ME" - # Ohne SMTP werden E-Mails nur ins Server-Log geschrieben (src/lib/mail.ts): - # 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" + # Mailversand ueber Microsoft 365 mit OAuth 2.0 (src/lib/mail.ts) — mit der + # App-Registrierung der Entra-ID-SSO unten (ENTRA_TENANT_ID, + # ENTRA_CLIENT_ID, ENTRA_CLIENT_SECRET), kein Passwort. Ohne diese Werte + # und SMTP_USER werden E-Mails nur ins Server-Log geschrieben: keine + # Bestellbestaetigung, keine Versandmeldung, keine Passwort-Zuruecksetzung. + # SMTP_USER ist das Postfach, mit dem gesendet wird (z. B. + # hallo@wabenhain.com) — dieselbe Adresse wie SMTP_FROM in + # 01-configmap.yaml. Einrichtung: README "E-Mail-Versand". 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 @@ -141,6 +129,10 @@ stringData: # Entra-ID-SSO ("Mit Microsoft anmelden"), siehe .env.example fuer die # Einrichtung in Azure. Leer = der Anmeldeknopf erscheint gar nicht erst. + # Dieselbe App versendet auch die Shop-E-Mails (SMTP_USER oben, Berechtigung + # SMTP.SendAsApp) — ohne diese Werte also auch kein Mailversand. Das + # Client-Secret laeuft ab (hoechstens 24 Monate): Ablaufdatum im Kalender + # eintragen, sonst fallen Anmeldung UND Mails gleichzeitig aus. # ENTRA_REDIRECT_URI muss exakt der in Azure hinterlegten Adresse # entsprechen und auf die echte Domain zeigen: # https://wabenhain.com/api/auth/entra/callback diff --git a/scripts/preflight.ts b/scripts/preflight.ts index be6c95b..79359b2 100644 --- a/scripts/preflight.ts +++ b/scripts/preflight.ts @@ -146,13 +146,11 @@ mustWhenSet( ); // --- E-Mail ---------------------------------------------------------------- -// Zwei gueltige Wege (siehe src/lib/mail.ts): Microsoft 365 per OAuth 2.0 -// oder klassisch mit Passwort. +// Versand nur ueber Microsoft 365 mit OAuth 2.0 — mit der SSO-App- +// Registrierung (siehe src/lib/mail.ts). must( - "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"))), + "Mailversand eingerichtet (SMTP_USER + ENTRA_TENANT_ID/ENTRA_CLIENT_ID/ENTRA_CLIENT_SECRET)", + isSet("SMTP_USER") && isSet("ENTRA_TENANT_ID") && isSet("ENTRA_CLIENT_ID") && isSet("ENTRA_CLIENT_SECRET"), "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 b6e7ed7..10ca216 100644 --- a/src/app/admin/e-mail-test/smtp-test-panel.tsx +++ b/src/app/admin/e-mail-test/smtp-test-panel.tsx @@ -47,7 +47,7 @@ export function SmtpTestPanel() {

{s.configLabel}

- {state.authMode === "oauth2" ? s.authModeOAuth : s.authModePassword} · SMTP_HOST={state.host} · SMTP_USER={state.user} · SMTP_FROM={state.from} + {s.authModeOAuth} · Server {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 5b755fd..0609462 100644 --- a/src/i18n/dictionaries/de.ts +++ b/src/i18n/dictionaries/de.ts @@ -1259,7 +1259,7 @@ export const de: Dictionary = { }, adminEmailTestPage: { title: "E-Mail-Versand testen", - intro: "Baut eine Verbindung zum Mailserver auf, meldet sich mit SMTP_USER an und schickt eine Test-Mail an deine eigene Adresse. Schlägt etwas fehl, siehst du die Meldung des Mailservers und was zu tun ist.", + intro: "Holt ein Token über die App-Registrierung von „Mit Microsoft anmelden“, meldet sich bei Microsoft 365 als SMTP_USER an und schickt eine Test-Mail an deine eigene Adresse. Schlägt etwas fehl, siehst du die Meldung des Mailservers und was zu tun ist.", button: "Test-Mail senden", running: "Wird getestet …", success: (to) => `Versand funktioniert. Die Test-Mail ist an ${to} unterwegs.`, @@ -1268,8 +1268,7 @@ export const de: Dictionary = { causeLabel: "Was zu tun ist", detailLabel: "Meldung des Mailservers", configLabel: "Verwendete Einstellungen", - authModeOAuth: "OAuth 2.0 (App-Registrierung)", - authModePassword: "Passwort (Basic Auth)", + authModeOAuth: "Microsoft 365, OAuth 2.0 über die SSO-App", missingLabel: "Fehlende Einstellungen", }, adminWithdrawalsPage: { diff --git a/src/i18n/dictionaries/en.ts b/src/i18n/dictionaries/en.ts index 54233ac..b841cd4 100644 --- a/src/i18n/dictionaries/en.ts +++ b/src/i18n/dictionaries/en.ts @@ -1233,7 +1233,7 @@ export const en: Dictionary = { }, adminEmailTestPage: { title: "Test email delivery", - intro: "Connects to the mail server, signs in with SMTP_USER and sends a test email to your own address. If something fails, you see the mail server message and what to do (the explanation is in German).", + intro: "Gets a token via the “Sign in with Microsoft” app registration, signs in to Microsoft 365 as SMTP_USER and sends a test email to your own address. If something fails, you see the mail server message and what to do (the explanation is in German).", button: "Send test email", running: "Testing …", success: (to) => `Delivery works. The test email is on its way to ${to}.`, @@ -1242,8 +1242,7 @@ export const en: Dictionary = { causeLabel: "What to do", detailLabel: "Mail server message", configLabel: "Settings used", - authModeOAuth: "OAuth 2.0 (app registration)", - authModePassword: "Password (basic auth)", + authModeOAuth: "Microsoft 365, OAuth 2.0 via the SSO app", missingLabel: "Missing settings", }, adminWithdrawalsPage: { diff --git a/src/i18n/dictionary-type.ts b/src/i18n/dictionary-type.ts index ede4bd2..6176b3a 100644 --- a/src/i18n/dictionary-type.ts +++ b/src/i18n/dictionary-type.ts @@ -830,7 +830,6 @@ export interface Dictionary { detailLabel: string; configLabel: string; authModeOAuth: string; - authModePassword: string; missingLabel: string; }; adminWithdrawalsPage: { diff --git a/src/lib/entra.ts b/src/lib/entra.ts index f91a3dd..65c49ec 100644 --- a/src/lib/entra.ts +++ b/src/lib/entra.ts @@ -46,15 +46,35 @@ interface EntraConfig { redirectUri: string; } +export interface EntraAppCredentials { + tenantId: string; + clientId: string; + clientSecret: string; +} + +/** + * Zugangsdaten der EINEN App-Registrierung in Entra ID. Sie dient zwei + * Zwecken: der Anmeldung "Mit Microsoft anmelden" (dieses Modul) und dem + * Mailversand ueber Microsoft 365 per OAuth 2.0 (lib/mail.ts, Berechtigung + * SMTP.SendAsApp) — eine Unternehmensanwendung statt zwei. Beim Secret wird + * nur ein Zeilenumbruch am Ende entfernt (typischer Fehler beim Anlegen des + * Kubernetes-Secrets), sonstige Werte werden getrimmt. null, wenn etwas fehlt. + */ +export function readEntraAppCredentials(): EntraAppCredentials | null { + const tenantId = process.env.ENTRA_TENANT_ID?.trim() ?? ""; + const clientId = process.env.ENTRA_CLIENT_ID?.trim() ?? ""; + const clientSecret = process.env.ENTRA_CLIENT_SECRET?.replace(/[\r\n]+$/, "") ?? ""; + if (!tenantId || !clientId || !clientSecret) return null; + return { tenantId, clientId, clientSecret }; +} + function getEntraConfig(): EntraConfig { - const tenantId = process.env.ENTRA_TENANT_ID; - const clientId = process.env.ENTRA_CLIENT_ID; - const clientSecret = process.env.ENTRA_CLIENT_SECRET; + const credentials = readEntraAppCredentials(); const redirectUri = process.env.ENTRA_REDIRECT_URI; - if (!tenantId || !clientId || !clientSecret || !redirectUri) { + if (!credentials || !redirectUri) { throw new EntraNotConfiguredError(); } - return { tenantId, clientId, clientSecret, redirectUri }; + return { ...credentials, redirectUri }; } /** Fuer die bedingte Anzeige des "Mit Microsoft anmelden"-Buttons im Login-UI. */ diff --git a/src/lib/mail.ts b/src/lib/mail.ts index b22756a..8b39f7b 100644 --- a/src/lib/mail.ts +++ b/src/lib/mail.ts @@ -1,7 +1,8 @@ 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"; +import { explainSmtpError, type SmtpDiagnosis } from "@/lib/smtp-diagnose"; +import { createMs365TokenProvider, type Ms365TokenProvider } from "@/lib/ms365-oauth"; +import { readEntraAppCredentials, type EntraAppCredentials } from "@/lib/entra"; export interface MailAttachment { filename: string; @@ -24,21 +25,29 @@ interface SendMailInput { } /** - * 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. + * Versand ausschliesslich ueber Microsoft 365 mit OAuth 2.0 (SMTP XOAUTH2, + * Client-Credentials-Flow). Als App dient dieselbe App-Registrierung wie fuer + * "Mit Microsoft anmelden" (ENTRA_TENANT_ID, ENTRA_CLIENT_ID, + * ENTRA_CLIENT_SECRET) — eine Unternehmensanwendung statt zwei. Eine + * Anmeldung per Passwort (Basic Auth) gibt es bewusst nicht mehr: kein + * Benutzerpasswort im Cluster, kein Konflikt mit MFA/Sicherheitsstandards, + * nicht betroffen vom Ende der Basic Auth bei Exchange Online. + * + * SMTP_USER ist das Postfach, mit dem gesendet wird; Exchange erlaubt der App + * nur die Postfaecher, die ihr per Add-MailboxPermission freigegeben wurden. */ -type SmtpConfig = - | { authMode: "oauth2"; host: string; port: number; user: string; oauth: Ms365OAuthConfig } - | { authMode: "password"; host: string; port: number; user: string; pass: string }; +interface SmtpConfig { + user: string; + app: EntraAppCredentials; +} const MS365_SMTP_HOST = "smtp.office365.com"; +/** STARTTLS — der Port, den Microsoft fuer SMTP-Client-Submission vorsieht. */ +const MS365_SMTP_PORT = 587; const TEST_TIMEOUT_MS = 15_000; +type SmtpTransporter = Transporter; + /** Maskiert eine E-Mail-Adresse fuer Log-Ausgaben, z.B. "ma***@example.com". */ function maskEmail(email: string): string { const [local, domain] = email.split("@"); @@ -46,106 +55,60 @@ function maskEmail(email: string): string { return `${local.slice(0, 2)}***@${domain}`; } -/** - * 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 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. */ +/** Namen der Variablen, die fuer den Versand 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] === ""); + const missing: string[] = []; + if (!process.env.SMTP_USER?.trim()) missing.push("SMTP_USER"); + if (!process.env.ENTRA_TENANT_ID?.trim()) missing.push("ENTRA_TENANT_ID"); + if (!process.env.ENTRA_CLIENT_ID?.trim()) missing.push("ENTRA_CLIENT_ID"); + if (!process.env.ENTRA_CLIENT_SECRET?.trim()) missing.push("ENTRA_CLIENT_SECRET"); + return missing; } 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 }; + const user = process.env.SMTP_USER?.trim() ?? ""; + const app = readEntraAppCredentials(); + return user && app ? { user, app } : null; } function getFromAddress(): string { return process.env.SMTP_FROM || "Wabenhain "; } -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}`; +function getTokenProvider(app: EntraAppCredentials): Ms365TokenProvider { + const key = `${app.tenantId}|${app.clientId}|${app.clientSecret}`; if (!tokenProvider || tokenProvider.key !== key) { - tokenProvider = { key, provider: createMs365TokenProvider(oauth) }; + tokenProvider = { key, provider: createMs365TokenProvider(app) }; } 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, + host: MS365_SMTP_HOST, + port: MS365_SMTP_PORT, + secure: false, + // Die Verbindung MUSS per STARTTLS verschluesselt werden, bevor das Token + // gesendet wird — ohne requireTLS wuerde nodemailer bei einem Server ohne + // STARTTLS-Angebot (Angriff auf dem Netzweg) im Klartext weitermachen. + requireTLS: true, + auth: { + type: "OAuth2", + user: config.user, + // nodemailer fragt hier nach einem Token; renew=true nach einer + // Ablehnung durch den Server → frisches Token statt des gecachten. + // expires 0: nodemailer fragt bei jeder Anmeldung neu — das Cachen mit + // Ablaufreserve uebernimmt der Token-Provider. + provisionCallback: (_user: string, renew: boolean, callback: (err: Error | null, accessToken: string, expires: number) => void) => { + getTokenProvider(config.app) + .getToken(renew) + .then((token) => callback(null, token, 0), (error: Error) => callback(error, "", 0)); + }, + }, ...extra, }; return nodemailer.createTransport(options); @@ -153,7 +116,7 @@ function buildTransport(config: SmtpConfig, extra: { connectionTimeout?: number; 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 +// Produktion fehlendes Setup im Server-Log sichtbar, statt dass // Bestellbestaetigungen/Passwort-Resets fortlaufend lautlos verschwinden. let warnedAboutMissingSmtpInProduction = false; @@ -163,7 +126,7 @@ function getTransport(): { config: SmtpConfig; transport: SmtpTransporter } | nu if (process.env.NODE_ENV === "production" && !warnedAboutMissingSmtpInProduction) { warnedAboutMissingSmtpInProduction = true; console.warn( - `[mail] SMTP ist nicht vollstaendig eingerichtet (es fehlt: ${missingSmtpSettings().join(", ")}) — Transaktions-E-Mails werden nur geloggt, nicht versendet.` + `[mail] Mailversand nicht vollstaendig eingerichtet (es fehlt: ${missingSmtpSettings().join(", ")}) — Transaktions-E-Mails werden nur geloggt, nicht versendet.` ); } return null; @@ -173,11 +136,11 @@ function getTransport(): { config: SmtpConfig; transport: SmtpTransporter } | nu } /** - * 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. + * Versendet eine E-Mail ueber Microsoft 365. Ohne Konfiguration wird die + * Mail NICHT gesendet, sondern nur geloggt — 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 @@ -188,7 +151,7 @@ function getTransport(): { config: SmtpConfig; transport: SmtpTransporter } | nu export async function sendMail(input: SendMailInput): Promise { const active = getTransport(); if (!active) { - console.warn(`[mail] SMTP nicht konfiguriert — E-Mail an ${maskEmail(input.to)} ("${input.subject}") wurde nicht gesendet.`); + console.warn(`[mail] Mailversand nicht eingerichtet — E-Mail an ${maskEmail(input.to)} ("${input.subject}") wurde nicht gesendet.`); return false; } @@ -203,7 +166,7 @@ export async function sendMail(input: SendMailInput): Promise { }); return true; } catch (error: unknown) { - const diagnosis = explainSmtpError(error, { secrets: secretsOf(active.config), authMode: active.config.authMode }); + const diagnosis = explainSmtpError(error, { secrets: [active.config.app.clientSecret] }); console.error(`[mail] Versand an ${maskEmail(input.to)} fehlgeschlagen (${diagnosis.cause}): ${diagnosis.detail} — ${diagnosis.hint}`); return false; } @@ -213,7 +176,6 @@ interface SmtpTestBase { host: string; user: string; from: string; - authMode: SmtpAuthMode; } export type SmtpTestResult = @@ -222,16 +184,16 @@ export type SmtpTestResult = | ({ ok: false; configured: true; diagnosis: SmtpDiagnosis } & SmtpTestBase); /** - * Prueft die SMTP-Konfiguration im laufenden Betrieb: Verbindung + Anmeldung + * Prueft den Mailversand im laufenden Betrieb: Token, 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. Weder - * Passwort noch Client-Secret erscheinen im Ergebnis. + * bewusst einen frischen Transport statt des zwischengespeicherten. Das + * Client-Secret erscheint nie im Ergebnis. */ export async function testSmtpConnection(to: string): Promise { const config = readSmtpConfig(); if (!config) return { ok: false, configured: false, missing: missingSmtpSettings() }; - const base: SmtpTestBase = { host: config.host, user: config.user, from: getFromAddress(), authMode: config.authMode }; + const base: SmtpTestBase = { host: MS365_SMTP_HOST, user: config.user, from: getFromAddress() }; const transport = buildTransport(config, { connectionTimeout: TEST_TIMEOUT_MS, greetingTimeout: TEST_TIMEOUT_MS }); try { await transport.verify(); @@ -239,11 +201,11 @@ export async function testSmtpConnection(to: string): Promise { 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} (Anmeldung: ${config.authMode === "oauth2" ? "OAuth 2.0" : "Passwort"}) mit dem Absender ${base.from} senden kann.`, + text: `Diese Test-Mail bestaetigt, dass der Shop ueber Microsoft 365 (OAuth 2.0) als ${config.user} mit dem Absender ${base.from} senden kann.`, }); return { ok: true, ...base }; } catch (error: unknown) { - return { ok: false, configured: true, ...base, diagnosis: explainSmtpError(error, { secrets: secretsOf(config), authMode: config.authMode }) }; + return { ok: false, configured: true, ...base, diagnosis: explainSmtpError(error, { secrets: [config.app.clientSecret] }) }; } finally { transport.close(); } diff --git a/src/lib/ms365-oauth.ts b/src/lib/ms365-oauth.ts index 84f3984..a3c54ae 100644 --- a/src/lib/ms365-oauth.ts +++ b/src/lib/ms365-oauth.ts @@ -4,7 +4,8 @@ * 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). + * (Berechtigung SMTP.SendAsApp + Add-MailboxPermission, siehe README). Die + * Zugangsdaten stammen aus der SSO-App-Registrierung (lib/entra.ts). */ export interface Ms365OAuthConfig { @@ -49,7 +50,7 @@ export function createMs365TokenProvider(config: Ms365OAuthConfig, options: Prov // 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."); + throw new Ms365TokenError("ENTRA_TENANT_ID ist weder eine Tenant-GUID noch ein Domainname."); } const fetchImpl = options.fetchImpl ?? fetch; const now = options.now ?? Date.now; diff --git a/src/lib/smtp-diagnose.test.ts b/src/lib/smtp-diagnose.test.ts index e71aeef..7c3ae9c 100644 --- a/src/lib/smtp-diagnose.test.ts +++ b/src/lib/smtp-diagnose.test.ts @@ -7,12 +7,36 @@ function smtpError(message: string, extra: Record = {}): Error } describe("explainSmtpError", () => { - it("recognises security defaults blocking basic auth", () => { - const result = explainSmtpError( - smtpError("Invalid login: 535 5.7.139 Authentication unsuccessful, user is locked by your organization's security defaults policy.", { code: "EAUTH", responseCode: 535 }) - ); - expect(result.cause).toBe("security-defaults"); - expect(result.hint).toContain("Sicherheitsstandards"); + it("explains a client secret ID used instead of its value", () => { + const result = explainSmtpError(new Ms365TokenError("Token-Abruf abgelehnt: AADSTS7000215: Invalid client secret provided.", "7000215")); + expect(result.cause).toBe("oauth-token"); + expect(result.hint).toContain("ENTRA_CLIENT_SECRET"); + expect(result.hint).toContain("Wert"); + }); + + it("explains an expired client secret and that SSO is affected too", () => { + const result = explainSmtpError(new Ms365TokenError("AADSTS7000222: The provided client secret keys are expired.", "7000222")); + expect(result.cause).toBe("oauth-token"); + expect(result.hint).toContain("abgelaufen"); + expect(result.hint).toContain("Mit Microsoft anmelden"); + }); + + it("explains an unknown app or tenant", () => { + expect(explainSmtpError(new Ms365TokenError("AADSTS700016: Application not found", "700016")).hint).toContain("ENTRA_CLIENT_ID"); + expect(explainSmtpError(new Ms365TokenError("AADSTS90002: Tenant not found", "90002")).hint).toContain("ENTRA_TENANT_ID"); + }); + + it("falls back to a general token hint for other AADSTS codes", () => { + const result = explainSmtpError(new Ms365TokenError("AADSTS1234: something", "1234")); + expect(result.cause).toBe("oauth-token"); + expect(result.hint).toContain("ENTRA_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" })); + expect(result.cause).toBe("oauth-mailbox-access"); + expect(result.hint).toContain("SMTP.SendAsApp"); + expect(result.hint).toContain("Add-MailboxPermission"); }); it("recognises SMTP AUTH disabled for the tenant or mailbox", () => { @@ -24,20 +48,6 @@ describe("explainSmtpError", () => { ); }); - it("recognises MFA or conditional access rejections", () => { - expect( - explainSmtpError(smtpError("535 5.7.139 Authentication unsuccessful, the request did not meet the criteria to be authenticated successfully.")).cause - ).toBe("mfa-or-conditional-access"); - }); - - it("recognises wrong credentials", () => { - expect(explainSmtpError(smtpError("Invalid login: 535 5.7.3 Authentication unsuccessful", { code: "EAUTH" })).cause).toBe("wrong-credentials"); - }); - - it("recognises the basic auth retirement", () => { - expect(explainSmtpError(smtpError("550 5.7.30 Basic authentication is not supported for Client Submission.")).cause).toBe("basic-auth-retired"); - }); - it("recognises a sender address the mailbox may not use", () => { expect( explainSmtpError(smtpError("Message failed: 554 5.2.252 SendAsDenied; info@wabenhain.com not allowed to send as hallo@wabenhain.com")).cause @@ -47,7 +57,7 @@ describe("explainSmtpError", () => { it("recognises network problems", () => { expect(explainSmtpError(smtpError("connect ETIMEDOUT 52.97.1.1:587", { code: "ETIMEDOUT" })).cause).toBe("network"); - expect(explainSmtpError(smtpError("getaddrinfo ENOTFOUND smtp.office356.com", { code: "EDNS" })).cause).toBe("network"); + expect(explainSmtpError(smtpError("getaddrinfo ENOTFOUND smtp.office365.com", { code: "EDNS" })).cause).toBe("network"); }); it("falls back to unknown and never throws on odd input", () => { @@ -55,54 +65,8 @@ describe("explainSmtpError", () => { expect(explainSmtpError(null).cause).toBe("unknown"); }); - it("never echoes a password that appears in the error", () => { - 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"), { 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"] }); + it("redacts secrets from the echoed server message", () => { + const result = explainSmtpError(smtpError("AUTH XOAUTH2 eyJtoken failed secret-xyz"), { 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 1488652..6a1534e 100644 --- a/src/lib/smtp-diagnose.ts +++ b/src/lib/smtp-diagnose.ts @@ -1,23 +1,17 @@ /** - * Uebersetzt SMTP-Fehler (vor allem von Microsoft 365 / Exchange Online) in - * eine konkrete Ursache mit deutscher Handlungsanweisung. Der Versand selbst - * verschluckt Fehler bewusst (lib/mail.ts) — ohne diese Einordnung sieht man - * nur "keine Mails kommen an". + * Uebersetzt Fehler beim Mailversand ueber Microsoft 365 (OAuth 2.0, siehe + * lib/mail.ts) in eine konkrete Ursache mit deutscher Handlungsanweisung. + * Der Versand selbst verschluckt Fehler bewusst — ohne diese Einordnung sieht + * man 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" - | "mfa-or-conditional-access" | "send-as-denied" - | "wrong-credentials" | "network" | "tls" | "unknown"; @@ -25,43 +19,35 @@ export type SmtpFailureCause = export interface SmtpDiagnosis { cause: SmtpFailureCause; hint: string; - /** Originalmeldung des Servers, gekuerzt und ohne Passwort. */ + /** Originalmeldung des Servers, gekuerzt und ohne Geheimnisse. */ detail: string; } 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.", + "Microsoft Entra ID gibt kein Zugriffstoken aus. Prüfe ENTRA_TENANT_ID, ENTRA_CLIENT_ID und ENTRA_CLIENT_SECRET — dieselben Werte wie für „Mit Microsoft anmelden“.", "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": - "Die Sicherheitsstandards (Security Defaults) in Microsoft Entra ID blockieren die Anmeldung per Passwort — unabhängig von der Exchange-Einstellung. Entra Admin Center → Identität → Übersicht → Eigenschaften → „Sicherheitsstandards verwalten“. Sicherer als das Abschalten: auf OAuth 2.0 umstellen.", + "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 an der SSO-App-Registrierung, (2) New-ServicePrincipal -AppId -ObjectId in Exchange Online PowerShell, (3) Add-MailboxPermission -Identity -User -AccessRights FullAccess. Nach Änderungen bis zu 30 Minuten warten.", "smtp-auth-disabled": - "„Authentifiziertes SMTP“ ist für den Tenant oder das Postfach ausgeschaltet. Die Postfach-Einstellung hat Vorrang: Microsoft 365 Admin Center → Benutzer → Aktive Benutzer → Postfach-Benutzer → E-Mail → „E-Mail-Apps verwalten“ → „Authentifiziertes SMTP“ aktivieren. Änderungen können bis zu einer Stunde dauern.", - "mfa-or-conditional-access": - "Die Anmeldung erfüllt eine Richtlinie nicht — meist Multi-Faktor-Authentifizierung auf dem Konto oder eine Richtlinie für bedingten Zugriff, die Legacy-Authentifizierung blockiert. Ein Konto mit MFA kann sich nicht per SMTP-Passwort anmelden.", + "„Authentifiziertes SMTP“ ist für den Tenant oder das Postfach ausgeschaltet — auch OAuth 2.0 läuft darüber. Exchange Online PowerShell: Set-CASMailbox -Identity -SmtpClientAuthenticationDisabled $false. Die Postfach-Einstellung hat Vorrang vor der des Tenants; Änderungen können bis zu einer Stunde dauern.", "send-as-denied": - "Das angemeldete Postfach darf nicht mit der Absenderadresse (SMTP_FROM) senden. SMTP_FROM muss die Adresse des Postfachs aus SMTP_USER sein, oder das Postfach braucht die Berechtigung „Senden als“ für diese Adresse. Für einen Alias: Set-OrganizationConfig -SendFromAliasEnabled $true.", - "wrong-credentials": - "Microsoft lehnt Benutzername oder Passwort ab. Prüfe SMTP_USER (vollständige Adresse) und SMTP_PASSWORD im Kubernetes-Secret — ein Zeilenumbruch am Ende (z. B. durch echo ohne -n beim base64-Kodieren) oder ein abgelaufenes Passwort reichen aus.", + "Die App darf nicht mit der Absenderadresse (SMTP_FROM) senden. SMTP_FROM muss die Adresse des Postfachs aus SMTP_USER sein, oder das Postfach braucht die Berechtigung „Senden als“ für diese Adresse. Für einen Alias: Set-OrganizationConfig -SendFromAliasEnabled $true.", network: - "Der Mailserver ist nicht erreichbar. Prüfe SMTP_HOST (smtp.office365.com), SMTP_PORT (587) und ob der Cluster ausgehende Verbindungen auf Port 587 erlaubt (Firewall, NetworkPolicy).", - tls: "Die verschlüsselte Verbindung zum Mailserver ist fehlgeschlagen. Prüfe SMTP_HOST und SMTP_PORT (587 mit STARTTLS, 465 mit direktem TLS).", + "smtp.office365.com ist nicht erreichbar. Prüfe, ob der Cluster ausgehende Verbindungen auf Port 587 und zu login.microsoftonline.com erlaubt (Firewall, NetworkPolicy).", + tls: "Die verschlüsselte Verbindung zu smtp.office365.com ist fehlgeschlagen (STARTTLS auf Port 587). Prüfe Proxy oder TLS-Inspektion auf dem Netzweg.", 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.", + "Das Client-Secret ist falsch. In ENTRA_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.", + "Das Client-Secret ist abgelaufen — das betrifft auch „Mit Microsoft anmelden“. Entra ID → App-Registrierungen → Zertifikate & Geheimnisse → neuen geheimen Clientschlüssel anlegen, ENTRA_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).", + "Die App-Registrierung wurde in diesem Tenant nicht gefunden. Prüfe ENTRA_CLIENT_ID (Anwendungs-ID/Client-ID) und ob ENTRA_TENANT_ID zum selben Tenant gehört.", + "90002": "Der Tenant existiert nicht. Prüfe ENTRA_TENANT_ID (Verzeichnis-ID/Mandanten-ID).", + "900023": "Die Tenant-Angabe ist ungültig. Prüfe ENTRA_TENANT_ID (Verzeichnis-ID/Mandanten-ID).", }; const MAX_DETAIL_LENGTH = 300; @@ -74,19 +60,14 @@ function readError(error: unknown): { message: string; code: string } { return { message: typeof error === "string" ? error : "", code: "" }; } -function classify(message: string, code: string, authMode: SmtpAuthMode, isTokenError: boolean): SmtpFailureCause { - if (isTokenError) return "oauth-token"; +function classify(error: unknown, message: string, code: string): SmtpFailureCause { + if (error instanceof Ms365TokenError) 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")) { - // 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"; - } + // Mit OAuth gibt es kein Passwort, das falsch sein koennte: ein abgelehntes + // XOAUTH2-Token heisst fast immer fehlender Postfachzugriff der App. + if (code === "EAUTH" || text.includes("535") || text.includes("authentication unsuccessful")) return "oauth-mailbox-access"; if (["ETIMEDOUT", "ECONNREFUSED", "ECONNECTION", "EDNS", "ENOTFOUND", "ESOCKET", "ECONNRESET"].includes(code) || /etimedout|econnrefused|enotfound/.test(text)) { return "network"; } @@ -95,16 +76,14 @@ function classify(message: string, code: string, authMode: SmtpAuthMode, isToken } interface ExplainOptions { - /** Werte, die nie in der Ausgabe erscheinen duerfen (Passwort, Client-Secret, Token). */ + /** Werte, die nie in der Ausgabe erscheinen duerfen (Client-Secret, Token). */ secrets?: (string | undefined)[]; - authMode?: SmtpAuthMode; } export function explainSmtpError(error: unknown, options: ExplainOptions = {}): SmtpDiagnosis { const { message, code } = readError(error); - 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 cause = classify(error, message, code); + const aadHint = error instanceof Ms365TokenError && 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) }; }