feat(mail): SMTP diagnosis and admin email test for Microsoft 365
Mail failures were only logged as a bare error, so a broken Microsoft 365 setup showed up as "no emails arrive" with no cause. Now: - lib/smtp-diagnose.ts maps SMTP errors to a cause with German instructions: security defaults, SMTP AUTH disabled for tenant or mailbox, MFA/conditional access, SendAsDenied (SMTP_FROM differs from the mailbox), wrong credentials, basic-auth retirement, network, TLS. The password is redacted from any echoed server message. - /admin/e-mail-test verifies login and sends a real test email to the signed-in admin only (no free recipient, rate limited, audit logged) and shows the server message, the cause and the settings in use. - The transport now requires STARTTLS on port 587 so credentials are never sent unencrypted, strips a trailing newline from secrets (a common kubectl/base64 mistake) and logs the diagnosed cause. Verified against smtp.office365.com with a fake account: STARTTLS succeeds and "535 5.7.3" is classified as wrong credentials. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
cef2248a45
commit
302a18baa6
10 files changed
+355
-12
No files matched your search
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Uebersetzt SMTP-Fehler (vor allem von Microsoft 365 / Exchange Online) in
|
||||
* eine konkrete Ursache mit deutscher Handlungsanweisung. Der Versand selbst
|
||||
* verschluckt Fehler bewusst (lib/mail.ts) — ohne diese Einordnung sieht man
|
||||
* nur "keine Mails kommen an".
|
||||
*/
|
||||
|
||||
export type SmtpFailureCause =
|
||||
| "basic-auth-retired"
|
||||
| "security-defaults"
|
||||
| "smtp-auth-disabled"
|
||||
| "mfa-or-conditional-access"
|
||||
| "send-as-denied"
|
||||
| "wrong-credentials"
|
||||
| "network"
|
||||
| "tls"
|
||||
| "unknown";
|
||||
|
||||
export interface SmtpDiagnosis {
|
||||
cause: SmtpFailureCause;
|
||||
hint: string;
|
||||
/** Originalmeldung des Servers, gekuerzt und ohne Passwort. */
|
||||
detail: string;
|
||||
}
|
||||
|
||||
const HINTS: Record<SmtpFailureCause, string> = {
|
||||
"basic-auth-retired":
|
||||
"Microsoft nimmt für dieses Postfach keine Anmeldung per Passwort (Basic Auth) mehr an. Der Versand muss auf OAuth 2.0 umgestellt werden.",
|
||||
"security-defaults":
|
||||
"Die Sicherheitsstandards (Security Defaults) in Microsoft Entra ID blockieren die Anmeldung per Passwort — unabhängig von der Exchange-Einstellung. Entra Admin Center → Identität → Übersicht → Eigenschaften → „Sicherheitsstandards verwalten“. Sicherer als das Abschalten: auf OAuth 2.0 umstellen.",
|
||||
"smtp-auth-disabled":
|
||||
"„Authentifiziertes SMTP“ ist für den Tenant oder das Postfach ausgeschaltet. Die Postfach-Einstellung hat Vorrang: Microsoft 365 Admin Center → Benutzer → Aktive Benutzer → Postfach-Benutzer → E-Mail → „E-Mail-Apps verwalten“ → „Authentifiziertes SMTP“ aktivieren. Änderungen können bis zu einer Stunde dauern.",
|
||||
"mfa-or-conditional-access":
|
||||
"Die Anmeldung erfüllt eine Richtlinie nicht — meist Multi-Faktor-Authentifizierung auf dem Konto oder eine Richtlinie für bedingten Zugriff, die Legacy-Authentifizierung blockiert. Ein Konto mit MFA kann sich nicht per SMTP-Passwort anmelden.",
|
||||
"send-as-denied":
|
||||
"Das angemeldete Postfach 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.",
|
||||
"wrong-credentials":
|
||||
"Microsoft lehnt Benutzername oder Passwort ab. Prüfe SMTP_USER (vollständige Adresse) und SMTP_PASSWORD im Kubernetes-Secret — ein Zeilenumbruch am Ende (z. B. durch echo ohne -n beim base64-Kodieren) oder ein abgelaufenes Passwort reichen aus.",
|
||||
network:
|
||||
"Der Mailserver ist nicht erreichbar. Prüfe SMTP_HOST (smtp.office365.com), SMTP_PORT (587) und ob der Cluster ausgehende Verbindungen auf Port 587 erlaubt (Firewall, NetworkPolicy).",
|
||||
tls: "Die verschlüsselte Verbindung zum Mailserver ist fehlgeschlagen. Prüfe SMTP_HOST und SMTP_PORT (587 mit STARTTLS, 465 mit direktem TLS).",
|
||||
unknown: "Unbekannter Fehler. Die Originalmeldung des Mailservers steht unten.",
|
||||
};
|
||||
|
||||
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(message: string, code: string): SmtpFailureCause {
|
||||
const text = message.toLowerCase();
|
||||
if (text.includes("5.7.30") || text.includes("basic authentication is not supported")) return "basic-auth-retired";
|
||||
if (text.includes("security defaults")) return "security-defaults";
|
||||
if (text.includes("smtpclientauthentication is disabled")) return "smtp-auth-disabled";
|
||||
if (text.includes("did not meet the criteria")) return "mfa-or-conditional-access";
|
||||
if (text.includes("sendasdenied") || text.includes("5.2.252") || text.includes("5.7.60")) return "send-as-denied";
|
||||
if (code === "EAUTH" || text.includes("535") || text.includes("authentication unsuccessful")) return "wrong-credentials";
|
||||
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";
|
||||
}
|
||||
|
||||
export function explainSmtpError(error: unknown, secret?: string): SmtpDiagnosis {
|
||||
const { message, code } = readError(error);
|
||||
const cause = classify(message, code);
|
||||
const redacted = secret ? message.split(secret).join("***") : message;
|
||||
return { cause, hint: HINTS[cause], detail: redacted.slice(0, MAX_DETAIL_LENGTH) };
|
||||
}
|
||||
Reference in new issue
Block a user