Files
Wabenhain/src/lib/mail.ts
T
Nick SzaunigandClaude Opus 5.5 302a18baa6 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]>
2026-09-26 17:48:40 +02:00

168 lines
6.2 KiB
TypeScript

import nodemailer from "nodemailer";
import { explainSmtpError, type SmtpDiagnosis } from "@/lib/smtp-diagnose";
export interface MailAttachment {
filename: string;
content: Buffer;
contentType: string;
}
interface SendMailInput {
to: string;
subject: string;
html: string;
text: string;
/**
* Optionale Anhaenge, aktuell die Rechnung zur Bestellbestaetigung.
* Bewusst als Buffer und nicht als Pfad: der Aufrufer hat das Dokument
* ohnehin schon im Speicher, und so haengt der Mailversand nicht daran,
* dass das Ablageverzeichnis gerade erreichbar ist.
*/
attachments?: MailAttachment[];
}
/** Maskiert eine E-Mail-Adresse fuer Log-Ausgaben, z.B. "ma***@example.com". */
function maskEmail(email: string): string {
const [local, domain] = email.split("@");
if (!domain) return "***";
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.
*/
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 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."
);
}
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 },
});
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.
*
* Liefert true, wenn der Mailserver die Nachricht angenommen hat, sonst
* false (nicht konfiguriert oder Versand fehlgeschlagen). Aktionen mit
* sichtbarer Rueckmeldung ("Link erneut gesendet", "Kundschaft informiert")
* duerfen Erfolg nur melden, wenn hier true zurueckkam — sonst wartet die
* 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.`
);
return false;
}
try {
await transport.sendMail({
from,
to: input.to,
subject: input.subject,
html: input.html,
text: input.text,
...(input.attachments?.length ? { attachments: input.attachments } : {}),
});
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}`
);
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();
}
}