refactor(mail): send via the SSO app registration, drop SMTP basic auth
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m25s
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:
1 parent
0620e9d79a
commit
11910dd6dd
15 files changed
+211
-309
No files matched your search
+14
-9
@@ -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=
|
||||||
|
|||||||
@@ -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 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 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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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
@@ -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. */
|
||||||
|
|||||||
+69
-107
@@ -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"] =
|
|
||||||
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 = {
|
const options: SMTPTransport.Options = {
|
||||||
host: config.host,
|
host: MS365_SMTP_HOST,
|
||||||
port: config.port,
|
port: MS365_SMTP_PORT,
|
||||||
secure: config.port === 465,
|
secure: false,
|
||||||
// Auf 587 MUSS die Verbindung per STARTTLS verschluesselt werden, bevor
|
// Die Verbindung MUSS per STARTTLS verschluesselt werden, bevor das Token
|
||||||
// Passwort oder Token gesendet werden — ohne requireTLS wuerde nodemailer
|
// gesendet wird — ohne requireTLS wuerde nodemailer bei einem Server ohne
|
||||||
// bei einem Server ohne STARTTLS-Angebot (Fehlkonfiguration, Angriff) die
|
// STARTTLS-Angebot (Angriff auf dem Netzweg) im Klartext weitermachen.
|
||||||
// Zugangsdaten im Klartext schicken.
|
requireTLS: true,
|
||||||
requireTLS: config.port !== 465,
|
auth: {
|
||||||
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,
|
...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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
});
|
||||||
|
|||||||
+24
-45
@@ -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.
|
if (code === "EAUTH" || text.includes("535") || text.includes("authentication unsuccessful")) return "oauth-mailbox-access";
|
||||||
return authMode === "oauth2" ? "oauth-mailbox-access" : "wrong-credentials";
|
|
||||||
}
|
|
||||||
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) };
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user