refactor(mail): send via the SSO app registration, drop SMTP basic auth
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:
Nick SzaunigandClaude Opus 5.5 committed 2026-09-26 19:23:05 +02:00
1 parent 0620e9d79a
commit 11910dd6dd
15 files changed
+211 -309

No files matched your search

+33 -69
View File
@@ -7,12 +7,36 @@ function smtpError(message: string, extra: Record<string, unknown> = {}): Error
}
describe("explainSmtpError", () => {
it("recognises security defaults blocking basic auth", () => {
const result = explainSmtpError(
smtpError("Invalid login: 535 5.7.139 Authentication unsuccessful, user is locked by your organization's security defaults policy.", { code: "EAUTH", responseCode: 535 })
);
expect(result.cause).toBe("security-defaults");
expect(result.hint).toContain("Sicherheitsstandards");
it("explains a client secret ID used instead of its value", () => {
const result = explainSmtpError(new Ms365TokenError("Token-Abruf abgelehnt: AADSTS7000215: Invalid client secret provided.", "7000215"));
expect(result.cause).toBe("oauth-token");
expect(result.hint).toContain("ENTRA_CLIENT_SECRET");
expect(result.hint).toContain("Wert");
});
it("explains an expired client secret and that SSO is affected too", () => {
const result = explainSmtpError(new Ms365TokenError("AADSTS7000222: The provided client secret keys are expired.", "7000222"));
expect(result.cause).toBe("oauth-token");
expect(result.hint).toContain("abgelaufen");
expect(result.hint).toContain("Mit Microsoft anmelden");
});
it("explains an unknown app or tenant", () => {
expect(explainSmtpError(new Ms365TokenError("AADSTS700016: Application not found", "700016")).hint).toContain("ENTRA_CLIENT_ID");
expect(explainSmtpError(new Ms365TokenError("AADSTS90002: Tenant not found", "90002")).hint).toContain("ENTRA_TENANT_ID");
});
it("falls back to a general token hint for other AADSTS codes", () => {
const result = explainSmtpError(new Ms365TokenError("AADSTS1234: something", "1234"));
expect(result.cause).toBe("oauth-token");
expect(result.hint).toContain("ENTRA_TENANT_ID");
});
it("maps a rejected XOAUTH2 login to missing mailbox access", () => {
const result = explainSmtpError(smtpError("Invalid login: 535 5.7.3 Authentication unsuccessful", { code: "EAUTH" }));
expect(result.cause).toBe("oauth-mailbox-access");
expect(result.hint).toContain("SMTP.SendAsApp");
expect(result.hint).toContain("Add-MailboxPermission");
});
it("recognises SMTP AUTH disabled for the tenant or mailbox", () => {
@@ -24,20 +48,6 @@ describe("explainSmtpError", () => {
);
});
it("recognises MFA or conditional access rejections", () => {
expect(
explainSmtpError(smtpError("535 5.7.139 Authentication unsuccessful, the request did not meet the criteria to be authenticated successfully.")).cause
).toBe("mfa-or-conditional-access");
});
it("recognises wrong credentials", () => {
expect(explainSmtpError(smtpError("Invalid login: 535 5.7.3 Authentication unsuccessful", { code: "EAUTH" })).cause).toBe("wrong-credentials");
});
it("recognises the basic auth retirement", () => {
expect(explainSmtpError(smtpError("550 5.7.30 Basic authentication is not supported for Client Submission.")).cause).toBe("basic-auth-retired");
});
it("recognises a sender address the mailbox may not use", () => {
expect(
explainSmtpError(smtpError("Message failed: 554 5.2.252 SendAsDenied; [email protected] not allowed to send as [email protected]")).cause
@@ -47,7 +57,7 @@ describe("explainSmtpError", () => {
it("recognises network problems", () => {
expect(explainSmtpError(smtpError("connect ETIMEDOUT 52.97.1.1:587", { code: "ETIMEDOUT" })).cause).toBe("network");
expect(explainSmtpError(smtpError("getaddrinfo ENOTFOUND smtp.office356.com", { code: "EDNS" })).cause).toBe("network");
expect(explainSmtpError(smtpError("getaddrinfo ENOTFOUND smtp.office365.com", { code: "EDNS" })).cause).toBe("network");
});
it("falls back to unknown and never throws on odd input", () => {
@@ -55,54 +65,8 @@ describe("explainSmtpError", () => {
expect(explainSmtpError(null).cause).toBe("unknown");
});
it("never echoes a password that appears in the error", () => {
const result = explainSmtpError(smtpError("535 5.7.3 Authentication unsuccessful"), { secrets: ["geheim123"] });
expect(JSON.stringify(result)).not.toContain("geheim123");
const leaking = explainSmtpError(smtpError("AUTH PLAIN failed for secret geheim123"), { secrets: ["geheim123"] });
expect(leaking.detail).not.toContain("geheim123");
});
});
describe("explainSmtpError with OAuth 2.0", () => {
it("explains an invalid client secret", () => {
const result = explainSmtpError(new Ms365TokenError("Token-Abruf abgelehnt: AADSTS7000215: Invalid client secret provided.", "7000215"), {
authMode: "oauth2",
});
expect(result.cause).toBe("oauth-token");
expect(result.hint).toContain("Wert");
});
it("explains an expired client secret", () => {
const result = explainSmtpError(new Ms365TokenError("AADSTS7000222: The provided client secret keys are expired.", "7000222"), {
authMode: "oauth2",
});
expect(result.cause).toBe("oauth-token");
expect(result.hint).toContain("abgelaufen");
});
it("explains an unknown app or tenant", () => {
expect(explainSmtpError(new Ms365TokenError("AADSTS700016: Application not found", "700016"), { authMode: "oauth2" }).hint).toContain(
"SMTP_OAUTH_CLIENT_ID"
);
expect(explainSmtpError(new Ms365TokenError("AADSTS90002: Tenant not found", "90002"), { authMode: "oauth2" }).hint).toContain(
"SMTP_OAUTH_TENANT_ID"
);
});
it("maps a rejected XOAUTH2 login to missing mailbox access", () => {
const result = explainSmtpError(smtpError("Invalid login: 535 5.7.3 Authentication unsuccessful", { code: "EAUTH" }), { authMode: "oauth2" });
expect(result.cause).toBe("oauth-mailbox-access");
expect(result.hint).toContain("Add-MailboxPermission");
});
it("keeps SMTP AUTH disabled as its own cause in OAuth mode", () => {
expect(
explainSmtpError(smtpError("535 5.7.139 Authentication unsuccessful, SmtpClientAuthentication is disabled for the Mailbox."), { authMode: "oauth2" }).cause
).toBe("smtp-auth-disabled");
});
it("redacts the access token and client secret", () => {
const result = explainSmtpError(smtpError("AUTH XOAUTH2 eyJtoken failed secret-xyz"), { authMode: "oauth2", secrets: ["eyJtoken", "secret-xyz"] });
it("redacts secrets from the echoed server message", () => {
const result = explainSmtpError(smtpError("AUTH XOAUTH2 eyJtoken failed secret-xyz"), { secrets: ["eyJtoken", "secret-xyz"] });
expect(result.detail).not.toContain("eyJtoken");
expect(result.detail).not.toContain("secret-xyz");
});