Six months ago I needed to stop fake signups on a side project. The plan was simple: check the email at signup, reject the bad ones, move on with my life.
That turned into building Mailbeam, because every step of "simple" turned out to be a trapdoor.
This is the write-up I wish I'd found before I started. It's mostly about the parts that broke.
Step 1: the regex, which is a lie
Everyone starts here.
const isValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
This tells you the string is shaped like an email address. That's it. It happily accepts:
-
asdf@asdf.com— valid syntax, nobody home -
user@gmial.com— valid syntax, typo'd domain, your welcome email bounces -
test@mailinator.com— valid syntax, disposable, gone in 10 minutes If you go the other way and try to actually implement RFC 5322, you'll find the official grammar permits things like"very.(),:;<>[]\".VERY.\"very@\\ \"very\".unusual"@strange.example.com. There's a famous 6,000-character regex that gets close to correct and is useless in practice, because RFC-valid and deliverable are completely different questions.
Syntax validation catches maybe 5% of your bad signups. Necessary, nowhere near sufficient.
Step 2: MX records, the actual first useful check
Before you can deliver mail to a domain, that domain needs a mail server. You can check this in one DNS lookup:
import { promises as dns } from "node:dns";
async function hasMailServer(email) {
const domain = email.split("@")[1];
try {
const records = await dns.resolveMx(domain);
return records.length > 0;
} catch {
return false; // NXDOMAIN or no MX
}
}
await hasMailServer("user@gmial.com"); // depends on the day — typosquatters buy these
await hasMailServer("user@notarealdomain-zzz.com"); // false
This is cheap, fast, and catches a real chunk of garbage. It's also where most homegrown implementations stop, and it's the point where you start believing the problem is solved.
Two gotchas:
Fallback to A records. RFC 5321 says that if a domain has no MX record, mail should fall back to its A record. Most naive implementations don't check this and reject valid domains.
Typosquatting. gmial.com, hotmial.com, yaho.com — a lot of these are registered, with MX records, by people who want your users' password reset emails. An MX check passes them. You need a separate Levenshtein-distance check against a list of common providers to catch typos, and then you have to decide whether to hard-reject or just warn the user.
Step 3: SMTP probing, where it all falls apart
To know whether a specific mailbox exists, the theory is you open an SMTP conversation with the mail server and ask, without sending anything:
> HELO yourdomain.com
< 250 mx.example.com
> MAIL FROM: <check@yourdomain.com>
< 250 OK
> RCPT TO: <user@example.com>
< 250 OK ← mailbox exists
< 550 No such user ← it doesn't
> QUIT
Clean, right? Here's what happens when you actually run it.
Your cloud provider blocks port 25. AWS blocks outbound port 25 on EC2 by default and requires you to file a request to lift it. Google Cloud blocks outbound port 25 entirely, with no exception process. So the moment you deploy, your verification silently starts returning "unknown" for everything. Discovering this in production is a genuinely bad afternoon.
Greylisting. A large share of mail servers deliberately return a temporary 4xx to unknown senders on first contact, expecting a legitimate sender to retry in a few minutes. A real MTA retries. Your synchronous signup endpoint cannot wait four minutes. You get a soft failure and no information.
Servers lie on purpose. Accepting every RCPT TO is standard anti-enumeration hygiene. If a server returned 550 for nonexistent mailboxes, anyone could harvest its entire valid address list in an afternoon. So many servers — including a lot of Microsoft 365 tenants — return 250 for everything and bounce later. Your probe says "valid." Reality disagrees a day after.
You get blacklisted. This is the one that ends the project. Opening thousands of SMTP connections that never deliver mail is behaviourally identical to a directory harvest attack. Providers rate-limit you, then tarpit you, then your IP lands on Spamhaus or Barracuda. If that IP is shared with your transactional email, you've just broken password resets for your entire user base in order to validate signups.
You can work around this with a rotating pool of IPs, warmed reputation, per-domain rate limiting, connection reuse, and retry queues that respect greylisting. That is not a validation function. That's infrastructure with an on-call rotation.
Step 4: catch-all domains, which nobody solves
A catch-all domain accepts mail for every address at that domain and sorts it out internally. anything@company.com returns 250.
There is no protocol-level way to determine whether a specific mailbox exists behind a catch-all. Not slow, not expensive — impossible. The information isn't exposed.
This matters more than it sounds, because catch-all is common in exactly the segment you care about: B2B domains. Depending on the list, 15–25% of business domains behave this way.
So every verification service in existence has to make a judgment call, and here's where they differ:
- Some return
validand quietly inflate their accuracy numbers - Some return
unknownand hand you the problem - Some return a probabilistic score I went with a score plus a reason string, because "risky: 41" with no explanation is not actionable. If I can tell you the domain is catch-all, has a good sending history, and the local part matches a common pattern, you can make your own policy decision:
{
"valid": true,
"score": 41,
"catchAll": true,
"reason": "catch_all_domain",
"mx": true
}
// your call, not mine
if (score < 30) return reject();
if (result.catchAll) return allowWithDoubleOptIn();
return accept();
The honest framing is that catch-all is a risk-management problem, not a validation problem. Anyone selling you certainty there is selling you something.
Step 5: disposable domains rot faster than you think
Blocking temp-mail is conceptually the easiest check: keep a list, compare against it.
Then you look at how the list behaves. Disposable providers spin up new domains constantly — a single service can operate hundreds of rotating domains, and new ones appear daily. A static list you pulled from GitHub is meaningfully stale within weeks and badly stale within months.
Keeping it current means monitoring the providers, watching newly-registered domain feeds, and looking at behavioural signals (domain age, MX pointing at known temp-mail infrastructure, patterns in the local part). It's a small, permanent, unglamorous job that never finishes.
Step 6: the checks that aren't validity checks at all
Two more things get lumped into "email verification" that are actually policy decisions:
Role-based addresses — info@, admin@, support@, sales@. These are perfectly valid mailboxes. Whether you want them depends entirely on your product. A B2B tool probably does. A consumer app with per-seat billing probably doesn't. This is a flag, not a verdict.
Free providers — Gmail, Outlook, Proton. Also perfectly valid. Also might matter if you're gating a B2B free trial.
The mistake is baking these into a boolean valid field. They belong as separate flags so the caller can apply their own rules.
The part specific to those of us in the EU
An email address is personal data under GDPR. Sending it to a verification API means you're using a processor, and that carries paperwork regardless of where they are.
If that processor is in the US, it's a Chapter V transfer, and you need a lawful basis for it. Post-Schrems II, that's typically the processor's EU–US Data Privacy Framework certification, or Standard Contractual Clauses plus a transfer impact assessment. It is not illegal — it's genuinely fine when done properly — but it is real work: you verify the certification is current, you document the transfer in your Article 30 records, you list them as a sub-processor, and you keep an eye on it, because the adequacy decision underpinning DPF has been challenged before and the previous two frameworks were both struck down.
Most of the established players in this space are US-based. That's why I built Mailbeam EU-hosted with the DPA signed by default — not because US processors are inherently a problem, but because for an EU team the compliance overhead of "the data never leaves the EU" is a rounding error compared to the alternative, and I was tired of doing that assessment.
If you're not in the EU, ignore all of this. If you are, it's about a day of work you can skip.
So: build or buy?
Genuinely build it yourself if:
- Syntax and MX checks are enough for your threat model — this is a completely reasonable place to land
- You're doing offline batch cleaning where latency doesn't matter and you can run a proper retry queue
- You have an IP reputation problem you're already solving for other reasons Don't build it if you need real-time verification in a signup flow. The gap between "MX lookup" and "reliable at signup time" is IP reputation management, greylisting-aware retries, a live disposable feed, and catch-all scoring — four ongoing maintenance jobs, not four functions.
That's the actual build-vs-buy line, and it took me an embarrassingly long time to see it.
If you want the version that already has all of this in it, Mailbeam does the eight checks above in parallel in under 100ms, is hosted in Frankfurt, and prices per verification with no credit expiry. The free tier is 1,000/month with no card, which is enough for most side projects to just stay on forever.
- Docs and quickstart
- Free email verifier if you just want to check one address
- How it compares to ZeroBounce if you're already using something Happy to answer anything in the comments — particularly interested in how other people are handling catch-all domains, because I still don't think anyone has a great answer.
Top comments (0)