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

Mail now uses the same Entra ID app registration as "Sign in with
Microsoft" (ENTRA_TENANT_ID, ENTRA_CLIENT_ID, ENTRA_CLIENT_SECRET), so
only one enterprise application is needed. The app additionally gets
the SMTP.SendAsApp permission; Exchange still limits it to the mailbox
granted via Add-MailboxPermission.

Password (basic auth) sending is removed entirely: SMTP_HOST, SMTP_PORT,
SMTP_PASSWORD and the separate SMTP_OAUTH_* settings are gone. The
transport is fixed to smtp.office365.com:587 with required STARTTLS.
SMTP_USER (sending mailbox) and SMTP_FROM remain.

- lib/entra.ts exposes readEntraAppCredentials(), shared by SSO and mail
- diagnosis drops basic-auth-only causes; hints name the ENTRA_* values
  and warn that an expired secret breaks sign-in and mail together
- admin e-mail test, .env.example, docker-compose, k8s manifests,
  preflight and README updated

Verified against Entra ID with fake values: token error classified,
client secret never exposed, missing ENTRA_CLIENT_SECRET reported.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-09-26 19:23:05 +02:00
1 parent 0620e9d79a
commit 11910dd6dd
15 files changed
+211 -309

No files matched your search

+69 -107
View File
@@ -1,7 +1,8 @@
import nodemailer, { type Transporter } from "nodemailer";
import type SMTPTransport from "nodemailer/lib/smtp-transport";
import { explainSmtpError, type SmtpAuthMode, type SmtpDiagnosis } from "@/lib/smtp-diagnose";
import { createMs365TokenProvider, type Ms365OAuthConfig, type Ms365TokenProvider } from "@/lib/ms365-oauth";
import { explainSmtpError, type SmtpDiagnosis } from "@/lib/smtp-diagnose";
import { createMs365TokenProvider, type Ms365TokenProvider } from "@/lib/ms365-oauth";
import { readEntraAppCredentials, type EntraAppCredentials } from "@/lib/entra";
export interface MailAttachment {
filename: string;
@@ -24,21 +25,29 @@ interface SendMailInput {
}
/**
* Zwei Anmeldearten am Mailserver:
* - oauth2: Microsoft 365 per App-Registrierung (SMTP_OAUTH_TENANT_ID,
* SMTP_OAUTH_CLIENT_ID, SMTP_OAUTH_CLIENT_SECRET). Bevorzugt — kein
* Benutzerpasswort, unabhaengig vom Ende der Basic-Auth bei Microsoft.
* - password: klassisch mit SMTP_PASSWORD, fuer jeden anderen Anbieter.
* Sobald einer der SMTP_OAUTH_*-Werte gesetzt ist, gilt oauth2 — ein
* unvollstaendiger Satz faellt NICHT still auf das Passwort zurueck.
* Versand ausschliesslich ueber Microsoft 365 mit OAuth 2.0 (SMTP XOAUTH2,
* Client-Credentials-Flow). Als App dient dieselbe App-Registrierung wie fuer
* "Mit Microsoft anmelden" (ENTRA_TENANT_ID, ENTRA_CLIENT_ID,
* ENTRA_CLIENT_SECRET) — eine Unternehmensanwendung statt zwei. Eine
* Anmeldung per Passwort (Basic Auth) gibt es bewusst nicht mehr: kein
* Benutzerpasswort im Cluster, kein Konflikt mit MFA/Sicherheitsstandards,
* nicht betroffen vom Ende der Basic Auth bei Exchange Online.
*
* SMTP_USER ist das Postfach, mit dem gesendet wird; Exchange erlaubt der App
* nur die Postfaecher, die ihr per Add-MailboxPermission freigegeben wurden.
*/
type SmtpConfig =
| { authMode: "oauth2"; host: string; port: number; user: string; oauth: Ms365OAuthConfig }
| { authMode: "password"; host: string; port: number; user: string; pass: string };
interface SmtpConfig {
user: string;
app: EntraAppCredentials;
}
const MS365_SMTP_HOST = "smtp.office365.com";
/** STARTTLS — der Port, den Microsoft fuer SMTP-Client-Submission vorsieht. */
const MS365_SMTP_PORT = 587;
const TEST_TIMEOUT_MS = 15_000;
type SmtpTransporter = Transporter<SMTPTransport.SentMessageInfo>;
/** Maskiert eine E-Mail-Adresse fuer Log-Ausgaben, z.B. "ma***@example.com". */
function maskEmail(email: string): string {
const [local, domain] = email.split("@");
@@ -46,106 +55,60 @@ function maskEmail(email: string): string {
return `${local.slice(0, 2)}***@${domain}`;
}
/**
* Liest die SMTP-Umgebung. Bei Geheimnissen wird nur ein Zeilenumbruch am
* Ende entfernt: er entsteht leicht beim Anlegen des Kubernetes-Secrets
* (echo ohne -n vor base64) und laesst jede Anmeldung scheitern. Sonstige
* Werte werden getrimmt.
*
* Bewusst woertliche Zugriffe auf process.env mit festem Namen: k8s-manifest-env.test.ts
* gleicht genau diese mit den Kubernetes-Manifesten ab.
*/
function readSmtpEnv() {
const secret = (value: string | undefined) => value?.replace(/[\r\n]+$/, "") ?? "";
const plain = (value: string | undefined) => value?.trim() ?? "";
return {
SMTP_HOST: plain(process.env.SMTP_HOST),
SMTP_USER: plain(process.env.SMTP_USER),
SMTP_PASSWORD: secret(process.env.SMTP_PASSWORD),
SMTP_OAUTH_TENANT_ID: plain(process.env.SMTP_OAUTH_TENANT_ID),
SMTP_OAUTH_CLIENT_ID: plain(process.env.SMTP_OAUTH_CLIENT_ID),
SMTP_OAUTH_CLIENT_SECRET: secret(process.env.SMTP_OAUTH_CLIENT_SECRET),
};
}
type SmtpEnvName = keyof ReturnType<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. */
/** Namen der Variablen, die fuer den Versand noch fehlen — leer, wenn alles da ist. */
function missingSmtpSettings(): string[] {
const env = readSmtpEnv();
const wantsOAuth = OAUTH_SETTINGS.some((name) => env[name] !== "");
const required: SmtpEnvName[] = wantsOAuth ? ["SMTP_USER", ...OAUTH_SETTINGS] : ["SMTP_HOST", "SMTP_USER", "SMTP_PASSWORD"];
return required.filter((name) => env[name] === "");
const missing: string[] = [];
if (!process.env.SMTP_USER?.trim()) missing.push("SMTP_USER");
if (!process.env.ENTRA_TENANT_ID?.trim()) missing.push("ENTRA_TENANT_ID");
if (!process.env.ENTRA_CLIENT_ID?.trim()) missing.push("ENTRA_CLIENT_ID");
if (!process.env.ENTRA_CLIENT_SECRET?.trim()) missing.push("ENTRA_CLIENT_SECRET");
return missing;
}
function readSmtpConfig(): SmtpConfig | null {
if (missingSmtpSettings().length > 0) return null;
const env = readSmtpEnv();
const port = Number(process.env.SMTP_PORT ?? 587);
if (env.SMTP_OAUTH_CLIENT_SECRET) {
return {
authMode: "oauth2",
host: env.SMTP_HOST || MS365_SMTP_HOST,
port,
user: env.SMTP_USER,
oauth: { tenantId: env.SMTP_OAUTH_TENANT_ID, clientId: env.SMTP_OAUTH_CLIENT_ID, clientSecret: env.SMTP_OAUTH_CLIENT_SECRET },
};
}
return { authMode: "password", host: env.SMTP_HOST, port, user: env.SMTP_USER, pass: env.SMTP_PASSWORD };
const user = process.env.SMTP_USER?.trim() ?? "";
const app = readEntraAppCredentials();
return user && app ? { user, app } : null;
}
function getFromAddress(): string {
return process.env.SMTP_FROM || "Wabenhain <[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
// ueber alle Mails hinweg wiederverwendet statt je Mail neu geholt.
let tokenProvider: { key: string; provider: Ms365TokenProvider } | null = null;
function getTokenProvider(oauth: Ms365OAuthConfig): Ms365TokenProvider {
const key = `${oauth.tenantId}|${oauth.clientId}|${oauth.clientSecret}`;
function getTokenProvider(app: EntraAppCredentials): Ms365TokenProvider {
const key = `${app.tenantId}|${app.clientId}|${app.clientSecret}`;
if (!tokenProvider || tokenProvider.key !== key) {
tokenProvider = { key, provider: createMs365TokenProvider(oauth) };
tokenProvider = { key, provider: createMs365TokenProvider(app) };
}
return tokenProvider.provider;
}
type SmtpTransporter = Transporter<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,
host: MS365_SMTP_HOST,
port: MS365_SMTP_PORT,
secure: false,
// Die Verbindung MUSS per STARTTLS verschluesselt werden, bevor das Token
// gesendet wird — ohne requireTLS wuerde nodemailer bei einem Server ohne
// STARTTLS-Angebot (Angriff auf dem Netzweg) im Klartext weitermachen.
requireTLS: true,
auth: {
type: "OAuth2",
user: config.user,
// nodemailer fragt hier nach einem Token; renew=true nach einer
// Ablehnung durch den Server → frisches Token statt des gecachten.
// expires 0: nodemailer fragt bei jeder Anmeldung neu — das Cachen mit
// Ablaufreserve uebernimmt der Token-Provider.
provisionCallback: (_user: string, renew: boolean, callback: (err: Error | null, accessToken: string, expires: number) => void) => {
getTokenProvider(config.app)
.getToken(renew)
.then((token) => callback(null, token, 0), (error: Error) => callback(error, "", 0));
},
},
...extra,
};
return nodemailer.createTransport(options);
@@ -153,7 +116,7 @@ function buildTransport(config: SmtpConfig, extra: { connectionTimeout?: number;
let cachedTransport: { config: SmtpConfig; transport: SmtpTransporter } | null = null;
// Nur einmal gemeldet (nicht bei jedem sendMail-Aufruf) — macht ein in
// Produktion fehlendes SMTP-Setup im Server-Log sichtbar, statt dass
// Produktion fehlendes Setup im Server-Log sichtbar, statt dass
// Bestellbestaetigungen/Passwort-Resets fortlaufend lautlos verschwinden.
let warnedAboutMissingSmtpInProduction = false;
@@ -163,7 +126,7 @@ function getTransport(): { config: SmtpConfig; transport: SmtpTransporter } | nu
if (process.env.NODE_ENV === "production" && !warnedAboutMissingSmtpInProduction) {
warnedAboutMissingSmtpInProduction = true;
console.warn(
`[mail] SMTP ist nicht vollstaendig eingerichtet (es fehlt: ${missingSmtpSettings().join(", ")}) — Transaktions-E-Mails werden nur geloggt, nicht versendet.`
`[mail] Mailversand nicht vollstaendig eingerichtet (es fehlt: ${missingSmtpSettings().join(", ")}) — Transaktions-E-Mails werden nur geloggt, nicht versendet.`
);
}
return null;
@@ -173,11 +136,11 @@ function getTransport(): { config: SmtpConfig; transport: SmtpTransporter } | nu
}
/**
* Versendet eine E-Mail ueber den konfigurierten Mailserver. Ohne
* Konfiguration wird die Mail NICHT gesendet, sondern nur geloggt — analog
* zum Stripe-Fallback ohne Keys, damit lokale Entwicklung und Deployments
* ohne Mail-Setup nicht abstuerzen. Wirft absichtlich keinen Fehler: ein
* Mail-Ausfall darf eine Bestellung oder Registrierung nicht verhindern.
* Versendet eine E-Mail ueber Microsoft 365. Ohne Konfiguration wird die
* Mail NICHT gesendet, sondern nur geloggt — damit lokale Entwicklung und
* Deployments ohne Mail-Setup nicht abstuerzen. Wirft absichtlich keinen
* Fehler: ein Mail-Ausfall darf eine Bestellung oder Registrierung nicht
* verhindern.
*
* Liefert true, wenn der Mailserver die Nachricht angenommen hat, sonst
* false (nicht konfiguriert oder Versand fehlgeschlagen). Aktionen mit
@@ -188,7 +151,7 @@ function getTransport(): { config: SmtpConfig; transport: SmtpTransporter } | nu
export async function sendMail(input: SendMailInput): Promise<boolean> {
const active = getTransport();
if (!active) {
console.warn(`[mail] SMTP nicht konfiguriert — E-Mail an ${maskEmail(input.to)} ("${input.subject}") wurde nicht gesendet.`);
console.warn(`[mail] Mailversand nicht eingerichtet — E-Mail an ${maskEmail(input.to)} ("${input.subject}") wurde nicht gesendet.`);
return false;
}
@@ -203,7 +166,7 @@ export async function sendMail(input: SendMailInput): Promise<boolean> {
});
return true;
} catch (error: unknown) {
const diagnosis = explainSmtpError(error, { secrets: secretsOf(active.config), authMode: active.config.authMode });
const diagnosis = explainSmtpError(error, { secrets: [active.config.app.clientSecret] });
console.error(`[mail] Versand an ${maskEmail(input.to)} fehlgeschlagen (${diagnosis.cause}): ${diagnosis.detail} — ${diagnosis.hint}`);
return false;
}
@@ -213,7 +176,6 @@ interface SmtpTestBase {
host: string;
user: string;
from: string;
authMode: SmtpAuthMode;
}
export type SmtpTestResult =
@@ -222,16 +184,16 @@ export type SmtpTestResult =
| ({ ok: false; configured: true; diagnosis: SmtpDiagnosis } & SmtpTestBase);
/**
* Prueft die SMTP-Konfiguration im laufenden Betrieb: Verbindung + Anmeldung
* Prueft den Mailversand im laufenden Betrieb: Token, Verbindung, Anmeldung
* (verify) und danach ein echter Versand an `to` — erst der Versand zeigt,
* ob das Postfach mit der Absenderadresse (SMTP_FROM) senden darf. Baut
* bewusst einen frischen Transport statt des zwischengespeicherten. Weder
* Passwort noch Client-Secret erscheinen im Ergebnis.
* bewusst einen frischen Transport statt des zwischengespeicherten. Das
* Client-Secret erscheint nie im Ergebnis.
*/
export async function testSmtpConnection(to: string): Promise<SmtpTestResult> {
const config = readSmtpConfig();
if (!config) return { ok: false, configured: false, missing: missingSmtpSettings() };
const base: SmtpTestBase = { host: config.host, user: config.user, from: getFromAddress(), authMode: config.authMode };
const base: SmtpTestBase = { host: MS365_SMTP_HOST, user: config.user, from: getFromAddress() };
const transport = buildTransport(config, { connectionTimeout: TEST_TIMEOUT_MS, greetingTimeout: TEST_TIMEOUT_MS });
try {
await transport.verify();
@@ -239,11 +201,11 @@ export async function testSmtpConnection(to: string): Promise<SmtpTestResult> {
from: base.from,
to,
subject: "Wabenhain: Test des E-Mail-Versands",
text: `Diese Test-Mail bestaetigt, dass der Shop ueber ${config.host} als ${config.user} (Anmeldung: ${config.authMode === "oauth2" ? "OAuth 2.0" : "Passwort"}) mit dem Absender ${base.from} senden kann.`,
text: `Diese Test-Mail bestaetigt, dass der Shop ueber Microsoft 365 (OAuth 2.0) als ${config.user} mit dem Absender ${base.from} senden kann.`,
});
return { ok: true, ...base };
} catch (error: unknown) {
return { ok: false, configured: true, ...base, diagnosis: explainSmtpError(error, { secrets: secretsOf(config), authMode: config.authMode }) };
return { ok: false, configured: true, ...base, diagnosis: explainSmtpError(error, { secrets: [config.app.clientSecret] }) };
} finally {
transport.close();
}