SMTP mailbox verification: why it's unreliable
· 7 min read · Email validation
SMTP mailbox verification connects to a domain's mail server, starts a delivery, and asks whether it will accept a recipient, then hangs up before sending anything. It is unreliable because the protocol never promised that answer would be true: servers are allowed to accept recipients they will later bounce, to accept every address on the domain, to defer unknown senders, and to refuse verification outright. Treat a probe result as one weak signal, never as proof that a person owns the mailbox.
If you need the difference between checking an address's shape and checking its mailbox, start with email validation vs verification. This article is about the second half and why it disappoints.
What an SMTP probe actually does
Every "email verifier" that claims mailbox-level checks runs some version of this conversation against the domain's highest-priority MX host:
- Resolve the MX records for the domain (see checking MX records).
- Open TCP port 25 to the MX host and read the
220banner. - Send
EHLO your-host.example. - Send
MAIL FROM:<probe@your-domain.example>. - Send
RCPT TO:<target@their-domain.example>and read the reply code. - Send
QUITwithout ever issuingDATA.
A 250 at step 5 gets reported as "valid". A 550 gets reported as "invalid". Anything else becomes "unknown" or "risky". Here is the whole thing in about 30 lines of Python so you can see how little is going on:
# pip install dnspython
import smtplib
import dns.resolver
def probe(address: str, helo_host: str, mail_from: str) -> str:
domain = address.rsplit("@", 1)[1]
try:
answers = dns.resolver.resolve(domain, "MX")
mx = sorted(answers, key=lambda r: r.preference)[0].exchange.to_text().rstrip(".")
except dns.resolver.NoAnswer:
mx = domain # RFC 5321 5.1: no MX means the domain itself is the implicit MX
except dns.resolver.NXDOMAIN:
return "no_such_domain"
except Exception:
return "dns_error"
if mx == "":
return "null_mx" # RFC 7505: domain accepts no mail
try:
with smtplib.SMTP(mx, 25, local_hostname=helo_host, timeout=10) as smtp:
smtp.ehlo()
smtp.mail(mail_from)
code, msg = smtp.rcpt(address)
except (OSError, smtplib.SMTPException) as e:
return f"connect_failed:{type(e).__name__}"
if 200 <= code < 300:
return "accepted" # not the same as "exists"
if 400 <= code < 500:
return "deferred" # greylisting, rate limit, busy
if code in (550, 551, 553):
return "rejected" # probably invalid, possibly policy
return f"other:{code}"
print(probe("someone@example.org", "probe.yourdomain.example", "probe@yourdomain.example"))
Notice the return values. Even the code is honest about the fact that accepted and exists are different words.
Six reasons the answer misleads you
1. Accepting RCPT is not a promise to deliver
RFC 5321 section 3.3 explicitly allows servers that do not verify recipients until after the message body arrives. Those failures are treated as "subsequent failures" and come back as a bounce message later. Large providers and filtering gateways commonly sit in front of the real mailbox store, accept the envelope, and decide afterwards. Your probe sees 250, quits, and records "valid" for an address that was never checked.
2. VRFY is switched off, by design
The protocol has a command that literally asks "is this a mailbox": VRFY. RFC 5321 section 7.3 says sites may disable it for security reasons, and if they do, the server must return 252 ("Cannot VRFY user, but will accept message and attempt delivery") rather than anything that looks like a real yes or no. The same section notes address harvesting by spammers as the reason. So the one command designed for this job is the one you can count on not working. RCPT-based probing exists because VRFY was neutered.
3. Catch-all domains say yes to everything
Some domains route every unrecognised local part to a shared mailbox. Ask about zz-does-not-exist-91@ and you get 250. A probe against a catch-all domain tells you nothing about the specific address. Verifiers try to detect this by probing a random address first, which costs a second connection and still can't tell you whether jane@ is a real person. We cover the detection trick and what to do with the result in catch-all email domains.
4. Greylisting returns "try later" on purpose
Greylisting deliberately rejects mail from an unfamiliar sender with a temporary 4xx code and accepts it when the sender retries. RFC 6647 describes the technique: the receiver tracks a tuple of client IP, MAIL FROM and first RCPT TO, and its default retry window runs from one minute to 24 hours. A real mail server retries on a schedule (RFC 5321 suggests intervals of at least 30 minutes and a give-up time of at least 4 to 5 days). A signup form waiting on a probe cannot. You either hold the user for minutes or record "unknown".
5. You probably can't reach port 25 from your servers
This one surprises people building verifiers on cloud infrastructure. AWS restricts outbound port 25 on EC2 and Lambda by default and makes you file a request to lift it. Google Compute Engine's documentation says connections to destination TCP port 25 are blocked due to the risk of abuse, for traffic leaving your VPC. Many serverless and edge runtimes give you no raw TCP to arbitrary hosts at all. If you do get port 25 open, the receiving side still sees a connection from a cloud IP range with no mail-sending history, which is exactly what their reputation systems are built to distrust.
6. Probing has a cost to you
RFC 5321 section 7.8 says a server seeing a large number of RCPT TO commands with mostly invalid addresses may reasonably close the connection. In practice, receivers rate-limit, tarpit or blocklist IPs that open lots of sessions and never send DATA. That's the fingerprint of a directory harvest. If your probe IP shares reputation with the IP you send product email from, you are spending deliverability on a check that often returns "unknown".
How to read probe results if you run them anyway
If you already pay for a verifier or run your own, map its output to decisions conservatively:
| Probe result | What it really means | Sensible action |
|---|---|---|
550 / 551 / 553 on RCPT |
The server refused this recipient now. Usually nonexistent, sometimes a policy block on you. | Ask the user to recheck the address. Don't hard-ban. |
250 on a non catch-all domain |
Accepted for now. May still bounce. | Treat as "probably deliverable". Still send a confirmation. |
250 on a catch-all domain |
Says nothing about this address. | Unknown. Rely on confirmation and other signals. |
4xx |
Greylisting, rate limit, or a busy server. | Unknown. Never treat as invalid. |
252 on VRFY |
Explicitly "I won't tell you". | Unknown. |
| Timeout / connection refused | Your network, their firewall, or both. | Unknown. Check your own egress before blaming the domain. |
Null MX (0 .) |
The domain publishes that it accepts no mail (RFC 7505). | Invalid. This one you can trust. |
The pattern: SMTP probing is decent at producing a "definitely bad" answer in a minority of cases and weak everywhere else. Build your policy around that asymmetry.
What to do instead
Confirm by delivery
The only check that proves a person controls a mailbox is sending them something they have to act on: a magic link, a 6-digit code, a "confirm your email" button. It also handles every case above for free. Catch-alls, greylisting and accept-then-bounce all resolve themselves, because a real message either arrives and gets clicked or it doesn't. Gate the features that matter (inviting teammates, sending email from your platform, claiming credits) on confirmation instead of gating signup on a probe.
Run the cheap, deterministic checks first
Before anything touches port 25, these catch a lot of junk with no ambiguity:
- Syntax that real mail systems accept, which is looser than most regexes. See why an email regex is never enough.
- Does the domain exist in DNS, and can it receive mail at all (MX, implicit MX, null MX)?
- Is the domain on a disposable list?
- Does the local part look machine-generated?
All of these are fast, cacheable, and don't depend on a remote server's mood.
Where pingutil fits, and where it doesn't
To be plain about it: pingutil does not do SMTP verification and does not detect catch-all domains. We decided not to, for the reasons in this article. What it does return is the deterministic layer: the domain's category (disposable, free, company and so on), heuristic email_signals on the local part, a verdict, and, if you ask for it, DNS facts in domain_signals.
curl -X POST https://pingutil.com/v1/lookup \
-H "Authorization: Bearer pk_your_key_here" \
-H "Content-Type: application/json" \
-d '{ "input": "jane@northwind-example.com", "skip_enrichment": true, "check_dns": true }'
domain_signals.can_receive_email tells you whether the domain has an MX record or an A record to fall back on. It does not tell you whether jane exists. Pair it with a confirmation email and you have covered more ground than a probe would, without the port 25 problem. Field details are in the API reference.
A short policy you can ship
- Reject at signup only on things you can know for sure: bad syntax, nonexistent domain, null MX, disposable domain.
- Score the soft stuff (randomized local parts, brand-new domains, role addresses) instead of blocking it. The email risk score from free signals article shows one way.
- Send a confirmation message and gate valuable actions on it.
- If you use a commercial verifier for list cleaning, treat "unknown", "risky" and "accept-all" as the same bucket, and never let a probe's
250override a hard bounce from a real send. - Keep any probing infrastructure on IPs that never send your product email.
Questions people ask
- Can I check if an email address exists without sending an email?
- Only approximately. An SMTP probe can tell you a server rejected a recipient, but a server that accepts the recipient may still bounce the message later, accept everything, or refuse to answer honestly. The only definitive test is delivering a message the person acts on, such as a confirmation link.
- Why does my SMTP verifier time out or fail from AWS or Google Cloud?
- Both providers block outbound connections to TCP port 25 by default. AWS lets you request removal of the restriction; on Compute Engine, connections to destination port 25 outside your VPC are blocked for most projects. Without port 25 you cannot talk to another domain's MX server at all.
- Is the VRFY command useful for email verification?
- Rarely. RFC 5321 lets sites disable VRFY and says a server that does so must answer 252, which means it cannot verify the user but will accept the message. Most public mail servers either disable it or answer 252 for every address.