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
+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();
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user