feat(mail): Microsoft 365 sending via OAuth 2.0 (client credentials)
Sending no longer needs a user password for Microsoft 365. When SMTP_OAUTH_TENANT_ID, SMTP_OAUTH_CLIENT_ID and SMTP_OAUTH_CLIENT_SECRET are set, the shop fetches an app-only token from Entra ID and signs in to smtp.office365.com with XOAUTH2. This works with security defaults and MFA, survives the end of SMTP basic auth (default off from end of December 2026) and limits the app to the one mailbox Exchange grants it (SMTP.SendAsApp + Add-MailboxPermission). Password login stays available for other providers; a partial OAuth setup never silently falls back. - lib/ms365-oauth.ts: token cached until 5 minutes before expiry, concurrent requests share one fetch, forced refresh after a server rejection, 15 s timeout, tenant id validated before it enters the URL, client secret redacted from every error. - lib/smtp-diagnose.ts: explains AADSTS7000215 (secret ID instead of value), 7000222 (expired), 700016 (unknown app), 90002/900023 (tenant) and maps a rejected XOAUTH2 login to missing mailbox access. - Admin e-mail test shows the auth mode and lists missing settings. - k8s manifests, preflight check and README (step-by-step Entra and Exchange setup) updated; misplaced SMTP_PORT comment moved. Verified against Entra ID and smtp.office365.com with fake values: AADSTS90002 is classified as tenant error without leaking the secret, an invalid token yields 535 5.7.3 and is classified as mailbox access. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
302a18baa6
commit
0620e9d79a
13 files changed
+511
-88
No files matched your search
@@ -5,7 +5,13 @@
|
||||
* 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"
|
||||
@@ -24,6 +30,10 @@ export interface SmtpDiagnosis {
|
||||
}
|
||||
|
||||
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.",
|
||||
"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":
|
||||
@@ -42,6 +52,18 @@ const HINTS: Record<SmtpFailureCause, string> = {
|
||||
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.",
|
||||
"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.",
|
||||
"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).",
|
||||
};
|
||||
|
||||
const MAX_DETAIL_LENGTH = 300;
|
||||
|
||||
function readError(error: unknown): { message: string; code: string } {
|
||||
@@ -52,14 +74,19 @@ function readError(error: unknown): { message: string; code: string } {
|
||||
return { message: typeof error === "string" ? error : "", code: "" };
|
||||
}
|
||||
|
||||
function classify(message: string, code: string): SmtpFailureCause {
|
||||
function classify(message: string, code: string, authMode: SmtpAuthMode, isTokenError: boolean): SmtpFailureCause {
|
||||
if (isTokenError) 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")) return "wrong-credentials";
|
||||
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";
|
||||
}
|
||||
if (["ETIMEDOUT", "ECONNREFUSED", "ECONNECTION", "EDNS", "ENOTFOUND", "ESOCKET", "ECONNRESET"].includes(code) || /etimedout|econnrefused|enotfound/.test(text)) {
|
||||
return "network";
|
||||
}
|
||||
@@ -67,9 +94,17 @@ function classify(message: string, code: string): SmtpFailureCause {
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
export function explainSmtpError(error: unknown, secret?: string): SmtpDiagnosis {
|
||||
interface ExplainOptions {
|
||||
/** Werte, die nie in der Ausgabe erscheinen duerfen (Passwort, Client-Secret, Token). */
|
||||
secrets?: (string | undefined)[];
|
||||
authMode?: SmtpAuthMode;
|
||||
}
|
||||
|
||||
export function explainSmtpError(error: unknown, options: ExplainOptions = {}): 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) };
|
||||
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 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