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 | 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 "; } 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 { 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 { 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(); } }