feat(mail): Microsoft 365 sending via OAuth 2.0 (client credentials)
Sending no longer needs a user password for Microsoft 365. When SMTP_OAUTH_TENANT_ID, SMTP_OAUTH_CLIENT_ID and SMTP_OAUTH_CLIENT_SECRET are set, the shop fetches an app-only token from Entra ID and signs in to smtp.office365.com with XOAUTH2. This works with security defaults and MFA, survives the end of SMTP basic auth (default off from end of December 2026) and limits the app to the one mailbox Exchange grants it (SMTP.SendAsApp + Add-MailboxPermission). Password login stays available for other providers; a partial OAuth setup never silently falls back. - lib/ms365-oauth.ts: token cached until 5 minutes before expiry, concurrent requests share one fetch, forced refresh after a server rejection, 15 s timeout, tenant id validated before it enters the URL, client secret redacted from every error. - lib/smtp-diagnose.ts: explains AADSTS7000215 (secret ID instead of value), 7000222 (expired), 700016 (unknown app), 90002/900023 (tenant) and maps a rejected XOAUTH2 login to missing mailbox access. - Admin e-mail test shows the auth mode and lists missing settings. - k8s manifests, preflight check and README (step-by-step Entra and Exchange setup) updated; misplaced SMTP_PORT comment moved. Verified against Entra ID and smtp.office365.com with fake values: AADSTS90002 is classified as tenant error without leaking the secret, an invalid token yields 535 5.7.3 and is classified as mailbox access. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
302a18baa6
commit
0620e9d79a
13 files changed
+511
-88
No files matched your search
@@ -262,6 +262,31 @@ Bestellbestätigung, Versand-/Abholbereit-Benachrichtigung, E-Mail-Verifizierung
|
|||||||
|
|
||||||
Funktioniert mit jedem SMTP-Anbieter, z.B. dem E-Mail-Postfach eures Domain-Hosters (IONOS, Strato, …) oder Diensten wie Resend/Postmark im SMTP-Modus. `SMTP_FROM` sollte zur `SMTP_USER`-Domain passen, sonst landen Mails leicht im Spam.
|
Funktioniert mit jedem SMTP-Anbieter, z.B. dem E-Mail-Postfach eures Domain-Hosters (IONOS, Strato, …) oder Diensten wie Resend/Postmark im SMTP-Modus. `SMTP_FROM` sollte zur `SMTP_USER`-Domain passen, sonst landen Mails leicht im Spam.
|
||||||
|
|
||||||
|
Ob der Versand funktioniert, zeigt **Admin → E-Mail-Test**: Anmeldung, Test-Mail an die eigene Admin-Adresse und bei einem Fehler die Meldung des Mailservers samt Ursache.
|
||||||
|
|
||||||
|
### Microsoft 365 mit OAuth 2.0 (empfohlen)
|
||||||
|
|
||||||
|
Statt eines Benutzerpassworts meldet sich der Shop als eigene App-Registrierung an (Client-Credentials-Flow, SMTP mit XOAUTH2). Das funktioniert mit aktivierten Sicherheitsstandards und MFA, übersteht das Ende der Passwort-Anmeldung (Basic Auth) bei Exchange Online und gibt der App Zugriff auf genau ein Postfach. Aktiv, sobald `SMTP_OAUTH_TENANT_ID`, `SMTP_OAUTH_CLIENT_ID` und `SMTP_OAUTH_CLIENT_SECRET` gesetzt sind; `SMTP_PASSWORD` wird dann nicht gebraucht, `SMTP_HOST` ist standardmäßig `smtp.office365.com`.
|
||||||
|
|
||||||
|
1. **Absender-Postfach:** Am besten ein freigegebenes Postfach (z. B. `[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)**.
|
||||||
|
3. **Berechtigung:** API-Berechtigungen → Berechtigung hinzufügen → „Von meiner Organisation verwendete APIs“ → **Office 365 Exchange Online** → Anwendungsberechtigungen → **SMTP.SendAsApp** → danach „Administratorzustimmung erteilen“.
|
||||||
|
4. **Geheimer Schlüssel:** Zertifikate & Geheimnisse → Neuer geheimer Clientschlüssel → den **Wert** kopieren (nicht die Geheimnis-ID; der Wert wird nur einmal angezeigt). Ablaufdatum im Kalender eintragen.
|
||||||
|
5. **Objekt-ID der Unternehmensanwendung:** Entra → Unternehmensanwendungen → die App → **Objekt-ID** (nicht die der App-Registrierung).
|
||||||
|
6. **Exchange-Zugriff auf genau dieses Postfach** (Exchange Online PowerShell):
|
||||||
|
```powershell
|
||||||
|
Install-Module ExchangeOnlineManagement # einmalig
|
||||||
|
Connect-ExchangeOnline
|
||||||
|
New-ServicePrincipal -AppId <Client-ID> -ObjectId <Objekt-ID aus Schritt 5> -DisplayName "Wabenhain Shop Mail"
|
||||||
|
Add-MailboxPermission -Identity "[email protected]" -User <Objekt-ID aus Schritt 5> -AccessRights FullAccess
|
||||||
|
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.
|
||||||
|
7. **Kubernetes:** `SMTP_OAUTH_TENANT_ID` und `SMTP_OAUTH_CLIENT_ID` in `k8s/01-configmap.yaml`, `SMTP_OAUTH_CLIENT_SECRET` und `SMTP_USER` im Secret, `SMTP_PASSWORD` leer. Danach `kubectl rollout restart deploy/wabenhain-app -n wabenhain` und **Admin → E-Mail-Test**.
|
||||||
|
8. **Aufräumen:** Ein zuvor für SMTP genutztes Benutzerpasswort ändern und aus dem Secret entfernen.
|
||||||
|
|
||||||
|
Läuft der Schlüssel ab, meldet der E-Mail-Test „Das Client-Secret ist abgelaufen“ (AADSTS7000222): neuen Schlüssel anlegen, Secret ersetzen, Pod neu starten.
|
||||||
|
|
||||||
## CAPTCHA (Cloudflare Turnstile)
|
## CAPTCHA (Cloudflare Turnstile)
|
||||||
|
|
||||||
**Alle öffentlichen Eingabeformulare** sind durch [Cloudflare Turnstile](https://developers.cloudflare.com/turnstile/) gegen automatisierten Missbrauch geschützt — zusätzlich zum bestehenden Rate-Limiting:
|
**Alle öffentlichen Eingabeformulare** sind durch [Cloudflare Turnstile](https://developers.cloudflare.com/turnstile/) gegen automatisierten Missbrauch geschützt — zusätzlich zum bestehenden Rate-Limiting:
|
||||||
|
|||||||
+13
-2
@@ -30,6 +30,17 @@ data:
|
|||||||
# Domain liegen, sonst landen die Mails regelmaessig im Spam-Ordner.
|
# Domain liegen, sonst landen die Mails regelmaessig im Spam-Ordner.
|
||||||
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
|
||||||
# ---------------------------------------------------------------------
|
# ---------------------------------------------------------------------
|
||||||
@@ -42,8 +53,6 @@ data:
|
|||||||
# Prisma-Client-Varianten, siehe src/lib/prisma.ts.
|
# Prisma-Client-Varianten, siehe src/lib/prisma.ts.
|
||||||
DATABASE_PROVIDER: "postgresql"
|
DATABASE_PROVIDER: "postgresql"
|
||||||
|
|
||||||
# Port des SMTP-Anbieters. 587 ist STARTTLS (der Normalfall), 465 waere
|
|
||||||
# implizites TLS.
|
|
||||||
# Gleichzeitig offene Datenbankverbindungen JE INSTANZ. Leer = 20.
|
# Gleichzeitig offene Datenbankverbindungen JE INSTANZ. Leer = 20.
|
||||||
#
|
#
|
||||||
# Beim Hochskalieren mitdenken: der Wert multipliziert sich mit "replicas"
|
# Beim Hochskalieren mitdenken: der Wert multipliziert sich mit "replicas"
|
||||||
@@ -53,6 +62,8 @@ 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"
|
SMTP_PORT: "587"
|
||||||
|
|
||||||
# Verzeichnis fuer hochgeladene Bilder und Videos. MUSS exakt dem mountPath
|
# Verzeichnis fuer hochgeladene Bilder und Videos. MUSS exakt dem mountPath
|
||||||
|
|||||||
@@ -72,9 +72,23 @@ stringData:
|
|||||||
# keine Bestellbestaetigung, keine Versandmeldung, keine
|
# keine Bestellbestaetigung, keine Versandmeldung, keine
|
||||||
# Passwort-Zuruecksetzung. Funktioniert mit jedem SMTP-Anbieter; Port und
|
# Passwort-Zuruecksetzung. Funktioniert mit jedem SMTP-Anbieter; Port und
|
||||||
# Absenderadresse stehen als SMTP_PORT/SMTP_FROM in 01-configmap.yaml.
|
# Absenderadresse stehen als SMTP_PORT/SMTP_FROM in 01-configmap.yaml.
|
||||||
|
#
|
||||||
|
# SMTP_USER ist immer das Postfach, mit dem gesendet wird (z. B.
|
||||||
|
# [email protected]) — und sollte dieselbe Adresse wie SMTP_FROM sein.
|
||||||
|
#
|
||||||
|
# Microsoft 365 mit OAuth 2.0 (empfohlen): SMTP_OAUTH_CLIENT_SECRET setzen,
|
||||||
|
# dazu SMTP_OAUTH_TENANT_ID/SMTP_OAUTH_CLIENT_ID in 01-configmap.yaml.
|
||||||
|
# SMTP_HOST darf leer bleiben (Standard smtp.office365.com), SMTP_PASSWORD
|
||||||
|
# wird nicht gebraucht. Das Secret laeuft ab (hoechstens 24 Monate) —
|
||||||
|
# Ablaufdatum im Kalender eintragen.
|
||||||
|
#
|
||||||
|
# Andere Anbieter: SMTP_HOST und SMTP_PASSWORD setzen,
|
||||||
|
# SMTP_OAUTH_CLIENT_SECRET leer lassen.
|
||||||
SMTP_HOST: "CHANGE-ME"
|
SMTP_HOST: "CHANGE-ME"
|
||||||
SMTP_USER: "CHANGE-ME"
|
SMTP_USER: "CHANGE-ME"
|
||||||
SMTP_PASSWORD: "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
|
||||||
|
|||||||
@@ -146,9 +146,13 @@ mustWhenSet(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// --- E-Mail ----------------------------------------------------------------
|
// --- E-Mail ----------------------------------------------------------------
|
||||||
|
// Zwei gueltige Wege (siehe src/lib/mail.ts): Microsoft 365 per OAuth 2.0
|
||||||
|
// oder klassisch mit Passwort.
|
||||||
must(
|
must(
|
||||||
"SMTP_HOST, SMTP_USER und SMTP_PASSWORD gesetzt",
|
"SMTP eingerichtet (SMTP_USER + OAuth 2.0 oder SMTP_HOST/SMTP_PASSWORD)",
|
||||||
isSet("SMTP_HOST") && isSet("SMTP_USER") && isSet("SMTP_PASSWORD"),
|
isSet("SMTP_USER") &&
|
||||||
|
((isSet("SMTP_OAUTH_TENANT_ID") && isSet("SMTP_OAUTH_CLIENT_ID") && isSet("SMTP_OAUTH_CLIENT_SECRET")) ||
|
||||||
|
(isSet("SMTP_HOST") && isSet("SMTP_PASSWORD"))),
|
||||||
"Ohne SMTP werden E-Mails nur ins Server-Log geschrieben (siehe src/lib/mail.ts): keine Bestellbestaetigung, keine Versandmeldung, keine Passwort-Zuruecksetzung."
|
"Ohne SMTP 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
|
||||||
|
|||||||
@@ -26,7 +26,12 @@ export function SmtpTestPanel() {
|
|||||||
<p className="border border-heading/30 bg-forest/5 px-5 py-4 text-sm text-heading">{s.success(state.to)}</p>
|
<p className="border border-heading/30 bg-forest/5 px-5 py-4 text-sm text-heading">{s.success(state.to)}</p>
|
||||||
) : null}
|
) : null}
|
||||||
{state && "ok" in state && !state.ok && !state.configured ? (
|
{state && "ok" in state && !state.ok && !state.configured ? (
|
||||||
<p className="border border-red-300 px-5 py-4 text-sm text-red-700 dark:text-red-300">{s.notConfigured}</p>
|
<div className="space-y-2 border border-red-300 px-5 py-4 text-sm text-red-700 dark:text-red-300">
|
||||||
|
<p>{s.notConfigured}</p>
|
||||||
|
<p className="font-mono text-xs">
|
||||||
|
{s.missingLabel}: {state.missing.join(", ")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{state && "ok" in state && !state.ok && state.configured ? (
|
{state && "ok" in state && !state.ok && state.configured ? (
|
||||||
<div className="space-y-5 border border-red-300 px-5 py-5 text-sm">
|
<div className="space-y-5 border border-red-300 px-5 py-5 text-sm">
|
||||||
@@ -42,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">
|
||||||
SMTP_HOST={state.host} · SMTP_USER={state.user} · SMTP_FROM={state.from}
|
{state.authMode === "oauth2" ? s.authModeOAuth : s.authModePassword} · SMTP_HOST={state.host} · SMTP_USER={state.user} · SMTP_FROM={state.from}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1263,11 +1263,14 @@ export const de: Dictionary = {
|
|||||||
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.`,
|
||||||
notConfigured: "SMTP ist nicht eingerichtet: SMTP_HOST, SMTP_USER oder SMTP_PASSWORD fehlen im Kubernetes-Secret. Ohne sie werden E-Mails nur ins Server-Log geschrieben.",
|
notConfigured: "Der Mailversand ist nicht vollständig eingerichtet. Ohne die unten genannten Werte (Kubernetes-ConfigMap bzw. -Secret) werden E-Mails nur ins Server-Log geschrieben.",
|
||||||
failureHeading: "Versand fehlgeschlagen",
|
failureHeading: "Versand fehlgeschlagen",
|
||||||
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)",
|
||||||
|
authModePassword: "Passwort (Basic Auth)",
|
||||||
|
missingLabel: "Fehlende Einstellungen",
|
||||||
},
|
},
|
||||||
adminWithdrawalsPage: {
|
adminWithdrawalsPage: {
|
||||||
title: "Widerrufe",
|
title: "Widerrufe",
|
||||||
|
|||||||
@@ -1237,11 +1237,14 @@ export const en: Dictionary = {
|
|||||||
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}.`,
|
||||||
notConfigured: "SMTP is not set up: SMTP_HOST, SMTP_USER or SMTP_PASSWORD is missing from the Kubernetes secret. Without them, emails are only written to the server log.",
|
notConfigured: "Email delivery is not fully set up. Without the values listed below (Kubernetes config map or secret), emails are only written to the server log.",
|
||||||
failureHeading: "Delivery failed",
|
failureHeading: "Delivery failed",
|
||||||
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)",
|
||||||
|
authModePassword: "Password (basic auth)",
|
||||||
|
missingLabel: "Missing settings",
|
||||||
},
|
},
|
||||||
adminWithdrawalsPage: {
|
adminWithdrawalsPage: {
|
||||||
title: "Withdrawals",
|
title: "Withdrawals",
|
||||||
|
|||||||
@@ -829,6 +829,9 @@ export interface Dictionary {
|
|||||||
causeLabel: string;
|
causeLabel: string;
|
||||||
detailLabel: string;
|
detailLabel: string;
|
||||||
configLabel: string;
|
configLabel: string;
|
||||||
|
authModeOAuth: string;
|
||||||
|
authModePassword: string;
|
||||||
|
missingLabel: string;
|
||||||
};
|
};
|
||||||
adminWithdrawalsPage: {
|
adminWithdrawalsPage: {
|
||||||
title: string;
|
title: string;
|
||||||
|
|||||||
+155
-72
@@ -1,5 +1,7 @@
|
|||||||
import nodemailer from "nodemailer";
|
import nodemailer, { type Transporter } from "nodemailer";
|
||||||
import { explainSmtpError, type SmtpDiagnosis } from "@/lib/smtp-diagnose";
|
import type SMTPTransport from "nodemailer/lib/smtp-transport";
|
||||||
|
import { explainSmtpError, type SmtpAuthMode, type SmtpDiagnosis } from "@/lib/smtp-diagnose";
|
||||||
|
import { createMs365TokenProvider, type Ms365OAuthConfig, type Ms365TokenProvider } from "@/lib/ms365-oauth";
|
||||||
|
|
||||||
export interface MailAttachment {
|
export interface MailAttachment {
|
||||||
filename: string;
|
filename: string;
|
||||||
@@ -21,6 +23,22 @@ interface SendMailInput {
|
|||||||
attachments?: MailAttachment[];
|
attachments?: MailAttachment[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zwei Anmeldearten am Mailserver:
|
||||||
|
* - oauth2: Microsoft 365 per App-Registrierung (SMTP_OAUTH_TENANT_ID,
|
||||||
|
* SMTP_OAUTH_CLIENT_ID, SMTP_OAUTH_CLIENT_SECRET). Bevorzugt — kein
|
||||||
|
* Benutzerpasswort, unabhaengig vom Ende der Basic-Auth bei Microsoft.
|
||||||
|
* - password: klassisch mit SMTP_PASSWORD, fuer jeden anderen Anbieter.
|
||||||
|
* Sobald einer der SMTP_OAUTH_*-Werte gesetzt ist, gilt oauth2 — ein
|
||||||
|
* unvollstaendiger Satz faellt NICHT still auf das Passwort zurueck.
|
||||||
|
*/
|
||||||
|
type SmtpConfig =
|
||||||
|
| { authMode: "oauth2"; host: string; port: number; user: string; oauth: Ms365OAuthConfig }
|
||||||
|
| { authMode: "password"; host: string; port: number; user: string; pass: string };
|
||||||
|
|
||||||
|
const MS365_SMTP_HOST = "smtp.office365.com";
|
||||||
|
const TEST_TIMEOUT_MS = 15_000;
|
||||||
|
|
||||||
/** Maskiert eine E-Mail-Adresse fuer Log-Ausgaben, z.B. "ma***@example.com". */
|
/** 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("@");
|
||||||
@@ -28,64 +46,138 @@ function maskEmail(email: string): string {
|
|||||||
return `${local.slice(0, 2)}***@${domain}`;
|
return `${local.slice(0, 2)}***@${domain}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
let cachedTransport: ReturnType<typeof nodemailer.createTransport> | null = null;
|
|
||||||
// Nur einmal geprueft (nicht bei jedem sendMail-Aufruf) — macht ein in
|
|
||||||
// Produktion fehlendes SMTP-Setup im Server-Log sichtbar, statt dass
|
|
||||||
// Bestellbestaetigungen/Passwort-Resets fortlaufend lautlos verschwinden.
|
|
||||||
let warnedAboutMissingSmtpInProduction = false;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Zugangsdaten aus der Umgebung. Ein Zeilenumbruch am Ende entsteht leicht
|
* Liest die SMTP-Umgebung. Bei Geheimnissen wird nur ein Zeilenumbruch am
|
||||||
* beim Anlegen des Kubernetes-Secrets (echo ohne -n vor base64) und laesst
|
* Ende entfernt: er entsteht leicht beim Anlegen des Kubernetes-Secrets
|
||||||
* jede Anmeldung scheitern — er wird entfernt, alle anderen Zeichen bleiben.
|
* (echo ohne -n vor base64) und laesst jede Anmeldung scheitern. Sonstige
|
||||||
|
* Werte werden getrimmt.
|
||||||
|
*
|
||||||
|
* Bewusst woertliche Zugriffe auf process.env mit festem Namen: k8s-manifest-env.test.ts
|
||||||
|
* gleicht genau diese mit den Kubernetes-Manifesten ab.
|
||||||
*/
|
*/
|
||||||
function readSmtpConfig(): { host: string; port: number; user: string; pass: string } | null {
|
function readSmtpEnv() {
|
||||||
const host = process.env.SMTP_HOST?.trim();
|
const secret = (value: string | undefined) => value?.replace(/[\r\n]+$/, "") ?? "";
|
||||||
const user = process.env.SMTP_USER?.trim();
|
const plain = (value: string | undefined) => value?.trim() ?? "";
|
||||||
const pass = process.env.SMTP_PASSWORD?.replace(/[\r\n]+$/, "");
|
return {
|
||||||
if (!host || !user || !pass) return null;
|
SMTP_HOST: plain(process.env.SMTP_HOST),
|
||||||
return { host, port: Number(process.env.SMTP_PORT ?? 587), user, pass };
|
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[] {
|
||||||
|
const env = readSmtpEnv();
|
||||||
|
const wantsOAuth = OAUTH_SETTINGS.some((name) => env[name] !== "");
|
||||||
|
const required: SmtpEnvName[] = wantsOAuth ? ["SMTP_USER", ...OAUTH_SETTINGS] : ["SMTP_HOST", "SMTP_USER", "SMTP_PASSWORD"];
|
||||||
|
return required.filter((name) => env[name] === "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSmtpConfig(): SmtpConfig | null {
|
||||||
|
if (missingSmtpSettings().length > 0) return null;
|
||||||
|
const env = readSmtpEnv();
|
||||||
|
const port = Number(process.env.SMTP_PORT ?? 587);
|
||||||
|
if (env.SMTP_OAUTH_CLIENT_SECRET) {
|
||||||
|
return {
|
||||||
|
authMode: "oauth2",
|
||||||
|
host: env.SMTP_HOST || MS365_SMTP_HOST,
|
||||||
|
port,
|
||||||
|
user: env.SMTP_USER,
|
||||||
|
oauth: { tenantId: env.SMTP_OAUTH_TENANT_ID, clientId: env.SMTP_OAUTH_CLIENT_ID, clientSecret: env.SMTP_OAUTH_CLIENT_SECRET },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { authMode: "password", host: env.SMTP_HOST, port, user: env.SMTP_USER, pass: env.SMTP_PASSWORD };
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFromAddress(): string {
|
function getFromAddress(): string {
|
||||||
return process.env.SMTP_FROM || "Wabenhain <[email protected]>";
|
return process.env.SMTP_FROM || "Wabenhain <[email protected]>";
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTransport() {
|
function secretsOf(config: SmtpConfig | null): string[] {
|
||||||
if (cachedTransport) return cachedTransport;
|
if (!config) return [];
|
||||||
|
return config.authMode === "oauth2" ? [config.oauth.clientSecret] : [config.pass];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein Token-Provider pro Prozess: das Token (ca. 60 Minuten gueltig) wird
|
||||||
|
// ueber alle Mails hinweg wiederverwendet statt je Mail neu geholt.
|
||||||
|
let tokenProvider: { key: string; provider: Ms365TokenProvider } | null = null;
|
||||||
|
|
||||||
|
function getTokenProvider(oauth: Ms365OAuthConfig): Ms365TokenProvider {
|
||||||
|
const key = `${oauth.tenantId}|${oauth.clientId}|${oauth.clientSecret}`;
|
||||||
|
if (!tokenProvider || tokenProvider.key !== key) {
|
||||||
|
tokenProvider = { key, provider: createMs365TokenProvider(oauth) };
|
||||||
|
}
|
||||||
|
return tokenProvider.provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SmtpTransporter = Transporter<SMTPTransport.SentMessageInfo>;
|
||||||
|
|
||||||
|
function buildTransport(config: SmtpConfig, extra: { connectionTimeout?: number; greetingTimeout?: number } = {}): SmtpTransporter {
|
||||||
|
const auth: SMTPTransport.Options["auth"] =
|
||||||
|
config.authMode === "oauth2"
|
||||||
|
? {
|
||||||
|
type: "OAuth2" as const,
|
||||||
|
user: config.user,
|
||||||
|
// nodemailer fragt hier nach einem Token; renew=true nach einer
|
||||||
|
// Ablehnung durch den Server → frisches Token statt des gecachten.
|
||||||
|
provisionCallback: (_user: string, renew: boolean, callback: (err: Error | null, accessToken: string, expires: number) => void) => {
|
||||||
|
// expires 0: nodemailer fragt bei jeder Anmeldung neu — das Cachen
|
||||||
|
// mit Ablaufreserve uebernimmt der Token-Provider.
|
||||||
|
getTokenProvider(config.oauth)
|
||||||
|
.getToken(renew)
|
||||||
|
.then((token) => callback(null, token, 0), (error: Error) => callback(error, "", 0));
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: { user: config.user, pass: config.pass };
|
||||||
|
const options: SMTPTransport.Options = {
|
||||||
|
host: config.host,
|
||||||
|
port: config.port,
|
||||||
|
secure: config.port === 465,
|
||||||
|
// Auf 587 MUSS die Verbindung per STARTTLS verschluesselt werden, bevor
|
||||||
|
// Passwort oder Token gesendet werden — ohne requireTLS wuerde nodemailer
|
||||||
|
// bei einem Server ohne STARTTLS-Angebot (Fehlkonfiguration, Angriff) die
|
||||||
|
// Zugangsdaten im Klartext schicken.
|
||||||
|
requireTLS: config.port !== 465,
|
||||||
|
auth,
|
||||||
|
...extra,
|
||||||
|
};
|
||||||
|
return nodemailer.createTransport(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedTransport: { config: SmtpConfig; transport: SmtpTransporter } | null = null;
|
||||||
|
// Nur einmal gemeldet (nicht bei jedem sendMail-Aufruf) — macht ein in
|
||||||
|
// Produktion fehlendes SMTP-Setup im Server-Log sichtbar, statt dass
|
||||||
|
// Bestellbestaetigungen/Passwort-Resets fortlaufend lautlos verschwinden.
|
||||||
|
let warnedAboutMissingSmtpInProduction = false;
|
||||||
|
|
||||||
|
function getTransport(): { config: SmtpConfig; transport: SmtpTransporter } | null {
|
||||||
const config = readSmtpConfig();
|
const config = readSmtpConfig();
|
||||||
if (!config) {
|
if (!config) {
|
||||||
if (process.env.NODE_ENV === "production" && !warnedAboutMissingSmtpInProduction) {
|
if (process.env.NODE_ENV === "production" && !warnedAboutMissingSmtpInProduction) {
|
||||||
warnedAboutMissingSmtpInProduction = true;
|
warnedAboutMissingSmtpInProduction = true;
|
||||||
console.warn(
|
console.warn(
|
||||||
"SMTP_HOST/SMTP_USER/SMTP_PASSWORD sind in Produktion nicht gesetzt — Bestellbestaetigungen, Passwort-Resets und weitere Transaktions-E-Mails werden nur geloggt, nicht versendet."
|
`[mail] SMTP ist nicht vollstaendig eingerichtet (es fehlt: ${missingSmtpSettings().join(", ")}) — Transaktions-E-Mails werden nur geloggt, nicht versendet.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
if (!cachedTransport) cachedTransport = { config, transport: buildTransport(config) };
|
||||||
cachedTransport = nodemailer.createTransport({
|
|
||||||
host: config.host,
|
|
||||||
port: config.port,
|
|
||||||
secure: config.port === 465,
|
|
||||||
// Auf 587 MUSS die Verbindung per STARTTLS verschluesselt werden, bevor
|
|
||||||
// das Passwort gesendet wird — ohne requireTLS wuerde nodemailer bei
|
|
||||||
// einem Server ohne STARTTLS-Angebot (Fehlkonfiguration, Angriff) die
|
|
||||||
// Zugangsdaten im Klartext schicken.
|
|
||||||
requireTLS: config.port !== 465,
|
|
||||||
auth: { user: config.user, pass: config.pass },
|
|
||||||
});
|
|
||||||
return cachedTransport;
|
return cachedTransport;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Versendet eine E-Mail ueber den in SMTP_HOST/SMTP_USER/SMTP_PASSWORD
|
* Versendet eine E-Mail ueber den konfigurierten Mailserver. Ohne
|
||||||
* konfigurierten Mailserver. Ohne Konfiguration wird die Mail NICHT
|
* Konfiguration wird die Mail NICHT gesendet, sondern nur geloggt — analog
|
||||||
* gesendet, sondern nur geloggt — analog zum Stripe-Fallback ohne Keys,
|
* zum Stripe-Fallback ohne Keys, damit lokale Entwicklung und Deployments
|
||||||
* damit lokale Entwicklung und Deployments ohne Mail-Setup nicht abstuerzen.
|
* ohne Mail-Setup nicht abstuerzen. Wirft absichtlich keinen Fehler: ein
|
||||||
* Wirft absichtlich keinen Fehler: ein Mail-Ausfall darf eine Bestellung
|
* Mail-Ausfall darf eine Bestellung oder Registrierung nicht verhindern.
|
||||||
* oder Registrierung nicht 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
|
||||||
@@ -94,19 +186,15 @@ function getTransport() {
|
|||||||
* Kundschaft auf eine Mail, die nie kommt.
|
* Kundschaft auf eine Mail, die nie kommt.
|
||||||
*/
|
*/
|
||||||
export async function sendMail(input: SendMailInput): Promise<boolean> {
|
export async function sendMail(input: SendMailInput): Promise<boolean> {
|
||||||
const transport = getTransport();
|
const active = getTransport();
|
||||||
const from = getFromAddress();
|
if (!active) {
|
||||||
|
console.warn(`[mail] SMTP nicht konfiguriert — E-Mail an ${maskEmail(input.to)} ("${input.subject}") wurde nicht gesendet.`);
|
||||||
if (!transport) {
|
|
||||||
console.warn(
|
|
||||||
`[mail] SMTP nicht konfiguriert — E-Mail an ${maskEmail(input.to)} ("${input.subject}") wurde nicht gesendet.`
|
|
||||||
);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await transport.sendMail({
|
await active.transport.sendMail({
|
||||||
from,
|
from: getFromAddress(),
|
||||||
to: input.to,
|
to: input.to,
|
||||||
subject: input.subject,
|
subject: input.subject,
|
||||||
html: input.html,
|
html: input.html,
|
||||||
@@ -115,52 +203,47 @@ export async function sendMail(input: SendMailInput): Promise<boolean> {
|
|||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const diagnosis = explainSmtpError(error, readSmtpConfig()?.pass);
|
const diagnosis = explainSmtpError(error, { secrets: secretsOf(active.config), authMode: active.config.authMode });
|
||||||
console.error(
|
console.error(`[mail] Versand an ${maskEmail(input.to)} fehlgeschlagen (${diagnosis.cause}): ${diagnosis.detail} — ${diagnosis.hint}`);
|
||||||
`[mail] Versand an ${maskEmail(input.to)} fehlgeschlagen (${diagnosis.cause}): ${diagnosis.detail} — ${diagnosis.hint}`
|
|
||||||
);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SmtpTestBase {
|
||||||
|
host: string;
|
||||||
|
user: string;
|
||||||
|
from: string;
|
||||||
|
authMode: SmtpAuthMode;
|
||||||
|
}
|
||||||
|
|
||||||
export type SmtpTestResult =
|
export type SmtpTestResult =
|
||||||
| { ok: true; host: string; user: string; from: string }
|
| ({ ok: true } & SmtpTestBase)
|
||||||
| { ok: false; configured: false }
|
| { ok: false; configured: false; missing: string[] }
|
||||||
| { ok: false; configured: true; host: string; user: string; from: string; diagnosis: SmtpDiagnosis };
|
| ({ ok: false; configured: true; diagnosis: SmtpDiagnosis } & SmtpTestBase);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prueft die SMTP-Konfiguration im laufenden Betrieb: Verbindung + Anmeldung
|
* Prueft die SMTP-Konfiguration im laufenden Betrieb: 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, damit
|
* bewusst einen frischen Transport statt des zwischengespeicherten. Weder
|
||||||
* ein geaendertes Secret nach einem Neustart sofort getestet wird. Das
|
* Passwort noch Client-Secret erscheinen im Ergebnis.
|
||||||
* Passwort 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 };
|
if (!config) return { ok: false, configured: false, missing: missingSmtpSettings() };
|
||||||
const from = getFromAddress();
|
const base: SmtpTestBase = { host: config.host, user: config.user, from: getFromAddress(), authMode: config.authMode };
|
||||||
const transport = nodemailer.createTransport({
|
const transport = buildTransport(config, { connectionTimeout: TEST_TIMEOUT_MS, greetingTimeout: TEST_TIMEOUT_MS });
|
||||||
host: config.host,
|
|
||||||
port: config.port,
|
|
||||||
secure: config.port === 465,
|
|
||||||
requireTLS: config.port !== 465,
|
|
||||||
auth: { user: config.user, pass: config.pass },
|
|
||||||
connectionTimeout: 15_000,
|
|
||||||
greetingTimeout: 15_000,
|
|
||||||
});
|
|
||||||
const base = { host: config.host, user: config.user, from };
|
|
||||||
try {
|
try {
|
||||||
await transport.verify();
|
await transport.verify();
|
||||||
await transport.sendMail({
|
await transport.sendMail({
|
||||||
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} mit dem Absender ${from} senden kann.`,
|
text: `Diese Test-Mail bestaetigt, dass der Shop ueber ${config.host} als ${config.user} (Anmeldung: ${config.authMode === "oauth2" ? "OAuth 2.0" : "Passwort"}) mit dem Absender ${base.from} senden kann.`,
|
||||||
});
|
});
|
||||||
return { ok: true, ...base };
|
return { ok: true, ...base };
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
return { ok: false, configured: true, ...base, diagnosis: explainSmtpError(error, config.pass) };
|
return { ok: false, configured: true, ...base, diagnosis: explainSmtpError(error, { secrets: secretsOf(config), authMode: config.authMode }) };
|
||||||
} finally {
|
} finally {
|
||||||
transport.close();
|
transport.close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { createMs365TokenProvider, Ms365TokenError } from "./ms365-oauth";
|
||||||
|
|
||||||
|
const CONFIG = { tenantId: "wabenhain.onmicrosoft.com", clientId: "11111111-2222-3333-4444-555555555555", clientSecret: "s3cr3t~value" };
|
||||||
|
|
||||||
|
function tokenResponse(token: string, expiresIn = 3600): Response {
|
||||||
|
return new Response(JSON.stringify({ token_type: "Bearer", expires_in: expiresIn, access_token: token }), { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("createMs365TokenProvider", () => {
|
||||||
|
it("requests a client-credentials token for Exchange Online", async () => {
|
||||||
|
const fetchImpl = vi.fn(async () => tokenResponse("tok-1"));
|
||||||
|
const provider = createMs365TokenProvider(CONFIG, { fetchImpl });
|
||||||
|
|
||||||
|
await expect(provider.getToken()).resolves.toBe("tok-1");
|
||||||
|
|
||||||
|
const [url, init] = fetchImpl.mock.calls[0] as unknown as [string, RequestInit];
|
||||||
|
expect(url).toBe("https://login.microsoftonline.com/wabenhain.onmicrosoft.com/oauth2/v2.0/token");
|
||||||
|
expect(init.method).toBe("POST");
|
||||||
|
const body = new URLSearchParams(String(init.body));
|
||||||
|
expect(body.get("grant_type")).toBe("client_credentials");
|
||||||
|
expect(body.get("scope")).toBe("https://outlook.office365.com/.default");
|
||||||
|
expect(body.get("client_id")).toBe(CONFIG.clientId);
|
||||||
|
expect(body.get("client_secret")).toBe(CONFIG.clientSecret);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reuses the cached token until shortly before it expires", async () => {
|
||||||
|
let now = 1_000_000;
|
||||||
|
const fetchImpl = vi.fn().mockResolvedValueOnce(tokenResponse("tok-1", 3600)).mockResolvedValueOnce(tokenResponse("tok-2", 3600));
|
||||||
|
const provider = createMs365TokenProvider(CONFIG, { fetchImpl, now: () => now });
|
||||||
|
|
||||||
|
expect(await provider.getToken()).toBe("tok-1");
|
||||||
|
now += 50 * 60 * 1000; // 50 Minuten: noch gueltig
|
||||||
|
expect(await provider.getToken()).toBe("tok-1");
|
||||||
|
now += 6 * 60 * 1000; // 56 Minuten: innerhalb der 5-Minuten-Reserve
|
||||||
|
expect(await provider.getToken()).toBe("tok-2");
|
||||||
|
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fetches a fresh token when a renewal is forced", async () => {
|
||||||
|
const fetchImpl = vi.fn().mockResolvedValueOnce(tokenResponse("tok-1")).mockResolvedValueOnce(tokenResponse("tok-2"));
|
||||||
|
const provider = createMs365TokenProvider(CONFIG, { fetchImpl });
|
||||||
|
await provider.getToken();
|
||||||
|
expect(await provider.getToken(true)).toBe("tok-2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shares one request between concurrent callers", async () => {
|
||||||
|
const fetchImpl = vi.fn(async () => tokenResponse("tok-1"));
|
||||||
|
const provider = createMs365TokenProvider(CONFIG, { fetchImpl });
|
||||||
|
const tokens = await Promise.all([provider.getToken(), provider.getToken(), provider.getToken()]);
|
||||||
|
expect(tokens).toEqual(["tok-1", "tok-1", "tok-1"]);
|
||||||
|
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports the AADSTS code and never the client secret", async () => {
|
||||||
|
const fetchImpl = vi.fn(
|
||||||
|
async () =>
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "invalid_client",
|
||||||
|
error_description: `AADSTS7000215: Invalid client secret provided. Ensure the secret being sent is the client secret value, not the client secret ID, for a secret added to app '${CONFIG.clientId}'. Secret: ${CONFIG.clientSecret}`,
|
||||||
|
}),
|
||||||
|
{ status: 401 }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const provider = createMs365TokenProvider(CONFIG, { fetchImpl });
|
||||||
|
const error = await provider.getToken().catch((e: unknown) => e);
|
||||||
|
expect(error).toBeInstanceOf(Ms365TokenError);
|
||||||
|
expect((error as Ms365TokenError).aadCode).toBe("7000215");
|
||||||
|
expect((error as Error).message).not.toContain(CONFIG.clientSecret);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not cache a failed request", async () => {
|
||||||
|
const fetchImpl = vi.fn().mockRejectedValueOnce(new Error("fetch failed")).mockResolvedValueOnce(tokenResponse("tok-1"));
|
||||||
|
const provider = createMs365TokenProvider(CONFIG, { fetchImpl });
|
||||||
|
await expect(provider.getToken()).rejects.toBeInstanceOf(Ms365TokenError);
|
||||||
|
await expect(provider.getToken()).resolves.toBe("tok-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a tenant id that could change the request URL", () => {
|
||||||
|
expect(() => createMs365TokenProvider({ ...CONFIG, tenantId: "evil.com/x?" })).toThrow(Ms365TokenError);
|
||||||
|
expect(() => createMs365TokenProvider({ ...CONFIG, tenantId: "../common" })).toThrow(Ms365TokenError);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
/**
|
||||||
|
* OAuth 2.0 fuer den Mailversand ueber Microsoft 365 (Exchange Online SMTP
|
||||||
|
* mit XOAUTH2). Client-Credentials-Flow: der Shop meldet sich als eigene
|
||||||
|
* App-Registrierung in Entra ID an — kein Benutzerpasswort, kein MFA-Konflikt,
|
||||||
|
* unabhaengig vom Ende der Basic-Auth fuer SMTP. Die App darf nur mit dem
|
||||||
|
* Postfach senden, fuer das Exchange ihr ausdruecklich Zugriff gibt
|
||||||
|
* (Berechtigung SMTP.SendAsApp + Add-MailboxPermission, siehe README).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Ms365OAuthConfig {
|
||||||
|
tenantId: string;
|
||||||
|
clientId: string;
|
||||||
|
clientSecret: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Ms365TokenError extends Error {
|
||||||
|
/** Fehlernummer von Entra ID ("AADSTS7000215" → "7000215"), falls vorhanden. */
|
||||||
|
readonly aadCode: string | null;
|
||||||
|
|
||||||
|
constructor(message: string, aadCode: string | null = null) {
|
||||||
|
super(message);
|
||||||
|
this.name = "Ms365TokenError";
|
||||||
|
this.aadCode = aadCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Ms365TokenProvider {
|
||||||
|
/** Liefert ein gueltiges Zugriffstoken; `forceRefresh` holt ein neues (nach einer Ablehnung durch den Mailserver). */
|
||||||
|
getToken(forceRefresh?: boolean): Promise<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProviderOptions {
|
||||||
|
fetchImpl?: typeof fetch;
|
||||||
|
now?: () => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCOPE = "https://outlook.office365.com/.default";
|
||||||
|
/** Token 5 Minuten vor Ablauf erneuern — ein laufender Versand soll nie mit einem gerade abgelaufenen Token starten. */
|
||||||
|
const EXPIRY_MARGIN_MS = 5 * 60 * 1000;
|
||||||
|
const REQUEST_TIMEOUT_MS = 15_000;
|
||||||
|
const GUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
|
const DOMAIN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$/i;
|
||||||
|
|
||||||
|
function redact(text: string, secret: string): string {
|
||||||
|
return secret ? text.split(secret).join("***") : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMs365TokenProvider(config: Ms365OAuthConfig, options: ProviderOptions = {}): Ms365TokenProvider {
|
||||||
|
// Die Tenant-ID landet im URL-Pfad: nur GUID oder Domainname, sonst
|
||||||
|
// koennte ein manipulierter Wert die Anfrage an einen anderen Endpunkt lenken.
|
||||||
|
if (!GUID.test(config.tenantId) && !DOMAIN.test(config.tenantId)) {
|
||||||
|
throw new Ms365TokenError("SMTP_OAUTH_TENANT_ID ist weder eine Tenant-GUID noch ein Domainname.");
|
||||||
|
}
|
||||||
|
const fetchImpl = options.fetchImpl ?? fetch;
|
||||||
|
const now = options.now ?? Date.now;
|
||||||
|
const url = `https://login.microsoftonline.com/${config.tenantId}/oauth2/v2.0/token`;
|
||||||
|
|
||||||
|
let cached: { token: string; refreshAt: number } | null = null;
|
||||||
|
let inFlight: Promise<string> | null = null;
|
||||||
|
|
||||||
|
async function requestToken(): Promise<string> {
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
grant_type: "client_credentials",
|
||||||
|
client_id: config.clientId,
|
||||||
|
client_secret: config.clientSecret,
|
||||||
|
scope: SCOPE,
|
||||||
|
});
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetchImpl(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: body.toString(),
|
||||||
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
|
||||||
|
throw new Ms365TokenError(`Entra ID nicht erreichbar: ${redact(message, config.clientSecret)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: unknown = await response.json().catch(() => null);
|
||||||
|
const data = (payload && typeof payload === "object" ? payload : {}) as Record<string, unknown>;
|
||||||
|
if (!response.ok || typeof data.access_token !== "string") {
|
||||||
|
const description = typeof data.error_description === "string" ? data.error_description : `HTTP ${response.status}`;
|
||||||
|
const aadCode = /AADSTS(\d+)/.exec(description)?.[1] ?? null;
|
||||||
|
// Nur die erste Zeile: Entra haengt Trace- und Korrelations-IDs an.
|
||||||
|
throw new Ms365TokenError(`Token-Abruf abgelehnt: ${redact(description.split(/\r?\n/)[0], config.clientSecret)}`, aadCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiresInMs = (typeof data.expires_in === "number" ? data.expires_in : 3600) * 1000;
|
||||||
|
cached = { token: data.access_token, refreshAt: now() + expiresInMs - EXPIRY_MARGIN_MS };
|
||||||
|
return data.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
async getToken(forceRefresh = false): Promise<string> {
|
||||||
|
if (!forceRefresh && cached && now() < cached.refreshAt) return cached.token;
|
||||||
|
// Parallele Mails teilen sich einen Abruf, statt Entra gleichzeitig mehrfach zu fragen.
|
||||||
|
if (!inFlight) {
|
||||||
|
inFlight = requestToken().finally(() => {
|
||||||
|
inFlight = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return inFlight;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { explainSmtpError } from "./smtp-diagnose";
|
import { explainSmtpError } from "./smtp-diagnose";
|
||||||
|
import { Ms365TokenError } from "./ms365-oauth";
|
||||||
|
|
||||||
function smtpError(message: string, extra: Record<string, unknown> = {}): Error {
|
function smtpError(message: string, extra: Record<string, unknown> = {}): Error {
|
||||||
return Object.assign(new Error(message), extra);
|
return Object.assign(new Error(message), extra);
|
||||||
@@ -55,9 +56,54 @@ describe("explainSmtpError", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("never echoes a password that appears in the error", () => {
|
it("never echoes a password that appears in the error", () => {
|
||||||
const result = explainSmtpError(smtpError("535 5.7.3 Authentication unsuccessful"), "geheim123");
|
const result = explainSmtpError(smtpError("535 5.7.3 Authentication unsuccessful"), { secrets: ["geheim123"] });
|
||||||
expect(JSON.stringify(result)).not.toContain("geheim123");
|
expect(JSON.stringify(result)).not.toContain("geheim123");
|
||||||
const leaking = explainSmtpError(smtpError("AUTH PLAIN failed for secret geheim123"), "geheim123");
|
const leaking = explainSmtpError(smtpError("AUTH PLAIN failed for secret geheim123"), { secrets: ["geheim123"] });
|
||||||
expect(leaking.detail).not.toContain("geheim123");
|
expect(leaking.detail).not.toContain("geheim123");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("explainSmtpError with OAuth 2.0", () => {
|
||||||
|
it("explains an invalid client secret", () => {
|
||||||
|
const result = explainSmtpError(new Ms365TokenError("Token-Abruf abgelehnt: AADSTS7000215: Invalid client secret provided.", "7000215"), {
|
||||||
|
authMode: "oauth2",
|
||||||
|
});
|
||||||
|
expect(result.cause).toBe("oauth-token");
|
||||||
|
expect(result.hint).toContain("Wert");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("explains an expired client secret", () => {
|
||||||
|
const result = explainSmtpError(new Ms365TokenError("AADSTS7000222: The provided client secret keys are expired.", "7000222"), {
|
||||||
|
authMode: "oauth2",
|
||||||
|
});
|
||||||
|
expect(result.cause).toBe("oauth-token");
|
||||||
|
expect(result.hint).toContain("abgelaufen");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("explains an unknown app or tenant", () => {
|
||||||
|
expect(explainSmtpError(new Ms365TokenError("AADSTS700016: Application not found", "700016"), { authMode: "oauth2" }).hint).toContain(
|
||||||
|
"SMTP_OAUTH_CLIENT_ID"
|
||||||
|
);
|
||||||
|
expect(explainSmtpError(new Ms365TokenError("AADSTS90002: Tenant not found", "90002"), { authMode: "oauth2" }).hint).toContain(
|
||||||
|
"SMTP_OAUTH_TENANT_ID"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps a rejected XOAUTH2 login to missing mailbox access", () => {
|
||||||
|
const result = explainSmtpError(smtpError("Invalid login: 535 5.7.3 Authentication unsuccessful", { code: "EAUTH" }), { authMode: "oauth2" });
|
||||||
|
expect(result.cause).toBe("oauth-mailbox-access");
|
||||||
|
expect(result.hint).toContain("Add-MailboxPermission");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps SMTP AUTH disabled as its own cause in OAuth mode", () => {
|
||||||
|
expect(
|
||||||
|
explainSmtpError(smtpError("535 5.7.139 Authentication unsuccessful, SmtpClientAuthentication is disabled for the Mailbox."), { authMode: "oauth2" }).cause
|
||||||
|
).toBe("smtp-auth-disabled");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redacts the access token and client secret", () => {
|
||||||
|
const result = explainSmtpError(smtpError("AUTH XOAUTH2 eyJtoken failed secret-xyz"), { authMode: "oauth2", secrets: ["eyJtoken", "secret-xyz"] });
|
||||||
|
expect(result.detail).not.toContain("eyJtoken");
|
||||||
|
expect(result.detail).not.toContain("secret-xyz");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,7 +5,13 @@
|
|||||||
* nur "keine Mails kommen an".
|
* nur "keine Mails kommen an".
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { Ms365TokenError } from "@/lib/ms365-oauth";
|
||||||
|
|
||||||
|
export type SmtpAuthMode = "password" | "oauth2";
|
||||||
|
|
||||||
export type SmtpFailureCause =
|
export type SmtpFailureCause =
|
||||||
|
| "oauth-token"
|
||||||
|
| "oauth-mailbox-access"
|
||||||
| "basic-auth-retired"
|
| "basic-auth-retired"
|
||||||
| "security-defaults"
|
| "security-defaults"
|
||||||
| "smtp-auth-disabled"
|
| "smtp-auth-disabled"
|
||||||
@@ -24,6 +30,10 @@ export interface SmtpDiagnosis {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const HINTS: Record<SmtpFailureCause, string> = {
|
const HINTS: Record<SmtpFailureCause, string> = {
|
||||||
|
"oauth-token":
|
||||||
|
"Microsoft Entra ID gibt kein Zugriffstoken aus. Prüfe SMTP_OAUTH_TENANT_ID, SMTP_OAUTH_CLIENT_ID und SMTP_OAUTH_CLIENT_SECRET.",
|
||||||
|
"oauth-mailbox-access":
|
||||||
|
"Das Token ist gültig, aber Exchange lässt die App nicht mit dem Postfach aus SMTP_USER senden. Prüfe in dieser Reihenfolge: (1) API-Berechtigung „Office 365 Exchange Online → SMTP.SendAsApp“ (Anwendung) mit Administratorzustimmung, (2) New-ServicePrincipal -AppId <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":
|
"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.",
|
"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":
|
"security-defaults":
|
||||||
@@ -42,6 +52,18 @@ const HINTS: Record<SmtpFailureCause, string> = {
|
|||||||
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. */
|
||||||
|
const AAD_HINTS: Record<string, string> = {
|
||||||
|
"7000215":
|
||||||
|
"Das Client-Secret ist falsch. In SMTP_OAUTH_CLIENT_SECRET gehört der „Wert“ des geheimen Clientschlüssels, nicht die „Geheimnis-ID“. Der Wert wird nur direkt nach dem Anlegen angezeigt — sonst einen neuen Schlüssel erstellen.",
|
||||||
|
"7000222":
|
||||||
|
"Das Client-Secret ist abgelaufen. Entra ID → App-Registrierungen → Zertifikate & Geheimnisse → neuen geheimen Clientschlüssel anlegen, SMTP_OAUTH_CLIENT_SECRET im Kubernetes-Secret ersetzen, Pod neu starten. Ablaufdatum notieren.",
|
||||||
|
"700016":
|
||||||
|
"Die App-Registrierung wurde in diesem Tenant nicht gefunden. Prüfe SMTP_OAUTH_CLIENT_ID (Anwendungs-ID/Client-ID) und ob SMTP_OAUTH_TENANT_ID zum selben Tenant gehört.",
|
||||||
|
"90002": "Der Tenant existiert nicht. Prüfe SMTP_OAUTH_TENANT_ID (Verzeichnis-ID/Mandanten-ID oder z. B. wabenhain.onmicrosoft.com).",
|
||||||
|
"900023": "Die Tenant-Angabe ist ungültig. Prüfe SMTP_OAUTH_TENANT_ID (Verzeichnis-ID/Mandanten-ID).",
|
||||||
|
};
|
||||||
|
|
||||||
const MAX_DETAIL_LENGTH = 300;
|
const MAX_DETAIL_LENGTH = 300;
|
||||||
|
|
||||||
function readError(error: unknown): { message: string; code: string } {
|
function readError(error: unknown): { message: string; code: string } {
|
||||||
@@ -52,14 +74,19 @@ function readError(error: unknown): { message: string; code: string } {
|
|||||||
return { message: typeof error === "string" ? error : "", code: "" };
|
return { message: typeof error === "string" ? error : "", code: "" };
|
||||||
}
|
}
|
||||||
|
|
||||||
function classify(message: string, code: string): SmtpFailureCause {
|
function classify(message: string, code: string, authMode: SmtpAuthMode, isTokenError: boolean): SmtpFailureCause {
|
||||||
|
if (isTokenError) return "oauth-token";
|
||||||
const text = message.toLowerCase();
|
const text = message.toLowerCase();
|
||||||
if (text.includes("5.7.30") || text.includes("basic authentication is not supported")) return "basic-auth-retired";
|
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("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("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")) return "wrong-credentials";
|
if (code === "EAUTH" || text.includes("535") || text.includes("authentication unsuccessful")) {
|
||||||
|
// Mit OAuth gibt es kein Passwort, das falsch sein koennte: ein abgelehntes
|
||||||
|
// XOAUTH2-Token heisst fast immer fehlender Postfachzugriff der App.
|
||||||
|
return authMode === "oauth2" ? "oauth-mailbox-access" : "wrong-credentials";
|
||||||
|
}
|
||||||
if (["ETIMEDOUT", "ECONNREFUSED", "ECONNECTION", "EDNS", "ENOTFOUND", "ESOCKET", "ECONNRESET"].includes(code) || /etimedout|econnrefused|enotfound/.test(text)) {
|
if (["ETIMEDOUT", "ECONNREFUSED", "ECONNECTION", "EDNS", "ENOTFOUND", "ESOCKET", "ECONNRESET"].includes(code) || /etimedout|econnrefused|enotfound/.test(text)) {
|
||||||
return "network";
|
return "network";
|
||||||
}
|
}
|
||||||
@@ -67,9 +94,17 @@ function classify(message: string, code: string): SmtpFailureCause {
|
|||||||
return "unknown";
|
return "unknown";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function explainSmtpError(error: unknown, secret?: string): SmtpDiagnosis {
|
interface ExplainOptions {
|
||||||
|
/** Werte, die nie in der Ausgabe erscheinen duerfen (Passwort, Client-Secret, Token). */
|
||||||
|
secrets?: (string | undefined)[];
|
||||||
|
authMode?: SmtpAuthMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function explainSmtpError(error: unknown, options: ExplainOptions = {}): SmtpDiagnosis {
|
||||||
const { message, code } = readError(error);
|
const { message, code } = readError(error);
|
||||||
const cause = classify(message, code);
|
const isTokenError = error instanceof Ms365TokenError;
|
||||||
const redacted = secret ? message.split(secret).join("***") : message;
|
const cause = classify(message, code, options.authMode ?? "password", isTokenError);
|
||||||
return { cause, hint: HINTS[cause], detail: redacted.slice(0, MAX_DETAIL_LENGTH) };
|
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);
|
||||||
|
return { cause, hint: aadHint ?? HINTS[cause], detail: redacted.slice(0, MAX_DETAIL_LENGTH) };
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user