I run a visa agency in Bali. Scrapers and small data tools on Apify are the other half of my week, and one of them cleans e-mail lists: syntax, a live MX lookup, throwaway domains, role addresses, typo hints, duplicate spellings, a 0–100 score. No mail is ever sent.

The marketing for a verifier writes itself ("remove bounces!"); its honest performance is a number you have to go and measure. So I measured it on a list I did not curate: 1,000 maintainer and author addresses taken straight out of the npm registry. 995 came back deliverable — and of the handful that did not, three were my own bug.

The test list

The npm registry is a decent stand-in for a scraped B2B lead list: real humans, real company domains, addresses typed by hand years ago and never touched since. Every package document carries maintainers[].email and sometimes author.email.

import json, urllib.request, urllib.parse def doc(name): url = f'https://registry.npmjs.org/{urllib.parse.quote(name, safe="@/")}' req = urllib.request.Request(url, headers={'User-Agent': 'research/1.0'}) return json.load(urllib.request.urlopen(req, timeout=25)) emails = {} for name in package_names: # 900 packages from /-/v1/search d = doc(name) for m in d.get('maintainers') or []: if m.get('email'): emails.setdefault(m['email'].lower(), name) a = d.get('author') if isinstance(a, dict) and a.get('email'): emails.setdefault(a['email'].lower(), name) 

900 packages across sixteen search terms produced 2,088 distinct addresses. I took a seeded random 1,000 of them — 332 unique domains between them, from gmail.com (352 addresses) down to one-off personal domains.

What came back

Two runs of the same 1,000 addresses, before and after the fix described below:

verdict before after why
deliverable 992 995 domain publishes mail servers
risky 6 3 domain has no MX and relies on its A record
undeliverable 2 2 users.noreply.github.com accepts no mail
unknown 0 0 no DNS timeouts in either run

Five rows out of a thousand are worth deleting. That is the real yield of a domain-level check on a list of addresses that people actively use, and anyone promising you more than that on a list like this is selling you a different product than the one they're describing.

The five, in full:

  • 1336118+wittlock@users.noreply.github.com and microsoftopensource@users.noreply.github.com — GitHub's noreply domain publishes no MX record. These are the addresses npm records when a publisher hides their real one; they are permanently undeliverable and they do turn up in scraped developer lists.
  • admin@iov.one, admin@web3api.dev, mail@substack.net — domains that exist, serve a website, publish no MX. Under RFC 5321 a sender then falls back to the A record, which is a web server with no mail daemon, so it's risky rather than undeliverable — but in practice: dead.
{ "email": "microsoftopensource@users.noreply.github.com", "emailStatus": "undeliverable", "emailReason": "domain_accepts_no_mail", "emailDomain": "users.noreply.github.com", "emailProvider": null, "emailMxHost": null, "emailScore": 0, "checkLevel": "domain_mx" } 

One caveat I only saw because I ran the same addresses twice: in a smaller run on the same day, that GitHub address came back domain_not_found instead of domain_accepts_no_mail — the resolver answered NXDOMAIN once and NODATA the other time. Both verdicts are "drop it", but if you branch your pipeline on emailReason rather than emailStatus, don't assume the reason is stable.

The three failures that were mine

The other three flagged rows, from the first run:

arvinx@foxmail.com risky possible_typo suggestion: hotmail.com meteor.lxy@foxmail.com risky possible_typo suggestion: hotmail.com lcz_1996@foxmail.com risky possible_typo suggestion: hotmail.com 

foxmail.com is Tencent's consumer mail service. It has tens of millions of users and this is its MX record:

foxmail.com -> mx3.qq.com, mx2.qq.com 

The typo check was a Levenshtein distance against a list of big providers, suggesting a correction at distance 1 or 2. foxmail.com → hotmail.com is exactly two substitutions (f→h, x→t). So three working Chinese addresses were labelled risky with a score of 25, because of a heuristic that never looked at whether the domain actually receives mail.

Three in a thousand is a 0.3% false-positive rate, and it lands on one country's users — the kind of bias you only find by running real data instead of your own example list. I checked which other real services sit within two edits of a big provider and confirmed each with a live MX lookup:

domain MX would have been "corrected" to
foxmail.com mx3.qq.com hotmail.com
hotmail.ca hotmail-com.olc.protection.outlook.com hotmail.com
yahoo.ca mta6.am0.yahoodns.net yahoo.com
email.com mx01.mail.com gmail.com
aim.com mx-aol.mail.gm0.yahoodns.net aol.com

And the control group — actual typo domains, looked up the same way:

