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,30 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { requireAdmin } from "@/app/admin/actions";
|
||||||
|
import { testSmtpConnection, type SmtpTestResult } from "@/lib/mail";
|
||||||
|
import { logAdminAction } from "@/lib/audit-log";
|
||||||
|
import { rateLimit } from "@/lib/rate-limit";
|
||||||
|
|
||||||
|
const TESTS_PER_WINDOW = 5;
|
||||||
|
const WINDOW_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
|
export type SmtpTestState = (SmtpTestResult & { to: string }) | { rateLimited: true } | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test-Mail immer an die Adresse des angemeldeten Admins — nie an eine frei
|
||||||
|
* eingegebene, sonst waere die Aktion ein Werkzeug fuer Versand an Dritte.
|
||||||
|
* Das Limit schuetzt das Postfach vor Sperrungen durch Microsoft bei
|
||||||
|
* wiederholten Fehlanmeldungen.
|
||||||
|
*/
|
||||||
|
export async function runSmtpTestAction(): Promise<SmtpTestState> {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
if (!rateLimit(`smtp-test:${admin.id}`, TESTS_PER_WINDOW, WINDOW_MS)) return { rateLimited: true };
|
||||||
|
|
||||||
|
const result = await testSmtpConnection(admin.email);
|
||||||
|
await logAdminAction(admin, {
|
||||||
|
action: "mail.smtp_test",
|
||||||
|
entityType: "Mail",
|
||||||
|
detail: result.ok ? "erfolgreich" : result.configured ? `fehlgeschlagen: ${result.diagnosis.cause}` : "nicht eingerichtet",
|
||||||
|
});
|
||||||
|
return { ...result, to: admin.email };
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { BackLink } from "@/components/back-link";
|
||||||
|
import { AdminPageHeader } from "@/components/admin-list";
|
||||||
|
import { getLocale } from "@/i18n/locale";
|
||||||
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
|
import { SmtpTestPanel } from "./smtp-test-panel";
|
||||||
|
|
||||||
|
export default async function AdminEmailTestPage() {
|
||||||
|
const t = getDictionary(await getLocale());
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<BackLink href="/admin" label={t.adminWithdrawalsPage.backToDashboard} />
|
||||||
|
<AdminPageHeader title={t.adminEmailTestPage.title} />
|
||||||
|
<p className="mb-8 max-w-2xl text-sm text-ink/70">{t.adminEmailTestPage.intro}</p>
|
||||||
|
<SmtpTestPanel />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useActionState } from "react";
|
||||||
|
import { Button } from "@/components/ui";
|
||||||
|
import { useI18n } from "@/i18n/i18n-provider";
|
||||||
|
import { runSmtpTestAction, type SmtpTestState } from "./actions";
|
||||||
|
|
||||||
|
export function SmtpTestPanel() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const s = t.adminEmailTestPage;
|
||||||
|
const [state, formAction, isPending] = useActionState<SmtpTestState>(runSmtpTestAction, null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<form action={formAction}>
|
||||||
|
<Button type="submit" disabled={isPending}>
|
||||||
|
{isPending ? s.running : s.button}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div aria-live="polite" className="mt-8">
|
||||||
|
{state && "rateLimited" in state ? (
|
||||||
|
<p className="text-sm text-red-700 dark:text-red-300">Zu viele Tests. Bitte warte ein paar Minuten.</p>
|
||||||
|
) : null}
|
||||||
|
{state && "ok" in state && state.ok ? (
|
||||||
|
<p className="border border-heading/30 bg-forest/5 px-5 py-4 text-sm text-heading">{s.success(state.to)}</p>
|
||||||
|
) : null}
|
||||||
|
{state && "ok" in state && !state.ok && !state.configured ? (
|
||||||
|
<p className="border border-red-300 px-5 py-4 text-sm text-red-700 dark:text-red-300">{s.notConfigured}</p>
|
||||||
|
) : null}
|
||||||
|
{state && "ok" in state && !state.ok && state.configured ? (
|
||||||
|
<div className="space-y-5 border border-red-300 px-5 py-5 text-sm">
|
||||||
|
<p className="font-serif text-xl text-red-700 dark:text-red-300">{s.failureHeading}</p>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs tracking-wide-caps text-ink/60">{s.causeLabel}</p>
|
||||||
|
<p className="mt-1 leading-relaxed text-ink/85">{state.diagnosis.hint}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs tracking-wide-caps text-ink/60">{s.detailLabel}</p>
|
||||||
|
<p className="mt-1 break-words font-mono text-xs text-ink/80">{state.diagnosis.detail || "—"}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs tracking-wide-caps text-ink/60">{s.configLabel}</p>
|
||||||
|
<p className="mt-1 break-words font-mono text-xs text-ink/80">
|
||||||
|
SMTP_HOST={state.host} · SMTP_USER={state.user} · SMTP_FROM={state.from}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -59,6 +59,7 @@ function getAdminNavGroups(t: Dictionary): { label: string | null; items: NavIte
|
|||||||
items: [
|
items: [
|
||||||
{ href: "/admin/inhalte", label: t.adminShell.navContent },
|
{ href: "/admin/inhalte", label: t.adminShell.navContent },
|
||||||
{ href: "/admin/audit-log", label: t.adminShell.navAuditLog },
|
{ href: "/admin/audit-log", label: t.adminShell.navAuditLog },
|
||||||
|
{ href: "/admin/e-mail-test", label: t.adminShell.navEmailTest },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1257,6 +1257,18 @@ export const de: Dictionary = {
|
|||||||
sectionVisibility: "Sichtbarkeit",
|
sectionVisibility: "Sichtbarkeit",
|
||||||
create: "Anlegen",
|
create: "Anlegen",
|
||||||
},
|
},
|
||||||
|
adminEmailTestPage: {
|
||||||
|
title: "E-Mail-Versand testen",
|
||||||
|
intro: "Baut eine Verbindung zum Mailserver auf, meldet sich mit SMTP_USER an und schickt eine Test-Mail an deine eigene Adresse. Schlägt etwas fehl, siehst du die Meldung des Mailservers und was zu tun ist.",
|
||||||
|
button: "Test-Mail senden",
|
||||||
|
running: "Wird getestet …",
|
||||||
|
success: (to) => `Versand funktioniert. Die Test-Mail ist an ${to} unterwegs.`,
|
||||||
|
notConfigured: "SMTP ist nicht eingerichtet: SMTP_HOST, SMTP_USER oder SMTP_PASSWORD fehlen im Kubernetes-Secret. Ohne sie werden E-Mails nur ins Server-Log geschrieben.",
|
||||||
|
failureHeading: "Versand fehlgeschlagen",
|
||||||
|
causeLabel: "Was zu tun ist",
|
||||||
|
detailLabel: "Meldung des Mailservers",
|
||||||
|
configLabel: "Verwendete Einstellungen",
|
||||||
|
},
|
||||||
adminWithdrawalsPage: {
|
adminWithdrawalsPage: {
|
||||||
title: "Widerrufe",
|
title: "Widerrufe",
|
||||||
intro: "Erklärungen über die Online-Widerrufsfunktion (§ 356a BGB). Die Erstattung ist spätestens 14 Tage nach Eingang fällig; du darfst sie zurückhalten, bis die Ware zurück ist oder die Rücksendung nachgewiesen wurde.",
|
intro: "Erklärungen über die Online-Widerrufsfunktion (§ 356a BGB). Die Erstattung ist spätestens 14 Tage nach Eingang fällig; du darfst sie zurückhalten, bis die Ware zurück ist oder die Rücksendung nachgewiesen wurde.",
|
||||||
@@ -1496,6 +1508,7 @@ export const de: Dictionary = {
|
|||||||
navVouchers: "Gutscheine",
|
navVouchers: "Gutscheine",
|
||||||
navReviews: "Bewertungen",
|
navReviews: "Bewertungen",
|
||||||
navAuditLog: "Audit-Log",
|
navAuditLog: "Audit-Log",
|
||||||
|
navEmailTest: "E-Mail-Test",
|
||||||
navContent: "Inhalte",
|
navContent: "Inhalte",
|
||||||
navAriaLabel: "Admin-Navigation",
|
navAriaLabel: "Admin-Navigation",
|
||||||
mobileNavAriaLabel: "Mobile Admin-Navigation",
|
mobileNavAriaLabel: "Mobile Admin-Navigation",
|
||||||
|
|||||||
@@ -1231,6 +1231,18 @@ export const en: Dictionary = {
|
|||||||
sectionVisibility: "Visibility",
|
sectionVisibility: "Visibility",
|
||||||
create: "Create",
|
create: "Create",
|
||||||
},
|
},
|
||||||
|
adminEmailTestPage: {
|
||||||
|
title: "Test email delivery",
|
||||||
|
intro: "Connects to the mail server, signs in with SMTP_USER and sends a test email to your own address. If something fails, you see the mail server message and what to do (the explanation is in German).",
|
||||||
|
button: "Send test email",
|
||||||
|
running: "Testing …",
|
||||||
|
success: (to) => `Delivery works. The test email is on its way to ${to}.`,
|
||||||
|
notConfigured: "SMTP is not set up: SMTP_HOST, SMTP_USER or SMTP_PASSWORD is missing from the Kubernetes secret. Without them, emails are only written to the server log.",
|
||||||
|
failureHeading: "Delivery failed",
|
||||||
|
causeLabel: "What to do",
|
||||||
|
detailLabel: "Mail server message",
|
||||||
|
configLabel: "Settings used",
|
||||||
|
},
|
||||||
adminWithdrawalsPage: {
|
adminWithdrawalsPage: {
|
||||||
title: "Withdrawals",
|
title: "Withdrawals",
|
||||||
intro: "Notices submitted via the online withdrawal function (§ 356a BGB). The refund is due no later than 14 days after receipt; you may withhold it until the goods are back or proof of return has been provided.",
|
intro: "Notices submitted via the online withdrawal function (§ 356a BGB). The refund is due no later than 14 days after receipt; you may withhold it until the goods are back or proof of return has been provided.",
|
||||||
@@ -1470,6 +1482,7 @@ export const en: Dictionary = {
|
|||||||
navVouchers: "Vouchers",
|
navVouchers: "Vouchers",
|
||||||
navReviews: "Reviews",
|
navReviews: "Reviews",
|
||||||
navAuditLog: "Audit log",
|
navAuditLog: "Audit log",
|
||||||
|
navEmailTest: "Email test",
|
||||||
navContent: "Content",
|
navContent: "Content",
|
||||||
navAriaLabel: "Admin navigation",
|
navAriaLabel: "Admin navigation",
|
||||||
mobileNavAriaLabel: "Mobile admin navigation",
|
mobileNavAriaLabel: "Mobile admin navigation",
|
||||||
|
|||||||
@@ -818,6 +818,18 @@ export interface Dictionary {
|
|||||||
sectionVisibility: string;
|
sectionVisibility: string;
|
||||||
create: string;
|
create: string;
|
||||||
};
|
};
|
||||||
|
adminEmailTestPage: {
|
||||||
|
title: string;
|
||||||
|
intro: string;
|
||||||
|
button: string;
|
||||||
|
running: string;
|
||||||
|
success: (to: string) => string;
|
||||||
|
notConfigured: string;
|
||||||
|
failureHeading: string;
|
||||||
|
causeLabel: string;
|
||||||
|
detailLabel: string;
|
||||||
|
configLabel: string;
|
||||||
|
};
|
||||||
adminWithdrawalsPage: {
|
adminWithdrawalsPage: {
|
||||||
title: string;
|
title: string;
|
||||||
intro: string;
|
intro: string;
|
||||||
@@ -1045,6 +1057,7 @@ export interface Dictionary {
|
|||||||
navVouchers: string;
|
navVouchers: string;
|
||||||
navReviews: string;
|
navReviews: string;
|
||||||
navAuditLog: string;
|
navAuditLog: string;
|
||||||
|
navEmailTest: string;
|
||||||
navContent: string;
|
navContent: string;
|
||||||
navAriaLabel: string;
|
navAriaLabel: string;
|
||||||
mobileNavAriaLabel: string;
|
mobileNavAriaLabel: string;
|
||||||
|
|||||||
+77
-12
@@ -1,4 +1,5 @@
|
|||||||
import nodemailer from "nodemailer";
|
import nodemailer from "nodemailer";
|
||||||
|
import { explainSmtpError, type SmtpDiagnosis } from "@/lib/smtp-diagnose";
|
||||||
|
|
||||||
export interface MailAttachment {
|
export interface MailAttachment {
|
||||||
filename: string;
|
filename: string;
|
||||||
@@ -33,14 +34,28 @@ let cachedTransport: ReturnType<typeof nodemailer.createTransport> | null = null
|
|||||||
// Bestellbestaetigungen/Passwort-Resets fortlaufend lautlos verschwinden.
|
// Bestellbestaetigungen/Passwort-Resets fortlaufend lautlos verschwinden.
|
||||||
let warnedAboutMissingSmtpInProduction = false;
|
let warnedAboutMissingSmtpInProduction = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zugangsdaten aus der Umgebung. Ein Zeilenumbruch am Ende entsteht leicht
|
||||||
|
* beim Anlegen des Kubernetes-Secrets (echo ohne -n vor base64) und laesst
|
||||||
|
* jede Anmeldung scheitern — er wird entfernt, alle anderen Zeichen bleiben.
|
||||||
|
*/
|
||||||
|
function readSmtpConfig(): { host: string; port: number; user: string; pass: string } | null {
|
||||||
|
const host = process.env.SMTP_HOST?.trim();
|
||||||
|
const user = process.env.SMTP_USER?.trim();
|
||||||
|
const pass = process.env.SMTP_PASSWORD?.replace(/[\r\n]+$/, "");
|
||||||
|
if (!host || !user || !pass) return null;
|
||||||
|
return { host, port: Number(process.env.SMTP_PORT ?? 587), user, pass };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFromAddress(): string {
|
||||||
|
return process.env.SMTP_FROM || "Wabenhain <[email protected]>";
|
||||||
|
}
|
||||||
|
|
||||||
function getTransport() {
|
function getTransport() {
|
||||||
if (cachedTransport) return cachedTransport;
|
if (cachedTransport) return cachedTransport;
|
||||||
|
|
||||||
const host = process.env.SMTP_HOST;
|
const config = readSmtpConfig();
|
||||||
const port = Number(process.env.SMTP_PORT ?? 587);
|
if (!config) {
|
||||||
const user = process.env.SMTP_USER;
|
|
||||||
const pass = process.env.SMTP_PASSWORD;
|
|
||||||
if (!host || !user || !pass) {
|
|
||||||
if (process.env.NODE_ENV === "production" && !warnedAboutMissingSmtpInProduction) {
|
if (process.env.NODE_ENV === "production" && !warnedAboutMissingSmtpInProduction) {
|
||||||
warnedAboutMissingSmtpInProduction = true;
|
warnedAboutMissingSmtpInProduction = true;
|
||||||
console.warn(
|
console.warn(
|
||||||
@@ -51,10 +66,15 @@ function getTransport() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cachedTransport = nodemailer.createTransport({
|
cachedTransport = nodemailer.createTransport({
|
||||||
host,
|
host: config.host,
|
||||||
port,
|
port: config.port,
|
||||||
secure: port === 465,
|
secure: config.port === 465,
|
||||||
auth: { user, pass },
|
// Auf 587 MUSS die Verbindung per STARTTLS verschluesselt werden, bevor
|
||||||
|
// das Passwort gesendet wird — ohne requireTLS wuerde nodemailer bei
|
||||||
|
// einem Server ohne STARTTLS-Angebot (Fehlkonfiguration, Angriff) die
|
||||||
|
// Zugangsdaten im Klartext schicken.
|
||||||
|
requireTLS: config.port !== 465,
|
||||||
|
auth: { user: config.user, pass: config.pass },
|
||||||
});
|
});
|
||||||
return cachedTransport;
|
return cachedTransport;
|
||||||
}
|
}
|
||||||
@@ -75,7 +95,7 @@ function getTransport() {
|
|||||||
*/
|
*/
|
||||||
export async function sendMail(input: SendMailInput): Promise<boolean> {
|
export async function sendMail(input: SendMailInput): Promise<boolean> {
|
||||||
const transport = getTransport();
|
const transport = getTransport();
|
||||||
const from = process.env.SMTP_FROM || "Wabenhain <[email protected]>";
|
const from = getFromAddress();
|
||||||
|
|
||||||
if (!transport) {
|
if (!transport) {
|
||||||
console.warn(
|
console.warn(
|
||||||
@@ -95,8 +115,53 @@ export async function sendMail(input: SendMailInput): Promise<boolean> {
|
|||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
|
const diagnosis = explainSmtpError(error, readSmtpConfig()?.pass);
|
||||||
console.error(`[mail] Versand an ${maskEmail(input.to)} fehlgeschlagen: ${message}`);
|
console.error(
|
||||||
|
`[mail] Versand an ${maskEmail(input.to)} fehlgeschlagen (${diagnosis.cause}): ${diagnosis.detail} — ${diagnosis.hint}`
|
||||||
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SmtpTestResult =
|
||||||
|
| { ok: true; host: string; user: string; from: string }
|
||||||
|
| { ok: false; configured: false }
|
||||||
|
| { ok: false; configured: true; host: string; user: string; from: string; diagnosis: SmtpDiagnosis };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prueft die SMTP-Konfiguration im laufenden Betrieb: Verbindung + Anmeldung
|
||||||
|
* (verify) und danach ein echter Versand an `to` — erst der Versand zeigt,
|
||||||
|
* ob das Postfach mit der Absenderadresse (SMTP_FROM) senden darf. Baut
|
||||||
|
* bewusst einen frischen Transport statt des zwischengespeicherten, damit
|
||||||
|
* ein geaendertes Secret nach einem Neustart sofort getestet wird. Das
|
||||||
|
* Passwort erscheint nie im Ergebnis.
|
||||||
|
*/
|
||||||
|
export async function testSmtpConnection(to: string): Promise<SmtpTestResult> {
|
||||||
|
const config = readSmtpConfig();
|
||||||
|
if (!config) return { ok: false, configured: false };
|
||||||
|
const from = getFromAddress();
|
||||||
|
const transport = nodemailer.createTransport({
|
||||||
|
host: config.host,
|
||||||
|
port: config.port,
|
||||||
|
secure: config.port === 465,
|
||||||
|
requireTLS: config.port !== 465,
|
||||||
|
auth: { user: config.user, pass: config.pass },
|
||||||
|
connectionTimeout: 15_000,
|
||||||
|
greetingTimeout: 15_000,
|
||||||
|
});
|
||||||
|
const base = { host: config.host, user: config.user, from };
|
||||||
|
try {
|
||||||
|
await transport.verify();
|
||||||
|
await transport.sendMail({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
subject: "Wabenhain: Test des E-Mail-Versands",
|
||||||
|
text: `Diese Test-Mail bestaetigt, dass der Shop ueber ${config.host} als ${config.user} mit dem Absender ${from} senden kann.`,
|
||||||
|
});
|
||||||
|
return { ok: true, ...base };
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return { ok: false, configured: true, ...base, diagnosis: explainSmtpError(error, config.pass) };
|
||||||
|
} finally {
|
||||||
|
transport.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { explainSmtpError } from "./smtp-diagnose";
|
||||||
|
|
||||||
|
function smtpError(message: string, extra: Record<string, unknown> = {}): Error {
|
||||||
|
return Object.assign(new Error(message), extra);
|
||||||
|
}
|
||||||
|
|
||||||
|
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("recognises SMTP AUTH disabled for the tenant or mailbox", () => {
|
||||||
|
expect(explainSmtpError(smtpError("535 5.7.139 Authentication unsuccessful, SmtpClientAuthentication is disabled for the Tenant.")).cause).toBe(
|
||||||
|
"smtp-auth-disabled"
|
||||||
|
);
|
||||||
|
expect(explainSmtpError(smtpError("535 5.7.139 Authentication unsuccessful, SmtpClientAuthentication is disabled for the Mailbox.")).cause).toBe(
|
||||||
|
"smtp-auth-disabled"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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
|
||||||
|
).toBe("send-as-denied");
|
||||||
|
expect(explainSmtpError(smtpError("550 5.7.60 SMTP; Client does not have permissions to send as this sender")).cause).toBe("send-as-denied");
|
||||||
|
});
|
||||||
|
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to unknown and never throws on odd input", () => {
|
||||||
|
expect(explainSmtpError("kaputt").cause).toBe("unknown");
|
||||||
|
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"), "geheim123");
|
||||||
|
expect(JSON.stringify(result)).not.toContain("geheim123");
|
||||||
|
const leaking = explainSmtpError(smtpError("AUTH PLAIN failed for secret geheim123"), "geheim123");
|
||||||
|
expect(leaking.detail).not.toContain("geheim123");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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