refactor(mail): send via the SSO app registration, drop SMTP basic auth
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m25s
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m25s
Mail now uses the same Entra ID app registration as "Sign in with Microsoft" (ENTRA_TENANT_ID, ENTRA_CLIENT_ID, ENTRA_CLIENT_SECRET), so only one enterprise application is needed. The app additionally gets the SMTP.SendAsApp permission; Exchange still limits it to the mailbox granted via Add-MailboxPermission. Password (basic auth) sending is removed entirely: SMTP_HOST, SMTP_PORT, SMTP_PASSWORD and the separate SMTP_OAUTH_* settings are gone. The transport is fixed to smtp.office365.com:587 with required STARTTLS. SMTP_USER (sending mailbox) and SMTP_FROM remain. - lib/entra.ts exposes readEntraAppCredentials(), shared by SSO and mail - diagnosis drops basic-auth-only causes; hints name the ENTRA_* values and warn that an expired secret breaks sign-in and mail together - admin e-mail test, .env.example, docker-compose, k8s manifests, preflight and README updated Verified against Entra ID with fake values: token error classified, client secret never exposed, missing ENTRA_CLIENT_SECRET reported. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
0620e9d79a
commit
11910dd6dd
15 files changed
+211
-309
No files matched your search
+24
-45
@@ -1,23 +1,17 @@
|
||||
/**
|
||||
* 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".
|
||||
* 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 SmtpAuthMode = "password" | "oauth2";
|
||||
|
||||
export type SmtpFailureCause =
|
||||
| "oauth-token"
|
||||
| "oauth-mailbox-access"
|
||||
| "basic-auth-retired"
|
||||
| "security-defaults"
|
||||
| "smtp-auth-disabled"
|
||||
| "mfa-or-conditional-access"
|
||||
| "send-as-denied"
|
||||
| "wrong-credentials"
|
||||
| "network"
|
||||
| "tls"
|
||||
| "unknown";
|
||||
@@ -25,43 +19,35 @@ export type SmtpFailureCause =
|
||||
export interface SmtpDiagnosis {
|
||||
cause: SmtpFailureCause;
|
||||
hint: string;
|
||||
/** Originalmeldung des Servers, gekuerzt und ohne Passwort. */
|
||||
/** 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 SMTP_OAUTH_TENANT_ID, SMTP_OAUTH_CLIENT_ID und SMTP_OAUTH_CLIENT_SECRET.",
|
||||
"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, (2) New-ServicePrincipal -AppId <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.",
|
||||
"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.",
|
||||
"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. 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.",
|
||||
"„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":
|
||||
"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.",
|
||||
"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:
|
||||
"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).",
|
||||
"smtp.office365.com ist nicht erreichbar. Prüfe, ob der Cluster ausgehende Verbindungen auf Port 587 und zu login.microsoftonline.com erlaubt (Firewall, NetworkPolicy).",
|
||||
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 SMTP_OAUTH_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.",
|
||||
"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. Entra ID → App-Registrierungen → Zertifikate & Geheimnisse → neuen geheimen Clientschlüssel anlegen, SMTP_OAUTH_CLIENT_SECRET im Kubernetes-Secret ersetzen, Pod neu starten. Ablaufdatum notieren.",
|
||||
"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 SMTP_OAUTH_CLIENT_ID (Anwendungs-ID/Client-ID) und ob SMTP_OAUTH_TENANT_ID zum selben Tenant gehört.",
|
||||
"90002": "Der Tenant existiert nicht. Prüfe SMTP_OAUTH_TENANT_ID (Verzeichnis-ID/Mandanten-ID oder z. B. wabenhain.onmicrosoft.com).",
|
||||
"900023": "Die Tenant-Angabe ist ungültig. Prüfe SMTP_OAUTH_TENANT_ID (Verzeichnis-ID/Mandanten-ID).",
|
||||
"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;
|
||||
@@ -74,19 +60,14 @@ function readError(error: unknown): { message: string; code: string } {
|
||||
return { message: typeof error === "string" ? error : "", code: "" };
|
||||
}
|
||||
|
||||
function classify(message: string, code: string, authMode: SmtpAuthMode, isTokenError: boolean): SmtpFailureCause {
|
||||
if (isTokenError) return "oauth-token";
|
||||
function classify(error: unknown, message: string, code: string): SmtpFailureCause {
|
||||
if (error instanceof Ms365TokenError) return "oauth-token";
|
||||
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")) {
|
||||
// Mit OAuth gibt es kein Passwort, das falsch sein koennte: ein abgelehntes
|
||||
// XOAUTH2-Token heisst fast immer fehlender Postfachzugriff der App.
|
||||
return authMode === "oauth2" ? "oauth-mailbox-access" : "wrong-credentials";
|
||||
}
|
||||
// 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";
|
||||
}
|
||||
@@ -95,16 +76,14 @@ function classify(message: string, code: string, authMode: SmtpAuthMode, isToken
|
||||
}
|
||||
|
||||
interface ExplainOptions {
|
||||
/** Werte, die nie in der Ausgabe erscheinen duerfen (Passwort, Client-Secret, Token). */
|
||||
/** Werte, die nie in der Ausgabe erscheinen duerfen (Client-Secret, Token). */
|
||||
secrets?: (string | undefined)[];
|
||||
authMode?: SmtpAuthMode;
|
||||
}
|
||||
|
||||
export function explainSmtpError(error: unknown, options: ExplainOptions = {}): SmtpDiagnosis {
|
||||
const { message, code } = readError(error);
|
||||
const isTokenError = error instanceof Ms365TokenError;
|
||||
const cause = classify(message, code, options.authMode ?? "password", isTokenError);
|
||||
const aadHint = isTokenError && error.aadCode ? AAD_HINTS[error.aadCode] : undefined;
|
||||
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) };
|
||||
}
|
||||
Reference in new issue
Block a user