mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-18 06:09:41 +02:00
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:
parent
75832faef7
commit
260dd312a9
8 changed files with 771 additions and 0 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 2–3 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>
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
42
services/mana-mail/src/routes/broadcast-dns.ts
Normal file
42
services/mana-mail/src/routes/broadcast-dns.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
9
services/mana-mail/src/services/dns-check-env.ts
Normal file
9
services/mana-mail/src/services/dns-check-env.ts
Normal 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';
|
||||
}
|
||||
108
services/mana-mail/src/services/dns-check.test.ts
Normal file
108
services/mana-mail/src/services/dns-check.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
228
services/mana-mail/src/services/dns-check.ts
Normal file
228
services/mana-mail/src/services/dns-check.ts
Normal 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}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue