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
@@ -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>
|
||||
) : null}
|
||||
{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}
|
||||
{state && "ok" in state && !state.ok && state.configured ? (
|
||||
<div className="space-y-5 border border-red-300 px-5 py-5 text-sm">
|
||||
@@ -42,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">
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1263,11 +1263,14 @@ export const de: Dictionary = {
|
||||
button: "Test-Mail senden",
|
||||
running: "Wird getestet …",
|
||||
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",
|
||||
causeLabel: "Was zu tun ist",
|
||||
detailLabel: "Meldung des Mailservers",
|
||||
configLabel: "Verwendete Einstellungen",
|
||||
authModeOAuth: "OAuth 2.0 (App-Registrierung)",
|
||||
authModePassword: "Passwort (Basic Auth)",
|
||||
missingLabel: "Fehlende Einstellungen",
|
||||
},
|
||||
adminWithdrawalsPage: {
|
||||
title: "Widerrufe",
|
||||
|
||||
@@ -1237,11 +1237,14 @@ export const en: Dictionary = {
|
||||
button: "Send test email",
|
||||
running: "Testing …",
|
||||
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",
|
||||
causeLabel: "What to do",
|
||||
detailLabel: "Mail server message",
|
||||
configLabel: "Settings used",
|
||||
authModeOAuth: "OAuth 2.0 (app registration)",
|
||||
authModePassword: "Password (basic auth)",
|
||||
missingLabel: "Missing settings",
|
||||
},
|
||||
adminWithdrawalsPage: {
|
||||
title: "Withdrawals",
|
||||
|
||||
@@ -829,6 +829,9 @@ export interface Dictionary {
|
||||
causeLabel: string;
|
||||
detailLabel: string;
|
||||
configLabel: string;
|
||||
authModeOAuth: string;
|
||||
authModePassword: string;
|
||||
missingLabel: string;
|
||||
};
|
||||
adminWithdrawalsPage: {
|
||||
title: string;
|
||||
|
||||
+155
-72
@@ -1,5 +1,7 @@
|
||||
import nodemailer from "nodemailer";
|
||||
import { explainSmtpError, type SmtpDiagnosis } from "@/lib/smtp-diagnose";
|
||||
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";
|
||||
|
||||
export interface MailAttachment {
|
||||
filename: string;
|
||||
@@ -21,6 +23,22 @@ interface SendMailInput {
|
||||
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". */
|
||||
function maskEmail(email: string): string {
|
||||
const [local, domain] = email.split("@");
|
||||
@@ -28,64 +46,138 @@ function maskEmail(email: string): string {
|
||||
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
|
||||
* beim Anlegen des Kubernetes-Secrets (echo ohne -n vor base64) und laesst
|
||||
* jede Anmeldung scheitern — er wird entfernt, alle anderen Zeichen bleiben.
|
||||
* 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 readSmtpConfig(): { host: string; port: number; user: string; pass: string } | null {
|
||||
const host = process.env.SMTP_HOST?.trim();
|
||||
const user = process.env.SMTP_USER?.trim();
|
||||
const pass = process.env.SMTP_PASSWORD?.replace(/[\r\n]+$/, "");
|
||||
if (!host || !user || !pass) return null;
|
||||
return { host, port: Number(process.env.SMTP_PORT ?? 587), user, pass };
|
||||
function readSmtpEnv() {
|
||||
const secret = (value: string | undefined) => value?.replace(/[\r\n]+$/, "") ?? "";
|
||||
const plain = (value: string | undefined) => value?.trim() ?? "";
|
||||
return {
|
||||
SMTP_HOST: plain(process.env.SMTP_HOST),
|
||||
SMTP_USER: plain(process.env.SMTP_USER),
|
||||
SMTP_PASSWORD: secret(process.env.SMTP_PASSWORD),
|
||||
SMTP_OAUTH_TENANT_ID: plain(process.env.SMTP_OAUTH_TENANT_ID),
|
||||
SMTP_OAUTH_CLIENT_ID: plain(process.env.SMTP_OAUTH_CLIENT_ID),
|
||||
SMTP_OAUTH_CLIENT_SECRET: secret(process.env.SMTP_OAUTH_CLIENT_SECRET),
|
||||
};
|
||||
}
|
||||
|
||||
type SmtpEnvName = keyof ReturnType<typeof readSmtpEnv>;
|
||||
|
||||
const OAUTH_SETTINGS: SmtpEnvName[] = ["SMTP_OAUTH_TENANT_ID", "SMTP_OAUTH_CLIENT_ID", "SMTP_OAUTH_CLIENT_SECRET"];
|
||||
|
||||
/** Namen der Variablen, die fuer die gewaehlte Anmeldeart noch fehlen — leer, wenn alles da ist. */
|
||||
function missingSmtpSettings(): string[] {
|
||||
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 {
|
||||
return process.env.SMTP_FROM || "Wabenhain <[email protected]>";
|
||||
}
|
||||
|
||||
function getTransport() {
|
||||
if (cachedTransport) return cachedTransport;
|
||||
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}`;
|
||||
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();
|
||||
if (!config) {
|
||||
if (process.env.NODE_ENV === "production" && !warnedAboutMissingSmtpInProduction) {
|
||||
warnedAboutMissingSmtpInProduction = true;
|
||||
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;
|
||||
}
|
||||
|
||||
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 },
|
||||
});
|
||||
if (!cachedTransport) cachedTransport = { config, transport: buildTransport(config) };
|
||||
return cachedTransport;
|
||||
}
|
||||
|
||||
/**
|
||||
* Versendet eine E-Mail ueber den in SMTP_HOST/SMTP_USER/SMTP_PASSWORD
|
||||
* 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 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.
|
||||
*
|
||||
* Liefert true, wenn der Mailserver die Nachricht angenommen hat, sonst
|
||||
* false (nicht konfiguriert oder Versand fehlgeschlagen). Aktionen mit
|
||||
@@ -94,19 +186,15 @@ function getTransport() {
|
||||
* Kundschaft auf eine Mail, die nie kommt.
|
||||
*/
|
||||
export async function sendMail(input: SendMailInput): Promise<boolean> {
|
||||
const transport = getTransport();
|
||||
const from = getFromAddress();
|
||||
|
||||
if (!transport) {
|
||||
console.warn(
|
||||
`[mail] SMTP nicht konfiguriert — E-Mail an ${maskEmail(input.to)} ("${input.subject}") wurde nicht gesendet.`
|
||||
);
|
||||
const active = getTransport();
|
||||
if (!active) {
|
||||
console.warn(`[mail] SMTP nicht konfiguriert — E-Mail an ${maskEmail(input.to)} ("${input.subject}") wurde nicht gesendet.`);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await transport.sendMail({
|
||||
from,
|
||||
await active.transport.sendMail({
|
||||
from: getFromAddress(),
|
||||
to: input.to,
|
||||
subject: input.subject,
|
||||
html: input.html,
|
||||
@@ -115,52 +203,47 @@ export async function sendMail(input: SendMailInput): Promise<boolean> {
|
||||
});
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
const diagnosis = explainSmtpError(error, readSmtpConfig()?.pass);
|
||||
console.error(
|
||||
`[mail] Versand an ${maskEmail(input.to)} fehlgeschlagen (${diagnosis.cause}): ${diagnosis.detail} — ${diagnosis.hint}`
|
||||
);
|
||||
const diagnosis = explainSmtpError(error, { secrets: secretsOf(active.config), authMode: active.config.authMode });
|
||||
console.error(`[mail] Versand an ${maskEmail(input.to)} fehlgeschlagen (${diagnosis.cause}): ${diagnosis.detail} — ${diagnosis.hint}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
interface SmtpTestBase {
|
||||
host: string;
|
||||
user: string;
|
||||
from: string;
|
||||
authMode: SmtpAuthMode;
|
||||
}
|
||||
|
||||
export type SmtpTestResult =
|
||||
| { ok: true; host: string; user: string; from: string }
|
||||
| { ok: false; configured: false }
|
||||
| { ok: false; configured: true; host: string; user: string; from: string; diagnosis: SmtpDiagnosis };
|
||||
| ({ ok: true } & SmtpTestBase)
|
||||
| { ok: false; configured: false; missing: string[] }
|
||||
| ({ ok: false; configured: true; diagnosis: SmtpDiagnosis } & SmtpTestBase);
|
||||
|
||||
/**
|
||||
* Prueft die SMTP-Konfiguration im laufenden Betrieb: 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, damit
|
||||
* ein geaendertes Secret nach einem Neustart sofort getestet wird. Das
|
||||
* Passwort erscheint nie im Ergebnis.
|
||||
* bewusst einen frischen Transport statt des zwischengespeicherten. Weder
|
||||
* Passwort noch Client-Secret erscheinen im Ergebnis.
|
||||
*/
|
||||
export async function testSmtpConnection(to: string): Promise<SmtpTestResult> {
|
||||
const config = readSmtpConfig();
|
||||
if (!config) return { ok: false, configured: false };
|
||||
const from = getFromAddress();
|
||||
const transport = nodemailer.createTransport({
|
||||
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 };
|
||||
if (!config) return { ok: false, configured: false, missing: missingSmtpSettings() };
|
||||
const base: SmtpTestBase = { host: config.host, user: config.user, from: getFromAddress(), authMode: config.authMode };
|
||||
const transport = buildTransport(config, { connectionTimeout: TEST_TIMEOUT_MS, greetingTimeout: TEST_TIMEOUT_MS });
|
||||
try {
|
||||
await transport.verify();
|
||||
await transport.sendMail({
|
||||
from,
|
||||
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} 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 };
|
||||
} 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 {
|
||||
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 { explainSmtpError } from "./smtp-diagnose";
|
||||
import { Ms365TokenError } from "./ms365-oauth";
|
||||
|
||||
function smtpError(message: string, extra: Record<string, unknown> = {}): Error {
|
||||
return Object.assign(new Error(message), extra);
|
||||
@@ -55,9 +56,54 @@ describe("explainSmtpError", () => {
|
||||
});
|
||||
|
||||
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");
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
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".
|
||||
*/
|
||||
|
||||
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"
|
||||
@@ -24,6 +30,10 @@ export interface SmtpDiagnosis {
|
||||
}
|
||||
|
||||
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":
|
||||
"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":
|
||||
@@ -42,6 +52,18 @@ const HINTS: Record<SmtpFailureCause, string> = {
|
||||
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;
|
||||
|
||||
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: "" };
|
||||
}
|
||||
|
||||
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();
|
||||
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")) 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)) {
|
||||
return "network";
|
||||
}
|
||||
@@ -67,9 +94,17 @@ function classify(message: string, code: string): SmtpFailureCause {
|
||||
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 cause = classify(message, code);
|
||||
const redacted = secret ? message.split(secret).join("***") : message;
|
||||
return { cause, hint: HINTS[cause], detail: redacted.slice(0, MAX_DETAIL_LENGTH) };
|
||||
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 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