Files
Wabenhain/src/app/admin/e-mail-test/smtp-test-panel.tsx
T
Nick SzaunigandClaude Opus 5.5 0620e9d79a feat(mail): Microsoft 365 sending via OAuth 2.0 (client credentials)
Sending no longer needs a user password for Microsoft 365. When
SMTP_OAUTH_TENANT_ID, SMTP_OAUTH_CLIENT_ID and SMTP_OAUTH_CLIENT_SECRET
are set, the shop fetches an app-only token from Entra ID and signs in to
smtp.office365.com with XOAUTH2. This works with security defaults and
MFA, survives the end of SMTP basic auth (default off from end of
December 2026) and limits the app to the one mailbox Exchange grants it
(SMTP.SendAsApp + Add-MailboxPermission). Password login stays available
for other providers; a partial OAuth setup never silently falls back.

- lib/ms365-oauth.ts: token cached until 5 minutes before expiry,
  concurrent requests share one fetch, forced refresh after a server
  rejection, 15 s timeout, tenant id validated before it enters the URL,
  client secret redacted from every error.
- lib/smtp-diagnose.ts: explains AADSTS7000215 (secret ID instead of
  value), 7000222 (expired), 700016 (unknown app), 90002/900023 (tenant)
  and maps a rejected XOAUTH2 login to missing mailbox access.
- Admin e-mail test shows the auth mode and lists missing settings.
- k8s manifests, preflight check and README (step-by-step Entra and
  Exchange setup) updated; misplaced SMTP_PORT comment moved.

Verified against Entra ID and smtp.office365.com with fake values:
AADSTS90002 is classified as tenant error without leaking the secret,
an invalid token yields 535 5.7.3 and is classified as mailbox access.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 18:17:33 +02:00

59 lines
2.5 KiB
TypeScript

"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 ? (
<div className="space-y-2 border border-red-300 px-5 py-4 text-sm text-red-700 dark:text-red-300">
<p>{s.notConfigured}</p>
<p className="font-mono text-xs">
{s.missingLabel}: {state.missing.join(", ")}
</p>
</div>
) : 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">
{state.authMode === "oauth2" ? s.authModeOAuth : s.authModePassword} · SMTP_HOST={state.host} · SMTP_USER={state.user} · SMTP_FROM={state.from}
</p>
</div>
</div>
) : null}
</div>
</div>
);
}