import { promises as dns } from "node:dns"; import net from "node:net"; 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 ENTRA_HOST = "login.microsoftonline.com"; const TEST_TIMEOUT_MS = 15_000; const PROBE_TIMEOUT_MS = 8_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; } /** * Nur IPv4: nodemailer loest den Host selbst auf und waehlt ZUFAELLIG eine * der IPv4- und IPv6-Adressen. In Clustern mit IPv6-Schnittstelle, aber ohne * IPv6-Route nach aussen, scheitert das mit "ENETUNREACH 2603:…" — je nach * Los mal ja, mal nein. Microsoft 365 ist per IPv4 immer erreichbar; die * TLS-Pruefung bleibt ueber servername an smtp.office365.com gebunden. */ async function resolveSmtpIpv4(): Promise { const addresses = await dns.resolve4(MS365_SMTP_HOST); if (addresses.length === 0) { throw Object.assign(new Error(`Keine IPv4-Adresse fuer ${MS365_SMTP_HOST} gefunden`), { code: "EDNS" }); } return addresses[Math.floor(Math.random() * addresses.length)]; } /** * Baut je Versand einen frischen Transport: die IPv4-Adresse von * smtp.office365.com wechselt, ein dauerhaft gemerkter Transport hinge an * einer Adresse, die Microsoft irgendwann nicht mehr bedient. */ async function buildTransport(config: SmtpConfig, extra: { connectionTimeout?: number; greetingTimeout?: number } = {}): Promise { const address = await resolveSmtpIpv4(); const options: SMTPTransport.Options = { host: address, 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, // Verbunden wird per IP, geprueft wird das Zertifikat trotzdem gegen den // Namen — sonst liesse sich die Verbindung unbemerkt umleiten. tls: { servername: MS365_SMTP_HOST }, 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); } // 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; /** * 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 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.` ); } console.warn(`[mail] Mailversand nicht eingerichtet — E-Mail an ${maskEmail(input.to)} ("${input.subject}") wurde nicht gesendet.`); return false; } let transport: SmtpTransporter | null = null; try { transport = await buildTransport(config); await 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: [config.app.clientSecret] }); console.error(`[mail] Versand an ${maskEmail(input.to)} fehlgeschlagen (${diagnosis.cause}): ${diagnosis.detail} — ${diagnosis.hint}`); return false; } finally { transport?.close(); } } export interface NetworkCheck { ok: boolean; /** Kurze technische Angabe: erreichte IP-Adresse oder Fehlercode. */ detail: string; } /** Oeffnet nur eine TCP-Verbindung (IPv4) und schliesst sie sofort — ohne Daten zu senden. */ function probeTcp(host: string, port: number): Promise { return new Promise((resolve) => { const socket = net.connect({ host, port, family: 4, timeout: PROBE_TIMEOUT_MS }); const finish = (result: NetworkCheck) => { socket.destroy(); resolve(result); }; socket.once("connect", () => finish({ ok: true, detail: `${socket.remoteAddress ?? host}:${port}` })); socket.once("timeout", () => finish({ ok: false, detail: `Zeitueberschreitung nach ${PROBE_TIMEOUT_MS / 1000} s (Port ${port} vermutlich blockiert)` })); socket.once("error", (error: Error & { code?: string }) => finish({ ok: false, detail: error.code ?? error.message })); }); } interface SmtpTestBase { host: string; user: string; from: string; /** Erreichbarkeit per IPv4 — trennt Netzwerk-/Firewall-Probleme von Konfigurationsfehlern. */ network: { smtp: NetworkCheck; entra: NetworkCheck }; } 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: erst die Netzwerkwege * (smtp.office365.com:587, login.microsoftonline.com:443), dann Token, * Anmeldung (verify) und ein echter Versand an `to` — erst der Versand zeigt, * ob das Postfach mit der Absenderadresse (SMTP_FROM) senden darf. 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 [smtp, entra] = await Promise.all([probeTcp(MS365_SMTP_HOST, MS365_SMTP_PORT), probeTcp(ENTRA_HOST, 443)]); const base: SmtpTestBase = { host: MS365_SMTP_HOST, user: config.user, from: getFromAddress(), network: { smtp, entra } }; let transport: SmtpTransporter | null = null; try { transport = await buildTransport(config, { connectionTimeout: TEST_TIMEOUT_MS, greetingTimeout: TEST_TIMEOUT_MS }); 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(); } }