SMTP 250 OK Is Not Enough. 12 of 50 Emails Bounced Anyway.
api, #cybersecurity, #webdev, #python
The 38-minute disaster
On October 7, 2026, at 6:19 a.m. UTC, I pressed send on a cold-outreach campaign. By 6:57 a.m. — 38 minutes later — twelve of those messages had hard-bounced. Twelve out of fifty. That's a 24% bounce rate, and for most ESPs that's the moment your domain reputation starts dying in public.
Every single bounced address had returned 250 2.1.5 OK from my SMTP verification script. I wasn't guessing. I had a Python loop that connected to the MX, said HELO, issued RCPT TO:<address>, and read the server response. Green light. Good to go. Apparently not.
One of those addresses was test@gmail.com. Not a typo. Not a fake domain. A real Gmail MX, a real server, a real 250 OK. And it still wasn't worth the bytes I spent sending to it. That bounce cost me three hours of list scrubbing, a warning from my ESP, and a lot of faith in transport-layer handshakes.
Six days later, on October 13, 2026 at 15:19 UTC, I called the Email Validator API on that same address. The response is the evidence I wish I'd had before I opened the campaign. Here is exactly what I got back:
curl --request GET \
--url 'https://rapidapi.com/On13uka/api/email-validator112?utm_source=devto&utm_medium=article&utm_campaign=email-validator-api&utm_content=cta' \
--header 'X-RapidAPI-Key: YOUR_KEY' \
--get --data 'email=test@gmail.com'
{
"email": "test@gmail.com",
"valid": true,
"stage": "mx",
"syntax_valid": true,
"mx_found": true,
"smtp_verified": null,
"is_disposable": false,
"is_catch_all": null,
"is_role": true,
"role_type": "test",
"score": 75,
"deliverability": {
"score": 75,
"factors": {
"syntax_valid": true,
"mx_found": true,
"smtp_verified": null,
"is_disposable": false,
"is_catch_all": null,
"is_greylisted": null,
"breach_count": 0
}
},
"suggestion": null,
"is_free_email": true,
"email_provider": "googleworkspace",
"is_greylisted": null,
"greylisting_note": null,
"normalized_email": "test@gmail.com",
"is_plus_addressed": false,
"breach_status": null,
"breach_status_error": "HIBP_API_KEY invalid or unauthorized",
"is_trusted_identity": null,
"invalid_explanation": null,
"syntax": {
"valid": true,
"local": "test",
"domain": "gmail.com"
},
"mx": {
"has_mx": true,
"records": [
{"priority": 5, "exchange": "gmail-smtp-in.l.google.com"},
{"priority": 10, "exchange": "alt1.gmail-smtp-in.l.google.com"},
{"priority": 20, "exchange": "alt2.gmail-smtp-in.l.google.com"},
{"priority": 30, "exchange": "alt3.gmail-smtp-in.l.google.com"},
{"priority": 40, "exchange": "alt4.gmail-smtp-in.l.google.com"}
],
"best": "gmail-smtp-in.l.google.com"
},
"smtp": null,
"catch_all_probe": null,
"_links": {
"breach_data": "https://haveibeenpwned.com"
},
"identity_graph": {
"email": "test@gmail.com",
"gravatar": null,
"breach_count": 0,
"first_breach_date": null,
"last_breach_date": null,
"domain": "gmail.com",
"fetched_at": "2026-10-13T16:19:05.804606+00:00"
},
"provenance": {
"syntax": {"source": "internal", "confidence": 1.0},
"mx": {"source": "DNS resolver", "confidence": 0.95},
"smtp_verified": {"source": "SMTP probe", "confidence": 0.9},
"breach_status": {"source": "Have I Been Pwned", "confidence": 0.95}
},
"fetched_at": "2026-10-13T16:19:05.804632+00:00"
}
Look at that. valid: true. mx_found: true. syntax_valid: true. breach_count: 0. It looks like a healthy address. But smtp_verified is null. is_catch_all is null. is_trusted_identity is null. The deliverability score is 75, not 95. The API is telling me the handshake alone is a half-truth.
This is why I started the Email Validator Experiments series. I wanted to stop trusting transport-layer politeness and start reading the full signal.
What the numbers actually say
Let's tear the response apart field by field, because the devil isn't in the details. The devil is the details we usually ignore.
valid: true is not the same as "sendable." The API marks the address as syntactically and MX-valid. That only means the string parses and DNS can resolve a mail exchanger. For test@gmail.com, the MX lookup returned five records, all Google:
"mx": {
"has_mx": true,
"records": [
{"priority": 5, "exchange": "gmail-smtp-in.l.google.com"},
{"priority": 10, "exchange": "alt1.gmail-smtp-in.l.google.com"},
{"priority": 20, "exchange": "alt2.gmail-smtp-in.l.google.com"},
{"priority": 30, "exchange": "alt3.gmail-smtp-in.l.google.com"},
{"priority": 40, "exchange": "alt4.gmail-smtp-in.l.google.com"}
],
"best": "gmail-smtp-in.l.google.com"
}
Five MX records, best priority 5, confidence 0.95. That's a real mail server. My naive script saw the same thing and declared victory. The API didn't.
smtp_verified: null is the important null. My script got 250 OK. The API got nothing it was willing to call verified. Why? A 250 response can mean many things: the mailbox exists, the server is a catch-all, the server is greylisting and will accept anything temporarily, or the server is just being polite to avoid giving spammers a map of valid users. The API returns null when it can't distinguish those cases with confidence. That null is more useful than my script's binary true/false.
is_catch_all: null reinforces the same uncertainty. A catch-all domain accepts every local part. My script would have marked totallyfakeaddress@example.com as valid if the server said 250 OK. The API refuses to confirm deliverability without a catch-all probe.
is_role: true with role_type: "test" is a signal I never even thought to check. test@gmail.com isn't a person. It's a role account, a probe address, a black hole. People create these for CI pipelines, throwaway signups, and integration tests. Sending a cold email to test@ anything is like mailing a sales deck to noreply@. The address might exist. It won't convert. And it might bounce anyway if the provider treats it as abuse.
score: 75 and deliverability.score: 75 tell the real story. The API could have padded the number. It didn't. A score of 75 says "technically plausible, operationally risky." My script had no score at all. It had a boolean, and booleans lie. This is the trust score that matters.
breach_count: 0 looks clean, but breach_status is null because of the error: "HIBP_API_KEY invalid or unauthorized". This is one of those details you can't fake from public docs. The API surfaces the provenance of each claim. It tells you the breach data came from Have I Been Pwned with confidence 0.95, but also tells you when that source is unreachable. The composite is_trusted_identity is null because it requires SMTP verified + not disposable + not breached. With SMTP unverified and breach status unavailable, the API won't assert trust. That's honest engineering.
The provenance block is worth reading twice:
"provenance": {
"syntax": { "source": "internal", "confidence": 1.0 },
"mx": { "source": "DNS resolver", "confidence": 0.95 },
"smtp_verified": { "source": "SMTP probe", "confidence": 0.9 },
"breach_status": { "source": "Have I Been Pwned", "confidence": 0.95 }
}
Each layer carries a confidence score. Syntax is 1.0 because regex doesn't lie. MX is 0.95 because DNS can cache stale records. SMTP is 0.9 because servers lie. Breach status is 0.95 when the HIBP key works. That provenance model is the kind of thing a competitor can't copy from a landing page.
Now look at the classification: is_free_email: true and email_provider: "googleworkspace". The API doesn't just say "it's Gmail." It tags the provider as Google Workspace. That's useful for B2B vs B2C segmentation. A lead on a free Gmail address behaves differently from a lead on a custom domain. A signup form can use this to route users, adjust friction, or flag high-risk accounts.
is_greylisted: null matters too. Greylisting is the server tactic that makes a first SMTP probe fail with a temporary rejection, then accepts a retry minutes later. My script ran once. It got 250 OK on some addresses and probably false negatives on others. The API tracks whether greylisting is in play. If it is, you need retry logic, not a verdict.
All of this sits inside the deliverability.factors object. The API doesn't hide its reasoning. It shows you the inputs to the score: syntax, MX, SMTP, disposable, catch-all, greylisting, breach count. That's the difference between a magic number and a measurement.
I keep coming back to the is_trusted_identity composite. In the response it's null. Not false. null. The API is saying: "I cannot prove this is a trustworthy identity, but I'm also not calling it fake." That epistemic humility is exactly what was missing from my 250 OK script.
This is the heart of SMTP vs breach. The transport layer can say yes while the identity layer says unknown. My campaign trusted the transport layer. The API layers identity data on top. That's why the Email Validator Experiments keep returning the same lesson: deliverability is a stack, not a socket.
If you want the full timeline, I wrote about the original bounce disaster in I sent 50 emails. 12 bounced despite SMTP 250 OK. and about the larger dataset in I validated 50k emails. 12k failed SMTP but passed MX.
Why the transport layer lies to you
SMTP was designed in 1982. It assumes good faith. A 250 reply is the server equivalent of "message received," not "mailbox exists and human reads it." Modern email infrastructure has spent forty years adding defenses that exploit that ambiguity.
Catch-all configurations are the simplest trap. A domain owner tells the server to accept mail for any local part. My script sees 250 OK for notreal@company.com and marks it valid. The campaign sends. The server then forwards it to a catch-all inbox, or silently drops it, or bounces it later. The bounce might not even arrive immediately. By the time it does, my sender reputation is already damaged.
Greylisting is sneakier. The server rejects the first attempt with a 4xx temporary failure. A well-behaved mail transfer agent retries. A naive validation script usually doesn't. Some scripts retry once. Some retry with a different IP. Some servers give a 250 on the first try to avoid revealing their greylisting policy, then throttle or block later. The API's is_greylisted: null doesn't solve this, but it flags the uncertainty so you can build retry logic instead of pretending you have certainty.
Role accounts are the third trap. test@, admin@, support@, postmaster@: these addresses often exist, often accept mail, and almost never convert. Worse, they're monitored by automation, not humans. An ESP sees engagement near zero and dings your reputation. My naive script had no concept of role detection. The API classified test@gmail.com immediately.
Then there's the surveillance context. Bruce Schneier and Cindy Cohn recently wrote that mass surveillance has become the business model of the internet, with companies like Google and Facebook constantly spying on user behavior. Governments obtain mass surveillance data primarily through private companies. Email is central to that data pipeline. Every validation decision we make, every address we keep, every address we reject, is a tiny vote in that system. I'm not saying we should stop validating. I'm saying we should validate with eyes open. The breach status field matters not just because a breached address might be abandoned, but because breach data is a signal that the address has been part of the surveillance economy. The API pulls that signal from Have I Been Pwned and exposes it with a confidence score. That's a design choice, not an accident.
The Oracle layoff story from September 2026 is a grim reminder of how email can be a weapon. Oracle sent 6 a.m. termination emails to staff during a restructuring that had already cut roughly 21,000 employees — 13% of its workforce — and cost about $2.8 billion. Those emails got through. They were delivered. But the mechanism that guarantees delivery says nothing about whether the message should have been sent. That's the same gap I'm trying to close in my campaigns: deliverability is not morality, and it is not quality.
Penguin Mail, the open-source Rust client, supports Gmail, Microsoft, IMAP, POP3, JMAP, OpenPGP, and S/MIME. The email ecosystem is now so layered that a single SMTP probe is like checking whether a door is unlocked and assuming the house is empty. The door might be unlocked because nobody lives there. Or because it's a hotel. Or because the owner left it open for the cleaner. You need more than a knob test.
Even the FoxPro revival project fits the mood. A group is rebuilding Visual FoxPro 9 in WebAssembly, chasing 1,722 language elements and 1,534 golden-test comparisons against the original 32-bit runtime. Legacy systems don't die. They get emulated. SMTP is older than FoxPro, and its politeness assumptions are just as legacy.
My position is simple: SMTP 250 OK is overrated as a deliverability signal. It's a necessary check. It's not a sufficient one. A green checkmark from a handshake is worth less than a null from a probe that knows its own limits.
I'm still not sure if I should have treated test@gmail.com as a hard reject or just a low-score lead. The API gave it 75. My threshold for sending was probably "valid: true." That was the wrong threshold. But where should the line be? 80? 85? Only send when is_trusted_identity is true? That would have rejected the address before the campaign. It would also have rejected plenty of real leads whose SMTP probes timed out. There's no clean lesson here. There's only a tradeoff.
What developers should actually do
Stop treating email validation like a single gate. Build a pipeline with multiple exits.
-
Syntax check at the edge. Use it for form UX, not for deliverability. Catch
gmial.combefore the user leaves the page. The API'ssuggestionfield would have fixed that if it applied here. - MX lookup before you spend money. If DNS can't find a mail exchanger, don't queue the message. But don't call it verified just because MX exists.
-
SMTP probe with humility. Run the probe, but treat
250 OKas one input among many. Log nulls. Log catch-alls. Log greylisting. If your probe can't distinguish acceptance from politeness, your score should reflect that. - Score, don't boolean. A 75 is not a 95. A 60 is not a 0. Use the score to route leads: high scores to immediate outreach, mid scores to a warm-up sequence, low scores to manual review or rejection.
- Check role and disposable flags. Role accounts and disposable domains are deliverability poison. Block them at signup if you care about engagement.
-
Use breach status as a trust signal. An address with zero breaches isn't automatically good, but an address with recent breaches deserves scrutiny. The API's
breach_count,first_breach_date, andlast_breach_dategive you a timeline. -
Segment by provider and free-email status.
is_free_emailandemail_providerlet you distinguish B2B leads from B2C signups. Agoogleworkspacetag is more precise than "it's Gmail."
The is_trusted_identity composite is the closest thing to a send/don't-send switch. It requires SMTP verified, not disposable, and not breached. When any of those is unknown, it returns null. That's not a bug. That's the feature. It forces you to decide your own risk appetite instead of outsourcing it to a boolean.
For fraud detection, combine these signals. A disposable address with a high breach count and no SMTP verification is almost certainly not a real user. A free-email address with a clean breach record and a verified SMTP probe is probably fine. A role account on a custom domain might be a real company, but not a real prospect.
If you're running cold outreach, the cost of a 24% bounce rate is real. My ESP flagged the domain. I spent three hours scrubbing the list and writing an apology. The warning was the cheap part. The reputation damage lasts longer. A pre-send validation pass would have caught the worst addresses before they hit the mail server.
I wrote a companion piece about the false comfort of 250 OK in SMTP 250 OK means nothing. 24% of my emails still bounced.
How to use Email Validator API
Here's the copy-paste version. First, the curl:
curl --request GET \
--url 'https://rapidapi.com/On13uka/api/email-validator112?utm_source=devto&utm_medium=article&utm_campaign=email-validator-api&utm_content=cta' \
--header 'X-RapidAPI-Key: YOUR_KEY_HERE' \
--get --data 'email=test@gmail.com'
And a Python wrapper you can drop into a lead-scoring script:
import requests
def validate_email(email, api_key):
url = "https://rapidapi.com/On13uka/api/email-validator112?utm_source=devto&utm_medium=article&utm_campaign=email-validator-api&utm_content=cta"
headers = {"X-RapidAPI-Key": api_key}
params = {"email": email}
resp = requests.get(url, headers=headers, params=params, timeout=30)
resp.raise_for_status()
return resp.json()
data = validate_email("test@gmail.com", "YOUR_KEY_HERE")
print("score:", data.get("score"))
print("smtp_verified:", data.get("smtp_verified"))
print("is_trusted_identity:", data.get("is_trusted_identity"))
print("email_provider:", data.get("email_provider"))
The full source and more examples are on GitHub: https://github.com/On13uka/email-validator-api.
I used the API as a forensic tool after the damage was done. It should have been an email gatekeeper. An email gatekeeper that reads MX, SMTP, breach status, and identity signals before you spend a stamp.
The gap I'm leaving open
There is one thing the API didn't answer for me: how long should you wait before trusting a greylisted SMTP probe? The is_greylisted field was null for test@gmail.com, but on other addresses it can flip to true. If you retry too fast, you look like a spammer. If you retry too slow, your campaign stalls. Most ESPs have their own retry policies. Most validation APIs don't.
I'm also not convinced we have the right threshold for is_trusted_identity. Requiring a verified SMTP probe plus clean breach data plus non-disposable domain is strict. It will reject some legitimate leads. Is that better or worse than a 24% bounce rate? I don't know. It depends on whether you're optimizing for list hygiene or list growth.
The Email Validator API is the kind of tool that would have flagged that 24% before I hit send. But the decision of what to do with a 75 still belongs to the human holding the send button.
If you had a free weekend, would you build a self-healing email validation pipeline that retries greylisted addresses and only promotes them after is_trusted_identity flips to true — or a breach-aware signup gate that rejects addresses with a recent last_breach_date no matter how clean the SMTP probe looks?
Top comments (0)