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:
Nick SzaunigandClaude Opus 5.5 committed 2026-09-26 18:17:33 +02:00
1 parent 302a18baa6
commit 0620e9d79a
13 files changed
+511 -88

No files matched your search

+13 -2
View File
@@ -30,6 +30,17 @@ data:
# Domain liegen, sonst landen die Mails regelmaessig im Spam-Ordner.
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
# ---------------------------------------------------------------------
@@ -42,8 +53,6 @@ data:
# Prisma-Client-Varianten, siehe src/lib/prisma.ts.
DATABASE_PROVIDER: "postgresql"
# Port des SMTP-Anbieters. 587 ist STARTTLS (der Normalfall), 465 waere
# implizites TLS.
# Gleichzeitig offene Datenbankverbindungen JE INSTANZ. Leer = 20.
#
# Beim Hochskalieren mitdenken: der Wert multipliziert sich mit "replicas"
@@ -53,6 +62,8 @@ data:
# welche. Entweder diesen Wert senken oder max_connections anheben.
DATABASE_POOL_SIZE: "20"
# Port des SMTP-Anbieters. 587 ist STARTTLS (der Normalfall, auch fuer
# Microsoft 365), 465 waere implizites TLS.
SMTP_PORT: "587"
# Verzeichnis fuer hochgeladene Bilder und Videos. MUSS exakt dem mountPath
+14
View File
@@ -72,9 +72,23 @@ stringData:
# keine Bestellbestaetigung, keine Versandmeldung, keine
# Passwort-Zuruecksetzung. Funktioniert mit jedem SMTP-Anbieter; Port und
# Absenderadresse stehen als SMTP_PORT/SMTP_FROM in 01-configmap.yaml.
#
# SMTP_USER ist immer das Postfach, mit dem gesendet wird (z. B.
# [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_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