From aee1c15f79aeafaa49594e079aadf23f6417c806 Mon Sep 17 00:00:00 2001 From: goetchstone Date: Fri, 17 Jul 2026 08:10:29 -0400 Subject: [PATCH] feat: broaden email-auth checker + paste fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds MTA-STS/TLS-RPT/BIMI, parses DMARC pct/sp so a reject-at-pct=0 record stops scoring Strong, and emits paste-ready DNS records per gap — the lead-qualifying pass in one run. --- __tests__/dmarc-dns.test.ts | 189 +++++++++++++- app/api/tools/dmarc-check/route.ts | 129 +++++---- app/tools/dmarc-check/page.tsx | 16 +- components/tools/dmarc-check-form.tsx | 135 +++++++++- lib/dmarc-dns.ts | 362 +++++++++++++++++++++++++- 5 files changed, 747 insertions(+), 84 deletions(-) diff --git a/__tests__/dmarc-dns.test.ts b/__tests__/dmarc-dns.test.ts index cc09700..7f1864b 100644 --- a/__tests__/dmarc-dns.test.ts +++ b/__tests__/dmarc-dns.test.ts @@ -1,6 +1,19 @@ // __tests__/dmarc-dns.test.ts import { describe, it, expect } from "vitest"; -import { classifyDnsError, isDkimKeyRecord } from "@/lib/dmarc-dns"; +import { + classifyDnsError, + isDkimKeyRecord, + parseTagRecord, + findDmarcRecords, + analyzeDmarc, + analyzeMtaSts, + analyzeTlsRpt, + analyzeBimi, + suggestedRecords, +} from "@/lib/dmarc-dns"; + +/** TXT lookups come back as an array of records, each split into chunks. */ +const txt = (...records: string[]): string[][] => records.map((r) => [r]); describe("classifyDnsError", () => { it("treats NXDOMAIN / no-data as a genuinely missing record", () => { @@ -11,7 +24,6 @@ describe("classifyDnsError", () => { it("treats resolver failures as transient, never a verdict", () => { expect(classifyDnsError({ code: "ESERVFAIL" })).toBe("transient"); expect(classifyDnsError({ code: "ETIMEOUT" })).toBe("transient"); - expect(classifyDnsError({ code: "ECONNREFUSED" })).toBe("transient"); expect(classifyDnsError(new Error("boom"))).toBe("transient"); expect(classifyDnsError(null)).toBe("transient"); }); @@ -20,14 +32,181 @@ describe("classifyDnsError", () => { describe("isDkimKeyRecord", () => { it("accepts real DKIM key records", () => { expect(isDkimKeyRecord("v=DKIM1; k=rsa; p=MIGfMA0GCSq")).toBe(true); - expect(isDkimKeyRecord("k=rsa; p=MIGfMA0GCSq")).toBe(true); // v= tag omitted - expect(isDkimKeyRecord("v=DKIM1; p=")).toBe(true); // revoked, still a DKIM record + expect(isDkimKeyRecord("k=rsa; p=MIGfMA0GCSq")).toBe(true); + expect(isDkimKeyRecord("v=DKIM1; p=")).toBe(true); }); it("rejects unrelated TXT records at the probed name", () => { expect(isDkimKeyRecord("v=spf1 include:_spf.google.com -all")).toBe(false); expect(isDkimKeyRecord("google-site-verification=abc123")).toBe(false); - expect(isDkimKeyRecord("MS=ms12345678")).toBe(false); expect(isDkimKeyRecord("")).toBe(false); }); }); + +describe("parseTagRecord", () => { + it("parses tag=value pairs case-insensitively on the key", () => { + const t = parseTagRecord("v=DMARC1; P=reject; pct=50"); + expect(t.v).toBe("DMARC1"); + expect(t.p).toBe("reject"); + expect(t.pct).toBe("50"); + }); + + it("keeps '=' inside values and tolerates junk segments", () => { + const t = parseTagRecord("v=BIMI1; l=https://x.test/l.svg?a=b; ; nonsense"); + expect(t.l).toBe("https://x.test/l.svg?a=b"); + }); + + it("is not prototype-polluted by a hostile TXT record", () => { + const t = parseTagRecord("__proto__=polluted; p=reject"); + expect(t.p).toBe("reject"); + expect(({} as Record).polluted).toBeUndefined(); + expect(Object.getPrototypeOf(t)).toBeNull(); + }); +}); + +describe("analyzeDmarc", () => { + it("reports missing when nothing is published", () => { + expect(analyzeDmarc([]).status).toBe("missing"); + expect(analyzeDmarc([]).enforcing).toBe(false); + }); + + it("fails when more than one DMARC record exists (receivers ignore all)", () => { + const a = analyzeDmarc(["v=DMARC1; p=reject", "v=DMARC1; p=none"]); + expect(a.status).toBe("fail"); + expect(a.enforcing).toBe(false); + expect(a.issues[0]).toMatch(/ignore all/i); + }); + + it("accepts a fully enforcing policy", () => { + const a = analyzeDmarc(["v=DMARC1; p=reject; pct=100; rua=mailto:d@x.test"]); + expect(a.status).toBe("ok"); + expect(a.enforcing).toBe(true); + expect(a.pct).toBe(100); + }); + + it("defaults pct to 100 when the tag is absent", () => { + expect(analyzeDmarc(["v=DMARC1; p=reject; rua=mailto:d@x.test"]).pct).toBe(100); + }); + + it("warns on p=none (monitoring only)", () => { + const a = analyzeDmarc(["v=DMARC1; p=none; rua=mailto:d@x.test"]); + expect(a.status).toBe("warn"); + expect(a.enforcing).toBe(false); + }); + + // The bug this whole change exists to kill: looks strong, enforces nothing. + it("does not call p=reject with pct=0 enforcing", () => { + const a = analyzeDmarc(["v=DMARC1; p=reject; pct=0; rua=mailto:d@x.test"]); + expect(a.enforcing).toBe(false); + expect(a.status).toBe("warn"); + expect(a.issues.join(" ")).toMatch(/pct=0/); + }); + + it("flags a strong policy undermined by sp=none", () => { + const a = analyzeDmarc(["v=DMARC1; p=reject; sp=none; rua=mailto:d@x.test"]); + expect(a.enforcing).toBe(false); + expect(a.status).toBe("warn"); + expect(a.issues.join(" ")).toMatch(/subdomain/i); + }); + + it("warns when there is no reporting address", () => { + const a = analyzeDmarc(["v=DMARC1; p=reject"]); + expect(a.hasReporting).toBe(false); + expect(a.status).toBe("warn"); + }); + + it("fails a record with no policy tag", () => { + expect(analyzeDmarc(["v=DMARC1; rua=mailto:d@x.test"]).status).toBe("fail"); + }); +}); + +describe("findDmarcRecords", () => { + it("picks out only v=DMARC1 records and joins chunked TXT", () => { + const lookup = [["v=DMARC1; ", "p=reject"], ["some-other-verification=1"]]; + expect(findDmarcRecords(lookup)).toEqual(["v=DMARC1; p=reject"]); + }); + + it("returns nothing for a null lookup", () => { + expect(findDmarcRecords(null)).toEqual([]); + }); +}); + +describe("MTA-STS / TLS-RPT / BIMI", () => { + it("reports MTA-STS present vs missing, and flags a missing id", () => { + expect(analyzeMtaSts(null).status).toBe("missing"); + expect(analyzeMtaSts(txt("v=STSv1; id=20260619T120000")).status).toBe("ok"); + expect(analyzeMtaSts(txt("v=STSv1;")).status).toBe("warn"); + }); + + it("reports TLS-RPT, flagging a record with no destination", () => { + expect(analyzeTlsRpt(null).status).toBe("missing"); + expect(analyzeTlsRpt(txt("v=TLSRPTv1; rua=mailto:t@x.test")).status).toBe("ok"); + expect(analyzeTlsRpt(txt("v=TLSRPTv1;")).status).toBe("warn"); + }); + + it("reports BIMI, flagging a record with no logo", () => { + expect(analyzeBimi(null).status).toBe("missing"); + expect(analyzeBimi(txt("v=BIMI1; l=https://x.test/l.svg")).status).toBe("ok"); + expect(analyzeBimi(txt("v=BIMI1;")).status).toBe("warn"); + }); +}); + +describe("suggestedRecords", () => { + const base = { + domain: "x.test", + provider: null as string | null, + spfRecord: null as string | null, + spfStatus: "ok" as const, + dmarc: analyzeDmarc(["v=DMARC1; p=reject; rua=mailto:d@x.test"]), + mtaSts: analyzeMtaSts(txt("v=STSv1; id=1")), + tlsRpt: analyzeTlsRpt(txt("v=TLSRPTv1; rua=mailto:t@x.test")), + }; + + it("tailors a missing SPF record to the detected provider", () => { + const r = suggestedRecords({ ...base, spfStatus: "missing", provider: "Google Workspace" }); + expect(r[0]!.name).toBe("x.test"); + expect(r[0]!.value).toBe("v=spf1 include:_spf.google.com ~all"); + }); + + it("falls back to a placeholder include when the provider is unknown", () => { + const r = suggestedRecords({ ...base, spfStatus: "missing" }); + expect(r[0]!.value).toMatch(/YOUR_MAIL_PROVIDER/); + }); + + it("rewrites an unsafe SPF qualifier in place", () => { + const r = suggestedRecords({ + ...base, + spfRecord: "v=spf1 include:_spf.google.com ?all", + spfStatus: "warn", + }); + expect(r[0]!.value).toBe("v=spf1 include:_spf.google.com ~all"); + }); + + it("suggests a monitoring DMARC record when none exists", () => { + const r = suggestedRecords({ ...base, dmarc: analyzeDmarc([]) }); + const rec = r.find((x) => x.name === "_dmarc.x.test"); + expect(rec?.value).toBe("v=DMARC1; p=none; rua=mailto:dmarc@x.test; fo=1"); + }); + + it("suggests the enforcement step when the policy is p=none", () => { + const r = suggestedRecords({ ...base, dmarc: analyzeDmarc(["v=DMARC1; p=none; rua=mailto:d@x.test"]) }); + expect(r.find((x) => x.name === "_dmarc.x.test")?.value).toMatch(/p=quarantine/); + }); + + it("raises pct to 100 on a sampled enforcing policy", () => { + const r = suggestedRecords({ + ...base, + dmarc: analyzeDmarc(["v=DMARC1; p=reject; pct=10; rua=mailto:d@x.test"]), + }); + expect(r.find((x) => x.name === "_dmarc.x.test")?.value).toMatch(/pct=100/); + }); + + it("offers TLS-RPT and MTA-STS records only when they're absent", () => { + const none = suggestedRecords(base); + expect(none.some((x) => x.name.startsWith("_smtp._tls"))).toBe(false); + + const r = suggestedRecords({ ...base, tlsRpt: analyzeTlsRpt(null), mtaSts: analyzeMtaSts(null) }); + expect(r.find((x) => x.name === "_smtp._tls.x.test")?.value).toBe("v=TLSRPTv1; rua=mailto:tls@x.test"); + expect(r.find((x) => x.name === "_mta-sts.x.test")?.why).toMatch(/policy file/i); + }); +}); diff --git a/app/api/tools/dmarc-check/route.ts b/app/api/tools/dmarc-check/route.ts index e506135..b3f1d0d 100644 --- a/app/api/tools/dmarc-check/route.ts +++ b/app/api/tools/dmarc-check/route.ts @@ -6,7 +6,19 @@ import { NextRequest, NextResponse } from "next/server"; import { promises as dns } from "node:dns"; import { rateLimit } from "@/server/rate-limit"; -import { classifyDnsError, isDkimKeyRecord } from "@/lib/dmarc-dns"; +import { + classifyDnsError, + isDkimKeyRecord, + findDmarcRecords, + analyzeDmarc, + analyzeMtaSts, + analyzeTlsRpt, + analyzeBimi, + suggestedRecords, + type SimpleRecordCheck, + type SuggestedRecord, + type Status, +} from "@/lib/dmarc-dns"; // Common DKIM selectors used by major mail providers. We probe these because // DKIM selector names aren't discoverable from the domain itself — DNS gives @@ -79,6 +91,9 @@ const DKIM_SELECTORS = [ // the raw MX values. const MX_PROVIDERS: { match: RegExp; name: string }[] = [ { match: /google\.com$|googlemail\.com$/i, name: "Google Workspace" }, + // Inbound relay fronting several shared hosts (DreamHost among them), so it + // identifies the MX but not the sending platform — no SPF include is implied. + { match: /mailchannels\.net$/i, name: "MailChannels" }, { match: /outlook\.com$|protection\.outlook\.com$/i, name: "Microsoft 365" }, { match: /mail\.protonmail\.ch$/i, name: "Proton Mail" }, { match: /icloud\.com$|mail\.me\.com$/i, name: "iCloud Mail" }, @@ -106,21 +121,30 @@ interface CheckResult { found: boolean; record: string | null; issues: string[]; - status: "ok" | "warn" | "fail" | "missing"; + status: Status; }; dkim: { selectorsChecked: number; found: DkimResult[]; - status: "ok" | "warn" | "missing"; + status: "ok" | "missing"; }; dmarc: { found: boolean; record: string | null; policy: string | null; + subdomainPolicy: string | null; + pct: number; + enforcing: boolean; hasReporting: boolean; issues: string[]; - status: "ok" | "warn" | "fail" | "missing"; + status: Status; }; + // Optional hardening — reported for visibility, deliberately not scored: a + // domain without BIMI isn't insecure, it's just not decorated. + mtaSts: SimpleRecordCheck; + tlsRpt: SimpleRecordCheck; + bimi: SimpleRecordCheck; + fixes: SuggestedRecord[]; summary: { score: number; verdict: string }; } @@ -212,54 +236,6 @@ function analyzeSpf(record: string | null): { issues: string[]; status: "ok" | " return { issues, status }; } -function findDmarc(txtRecords: string[][] | null): string | null { - if (!txtRecords) return null; - for (const parts of txtRecords) { - const joined = parts.join(""); - if (/^v=DMARC1\b/i.test(joined)) return joined; - } - return null; -} - -function analyzeDmarc(record: string | null): { - policy: string | null; - hasReporting: boolean; - issues: string[]; - status: "ok" | "warn" | "fail" | "missing"; -} { - if (!record) { - return { - policy: null, - hasReporting: false, - issues: ["No DMARC record found. Even with SPF and DKIM in place, receivers don't know what to do when a message fails. Spoofing protection is incomplete without DMARC."], - status: "missing", - }; - } - const issues: string[] = []; - let status: "ok" | "warn" | "fail" = "ok"; - - const policyMatch = record.match(/\bp\s*=\s*(none|quarantine|reject)/i); - const policy = policyMatch?.[1]?.toLowerCase() ?? null; - - if (policy === "none") { - issues.push("Policy is 'p=none' — reports only, no enforcement. Receivers will not block spoofed mail. Good for initial monitoring; move to 'quarantine' or 'reject' once SPF/DKIM align cleanly."); - if (status === "ok") status = "warn"; - } else if (!policy) { - issues.push("No policy ('p=') tag found. DMARC requires a policy."); - status = "fail"; - } - - const hasRua = /\brua\s*=\s*mailto:/i.test(record); - const hasRuf = /\bruf\s*=\s*mailto:/i.test(record); - - if (!hasRua) { - issues.push("No 'rua' reporting address. You're flying blind on which senders are using your domain. Add 'rua=mailto:dmarc-reports@yourdomain.com' to receive aggregate reports."); - if (status === "ok") status = "warn"; - } - - return { policy, hasReporting: hasRua || hasRuf, issues, status }; -} - function identifyProvider(mxRecords: { exchange: string }[]): string | null { for (const mx of mxRecords) { for (const p of MX_PROVIDERS) { @@ -279,7 +255,6 @@ function computeSummary(result: Omit): { score: number; else if (result.spf.status === "fail") score += 5; if (result.dkim.status === "ok") score += 20; - else if (result.dkim.status === "warn") score += 10; if (result.dmarc.status === "ok") score += 50; else if (result.dmarc.status === "warn") score += 25; @@ -343,11 +318,17 @@ export async function POST(request: NextRequest) { let rootTxt: string[][] | null; let dmarcTxt: string[][] | null; let mxRecords: { exchange: string; priority: number }[] | null; + let mtaStsTxt: string[][] | null; + let tlsRptTxt: string[][] | null; + let bimiTxt: string[][] | null; try { - [rootTxt, dmarcTxt, mxRecords] = await Promise.all([ + [rootTxt, dmarcTxt, mxRecords, mtaStsTxt, tlsRptTxt, bimiTxt] = await Promise.all([ lookupTxt(domain), lookupTxt(`_dmarc.${domain}`), lookupMx(domain), + lookupTxt(`_mta-sts.${domain}`), + lookupTxt(`_smtp._tls.${domain}`), + lookupTxt(`default._bimi.${domain}`), ]); } catch { return NextResponse.json( @@ -364,19 +345,20 @@ export async function POST(request: NextRequest) { const spfRecord = findSpf(rootTxt); const spf = { record: spfRecord, ...analyzeSpf(spfRecord) }; - const dmarcRecord = findDmarc(dmarcTxt); - const dmarcAnalysis = analyzeDmarc(dmarcRecord); + const dmarc = analyzeDmarc(findDmarcRecords(dmarcTxt)); + const mtaSts = analyzeMtaSts(mtaStsTxt); + const tlsRpt = analyzeTlsRpt(tlsRptTxt); + const bimi = analyzeBimi(bimiTxt); const dkimFound = dkimResults.filter((r) => r.found); - const dkimStatus: "ok" | "warn" | "missing" = - dkimFound.length >= 1 ? "ok" : "missing"; + const provider = mxRecords ? identifyProvider(mxRecords) : null; const result: Omit = { domain, mx: { found: !!mxRecords && mxRecords.length > 0, records: mxRecords ?? [], - provider: mxRecords ? identifyProvider(mxRecords) : null, + provider, }, spf: { found: !!spfRecord, @@ -387,16 +369,31 @@ export async function POST(request: NextRequest) { dkim: { selectorsChecked: DKIM_SELECTORS.length, found: dkimFound, - status: dkimStatus, + status: dkimFound.length >= 1 ? "ok" : "missing", }, dmarc: { - found: !!dmarcRecord, - record: dmarcRecord, - policy: dmarcAnalysis.policy, - hasReporting: dmarcAnalysis.hasReporting, - issues: dmarcAnalysis.issues, - status: dmarcAnalysis.status, + found: !!dmarc.record, + record: dmarc.record, + policy: dmarc.policy, + subdomainPolicy: dmarc.subdomainPolicy, + pct: dmarc.pct, + enforcing: dmarc.enforcing, + hasReporting: dmarc.hasReporting, + issues: dmarc.issues, + status: dmarc.status, }, + mtaSts, + tlsRpt, + bimi, + fixes: suggestedRecords({ + domain, + provider, + spfRecord, + spfStatus: spf.status, + dmarc, + mtaSts, + tlsRpt, + }), }; const summary = computeSummary(result); diff --git a/app/tools/dmarc-check/page.tsx b/app/tools/dmarc-check/page.tsx index 5a698ec..a50a9e3 100644 --- a/app/tools/dmarc-check/page.tsx +++ b/app/tools/dmarc-check/page.tsx @@ -8,14 +8,14 @@ import { SiteFooter } from "@/components/site/site-footer"; import { DmarcCheckForm } from "@/components/tools/dmarc-check-form"; import { JsonLd, softwareApplicationSchema, breadcrumbSchema } from "@/components/site/json-ld"; import { db } from "@/server/db"; -import { ArrowRight, Mail, ShieldCheck, MailWarning } from "lucide-react"; +import { ArrowRight, Mail, ShieldCheck, ClipboardCheck } from "lucide-react"; const SITE_URL = "https://akritos.com"; const URL = `${SITE_URL}/tools/dmarc-check`; // No brand suffix here — the root layout's title template appends " | Akritos". -const TITLE = "Free DMARC, SPF & DKIM Checker"; +const TITLE = "Free DMARC, SPF, DKIM & MTA-STS Checker"; const DESCRIPTION = - "Free tool: enter your domain to instantly check SPF, DKIM, DMARC, and MX records. Plain-English explanations of what's wrong and how to fix it. No email required to use."; + "Free tool: enter your domain to check SPF, DKIM, DMARC, MX, MTA-STS, TLS-RPT and BIMI. Catches the DMARC records that say 'reject' but enforce nothing, and hands you the exact DNS record to paste for every gap. No email required."; export const metadata: Metadata = { title: TITLE, @@ -49,13 +49,13 @@ const whatItDoes = [ }, { icon: ShieldCheck, - title: "SPF / DKIM / DMARC Check", - body: "Pulls each record and parses it. SPF gets analyzed for the most common misconfigurations. DKIM probes the static selectors major providers use — Google, Microsoft 365, DreamHost, Mailchimp, SendGrid, Proton, and more. DMARC parses your policy and checks for a reporting address.", + title: "Every record that matters", + body: "SPF checked for the common misconfigurations and the 10-lookup limit. DKIM probes the static selectors the major providers use. DMARC parsed in full — policy, pct, subdomain policy — so a record that claims 'reject' while enforcing nothing gets called out. Then MTA-STS, TLS-RPT and BIMI.", }, { - icon: MailWarning, - title: "Plain-English Issues", - body: "Each problem we find gets an explanation in language a non-IT person can act on. No raw DNS jargon, no 'consult your administrator.'", + icon: ClipboardCheck, + title: "Fixes you can paste", + body: "Every gap comes with the DNS record that closes it — built for your domain and the provider we detected, explained in language a non-IT person can act on. Copy, paste, done. No translating advice into syntax.", }, ]; diff --git a/components/tools/dmarc-check-form.tsx b/components/tools/dmarc-check-form.tsx index e89513c..a6f6493 100644 --- a/components/tools/dmarc-check-form.tsx +++ b/components/tools/dmarc-check-form.tsx @@ -3,7 +3,7 @@ import { useState } from "react"; import Link from "next/link"; -import { ArrowRight, CheckCircle2, AlertTriangle, XCircle, HelpCircle, Loader2 } from "lucide-react"; +import { ArrowRight, CheckCircle2, AlertTriangle, XCircle, HelpCircle, Loader2, Copy, Check } from "lucide-react"; interface DkimResult { selector: string; @@ -11,6 +11,20 @@ interface DkimResult { record?: string; } +interface SimpleRecordCheck { + found: boolean; + record: string | null; + detail: string; + status: "ok" | "warn" | "fail" | "missing"; +} + +interface SuggestedRecord { + name: string; + type: "TXT"; + value: string; + why: string; +} + interface CheckResult { domain: string; mx: { @@ -27,16 +41,23 @@ interface CheckResult { dkim: { selectorsChecked: number; found: DkimResult[]; - status: "ok" | "warn" | "missing"; + status: "ok" | "missing"; }; dmarc: { found: boolean; record: string | null; policy: string | null; + subdomainPolicy: string | null; + pct: number; + enforcing: boolean; hasReporting: boolean; issues: string[]; status: "ok" | "warn" | "fail" | "missing"; }; + mtaSts: SimpleRecordCheck; + tlsRpt: SimpleRecordCheck; + bimi: SimpleRecordCheck; + fixes: SuggestedRecord[]; summary: { score: number; verdict: string }; } @@ -248,7 +269,14 @@ export function DmarcCheckForm() { status={result.dmarc.status} statusLabelOverride={ result.dmarc.found - ? `Policy: ${result.dmarc.policy ?? "unset"}` + ? [ + `Policy: ${result.dmarc.policy ?? "unset"}`, + result.dmarc.pct < 100 ? `pct=${result.dmarc.pct}` : null, + result.dmarc.subdomainPolicy ? `sp=${result.dmarc.subdomainPolicy}` : null, + result.dmarc.enforcing ? "enforcing" : "not enforcing", + ] + .filter(Boolean) + .join(" · ") : "Not found" } description={ @@ -270,6 +298,60 @@ export function DmarcCheckForm() { )} + {/* Optional hardening — shown for visibility, deliberately not scored: + a domain without BIMI isn't insecure, it's just not decorated. */} +
+

