DEV Community

Cover image for I tested my browser-based email checkers against dkimpy, checkdmarc and pyspf: 12,618 results, 76 disagreements
Jeremiah Say
Jeremiah Say

Posted on

I tested my browser-based email checkers against dkimpy, checkdmarc and pyspf: 12,618 results, 76 disagreements

I built five email security tools for Toggle9 that run in the browser: a DKIM checker, a DMARC checker, an SPF checker, an MX lookup and an email header analyzer. There's no backend. DNS queries go from your browser to Cloudflare's public resolver, and DKIM signatures are verified with WebCrypto.

That raises an obvious question: why would anyone trust a JavaScript reimplementation of RFC 6376? There are established open-source libraries for all of this: dkimpy for DKIM, checkdmarc for DMARC and MX, pyspf (the reference implementation of RFC 7208) for SPF, and mail-parser for headers.

So I ran each tool against one of them, on real data, and read every result where they disagreed.

The scoreboard

Tool Reference Test set Compared Same answer
DKIM checker dkimpy 1.1.8 510 real emails from 18 public mailing lists 623 signatures 623
Email header analyzer mail-parser 4.8.0 The same 510 emails 2,333 hops 2,333
DMARC checker checkdmarc 5.17.2 747 popular domains (Tranco top 500 + top 250 .uk) 705 domains 692 (98.2%)
SPF checker pyspf 2.0.14 The first 200 of those domains that publish SPF 454 IP checks 451
MX lookup checkdmarc 5.17.2 746 domains 8,503 checks 8,443

That's 12,618 results and 76 disagreements. The benchmarks ran on 22 and 23 October 2026. The rest of this post is about those 76.

How a browser does DNS

Every tool here needs DNS: TXT records for SPF, DMARC and DKIM keys, MX records, and TLSA and PTR records. Browsers can't send raw DNS queries, but they can use DNS over HTTPS. Cloudflare's resolver answers JSON:

const r = await fetch(`https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(name)}&type=${type}`, {
  headers: { accept: 'application/dns-json' },
  referrerPolicy: 'no-referrer',
  credentials: 'omit',
});
const j = await r.json();
// j.Status: 0 = NOERROR, 3 = NXDOMAIN. j.Answer: [{ name, type, TTL, data }]
Enter fullscreen mode Exit fullscreen mode

The real resolver adds a cache per check, a limit of six queries in flight, a 6-second timeout and one retry. An SPF check can fan out into dozens of lookups, so the cache matters more than you'd expect.

For DMARC, SPF and MX, my tools and the reference libraries queried the same resolver (1.1.1.1), so a difference can't be blamed on two resolvers seeing different DNS.

DKIM: 623 of 623, and why 65 signatures "failed"

DKIM verification is canonicalization (RFC 6376 section 3.4), then a hash, then a signature check. The relaxed header rule fits in a few lines:

export function canonHeader(raw, how) {
  if (how === 'simple') return raw + '\r\n';
  const i = raw.indexOf(':');
  const name = raw.slice(0, i).replace(/[ \t]+$/, '').toLowerCase();
  const val = raw.slice(i + 1).replace(/\r\n(?=[ \t])/g, '').replace(/[ \t]+/g, ' ').replace(/^ | $/g, '');
  return `${name}:${val}\r\n`;
}
Enter fullscreen mode Exit fullscreen mode

WebCrypto handles both DKIM signing algorithms, RSA and Ed25519 (simplified):

if (alg === 'ed25519-sha256')
  return crypto.subtle.importKey('raw', pubKey, { name: 'Ed25519' }, false, ['verify']);
