seo: Add dkim, dmarc, spf check
This commit is contained in:
@@ -87,6 +87,25 @@ export default function Footer() {
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3 style={{fontFamily: 'var(--font-mono)'}} className="mt-8 text-[11px] font-semibold uppercase tracking-[0.18em] text-neutral-500">Checkers</h3>
|
||||
<ul role="list" className="mt-6 space-y-4">
|
||||
<li>
|
||||
<Link href={'/tools/spf-checker'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||
SPF checker
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={'/tools/dmarc-checker'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||
DMARC checker
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={'/tools/dkim-checker'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||
DKIM checker
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -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<string, string>): 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 (
|
||||
<div className={`flex flex-col items-center gap-1 rounded-2xl border-2 px-8 py-5 ${cls}`}>
|
||||
<span style={{fontFamily: 'var(--font-display)'}} className={'text-2xl font-extrabold'}>{label}</span>
|
||||
<span className={'text-xs font-medium opacity-80'}>{sub}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const TAG_DESCRIPTIONS: Record<string, string> = {
|
||||
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<string, string>;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function cleanTxt(raw: string): string {
|
||||
return raw.replace(/^"|"$/g, '').replace(/"\s*"/g, '');
|
||||
}
|
||||
|
||||
async function lookupDkim(domain: string, selector: string): Promise<DkimLookupResult> {
|
||||
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<string, string> = {};
|
||||
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<DkimLookupResult | null>(null);
|
||||
const [analysis, setAnalysis] = useState<DkimAnalysis | null>(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 (
|
||||
<>
|
||||
<NextSeo
|
||||
title="DKIM Record Checker | Free DKIM Lookup & Validator | Plunk"
|
||||
description="Free DKIM record checker. Look up your DKIM public key by domain and selector, validate the record, and get advice to fix misconfigurations."
|
||||
canonical="https://www.useplunk.com/tools/dkim-checker"
|
||||
openGraph={{
|
||||
title: 'DKIM Record Checker | Free DKIM Lookup & Validator | Plunk',
|
||||
description: 'Free DKIM record checker. Look up and validate your DKIM key by domain and selector.',
|
||||
url: 'https://www.useplunk.com/tools/dkim-checker',
|
||||
images: [{url: 'https://www.useplunk.com/api/og?title=Free+DKIM+Record+Checker&tag=Tool', alt: 'Plunk DKIM Checker', width: 1200, height: 630}],
|
||||
}}
|
||||
/>
|
||||
|
||||
<Navbar />
|
||||
|
||||
<div className={`${display.variable} ${body.variable} ${mono.variable}`}>
|
||||
<main className={'text-neutral-800'}>
|
||||
{/* ========== HERO ========== */}
|
||||
<section className={'relative overflow-hidden'}>
|
||||
<div
|
||||
aria-hidden
|
||||
className={'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#eeeeee_1px,transparent_1px),linear-gradient(to_bottom,#eeeeee_1px,transparent_1px)] bg-[size:6rem_6rem] [mask-image:radial-gradient(ellipse_70%_60%_at_50%_30%,#000_40%,transparent_95%)]'}
|
||||
/>
|
||||
|
||||
<div className={'mx-auto max-w-[88rem] px-6 pb-24 pt-20 sm:px-10 sm:pt-28 lg:pb-36'}>
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 8}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.5, ease: [0.22, 1, 0.36, 1]}}
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={'mb-16 flex items-center justify-between border-t border-neutral-900/90 pt-4 text-[11px] uppercase tracking-[0.18em] text-neutral-700 sm:mb-24'}
|
||||
>
|
||||
<span className={'font-medium text-neutral-900'}>§ T-06 — Tool</span>
|
||||
<Link href="/tools" className={'text-neutral-500 transition hover:text-neutral-900'}>
|
||||
← All tools
|
||||
</Link>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 16}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mx-auto max-w-5xl text-center'}
|
||||
>
|
||||
<h1
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={'text-[clamp(2.5rem,7vw,6.5rem)] font-extrabold leading-[0.92] tracking-[-0.04em] text-neutral-900'}
|
||||
>
|
||||
DKIM record
|
||||
<br />
|
||||
checker
|
||||
</h1>
|
||||
<p className={'mx-auto mt-8 max-w-2xl text-lg leading-relaxed text-neutral-600 sm:text-xl'}>
|
||||
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.
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ========== TOOL ========== */}
|
||||
<section className={'mx-auto max-w-[88rem] px-6 py-16 sm:px-10 sm:py-20'}>
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 20}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mx-auto max-w-2xl'}
|
||||
>
|
||||
<div className={'overflow-hidden rounded-[20px] border border-neutral-200 bg-white'}>
|
||||
<div className={'border-b border-neutral-200 px-8 py-5'}>
|
||||
<div className={'flex items-center gap-3'}>
|
||||
<Key className={'h-4 w-4 text-neutral-500'} strokeWidth={1.5} />
|
||||
<span
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={'text-[11px] uppercase tracking-[0.18em] text-neutral-500'}
|
||||
>
|
||||
DKIM record lookup
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleCheck} className={'p-8'}>
|
||||
<div className={'space-y-4'}>
|
||||
<div>
|
||||
<label htmlFor="domain" className={'mb-2 block text-sm font-medium text-neutral-900'}>
|
||||
Domain name <span className={'text-red-500'}>*</span>
|
||||
</label>
|
||||
<Input
|
||||
id="domain"
|
||||
type="text"
|
||||
value={domain}
|
||||
onChange={e => setDomain(e.target.value)}
|
||||
placeholder="example.com"
|
||||
required
|
||||
className={'w-full'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="selector" className={'mb-2 block text-sm font-medium text-neutral-900'}>
|
||||
DKIM selector <span className={'text-red-500'}>*</span>
|
||||
</label>
|
||||
<Input
|
||||
id="selector"
|
||||
type="text"
|
||||
value={selector}
|
||||
onChange={e => setSelector(e.target.value)}
|
||||
placeholder="google"
|
||||
required
|
||||
className={'w-full'}
|
||||
/>
|
||||
<p className={'mt-1.5 text-xs text-neutral-400'}>
|
||||
Not sure? Try a common selector below.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Common selectors */}
|
||||
<div>
|
||||
<p className={'mb-2 text-xs font-medium text-neutral-500'}>Common selectors</p>
|
||||
<div className={'flex flex-wrap gap-2'}>
|
||||
{COMMON_SELECTORS.map(s => (
|
||||
<button
|
||||
key={s.value}
|
||||
type="button"
|
||||
onClick={() => setSelector(s.value)}
|
||||
className={`rounded-full border px-3 py-1 text-xs font-medium transition ${
|
||||
selector === s.value
|
||||
? 'border-neutral-900 bg-neutral-900 text-white'
|
||||
: 'border-neutral-200 text-neutral-600 hover:border-neutral-400 hover:text-neutral-900'
|
||||
}`}
|
||||
>
|
||||
{s.label} <span style={{fontFamily: 'var(--font-mono)'}} className={'opacity-60'}>({s.value})</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className={'w-full gap-2'} disabled={loading}>
|
||||
<Key className={'h-4 w-4'} />
|
||||
{loading ? 'Looking up DKIM record…' : 'Check DKIM record'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{result && (
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 16}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.5, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mt-6 space-y-4'}
|
||||
>
|
||||
{!result.found ? (
|
||||
<div className={'rounded-[20px] border border-amber-100 bg-amber-50 p-8'}>
|
||||
<div className={'flex items-start gap-3'}>
|
||||
<XCircle className={'mt-0.5 h-5 w-5 shrink-0 text-amber-600'} />
|
||||
<div>
|
||||
<p className={'font-semibold text-amber-900'}>No DKIM record found</p>
|
||||
<p className={'mt-1 text-sm text-amber-800'}>
|
||||
{result.error
|
||||
? 'DNS lookup failed. Please check the domain and try again.'
|
||||
: `No DKIM record was found at `}
|
||||
{!result.error && (
|
||||
<code style={{fontFamily: 'var(--font-mono)'}} className={'text-xs'}>
|
||||
{result.selector}._domainkey.{result.domain}
|
||||
</code>
|
||||
)}
|
||||
</p>
|
||||
{!result.error && (
|
||||
<p className={'mt-2 text-sm text-amber-700'}>
|
||||
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.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Lookup host */}
|
||||
<div className={'rounded-[20px] border border-neutral-200 bg-white p-8'}>
|
||||
<p
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={'mb-2 text-[11px] uppercase tracking-[0.18em] text-neutral-400'}
|
||||
>
|
||||
Record found at
|
||||
</p>
|
||||
<code
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={'text-sm font-medium text-neutral-700'}
|
||||
>
|
||||
{result.selector}._domainkey.{result.domain}
|
||||
</code>
|
||||
|
||||
{result.record && (
|
||||
<>
|
||||
<p
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={'mb-2 mt-6 text-[11px] uppercase tracking-[0.18em] text-neutral-400'}
|
||||
>
|
||||
Raw record
|
||||
</p>
|
||||
<code
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={'block break-all rounded-lg bg-neutral-50 px-4 py-3 text-xs text-neutral-700'}
|
||||
>
|
||||
{result.record.length > 200
|
||||
? `${result.record.slice(0, 200)}… [${result.record.length - 200} more characters]`
|
||||
: result.record}
|
||||
</code>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{analysis && (
|
||||
<>
|
||||
{/* Grade */}
|
||||
<div className={'rounded-[20px] border border-neutral-200 bg-white p-8'}>
|
||||
<div className={'flex flex-col items-center gap-4 text-center'}>
|
||||
<GradeBadge grade={analysis.grade} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
{Object.keys(result.tags).length > 0 && (
|
||||
<div className={'rounded-[20px] border border-neutral-200 bg-white p-8'}>
|
||||
<h3
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={'mb-6 text-lg font-bold text-neutral-900'}
|
||||
>
|
||||
Record tags
|
||||
</h3>
|
||||
<div className={'space-y-2'}>
|
||||
{Object.entries(result.tags).map(([key, value]) => {
|
||||
const displayValue = key === 'p' && value.length > 48
|
||||
? `${value.slice(0, 48)}… [${value.length - 48} more chars]`
|
||||
: value || '(empty — key revoked)';
|
||||
return (
|
||||
<div key={key} className={'flex items-start gap-3 rounded-lg border border-neutral-100 bg-neutral-50 px-4 py-3'}>
|
||||
<div className={'flex min-w-0 flex-1 flex-col gap-0.5'}>
|
||||
<div className={'flex items-center gap-2'}>
|
||||
<span style={{fontFamily: 'var(--font-mono)'}} className={'text-xs font-bold text-neutral-700'}>
|
||||
{key}=
|
||||
</span>
|
||||
<span style={{fontFamily: 'var(--font-mono)'}} className={'truncate text-xs text-neutral-500'}>
|
||||
{displayValue}
|
||||
</span>
|
||||
</div>
|
||||
{TAG_DESCRIPTIONS[key] && (
|
||||
<span className={'text-xs text-neutral-400'}>{TAG_DESCRIPTIONS[key]}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Analysis */}
|
||||
<div className={'rounded-[20px] border border-neutral-200 bg-white p-8'}>
|
||||
<h3
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={'mb-6 text-lg font-bold text-neutral-900'}
|
||||
>
|
||||
Analysis & recommendations
|
||||
</h3>
|
||||
<ul className={'space-y-3'}>
|
||||
{analysis.issues.map((issue, i) => (
|
||||
<li key={i} className={'flex items-start gap-3'}>
|
||||
{issue.type === 'pass' ? (
|
||||
<CheckCircle className={'mt-0.5 h-5 w-5 shrink-0 text-green-600'} />
|
||||
) : issue.type === 'warning' ? (
|
||||
<AlertTriangle className={'mt-0.5 h-5 w-5 shrink-0 text-amber-500'} />
|
||||
) : (
|
||||
<XCircle className={'mt-0.5 h-5 w-5 shrink-0 text-red-500'} />
|
||||
)}
|
||||
<div>
|
||||
<p className={'text-sm font-medium text-neutral-900'}>{issue.label}</p>
|
||||
<p className={'mt-0.5 text-xs text-neutral-500'}>{issue.detail}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
{/* ========== EDUCATION ========== */}
|
||||
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-28 sm:px-10 sm:py-36'}>
|
||||
<SectionHeader
|
||||
number={'01'}
|
||||
label={'DKIM explained'}
|
||||
title={'How DKIM signing works.'}
|
||||
subtitle={'DKIM proves your email was sent by your domain and wasn\'t altered in transit.'}
|
||||
/>
|
||||
|
||||
<div className={'mt-20 grid gap-6 sm:grid-cols-2 lg:grid-cols-3'}>
|
||||
{[
|
||||
{
|
||||
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) => (
|
||||
<motion.div
|
||||
key={item.title}
|
||||
initial={{opacity: 0, y: 16}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.5, delay: i * 0.05, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'flex flex-col gap-4 rounded-[20px] border border-neutral-200 bg-white p-8'}
|
||||
>
|
||||
<h3
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={'text-xl font-bold tracking-[-0.02em] text-neutral-900'}
|
||||
>
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className={'text-sm leading-relaxed text-neutral-600'}>{item.body}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ========== CTA ========== */}
|
||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-32 sm:px-10 sm:py-40'}>
|
||||
<div className={'flex flex-col items-start gap-12 lg:flex-row lg:items-end lg:justify-between'}>
|
||||
<motion.h2
|
||||
initial={{opacity: 0, y: 16}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}}
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={'text-[clamp(2.5rem,7vw,6rem)] font-extrabold leading-[0.95] tracking-[-0.035em]'}
|
||||
>
|
||||
Sign every email. Reach the inbox.
|
||||
</motion.h2>
|
||||
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 16}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.9, delay: 0.15, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'flex max-w-md flex-col gap-6'}
|
||||
>
|
||||
<p className={'text-base text-neutral-300 sm:text-lg'}>
|
||||
Plunk configures DKIM signing automatically and guides you through setting up SPF and DMARC for your
|
||||
domain.
|
||||
</p>
|
||||
<div className={'flex flex-wrap gap-3'}>
|
||||
<motion.a
|
||||
whileHover={{scale: 1.015}}
|
||||
whileTap={{scale: 0.985}}
|
||||
href={`${DASHBOARD_URI}/auth/signup`}
|
||||
className={'inline-flex items-center gap-2 rounded-full bg-white px-7 py-3.5 text-sm font-semibold text-neutral-900 transition hover:bg-neutral-100'}
|
||||
>
|
||||
Start with Plunk
|
||||
<ArrowRight className={'h-4 w-4'} />
|
||||
</motion.a>
|
||||
<Link
|
||||
href="/guides/what-is-dkim"
|
||||
className={'inline-flex items-center gap-2 rounded-full border border-neutral-700 px-7 py-3.5 text-sm font-semibold text-white transition hover:border-white'}
|
||||
>
|
||||
What is DKIM?
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<FAQSection faqs={faqs} schemaId="faq-dkim-checker" />
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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<string, string>): 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:[email protected] 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 <span className={'rounded-full bg-green-50 px-3 py-1 text-xs font-bold uppercase tracking-wide text-green-700 border border-green-200'}>reject</span>;
|
||||
}
|
||||
if (policy === 'quarantine') {
|
||||
return <span className={'rounded-full bg-amber-50 px-3 py-1 text-xs font-bold uppercase tracking-wide text-amber-700 border border-amber-200'}>quarantine</span>;
|
||||
}
|
||||
if (policy === 'none') {
|
||||
return <span className={'rounded-full bg-neutral-100 px-3 py-1 text-xs font-bold uppercase tracking-wide text-neutral-600 border border-neutral-300'}>none</span>;
|
||||
}
|
||||
return <span className={'rounded-full bg-red-50 px-3 py-1 text-xs font-bold uppercase tracking-wide text-red-700 border border-red-200'}>missing</span>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={`flex flex-col items-center gap-1 rounded-2xl border-2 px-8 py-5 ${cls}`}>
|
||||
<span style={{fontFamily: 'var(--font-display)'}} className={'text-2xl font-extrabold'}>{label}</span>
|
||||
<span className={'text-xs font-medium opacity-80'}>{sub}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const TAG_DESCRIPTIONS: Record<string, string> = {
|
||||
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<string, string>;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function cleanTxt(raw: string): string {
|
||||
return raw.replace(/^"|"$/g, '').replace(/"\s*"/g, '');
|
||||
}
|
||||
|
||||
async function lookupDmarc(domain: string): Promise<DmarcLookupResult> {
|
||||
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<string, string> = {};
|
||||
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<DmarcLookupResult | null>(null);
|
||||
const [analysis, setAnalysis] = useState<DmarcAnalysis | null>(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 (
|
||||
<>
|
||||
<NextSeo
|
||||
title="DMARC Record Checker | Free DMARC Lookup & Validator | Plunk"
|
||||
description="Free DMARC record checker. Look up and validate your domain's DMARC policy, check reporting configuration, and get step-by-step advice to strengthen email security."
|
||||
canonical="https://www.useplunk.com/tools/dmarc-checker"
|
||||
openGraph={{
|
||||
title: 'DMARC Record Checker | Free DMARC Lookup & Validator | Plunk',
|
||||
description: 'Free DMARC record checker. Validate your DMARC policy and get actionable advice to protect your domain from spoofing.',
|
||||
url: 'https://www.useplunk.com/tools/dmarc-checker',
|
||||
images: [{url: 'https://www.useplunk.com/api/og?title=Free+DMARC+Record+Checker&tag=Tool', alt: 'Plunk DMARC Checker', width: 1200, height: 630}],
|
||||
}}
|
||||
/>
|
||||
|
||||
<Navbar />
|
||||
|
||||
<div className={`${display.variable} ${body.variable} ${mono.variable}`}>
|
||||
<main className={'text-neutral-800'}>
|
||||
{/* ========== HERO ========== */}
|
||||
<section className={'relative overflow-hidden'}>
|
||||
<div
|
||||
aria-hidden
|
||||
className={'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#eeeeee_1px,transparent_1px),linear-gradient(to_bottom,#eeeeee_1px,transparent_1px)] bg-[size:6rem_6rem] [mask-image:radial-gradient(ellipse_70%_60%_at_50%_30%,#000_40%,transparent_95%)]'}
|
||||
/>
|
||||
|
||||
<div className={'mx-auto max-w-[88rem] px-6 pb-24 pt-20 sm:px-10 sm:pt-28 lg:pb-36'}>
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 8}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.5, ease: [0.22, 1, 0.36, 1]}}
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={'mb-16 flex items-center justify-between border-t border-neutral-900/90 pt-4 text-[11px] uppercase tracking-[0.18em] text-neutral-700 sm:mb-24'}
|
||||
>
|
||||
<span className={'font-medium text-neutral-900'}>§ T-05 — Tool</span>
|
||||
<Link href="/tools" className={'text-neutral-500 transition hover:text-neutral-900'}>
|
||||
← All tools
|
||||
</Link>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 16}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mx-auto max-w-5xl text-center'}
|
||||
>
|
||||
<h1
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={'text-[clamp(2.5rem,7vw,6.5rem)] font-extrabold leading-[0.92] tracking-[-0.04em] text-neutral-900'}
|
||||
>
|
||||
DMARC record
|
||||
<br />
|
||||
checker
|
||||
</h1>
|
||||
<p className={'mx-auto mt-8 max-w-2xl text-lg leading-relaxed text-neutral-600 sm:text-xl'}>
|
||||
Look up and validate your domain's DMARC record. Understand your current policy, reporting
|
||||
configuration, and get clear advice to progress toward full enforcement.
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ========== TOOL ========== */}
|
||||
<section className={'mx-auto max-w-[88rem] px-6 py-16 sm:px-10 sm:py-20'}>
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 20}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mx-auto max-w-2xl'}
|
||||
>
|
||||
<div className={'overflow-hidden rounded-[20px] border border-neutral-200 bg-white'}>
|
||||
<div className={'border-b border-neutral-200 px-8 py-5'}>
|
||||
<div className={'flex items-center gap-3'}>
|
||||
<ShieldCheck className={'h-4 w-4 text-neutral-500'} strokeWidth={1.5} />
|
||||
<span
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={'text-[11px] uppercase tracking-[0.18em] text-neutral-500'}
|
||||
>
|
||||
DMARC record lookup
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleCheck} className={'p-8'}>
|
||||
<div className={'space-y-4'}>
|
||||
<div>
|
||||
<label htmlFor="domain" className={'mb-2 block text-sm font-medium text-neutral-900'}>
|
||||
Domain name <span className={'text-red-500'}>*</span>
|
||||
</label>
|
||||
<Input
|
||||
id="domain"
|
||||
type="text"
|
||||
value={domain}
|
||||
onChange={e => setDomain(e.target.value)}
|
||||
placeholder="example.com"
|
||||
required
|
||||
className={'w-full'}
|
||||
/>
|
||||
<p className={'mt-1.5 text-xs text-neutral-400'}>Enter the domain without http:// or www.</p>
|
||||
</div>
|
||||
<Button type="submit" className={'w-full gap-2'} disabled={loading}>
|
||||
<ShieldCheck className={'h-4 w-4'} />
|
||||
{loading ? 'Looking up DMARC record…' : 'Check DMARC record'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{result && (
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 16}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.5, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mt-6 space-y-4'}
|
||||
>
|
||||
{!result.found ? (
|
||||
<div className={'rounded-[20px] border border-red-100 bg-red-50 p-8'}>
|
||||
<div className={'flex items-start gap-3'}>
|
||||
<XCircle className={'mt-0.5 h-5 w-5 shrink-0 text-red-500'} />
|
||||
<div>
|
||||
<p className={'font-semibold text-red-900'}>No DMARC record found</p>
|
||||
<p className={'mt-1 text-sm text-red-700'}>
|
||||
{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.`}
|
||||
</p>
|
||||
<p className={'mt-3 text-sm font-medium text-red-800'}>
|
||||
Add a TXT record to <span style={{fontFamily: 'var(--font-mono)'}}>_dmarc.{result.domain}</span>:
|
||||
</p>
|
||||
<code
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={'mt-2 block rounded-lg bg-red-100 px-4 py-3 text-xs text-red-900 break-all'}
|
||||
>
|
||||
{`v=DMARC1; p=none; rua=mailto:dmarc@${result.domain}`}
|
||||
</code>
|
||||
<p className={'mt-2 text-xs text-red-600'}>Start with p=none to monitor, then progress to quarantine and reject.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Raw record */}
|
||||
<div className={'rounded-[20px] border border-neutral-200 bg-white p-8'}>
|
||||
<p
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={'mb-2 text-[11px] uppercase tracking-[0.18em] text-neutral-400'}
|
||||
>
|
||||
Raw record — _dmarc.{result.domain}
|
||||
</p>
|
||||
<code
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={'block break-all rounded-lg bg-neutral-50 px-4 py-3 text-xs text-neutral-700'}
|
||||
>
|
||||
{result.record}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
{analysis && (
|
||||
<>
|
||||
{/* Grade */}
|
||||
<div className={'rounded-[20px] border border-neutral-200 bg-white p-8'}>
|
||||
<div className={'flex flex-col items-center gap-4 text-center'}>
|
||||
<GradeBadge grade={analysis.grade} policy={analysis.policy} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className={'rounded-[20px] border border-neutral-200 bg-white p-8'}>
|
||||
<h3
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={'mb-6 text-lg font-bold text-neutral-900'}
|
||||
>
|
||||
Record tags
|
||||
</h3>
|
||||
<div className={'space-y-2'}>
|
||||
{Object.entries(result.tags).map(([key, value]) => (
|
||||
<div key={key} className={'flex items-start gap-3 rounded-lg border border-neutral-100 bg-neutral-50 px-4 py-3'}>
|
||||
<div className={'flex min-w-0 flex-1 items-center gap-3'}>
|
||||
<span style={{fontFamily: 'var(--font-mono)'}} className={'shrink-0 text-xs font-bold text-neutral-700'}>
|
||||
{key}=
|
||||
</span>
|
||||
{key === 'p' || key === 'sp' ? (
|
||||
<PolicyBadge policy={value} />
|
||||
) : (
|
||||
<span style={{fontFamily: 'var(--font-mono)'}} className={'truncate text-xs text-neutral-500'}>
|
||||
{value}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{TAG_DESCRIPTIONS[key] && (
|
||||
<span className={'shrink-0 text-xs text-neutral-400'}>{TAG_DESCRIPTIONS[key]}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Analysis */}
|
||||
<div className={'rounded-[20px] border border-neutral-200 bg-white p-8'}>
|
||||
<h3
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={'mb-6 text-lg font-bold text-neutral-900'}
|
||||
>
|
||||
Analysis & recommendations
|
||||
</h3>
|
||||
<ul className={'space-y-3'}>
|
||||
{analysis.issues.map((issue, i) => (
|
||||
<li key={i} className={'flex items-start gap-3'}>
|
||||
{issue.type === 'pass' ? (
|
||||
<CheckCircle className={'mt-0.5 h-5 w-5 shrink-0 text-green-600'} />
|
||||
) : issue.type === 'warning' ? (
|
||||
<AlertTriangle className={'mt-0.5 h-5 w-5 shrink-0 text-amber-500'} />
|
||||
) : (
|
||||
<XCircle className={'mt-0.5 h-5 w-5 shrink-0 text-red-500'} />
|
||||
)}
|
||||
<div>
|
||||
<p className={'text-sm font-medium text-neutral-900'}>{issue.label}</p>
|
||||
<p className={'mt-0.5 text-xs text-neutral-500'}>{issue.detail}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
{/* ========== EDUCATION ========== */}
|
||||
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-28 sm:px-10 sm:py-36'}>
|
||||
<SectionHeader
|
||||
number={'01'}
|
||||
label={'DMARC explained'}
|
||||
title={'From monitoring to full enforcement.'}
|
||||
subtitle={'DMARC is a journey. Start with none, build confidence, then enforce.'}
|
||||
/>
|
||||
|
||||
<div className={'mt-20 grid gap-6 sm:grid-cols-3'}>
|
||||
{[
|
||||
{
|
||||
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) => (
|
||||
<motion.div
|
||||
key={item.step}
|
||||
initial={{opacity: 0, y: 16}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.5, delay: i * 0.08, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={`flex flex-col gap-5 rounded-[20px] border-2 bg-white p-8 ${item.cls}`}
|
||||
>
|
||||
<div className={'flex items-center justify-between'}>
|
||||
<span
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={'text-[11px] uppercase tracking-[0.18em] text-neutral-400'}
|
||||
>
|
||||
Step {item.step}
|
||||
</span>
|
||||
<code
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={'rounded bg-neutral-100 px-2 py-0.5 text-xs text-neutral-700'}
|
||||
>
|
||||
{item.policy}
|
||||
</code>
|
||||
</div>
|
||||
<h3
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={'text-2xl font-bold tracking-[-0.02em] text-neutral-900'}
|
||||
>
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className={'text-sm leading-relaxed text-neutral-600'}>{item.body}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ========== CTA ========== */}
|
||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-32 sm:px-10 sm:py-40'}>
|
||||
<div className={'flex flex-col items-start gap-12 lg:flex-row lg:items-end lg:justify-between'}>
|
||||
<motion.h2
|
||||
initial={{opacity: 0, y: 16}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}}
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={'text-[clamp(2.5rem,7vw,6rem)] font-extrabold leading-[0.95] tracking-[-0.035em]'}
|
||||
>
|
||||
Email that reaches the inbox.
|
||||
</motion.h2>
|
||||
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 16}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.9, delay: 0.15, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'flex max-w-md flex-col gap-6'}
|
||||
>
|
||||
<p className={'text-base text-neutral-300 sm:text-lg'}>
|
||||
Plunk walks you through SPF, DKIM, and DMARC setup and monitors your sending reputation over time.
|
||||
</p>
|
||||
<div className={'flex flex-wrap gap-3'}>
|
||||
<motion.a
|
||||
whileHover={{scale: 1.015}}
|
||||
whileTap={{scale: 0.985}}
|
||||
href={`${DASHBOARD_URI}/auth/signup`}
|
||||
className={'inline-flex items-center gap-2 rounded-full bg-white px-7 py-3.5 text-sm font-semibold text-neutral-900 transition hover:bg-neutral-100'}
|
||||
>
|
||||
Start with Plunk
|
||||
<ArrowRight className={'h-4 w-4'} />
|
||||
</motion.a>
|
||||
<Link
|
||||
href="/guides/what-is-dmarc"
|
||||
className={'inline-flex items-center gap-2 rounded-full border border-neutral-700 px-7 py-3.5 text-sm font-semibold text-white transition hover:border-white'}
|
||||
>
|
||||
What is DMARC?
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<FAQSection faqs={faqs} schemaId="faq-dmarc-checker" />
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<>
|
||||
<NextSeo
|
||||
title="Free Email Tools | Spam Checker, Email Validator & More | Plunk"
|
||||
description="Free email developer tools: spam checker, email address validator, and markdown-to-email converter. No sign-up required."
|
||||
title="Free Email Tools | SPF, DMARC & DKIM Checker, Spam Checker & More | Plunk"
|
||||
description="Free email developer tools: SPF checker, DMARC checker, DKIM checker, spam checker, email address validator, and markdown-to-email converter. No sign-up required."
|
||||
canonical="https://www.useplunk.com/tools"
|
||||
openGraph={{
|
||||
title: 'Free Email Tools | Spam Checker, Email Validator & More | Plunk',
|
||||
title: 'Free Email Tools | SPF, DMARC & DKIM Checker, Spam Checker & More | Plunk',
|
||||
description:
|
||||
'Free email developer tools: spam checker, email address validator, and markdown-to-email converter. No sign-up required.',
|
||||
'Free email developer tools: SPF checker, DMARC checker, DKIM checker, spam checker, email address validator, and markdown-to-email converter. No sign-up required.',
|
||||
url: 'https://www.useplunk.com/tools',
|
||||
images: [{url: 'https://www.useplunk.com/api/og?title=Free+Email+Developer+Tools&tag=Tool', alt: 'Plunk Email Tools', width: 1200, height: 630}],
|
||||
}}
|
||||
@@ -111,7 +132,7 @@ export default function ToolsIndex() {
|
||||
developer tools
|
||||
</h1>
|
||||
<p className={'mx-auto mt-8 max-w-2xl text-lg leading-relaxed text-neutral-600 sm:text-xl'}>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<div className={'mt-10 flex flex-wrap justify-center gap-3'}>
|
||||
|
||||
@@ -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 (
|
||||
<div className={`flex flex-col items-center gap-1 rounded-2xl border-2 px-8 py-5 ${cls}`}>
|
||||
<span style={{fontFamily: 'var(--font-display)'}} className={'text-2xl font-extrabold'}>{label}</span>
|
||||
<span className={'text-xs font-medium opacity-80'}>{sub}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<SpfLookupResult> {
|
||||
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<SpfLookupResult | null>(null);
|
||||
const [parsed, setParsed] = useState<ParsedSpf | null>(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 (
|
||||
<>
|
||||
<NextSeo
|
||||
title="SPF Record Checker | Free SPF Lookup & Validator | Plunk"
|
||||
description="Free SPF record checker. Look up and validate your domain's SPF record, check for misconfigurations, and get actionable advice to improve email deliverability."
|
||||
canonical="https://www.useplunk.com/tools/spf-checker"
|
||||
openGraph={{
|
||||
title: 'SPF Record Checker | Free SPF Lookup & Validator | Plunk',
|
||||
description: 'Free SPF record checker. Look up and validate your domain SPF record and get actionable deliverability advice.',
|
||||
url: 'https://www.useplunk.com/tools/spf-checker',
|
||||
images: [{url: 'https://www.useplunk.com/api/og?title=Free+SPF+Record+Checker&tag=Tool', alt: 'Plunk SPF Checker', width: 1200, height: 630}],
|
||||
}}
|
||||
/>
|
||||
|
||||
<Navbar />
|
||||
|
||||
<div className={`${display.variable} ${body.variable} ${mono.variable}`}>
|
||||
<main className={'text-neutral-800'}>
|
||||
{/* ========== HERO ========== */}
|
||||
<section className={'relative overflow-hidden'}>
|
||||
<div
|
||||
aria-hidden
|
||||
className={'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#eeeeee_1px,transparent_1px),linear-gradient(to_bottom,#eeeeee_1px,transparent_1px)] bg-[size:6rem_6rem] [mask-image:radial-gradient(ellipse_70%_60%_at_50%_30%,#000_40%,transparent_95%)]'}
|
||||
/>
|
||||
|
||||
<div className={'mx-auto max-w-[88rem] px-6 pb-24 pt-20 sm:px-10 sm:pt-28 lg:pb-36'}>
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 8}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.5, ease: [0.22, 1, 0.36, 1]}}
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={'mb-16 flex items-center justify-between border-t border-neutral-900/90 pt-4 text-[11px] uppercase tracking-[0.18em] text-neutral-700 sm:mb-24'}
|
||||
>
|
||||
<span className={'font-medium text-neutral-900'}>§ T-04 — Tool</span>
|
||||
<Link href="/tools" className={'text-neutral-500 transition hover:text-neutral-900'}>
|
||||
← All tools
|
||||
</Link>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 16}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mx-auto max-w-5xl text-center'}
|
||||
>
|
||||
<h1
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={'text-[clamp(2.5rem,7vw,6.5rem)] font-extrabold leading-[0.92] tracking-[-0.04em] text-neutral-900'}
|
||||
>
|
||||
SPF record
|
||||
<br />
|
||||
checker
|
||||
</h1>
|
||||
<p className={'mx-auto mt-8 max-w-2xl text-lg leading-relaxed text-neutral-600 sm:text-xl'}>
|
||||
Look up and validate your domain's SPF record. Get a full breakdown of your sending policy and
|
||||
catch misconfigurations before they hurt deliverability.
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ========== TOOL ========== */}
|
||||
<section className={'mx-auto max-w-[88rem] px-6 py-16 sm:px-10 sm:py-20'}>
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 20}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mx-auto max-w-2xl'}
|
||||
>
|
||||
<div className={'overflow-hidden rounded-[20px] border border-neutral-200 bg-white'}>
|
||||
<div className={'border-b border-neutral-200 px-8 py-5'}>
|
||||
<div className={'flex items-center gap-3'}>
|
||||
<Shield className={'h-4 w-4 text-neutral-500'} strokeWidth={1.5} />
|
||||
<span
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={'text-[11px] uppercase tracking-[0.18em] text-neutral-500'}
|
||||
>
|
||||
SPF record lookup
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleCheck} className={'p-8'}>
|
||||
<div className={'space-y-4'}>
|
||||
<div>
|
||||
<label htmlFor="domain" className={'mb-2 block text-sm font-medium text-neutral-900'}>
|
||||
Domain name <span className={'text-red-500'}>*</span>
|
||||
</label>
|
||||
<Input
|
||||
id="domain"
|
||||
type="text"
|
||||
value={domain}
|
||||
onChange={e => setDomain(e.target.value)}
|
||||
placeholder="example.com"
|
||||
required
|
||||
className={'w-full'}
|
||||
/>
|
||||
<p className={'mt-1.5 text-xs text-neutral-400'}>Enter the domain without http:// or www.</p>
|
||||
</div>
|
||||
<Button type="submit" className={'w-full gap-2'} disabled={loading}>
|
||||
<Shield className={'h-4 w-4'} />
|
||||
{loading ? 'Looking up SPF record…' : 'Check SPF record'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{result && (
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 16}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.5, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mt-6 space-y-4'}
|
||||
>
|
||||
{!result.found ? (
|
||||
<div className={'rounded-[20px] border border-red-100 bg-red-50 p-8'}>
|
||||
<div className={'flex items-start gap-3'}>
|
||||
<XCircle className={'mt-0.5 h-5 w-5 shrink-0 text-red-500'} />
|
||||
<div>
|
||||
<p className={'font-semibold text-red-900'}>No SPF record found</p>
|
||||
<p className={'mt-1 text-sm text-red-700'}>
|
||||
{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.`}
|
||||
</p>
|
||||
<p className={'mt-3 text-sm font-medium text-red-800'}>
|
||||
Add a TXT record to <span style={{fontFamily: 'var(--font-mono)'}}>{result.domain}</span>:
|
||||
</p>
|
||||
<code
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={'mt-2 block rounded-lg bg-red-100 px-4 py-3 text-xs text-red-900'}
|
||||
>
|
||||
v=spf1 include:your-email-provider.com -all
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{result.multiple && (
|
||||
<div className={'rounded-[20px] border border-red-100 bg-red-50 p-6'}>
|
||||
<div className={'flex items-start gap-3'}>
|
||||
<XCircle className={'mt-0.5 h-5 w-5 shrink-0 text-red-500'} />
|
||||
<div>
|
||||
<p className={'font-semibold text-red-900'}>Multiple SPF records detected</p>
|
||||
<p className={'mt-1 text-sm text-red-700'}>
|
||||
Having more than one SPF record causes a PermError, breaking SPF for your domain. Merge all mechanisms into a single v=spf1 record.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Raw record */}
|
||||
<div className={'rounded-[20px] border border-neutral-200 bg-white p-8'}>
|
||||
<p
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={'mb-2 text-[11px] uppercase tracking-[0.18em] text-neutral-400'}
|
||||
>
|
||||
Raw record — {result.domain}
|
||||
</p>
|
||||
<code
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={'block break-all rounded-lg bg-neutral-50 px-4 py-3 text-xs text-neutral-700'}
|
||||
>
|
||||
{result.records[0]}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
{parsed && (
|
||||
<>
|
||||
{/* Grade */}
|
||||
<div className={'rounded-[20px] border border-neutral-200 bg-white p-8'}>
|
||||
<div className={'flex flex-col items-center gap-4 text-center'}>
|
||||
<GradeBadge grade={parsed.grade} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mechanisms */}
|
||||
<div className={'rounded-[20px] border border-neutral-200 bg-white p-8'}>
|
||||
<h3
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={'mb-6 text-lg font-bold text-neutral-900'}
|
||||
>
|
||||
Sending mechanisms
|
||||
</h3>
|
||||
<div className={'space-y-2'}>
|
||||
{parsed.mechanisms.map((m, i) => {
|
||||
const {label, cls} = qualifierLabel(m.qualifier);
|
||||
return (
|
||||
<div key={i} className={'flex items-center gap-3 rounded-lg border border-neutral-100 bg-neutral-50 px-4 py-3'}>
|
||||
<span className={`shrink-0 rounded border px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide ${cls}`}>
|
||||
{label}
|
||||
</span>
|
||||
<span style={{fontFamily: 'var(--font-mono)'}} className={'text-xs font-medium text-neutral-700'}>
|
||||
{m.type}
|
||||
</span>
|
||||
{m.value && (
|
||||
<span style={{fontFamily: 'var(--font-mono)'}} className={'truncate text-xs text-neutral-500'}>
|
||||
{m.value}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{parsed.allMechanism && (
|
||||
<div className={'flex items-center gap-3 rounded-lg border border-neutral-200 bg-neutral-100 px-4 py-3'}>
|
||||
<span
|
||||
className={`shrink-0 rounded border px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide ${qualifierLabel(parsed.allMechanism[0] ?? '+').cls}`}
|
||||
>
|
||||
{qualifierLabel(parsed.allMechanism[0] ?? '+').label}
|
||||
</span>
|
||||
<span style={{fontFamily: 'var(--font-mono)'}} className={'text-xs font-semibold text-neutral-700'}>
|
||||
all
|
||||
</span>
|
||||
<span className={'text-xs text-neutral-500'}>— default for all other senders</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Issues */}
|
||||
<div className={'rounded-[20px] border border-neutral-200 bg-white p-8'}>
|
||||
<h3
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={'mb-6 text-lg font-bold text-neutral-900'}
|
||||
>
|
||||
Analysis & recommendations
|
||||
</h3>
|
||||
<ul className={'space-y-3'}>
|
||||
{parsed.issues.map((issue, i) => (
|
||||
<li key={i} className={'flex items-start gap-3'}>
|
||||
{issue.type === 'pass' ? (
|
||||
<CheckCircle className={'mt-0.5 h-5 w-5 shrink-0 text-green-600'} />
|
||||
) : issue.type === 'warning' ? (
|
||||
<AlertTriangle className={'mt-0.5 h-5 w-5 shrink-0 text-amber-500'} />
|
||||
) : (
|
||||
<XCircle className={'mt-0.5 h-5 w-5 shrink-0 text-red-500'} />
|
||||
)}
|
||||
<div>
|
||||
<p className={'text-sm font-medium text-neutral-900'}>{issue.label}</p>
|
||||
<p className={'mt-0.5 text-xs text-neutral-500'}>{issue.detail}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
{/* ========== EDUCATION ========== */}
|
||||
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-28 sm:px-10 sm:py-36'}>
|
||||
<SectionHeader
|
||||
number={'01'}
|
||||
label={'SPF explained'}
|
||||
title={'How SPF protects your domain.'}
|
||||
subtitle={'SPF is the first line of defence against email spoofing and phishing.'}
|
||||
/>
|
||||
|
||||
<div className={'mt-20 grid gap-6 sm:grid-cols-2 lg:grid-cols-3'}>
|
||||
{[
|
||||
{
|
||||
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) => (
|
||||
<motion.div
|
||||
key={item.title}
|
||||
initial={{opacity: 0, y: 16}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.5, delay: i * 0.05, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'flex flex-col gap-4 rounded-[20px] border border-neutral-200 bg-white p-8'}
|
||||
>
|
||||
<h3
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={'text-xl font-bold tracking-[-0.02em] text-neutral-900'}
|
||||
>
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className={'text-sm leading-relaxed text-neutral-600'}>{item.body}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ========== CTA ========== */}
|
||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-32 sm:px-10 sm:py-40'}>
|
||||
<div className={'flex flex-col items-start gap-12 lg:flex-row lg:items-end lg:justify-between'}>
|
||||
<motion.h2
|
||||
initial={{opacity: 0, y: 16}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}}
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={'text-[clamp(2.5rem,7vw,6rem)] font-extrabold leading-[0.95] tracking-[-0.035em]'}
|
||||
>
|
||||
SPF, DKIM, and DMARC — handled.
|
||||
</motion.h2>
|
||||
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 16}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.9, delay: 0.15, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'flex max-w-md flex-col gap-6'}
|
||||
>
|
||||
<p className={'text-base text-neutral-300 sm:text-lg'}>
|
||||
Plunk guides you through domain authentication setup and monitors your sending reputation.
|
||||
Start free, no credit card required.
|
||||
</p>
|
||||
<div className={'flex flex-wrap gap-3'}>
|
||||
<motion.a
|
||||
whileHover={{scale: 1.015}}
|
||||
whileTap={{scale: 0.985}}
|
||||
href={`${DASHBOARD_URI}/auth/signup`}
|
||||
className={'inline-flex items-center gap-2 rounded-full bg-white px-7 py-3.5 text-sm font-semibold text-neutral-900 transition hover:bg-neutral-100'}
|
||||
>
|
||||
Start with Plunk
|
||||
<ArrowRight className={'h-4 w-4'} />
|
||||
</motion.a>
|
||||
<Link
|
||||
href="/guides/what-is-spf"
|
||||
className={'inline-flex items-center gap-2 rounded-full border border-neutral-700 px-7 py-3.5 text-sm font-semibold text-white transition hover:border-white'}
|
||||
>
|
||||
What is SPF?
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<FAQSection faqs={faqs} schemaId="faq-spf-checker" />
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user