diff --git a/src/app/admin/e-mail-test/actions.ts b/src/app/admin/e-mail-test/actions.ts new file mode 100644 index 0000000..3ab240e --- /dev/null +++ b/src/app/admin/e-mail-test/actions.ts @@ -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 { + 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 }; +} diff --git a/src/app/admin/e-mail-test/page.tsx b/src/app/admin/e-mail-test/page.tsx new file mode 100644 index 0000000..f795629 --- /dev/null +++ b/src/app/admin/e-mail-test/page.tsx @@ -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 ( +
+ + +

{t.adminEmailTestPage.intro}

+ +
+ ); +} diff --git a/src/app/admin/e-mail-test/smtp-test-panel.tsx b/src/app/admin/e-mail-test/smtp-test-panel.tsx new file mode 100644 index 0000000..c1a1010 --- /dev/null +++ b/src/app/admin/e-mail-test/smtp-test-panel.tsx @@ -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(runSmtpTestAction, null); + + return ( +
+
+ +
+ +
+ {state && "rateLimited" in state ? ( +

Zu viele Tests. Bitte warte ein paar Minuten.

+ ) : null} + {state && "ok" in state && state.ok ? ( +

{s.success(state.to)}

+ ) : null} + {state && "ok" in state && !state.ok && !state.configured ? ( +

{s.notConfigured}

+ ) : null} + {state && "ok" in state && !state.ok && state.configured ? ( +
+

{s.failureHeading}

+
+

{s.causeLabel}

+

{state.diagnosis.hint}

+
+
+

{s.detailLabel}

+

{state.diagnosis.detail || "—"}

+
+
+

{s.configLabel}

+

+ SMTP_HOST={state.host} · SMTP_USER={state.user} · SMTP_FROM={state.from} +

+
+
+ ) : null} +
+
+ ); +} diff --git a/src/components/admin-shell.tsx b/src/components/admin-shell.tsx index 1e1e6e1..91e7cfb 100644 --- a/src/components/admin-shell.tsx +++ b/src/components/admin-shell.tsx @@ -59,6 +59,7 @@ function getAdminNavGroups(t: Dictionary): { label: string | null; items: NavIte items: [ { href: "/admin/inhalte", label: t.adminShell.navContent }, { href: "/admin/audit-log", label: t.adminShell.navAuditLog }, + { href: "/admin/e-mail-test", label: t.adminShell.navEmailTest }, ], }, ]; diff --git a/src/i18n/dictionaries/de.ts b/src/i18n/dictionaries/de.ts index 692f27b..593d563 100644 --- a/src/i18n/dictionaries/de.ts +++ b/src/i18n/dictionaries/de.ts @@ -1257,6 +1257,18 @@ export const de: Dictionary = { sectionVisibility: "Sichtbarkeit", 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: { 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.", @@ -1496,6 +1508,7 @@ export const de: Dictionary = { navVouchers: "Gutscheine", navReviews: "Bewertungen", navAuditLog: "Audit-Log", + navEmailTest: "E-Mail-Test", navContent: "Inhalte", navAriaLabel: "Admin-Navigation", mobileNavAriaLabel: "Mobile Admin-Navigation", diff --git a/src/i18n/dictionaries/en.ts b/src/i18n/dictionaries/en.ts index 433192a..229d534 100644 --- a/src/i18n/dictionaries/en.ts +++ b/src/i18n/dictionaries/en.ts @@ -1231,6 +1231,18 @@ export const en: Dictionary = { sectionVisibility: "Visibility", 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: { 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.", @@ -1470,6 +1482,7 @@ export const en: Dictionary = { navVouchers: "Vouchers", navReviews: "Reviews", navAuditLog: "Audit log", + navEmailTest: "Email test", navContent: "Content", navAriaLabel: "Admin navigation", mobileNavAriaLabel: "Mobile admin navigation", diff --git a/src/i18n/dictionary-type.ts b/src/i18n/dictionary-type.ts index 5e5c120..cc72f5c 100644 --- a/src/i18n/dictionary-type.ts +++ b/src/i18n/dictionary-type.ts @@ -818,6 +818,18 @@ export interface Dictionary { sectionVisibility: 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: { title: string; intro: string; @@ -1045,6 +1057,7 @@ export interface Dictionary { navVouchers: string; navReviews: string; navAuditLog: string; + navEmailTest: string; navContent: string; navAriaLabel: string; mobileNavAriaLabel: string; diff --git a/src/lib/mail.ts b/src/lib/mail.ts index 6fa7a60..751f135 100644 --- a/src/lib/mail.ts +++ b/src/lib/mail.ts @@ -1,4 +1,5 @@ import nodemailer from "nodemailer"; +import { explainSmtpError, type SmtpDiagnosis } from "@/lib/smtp-diagnose"; export interface MailAttachment { filename: string; @@ -33,14 +34,28 @@ let cachedTransport: ReturnType | null = null // Bestellbestaetigungen/Passwort-Resets fortlaufend lautlos verschwinden. 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 "; +} + function getTransport() { if (cachedTransport) return cachedTransport; - const host = process.env.SMTP_HOST; - const port = Number(process.env.SMTP_PORT ?? 587); - const user = process.env.SMTP_USER; - const pass = process.env.SMTP_PASSWORD; - if (!host || !user || !pass) { + const config = readSmtpConfig(); + if (!config) { if (process.env.NODE_ENV === "production" && !warnedAboutMissingSmtpInProduction) { warnedAboutMissingSmtpInProduction = true; console.warn( @@ -51,10 +66,15 @@ function getTransport() { } cachedTransport = nodemailer.createTransport({ - host, - port, - secure: port === 465, - auth: { user, pass }, + host: config.host, + port: config.port, + secure: config.port === 465, + // 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; } @@ -75,7 +95,7 @@ function getTransport() { */ export async function sendMail(input: SendMailInput): Promise { const transport = getTransport(); - const from = process.env.SMTP_FROM || "Wabenhain "; + const from = getFromAddress(); if (!transport) { console.warn( @@ -95,8 +115,53 @@ export async function sendMail(input: SendMailInput): Promise { }); return true; } catch (error: unknown) { - const message = error instanceof Error ? error.message : "Unbekannter Fehler"; - console.error(`[mail] Versand an ${maskEmail(input.to)} fehlgeschlagen: ${message}`); + const diagnosis = explainSmtpError(error, readSmtpConfig()?.pass); + console.error( + `[mail] Versand an ${maskEmail(input.to)} fehlgeschlagen (${diagnosis.cause}): ${diagnosis.detail} — ${diagnosis.hint}` + ); 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 { + 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(); + } +} diff --git a/src/lib/smtp-diagnose.test.ts b/src/lib/smtp-diagnose.test.ts new file mode 100644 index 0000000..efb4256 --- /dev/null +++ b/src/lib/smtp-diagnose.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import { explainSmtpError } from "./smtp-diagnose"; + +function smtpError(message: string, extra: Record = {}): 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; info@wabenhain.com not allowed to send as hallo@wabenhain.com")).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"); + }); +}); diff --git a/src/lib/smtp-diagnose.ts b/src/lib/smtp-diagnose.ts new file mode 100644 index 0000000..fe0b55b --- /dev/null +++ b/src/lib/smtp-diagnose.ts @@ -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 = { + "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) }; +}