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:
1 parent
2943021fe9
commit
b5311fae83
2 files changed
+77
-30
No files matched your search
+76
-29
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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.",
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in new issue
Block a user