fix(mail): connect to Microsoft 365 over IPv4 and check network paths

The admin email test failed with "connect ENETUNREACH 2603:…:587".
nodemailer resolves smtp.office365.com to IPv4 and IPv6 addresses and
picks one at random; pods with an IPv6 interface but no IPv6 route to
the internet then fail depending on the draw, and the error shown is
always that of the last (IPv6) fallback.

- The shop now resolves smtp.office365.com via IPv4 only and connects
  to that address; the TLS certificate is still verified against
  smtp.office365.com (tls.servername), STARTTLS stays mandatory.
- A transport is built per send so a rotating Microsoft address never
  sticks.
- The email test first probes smtp.office365.com:587 and
  login.microsoftonline.com:443 over IPv4 and reports each result, so a
  blocked port (hoster firewall, NetworkPolicy) is distinguishable from
  a configuration error.

Verified against the real Microsoft endpoints: both probes succeed,
the connection is made via IPv4, TLS validates and the auth stage is
reached.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-09-26 22:41:49 +02:00
1 parent 2943021fe9
commit b5311fae83
2 files changed
+77 -30

No files matched your search

+76 -29
View File
@@ -1,3 +1,5 @@
import { promises as dns } from "node:dns";
import net from "node:net";
import nodemailer, { type Transporter } from "nodemailer"; import nodemailer, { type Transporter } from "nodemailer";
import type SMTPTransport from "nodemailer/lib/smtp-transport"; import type SMTPTransport from "nodemailer/lib/smtp-transport";
import { explainSmtpError, type SmtpDiagnosis } from "@/lib/smtp-diagnose"; import { explainSmtpError, type SmtpDiagnosis } from "@/lib/smtp-diagnose";
@@ -44,7 +46,9 @@ interface SmtpConfig {
const MS365_SMTP_HOST = "smtp.office365.com"; const MS365_SMTP_HOST = "smtp.office365.com";
/** STARTTLS — der Port, den Microsoft fuer SMTP-Client-Submission vorsieht. */ /** STARTTLS — der Port, den Microsoft fuer SMTP-Client-Submission vorsieht. */
const MS365_SMTP_PORT = 587; const MS365_SMTP_PORT = 587;
const ENTRA_HOST = "login.microsoftonline.com";
const TEST_TIMEOUT_MS = 15_000; const TEST_TIMEOUT_MS = 15_000;
const PROBE_TIMEOUT_MS = 8_000;
type SmtpTransporter = Transporter<SMTPTransport.SentMessageInfo>; type SmtpTransporter = Transporter<SMTPTransport.SentMessageInfo>;
@@ -87,15 +91,39 @@ function getTokenProvider(app: EntraAppCredentials): Ms365TokenProvider {
return tokenProvider.provider; 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<string> {
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<SmtpTransporter> {
const address = await resolveSmtpIpv4();
const options: SMTPTransport.Options = { const options: SMTPTransport.Options = {
host: MS365_SMTP_HOST, host: address,
port: MS365_SMTP_PORT, port: MS365_SMTP_PORT,
secure: false, secure: false,
// Die Verbindung MUSS per STARTTLS verschluesselt werden, bevor das Token // Die Verbindung MUSS per STARTTLS verschluesselt werden, bevor das Token
// gesendet wird — ohne requireTLS wuerde nodemailer bei einem Server ohne // gesendet wird — ohne requireTLS wuerde nodemailer bei einem Server ohne
// STARTTLS-Angebot (Angriff auf dem Netzweg) im Klartext weitermachen. // STARTTLS-Angebot (Angriff auf dem Netzweg) im Klartext weitermachen.
requireTLS: true, 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: { auth: {
type: "OAuth2", type: "OAuth2",
user: config.user, user: config.user,
@@ -114,27 +142,11 @@ function buildTransport(config: SmtpConfig, extra: { connectionTimeout?: number;
return nodemailer.createTransport(options); return nodemailer.createTransport(options);
} }
let cachedTransport: { config: SmtpConfig; transport: SmtpTransporter } | null = null;
// Nur einmal gemeldet (nicht bei jedem sendMail-Aufruf) — macht ein in // Nur einmal gemeldet (nicht bei jedem sendMail-Aufruf) — macht ein in
// Produktion fehlendes Setup im Server-Log sichtbar, statt dass // Produktion fehlendes Setup im Server-Log sichtbar, statt dass
// Bestellbestaetigungen/Passwort-Resets fortlaufend lautlos verschwinden. // Bestellbestaetigungen/Passwort-Resets fortlaufend lautlos verschwinden.
let warnedAboutMissingSmtpInProduction = false; 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 * Versendet eine E-Mail ueber Microsoft 365. Ohne Konfiguration wird die
* Mail NICHT gesendet, sondern nur geloggt — damit lokale Entwicklung und * 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. * Kundschaft auf eine Mail, die nie kommt.
*/ */
export async function sendMail(input: SendMailInput): Promise<boolean> { export async function sendMail(input: SendMailInput): Promise<boolean> {
const active = getTransport(); const config = readSmtpConfig();
if (!active) { 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.`); console.warn(`[mail] Mailversand nicht eingerichtet — E-Mail an ${maskEmail(input.to)} ("${input.subject}") wurde nicht gesendet.`);
return false; return false;
} }
let transport: SmtpTransporter | null = null;
try { try {
await active.transport.sendMail({ transport = await buildTransport(config);
await transport.sendMail({
from: getFromAddress(), from: getFromAddress(),
to: input.to, to: input.to,
subject: input.subject, subject: input.subject,
@@ -166,16 +186,40 @@ export async function sendMail(input: SendMailInput): Promise<boolean> {
}); });
return true; return true;
} catch (error: unknown) { } 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}`); console.error(`[mail] Versand an ${maskEmail(input.to)} fehlgeschlagen (${diagnosis.cause}): ${diagnosis.detail} — ${diagnosis.hint}`);
return false; 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<NetworkCheck> {
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 { interface SmtpTestBase {
host: string; host: string;
user: string; user: string;
from: string; from: string;
/** Erreichbarkeit per IPv4 — trennt Netzwerk-/Firewall-Probleme von Konfigurationsfehlern. */
network: { smtp: NetworkCheck; entra: NetworkCheck };
} }
export type SmtpTestResult = export type SmtpTestResult =
@@ -184,18 +228,21 @@ export type SmtpTestResult =
| ({ ok: false; configured: true; diagnosis: SmtpDiagnosis } & SmtpTestBase); | ({ ok: false; configured: true; diagnosis: SmtpDiagnosis } & SmtpTestBase);
/** /**
* Prueft den Mailversand im laufenden Betrieb: Token, Verbindung, Anmeldung * Prueft den Mailversand im laufenden Betrieb: erst die Netzwerkwege
* (verify) und danach ein echter Versand an `to` — erst der Versand zeigt, * (smtp.office365.com:587, login.microsoftonline.com:443), dann Token,
* ob das Postfach mit der Absenderadresse (SMTP_FROM) senden darf. Baut * Anmeldung (verify) und ein echter Versand an `to` — erst der Versand zeigt,
* bewusst einen frischen Transport statt des zwischengespeicherten. Das * ob das Postfach mit der Absenderadresse (SMTP_FROM) senden darf. Das
* Client-Secret erscheint nie im Ergebnis. * Client-Secret erscheint nie im Ergebnis.
*/ */
export async function testSmtpConnection(to: string): Promise<SmtpTestResult> { export async function testSmtpConnection(to: string): Promise<SmtpTestResult> {
const config = readSmtpConfig(); const config = readSmtpConfig();
if (!config) return { ok: false, configured: false, missing: missingSmtpSettings() }; if (!config) return { ok: false, configured: false, missing: missingSmtpSettings() };
const base: SmtpTestBase = { host: MS365_SMTP_HOST, user: config.user, from: getFromAddress() }; const [smtp, entra] = await Promise.all([probeTcp(MS365_SMTP_HOST, MS365_SMTP_PORT), probeTcp(ENTRA_HOST, 443)]);
const transport = buildTransport(config, { connectionTimeout: TEST_TIMEOUT_MS, greetingTimeout: TEST_TIMEOUT_MS }); const base: SmtpTestBase = { host: MS365_SMTP_HOST, user: config.user, from: getFromAddress(), network: { smtp, entra } };
let transport: SmtpTransporter | null = null;
try { try {
transport = await buildTransport(config, { connectionTimeout: TEST_TIMEOUT_MS, greetingTimeout: TEST_TIMEOUT_MS });
await transport.verify(); await transport.verify();
await transport.sendMail({ await transport.sendMail({
from: base.from, from: base.from,
@@ -207,6 +254,6 @@ export async function testSmtpConnection(to: string): Promise<SmtpTestResult> {
} catch (error: unknown) { } catch (error: unknown) {
return { ok: false, configured: true, ...base, diagnosis: explainSmtpError(error, { secrets: [config.app.clientSecret] }) }; return { ok: false, configured: true, ...base, diagnosis: explainSmtpError(error, { secrets: [config.app.clientSecret] }) };
} finally { } finally {
transport.close(); transport?.close();
} }
} }
+1 -1
View File
@@ -33,7 +33,7 @@ const HINTS: Record<SmtpFailureCause, string> = {
"send-as-denied": "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.", "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: 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.", 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.", unknown: "Unbekannter Fehler. Die Originalmeldung des Mailservers steht unten.",
}; };