return crypto.subtle.importKey('spki', spki, { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, false, ['verify']);
Enter fullscreen mode Exit fullscreen mode

The test set was 510 recent posts to 18 public mailing lists, taken from lore.kernel.org's public git mirrors. They had 623 signatures from 57 domains; two of the signatures used Ed25519. dkimpy and my checker gave the same pass or fail on all 623.

The useful part was the 65 signatures that failed in both. A checker that just says "fail" isn't much help, so mine reads the receiving server's own Authentication-Results header (RFC 8601) to work out when the email changed:

  • 62 had passed when the email arrived, according to the receiving server. They were changed afterwards. In 60 of them, a signed List-Unsubscribe header was missing: the archive removes it before it publishes the email.
  • 3 had already failed on arrival. They were changed on the way.

So most of the "broken DKIM" in that set wasn't broken at delivery. It's what an archive does to an email. That's the line the tool shows: "The email was fine when it was delivered."

Email headers: 2,333 of 2,333

The header analyzer turns Received: headers into a route: which server handed the email to which, when, and how long each hop took. mail-parser parses the same headers. On the same 510 emails, both found the same number of hops in every email, and all 2,333 hops matched on server, time and delay.

DMARC: the benchmark caught my bug

The first DMARC run agreed on everything for 682 of 705 domains (96.7%). I read every difference, and the biggest group was mine to fix.

My checker called it an error when an SPF record reached the same include: from two branches. msn.com, doi.org, grammarly.com and 7 others do this. I'd treated a repeat as a loop. Nothing in RFC 7208 makes a repeated include an error. Real loops get caught by the 10-lookup limit, and a repeat just counts against that limit. The fix: follow the repeat, count it again, and show a warning.

The second run agreed on 692 of 705 (98.2%). I read the 13 that were left by hand, against the RFCs:

Domains What happened Who's right
www.gov.uk, legislation.gov.uk, account.gov.uk, data.gov.uk No DMARC record of their own. The parent gov.uk record's sp=none applies to subdomains (RFC 9989). Mine
gcdn.co, thomsonreuters.co.uk SPF includes a name that doesn't exist in DNS, which is a permerror (RFC 7208 section 5.2) Mine
weather.com An unrelated TXT record at _dmarc alongside the real one; checkdmarc rejects it Mine
autodesk.com An = in the local part of a ruf address, which the standard allows Mine
alibabadns.com v=SPF1 in capitals. The ABNF is case-insensitive. Mine
globo.com rua=mailto: dmarc@… with a space. Both call the address invalid; checkdmarc then rejects the whole record, but RFC 9989 keeps p=none. Mine
iiko.it, who.int 15 and 17 DNS lookups (iiko.it confirmed by hand through 1.1.1.1). checkdmarc stops counting early. Mine
ozon.ru checkdmarc timed out Neither

"Mine" is my reading of the standards, written up with the RFC section each time. You don't have to take my word for it: the domains are real, so you can check them.

None of this makes checkdmarc a bad library. Rejecting a record the standards would accept is a reasonable choice for a tool that audits your own domain. Mine follows the standards' wording, because its job is to explain what a receiver should do with the record.

SPF: 451 of 454, and the 3 that weren't real

For each of 200 domains, I tested up to four IP addresses: the domain's own mail server, an address inside a range its record lists, an address reserved for documentation that never sends email, and a Google sending address for domains that include Google.

Three results differed: youtube.com, archive.org and youtube-nocookie.com, all for the same Google address. Large providers' DNS answers rotate, so an address can be listed in one lookup and missing from the next. Run again side by side, both tools agreed on all three.

MX: 8,443 of 8,503, and the rotation problem

The MX lookup checks a lot per domain: the MX hosts, their addresses, reverse DNS (PTR) and whether it points back, TLSA (DANE) records, MTA-STS and TLS reporting. Across 746 domains that came to 8,503 comparisons.

MX host sets matched on all 746 domains. MTA-STS and TLS reporting matched on all 746 too.

The addresses were the hard part. 738 of the 1,246 mail servers answered with a different few of their addresses each time. Google, Microsoft and Proofpoint spread their load that way, so two lookups a few minutes apart rarely agree, from any tool. Comparing those sets directly would only measure luck.

So the benchmark asks each server again over a few seconds. If those lookups turn up every address either tool saw, the server counts as rotating and isn't compared. That left 508 servers with steady answers, and 458 matched exactly. Of the 50 that didn't, 47 are Google or Proofpoint servers of the same rotating kind, whose other address didn't turn up in those few seconds.

The remaining 10 were differences in records. On 26 October I checked each one by hand through 1.1.1.1. In every case, the record my tool found is there:

  • TLSA: mx.ietf.org, two comcast.net servers and cm.com's Microsoft server publish TLSA records. checkdmarc reported none.
  • PTR: three servers (nic.ru, debian.org, ebi.ac.uk) have reverse DNS. checkdmarc reported none.
  • Forward-confirmed PTR: two huawei.com names do resolve back to their addresses.
  • Addresses: one Outlook server has IPv6 addresses that checkdmarc didn't list.

What I'd tell anyone writing a benchmark like this

  1. A reference library is a witness, not a judge. If you count disagreements as your failures, you end up copying the library's choices. Read each one against the spec.
  2. Read every difference, especially at the start. My first DMARC run had a real bug hiding behind a 96.7% score that looked fine.
  3. Handle rotating answers before comparing. Otherwise you're measuring DNS load balancing, not your code.
  4. Tie the results to the code. Each tool's test file hashes its engine and compares that with the hash stored in its benchmark result. Change the DKIM verifier without re-running the benchmark, and npm test fails:
test('dkim-bench.json was made by the engine that ships', () => {
  const b = JSON.parse(readFileSync(new URL('dkim-bench.json', dir), 'utf8'));
  const h = createHash('sha256')
    .update(readFileSync(new URL('verify.js', dir)))
    .update(readFileSync(new URL('compute.js', dir)))
    .digest('hex').slice(0, 16);
  assert.equal(b.engine, h);
});
Enter fullscreen mode Exit fullscreen mode

So the numbers quoted on each tool's page can't silently drift away from the code.

Try them

All five run in your browser, with no account. When you check a domain, only the DNS names go to Cloudflare's public resolver. Headers and emails you paste are read on your device.

If you check DMARC for a living, I'd like to hear where you think I've read RFC 9989 wrong, especially on those 13.

Top comments (0)