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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user