refactor(mail): send via the SSO app registration, drop SMTP basic auth
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m25s

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) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-09-26 19:23:05 +02:00
1 parent 0620e9d79a
commit 11910dd6dd
15 files changed
+203 -301

No files matched your search

+14 -9
View File
@@ -79,16 +79,16 @@ GOOGLE_PLACE_ID=
TURNSTILE_SECRET_KEY= TURNSTILE_SECRET_KEY=
NEXT_PUBLIC_TURNSTILE_SITE_KEY= NEXT_PUBLIC_TURNSTILE_SITE_KEY=
# SMTP fuer Transaktions-E-Mails (Bestellbestaetigung, Newsletter-Opt-in, # Transaktions-E-Mails (Bestellbestaetigung, Newsletter-Opt-in,
# Passwort-Reset). Ohne diese drei Werte werden E-Mails nur geloggt statt # Passwort-Reset) gehen ueber Microsoft 365 mit OAuth 2.0 — mit derselben
# versendet — praktisch fuer lokale Entwicklung ohne Mail-Setup. # App-Registrierung wie "Mit Microsoft anmelden" (ENTRA_* weiter unten, dort
# Funktioniert mit jedem SMTP-Anbieter (z.B. das E-Mail-Postfach eures # zusaetzlich die Berechtigung SMTP.SendAsApp, siehe README
# Domain-Hosters wie IONOS/Strato, oder ein Dienst wie Resend/Postmark im # "E-Mail-Versand"). Ein Passwort gibt es nicht. Ohne SMTP_USER und die
# SMTP-Modus). # ENTRA_*-Werte werden E-Mails nur geloggt statt versendet — praktisch fuer
SMTP_HOST= # lokale Entwicklung ohne Mail-Setup.
SMTP_PORT=587 # SMTP_USER = das Postfach, mit dem gesendet wird; SMTP_FROM sollte dieselbe
# Adresse tragen.
SMTP_USER= SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM="Wabenhain <[email protected]>" SMTP_FROM="Wabenhain <[email protected]>"
# ApiCore-Synchronisierung (optional). Zwei Knoepfe nutzen sie: admin/ernten # ApiCore-Synchronisierung (optional). Zwei Knoepfe nutzen sie: admin/ernten
@@ -187,6 +187,11 @@ DHL_ENVIRONMENT=sandbox
# als Unternehmensapplikation (wird beim App-Registration-Schritt # als Unternehmensapplikation (wird beim App-Registration-Schritt
# automatisch mit angelegt) — dort laesst sich optional der Nutzerkreis # automatisch mit angelegt) — dort laesst sich optional der Nutzerkreis
# (z.B. nur bestimmte Gruppen) einschraenken. # (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_TENANT_ID=
ENTRA_CLIENT_ID= ENTRA_CLIENT_ID=
ENTRA_CLIENT_SECRET= ENTRA_CLIENT_SECRET=
+14 -20
View File
@@ -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 - **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) - **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) - **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 - `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. - **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&nbsp;KB gzip) ohne sichtbaren Unterschied im Scroll-Reveal- oder Hero-Effekt. Core-Web-Vitals-Messung (LCP/CLS) zeigt keine Auffälligkeiten. - **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&nbsp;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 ## 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. **Einrichtung** (einmalig; die SSO-App-Registrierung aus `.env.example` muss schon bestehen):
### 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. `[email protected]`) — 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. 1. **Absender-Postfach:** Am besten ein freigegebenes Postfach (z. B. `[email protected]`) — 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)**. 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. **Berechtigung:** 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. **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. 4. **Exchange-Zugriff auf genau dieses Postfach** (Exchange Online PowerShell):
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 ```powershell
Install-Module ExchangeOnlineManagement # einmalig Install-Module ExchangeOnlineManagement # einmalig
Connect-ExchangeOnline Connect-ExchangeOnline
New-ServicePrincipal -AppId <Client-ID> -ObjectId <Objekt-ID aus Schritt 5> -DisplayName "Wabenhain Shop Mail" New-ServicePrincipal -AppId <ENTRA_CLIENT_ID> -ObjectId <Objekt-ID aus Schritt 3> -DisplayName "Wabenhain Shop"
Add-MailboxPermission -Identity "[email protected]" -User <Objekt-ID aus Schritt 5> -AccessRights FullAccess Add-MailboxPermission -Identity "[email protected]" -User <Objekt-ID aus Schritt 3> -AccessRights FullAccess
Set-CASMailbox -Identity "[email protected]" -SmtpClientAuthenticationDisabled $false Set-CASMailbox -Identity "[email protected]" -SmtpClientAuthenticationDisabled $false
``` ```
Die letzte Zeile ist nötig, weil auch OAuth über „authentifiziertes SMTP“ läuft. Änderungen brauchen bis zu 30 Minuten. 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**. 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**.
8. **Aufräumen:** Ein zuvor für SMTP genutztes Benutzerpasswort ändern und aus dem Secret entfernen. 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) ## CAPTCHA (Cloudflare Turnstile)
@@ -363,7 +357,7 @@ Die App läuft danach hinter [Caddy](https://caddyserver.com/) unter `http://loc
pnpm preflight 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: 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). - `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. - `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. - 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. - 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). 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).
+6 -3
View File
@@ -120,11 +120,14 @@ services:
SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET muss in .env gesetzt sein} SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET muss in .env gesetzt sein}
GOOGLE_PLACES_API_KEY: ${GOOGLE_PLACES_API_KEY} GOOGLE_PLACES_API_KEY: ${GOOGLE_PLACES_API_KEY}
GOOGLE_PLACE_ID: ${GOOGLE_PLACE_ID} GOOGLE_PLACE_ID: ${GOOGLE_PLACE_ID}
SMTP_HOST: ${SMTP_HOST} # Mailversand ueber Microsoft 365 mit OAuth 2.0 — dieselbe
SMTP_PORT: ${SMTP_PORT:-587} # App-Registrierung wie "Mit Microsoft anmelden" (siehe .env.example).
SMTP_USER: ${SMTP_USER} SMTP_USER: ${SMTP_USER}
SMTP_PASSWORD: ${SMTP_PASSWORD}
SMTP_FROM: ${SMTP_FROM:-Wabenhain <[email protected]>} SMTP_FROM: ${SMTP_FROM:-Wabenhain <[email protected]>}
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} NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-http://localhost:3000}
NODE_ENV: production NODE_ENV: production
+2 -15
View File
@@ -28,19 +28,10 @@ data:
# Absenderadresse aller Transaktions-E-Mails. Sollte auf einer eigenen # Absenderadresse aller Transaktions-E-Mails. Sollte auf einer eigenen
# Domain liegen, sonst landen die Mails regelmaessig im Spam-Ordner. # 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 <[email protected]>" SMTP_FROM: "Wabenhain <[email protected]>"
# 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 → <App> → 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 # VORGABEN — koennen so bleiben
# --------------------------------------------------------------------- # ---------------------------------------------------------------------
@@ -62,10 +53,6 @@ data:
# welche. Entweder diesen Wert senken oder max_connections anheben. # welche. Entweder diesen Wert senken oder max_connections anheben.
DATABASE_POOL_SIZE: "20" 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 # Verzeichnis fuer hochgeladene Bilder und Videos. MUSS exakt dem mountPath
# des "uploads"-Volumes in 05-app-deployment.yaml entsprechen — die App # des "uploads"-Volumes in 05-app-deployment.yaml entsprechen — die App
# weiss nicht, was dort gemountet ist, sie schreibt nur in diesen Pfad. # weiss nicht, was dort gemountet ist, sie schreibt nur in diesen Pfad.
+12 -20
View File
@@ -68,27 +68,15 @@ stringData:
# https://wabenhain.com/api/checkout/webhook zeigt (whsec_...). # https://wabenhain.com/api/checkout/webhook zeigt (whsec_...).
STRIPE_WEBHOOK_SECRET: "CHANGE-ME" STRIPE_WEBHOOK_SECRET: "CHANGE-ME"
# Ohne SMTP werden E-Mails nur ins Server-Log geschrieben (src/lib/mail.ts): # Mailversand ueber Microsoft 365 mit OAuth 2.0 (src/lib/mail.ts) — mit der
# keine Bestellbestaetigung, keine Versandmeldung, keine # App-Registrierung der Entra-ID-SSO unten (ENTRA_TENANT_ID,
# Passwort-Zuruecksetzung. Funktioniert mit jedem SMTP-Anbieter; Port und # ENTRA_CLIENT_ID, ENTRA_CLIENT_SECRET), kein Passwort. Ohne diese Werte
# Absenderadresse stehen als SMTP_PORT/SMTP_FROM in 01-configmap.yaml. # und SMTP_USER werden E-Mails nur ins Server-Log geschrieben: keine
# # Bestellbestaetigung, keine Versandmeldung, keine Passwort-Zuruecksetzung.
# SMTP_USER ist immer das Postfach, mit dem gesendet wird (z. B. # SMTP_USER ist das Postfach, mit dem gesendet wird (z. B.
# [email protected]) — und sollte dieselbe Adresse wie SMTP_FROM sein. # [email protected]) — dieselbe Adresse wie SMTP_FROM in
# # 01-configmap.yaml. Einrichtung: README "E-Mail-Versand".
# 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_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 # EMPFOHLEN — der Shop laeuft auch ohne, aber ungeschuetzt
@@ -141,6 +129,10 @@ stringData:
# Entra-ID-SSO ("Mit Microsoft anmelden"), siehe .env.example fuer die # Entra-ID-SSO ("Mit Microsoft anmelden"), siehe .env.example fuer die
# Einrichtung in Azure. Leer = der Anmeldeknopf erscheint gar nicht erst. # 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 # ENTRA_REDIRECT_URI muss exakt der in Azure hinterlegten Adresse
# entsprechen und auf die echte Domain zeigen: # entsprechen und auf die echte Domain zeigen:
# https://wabenhain.com/api/auth/entra/callback # https://wabenhain.com/api/auth/entra/callback
+4 -6
View File
@@ -146,13 +146,11 @@ mustWhenSet(
); );
// --- E-Mail ---------------------------------------------------------------- // --- E-Mail ----------------------------------------------------------------
// Zwei gueltige Wege (siehe src/lib/mail.ts): Microsoft 365 per OAuth 2.0 // Versand nur ueber Microsoft 365 mit OAuth 2.0 — mit der SSO-App-
// oder klassisch mit Passwort. // Registrierung (siehe src/lib/mail.ts).
must( must(
"SMTP eingerichtet (SMTP_USER + OAuth 2.0 oder SMTP_HOST/SMTP_PASSWORD)", "Mailversand eingerichtet (SMTP_USER + ENTRA_TENANT_ID/ENTRA_CLIENT_ID/ENTRA_CLIENT_SECRET)",
isSet("SMTP_USER") && isSet("SMTP_USER") && isSet("ENTRA_TENANT_ID") && isSet("ENTRA_CLIENT_ID") && isSet("ENTRA_CLIENT_SECRET"),
((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 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 // Ohne SMTP_FROM greift der Standardabsender aus lib/mail.ts — deshalb nur
@@ -47,7 +47,7 @@ export function SmtpTestPanel() {
<div> <div>
<p className="text-xs tracking-wide-caps text-ink/60">{s.configLabel}</p> <p className="text-xs tracking-wide-caps text-ink/60">{s.configLabel}</p>
<p className="mt-1 break-words font-mono text-xs text-ink/80"> <p className="mt-1 break-words font-mono text-xs text-ink/80">
{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}
</p> </p>
</div> </div>
</div> </div>
+2 -3
View File
@@ -1259,7 +1259,7 @@ export const de: Dictionary = {
}, },
adminEmailTestPage: { adminEmailTestPage: {
title: "E-Mail-Versand testen", 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", button: "Test-Mail senden",
running: "Wird getestet …", running: "Wird getestet …",
success: (to) => `Versand funktioniert. Die Test-Mail ist an ${to} unterwegs.`, success: (to) => `Versand funktioniert. Die Test-Mail ist an ${to} unterwegs.`,
@@ -1268,8 +1268,7 @@ export const de: Dictionary = {
causeLabel: "Was zu tun ist", causeLabel: "Was zu tun ist",
detailLabel: "Meldung des Mailservers", detailLabel: "Meldung des Mailservers",
configLabel: "Verwendete Einstellungen", configLabel: "Verwendete Einstellungen",
authModeOAuth: "OAuth 2.0 (App-Registrierung)", authModeOAuth: "Microsoft 365, OAuth 2.0 über die SSO-App",
authModePassword: "Passwort (Basic Auth)",
missingLabel: "Fehlende Einstellungen", missingLabel: "Fehlende Einstellungen",
}, },
adminWithdrawalsPage: { adminWithdrawalsPage: {
+2 -3
View File
@@ -1233,7 +1233,7 @@ export const en: Dictionary = {
}, },
adminEmailTestPage: { adminEmailTestPage: {
title: "Test email delivery", 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", button: "Send test email",
running: "Testing …", running: "Testing …",
success: (to) => `Delivery works. The test email is on its way to ${to}.`, 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", causeLabel: "What to do",
detailLabel: "Mail server message", detailLabel: "Mail server message",
configLabel: "Settings used", configLabel: "Settings used",
authModeOAuth: "OAuth 2.0 (app registration)", authModeOAuth: "Microsoft 365, OAuth 2.0 via the SSO app",
authModePassword: "Password (basic auth)",
missingLabel: "Missing settings", missingLabel: "Missing settings",
}, },
adminWithdrawalsPage: { adminWithdrawalsPage: {
-1
View File
@@ -830,7 +830,6 @@ export interface Dictionary {
detailLabel: string; detailLabel: string;
configLabel: string; configLabel: string;
authModeOAuth: string; authModeOAuth: string;
authModePassword: string;
missingLabel: string; missingLabel: string;
}; };
adminWithdrawalsPage: { adminWithdrawalsPage: {
+25 -5
View File
@@ -46,15 +46,35 @@ interface EntraConfig {
redirectUri: string; 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 { function getEntraConfig(): EntraConfig {
const tenantId = process.env.ENTRA_TENANT_ID; const credentials = readEntraAppCredentials();
const clientId = process.env.ENTRA_CLIENT_ID;
const clientSecret = process.env.ENTRA_CLIENT_SECRET;
const redirectUri = process.env.ENTRA_REDIRECT_URI; const redirectUri = process.env.ENTRA_REDIRECT_URI;
if (!tenantId || !clientId || !clientSecret || !redirectUri) { if (!credentials || !redirectUri) {
throw new EntraNotConfiguredError(); throw new EntraNotConfiguredError();
} }
return { tenantId, clientId, clientSecret, redirectUri }; return { ...credentials, redirectUri };
} }
/** Fuer die bedingte Anzeige des "Mit Microsoft anmelden"-Buttons im Login-UI. */ /** Fuer die bedingte Anzeige des "Mit Microsoft anmelden"-Buttons im Login-UI. */
+63 -101
View File
@@ -1,7 +1,8 @@
import nodemailer, { type Transporter } from "nodemailer"; import nodemailer, { type Transporter } from "nodemailer";
import type SMTPTransport from "nodemailer/lib/smtp-transport"; import type SMTPTransport from "nodemailer/lib/smtp-transport";
import { explainSmtpError, type SmtpAuthMode, type SmtpDiagnosis } from "@/lib/smtp-diagnose"; import { explainSmtpError, type SmtpDiagnosis } from "@/lib/smtp-diagnose";
import { createMs365TokenProvider, type Ms365OAuthConfig, type Ms365TokenProvider } from "@/lib/ms365-oauth"; import { createMs365TokenProvider, type Ms365TokenProvider } from "@/lib/ms365-oauth";
import { readEntraAppCredentials, type EntraAppCredentials } from "@/lib/entra";
export interface MailAttachment { export interface MailAttachment {
filename: string; filename: string;
@@ -24,21 +25,29 @@ interface SendMailInput {
} }
/** /**
* Zwei Anmeldearten am Mailserver: * Versand ausschliesslich ueber Microsoft 365 mit OAuth 2.0 (SMTP XOAUTH2,
* - oauth2: Microsoft 365 per App-Registrierung (SMTP_OAUTH_TENANT_ID, * Client-Credentials-Flow). Als App dient dieselbe App-Registrierung wie fuer
* SMTP_OAUTH_CLIENT_ID, SMTP_OAUTH_CLIENT_SECRET). Bevorzugt — kein * "Mit Microsoft anmelden" (ENTRA_TENANT_ID, ENTRA_CLIENT_ID,
* Benutzerpasswort, unabhaengig vom Ende der Basic-Auth bei Microsoft. * ENTRA_CLIENT_SECRET) — eine Unternehmensanwendung statt zwei. Eine
* - password: klassisch mit SMTP_PASSWORD, fuer jeden anderen Anbieter. * Anmeldung per Passwort (Basic Auth) gibt es bewusst nicht mehr: kein
* Sobald einer der SMTP_OAUTH_*-Werte gesetzt ist, gilt oauth2 — ein * Benutzerpasswort im Cluster, kein Konflikt mit MFA/Sicherheitsstandards,
* unvollstaendiger Satz faellt NICHT still auf das Passwort zurueck. * 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 = interface SmtpConfig {
| { authMode: "oauth2"; host: string; port: number; user: string; oauth: Ms365OAuthConfig } user: string;
| { authMode: "password"; host: string; port: number; user: string; pass: string }; app: EntraAppCredentials;
}
const MS365_SMTP_HOST = "smtp.office365.com"; 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; const TEST_TIMEOUT_MS = 15_000;
type SmtpTransporter = Transporter<SMTPTransport.SentMessageInfo>;
/** Maskiert eine E-Mail-Adresse fuer Log-Ausgaben, z.B. "ma***@example.com". */ /** Maskiert eine E-Mail-Adresse fuer Log-Ausgaben, z.B. "ma***@example.com". */
function maskEmail(email: string): string { function maskEmail(email: string): string {
const [local, domain] = email.split("@"); const [local, domain] = email.split("@");
@@ -46,106 +55,60 @@ function maskEmail(email: string): string {
return `${local.slice(0, 2)}***@${domain}`; return `${local.slice(0, 2)}***@${domain}`;
} }
/** /** Namen der Variablen, die fuer den Versand noch fehlen — leer, wenn alles da ist. */
* 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<typeof readSmtpEnv>;
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[] { function missingSmtpSettings(): string[] {
const env = readSmtpEnv(); const missing: string[] = [];
const wantsOAuth = OAUTH_SETTINGS.some((name) => env[name] !== ""); if (!process.env.SMTP_USER?.trim()) missing.push("SMTP_USER");
const required: SmtpEnvName[] = wantsOAuth ? ["SMTP_USER", ...OAUTH_SETTINGS] : ["SMTP_HOST", "SMTP_USER", "SMTP_PASSWORD"]; if (!process.env.ENTRA_TENANT_ID?.trim()) missing.push("ENTRA_TENANT_ID");
return required.filter((name) => env[name] === ""); 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 { function readSmtpConfig(): SmtpConfig | null {
if (missingSmtpSettings().length > 0) return null; const user = process.env.SMTP_USER?.trim() ?? "";
const env = readSmtpEnv(); const app = readEntraAppCredentials();
const port = Number(process.env.SMTP_PORT ?? 587); return user && app ? { user, app } : null;
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 { function getFromAddress(): string {
return process.env.SMTP_FROM || "Wabenhain <[email protected]>"; return process.env.SMTP_FROM || "Wabenhain <[email protected]>";
} }
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 // Ein Token-Provider pro Prozess: das Token (ca. 60 Minuten gueltig) wird
// ueber alle Mails hinweg wiederverwendet statt je Mail neu geholt. // ueber alle Mails hinweg wiederverwendet statt je Mail neu geholt.
let tokenProvider: { key: string; provider: Ms365TokenProvider } | null = null; let tokenProvider: { key: string; provider: Ms365TokenProvider } | null = null;
function getTokenProvider(oauth: Ms365OAuthConfig): Ms365TokenProvider { function getTokenProvider(app: EntraAppCredentials): Ms365TokenProvider {
const key = `${oauth.tenantId}|${oauth.clientId}|${oauth.clientSecret}`; const key = `${app.tenantId}|${app.clientId}|${app.clientSecret}`;
if (!tokenProvider || tokenProvider.key !== key) { if (!tokenProvider || tokenProvider.key !== key) {
tokenProvider = { key, provider: createMs365TokenProvider(oauth) }; tokenProvider = { key, provider: createMs365TokenProvider(app) };
} }
return tokenProvider.provider; return tokenProvider.provider;
} }
type SmtpTransporter = Transporter<SMTPTransport.SentMessageInfo>;
function buildTransport(config: SmtpConfig, extra: { connectionTimeout?: number; greetingTimeout?: number } = {}): SmtpTransporter { function buildTransport(config: SmtpConfig, extra: { connectionTimeout?: number; greetingTimeout?: number } = {}): SmtpTransporter {
const auth: SMTPTransport.Options["auth"] = const options: SMTPTransport.Options = {
config.authMode === "oauth2" host: MS365_SMTP_HOST,
? { port: MS365_SMTP_PORT,
type: "OAuth2" as const, 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, user: config.user,
// nodemailer fragt hier nach einem Token; renew=true nach einer // nodemailer fragt hier nach einem Token; renew=true nach einer
// Ablehnung durch den Server → frisches Token statt des gecachten. // 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) => { provisionCallback: (_user: string, renew: boolean, callback: (err: Error | null, accessToken: string, expires: number) => void) => {
// expires 0: nodemailer fragt bei jeder Anmeldung neu — das Cachen getTokenProvider(config.app)
// mit Ablaufreserve uebernimmt der Token-Provider.
getTokenProvider(config.oauth)
.getToken(renew) .getToken(renew)
.then((token) => callback(null, token, 0), (error: Error) => callback(error, "", 0)); .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, ...extra,
}; };
return nodemailer.createTransport(options); return nodemailer.createTransport(options);
@@ -153,7 +116,7 @@ function buildTransport(config: SmtpConfig, extra: { connectionTimeout?: number;
let cachedTransport: { config: SmtpConfig; transport: SmtpTransporter } | null = null; let cachedTransport: { config: SmtpConfig; transport: SmtpTransporter } | null = null;
// Nur einmal gemeldet (nicht bei jedem sendMail-Aufruf) — macht ein in // 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. // Bestellbestaetigungen/Passwort-Resets fortlaufend lautlos verschwinden.
let warnedAboutMissingSmtpInProduction = false; let warnedAboutMissingSmtpInProduction = false;
@@ -163,7 +126,7 @@ function getTransport(): { config: SmtpConfig; transport: SmtpTransporter } | nu
if (process.env.NODE_ENV === "production" && !warnedAboutMissingSmtpInProduction) { if (process.env.NODE_ENV === "production" && !warnedAboutMissingSmtpInProduction) {
warnedAboutMissingSmtpInProduction = true; warnedAboutMissingSmtpInProduction = true;
console.warn( 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; return null;
@@ -173,11 +136,11 @@ function getTransport(): { config: SmtpConfig; transport: SmtpTransporter } | nu
} }
/** /**
* Versendet eine E-Mail ueber den konfigurierten Mailserver. Ohne * Versendet eine E-Mail ueber Microsoft 365. Ohne Konfiguration wird die
* Konfiguration wird die Mail NICHT gesendet, sondern nur geloggt — analog * Mail NICHT gesendet, sondern nur geloggt — damit lokale Entwicklung und
* zum Stripe-Fallback ohne Keys, damit lokale Entwicklung und Deployments * Deployments ohne Mail-Setup nicht abstuerzen. Wirft absichtlich keinen
* ohne Mail-Setup nicht abstuerzen. Wirft absichtlich keinen Fehler: ein * Fehler: ein Mail-Ausfall darf eine Bestellung oder Registrierung nicht
* Mail-Ausfall darf eine Bestellung oder Registrierung nicht verhindern. * verhindern.
* *
* Liefert true, wenn der Mailserver die Nachricht angenommen hat, sonst * Liefert true, wenn der Mailserver die Nachricht angenommen hat, sonst
* false (nicht konfiguriert oder Versand fehlgeschlagen). Aktionen mit * 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<boolean> { export async function sendMail(input: SendMailInput): Promise<boolean> {
const active = getTransport(); const active = getTransport();
if (!active) { 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; return false;
} }
@@ -203,7 +166,7 @@ export async function sendMail(input: SendMailInput): Promise<boolean> {
}); });
return true; return true;
} catch (error: unknown) { } 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}`); console.error(`[mail] Versand an ${maskEmail(input.to)} fehlgeschlagen (${diagnosis.cause}): ${diagnosis.detail} — ${diagnosis.hint}`);
return false; return false;
} }
@@ -213,7 +176,6 @@ interface SmtpTestBase {
host: string; host: string;
user: string; user: string;
from: string; from: string;
authMode: SmtpAuthMode;
} }
export type SmtpTestResult = export type SmtpTestResult =
@@ -222,16 +184,16 @@ export type SmtpTestResult =
| ({ ok: false; configured: true; diagnosis: SmtpDiagnosis } & SmtpTestBase); | ({ 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, * (verify) und danach ein echter Versand an `to` — erst der Versand zeigt,
* ob das Postfach mit der Absenderadresse (SMTP_FROM) senden darf. Baut * ob das Postfach mit der Absenderadresse (SMTP_FROM) senden darf. Baut
* bewusst einen frischen Transport statt des zwischengespeicherten. Weder * bewusst einen frischen Transport statt des zwischengespeicherten. Das
* Passwort noch Client-Secret erscheinen im Ergebnis. * Client-Secret erscheint nie im Ergebnis.
*/ */
export async function testSmtpConnection(to: string): Promise<SmtpTestResult> { export async function testSmtpConnection(to: string): Promise<SmtpTestResult> {
const config = readSmtpConfig(); const config = readSmtpConfig();
if (!config) return { ok: false, configured: false, missing: missingSmtpSettings() }; 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 }); const transport = buildTransport(config, { connectionTimeout: TEST_TIMEOUT_MS, greetingTimeout: TEST_TIMEOUT_MS });
try { try {
await transport.verify(); await transport.verify();
@@ -239,11 +201,11 @@ export async function testSmtpConnection(to: string): Promise<SmtpTestResult> {
from: base.from, from: base.from,
to, to,
subject: "Wabenhain: Test des E-Mail-Versands", 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 }; return { ok: true, ...base };
} catch (error: unknown) { } 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 { } finally {
transport.close(); transport.close();
} }
+3 -2
View File
@@ -4,7 +4,8 @@
* App-Registrierung in Entra ID an — kein Benutzerpasswort, kein MFA-Konflikt, * 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 * unabhaengig vom Ende der Basic-Auth fuer SMTP. Die App darf nur mit dem
* Postfach senden, fuer das Exchange ihr ausdruecklich Zugriff gibt * 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 { 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 // Die Tenant-ID landet im URL-Pfad: nur GUID oder Domainname, sonst
// koennte ein manipulierter Wert die Anfrage an einen anderen Endpunkt lenken. // koennte ein manipulierter Wert die Anfrage an einen anderen Endpunkt lenken.
if (!GUID.test(config.tenantId) && !DOMAIN.test(config.tenantId)) { 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 fetchImpl = options.fetchImpl ?? fetch;
const now = options.now ?? Date.now; const now = options.now ?? Date.now;
+33 -69
View File
@@ -7,12 +7,36 @@ function smtpError(message: string, extra: Record<string, unknown> = {}): Error
} }
describe("explainSmtpError", () => { describe("explainSmtpError", () => {
it("recognises security defaults blocking basic auth", () => { it("explains a client secret ID used instead of its value", () => {
const result = explainSmtpError( const result = explainSmtpError(new Ms365TokenError("Token-Abruf abgelehnt: AADSTS7000215: Invalid client secret provided.", "7000215"));
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("oauth-token");
); expect(result.hint).toContain("ENTRA_CLIENT_SECRET");
expect(result.cause).toBe("security-defaults"); expect(result.hint).toContain("Wert");
expect(result.hint).toContain("Sicherheitsstandards"); });
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", () => { 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", () => { it("recognises a sender address the mailbox may not use", () => {
expect( expect(
explainSmtpError(smtpError("Message failed: 554 5.2.252 SendAsDenied; [email protected] not allowed to send as [email protected]")).cause explainSmtpError(smtpError("Message failed: 554 5.2.252 SendAsDenied; [email protected] not allowed to send as [email protected]")).cause
@@ -47,7 +57,7 @@ describe("explainSmtpError", () => {
it("recognises network problems", () => { it("recognises network problems", () => {
expect(explainSmtpError(smtpError("connect ETIMEDOUT 52.97.1.1:587", { code: "ETIMEDOUT" })).cause).toBe("network"); 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", () => { it("falls back to unknown and never throws on odd input", () => {
@@ -55,54 +65,8 @@ describe("explainSmtpError", () => {
expect(explainSmtpError(null).cause).toBe("unknown"); expect(explainSmtpError(null).cause).toBe("unknown");
}); });
it("never echoes a password that appears in the error", () => { it("redacts secrets from the echoed server message", () => {
const result = explainSmtpError(smtpError("535 5.7.3 Authentication unsuccessful"), { secrets: ["geheim123"] }); const result = explainSmtpError(smtpError("AUTH XOAUTH2 eyJtoken failed secret-xyz"), { secrets: ["eyJtoken", "secret-xyz"] });
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"] });
expect(result.detail).not.toContain("eyJtoken"); expect(result.detail).not.toContain("eyJtoken");
expect(result.detail).not.toContain("secret-xyz"); expect(result.detail).not.toContain("secret-xyz");
}); });
+22 -43
View File
@@ -1,23 +1,17 @@
/** /**
* Uebersetzt SMTP-Fehler (vor allem von Microsoft 365 / Exchange Online) in * Uebersetzt Fehler beim Mailversand ueber Microsoft 365 (OAuth 2.0, siehe
* eine konkrete Ursache mit deutscher Handlungsanweisung. Der Versand selbst * lib/mail.ts) in eine konkrete Ursache mit deutscher Handlungsanweisung.
* verschluckt Fehler bewusst (lib/mail.ts) — ohne diese Einordnung sieht man * Der Versand selbst verschluckt Fehler bewusst — ohne diese Einordnung sieht
* nur "keine Mails kommen an". * man nur "keine Mails kommen an".
*/ */
import { Ms365TokenError } from "@/lib/ms365-oauth"; import { Ms365TokenError } from "@/lib/ms365-oauth";
export type SmtpAuthMode = "password" | "oauth2";
export type SmtpFailureCause = export type SmtpFailureCause =
| "oauth-token" | "oauth-token"
| "oauth-mailbox-access" | "oauth-mailbox-access"
| "basic-auth-retired"
| "security-defaults"
| "smtp-auth-disabled" | "smtp-auth-disabled"
| "mfa-or-conditional-access"
| "send-as-denied" | "send-as-denied"
| "wrong-credentials"
| "network" | "network"
| "tls" | "tls"
| "unknown"; | "unknown";
@@ -25,43 +19,35 @@ export type SmtpFailureCause =
export interface SmtpDiagnosis { export interface SmtpDiagnosis {
cause: SmtpFailureCause; cause: SmtpFailureCause;
hint: string; hint: string;
/** Originalmeldung des Servers, gekuerzt und ohne Passwort. */ /** Originalmeldung des Servers, gekuerzt und ohne Geheimnisse. */
detail: string; detail: string;
} }
const HINTS: Record<SmtpFailureCause, string> = { const HINTS: Record<SmtpFailureCause, string> = {
"oauth-token": "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": "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 <Client-ID> -ObjectId <Objekt-ID der Unternehmensanwendung> in Exchange Online PowerShell, (3) Add-MailboxPermission -Identity <SMTP_USER> -User <Objekt-ID> -AccessRights FullAccess. Nach Änderungen bis zu 30 Minuten warten.", "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 <ENTRA_CLIENT_ID> -ObjectId <Objekt-ID der Unternehmensanwendung> in Exchange Online PowerShell, (3) Add-MailboxPermission -Identity <SMTP_USER> -User <Objekt-ID> -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.",
"smtp-auth-disabled": "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.", "„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 <SMTP_USER> -SmtpClientAuthenticationDisabled $false. Die Postfach-Einstellung hat Vorrang vor der des Tenants; Ä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.",
"send-as-denied": "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.", "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.",
"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.",
network: 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).", "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 zum Mailserver ist fehlgeschlagen. Prüfe SMTP_HOST und SMTP_PORT (587 mit STARTTLS, 465 mit direktem TLS).", 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.", unknown: "Unbekannter Fehler. Die Originalmeldung des Mailservers steht unten.",
}; };
/** Konkrete Anweisung zu den haeufigsten Entra-Fehlernummern beim Token-Abruf. */ /** Konkrete Anweisung zu den haeufigsten Entra-Fehlernummern beim Token-Abruf. */
const AAD_HINTS: Record<string, string> = { const AAD_HINTS: Record<string, string> = {
"7000215": "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": "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": "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.", "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 SMTP_OAUTH_TENANT_ID (Verzeichnis-ID/Mandanten-ID oder z. B. wabenhain.onmicrosoft.com).", "90002": "Der Tenant existiert nicht. Prüfe ENTRA_TENANT_ID (Verzeichnis-ID/Mandanten-ID).",
"900023": "Die Tenant-Angabe ist ungültig. Prüfe SMTP_OAUTH_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; const MAX_DETAIL_LENGTH = 300;
@@ -74,19 +60,14 @@ function readError(error: unknown): { message: string; code: string } {
return { message: typeof error === "string" ? error : "", code: "" }; return { message: typeof error === "string" ? error : "", code: "" };
} }
function classify(message: string, code: string, authMode: SmtpAuthMode, isTokenError: boolean): SmtpFailureCause { function classify(error: unknown, message: string, code: string): SmtpFailureCause {
if (isTokenError) return "oauth-token"; if (error instanceof Ms365TokenError) return "oauth-token";
const text = message.toLowerCase(); 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("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 (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 // Mit OAuth gibt es kein Passwort, das falsch sein koennte: ein abgelehntes
// XOAUTH2-Token heisst fast immer fehlender Postfachzugriff der App. // XOAUTH2-Token heisst fast immer fehlender Postfachzugriff der App.
return authMode === "oauth2" ? "oauth-mailbox-access" : "wrong-credentials"; 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)) { if (["ETIMEDOUT", "ECONNREFUSED", "ECONNECTION", "EDNS", "ENOTFOUND", "ESOCKET", "ECONNRESET"].includes(code) || /etimedout|econnrefused|enotfound/.test(text)) {
return "network"; return "network";
} }
@@ -95,16 +76,14 @@ function classify(message: string, code: string, authMode: SmtpAuthMode, isToken
} }
interface ExplainOptions { 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)[]; secrets?: (string | undefined)[];
authMode?: SmtpAuthMode;
} }
export function explainSmtpError(error: unknown, options: ExplainOptions = {}): SmtpDiagnosis { export function explainSmtpError(error: unknown, options: ExplainOptions = {}): SmtpDiagnosis {
const { message, code } = readError(error); const { message, code } = readError(error);
const isTokenError = error instanceof Ms365TokenError; const cause = classify(error, message, code);
const cause = classify(message, code, options.authMode ?? "password", isTokenError); const aadHint = error instanceof Ms365TokenError && error.aadCode ? AAD_HINTS[error.aadCode] : undefined;
const aadHint = isTokenError && error.aadCode ? AAD_HINTS[error.aadCode] : undefined;
const redacted = (options.secrets ?? []).reduce<string>((text, secret) => (secret ? text.split(secret).join("***") : text), message); const redacted = (options.secrets ?? []).reduce<string>((text, secret) => (secret ? text.split(secret).join("***") : text), message);
return { cause, hint: aadHint ?? HINTS[cause], detail: redacted.slice(0, MAX_DETAIL_LENGTH) }; return { cause, hint: aadHint ?? HINTS[cause], detail: redacted.slice(0, MAX_DETAIL_LENGTH) };
} }