feat(mail): SMTP diagnosis and admin email test for Microsoft 365

Mail failures were only logged as a bare error, so a broken Microsoft
365 setup showed up as "no emails arrive" with no cause. Now:

- lib/smtp-diagnose.ts maps SMTP errors to a cause with German
  instructions: security defaults, SMTP AUTH disabled for tenant or
  mailbox, MFA/conditional access, SendAsDenied (SMTP_FROM differs from
  the mailbox), wrong credentials, basic-auth retirement, network, TLS.
  The password is redacted from any echoed server message.
- /admin/e-mail-test verifies login and sends a real test email to the
  signed-in admin only (no free recipient, rate limited, audit logged)
  and shows the server message, the cause and the settings in use.
- The transport now requires STARTTLS on port 587 so credentials are
  never sent unencrypted, strips a trailing newline from secrets (a
  common kubectl/base64 mistake) and logs the diagnosed cause.

Verified against smtp.office365.com with a fake account: STARTTLS
succeeds and "535 5.7.3" is classified as wrong credentials.

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 17:48:40 +02:00
1 parent cef2248a45
commit 302a18baa6
10 files changed
+355 -12

No files matched your search

+77 -12
View File
@@ -1,4 +1,5 @@
import nodemailer from "nodemailer";
import { explainSmtpError, type SmtpDiagnosis } from "@/lib/smtp-diagnose";
export interface MailAttachment {
filename: string;
@@ -33,14 +34,28 @@ let cachedTransport: ReturnType<typeof nodemailer.createTransport> | null = null
// 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.
*/
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 getFromAddress(): string {
return process.env.SMTP_FROM || "Wabenhain <[email protected]>";
}
function getTransport() {
if (cachedTransport) return cachedTransport;
const host = process.env.SMTP_HOST;
const port = Number(process.env.SMTP_PORT ?? 587);
const user = process.env.SMTP_USER;
const pass = process.env.SMTP_PASSWORD;
if (!host || !user || !pass) {
const config = readSmtpConfig();
if (!config) {
if (process.env.NODE_ENV === "production" && !warnedAboutMissingSmtpInProduction) {
warnedAboutMissingSmtpInProduction = true;
console.warn(
@@ -51,10 +66,15 @@ function getTransport() {
}
cachedTransport = nodemailer.createTransport({
host,
port,
secure: port === 465,
auth: { user, pass },
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 },
});
return cachedTransport;
}
@@ -75,7 +95,7 @@ function getTransport() {
*/
export async function sendMail(input: SendMailInput): Promise<boolean> {
const transport = getTransport();
const from = process.env.SMTP_FROM || "Wabenhain <[email protected]>";
const from = getFromAddress();
if (!transport) {
console.warn(
@@ -95,8 +115,53 @@ export async function sendMail(input: SendMailInput): Promise<boolean> {
});
return true;
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
console.error(`[mail] Versand an ${maskEmail(input.to)} fehlgeschlagen: ${message}`);
const diagnosis = explainSmtpError(error, readSmtpConfig()?.pass);
console.error(
`[mail] Versand an ${maskEmail(input.to)} fehlgeschlagen (${diagnosis.cause}): ${diagnosis.detail} — ${diagnosis.hint}`
);
return false;
}
}
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 };
/**
* 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.
*/
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 };
try {
await transport.verify();
await transport.sendMail({
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.`,
});
return { ok: true, ...base };
} catch (error: unknown) {
return { ok: false, configured: true, ...base, diagnosis: explainSmtpError(error, config.pass) };
} finally {
transport.close();
}
}
+63
View File
@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import { explainSmtpError } from "./smtp-diagnose";
function smtpError(message: string, extra: Record<string, unknown> = {}): Error {
return Object.assign(new Error(message), extra);
}
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("recognises SMTP AUTH disabled for the tenant or mailbox", () => {
expect(explainSmtpError(smtpError("535 5.7.139 Authentication unsuccessful, SmtpClientAuthentication is disabled for the Tenant.")).cause).toBe(
"smtp-auth-disabled"
);
expect(explainSmtpError(smtpError("535 5.7.139 Authentication unsuccessful, SmtpClientAuthentication is disabled for the Mailbox.")).cause).toBe(
"smtp-auth-disabled"
);
});
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
).toBe("send-as-denied");
expect(explainSmtpError(smtpError("550 5.7.60 SMTP; Client does not have permissions to send as this sender")).cause).toBe("send-as-denied");
});
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");
});
it("falls back to unknown and never throws on odd input", () => {
expect(explainSmtpError("kaputt").cause).toBe("unknown");
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"), "geheim123");
expect(JSON.stringify(result)).not.toContain("geheim123");
const leaking = explainSmtpError(smtpError("AUTH PLAIN failed for secret geheim123"), "geheim123");
expect(leaking.detail).not.toContain("geheim123");
});
});
+75
View File
@@ -0,0 +1,75 @@
/**
* 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".
*/
export type SmtpFailureCause =
| "basic-auth-retired"
| "security-defaults"
| "smtp-auth-disabled"
| "mfa-or-conditional-access"
| "send-as-denied"
| "wrong-credentials"
| "network"
| "tls"
| "unknown";
export interface SmtpDiagnosis {
cause: SmtpFailureCause;
hint: string;
/** Originalmeldung des Servers, gekuerzt und ohne Passwort. */
detail: string;
}
const HINTS: Record<SmtpFailureCause, string> = {
"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.",
"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.",
"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.",
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).",
unknown: "Unbekannter Fehler. Die Originalmeldung des Mailservers steht unten.",
};
const MAX_DETAIL_LENGTH = 300;
function readError(error: unknown): { message: string; code: string } {
if (error instanceof Error) {
const code = (error as Error & { code?: unknown }).code;
return { message: error.message, code: typeof code === "string" ? code : "" };
}
return { message: typeof error === "string" ? error : "", code: "" };
}
function classify(message: string, code: string): SmtpFailureCause {
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 (["ETIMEDOUT", "ECONNREFUSED", "ECONNECTION", "EDNS", "ENOTFOUND", "ESOCKET", "ECONNRESET"].includes(code) || /etimedout|econnrefused|enotfound/.test(text)) {
return "network";
}
if (code === "ETLS" || text.includes("certificate") || text.includes("ssl") || text.includes("tls")) return "tls";
return "unknown";
}
export function explainSmtpError(error: unknown, secret?: string): 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) };
}