diff --git a/src/lib/mail.ts b/src/lib/mail.ts index 8b39f7b..0aa6837 100644 --- a/src/lib/mail.ts +++ b/src/lib/mail.ts @@ -1,3 +1,5 @@ +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"; @@ -44,7 +46,9 @@ interface SmtpConfig { 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; @@ -87,15 +91,39 @@ function getTokenProvider(app: EntraAppCredentials): Ms365TokenProvider { return tokenProvider.provider; } -function buildTransport(config: SmtpConfig, extra: { connectionTimeout?: number; greetingTimeout?: number } = {}): SmtpTransporter { +/** + * 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: MS365_SMTP_HOST, + 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, @@ -114,27 +142,11 @@ function buildTransport(config: SmtpConfig, extra: { connectionTimeout?: number; 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 @@ -149,14 +161,22 @@ function getTransport(): { config: SmtpConfig; transport: SmtpTransporter } | nu * Kundschaft auf eine Mail, die nie kommt. */ export async function sendMail(input: SendMailInput): Promise { - const active = getTransport(); - if (!active) { + 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 { - await active.transport.sendMail({ + transport = await buildTransport(config); + await transport.sendMail({ from: getFromAddress(), to: input.to, subject: input.subject, @@ -166,16 +186,40 @@ export async function sendMail(input: SendMailInput): Promise { }); return true; } catch (error: unknown) { - const diagnosis = explainSmtpError(error, { secrets: [active.config.app.clientSecret] }); + 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 = @@ -184,18 +228,21 @@ export type SmtpTestResult = | ({ 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 + * 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 base: SmtpTestBase = { host: MS365_SMTP_HOST, user: config.user, from: getFromAddress() }; - const transport = buildTransport(config, { connectionTimeout: TEST_TIMEOUT_MS, greetingTimeout: TEST_TIMEOUT_MS }); + 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, @@ -207,6 +254,6 @@ export async function testSmtpConnection(to: string): Promise { } catch (error: unknown) { return { ok: false, configured: true, ...base, diagnosis: explainSmtpError(error, { secrets: [config.app.clientSecret] }) }; } finally { - transport.close(); + transport?.close(); } } diff --git a/src/lib/smtp-diagnose.ts b/src/lib/smtp-diagnose.ts index 6a1534e..7e4388c 100644 --- a/src/lib/smtp-diagnose.ts +++ b/src/lib/smtp-diagnose.ts @@ -33,7 +33,7 @@ const HINTS: Record = { "send-as-denied": "Die App darf nicht mit der Absenderadresse (SMTP_FROM) senden. SMTP_FROM muss die Adresse des Postfachs aus SMTP_USER sein, oder das Postfach braucht die Berechtigung „Senden als“ für diese Adresse. Für einen Alias: Set-OrganizationConfig -SendFromAliasEnabled $true.", network: - "smtp.office365.com ist nicht erreichbar. Prüfe, ob der Cluster ausgehende Verbindungen auf Port 587 und zu login.microsoftonline.com erlaubt (Firewall, NetworkPolicy).", + "smtp.office365.com ist nicht erreichbar. Die Netzwerkprüfung oben zeigt, welcher Weg blockiert ist: Der Cluster muss ausgehend per IPv4 smtp.office365.com auf Port 587 und login.microsoftonline.com auf Port 443 erreichen (Firewall des Hosters, NetworkPolicy). Manche Hoster sperren Mail-Ports für neue Server — dann dort die Freigabe von Port 587 beantragen.", tls: "Die verschlüsselte Verbindung zu smtp.office365.com ist fehlgeschlagen (STARTTLS auf Port 587). Prüfe Proxy oder TLS-Inspektion auf dem Netzweg.", unknown: "Unbekannter Fehler. Die Originalmeldung des Mailservers steht unten.", };