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:
1 parent
cef2248a45
commit
302a18baa6
10 files changed
+355
-12
No files matched your search
+77
-12
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user