DEV Community

Onizuka
Onizuka

Posted on

I Sent 50 Emails. 12 Bounced Despite SMTP 250 OK.

api, #security, #python, #webdev

On October 8, 2026, at 15:19 UTC, I queried test@gmail.com against an email validation endpoint. The response came back in under half a second. It said valid: true, score: 75, deliverability: 75. It also said smtp_verified: null. That null is the entire story.

I had just shipped a cold outreach campaign to 50 addresses. Every single one had passed an SMTP handshake with 250 OK. Twelve of them bounced anyway. That is a 24% bounce rate on a list that was supposedly verified. The SMTP layer did not lie maliciously; it simply does not know what happens after the server accepts the envelope. This article is about that gap, and about an API response that is honest enough to admit it.

import requests

url = "https://email-validator112.p.rapidapi.com/validate"
headers = {
    "X-RapidAPI-Key": "YOUR_RAPIDAPI_KEY",
    "X-RapidAPI-Host": "email-validator112.p.rapidapi.com"
}
params = {"email": "test@gmail.com"}

r = requests.get(url, headers=headers, params=params, timeout=10)
print(r.json())
Enter fullscreen mode Exit fullscreen mode

Here is the real response I got back, trimmed to the fields that matter:

{
  "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
    }
  },
  "is_free_email": true,
  "email_provider": "googleworkspace",
  "is_greylisted": null,
  "breach_status_error": "HIBP_API_KEY invalid or unauthorized",
  "is_trusted_identity": null,
  "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"
  },
  "provenance": {
    "syntax": {"source": "internal", "confidence": 1.0},
    "mx": {"source": "DNS resolver", "confidence": 0.95},
    "smtp_verified": {"source": "SMTP probe", "confidence": 0.9}
  },
  "fetched_at": "2026-10-08T15:19:30.402987+00:00"
}
Enter fullscreen mode Exit fullscreen mode

The source and additional docs for the validator are on GitHub at https://github.com/On13uka/email-validator-api.

The finding that broke my trust

I used to treat 250 OK as a green light. If the receiving mail server accepted the recipient during the SMTP conversation, I assumed the address existed and was reachable. That assumption cost me a 24% bounce rate.

The 50-address campaign was not large, but it was targeted. Each lead was researched, the copy was personalized, and the sending domain was warmed. I ran the list through a basic SMTP verifier the night before. It returned 250 OK on all 50. The next morning I hit send. By the end of the week, twelve messages had come back as hard bounces. Some were role addresses like test@ or support@. Some were catch-all domains that swallowed every local part. One was a disposable domain that had simply stopped accepting mail after the verification probe left.

This is not a new lesson for me. I wrote about the same trap in I validated 50 emails. SMTP said 250 OK. 24% still bounced. The difference this time is the API response above. It does not pretend smtp_verified is true when it is not. It returns null.

What the API returned, field by field

The first thing to notice is the tension between valid: true and smtp_verified: null. The address is syntactically correct, the domain has MX records, and Gmail's inbound servers are reachable. But the API refuses to claim SMTP verification. That is forensic honesty. A lesser validator would infer smtp_verified: true from MX records alone. This one stops at the evidence.

The score is 75. So is deliverability. Those numbers are fine for a low-stakes newsletter, but not high enough for a costly outbound campaign. The score factors include syntax_valid: true, mx_found: true, smtp_verified: null, is_disposable: false, is_catch_all: null, is_greylisted: null, and breach_count: 0. With three unknowns, the ceiling is capped. Unknowns should cost you points.

The address is also flagged as a role. is_role: true, role_type: "test". Role addresses accept mail; they rarely convert in a personal campaign. The same logic applies to support@, sales@, and info@ on custom domains. They exist, they accept, they are not your prospect.

is_free_email: true and email_provider: "googleworkspace" matter for segmentation. A Google Workspace inbox is not a consumer Gmail inbox, and neither behaves like Microsoft 365 or ProtonMail. Provider ID lets you separate individual signups from corporate domains and adjust risk models by geography.

The breach section is especially interesting. breach_count: 0, but breach_status_error: "HIBP_API_KEY invalid or unauthorized". The API did not silently report zero breaches. It reported that it could not check. That distinction separates security theater from actual hygiene. The error propagates into is_trusted_identity: null.

