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

@@ -47,7 +47,7 @@ export function SmtpTestPanel() {
<div>
<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">
{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>
</div>
</div>
+2 -3
View File
@@ -1259,7 +1259,7 @@ export const de: Dictionary = {
},
adminEmailTestPage: {
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",
running: "Wird getestet …",
success: (to) => `Versand funktioniert. Die Test-Mail ist an ${to} unterwegs.`,
@@ -1268,8 +1268,7 @@ export const de: Dictionary = {
causeLabel: "Was zu tun ist",
detailLabel: "Meldung des Mailservers",
configLabel: "Verwendete Einstellungen",
authModeOAuth: "OAuth 2.0 (App-Registrierung)",
authModePassword: "Passwort (Basic Auth)",
authModeOAuth: "Microsoft 365, OAuth 2.0 über die SSO-App",
missingLabel: "Fehlende Einstellungen",
},
adminWithdrawalsPage: {
+2 -3
View File
@@ -1233,7 +1233,7 @@ export const en: Dictionary = {
},
adminEmailTestPage: {
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",
running: "Testing …",
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",
detailLabel: "Mail server message",
configLabel: "Settings used",
authModeOAuth: "OAuth 2.0 (app registration)",
authModePassword: "Password (basic auth)",
authModeOAuth: "Microsoft 365, OAuth 2.0 via the SSO app",
missingLabel: "Missing settings",
},
adminWithdrawalsPage: {
-1
View File
@@ -830,7 +830,6 @@ export interface Dictionary {
detailLabel: string;
configLabel: string;
authModeOAuth: string;
authModePassword: string;
missingLabel: string;
};
adminWithdrawalsPage: {
+25 -5
View File
@@ -46,15 +46,35 @@ interface EntraConfig {
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 {
const tenantId = process.env.ENTRA_TENANT_ID;
const clientId = process.env.ENTRA_CLIENT_ID;
const clientSecret = process.env.ENTRA_CLIENT_SECRET;
const credentials = readEntraAppCredentials();
const redirectUri = process.env.ENTRA_REDIRECT_URI;
if (!tenantId || !clientId || !clientSecret || !redirectUri) {
if (!credentials || !redirectUri) {
throw new EntraNotConfiguredError();
}
return { tenantId, clientId, clientSecret, redirectUri };
return { ...credentials, redirectUri };
}
/** Fuer die bedingte Anzeige des "Mit Microsoft anmelden"-Buttons im Login-UI. */
+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();
}
+3 -2
View File
@@ -4,7 +4,8 @@
* 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).
* (Berechtigung SMTP.SendAsApp + Add-MailboxPermission, siehe README). Die
* Zugangsdaten stammen aus der SSO-App-Registrierung (lib/entra.ts).
*/
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
// 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.");
throw new Ms365TokenError("ENTRA_TENANT_ID ist weder eine Tenant-GUID noch ein Domainname.");
}
const fetchImpl = options.fetchImpl ?? fetch;
const now = options.now ?? Date.now;
+33 -69
View File
@@ -7,12 +7,36 @@ function smtpError(message: string, extra: Record<string, unknown> = {}): Error
}
describe("explainSmtpError", () => {
it("recognises security defaults blocking basic auth", () => {
const result = explainSmtpError(
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("security-defaults");
expect(result.hint).toContain("Sicherheitsstandards");
it("explains a client secret ID used instead of its value", () => {
const result = explainSmtpError(new Ms365TokenError("Token-Abruf abgelehnt: AADSTS7000215: Invalid client secret provided.", "7000215"));
expect(result.cause).toBe("oauth-token");
expect(result.hint).toContain("ENTRA_CLIENT_SECRET");
expect(result.hint).toContain("Wert");
});
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", () => {
@@ -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", () => {
expect(
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", () => {
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", () => {
@@ -55,54 +65,8 @@ describe("explainSmtpError", () => {
expect(explainSmtpError(null).cause).toBe("unknown");
});
it("never echoes a password that appears in the error", () => {
const result = explainSmtpError(smtpError("535 5.7.3 Authentication unsuccessful"), { secrets: ["geheim123"] });
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"] });
it("redacts secrets from the echoed server message", () => {
const result = explainSmtpError(smtpError("AUTH XOAUTH2 eyJtoken failed secret-xyz"), { secrets: ["eyJtoken", "secret-xyz"] });
expect(result.detail).not.toContain("eyJtoken");
expect(result.detail).not.toContain("secret-xyz");
});
+24 -45
View File
@@ -1,23 +1,17 @@
/**
* Uebersetzt SMTP-Fehler (vor allem von Microsoft 365 / Exchange Online) in
* eine konkrete Ursache mit deutscher Handlungsanweisung. Der Versand selbst
* verschluckt Fehler bewusst (lib/mail.ts) — ohne diese Einordnung sieht man
* nur "keine Mails kommen an".
* Uebersetzt Fehler beim Mailversand ueber Microsoft 365 (OAuth 2.0, siehe
* lib/mail.ts) in eine konkrete Ursache mit deutscher Handlungsanweisung.
* Der Versand selbst verschluckt Fehler bewusst — ohne diese Einordnung sieht
* man nur "keine Mails kommen an".
*/
import { Ms365TokenError } from "@/lib/ms365-oauth";
export type SmtpAuthMode = "password" | "oauth2";
export type SmtpFailureCause =
| "oauth-token"
| "oauth-mailbox-access"
| "basic-auth-retired"
| "security-defaults"
| "smtp-auth-disabled"
| "mfa-or-conditional-access"
| "send-as-denied"
| "wrong-credentials"
| "network"
| "tls"
| "unknown";
@@ -25,43 +19,35 @@ export type SmtpFailureCause =
export interface SmtpDiagnosis {
cause: SmtpFailureCause;
hint: string;
/** Originalmeldung des Servers, gekuerzt und ohne Passwort. */
/** Originalmeldung des Servers, gekuerzt und ohne Geheimnisse. */
detail: 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.",
"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":
"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":
"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.",
"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.",
"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.",
"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.",
"„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.",
"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.",
"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.",
"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.",
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).",
tls: "Die verschlüsselte Verbindung zum Mailserver ist fehlgeschlagen. Prüfe SMTP_HOST und SMTP_PORT (587 mit STARTTLS, 465 mit direktem TLS).",
"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 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.",
};
/** 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.",
"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":
"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":
"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).",
"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 ENTRA_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;
@@ -74,19 +60,14 @@ function readError(error: unknown): { message: string; code: string } {
return { message: typeof error === "string" ? error : "", code: "" };
}
function classify(message: string, code: string, authMode: SmtpAuthMode, isTokenError: boolean): SmtpFailureCause {
if (isTokenError) return "oauth-token";
function classify(error: unknown, message: string, code: string): SmtpFailureCause {
if (error instanceof Ms365TokenError) return "oauth-token";
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("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 (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";
}
// Mit OAuth gibt es kein Passwort, das falsch sein koennte: ein abgelehntes
// XOAUTH2-Token heisst fast immer fehlender Postfachzugriff der App.
if (code === "EAUTH" || text.includes("535") || text.includes("authentication unsuccessful")) return "oauth-mailbox-access";
if (["ETIMEDOUT", "ECONNREFUSED", "ECONNECTION", "EDNS", "ENOTFOUND", "ESOCKET", "ECONNRESET"].includes(code) || /etimedout|econnrefused|enotfound/.test(text)) {
return "network";
}
@@ -95,16 +76,14 @@ function classify(message: string, code: string, authMode: SmtpAuthMode, isToken
}
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)[];
authMode?: SmtpAuthMode;
}
export function explainSmtpError(error: unknown, options: ExplainOptions = {}): SmtpDiagnosis {
const { message, code } = readError(error);
const isTokenError = error instanceof Ms365TokenError;
const cause = classify(message, code, options.authMode ?? "password", isTokenError);
const aadHint = isTokenError && error.aadCode ? AAD_HINTS[error.aadCode] : undefined;
const cause = classify(error, message, code);
const aadHint = error instanceof Ms365TokenError && 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) };
}