DEV Community

PennyData
PennyData

Posted on

Validate a list of 10,000 emails without SMTP for about $10

Before you import a lead list or send a campaign, you want to drop the junk: typos, fake domains, throwaway inboxes. Many verifiers do this by opening SMTP connections to each mail server, which is slow and often gets IPs blocked. A cheaper first pass skips SMTP and checks everything you can know from the address and DNS alone.

This post shows how to run that pass on a CSV or a list of up to 10,000 addresses with an Apify actor I built, Email Verifier & Validator (No SMTP). Disclosure: it's my actor and I earn a little when it's used. The checks themselves are plain DNS, so you could also reimplement them yourself.

What it checks (and what it doesn't)

Per address, with no SMTP probing:

  • Syntax (length limits, bad characters, internationalized domains)
  • DNS: MX records, with A/AAAA fallback, and null-MX detection
  • Disposable-domain list, role accounts (info@, sales@), free providers (gmail etc.)
  • Typo suggestions for common providers (yaho.com -> yahoo.com)

What it can't do: it cannot tell if the mailbox jane@company.com exists. valid: true means "well-formed, and the domain accepts mail". Catch-all domains, full mailboxes and made-up users on real domains all pass. If you need mailbox-level verification, you need an SMTP-based verifier (more expensive, and results are often "unknown" anyway). Use this as a cheap first filter.

Price

Pay-per-event: $0.001 per email checked (at time of writing), so 10,000 emails is about $10, 1,000 is about $1. Duplicates in your list are removed before billing. Addresses whose DNS lookup errored out (status unknown) are not charged; invalid-syntax rows are charged like any other row. Compute is included in the per-event price.

Input

{
  "emails": ["jane.doe@gmail.com", "bob@yaho.com", "temp@10minutemail.com", "bad@@example.com"],
  "concurrency": 10
}
Enter fullscreen mode Exit fullscreen mode

For a CSV, give a public URL instead (it uses the column whose header contains "email", or csvColumn):

{ "csvUrl": "https://example.com/leads.csv", "csvColumn": "email", "maxEmails": 10000 }
Enter fullscreen mode Exit fullscreen mode

maxEmails defaults to 10,000 (maximum 100,000) and is a safety cap: you can also set a max-cost limit on the run in Apify, and the actor stops when it's reached.

Real output

I ran the actor's source locally on a 15-address test list (7 deliverable-domain, 3 disposable, 2 bad domain, 3 bad syntax). Four of the rows (mxRecords shortened):

[
  {
    "input": "jane.doe@gmail.com",
    "email": "jane.doe@gmail.com",
    "valid": true,
    "status": "deliverable_domain",
    "reason": null,
    "domain": "gmail.com",
    "mxRecords": [
      "gmail-smtp-in.l.google.com",
      "alt1.gmail-smtp-in.l.google.com",
      "..."
    ],
    "hasMx": true,
    "hasARecord": true,
    "isDisposable": false,
    "isRole": false,
    "isFreeProvider": true,
    "didYouMean": null,
    "checkedAt": "2026-10-16T05:53:24Z"
  },
  {
    "input": "bob@yaho.com",
    "email": "bob@yaho.com",
    "valid": false,
    "status": "invalid_domain",
    "reason": "likely_typo_no_mx",
    "domain": "yaho.com",
    "mxRecords": [],
    "hasMx": false,
    "hasARecord": true,
    "isDisposable": false,
    "isRole": false,
    "isFreeProvider": false,
    "didYouMean": "bob@yahoo.com",
    "checkedAt": "2026-10-16T05:53:24Z"
  },
  {
    "input": "temp@10minutemail.com",
    "email": "temp@10minutemail.com",
    "valid": false,
    "status": "disposable",
    "reason": "disposable_domain",
    "domain": "10minutemail.com",
    "mxRecords": [],
    "hasMx": false,
    "hasARecord": true,
    "isDisposable": true,
    "isRole": false,
    "isFreeProvider": false,
    "didYouMean": null,
    "checkedAt": "2026-10-16T05:53:24Z"
  },
  {
    "input": "bad@@example.com",
    "email": null,
    "valid": false,
    "status": "invalid_syntax",
    "reason": "invalid_local_part",
    "domain": null,
    "mxRecords": [],
    "hasMx": false,
    "hasARecord": false,
    "isDisposable": false,
    "isRole": false,
    "isFreeProvider": false,
    "didYouMean": null,
    "checkedAt": "2026-10-16T05:53:24Z"
  }
]
Enter fullscreen mode Exit fullscreen mode

Note the statuses: deliverable_domain, disposable, invalid_domain (with a didYouMean hint) and invalid_syntax. Filter on valid == true to keep the good ones. I did not run a 10,000-row batch for this post, so I won't quote timing for one. Run time depends mostly on how many distinct domains you have, since DNS results are cached per domain.

Call it from code

Run synchronously and get the rows back in one request (fine for small lists; use async runs for big ones):

curl -X POST \
  "https://api.apify.com/v2/acts/pennydata~bulk-email-validator-no-smtp/run-sync-get-dataset-items?token=$APIFY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"emails":["jane.doe@gmail.com","bob@yaho.com"]}'
Enter fullscreen mode Exit fullscreen mode

Python, with the official client:

from apify_client import ApifyClient

client = ApifyClient("YOUR_APIFY_TOKEN")
run = client.actor("pennydata/bulk-email-validator-no-smtp").call(
    run_input={"csvUrl": "https://example.com/leads.csv", "csvColumn": "email"}
)
rows = client.dataset(run["defaultDatasetId"]).list_items().items
good = [r["input"] for r in rows if r["valid"] and not r["isDisposable"]]
print(len(good), "of", len(rows), "kept")
Enter fullscreen mode Exit fullscreen mode

Pipeline suggestion

  1. Run this pass over the whole list (about $0.001 each).
  2. Drop invalid_syntax, invalid_domain, disposable; apply didYouMean fixes if you like.
  3. Optionally treat isRole addresses separately for cold outreach.
  4. Send only the survivors to a more expensive SMTP verifier, if you need one at all.

That keeps the pricey step small. Try it on a few hundred addresses first and compare against your own bounce data before trusting it on the full list.

Written with AI (Claude).

Top comments (0)