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]>
90 lines
5.7 KiB
TypeScript
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) };
|
|
}
|