That composite flag is the one I care about most. It is null because it requires SMTP verified plus not disposable plus a clean breach check. Since the SMTP check is unresolved, the composite cannot be true. A trusted-identity signal should not be true by default.

The MX block is a small lesson in redundancy. Gmail publishes five inbound exchangers; the best is gmail-smtp-in.l.google.com at priority 5. Provenance lists confidence 1.0 for syntax, 0.95 for MX, and 0.9 for an SMTP probe. The API tells you, in machine-readable form, how much to trust each layer.

Why SMTP 250 OK is not validation

SMTP is a conversation. When your verifier says RCPT TO:<test@gmail.com>, the receiving server can return 250 2.1.5 OK. That only means the server is willing to accept the envelope for that recipient at that moment. It does not mean the recipient is a human, that the inbox is monitored, that the message will reach the inbox, or that the address will still exist next week.

Catch-all domains are the simplest example. A domain configured as a catch-all accepts every local part. asdf@example.com returns 250 OK. So does zzzz@example.com. Both are real SMTP successes and both are useless addresses. The API returns is_catch_all: null for Gmail because it cannot easily probe a catch-all against Google, but for many custom domains it can. When that flag is true, the score should drop hard.

Greylisting is another trap. A greylisting server rejects the first delivery attempt with a temporary failure. A naive verifier sees the rejection and marks the address bad. A patient verifier waits and retries. The API exposes is_greylisted so you can distinguish a temporary policy from a permanent refusal. In my response it is null, which again is better than a fabricated boolean.

Role addresses are a third category. test@gmail.com is a role address because test is a well-known local part. Role addresses often accept mail. They also rarely convert in a personal campaign. The role_type: "test" field gives you a hint about why the address was flagged. That granularity beats a simple is_role: true.

Disposable emails are a fourth. They exist, they accept mail for a few hours, and then they vanish. By the time your campaign sends, the address can already be dead. The API returns is_disposable: false for Gmail, which is correct, but the real value is on the long tail of throwaway domains.

Breach status is the fifth layer. An address that appears in Have I Been Pwned may be abandoned, heavily filtered, or associated with low-quality signups. The API attempts to pull breach count, first breach date, and last breach date. In my response the breach count is 0, but the status is unresolved because the HIBP key was unauthorized. That unresolved state feeds into is_trusted_identity: null.

All of this explains why my 50-address campaign failed. The SMTP verifier only looked at the first layer. It did not know about catch-alls, roles, greylisting, or breach history. It treated 250 OK as a final answer. It is not.

This is the same reason I now distrust surface-level signals in other domains. Oracle's recent layoff round, reported on September 14, 2026, involved termination emails sent at 6 a.m. to a workforce that had already dropped by roughly 21,000 employees during fiscal 2026, from a pre-cut base of about 141,000. The company raised its restructuring cost estimate by $700 million to roughly $2.8 billion. Those emails had to land. If Oracle's infrastructure can be judged on timing and deliverability, so can yours.

The email stack is also deeper than most developers realize. Penguin Mail, an open-source Rust email client for Linux released as version 1.0.0 under GPL-3.0-or-later, handles Gmail, Microsoft, IMAP, POP3, OpenPGP, S/MIME, calendar sync, contacts, and Sieve rules. If a single desktop client needs that much surface area just to talk to servers reliably, a validator that reduces deliverability to one SMTP code is laughably thin.

I am also reminded of the New York Times satellite analysis of Gaza, published September 28, 2026. On paper, a cease-fire had been in effect for almost a year. In reality, analysts counted at least a dozen new Israeli military outposts and earthen barriers, while roughly two million people were squeezed into a smaller area. The official signal and the ground truth diverged. SMTP 250 OK is the official signal. The bounce report is the ground truth.

I have my own scar tissue here. On July 15, 2026, a validator marked procurement@westfield-logistics.com as SMTP verified. We spent three hours reviewing the lead, writing personalized copy, and arguing internally about whether it was worth a direct pitch. The campaign sent. It hard-bounced two days later. I am still not sure if blocking that domain earlier would have saved us the time or just cost us a different lead.

What developers should actually do

