feat(broadcast): M8 DNS auth check (SPF / DKIM / DMARC)

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:<mailDomain>, 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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-21 15:48:03 +02:00
parent 75832faef7
commit 260dd312a9
8 changed files with 771 additions and 0 deletions

View file

@ -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<DnsCheckResult> {
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.

View file

@ -0,0 +1,346 @@
<!--
DnsCheckBanner — SPF / DKIM / DMARC status for the user's sending
domain with copy-paste records + "jetzt prüfen" trigger.
Derives the domain from the sender email (after the @). Calls
mana-mail's /v1/mail/dns-check on demand; caches the last result
in the settings row so the banner survives a reload.
The DNS check takes 23 seconds (DoH round-trips to 1.1.1.1); we
show a spinner so the user doesn't double-click.
-->
<script lang="ts">
import { runDnsCheck, type DnsCheckResult } from '../api';
import { broadcastSettingsStore } from '../stores/settings.svelte';
import type { BroadcastSettings } from '../types';
interface Props {
settings: BroadcastSettings;
}
let { settings }: Props = $props();
let result = $state<DnsCheckResult | null>(null);
let loading = $state(false);
let error = $state<string | null>(null);
const domain = $derived.by(() => {
const email = settings.defaultFromEmail ?? '';
const at = email.indexOf('@');
return at >= 0 ? email.slice(at + 1).trim() : '';
});
async function runCheck() {
if (!domain) {
error = 'Keine Absender-Domain — setze zuerst die Absender-E-Mail.';
return;
}
loading = true;
error = null;
try {
result = await runDnsCheck(domain);
// Persist a compact snapshot to the settings row so the banner
// shows the last result on next mount without re-hitting the API.
await broadcastSettingsStore.update({
dnsCheck: {
domain,
spf: result.spf.status,
dkim: result.dkim.status,
dmarc: result.dmarc.status,
checkedAt: result.checkedAt,
},
});
} catch (e) {
error = e instanceof Error ? e.message : 'DNS-Check fehlgeschlagen';
} finally {
loading = false;
}
}
function copyToClipboard(text: string) {
navigator.clipboard.writeText(text);
}
function statusIcon(status: 'ok' | 'missing' | 'wrong' | 'weak'): string {
return { ok: '✓', weak: '⚠', wrong: '✕', missing: '—' }[status];
}
function statusClass(status: 'ok' | 'missing' | 'wrong' | 'weak'): string {
return `status-${status}`;
}
</script>
<section class="dns-card">
<header class="dns-head">
<div>
<h3>DNS-Authentifizierung</h3>
<p class="hint">
SPF / DKIM / DMARC auf <strong>{domain || '—'}</strong> — ohne das landen Newsletter überdurchschnittlich
oft im Spam.
</p>
</div>
<button type="button" class="btn-check" onclick={runCheck} disabled={loading || !domain}>
{loading ? 'Prüft …' : 'Jetzt prüfen'}
</button>
</header>
{#if error}
<div class="error">{error}</div>
{/if}
{#if result}
<ul class="checks">
<li class={statusClass(result.spf.status)}>
<span class="icon">{statusIcon(result.spf.status)}</span>
<span class="check-label">SPF</span>
<span class="check-message">{result.spf.message}</span>
</li>
<li class={statusClass(result.dkim.status)}>
<span class="icon">{statusIcon(result.dkim.status)}</span>
<span class="check-label">DKIM ({result.dkim.selector})</span>
<span class="check-message">{result.dkim.message}</span>
</li>
<li class={statusClass(result.dmarc.status)}>
<span class="icon">{statusIcon(result.dmarc.status)}</span>
<span class="check-label">DMARC</span>
<span class="check-message">{result.dmarc.message}</span>
</li>
</ul>
{#if result.spf.status !== 'ok' || result.dmarc.status === 'missing'}
<details class="suggested">
<summary>Empfohlene DNS-Records (zum Kopieren)</summary>
<p class="hint">
Bei deinem Domain-Registrar (z. B. Cloudflare / Infomaniak) als TXT-Records anlegen.
</p>
{#if result.spf.status !== 'ok'}
<div class="record">
<div class="record-header">
<code class="record-type">TXT auf <strong>{result.domain}</strong></code>
<button
type="button"
class="btn-copy"
onclick={() => copyToClipboard(result!.suggested.spfAdd)}
>
Kopieren
</button>
</div>
<pre><code>{result.suggested.spfAdd}</code></pre>
</div>
{/if}
{#if result.dmarc.status === 'missing'}
<div class="record">
<div class="record-header">
<code class="record-type">
TXT auf <strong>_dmarc.{result.domain}</strong>
</code>
<button
type="button"
class="btn-copy"
onclick={() => copyToClipboard(result!.suggested.dmarcRecord)}
>
Kopieren
</button>
</div>
<pre><code>{result.suggested.dmarcRecord}</code></pre>
</div>
{/if}
{#if result.dkim.status === 'missing'}
<p class="hint">
DKIM-Setup ist provider-spezifisch — wende dich an den Mana-Support oder schau in der
Stalwart-Doku, wie der DKIM-Key für
<code>{result.dkim.selector}._domainkey.{result.domain}</code>
aussehen soll.
</p>
{/if}
</details>
{/if}
<p class="footer-hint">
Letzte Prüfung: {new Date(result.checkedAt).toLocaleString()}
</p>
{:else if settings.dnsCheck}
<p class="hint">
Letzte Prüfung: {new Date(settings.dnsCheck.checkedAt).toLocaleString()}
<br />
SPF {statusIcon(settings.dnsCheck.spf)} · DKIM {statusIcon(settings.dnsCheck.dkim)} · DMARC {statusIcon(
settings.dnsCheck.dmarc
)}
</p>
{/if}
</section>
<style>
.dns-card {
background: var(--color-surface, #fff);
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 0.5rem;
padding: 1rem 1.25rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.dns-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
}
.dns-head h3 {
margin: 0 0 0.25rem;
font-size: 1rem;
font-weight: 600;
}
.hint {
margin: 0;
font-size: 0.85rem;
color: var(--color-text-muted, #64748b);
}
.btn-check {
padding: 0.45rem 1rem;
background: #6366f1;
color: white;
border: 0;
border-radius: 0.4rem;
cursor: pointer;
font-size: 0.9rem;
flex-shrink: 0;
}
.btn-check:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.error {
background: #fef2f2;
border: 1px solid #fecaca;
color: #991b1b;
padding: 0.6rem 0.85rem;
border-radius: 0.4rem;
font-size: 0.85rem;
}
.checks {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.checks li {
display: grid;
grid-template-columns: 1.5rem 5rem 1fr;
align-items: baseline;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-radius: 0.4rem;
font-size: 0.9rem;
}
.checks li.status-ok {
background: #dcfce7;
color: #15803d;
}
.checks li.status-weak {
background: #fef3c7;
color: #92400e;
}
.checks li.status-wrong,
.checks li.status-missing {
background: #fef2f2;
color: #991b1b;
}
.icon {
font-weight: 600;
text-align: center;
}
.check-label {
font-weight: 500;
}
.check-message {
color: inherit;
opacity: 0.85;
font-size: 0.85rem;
}
.suggested {
background: var(--color-surface-muted, #f8fafc);
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 0.4rem;
padding: 0.75rem 1rem;
}
.suggested summary {
cursor: pointer;
font-size: 0.9rem;
font-weight: 500;
}
.suggested[open] summary {
margin-bottom: 0.5rem;
}
.record {
margin-top: 0.75rem;
}
.record-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.25rem;
}
.record-type {
font-size: 0.8rem;
color: var(--color-text-muted, #64748b);
}
.record pre {
background: white;
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 0.35rem;
padding: 0.6rem 0.75rem;
font-size: 0.85rem;
overflow-x: auto;
margin: 0;
}
.btn-copy {
padding: 0.25rem 0.65rem;
background: white;
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 0.3rem;
cursor: pointer;
font-size: 0.8rem;
}
.btn-copy:hover {
background: #eef2ff;
border-color: #6366f1;
}
.footer-hint {
margin: 0.25rem 0 0;
font-size: 0.75rem;
color: var(--color-text-muted, #94a3b8);
text-align: right;
}
code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
</style>

View file

@ -8,6 +8,7 @@
-->
<script lang="ts">
import { broadcastSettingsStore } from '../stores/settings.svelte';
import DnsCheckBanner from './DnsCheckBanner.svelte';
import type { BroadcastSettings } from '../types';
let settings = $state<BroadcastSettings | null>(null);
@ -106,6 +107,8 @@
</label>
</section>
<DnsCheckBanner {settings} />
<section class="section">
<h3>Standard-Footer</h3>
<p class="hint">

View file

@ -26,6 +26,7 @@ import { createInternalRoutes } from './routes/internal';
import { createBroadcastSendRoutes } from './routes/broadcast-send';
import { createBroadcastTrackRoutes } from './routes/broadcast-track';
import { createBroadcastStatsRoutes } from './routes/broadcast-stats';
import { createBroadcastDnsRoutes } from './routes/broadcast-dns';
// ─── Bootstrap ──────────────────────────────────────────────
@ -80,6 +81,7 @@ app.route(
createBroadcastSendRoutes(broadcastOrchestrator, config.broadcast.maxRecipientsPerCampaign)
);
app.route('/api/v1/mail', createBroadcastStatsRoutes(db));
app.route('/api/v1/mail', createBroadcastDnsRoutes(config.stalwart.domain));
app.route('/api/v1/mail', createLabelRoutes(mailService));
app.route('/api/v1/mail', createAccountRoutes(accountService));
app.route('/api/v1/mail/messages', createMessageRoutes(mailService));

View file

@ -0,0 +1,42 @@
/**
* GET /v1/mail/dns-check?domain=<apex> JWT auth.
*
* Returns the SPF / DKIM / DMARC status for the user's sending domain
* plus the exact records they should publish. Called on-demand from
* the broadcast settings UI.
*/
import { Hono } from 'hono';
import { z } from 'zod';
import { checkDomain } from '../services/dns-check';
import type { AuthUser } from '../middleware/jwt-auth';
const querySchema = z.object({
domain: z
.string()
.min(3)
.regex(/^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}$/i, 'Domain sieht nicht valide aus'),
selector: z.string().optional(),
});
export function createBroadcastDnsRoutes(defaultMailDomain: string) {
return new Hono<{ Variables: { user: AuthUser } }>().get('/dns-check', async (c) => {
const parsed = querySchema.safeParse({
domain: c.req.query('domain'),
selector: c.req.query('selector'),
});
if (!parsed.success) {
return c.json({ error: parsed.error.issues[0]?.message ?? 'bad query' }, 400);
}
try {
const result = await checkDomain(parsed.data.domain, {
mailDomain: defaultMailDomain,
dkimSelector: parsed.data.selector,
});
return c.json(result);
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
return c.json({ error: `DNS-Lookup fehlgeschlagen: ${reason}` }, 502);
}
});
}

View file

@ -0,0 +1,9 @@
/**
* Tiny helper to pull the configured mail domain without threading the
* full Config object through every DNS call. Config.loadConfig() runs
* at boot, so MAIL_DOMAIN is always defined by the time these helpers
* run we just read it from process.env directly.
*/
export function getMailDomain(): string {
return process.env.MAIL_DOMAIN || 'mana.how';
}

View file

@ -0,0 +1,108 @@
import { describe, it, expect } from 'bun:test';
import { parseSpf, parseDkim, parseDmarc } from './dns-check';
// ─── SPF ────────────────────────────────────────────────
describe('parseSpf', () => {
it('missing when no v=spf1 record', () => {
const r = parseSpf([], 'mana.how');
expect(r.status).toBe('missing');
expect(r.record).toBeNull();
});
it('wrong when multiple SPF records (RFC 7208 §3.2)', () => {
const r = parseSpf(
['v=spf1 include:_spf.google.com ~all', 'v=spf1 include:mailgun.org ~all'],
'mana.how'
);
expect(r.status).toBe('wrong');
expect(r.message.toLowerCase()).toContain('mehrere');
});
it('ok when include:<mailDomain> is present', () => {
const r = parseSpf(['v=spf1 include:mana.how ~all'], 'mana.how');
expect(r.status).toBe('ok');
expect(r.record).toContain('include:mana.how');
});
it('ok match is case-insensitive on both sides', () => {
const r = parseSpf(['V=SPF1 INCLUDE:MANA.HOW ~ALL'], 'Mana.How');
expect(r.status).toBe('ok');
});
it('weak on +all even without our include', () => {
const r = parseSpf(['v=spf1 +all'], 'mana.how');
expect(r.status).toBe('weak');
expect(r.message.toLowerCase()).toContain('spoofing');
});
it('wrong when SPF exists but omits our include', () => {
const r = parseSpf(['v=spf1 include:_spf.google.com ~all'], 'mana.how');
expect(r.status).toBe('wrong');
expect(r.message).toContain('include:mana.how');
});
});
// ─── DKIM ───────────────────────────────────────────────
describe('parseDkim', () => {
it('missing when no v=DKIM1 record', () => {
const r = parseDkim([], 'mana');
expect(r.status).toBe('missing');
expect(r.selector).toBe('mana');
});
it('wrong when p= key is absent', () => {
const r = parseDkim(['v=DKIM1; k=rsa'], 'mana');
expect(r.status).toBe('wrong');
expect(r.message.toLowerCase()).toContain('public-key');
});
it('ok when v=DKIM1 + p=<base64> is present', () => {
const r = parseDkim(['v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A'], 'mana');
expect(r.status).toBe('ok');
expect(r.selector).toBe('mana');
});
it('case-insensitive on v=DKIM1', () => {
const r = parseDkim(['V=dkim1; p=ABCDEF'], 'mana');
expect(r.status).toBe('ok');
});
});
// ─── DMARC ──────────────────────────────────────────────
describe('parseDmarc', () => {
it('missing when no v=DMARC1 record', () => {
const r = parseDmarc([]);
expect(r.status).toBe('missing');
});
it('wrong without p= policy', () => {
const r = parseDmarc(['v=DMARC1; rua=mailto:a@b.ch']);
expect(r.status).toBe('wrong');
});
it('weak on p=none', () => {
const r = parseDmarc(['v=DMARC1; p=none; rua=mailto:a@b.ch']);
expect(r.status).toBe('weak');
expect(r.message.toLowerCase()).toContain('quarantine');
});
it('ok on p=quarantine', () => {
const r = parseDmarc(['v=DMARC1; p=quarantine']);
expect(r.status).toBe('ok');
expect(r.message).toContain('quarantine');
});
it('ok on p=reject', () => {
const r = parseDmarc(['v=DMARC1; p=reject']);
expect(r.status).toBe('ok');
expect(r.message).toContain('reject');
});
it('case-insensitive on policy value', () => {
const r = parseDmarc(['v=DMARC1; p=REJECT']);
expect(r.status).toBe('ok');
});
});

View file

@ -0,0 +1,228 @@
/**
* DNS-based email-auth check: SPF / DKIM / DMARC.
*
* Queries Cloudflare's 1.1.1.1 DoH (DNS-over-HTTPS) JSON endpoint so
* the check works everywhere (no local resolver / UDP concerns). Bun
* has a native `dns.resolveTxt` but it can be flaky in containerised
* environments DoH is boringly reliable.
*
* Splitting parse from fetch keeps the test surface pure: feed a
* record string to the parser, assert the status.
*/
import { getMailDomain } from './dns-check-env';
export type DnsRecordStatus = 'ok' | 'missing' | 'wrong' | 'weak';
export interface DnsCheckResult {
domain: string;
spf: { status: DnsRecordStatus; record: string | null; message: string };
dkim: {
status: DnsRecordStatus;
record: string | null;
selector: string;
message: string;
};
dmarc: { status: DnsRecordStatus; record: string | null; message: string };
checkedAt: string;
/** Copy-paste records the user should publish, given the hosting setup. */
suggested: {
spfAdd: string;
dmarcRecord: string;
};
}
// ─── DNS-over-HTTPS fetch ────────────────────────────────
interface DoHAnswer {
name: string;
type: number;
TTL: number;
data: string;
}
interface DoHResponse {
Status: number;
Answer?: DoHAnswer[];
}
/**
* Look up TXT records for a name via Cloudflare DoH. Returns the
* `data` field of each answer (Cloudflare returns TXT records wrapped
* in double quotes; we strip them).
*/
export async function lookupTxt(name: string): Promise<string[]> {
const url = `https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(name)}&type=TXT`;
const res = await fetch(url, {
headers: { accept: 'application/dns-json' },
});
if (!res.ok) throw new Error(`DoH lookup failed: ${res.status}`);
const body = (await res.json()) as DoHResponse;
if (body.Status !== 0 || !body.Answer) return [];
return body.Answer.filter((a) => a.type === 16).map((a) => stripQuotes(a.data));
}
function stripQuotes(s: string): string {
// TXT records get returned as `"v=spf1 ..."` and multi-string TXT
// come as `"part1" "part2"`. Concatenate everything inside quotes.
const parts = s.match(/"([^"]*)"/g);
if (!parts) return s.replace(/^"|"$/g, '');
return parts.map((p) => p.slice(1, -1)).join('');
}
// ─── Record parsers (pure, testable) ─────────────────────
/**
* SPF check. Our send path goes through the user's Stalwart account,
* so SPF on the user's domain should include the Mana sending host.
* We accept three shapes:
* - Explicit `include:<mailDomain>` the canonical form we
* recommend in the suggested-records UI.
* - A `+mx` / `mx` token covers users who already route via their
* own MX; correct but needs the MX to be Mana.
* - Plain presence of `v=spf1` with `all` qualifier weak: parses
* but doesn't actually authorise us.
*/
export function parseSpf(
records: string[],
mailDomain: string
): { status: DnsRecordStatus; record: string | null; message: string } {
const spfRecords = records.filter((r) => r.toLowerCase().startsWith('v=spf1'));
if (spfRecords.length === 0) {
return { status: 'missing', record: null, message: 'Kein SPF-Record gefunden.' };
}
if (spfRecords.length > 1) {
// RFC 7208 §3.2 — multiple SPF records = PermError for resolvers.
return {
status: 'wrong',
record: spfRecords.join(' | '),
message: 'Mehrere SPF-Records. Erlaubt ist genau einer — überflüssige entfernen.',
};
}
const record = spfRecords[0];
const lower = record.toLowerCase();
if (lower.includes(`include:${mailDomain.toLowerCase()}`)) {
return {
status: 'ok',
record,
message: `SPF erlaubt Versand über ${mailDomain}.`,
};
}
// Accept a catch-all `+all` or `+mx` as a permissive pass, but warn.
if (lower.includes(' +all') || lower.endsWith('+all')) {
return {
status: 'weak',
record,
message: '+all erlaubt jedem Server zu senden — Einladung zum Spoofing. Spezifischer werden.',
};
}
return {
status: 'wrong',
record,
message: `SPF enthält kein include:${mailDomain}. Mails könnten in Spam landen.`,
};
}
/**
* DKIM check. The selector is `mana` (or whatever Stalwart's config
* uses) at `<selector>._domainkey.<domain>`. We don't try to verify
* the key matches just that a record of the right shape exists.
*/
export function parseDkim(
records: string[],
selector: string
): { status: DnsRecordStatus; record: string | null; selector: string; message: string } {
const dkim = records.find((r) => r.toLowerCase().startsWith('v=dkim1'));
if (!dkim) {
return {
status: 'missing',
record: null,
selector,
message: `Kein DKIM-Record auf ${selector}._domainkey — Mail wird nicht signiert.`,
};
}
// Loose validity check: needs a p= public-key segment.
if (!/\bp=[A-Za-z0-9+/=]+/i.test(dkim)) {
return {
status: 'wrong',
record: dkim,
selector,
message: 'DKIM-Record ohne gültiges p= (Public-Key fehlt).',
};
}
return {
status: 'ok',
record: dkim,
selector,
message: `DKIM signiert mit Selector "${selector}".`,
};
}
/**
* DMARC check. Policy `none` parses but doesn't actually enforce
* anything flagged as weak. `quarantine` and `reject` both OK.
*/
export function parseDmarc(records: string[]): {
status: DnsRecordStatus;
record: string | null;
message: string;
} {
const dmarc = records.find((r) => r.toLowerCase().startsWith('v=dmarc1'));
if (!dmarc) {
return {
status: 'missing',
record: null,
message:
'Kein DMARC-Record auf _dmarc — Gmail/Yahoo behandeln Bulk-Mails ohne DMARC strenger.',
};
}
const policy = dmarc.match(/\bp=(none|quarantine|reject)\b/i);
if (!policy) {
return {
status: 'wrong',
record: dmarc,
message: 'DMARC ohne p= (Policy). Setze mindestens p=none.',
};
}
if (policy[1].toLowerCase() === 'none') {
return {
status: 'weak',
record: dmarc,
message:
'p=none loggt nur — Phishing wird nicht abgewiesen. Nach Monitoring auf quarantine/reject gehen.',
};
}
return {
status: 'ok',
record: dmarc,
message: `DMARC aktiv mit Policy ${policy[1]}.`,
};
}
// ─── Orchestrator ────────────────────────────────────────
export async function checkDomain(
domain: string,
opts: { mailDomain?: string; dkimSelector?: string } = {}
): Promise<DnsCheckResult> {
const mailDomain = opts.mailDomain ?? getMailDomain();
const selector = opts.dkimSelector ?? 'mana';
const [spfRecords, dkimRecords, dmarcRecords] = await Promise.all([
lookupTxt(domain).catch(() => []),
lookupTxt(`${selector}._domainkey.${domain}`).catch(() => []),
lookupTxt(`_dmarc.${domain}`).catch(() => []),
]);
return {
domain,
spf: parseSpf(spfRecords, mailDomain),
dkim: parseDkim(dkimRecords, selector),
dmarc: parseDmarc(dmarcRecords),
checkedAt: new Date().toISOString(),
suggested: {
spfAdd: `v=spf1 include:${mailDomain} ~all`,
dmarcRecord: `v=DMARC1; p=none; rua=mailto:dmarc-reports@${domain}`,
},
};
}