Files
Wabenhain/src/lib/smtp-diagnose.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

90 lines
5.7 KiB
TypeScript

/**
* Uebersetzt Fehler beim Mailversand ueber Microsoft 365 (OAuth 2.0, siehe
* lib/mail.ts) in eine konkrete Ursache mit deutscher Handlungsanweisung.
* Der Versand selbst verschluckt Fehler bewusst — ohne diese Einordnung sieht
* man nur "keine Mails kommen an".
*/
import { Ms365TokenError } from "@/lib/ms365-oauth";
export type SmtpFailureCause =
| "oauth-token"
| "oauth-mailbox-access"
| "smtp-auth-disabled"
| "send-as-denied"
| "network"
| "tls"
| "unknown";
export interface SmtpDiagnosis {
cause: SmtpFailureCause;
hint: string;
/** Originalmeldung des Servers, gekuerzt und ohne Geheimnisse. */
detail: string;
}
const HINTS: Record<SmtpFailureCause, string> = {
"oauth-token":
"Microsoft Entra ID gibt kein Zugriffstoken aus. Prüfe ENTRA_TENANT_ID, ENTRA_CLIENT_ID und ENTRA_CLIENT_SECRET — dieselben Werte wie für „Mit Microsoft anmelden“.",
"oauth-mailbox-access":
"Das Token ist gültig, aber Exchange lässt die App nicht mit dem Postfach aus SMTP_USER senden. Prüfe in dieser Reihenfolge: (1) API-Berechtigung „Office 365 Exchange Online → SMTP.SendAsApp“ (Anwendung) mit Administratorzustimmung an der SSO-App-Registrierung, (2) New-ServicePrincipal -AppId <ENTRA_CLIENT_ID> -ObjectId <Objekt-ID der Unternehmensanwendung> in Exchange Online PowerShell, (3) Add-MailboxPermission -Identity <SMTP_USER> -User <Objekt-ID> -AccessRights FullAccess. Nach Änderungen bis zu 30 Minuten warten.",
"smtp-auth-disabled":
"„Authentifiziertes SMTP“ ist für den Tenant oder das Postfach ausgeschaltet — auch OAuth 2.0 läuft darüber. Exchange Online PowerShell: Set-CASMailbox -Identity <SMTP_USER> -SmtpClientAuthenticationDisabled $false. Die Postfach-Einstellung hat Vorrang vor der des Tenants; Änderungen können bis zu einer Stunde dauern.",
"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. 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.",
};
/** Konkrete Anweisung zu den haeufigsten Entra-Fehlernummern beim Token-Abruf. */
const AAD_HINTS: Record<string, string> = {
"7000215":
"Das Client-Secret ist falsch. In ENTRA_CLIENT_SECRET gehört der „Wert“ des geheimen Clientschlüssels, nicht die „Geheimnis-ID“. Der Wert wird nur direkt nach dem Anlegen angezeigt — sonst einen neuen Schlüssel erstellen.",
"7000222":
"Das Client-Secret ist abgelaufen — das betrifft auch „Mit Microsoft anmelden“. Entra ID → App-Registrierungen → Zertifikate & Geheimnisse → neuen geheimen Clientschlüssel anlegen, ENTRA_CLIENT_SECRET im Kubernetes-Secret ersetzen, Pod neu starten. Ablaufdatum notieren.",
"700016":
"Die App-Registrierung wurde in diesem Tenant nicht gefunden. Prüfe ENTRA_CLIENT_ID (Anwendungs-ID/Client-ID) und ob ENTRA_TENANT_ID zum selben Tenant gehört.",
"90002": "Der Tenant existiert nicht. Prüfe ENTRA_TENANT_ID (Verzeichnis-ID/Mandanten-ID).",
"900023": "Die Tenant-Angabe ist ungültig. Prüfe ENTRA_TENANT_ID (Verzeichnis-ID/Mandanten-ID).",
};
const MAX_DETAIL_LENGTH = 300;
function readError(error: unknown): { message: string; code: string } {
if (error instanceof Error) {
const code = (error as Error & { code?: unknown }).code;
return { message: error.message, code: typeof code === "string" ? code : "" };
}
return { message: typeof error === "string" ? error : "", code: "" };
}
function classify(error: unknown, message: string, code: string): SmtpFailureCause {
if (error instanceof Ms365TokenError) return "oauth-token";
const text = message.toLowerCase();
if (text.includes("smtpclientauthentication is disabled")) return "smtp-auth-disabled";
if (text.includes("sendasdenied") || text.includes("5.2.252") || text.includes("5.7.60")) return "send-as-denied";
// Mit OAuth gibt es kein Passwort, das falsch sein koennte: ein abgelehntes
// XOAUTH2-Token heisst fast immer fehlender Postfachzugriff der App.
if (code === "EAUTH" || text.includes("535") || text.includes("authentication unsuccessful")) return "oauth-mailbox-access";
if (["ETIMEDOUT", "ECONNREFUSED", "ECONNECTION", "EDNS", "ENOTFOUND", "ESOCKET", "ECONNRESET"].includes(code) || /etimedout|econnrefused|enotfound/.test(text)) {
return "network";
}
if (code === "ETLS" || text.includes("certificate") || text.includes("ssl") || text.includes("tls")) return "tls";
return "unknown";
}
interface ExplainOptions {
/** Werte, die nie in der Ausgabe erscheinen duerfen (Client-Secret, Token). */
secrets?: (string | undefined)[];
}
export function explainSmtpError(error: unknown, options: ExplainOptions = {}): SmtpDiagnosis {
const { message, code } = readError(error);
const cause = classify(error, message, code);
const aadHint = error instanceof Ms365TokenError && error.aadCode ? AAD_HINTS[error.aadCode] : undefined;
const redacted = (options.secrets ?? []).reduce<string>((text, secret) => (secret ? text.split(secret).join("***") : text), message);
return { cause, hint: aadHint ?? HINTS[cause], detail: redacted.slice(0, MAX_DETAIL_LENGTH) };
}