diff --git a/apps/landing/src/pages/tools/dkim-checker.tsx b/apps/landing/src/pages/tools/dkim-checker.tsx
new file mode 100644
index 0000000..6efc14e
--- /dev/null
+++ b/apps/landing/src/pages/tools/dkim-checker.tsx
@@ -0,0 +1,621 @@
+import {FAQSection, Footer, Navbar, SectionHeader} from '../../components';
+import {motion} from 'framer-motion';
+import {DASHBOARD_URI} from '../../lib/constants';
+import React, {useState} from 'react';
+import {NextSeo} from 'next-seo';
+import {AlertTriangle, ArrowRight, CheckCircle, Key, XCircle} from 'lucide-react';
+import {Button, Input} from '@plunk/ui';
+import {Bricolage_Grotesque, Hanken_Grotesk, JetBrains_Mono} from 'next/font/google';
+import Link from 'next/link';
+import type {FAQ} from '../../components/FAQSection';
+
+const display = Bricolage_Grotesque({
+ subsets: ['latin'],
+ variable: '--font-display',
+ display: 'swap',
+ weight: ['400', '500', '600', '700', '800'],
+});
+
+const body = Hanken_Grotesk({
+ subsets: ['latin'],
+ variable: '--font-body',
+ display: 'swap',
+ weight: ['400', '500', '600', '700'],
+});
+
+const mono = JetBrains_Mono({
+ subsets: ['latin'],
+ variable: '--font-mono',
+ display: 'swap',
+ weight: ['400', '500'],
+});
+
+const COMMON_SELECTORS = [
+ {label: 'Google Workspace', value: 'google'},
+ {label: 'Microsoft 365', value: 'selector1'},
+ {label: 'Microsoft 365 (2)', value: 'selector2'},
+ {label: 'Mailchimp', value: 'k1'},
+ {label: 'Mailchimp (2)', value: 'k2'},
+ {label: 'Postmark', value: 'pm'},
+ {label: 'SendGrid', value: 'sendgrid'},
+ {label: 'Amazon SES', value: 'ses'},
+ {label: 'Mailjet', value: 'mailjet'},
+ {label: 'Zoho', value: 'zoho'},
+ {label: 'Generic', value: 'mail'},
+ {label: 'Generic (2)', value: 'default'},
+ {label: 'Generic (3)', value: 's1'},
+ {label: 'Generic (4)', value: 's2'},
+];
+
+interface DkimIssue {
+ type: 'error' | 'warning' | 'pass';
+ label: string;
+ detail: string;
+}
+
+interface DkimAnalysis {
+ keyType: string;
+ isRevoked: boolean;
+ isTesting: boolean;
+ publicKeySnippet: string;
+ issues: DkimIssue[];
+ grade: 'pass' | 'warning' | 'fail';
+}
+
+function analyzeDkim(tags: Record): DkimAnalysis {
+ const keyType = tags['k'] ?? 'rsa';
+ const publicKey = tags['p'] ?? '';
+ const flags = tags['t'] ?? '';
+ const isRevoked = publicKey === '';
+ const isTesting = flags.includes('y');
+ const issues: DkimIssue[] = [];
+
+ if (isRevoked) {
+ issues.push({type: 'error', label: 'DKIM key has been revoked', detail: 'p= is empty, which signals that this key has been intentionally revoked. Email signed with this selector will fail DKIM validation. Publish a new key.'});
+ } else {
+ issues.push({type: 'pass', label: 'Public key is present', detail: 'A valid public key is published for this selector.'});
+ }
+
+ if (isTesting) {
+ issues.push({type: 'warning', label: 'Key is in testing mode (t=y)', detail: 'Testing mode means receiving servers should not reject messages that fail DKIM, even if the signature is invalid. Remove t=y to enable full enforcement.'});
+ }
+
+ if (keyType === 'rsa') {
+ issues.push({type: 'pass', label: 'Key type: RSA', detail: 'RSA is the standard and widely-supported DKIM key type.'});
+ } else if (keyType === 'ed25519') {
+ issues.push({type: 'pass', label: 'Key type: Ed25519', detail: 'Ed25519 provides strong security with smaller key sizes. Ensure your sending infrastructure supports it, as some older servers may not.'});
+ }
+
+ const publicKeySnippet = publicKey.length > 32 ? `${publicKey.slice(0, 32)}…` : publicKey;
+
+ const grade = issues.some(i => i.type === 'error') ? 'fail' : issues.some(i => i.type === 'warning') ? 'warning' : 'pass';
+
+ return {keyType, isRevoked, isTesting, publicKeySnippet, issues, grade};
+}
+
+function GradeBadge({grade}: {grade: 'pass' | 'warning' | 'fail'}) {
+ const map = {
+ pass: {cls: 'bg-green-50 border-green-200 text-green-700', label: 'Valid', sub: 'DKIM key is active'},
+ warning: {cls: 'bg-amber-50 border-amber-200 text-amber-700', label: 'Needs attention', sub: 'DKIM has configuration issues'},
+ fail: {cls: 'bg-red-50 border-red-200 text-red-700', label: 'Invalid', sub: 'DKIM key is revoked or invalid'},
+ };
+ const {cls, label, sub} = map[grade];
+ return (
+
+ {label}
+ {sub}
+
+ );
+}
+
+const TAG_DESCRIPTIONS: Record = {
+ v: 'DKIM version',
+ k: 'Key type (rsa or ed25519)',
+ p: 'Base64-encoded public key',
+ t: 'Flags (y=testing, s=strict service)',
+ s: 'Service type restriction',
+ h: 'Acceptable hash algorithms',
+ n: 'Notes (human-readable)',
+};
+
+const faqs: FAQ[] = [
+ {
+ question: 'What is DKIM?',
+ answer: 'DKIM (DomainKeys Identified Mail) is an email authentication method that adds a digital signature to outgoing email. The signature is verified by receiving servers using a public key published in your DNS. If the signature matches, the email is confirmed to have originated from your domain and has not been tampered with in transit.',
+ },
+ {
+ question: 'What is a DKIM selector?',
+ answer: 'A DKIM selector is a label that identifies which DKIM key to use when there are multiple keys for a domain. Selectors are arbitrary strings chosen by the sending service (e.g., "google" for Google Workspace, "selector1" for Microsoft 365, "k1" for Mailchimp). The DKIM record is published at {selector}._domainkey.{domain}.',
+ },
+ {
+ question: 'Where do I find my DKIM selector?',
+ answer: 'Your DKIM selector is provided by your email sending service. In Google Workspace, it\'s typically "google". In Microsoft 365, it\'s "selector1" and "selector2". In Mailchimp, it\'s "k1". Check your email provider\'s DNS setup guide or look in the DKIM signature of a sent email (the "s=" tag in the DKIM-Signature header).',
+ },
+ {
+ question: 'Why is my DKIM key revoked?',
+ answer: 'A DKIM key is revoked by publishing a DKIM record with an empty p= value. This is intentional and signals that the key should no longer be used. Reasons include key rotation, key compromise, or switching email providers. If you didn\'t intentionally revoke the key, check your DNS records and publish a new DKIM key.',
+ },
+ {
+ question: 'Should I use RSA or Ed25519 for DKIM?',
+ answer: 'RSA (2048-bit) is the safest choice for maximum compatibility, as it is supported by all email providers. Ed25519 offers equivalent security with much smaller keys but is not supported by some older mail servers. A best practice is to publish both an RSA key and an Ed25519 key with different selectors, letting modern servers prefer Ed25519.',
+ },
+];
+
+interface DnsAnswer {
+ data: string;
+}
+
+interface DkimLookupResult {
+ domain: string;
+ selector: string;
+ found: boolean;
+ record: string | null;
+ tags: Record;
+ error?: string;
+}
+
+function cleanTxt(raw: string): string {
+ return raw.replace(/^"|"$/g, '').replace(/"\s*"/g, '');
+}
+
+async function lookupDkim(domain: string, selector: string): Promise {
+ const clean = domain.trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/.*$/, '').replace(/^www\./, '');
+ const sel = selector.trim().toLowerCase();
+ try {
+ const res = await fetch(`https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(`${sel}._domainkey.${clean}`)}&type=TXT`, {
+ headers: {Accept: 'application/dns-json'},
+ });
+ if (!res.ok) return {domain: clean, selector: sel, found: false, record: null, tags: {}, error: 'DNS lookup failed'};
+ const data = await res.json() as {Answer?: DnsAnswer[]};
+ const records = (data.Answer ?? [])
+ .map((a: DnsAnswer) => cleanTxt(a.data))
+ .filter(r => r.startsWith('v=DKIM1') || r.includes('k=') || r.includes('p='));
+ if (records.length === 0) return {domain: clean, selector: sel, found: false, record: null, tags: {}};
+ const record: string = records[0]!;
+ const tags: Record = {};
+ record.split(';').forEach(part => {
+ const eqIdx = part.indexOf('=');
+ if (eqIdx > -1) {
+ const key = part.slice(0, eqIdx).trim();
+ const value = part.slice(eqIdx + 1).trim();
+ if (key) tags[key] = value;
+ }
+ });
+ return {domain: clean, selector: sel, found: true, record, tags};
+ } catch {
+ return {domain: clean, selector: sel, found: false, record: null, tags: {}, error: 'DNS lookup failed'};
+ }
+}
+
+export default function DkimCheckerPage() {
+ const [domain, setDomain] = useState('');
+ const [selector, setSelector] = useState('');
+ const [loading, setLoading] = useState(false);
+ const [result, setResult] = useState(null);
+ const [analysis, setAnalysis] = useState(null);
+
+ const handleCheck = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setLoading(true);
+ setResult(null);
+ setAnalysis(null);
+
+ try {
+ const data = await lookupDkim(domain, selector);
+ setResult(data);
+ if (data.found && data.tags) {
+ setAnalysis(analyzeDkim(data.tags));
+ }
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+ <>
+
+
+
+
+
+
+ {/* ========== HERO ========== */}
+
+
+
+
+
+ § T-06 — Tool
+
+ ← All tools
+
+
+
+
+
+ DKIM record
+
+ checker
+
+
+ Look up your domain's DKIM public key by selector. Verify the key is active, understand the
+ configuration, and get advice if something looks wrong.
+
+
+
+
+
+ {/* ========== TOOL ========== */}
+
+
+
+
+
+
+
+ DKIM record lookup
+
+
+
+
+
+
+
+ {result && (
+
+ {!result.found ? (
+
+
+
+
+
No DKIM record found
+
+ {result.error
+ ? 'DNS lookup failed. Please check the domain and try again.'
+ : `No DKIM record was found at `}
+ {!result.error && (
+
+ {result.selector}._domainkey.{result.domain}
+
+ )}
+
+ {!result.error && (
+
+ Check that you are using the correct selector. If your email provider has given you a specific selector, use that. If the record still doesn't appear, DNS propagation may still be in progress.
+
+ {[
+ {
+ title: 'Cryptographic signature',
+ body: 'Your sending server signs each email using a private key. The corresponding public key is published in DNS. Receiving servers verify the signature to confirm authenticity.',
+ },
+ {
+ title: 'Selector system',
+ body: 'Each DKIM key is identified by a selector. You can have multiple selectors (and keys) per domain, allowing key rotation and multiple sending providers at the same time.',
+ },
+ {
+ title: 'Tamper detection',
+ body: 'The DKIM signature covers specific email headers and the body. If the email is modified in transit, the signature breaks and DKIM fails — protecting against content manipulation.',
+ },
+ {
+ title: 'Key rotation',
+ body: 'Best practice is to rotate DKIM keys annually. Publish the new key under a different selector, update your sending infrastructure, then revoke the old key by setting p= to empty.',
+ },
+ {
+ title: 'DKIM alone is not enough',
+ body: 'Like SPF, DKIM authentication alone doesn\'t protect the visible From header. You need DMARC to enforce authentication policies and protect against spoofing.',
+ },
+ {
+ title: '2048-bit RSA minimum',
+ body: '1024-bit RSA keys are considered insecure. Use at least 2048-bit RSA or switch to Ed25519, which provides equivalent security with much smaller keys.',
+ },
+ ].map((item, i) => (
+
+
+ {item.title}
+
+
{item.body}
+
+ ))}
+
+
+
+
+ {/* ========== CTA ========== */}
+
+
+
+
+ Sign every email. Reach the inbox.
+
+
+
+
+ Plunk configures DKIM signing automatically and guides you through setting up SPF and DMARC for your
+ domain.
+
+
+
+ Start with Plunk
+
+
+
+ What is DKIM?
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/apps/landing/src/pages/tools/dmarc-checker.tsx b/apps/landing/src/pages/tools/dmarc-checker.tsx
new file mode 100644
index 0000000..8fa56dd
--- /dev/null
+++ b/apps/landing/src/pages/tools/dmarc-checker.tsx
@@ -0,0 +1,584 @@
+import {FAQSection, Footer, Navbar, SectionHeader} from '../../components';
+import {motion} from 'framer-motion';
+import {DASHBOARD_URI} from '../../lib/constants';
+import React, {useState} from 'react';
+import {NextSeo} from 'next-seo';
+import {AlertTriangle, ArrowRight, CheckCircle, ShieldCheck, XCircle} from 'lucide-react';
+import {Button, Input} from '@plunk/ui';
+import {Bricolage_Grotesque, Hanken_Grotesk, JetBrains_Mono} from 'next/font/google';
+import Link from 'next/link';
+import type {FAQ} from '../../components/FAQSection';
+
+const display = Bricolage_Grotesque({
+ subsets: ['latin'],
+ variable: '--font-display',
+ display: 'swap',
+ weight: ['400', '500', '600', '700', '800'],
+});
+
+const body = Hanken_Grotesk({
+ subsets: ['latin'],
+ variable: '--font-body',
+ display: 'swap',
+ weight: ['400', '500', '600', '700'],
+});
+
+const mono = JetBrains_Mono({
+ subsets: ['latin'],
+ variable: '--font-mono',
+ display: 'swap',
+ weight: ['400', '500'],
+});
+
+interface DmarcIssue {
+ type: 'error' | 'warning' | 'pass';
+ label: string;
+ detail: string;
+}
+
+interface DmarcAnalysis {
+ policy: string;
+ subPolicy: string | null;
+ pct: number;
+ hasRua: boolean;
+ hasRuf: boolean;
+ adkim: string;
+ aspf: string;
+ issues: DmarcIssue[];
+ grade: 'pass' | 'warning' | 'fail';
+}
+
+function analyzeDmarc(tags: Record): DmarcAnalysis {
+ const policy = tags['p'] ?? '';
+ const subPolicy = tags['sp'] ?? null;
+ const pct = parseInt(tags['pct'] ?? '100', 10);
+ const hasRua = Boolean(tags['rua']);
+ const hasRuf = Boolean(tags['ruf']);
+ const adkim = tags['adkim'] ?? 'r';
+ const aspf = tags['aspf'] ?? 'r';
+ const issues: DmarcIssue[] = [];
+
+ // Policy
+ if (!policy) {
+ issues.push({type: 'error', label: 'Missing policy (p=)', detail: 'The p= tag is required. Set p=none to monitor, p=quarantine to send to spam, or p=reject to block.'});
+ } else if (policy === 'none') {
+ issues.push({type: 'warning', label: 'Policy is p=none (monitoring only)', detail: 'p=none means DMARC failures are reported but no action is taken. Upgrade to p=quarantine then p=reject once you confirm legitimate mail passes.'});
+ } else if (policy === 'quarantine') {
+ issues.push({type: 'warning', label: 'Policy is p=quarantine', detail: 'Failing messages are sent to spam/junk. Consider upgrading to p=reject for full protection.'});
+ } else if (policy === 'reject') {
+ issues.push({type: 'pass', label: 'Policy is p=reject — maximum protection', detail: 'Failing messages are rejected outright. This is the strongest DMARC policy.'});
+ }
+
+ // Percentage
+ if (pct < 100) {
+ issues.push({type: 'warning', label: `Policy applies to only ${pct}% of messages`, detail: `pct=${pct} means DMARC enforcement only applies to ${pct}% of failing mail. Set pct=100 for full enforcement.`});
+ } else if (policy && policy !== 'none') {
+ issues.push({type: 'pass', label: 'Policy applies to 100% of messages', detail: 'DMARC enforcement is fully deployed.'});
+ }
+
+ // Aggregate reports
+ if (!hasRua) {
+ issues.push({type: 'warning', label: 'No aggregate reporting (rua= missing)', detail: 'Without rua=, you receive no DMARC aggregate reports. Add rua=mailto:dmarc@yourdomain.com or use a DMARC reporting service to monitor your authentication results.'});
+ } else {
+ issues.push({type: 'pass', label: 'Aggregate reports configured (rua=)', detail: 'You will receive DMARC aggregate reports to monitor SPF and DKIM alignment.'});
+ }
+
+ // Alignment
+ if (adkim === 's') {
+ issues.push({type: 'pass', label: 'DKIM alignment: strict', detail: 'Strict DKIM alignment requires the d= domain in the DKIM signature to exactly match the From domain.'});
+ }
+ if (aspf === 's') {
+ issues.push({type: 'pass', label: 'SPF alignment: strict', detail: 'Strict SPF alignment requires the envelope sender domain to exactly match the From domain.'});
+ }
+
+ const grade = issues.some(i => i.type === 'error') ? 'fail' : issues.some(i => i.type === 'warning') ? 'warning' : 'pass';
+
+ return {policy, subPolicy, pct, hasRua, hasRuf, adkim, aspf, issues, grade};
+}
+
+function PolicyBadge({policy}: {policy: string}) {
+ if (policy === 'reject') {
+ return reject;
+ }
+ if (policy === 'quarantine') {
+ return quarantine;
+ }
+ if (policy === 'none') {
+ return none;
+ }
+ return missing;
+}
+
+function GradeBadge({grade, policy}: {grade: 'pass' | 'warning' | 'fail'; policy: string}) {
+ const map = {
+ pass: {cls: 'bg-green-50 border-green-200 text-green-700', label: 'Valid'},
+ warning: {cls: 'bg-amber-50 border-amber-200 text-amber-700', label: 'Needs attention'},
+ fail: {cls: 'bg-red-50 border-red-200 text-red-700', label: 'Action required'},
+ };
+ const {cls, label} = map[grade];
+ const sub = policy === 'reject' ? 'Full DMARC protection' : policy === 'quarantine' ? 'Partial protection' : policy === 'none' ? 'Monitoring only — no enforcement' : 'DMARC not enforcing';
+ return (
+
+ {label}
+ {sub}
+
+ );
+}
+
+const TAG_DESCRIPTIONS: Record = {
+ v: 'DMARC version',
+ p: 'Domain policy for failing messages',
+ sp: 'Subdomain policy override',
+ pct: 'Percentage of messages subject to policy',
+ rua: 'Aggregate report recipients',
+ ruf: 'Forensic report recipients',
+ adkim: 'DKIM alignment mode (r=relaxed, s=strict)',
+ aspf: 'SPF alignment mode (r=relaxed, s=strict)',
+ fo: 'Failure reporting options',
+ rf: 'Forensic report format',
+ ri: 'Reporting interval (seconds)',
+};
+
+const faqs: FAQ[] = [
+ {
+ question: 'What is DMARC?',
+ answer: 'DMARC (Domain-based Message Authentication, Reporting & Conformance) is an email authentication policy that builds on SPF and DKIM. It tells receiving mail servers what to do when an email fails authentication — none (monitor), quarantine (spam folder), or reject (block). DMARC also enables reporting so you can see who is sending email from your domain.',
+ },
+ {
+ question: 'What is the difference between p=none, quarantine, and reject?',
+ answer: 'p=none means DMARC is in monitoring mode — failures are reported but emails are still delivered. p=quarantine instructs receiving servers to put failing messages in the spam/junk folder. p=reject instructs servers to reject failing messages entirely. The recommended path is to start at p=none, review reports, then progress to quarantine and finally reject.',
+ },
+ {
+ question: 'What are DMARC aggregate reports?',
+ answer: 'Aggregate reports (rua=) are XML reports sent by receiving mail servers summarising how many messages passed or failed SPF and DKIM for your domain. They help you identify all sources sending on your behalf, catch misconfigurations, and detect spoofing attempts. Use a DMARC reporting service to parse and visualise these reports.',
+ },
+ {
+ question: 'Why do I need DMARC if I already have SPF and DKIM?',
+ answer: 'SPF and DKIM independently authenticate different aspects of an email, but neither specifies what to do when authentication fails. DMARC ties them together and enforces a policy. Without DMARC, even a domain with perfect SPF and DKIM offers no protection against spoofing of the visible From header.',
+ },
+ {
+ question: 'What is DMARC alignment?',
+ answer: 'DMARC alignment requires that the domain in a passing SPF or DKIM check matches (or aligns with) the From header domain. Relaxed alignment (r) allows subdomains; strict alignment (s) requires an exact domain match. Alignment is what connects SPF/DKIM to the From header the user sees.',
+ },
+];
+
+interface DnsAnswer {
+ data: string;
+}
+
+interface DmarcLookupResult {
+ domain: string;
+ found: boolean;
+ record: string | null;
+ tags: Record;
+ error?: string;
+}
+
+function cleanTxt(raw: string): string {
+ return raw.replace(/^"|"$/g, '').replace(/"\s*"/g, '');
+}
+
+async function lookupDmarc(domain: string): Promise {
+ const clean = domain.trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/.*$/, '').replace(/^www\./, '');
+ try {
+ const res = await fetch(`https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(`_dmarc.${clean}`)}&type=TXT`, {
+ headers: {Accept: 'application/dns-json'},
+ });
+ if (!res.ok) return {domain: clean, found: false, record: null, tags: {}, error: 'DNS lookup failed'};
+ const data = await res.json() as {Answer?: DnsAnswer[]};
+ const records = (data.Answer ?? []).map((a: DnsAnswer) => cleanTxt(a.data)).filter(r => r.startsWith('v=DMARC1'));
+ if (records.length === 0) return {domain: clean, found: false, record: null, tags: {}};
+ const record: string = records[0]!;
+ const tags: Record = {};
+ record.split(';').forEach(part => {
+ const eqIdx = part.indexOf('=');
+ if (eqIdx > -1) {
+ const key = part.slice(0, eqIdx).trim();
+ const value = part.slice(eqIdx + 1).trim();
+ if (key) tags[key] = value;
+ }
+ });
+ return {domain: clean, found: true, record, tags};
+ } catch {
+ return {domain: clean, found: false, record: null, tags: {}, error: 'DNS lookup failed'};
+ }
+}
+
+export default function DmarcCheckerPage() {
+ const [domain, setDomain] = useState('');
+ const [loading, setLoading] = useState(false);
+ const [result, setResult] = useState(null);
+ const [analysis, setAnalysis] = useState(null);
+
+ const handleCheck = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setLoading(true);
+ setResult(null);
+ setAnalysis(null);
+
+ try {
+ const data = await lookupDmarc(domain);
+ setResult(data);
+ if (data.found && data.tags) {
+ setAnalysis(analyzeDmarc(data.tags));
+ }
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+ <>
+
+
+
+
+
+
+ {/* ========== HERO ========== */}
+
+
+
+
+
+ § T-05 — Tool
+
+ ← All tools
+
+
+
+
+
+ DMARC record
+
+ checker
+
+
+ Look up and validate your domain's DMARC record. Understand your current policy, reporting
+ configuration, and get clear advice to progress toward full enforcement.
+
+
+
+
+
+ {/* ========== TOOL ========== */}
+
+
+
+
+
+
+
+ DMARC record lookup
+
+
+
+
+
+
+
+ {result && (
+
+ {!result.found ? (
+
+
+
+
+
No DMARC record found
+
+ {result.error
+ ? 'DNS lookup failed. Please check the domain and try again.'
+ : `No DMARC record was found at _dmarc.${result.domain}. Without DMARC, your domain has no enforcement policy and you won't receive authentication reports.`}
+
+ {[
+ {
+ step: '01',
+ policy: 'p=none',
+ title: 'Monitor',
+ body: 'Start here. DMARC is active but no action is taken on failures. Add rua= to receive aggregate reports and identify all your sending sources.',
+ cls: 'border-neutral-300',
+ },
+ {
+ step: '02',
+ policy: 'p=quarantine',
+ title: 'Quarantine',
+ body: 'Once you\'re confident all legitimate senders pass, move to quarantine. Failing messages are sent to spam, reducing spoofing impact.',
+ cls: 'border-amber-300',
+ },
+ {
+ step: '03',
+ policy: 'p=reject',
+ title: 'Reject',
+ body: 'Full enforcement. Failing messages are rejected by receiving servers. This is the goal — it completely prevents domain spoofing.',
+ cls: 'border-green-400',
+ },
+ ].map((item, i) => (
+
+
+
+ Step {item.step}
+
+
+ {item.policy}
+
+
+
+ {item.title}
+
+
{item.body}
+
+ ))}
+
+
+
+
+ {/* ========== CTA ========== */}
+
+
+
+
+ Email that reaches the inbox.
+
+
+
+
+ Plunk walks you through SPF, DKIM, and DMARC setup and monitors your sending reputation over time.
+
+
+
+ Start with Plunk
+
+
+
+ What is DMARC?
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/apps/landing/src/pages/tools/index.tsx b/apps/landing/src/pages/tools/index.tsx
index c081a20..f2a5c0c 100644
--- a/apps/landing/src/pages/tools/index.tsx
+++ b/apps/landing/src/pages/tools/index.tsx
@@ -4,7 +4,7 @@ import {DASHBOARD_URI} from '../../lib/constants';
import React from 'react';
import Link from 'next/link';
import {NextSeo} from 'next-seo';
-import {ArrowRight, ArrowUpRight, Code2, Search, ShieldAlert} from 'lucide-react';
+import {ArrowRight, ArrowUpRight, Code2, Key, Search, Shield, ShieldAlert, ShieldCheck} from 'lucide-react';
import {Bricolage_Grotesque, Hanken_Grotesk, JetBrains_Mono} from 'next/font/google';
const display = Bricolage_Grotesque({
@@ -50,19 +50,40 @@ const tools = [
icon: ShieldAlert,
number: '03',
},
+ {
+ name: 'SPF Checker',
+ slug: 'spf-checker',
+ description: 'Look up and validate your domain\'s SPF record. Catch misconfigurations before they hurt deliverability.',
+ icon: Shield,
+ number: '04',
+ },
+ {
+ name: 'DMARC Checker',
+ slug: 'dmarc-checker',
+ description: 'Check your DMARC policy, reporting configuration, and get step-by-step advice toward full enforcement.',
+ icon: ShieldCheck,
+ number: '05',
+ },
+ {
+ name: 'DKIM Checker',
+ slug: 'dkim-checker',
+ description: 'Look up your DKIM public key by selector. Verify it\'s active and correctly configured.',
+ icon: Key,
+ number: '06',
+ },
];
export default function ToolsIndex() {
return (
<>
- Convert markdown to email-safe HTML, verify addresses, and more. No sign-up required.
+ Check SPF, DMARC, and DKIM records. Verify addresses, convert markdown to email-safe HTML, test for spam, and more. No sign-up required.
diff --git a/apps/landing/src/pages/tools/spf-checker.tsx b/apps/landing/src/pages/tools/spf-checker.tsx
new file mode 100644
index 0000000..b703fac
--- /dev/null
+++ b/apps/landing/src/pages/tools/spf-checker.tsx
@@ -0,0 +1,587 @@
+import {FAQSection, Footer, Navbar, SectionHeader} from '../../components';
+import {motion} from 'framer-motion';
+import {DASHBOARD_URI} from '../../lib/constants';
+import React, {useState} from 'react';
+import {NextSeo} from 'next-seo';
+import {AlertTriangle, ArrowRight, CheckCircle, Shield, XCircle} from 'lucide-react';
+import {Button, Input} from '@plunk/ui';
+import {Bricolage_Grotesque, Hanken_Grotesk, JetBrains_Mono} from 'next/font/google';
+import Link from 'next/link';
+import type {FAQ} from '../../components/FAQSection';
+
+const display = Bricolage_Grotesque({
+ subsets: ['latin'],
+ variable: '--font-display',
+ display: 'swap',
+ weight: ['400', '500', '600', '700', '800'],
+});
+
+const body = Hanken_Grotesk({
+ subsets: ['latin'],
+ variable: '--font-body',
+ display: 'swap',
+ weight: ['400', '500', '600', '700'],
+});
+
+const mono = JetBrains_Mono({
+ subsets: ['latin'],
+ variable: '--font-mono',
+ display: 'swap',
+ weight: ['400', '500'],
+});
+
+interface SpfMechanism {
+ qualifier: string;
+ type: string;
+ value: string;
+}
+
+interface SpfIssue {
+ type: 'error' | 'warning' | 'pass';
+ label: string;
+ detail: string;
+}
+
+interface ParsedSpf {
+ mechanisms: SpfMechanism[];
+ allMechanism: string | null;
+ issues: SpfIssue[];
+ lookupCount: number;
+ grade: 'pass' | 'warning' | 'fail';
+}
+
+const DNS_LOOKUP_MECHS = new Set(['include', 'a', 'mx', 'ptr', 'exists', 'redirect']);
+
+function parseSpfRecord(record: string): ParsedSpf {
+ const parts = record.split(/\s+/);
+ const mechanisms: SpfMechanism[] = [];
+ let allMechanism: string | null = null;
+ const issues: SpfIssue[] = [];
+ let lookupCount = 0;
+
+ for (const part of parts.slice(1)) {
+ const lower = part.toLowerCase();
+ if (lower === '+all' || lower === '-all' || lower === '~all' || lower === '?all' || lower === 'all') {
+ allMechanism = lower === 'all' ? '+all' : lower;
+ continue;
+ }
+
+ let qualifier = '+';
+ let mech = part;
+ const firstChar = part[0] ?? '';
+ if (['+', '-', '~', '?'].includes(firstChar)) {
+ qualifier = firstChar;
+ mech = part.slice(1);
+ }
+
+ const colonIdx = mech.indexOf(':');
+ const slashIdx = mech.indexOf('/');
+ const endIdx = colonIdx > -1 ? colonIdx : slashIdx > -1 ? slashIdx : mech.length;
+ const type = mech.slice(0, endIdx).toLowerCase();
+ const value = colonIdx > -1 ? mech.slice(colonIdx + 1) : '';
+
+ if (DNS_LOOKUP_MECHS.has(type)) lookupCount++;
+
+ mechanisms.push({qualifier, type, value});
+ }
+
+ // Analyze
+ if (!allMechanism) {
+ issues.push({type: 'warning', label: 'No "all" mechanism', detail: 'SPF records should end with -all, ~all, or ?all to define default behaviour for unlisted senders.'});
+ } else if (allMechanism === '+all') {
+ issues.push({type: 'error', label: '+all allows any server to send', detail: '+all means any mail server in the world can send email claiming to be from your domain, completely defeating SPF protection.'});
+ } else if (allMechanism === '?all') {
+ issues.push({type: 'warning', label: '?all is too permissive', detail: '?all (neutral) gives no protection. Consider upgrading to ~all (softfail) or -all (hard fail).'});
+ } else if (allMechanism === '~all') {
+ issues.push({type: 'warning', label: '~all softfail is acceptable but not optimal', detail: 'Softfail marks unauthorised senders as suspicious but still delivers them. Prefer -all for maximum protection once your legitimate senders are configured.'});
+ } else if (allMechanism === '-all') {
+ issues.push({type: 'pass', label: '-all hard fail is the strongest policy', detail: 'Mail servers are instructed to reject email from any server not listed in your SPF record.'});
+ }
+
+ if (lookupCount > 8) {
+ issues.push({type: 'error', label: `DNS lookup limit exceeded (${lookupCount}/10)`, detail: `SPF is limited to 10 DNS lookups. Exceeding this causes a PermError, making SPF fail permanently. Consolidate includes or use IP ranges.`});
+ } else if (lookupCount > 6) {
+ issues.push({type: 'warning', label: `Approaching DNS lookup limit (${lookupCount}/10)`, detail: 'Adding more senders could push you over the 10 lookup limit. Monitor and consolidate where possible.'});
+ } else {
+ issues.push({type: 'pass', label: `DNS lookups within limit (${lookupCount}/10)`, detail: 'Your SPF record uses an acceptable number of DNS lookups.'});
+ }
+
+ const ptrMechs = mechanisms.filter(m => m.type === 'ptr');
+ if (ptrMechs.length > 0) {
+ issues.push({type: 'warning', label: 'ptr mechanism is deprecated', detail: 'The ptr mechanism is slow and unreliable. RFC 7208 recommends avoiding it. Use ip4/ip6 or include instead.'});
+ }
+
+ const grade = issues.some(i => i.type === 'error') ? 'fail' : issues.some(i => i.type === 'warning') ? 'warning' : 'pass';
+
+ return {mechanisms, allMechanism, issues, lookupCount, grade};
+}
+
+function qualifierLabel(q: string) {
+ if (q === '+') return {label: 'PASS', cls: 'bg-green-50 text-green-700 border-green-200'};
+ if (q === '-') return {label: 'FAIL', cls: 'bg-red-50 text-red-700 border-red-200'};
+ if (q === '~') return {label: 'SOFTFAIL', cls: 'bg-amber-50 text-amber-700 border-amber-200'};
+ return {label: 'NEUTRAL', cls: 'bg-neutral-100 text-neutral-600 border-neutral-300'};
+}
+
+function GradeBadge({grade}: {grade: 'pass' | 'warning' | 'fail'}) {
+ const map = {
+ pass: {cls: 'bg-green-50 border-green-200 text-green-700', label: 'Valid', sub: 'SPF is properly configured'},
+ warning: {cls: 'bg-amber-50 border-amber-200 text-amber-700', label: 'Needs attention', sub: 'SPF has configuration issues'},
+ fail: {cls: 'bg-red-50 border-red-200 text-red-700', label: 'Action required', sub: 'SPF has critical errors'},
+ };
+ const {cls, label, sub} = map[grade];
+ return (
+
+ {label}
+ {sub}
+
+ );
+}
+
+const faqs: FAQ[] = [
+ {
+ question: 'What is an SPF record?',
+ answer: 'An SPF (Sender Policy Framework) record is a DNS TXT record that lists the mail servers authorised to send email on behalf of your domain. Receiving mail servers check this record to verify that incoming email claiming to be from your domain was sent by an authorised server. Without SPF, anyone can spoof your domain in the From address.',
+ },
+ {
+ question: 'What does -all vs ~all mean?',
+ answer: '-all (hard fail) instructs receiving servers to reject any email not matching your SPF record. ~all (softfail) marks non-matching emails as suspicious but still delivers them. For production domains, -all is recommended once all your legitimate sending sources are added.',
+ },
+ {
+ question: 'Why is there a 10 DNS lookup limit?',
+ answer: 'RFC 7208 limits SPF to 10 DNS lookups to prevent denial-of-service attacks and excessive DNS load. Each include, a, mx, ptr, and exists mechanism counts as one lookup. Exceeding 10 lookups causes a PermError, which effectively makes SPF fail for your domain.',
+ },
+ {
+ question: 'Can I have multiple SPF records?',
+ answer: 'No. Having more than one SPF (v=spf1) TXT record on your domain causes a PermError and breaks SPF authentication. If you need to authorise multiple senders, combine everything into a single SPF record using multiple mechanisms.',
+ },
+ {
+ question: 'Does SPF alone protect against spoofing?',
+ answer: 'SPF alone is not enough. SPF only validates the envelope sender (the "Return-Path" address), not the visible "From" header. DMARC is required to connect SPF (and DKIM) validation to the From header and actually prevent spoofing of your visible sender address.',
+ },
+];
+
+interface DnsAnswer {
+ data: string;
+}
+
+interface SpfLookupResult {
+ domain: string;
+ found: boolean;
+ multiple: boolean;
+ records: string[];
+ error?: string;
+}
+
+function cleanTxt(raw: string): string {
+ return raw.replace(/^"|"$/g, '').replace(/"\s*"/g, '');
+}
+
+async function lookupSpf(domain: string): Promise {
+ const clean = domain.trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/.*$/, '').replace(/^www\./, '');
+ try {
+ const res = await fetch(`https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(clean)}&type=TXT`, {
+ headers: {Accept: 'application/dns-json'},
+ });
+ if (!res.ok) return {domain: clean, found: false, multiple: false, records: [], error: 'DNS lookup failed'};
+ const data = await res.json() as {Answer?: DnsAnswer[]};
+ const records = (data.Answer ?? []).map((a: DnsAnswer) => cleanTxt(a.data)).filter(r => r.startsWith('v=spf1'));
+ return {domain: clean, found: records.length > 0, multiple: records.length > 1, records};
+ } catch {
+ return {domain: clean, found: false, multiple: false, records: [], error: 'DNS lookup failed'};
+ }
+}
+
+export default function SpfCheckerPage() {
+ const [domain, setDomain] = useState('');
+ const [loading, setLoading] = useState(false);
+ const [result, setResult] = useState(null);
+ const [parsed, setParsed] = useState(null);
+
+ const handleCheck = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setLoading(true);
+ setResult(null);
+ setParsed(null);
+
+ try {
+ const data = await lookupSpf(domain);
+ setResult(data);
+ if (data.found && data.records[0]) {
+ setParsed(parseSpfRecord(data.records[0]));
+ }
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+ <>
+
+
+
+
+
+
+ {/* ========== HERO ========== */}
+
+
+
+
+
+ § T-04 — Tool
+
+ ← All tools
+
+
+
+
+
+ SPF record
+
+ checker
+
+
+ Look up and validate your domain's SPF record. Get a full breakdown of your sending policy and
+ catch misconfigurations before they hurt deliverability.
+
+
+
+
+
+ {/* ========== TOOL ========== */}
+
+
+
+
+
+
+
+ SPF record lookup
+
+
+
+
+
+
+
+ {result && (
+
+ {!result.found ? (
+
+
+
+
+
No SPF record found
+
+ {result.error
+ ? 'DNS lookup failed. Please check the domain and try again.'
+ : `No SPF record was found for ${result.domain}. Without SPF, anyone can send email impersonating your domain, and legitimate emails are more likely to land in spam.`}
+
+ {[
+ {
+ title: 'Authorise senders',
+ body: 'SPF lets you publish a list of mail servers allowed to send on your behalf. Any server not on the list fails SPF validation.',
+ },
+ {
+ title: 'Prevent spoofing',
+ body: 'Without SPF, anyone can claim to send email from your domain. SPF makes it possible for receiving servers to detect and reject spoofed messages.',
+ },
+ {
+ title: '10 lookup limit',
+ body: 'SPF allows at most 10 DNS lookups per evaluation. Exceeding this causes a PermError, making SPF permanently fail. Monitor your lookup count carefully.',
+ },
+ {
+ title: 'SPF is not enough alone',
+ body: 'SPF validates the envelope sender, not the visible From header. You need DMARC to tie SPF (and DKIM) results to the From header and actually block spoofed email.',
+ },
+ {
+ title: 'One record only',
+ body: 'A domain must have exactly one SPF TXT record. Multiple v=spf1 records cause a PermError. Merge all sending sources into a single record.',
+ },
+ {
+ title: 'Hard fail vs softfail',
+ body: '-all (hard fail) instructs servers to reject non-matching mail. ~all (softfail) marks it as suspicious. Use -all in production once all senders are listed.',
+ },
+ ].map((item, i) => (
+
+
+ {item.title}
+
+
{item.body}
+
+ ))}
+
+
+
+
+ {/* ========== CTA ========== */}
+
+
+
+
+ SPF, DKIM, and DMARC — handled.
+
+
+
+
+ Plunk guides you through domain authentication setup and monitors your sending reputation.
+ Start free, no credit card required.
+