Additional hardening

+

+ Optional, and not part of the score — missing these doesn't make you + spoofable. They're the next things worth doing once SPF, DKIM and DMARC + are clean. +

+
+ + + +
+
+ + {/* Copy-paste fixes */} + {result.fixes.length > 0 && ( +
+

Records to add

+

+ Built for {result.domain} from + the findings above. Read each one before you publish it — especially SPF, + which has to list every service that sends mail as you. +

+
+ {result.fixes.map((fix) => ( +
+
+ + {fix.type} {fix.name} + + +
+
+ {fix.value} +
+

{fix.why}

+
+ ))} +
+
+ )} + {/* CTA */}
); } + +function HardeningRow({ label, check }: { label: string; check: SimpleRecordCheck }) { + return ( +
+
+ +
+
+

+ {label}{" "} + + · {check.found ? statusLabel(check.status) : "Not configured"} + +

+

{check.detail}

+
+
+ ); +} + +function CopyButton({ value }: { value: string }) { + const [copied, setCopied] = useState(false); + + async function copy() { + try { + await navigator.clipboard.writeText(value); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + // Clipboard access can be denied (permissions, insecure context). The + // record is on screen and selectable either way, so fail quietly. + } + } + + return ( + + ); +} diff --git a/lib/dmarc-dns.ts b/lib/dmarc-dns.ts index ee0f4dd..d201508 100644 --- a/lib/dmarc-dns.ts +++ b/lib/dmarc-dns.ts @@ -1,6 +1,8 @@ // lib/dmarc-dns.ts -// Pure DNS-record classification helpers for the email-auth checker. Kept out of -// the route handler so they can be unit-tested without performing live DNS. +// Pure DNS-record classification/analysis for the email-auth checker. Kept out +// of the route handler so it can be unit-tested without performing live DNS. + +export type Status = "ok" | "warn" | "fail" | "missing"; /** * Classify a node:dns rejection. A record that genuinely doesn't exist must not @@ -26,3 +28,359 @@ export function classifyDnsError(err: unknown): "missing" | "transient" { export function isDkimKeyRecord(record: string): boolean { return /(^|;|\s)v=DKIM1\b/i.test(record) || /(^|;|\s)p=/i.test(record); } + +/** + * Parse a `k=v; k=v` tag record (the shape DMARC, MTA-STS, TLS-RPT and BIMI all + * share). Keys come from a TXT record any domain can publish, so the result is a + * null-prototype object — a plain literal would be polluted by a `__proto__=` tag. + */ +export function parseTagRecord(record: string): Record { + const tags: Record = Object.create(null); + for (const part of record.split(";")) { + const eq = part.indexOf("="); + if (eq < 0) continue; + const key = part.slice(0, eq).trim().toLowerCase(); + if (key) tags[key] = part.slice(eq + 1).trim(); + } + return tags; +} + +/** Pull every record matching a version tag out of a TXT lookup result. */ +function recordsMatching(txt: string[][] | null, version: RegExp): string[] { + if (!txt) return []; + return txt.map((parts) => parts.join("").trim()).filter((r) => version.test(r)); +} + +// ── DMARC ─────────────────────────────────────────────────────────────────── + +export interface DmarcAnalysis { + record: string | null; + policy: string | null; + subdomainPolicy: string | null; + pct: number; + /** True only when the policy actually acts on mail: enforcing p, full pct, and no weaker sp. */ + enforcing: boolean; + hasReporting: boolean; + issues: string[]; + status: Status; +} + +export function findDmarcRecords(txt: string[][] | null): string[] { + return recordsMatching(txt, /^v=DMARC1\b/i); +} + +export function analyzeDmarc(records: string[]): DmarcAnalysis { + const empty = { + record: null, + policy: null, + subdomainPolicy: null, + pct: 100, + enforcing: false, + hasReporting: false, + }; + + if (records.length === 0) { + return { + ...empty, + issues: [ + "No DMARC record found. Even with SPF and DKIM in place, receivers don't know what to do when a message fails. Spoofing protection is incomplete without DMARC.", + ], + status: "missing", + }; + } + if (records.length > 1) { + // RFC 7489: more than one DMARC record means receivers must ignore all of + // them — so publishing two is worse than publishing one. + return { + ...empty, + issues: [ + `${records.length} DMARC records are published at _dmarc. The spec requires receivers to ignore all of them, so right now you effectively have no DMARC. Delete all but one.`, + ], + status: "fail", + }; + } + + const record = records[0]!; + const tags = parseTagRecord(record); + const policy = normalizePolicy(tags.p); + const subdomainPolicy = normalizePolicy(tags.sp); + const pct = parsePct(tags.pct); + const hasReporting = /\bmailto:/i.test(tags.rua ?? "") || /\bmailto:/i.test(tags.ruf ?? ""); + + const issues: string[] = []; + let status: Exclude = "ok"; + const warn = () => { + if (status === "ok") status = "warn"; + }; + + if (!policy) { + issues.push("No policy ('p=') tag found. DMARC requires a policy, so this record does nothing."); + status = "fail"; + } else if (policy === "none") { + issues.push( + "Policy is 'p=none' — reports only, no enforcement. Receivers will not block spoofed mail. Good for initial monitoring; move to 'quarantine' or 'reject' once SPF/DKIM align cleanly." + ); + warn(); + } + + // pct only means anything on an enforcing policy, but a low pct there is the + // classic "looks protected, isn't" configuration. + if (pct < 100) { + if (policy === "quarantine" || policy === "reject") { + issues.push( + `Policy says '${policy}' but 'pct=${pct}' — it is only applied to ${pct}% of failing mail. The other ${100 - pct}% is delivered as if you had p=none. Raise to pct=100 once you're confident.` + ); + warn(); + } else { + issues.push(`'pct=${pct}' has no effect while the policy is 'p=none'.`); + } + } + + // A strong p with a weak sp leaves every subdomain spoofable — a common gap. + if (subdomainPolicy === "none" && (policy === "quarantine" || policy === "reject")) { + issues.push( + `Policy is '${policy}' but 'sp=none' — subdomains are exempt, so anyone can still spoof mail from a subdomain of your domain. Remove 'sp' (subdomains then inherit p) or set it to '${policy}'.` + ); + warn(); + } + + if (!hasReporting) { + issues.push( + "No 'rua' reporting address. You're flying blind on which senders are using your domain, and the reports are how you safely reach enforcement. Add 'rua=mailto:dmarc@yourdomain.com'." + ); + warn(); + } + + const enforcing = + (policy === "quarantine" || policy === "reject") && + pct === 100 && + subdomainPolicy !== "none"; + + return { record, policy, subdomainPolicy, pct, enforcing, hasReporting, issues, status }; +} + +function normalizePolicy(raw: string | undefined): string | null { + const v = (raw ?? "").trim().toLowerCase(); + return v === "none" || v === "quarantine" || v === "reject" ? v : null; +} + +function parsePct(raw: string | undefined): number { + const n = Number.parseInt((raw ?? "").trim(), 10); + // Absent or unparseable pct means 100 per the spec; clamp anything silly. + if (!Number.isFinite(n)) return 100; + return Math.min(100, Math.max(0, n)); +} + +// ── MTA-STS / TLS-RPT / BIMI (optional hardening, reported but not scored) ─── + +export interface SimpleRecordCheck { + found: boolean; + record: string | null; + detail: string; + status: Status; +} + +export function analyzeMtaSts(txt: string[][] | null): SimpleRecordCheck { + const records = recordsMatching(txt, /^v=STSv1\b/i); + if (records.length === 0) { + return { + found: false, + record: null, + detail: + "No MTA-STS record. Mail servers will deliver to you over plaintext if TLS negotiation is stripped, which makes a downgrade attack possible.", + status: "missing", + }; + } + const tags = parseTagRecord(records[0]!); + if (!tags.id) { + return { + found: true, + record: records[0]!, + detail: "MTA-STS record is published but has no 'id' tag, so receivers can't tell when the policy changes.", + status: "warn", + }; + } + return { + found: true, + record: records[0]!, + detail: + "MTA-STS record published. Note: we check the DNS record only — the policy file at https://mta-sts./.well-known/mta-sts.txt must also be served for it to take effect.", + status: "ok", + }; +} + +export function analyzeTlsRpt(txt: string[][] | null): SimpleRecordCheck { + const records = recordsMatching(txt, /^v=TLSRPTv1\b/i); + if (records.length === 0) { + return { + found: false, + record: null, + detail: + "No TLS-RPT record. You won't be told when someone fails to deliver mail to you over TLS — useful early warning that pairs with MTA-STS.", + status: "missing", + }; + } + const tags = parseTagRecord(records[0]!); + if (!/\b(mailto:|https:)/i.test(tags.rua ?? "")) { + return { + found: true, + record: records[0]!, + detail: "TLS-RPT record is published but has no valid 'rua' destination, so no reports will reach you.", + status: "warn", + }; + } + return { found: true, record: records[0]!, detail: "TLS-RPT reporting is configured.", status: "ok" }; +} + +export function analyzeBimi(txt: string[][] | null): SimpleRecordCheck { + const records = recordsMatching(txt, /^v=BIMI1\b/i); + if (records.length === 0) { + return { + found: false, + record: null, + detail: + "No BIMI record. Optional — it shows your logo next to your mail in supporting clients, but it requires DMARC at enforcement first (and usually a paid VMC).", + status: "missing", + }; + } + const tags = parseTagRecord(records[0]!); + if (!tags.l) { + return { + found: true, + record: records[0]!, + detail: "BIMI record is published but has no 'l=' logo URL, so nothing will display.", + status: "warn", + }; + } + return { + found: true, + record: records[0]!, + detail: tags.a + ? "BIMI is configured with a logo and a VMC." + : "BIMI logo is configured. Most large mailbox providers also require a VMC ('a=') before they'll display it.", + status: "ok", + }; +} + +// ── Copy-paste fixes ──────────────────────────────────────────────────────── + +export interface SuggestedRecord { + /** DNS name to create, already qualified for the checked domain. */ + name: string; + type: "TXT"; + value: string; + why: string; +} + +/** SPF include for the mail provider we detected from MX, when we know it. */ +function spfIncludeFor(provider: string | null): string | null { + switch (provider) { + case "Google Workspace": + return "include:_spf.google.com"; + case "Microsoft 365": + return "include:spf.protection.outlook.com"; + case "Proton Mail": + return "include:_spf.protonmail.ch"; + case "Zoho Mail": + return "include:zoho.com"; + case "Fastmail": + return "include:spf.messagingengine.com"; + case "iCloud Mail": + return "include:icloud.com"; + default: + return null; + } +} + +/** + * Turn findings into records the operator can paste straight into DNS. This is + * the whole point of the tool for a non-expert: "what exactly do I add?" + */ +export function suggestedRecords(input: { + domain: string; + provider: string | null; + spfRecord: string | null; + spfStatus: Status; + dmarc: DmarcAnalysis; + mtaSts: SimpleRecordCheck; + tlsRpt: SimpleRecordCheck; +}): SuggestedRecord[] { + const { domain, provider, spfRecord, spfStatus, dmarc, mtaSts, tlsRpt } = input; + const out: SuggestedRecord[] = []; + + if (spfStatus === "missing") { + const include = spfIncludeFor(provider); + out.push({ + name: domain, + type: "TXT", + value: `v=spf1 ${include ?? "include:YOUR_MAIL_PROVIDER"} ~all`, + why: include + ? `Authorizes ${provider} to send mail as ${domain}. Add an include: for every other service that sends on your behalf.` + : "Replace YOUR_MAIL_PROVIDER with your provider's include (and add one per sending service) before publishing.", + }); + } else if (spfRecord && /(\+all|\?all)\b/i.test(spfRecord)) { + out.push({ + name: domain, + type: "TXT", + value: spfRecord.replace(/(\+all|\?all)\b/i, "~all"), + why: "Your current record with the unsafe 'all' qualifier replaced by '~all' (softfail).", + }); + } else if (spfRecord && !/(~all|-all)\b/i.test(spfRecord)) { + out.push({ + name: domain, + type: "TXT", + value: `${spfRecord.trim()} ~all`, + why: "Your current record with a closing '~all' so unlisted senders are marked softfail.", + }); + } + + if (dmarc.status === "missing") { + out.push({ + name: `_dmarc.${domain}`, + type: "TXT", + value: `v=DMARC1; p=none; rua=mailto:dmarc@${domain}; fo=1`, + why: "Start in monitoring mode. Watch the reports for ~30 days, confirm every legitimate sender aligns, then tighten to quarantine.", + }); + } else if (dmarc.policy === "none") { + out.push({ + name: `_dmarc.${domain}`, + type: "TXT", + value: `v=DMARC1; p=quarantine; rua=mailto:dmarc@${domain}; fo=1`, + why: "The enforcement step — only publish this once your reports show every legitimate sender passing.", + }); + } else if (!dmarc.hasReporting && dmarc.record) { + out.push({ + name: `_dmarc.${domain}`, + type: "TXT", + value: `${dmarc.record.replace(/;\s*$/, "")}; rua=mailto:dmarc@${domain}`, + why: "Your current record with a reporting address added, so you can see who's sending as you.", + }); + } else if (dmarc.pct < 100 && dmarc.record) { + out.push({ + name: `_dmarc.${domain}`, + type: "TXT", + value: dmarc.record.replace(/\bpct\s*=\s*\d+/i, "pct=100"), + why: "Your current record at full enforcement, so the policy applies to all failing mail rather than a sample.", + }); + } + + if (tlsRpt.status === "missing") { + out.push({ + name: `_smtp._tls.${domain}`, + type: "TXT", + value: `v=TLSRPTv1; rua=mailto:tls@${domain}`, + why: "Get notified when someone can't deliver mail to you over TLS. Zero risk to add.", + }); + } + + if (mtaSts.status === "missing") { + out.push({ + name: `_mta-sts.${domain}`, + type: "TXT", + value: "v=STSv1; id=CHANGEME", + why: "MTA-STS also needs a policy file served at https://mta-sts." + domain + "/.well-known/mta-sts.txt — publish the record only once that file is live. Use a timestamp (e.g. 20260619T120000) as the id.", + }); + } + + return out; +}