How to verify an email address without sending an email
Syntax, a live MX lookup, and disposable/role checks get you surprisingly far without touching a mailbox — but confirming the exact address exists still needs a send.
Contents
"Verify email without sending" sounds like a contradiction, and partly it is. You can learn a lot about an address before you ever put it in a To: field — whether it parses, whether the domain can receive mail at all, whether it's a throwaway or a shared inbox. What you cannot learn, with certainty and without sending, is whether that exact mailbox exists. This post is about the gap between those two things: how far the no-send checks get you, why the last mile needs a real message, and how to run the no-send layer in one call.
What "without sending" actually buys you
Three checks run without touching a mailbox.
Syntax. Does the string parse as an email address? Your language already does this. Django's EmailField and Rails' URI::MailTo::EMAIL_REGEXP are pure syntax — local, free, no network call. (Flask-WTF's Email() is syntax-only by default too, though the email_validator package underneath it can add an MX check if you turn on deliverability — which is exactly the next layer.) If you only need to keep bob@@gmail out of your database, stop here — you don't need an API.
MX records. Does the domain publish mail servers? This is a DNS lookup, not a mailbox check: it confirms the domain can receive mail, not that your specific address does. You can do it yourself.
import dns.resolver
def has_mx(domain: str) -> bool:
try:
return len(dns.resolver.resolve(domain, "MX")) > 0
except dns.resolver.NXDOMAIN:
return False
An address whose domain has no MX record is a dead end. That's the single highest-value no-send signal, and it costs one DNS query.
Disposable and role lists. Is the domain a known throwaway (mailinator.com, guerrillamail.com)? Is the local part a shared alias (info@, support@, admin@)? These are list lookups against data someone has to maintain. A side project can copy a public list; keeping it current is the tedious part.
None of these send anything, and all of them are things you could wire up yourself. The reason to reach for an API is to stop maintaining the DNS timeouts and the throwaway list, and to get one answer instead of three.
The one call
POST /v1/verify/email runs all three no-send checks and reports each one.
curl -s https://api.boundstone.io/v1/verify/email \
-H "Authorization: Bearer bs_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"email":"jane@example.com"}'
{
"valid_syntax": true,
"domain": "example.com",
"mx_found": true,
"disposable": false,
"role_account": false,
"free_provider": false,
"checks": {
"performed": ["syntax", "mx", "disposable_list", "role_list"],
"not_performed": ["smtp_mailbox", "catch_all"]
}
}
The boolean keys map back to those checks: valid_syntax, mx_found, disposable, role_account. free_provider flags consumer domains like Gmail — useful for scoring a B2B signup, not a verdict, and it isn't one of the checks.performed. Keep the bs_live_ key on your server; it's a secret, never a browser or a form field.
Read the response honestly
Look at checks.not_performed. It reads ["smtp_mailbox", "catch_all"], and that's the point. The API tells you what it did not do so you don't mistake a clean no-send result for proof the mailbox exists.
checks.performed reads ["syntax", "mx", "disposable_list", "role_list"]. Everything on that list happened without sending. Nothing on it confirms delivery. An address with valid_syntax: true and mx_found: true is plausible — it parses, and its domain accepts mail — but "plausible" and "exists" are different claims, and we never blur them. For the difference between checking an address and confirming it, see email validation vs verification.
Why the mailbox needs a send
To confirm one specific mailbox without sending, you would open an SMTP connection and get as far as RCPT TO: without delivering — the classic SMTP probe. Two things break it.
First, servers lie. Many mail servers accept RCPT TO: for every address, to deny spammers a way to enumerate valid users, then bounce the message later. A 250 OK at probe time can still bounce an hour on. The probe looks authoritative and isn't.
Second, catch-all domains. A catch-all accepts mail for every possible local part, so anything@theirdomain.com comes back the same. There is no address you can probe that returns negative, which means the probe tells you nothing. That's why catch_all sits in not_performed — see what a catch-all email is for the full mechanics. Boundstone performs neither the SMTP probe nor catch-all detection, because a result we can't stand behind is worse than an honest gap.
The one method that survives both problems is a confirmation email: send a link, see who clicks. It is the only proof that the mailbox exists and someone reads it. It requires sending. That's the ceiling.
When the no-send layer is enough
Most of the time, it is. Blocking dead domains, throwaways, and typo'd syntax at signup removes the bulk of bad addresses without a single send. Route the rest to a confirmation email — which you were probably sending anyway — and use the no-send call to decide whether to bother.
You can run the checks with no key and no signup on the free email validator, then move to /v1/verify/email when you want it in your pipeline.
The short version
- Syntax, MX, and disposable/role checks all run without sending. Your stdlib does syntax; a DNS query does MX; a maintained list does the rest.
- One
POST /v1/verify/emailcall folds all three in and reportschecks.performedas["syntax", "mx", "disposable_list", "role_list"]. - Confirming that a specific mailbox exists needs an SMTP probe, and probes are defeated by servers that lie and by catch-all domains. Boundstone marks
smtp_mailboxandcatch_allasnot_performedrather than guess. - The only certain proof is a confirmation email. "Verify without sending" gets you most of the way; the last step sends.