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:
Nick SzaunigandClaude Opus 5.5 committed 2026-09-26 17:48:40 +02:00
1 parent cef2248a45
commit 302a18baa6
10 files changed
+355 -12

No files matched your search

@@ -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>
);
}