Files
Wabenhain/src/lib/mail.ts
T
Nick SzaunigandClaude Opus 5.5 b5311fae83 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]>
2026-09-26 22:41:49 +02:00

260 lines
11 KiB
TypeScript

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<SMTPTransport.SentMessageInfo>;
/** 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 <[email protected]>";
}
// 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<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 = {
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<boolean> {
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<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 {
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<SmtpTestResult> {
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();
}
}