import nodemailer, { type Transporter } from "nodemailer"; import type SMTPTransport from "nodemailer/lib/smtp-transport"; import { explainSmtpError, type SmtpDiagnosis } from "@/lib/smtp-diagnose"; import { createMs365TokenProvider, type Ms365TokenProvider } from "@/lib/ms365-oauth"; import { readEntraAppCredentials, type EntraAppCredentials } from "@/lib/entra"; 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[]; } /** * Versand ausschliesslich ueber Microsoft 365 mit OAuth 2.0 (SMTP XOAUTH2, * Client-Credentials-Flow). Als App dient dieselbe App-Registrierung wie fuer * "Mit Microsoft anmelden" (ENTRA_TENANT_ID, ENTRA_CLIENT_ID, * ENTRA_CLIENT_SECRET) — eine Unternehmensanwendung statt zwei. Eine * Anmeldung per Passwort (Basic Auth) gibt es bewusst nicht mehr: kein * Benutzerpasswort im Cluster, kein Konflikt mit MFA/Sicherheitsstandards, * nicht betroffen vom Ende der Basic Auth bei Exchange Online. * * SMTP_USER ist das Postfach, mit dem gesendet wird; Exchange erlaubt der App * nur die Postfaecher, die ihr per Add-MailboxPermission freigegeben wurden. */ interface SmtpConfig { user: string; app: EntraAppCredentials; } const MS365_SMTP_HOST = "smtp.office365.com"; /** STARTTLS — der Port, den Microsoft fuer SMTP-Client-Submission vorsieht. */ const MS365_SMTP_PORT = 587; const TEST_TIMEOUT_MS = 15_000; type SmtpTransporter = Transporter; /** 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}`; } /** Namen der Variablen, die fuer den Versand noch fehlen — leer, wenn alles da ist. */ function missingSmtpSettings(): string[] { const missing: string[] = []; if (!process.env.SMTP_USER?.trim()) missing.push("SMTP_USER"); if (!process.env.ENTRA_TENANT_ID?.trim()) missing.push("ENTRA_TENANT_ID"); if (!process.env.ENTRA_CLIENT_ID?.trim()) missing.push("ENTRA_CLIENT_ID"); if (!process.env.ENTRA_CLIENT_SECRET?.trim()) missing.push("ENTRA_CLIENT_SECRET"); return missing; } function readSmtpConfig(): SmtpConfig | null { const user = process.env.SMTP_USER?.trim() ?? ""; const app = readEntraAppCredentials(); return user && app ? { user, app } : null; } function getFromAddress(): string { return process.env.SMTP_FROM || "Wabenhain "; } // 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(app: EntraAppCredentials): Ms365TokenProvider { const key = `${app.tenantId}|${app.clientId}|${app.clientSecret}`; if (!tokenProvider || tokenProvider.key !== key) { tokenProvider = { key, provider: createMs365TokenProvider(app) }; } return tokenProvider.provider; } function buildTransport(config: SmtpConfig, extra: { connectionTimeout?: number; greetingTimeout?: number } = {}): SmtpTransporter { const options: SMTPTransport.Options = { host: MS365_SMTP_HOST, port: MS365_SMTP_PORT, secure: false, // Die Verbindung MUSS per STARTTLS verschluesselt werden, bevor das Token // gesendet wird — ohne requireTLS wuerde nodemailer bei einem Server ohne // STARTTLS-Angebot (Angriff auf dem Netzweg) im Klartext weitermachen. requireTLS: true, auth: { type: "OAuth2", user: config.user, // nodemailer fragt hier nach einem Token; renew=true nach einer // Ablehnung durch den Server → frisches Token statt des gecachten. // expires 0: nodemailer fragt bei jeder Anmeldung neu — das Cachen mit // Ablaufreserve uebernimmt der Token-Provider. provisionCallback: (_user: string, renew: boolean, callback: (err: Error | null, accessToken: string, expires: number) => void) => { getTokenProvider(config.app) .getToken(renew) .then((token) => callback(null, token, 0), (error: Error) => callback(error, "", 0)); }, }, ...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 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( `[mail] Mailversand nicht vollstaendig eingerichtet (es fehlt: ${missingSmtpSettings().join(", ")}) — Transaktions-E-Mails werden nur geloggt, nicht versendet.` ); } return null; } if (!cachedTransport) cachedTransport = { config, transport: buildTransport(config) }; return cachedTransport; } /** * Versendet eine E-Mail ueber Microsoft 365. Ohne Konfiguration wird die * Mail NICHT gesendet, sondern nur geloggt — 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 active = getTransport(); if (!active) { console.warn(`[mail] Mailversand nicht eingerichtet — E-Mail an ${maskEmail(input.to)} ("${input.subject}") wurde nicht gesendet.`); return false; } try { await active.transport.sendMail({ from: getFromAddress(), 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, { secrets: [active.config.app.clientSecret] }); 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; } export type SmtpTestResult = | ({ ok: true } & SmtpTestBase) | { ok: false; configured: false; missing: string[] } | ({ ok: false; configured: true; diagnosis: SmtpDiagnosis } & SmtpTestBase); /** * Prueft den Mailversand im laufenden Betrieb: Token, 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. Das * Client-Secret erscheint nie im Ergebnis. */ export async function testSmtpConnection(to: string): Promise { const config = readSmtpConfig(); if (!config) return { ok: false, configured: false, missing: missingSmtpSettings() }; const base: SmtpTestBase = { host: MS365_SMTP_HOST, user: config.user, from: getFromAddress() }; const transport = buildTransport(config, { connectionTimeout: TEST_TIMEOUT_MS, greetingTimeout: TEST_TIMEOUT_MS }); try { await transport.verify(); await transport.sendMail({ from: base.from, to, subject: "Wabenhain: Test des E-Mail-Versands", text: `Diese Test-Mail bestaetigt, dass der Shop ueber Microsoft 365 (OAuth 2.0) als ${config.user} mit dem Absender ${base.from} senden kann.`, }); return { ok: true, ...base }; } catch (error: unknown) { return { ok: false, configured: true, ...base, diagnosis: explainSmtpError(error, { secrets: [config.app.clientSecret] }) }; } finally { transport.close(); } }