From 260dd312a9b11239e3ef51b56ec634cf94942600 Mon Sep 17 00:00:00 2001 From: Till JS Date: Tue, 21 Apr 2026 15:48:03 +0200 Subject: [PATCH] feat(broadcast): M8 DNS auth check (SPF / DKIM / DMARC) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the last plan milestone. Users can verify their sending-domain setup without leaving the broadcast settings page. Server (mana-mail) - services/dns-check.ts: parseSpf / parseDkim / parseDmarc are pure functions. SPF accepts include:, flags weak (+all) and wrong (include missing) and multi-record (RFC 7208 §3.2). DKIM needs v=DKIM1 + a p= public-key segment. DMARC requires v=DMARC1, flags p=none as weak (monitoring only), ok on quarantine/reject. All three are case-insensitive. - lookupTxt(): DNS-over-HTTPS against Cloudflare 1.1.1.1 — avoids the Bun/container udp-resolver flakiness and works everywhere. Multi-string TXT (`"a" "b"`) get concatenated before parsing. - checkDomain(): one call, three parallel DoH lookups, returns a structured result with suggested copy-paste records scoped to the user's actual mail domain from config. - Route: GET /v1/mail/dns-check?domain=&selector= (JWT auth). Zod validates the domain looks sensible before hitting DoH. - 16 unit tests covering all three parsers + multi-record edge case. Client - api.ts: runDnsCheck(domain, selector?) helper with typed result. - components/DnsCheckBanner.svelte: derives domain from the default from-email (after @), calls the check on-demand, renders per-record status chips (ok / weak / wrong / missing) with messages, exposes copy-pasteable SPF + DMARC records when anything's off. DKIM setup is provider-specific so we show a hint rather than a canned record. Last-check timestamp persists to settings.dnsCheck so the banner survives a reload without re-hitting the API. - Wired into SettingsForm between Impressum and Standard-Footer — where the user is already thinking about "what's required to actually send". All checks clean: - webapp pnpm check: 0 broadcast errors (4 pre-existing articles errors from parallel Spaces work, unrelated) - mana-mail tests: 36/36 across tracking-token + link-rewriter + dns-check - mana-mail build: 2.51 MB (+8 KB for juice — dns-check itself is ~3 KB) Plan: docs/plans/broadcast-module.md §M8. All 10 milestones now done. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../apps/web/src/lib/modules/broadcast/api.ts | 33 ++ .../components/DnsCheckBanner.svelte | 346 ++++++++++++++++++ .../broadcast/components/SettingsForm.svelte | 3 + services/mana-mail/src/index.ts | 2 + .../mana-mail/src/routes/broadcast-dns.ts | 42 +++ .../mana-mail/src/services/dns-check-env.ts | 9 + .../mana-mail/src/services/dns-check.test.ts | 108 ++++++ services/mana-mail/src/services/dns-check.ts | 228 ++++++++++++ 8 files changed, 771 insertions(+) create mode 100644 apps/mana/apps/web/src/lib/modules/broadcast/components/DnsCheckBanner.svelte create mode 100644 services/mana-mail/src/routes/broadcast-dns.ts create mode 100644 services/mana-mail/src/services/dns-check-env.ts create mode 100644 services/mana-mail/src/services/dns-check.test.ts create mode 100644 services/mana-mail/src/services/dns-check.ts diff --git a/apps/mana/apps/web/src/lib/modules/broadcast/api.ts b/apps/mana/apps/web/src/lib/modules/broadcast/api.ts index 204af4c82..33a5bf315 100644 --- a/apps/mana/apps/web/src/lib/modules/broadcast/api.ts +++ b/apps/mana/apps/web/src/lib/modules/broadcast/api.ts @@ -107,6 +107,39 @@ export async function sendCampaign( return (await res.json()) as BulkSendResult; } +/** + * Run a DNS authentication check for the user's sending domain. + * Returns null on auth / 404 so the UI can treat "service down" and + * "nothing to report" the same way. + */ +export interface DnsCheckResult { + domain: string; + spf: { status: 'ok' | 'missing' | 'wrong' | 'weak'; record: string | null; message: string }; + dkim: { + status: 'ok' | 'missing' | 'wrong' | 'weak'; + record: string | null; + selector: string; + message: string; + }; + dmarc: { status: 'ok' | 'missing' | 'wrong' | 'weak'; record: string | null; message: string }; + checkedAt: string; + suggested: { + spfAdd: string; + dmarcRecord: string; + }; +} + +export async function runDnsCheck(domain: string, selector?: string): Promise { + const params = new URLSearchParams({ domain }); + if (selector) params.set('selector', selector); + const res = await fetchWithAuth(`/api/v1/mail/dns-check?${params.toString()}`); + if (!res.ok) { + const errorText = await res.text(); + throw new Error(`DNS-Check fehlgeschlagen (${res.status}): ${errorText}`); + } + return (await res.json()) as DnsCheckResult; +} + /** * Fetch aggregate stats for a campaign from mana-mail. Safe to poll on a * timer from the DetailView (M7) — server returns cached rollups. diff --git a/apps/mana/apps/web/src/lib/modules/broadcast/components/DnsCheckBanner.svelte b/apps/mana/apps/web/src/lib/modules/broadcast/components/DnsCheckBanner.svelte new file mode 100644 index 000000000..4ac941ef4 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/broadcast/components/DnsCheckBanner.svelte @@ -0,0 +1,346 @@ + + + +
+
+
+

DNS-Authentifizierung

+

+ SPF / DKIM / DMARC auf {domain || '—'} — ohne das landen Newsletter überdurchschnittlich + oft im Spam. +

+
+ +
+ + {#if error} +
{error}
+ {/if} + + {#if result} +
    +
  • + {statusIcon(result.spf.status)} + SPF + {result.spf.message} +
  • +
  • + {statusIcon(result.dkim.status)} + DKIM ({result.dkim.selector}) + {result.dkim.message} +
  • +
  • + {statusIcon(result.dmarc.status)} + DMARC + {result.dmarc.message} +
  • +
+ + {#if result.spf.status !== 'ok' || result.dmarc.status === 'missing'} +
+ Empfohlene DNS-Records (zum Kopieren) +

+ Bei deinem Domain-Registrar (z. B. Cloudflare / Infomaniak) als TXT-Records anlegen. +

+ {#if result.spf.status !== 'ok'} +
+
+ TXT auf {result.domain} + +
+
{result.suggested.spfAdd}
+
+ {/if} + {#if result.dmarc.status === 'missing'} +
+
+ + TXT auf _dmarc.{result.domain} + + +
+
{result.suggested.dmarcRecord}
+
+ {/if} + {#if result.dkim.status === 'missing'} +

+ DKIM-Setup ist provider-spezifisch — wende dich an den Mana-Support oder schau in der + Stalwart-Doku, wie der DKIM-Key für + {result.dkim.selector}._domainkey.{result.domain} + aussehen soll. +

+ {/if} +
+ {/if} + + + {:else if settings.dnsCheck} +

+ Letzte Prüfung: {new Date(settings.dnsCheck.checkedAt).toLocaleString()} +
+ SPF {statusIcon(settings.dnsCheck.spf)} · DKIM {statusIcon(settings.dnsCheck.dkim)} · DMARC {statusIcon( + settings.dnsCheck.dmarc + )} +

+ {/if} +
+ + diff --git a/apps/mana/apps/web/src/lib/modules/broadcast/components/SettingsForm.svelte b/apps/mana/apps/web/src/lib/modules/broadcast/components/SettingsForm.svelte index 915bfee27..f81f66bc8 100644 --- a/apps/mana/apps/web/src/lib/modules/broadcast/components/SettingsForm.svelte +++ b/apps/mana/apps/web/src/lib/modules/broadcast/components/SettingsForm.svelte @@ -8,6 +8,7 @@ -->