gmial.com -> no MX (ENODATA) hotmial.com -> no MX (ENODATA) yaho.com -> no MX (ENODATA) smail.com -> no MX (ENODATA) gnail.com -> mx1.oweb.cn, mx2.oweb.cn <- a squat that does catch mail 

The distance metric was right about the shape of the string and wrong about the world. The fix is an exemption list of those five verified providers, applied before the distance check: on the identical 1,000 addresses, suggestions 3 → 0, risky 6 → 3, deliverable 992 → 995, while gnail.com still comes back risky / possible_typo.

The lesson generalises past my five domains: an edit-distance typo hint needs an allowlist of real services, and the cheapest source of truth for "is this a real mail domain" is the MX lookup you are already doing.

The byproduct that turned out more useful than the verdicts

Every row carries emailMxHost and a normalised emailProvider, and across 1,000 developer addresses that distribution says something a verdict column cannot:

mail host addresses domains
google 614 164
microsoft 182 28
other 168 118
proofpoint 14 6
zoho 6 6
mimecast 5 3
yahoo 5 2

61% of this list is filtered by Google — 352 addresses on gmail.com plus 262 company addresses whose MX is Google Workspace (aspmx.l.google.com, smtp.google.com). Another 159 sit behind Microsoft 365, and nineteen go through Proofpoint or Mimecast, the subset where a cold link is most likely to be rewritten or stripped.

Also: 394 of 1,000 are free mailboxes, 606 are on a company domain, and 35 are role accounts (admin@, hello@, info@) — all 35 of them on a company domain, none on a free one. Zero were disposable; npm maintainers are not sign-up spam.

Dedupe: real, and smaller than advertised

The verifier collapses spellings of one mailbox (emailNormalized) and points duplicates at each other (emailDuplicateOf). On this list:

  • 28 addresses carry a plus-tag, nearly all of them the corporate publishing convention: aprigogin+npm@google.com, aws-cfn-dev-ex+npm@amazon.com, appurva.murawat+npm@postman.com.
  • 143 of the 352 Gmail addresses have dots in the local part, which Gmail ignores.
  • And the number of actual collisions found was one: npmjs@shopify.com and npmjs+shopify-dep@shopify.com.
{ "email": "npmjs@shopify.com", "emailNormalized": "npmjs@shopify.com", "emailDuplicateOf": "npmjs+shopify-dep@shopify.com", "emailScore": 100 } 

Which is the honest version of a dedupe feature: on a source already unique by string, normalisation buys almost nothing. It earns its place when you merge two lists, or scrape the same company from three directories.

Speed, and the row you must filter out

1,000 addresses on 332 domains finished in under 4 seconds, because the DNS answer is cached per domain for the run — 332 lookups, not 1,000. The run also appends one free bookkeeping row, and it is not your data:

{"type": "notice", "notice": "Run summary: 1000 row(s) delivered, 0 free; charged email-checked x1000 = $0.63. ..."} 

Filter on type != "notice" before you count anything. (List price is $0.70 per 1,000 addresses, cheaper on paid Store plans; nothing is charged per run.)

Calling it is one request, and the input can be a list, a public CSV/Google Sheet link, or the dataset of whatever scraper collected the addresses in the first place:

curl -X POST "https://api.apify.com/v2/acts/lergassy~bulk-email-verifier/run-sync-get-dataset-items?token=$APIFY_TOKEN" \ -H 'Content-Type: application/json' \ -d '{"inputDatasetId":"<dataset-of-your-lead-scraper>", "emailField":"email, emails, contactEmail", "keep":"clean"}' 

keep: "clean" returns only deliverable addresses with one spelling per mailbox — and since only returned rows are billed, the rows it drops cost nothing.

When this is the wrong tool

It checks domains, not mailboxes. Confirming that one specific inbox exists needs an SMTP conversation on port 25, and outbound 25 is blocked on Apify's cloud as it is on most clouds — so deliverable here means well formed and the domain accepts mail, which every row states as checkLevel: "domain_mx". nobody-here-12345@gmail.com passes.

So it will not catch the former employee whose company domain is still alive, and it does not detect catch-all domains. For years-old B2B data where people have changed jobs, pay for a mailbox-level verifier. For fresh scraper output full of users.noreply.github.com, domains without mail servers, info@ addresses and three spellings of one inbox, this removes that in seconds for cents — and, as of this week, it no longer tells Tencent users to fix a perfectly good address.

The Actor is Bulk Email Verifier on Apify Store. Both 1,000-address runs quoted here came out of it an hour apart — two build numbers, and three false positives, between them.