diff --git a/apps/landing/src/components/Footer/Footer.tsx b/apps/landing/src/components/Footer/Footer.tsx index 306c0b3..9a6ae6a 100644 --- a/apps/landing/src/components/Footer/Footer.tsx +++ b/apps/landing/src/components/Footer/Footer.tsx @@ -87,6 +87,25 @@ export default function Footer() { + +

Checkers

+
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 + +
+
+ +
+
+
+ + setDomain(e.target.value)} + placeholder="example.com" + required + className={'w-full'} + /> +
+ +
+ + setSelector(e.target.value)} + placeholder="google" + required + className={'w-full'} + /> +

+ Not sure? Try a common selector below. +

+
+ + {/* Common selectors */} +
+

Common selectors

+
+ {COMMON_SELECTORS.map(s => ( + + ))} +
+
+ + +
+
+
+ + {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. +

+ )} +
+
+
+ ) : ( + <> + {/* Lookup host */} +
+

+ Record found at +

+ + {result.selector}._domainkey.{result.domain} + + + {result.record && ( + <> +

+ Raw record +

+ + {result.record.length > 200 + ? `${result.record.slice(0, 200)}… [${result.record.length - 200} more characters]` + : result.record} + + + )} +
+ + {analysis && ( + <> + {/* Grade */} +
+
+ +
+
+ + {/* Tags */} + {Object.keys(result.tags).length > 0 && ( +
+

+ Record tags +

+
+ {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 ( +
+
+
+ + {key}= + + + {displayValue} + +
+ {TAG_DESCRIPTIONS[key] && ( + {TAG_DESCRIPTIONS[key]} + )} +
+
+ ); + })} +
+
+ )} + + {/* Analysis */} +
+

+ Analysis & recommendations +

+
    + {analysis.issues.map((issue, i) => ( +
  • + {issue.type === 'pass' ? ( + + ) : issue.type === 'warning' ? ( + + ) : ( + + )} +
    +

    {issue.label}

    +

    {issue.detail}

    +
    +
  • + ))} +
+
+ + )} + + )} +
+ )} +
+
+ + {/* ========== EDUCATION ========== */} +
+
+ + +
+ {[ + { + 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? + +
+
+
+
+
+
+
+ + +