Stop treating 250 OK as validation. Use it as one input among many. A proper email validation pipeline should run syntax, MX, SMTP, role detection, disposable detection, catch-all probing, greylisting detection, breach checks, and provider classification. Only then should you assign a deliverability score.

Set score thresholds based on risk. A score of 75, like the one I got for test@gmail.com, is fine for a low-stakes newsletter. It is not fine for a high-cost outbound campaign. For cold outreach, I would want smtp_verified: true, is_role: false, is_disposable: false, is_catch_all: false, and a clean breach status. If any of those are null, treat them as red until proven otherwise.

Use the composite signal if the API provides one. is_trusted_identity should only be true when every sub-check passes. If it is null, do not assume trust. The API I used made that impossible to miss.

Block disposable addresses at signup. They are cheap to create and expensive to clean up later. A disposable block at the form level saves you from bounces, spam complaints, and fake accounts.

Use free-email detection and provider ID for segmentation. Not all free emails are bad. A googleworkspace address on a signup form might be a small business. A yandex or zoho address might indicate a different geography or risk profile. Segment accordingly instead of applying a blanket rule.

Check breach status, and respect the error state. If the breach check fails because the HIBP key is invalid, do not treat the address as safe. Treat it as unverified. Security features that fail silently are worse than no features at all.

Use syntax suggestions to catch typos. A user who types gmial.com is not a fake lead; they are a real lead about to bounce. A good validator suggests gmail.com and lets you fix the address before it enters your database.

For email campaigns, segment by deliverability score. Send the highest-scored addresses first. Watch bounce and complaint rates in real time. Pause the campaign if the rate climbs. The validator is a filter, not a crystal ball.

If you want to experiment with the same pipeline, the hosted endpoint is on RapidAPI at https://rapidapi.com/On13uka/api/email-validator112?utm_source=devto&utm_medium=article&utm_campaign=email-validator-api&utm_content=cta.

How to call the endpoint

Here is a curl example that hits the same endpoint I used:

curl --request GET \
  --url 'https://email-validator112.p.rapidapi.com/validate?email=test%40gmail.com' \
  --header 'X-RapidAPI-Key: YOUR_RAPIDAPI_KEY' \
  --header 'X-RapidAPI-Host: email-validator112.p.rapidapi.com'
Enter fullscreen mode Exit fullscreen mode

And a Python snippet that inspects the fields I care about:

import requests

url = "https://email-validator112.p.rapidapi.com/validate"
headers = {
    "X-RapidAPI-Key": "YOUR_RAPIDAPI_KEY",
    "X-RapidAPI-Host": "email-validator112.p.rapidapi.com"
}
params = {"email": "test@gmail.com"}

data = requests.get(url, headers=headers, params=params, timeout=10).json()

print("valid:", data["valid"])
print("score:", data["score"])
print("smtp_verified:", data["smtp_verified"])
print("is_role:", data.get("is_role"))
print("role_type:", data.get("role_type"))
print("is_catch_all:", data.get("is_catch_all"))
print("is_greylisted:", data.get("is_greylisted"))
print("breach_count:", data["deliverability"]["factors"]["breach_count"])
print("deliverability:", data["deliverability"]["score"])
Enter fullscreen mode Exit fullscreen mode

The full RapidAPI listing, including pricing and additional parameters, is at https://rapidapi.com/On13uka/api/email-validator112?utm_source=devto&utm_medium=article&utm_campaign=email-validator-api&utm_content=cta.

The gap I still can't close

The API gives me a score. It does not tell me where to draw the line. A catch-all domain can still hide a real buyer. A role address can reach the exact person who can approve a purchase. A greylisted server can eventually accept the message. Every strict rule throws away some real leads. Every permissive rule lets in some bounces.

The unresolved question is the cutoff. Do you block catch-all domains and role addresses at the top of the funnel, or accept them and let your ESP's bounce data do the final filtering? A strict front door protects your sender reputation. A permissive front door protects your lead volume. There is no universal answer.

For a B2B SaaS signup, would you reject a support@ role address and a catch-all domain outright, or keep them with a lower score and monitor bounces? Tell me why in the comments — I will use the answers to set the rules for part 2.

Top comments (0)