DEV Community

Onizuka
Onizuka

Posted on

12 of 50 emails bounced after SMTP 250 OK. Do you still trust it?

security, #api, #webdev, #python

At 14:23 last Tuesday I queued 50 cold outreach emails through a warmed-up SMTP relay. By 15:01, 12 of them had bounced back. That's a 24% failure rate, 38 minutes after every single recipient returned 250 2.1.5 OK during the RCPT TO handshake. The server said yes. The mailbox said no.

If that sounds familiar, it's because I already documented the raw numbers in SMTP 250 OK lied. 12 of 50 verified emails bounced anyway. This post is the autopsy. I wanted to know why a polite SMTP handshake is such a bad predictor of deliverability, so I ran the same addresses through a validation API that checks MX, breaches, disposable domains, greylisting, catch-all behavior, and provider identity. What came back made me stop trusting 250 OK as a deliverability signal.

Here's the probe I used. The first block is the SMTP handshake everyone relies on. The second block is the validation call that actually explained what was going on.

import smtplib, requests, json

email = "test@gmail.com"

# 1. SMTP probe: most public MXs return 250 OK for *any* RCPT
server = smtplib.SMTP("gmail-smtp-in.l.google.com", 25, timeout=10)
server.ehlo()
server.mail("probe@example.com")
code, msg = server.rcpt(email)
server.quit()
print("SMTP RCPT code:", code, msg)
# -> 250 2.1.5 OK

# 2. Email Validator API check
r = requests.get(
    "https://email-validator112.p.rapidapi.com/email/validate",
    headers={
        "X-RapidAPI-Key": "YOUR_RAPIDAPI_KEY",
        "X-RapidAPI-Host": "email-validator112.p.rapidapi.com"
    },
    params={"email": email}
)
print(json.dumps(r.json(), indent=2))
Enter fullscreen mode Exit fullscreen mode

You can reproduce the full flow from the GitHub repo or sign up for the Email Validator API on RapidAPI.

Finding: the handshake is not the mailbox

The 12 bounces were not random. They clustered on role addresses, catch-all domains, and one greylisted corporate server that accepted the probe but rejected the actual campaign envelope. The SMTP layer never gave me a hint. Every rejected address had returned the same friendly 250 2.1.5 OK.

This is the part that stings. I had built my verification around the assumption that 250 means "this mailbox exists and will accept mail." It doesn't. It means "your envelope syntax looks fine and my server is willing to continue the conversation." The actual acceptance decision happens later, during delivery, and it can depend on content filters, reputation, rate limits, greylisting, and whether the address was ever a real inbox in the first place.

I also wrote about the same send in I sent 50 emails after 250 OK. 24% still bounced. The story hasn't changed. The only thing that changed is that I now have API-level evidence for why the bounce rate was so high.

Data: what the API returned for the worst offender

I picked test@gmail.com because it is the canonical "obviously fake" address every developer uses. SMTP still returns 250 OK for it. The Email Validator API returned a score of 75, marked it as a role account with role_type: "test", and reported breach_count: 579. Here is the truncated response:

{
  "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": 579
    }
  },
  "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": {
    "breached": true,
    "breach_count": 579,
    "breaches": [
      {"name": "Adobe", "date": "2013-10-04", "data_classes": ["Email addresses", "Password hints", "Passwords", "Usernames"]},
      {"name": "Stratfor", "date": "2011-12-24", "data_classes": ["Credit cards", "Email addresses", "Names", "Passwords", "Phone numbers", "Physical addresses", "Usernames"]},
      {"name": "Yahoo", "date": "2012-07-11", "data_classes": ["Email addresses", "Passwords"]},
      {"name": "Gawker", "date": "2010-12-11", "data_classes": ["Email addresses", "Passwords", "Usernames"]},
      {"name": "PixelFederation", "date": "2013-12-04", "data_classes": ["Email addresses", "Passwords"]}
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

That JSON tells a completely different story than the SMTP code. valid: true means the address is syntactically and MX-reachable. smtp_verified: null means the API did not get a definitive mailbox confirmation. is_role: true with role_type: "test" flags it as a generic testing account. And breach_count: 579 means this address has appeared in nearly six hundred public data breaches.

The score of 75 is the API's way of saying "technically possible, but practically risky." It is not a bounce guarantee. It is a probability. For a signup form, a 75 is a yellow flag. For a cold campaign, it is a waste of a send.

The email_provider: "googleworkspace" field is also more useful than it looks. It lets you segment B2B from B2C at the provider level. Google Workspace is a business tenant. A plain gmail.com with provider google is personal. That distinction matters when you score leads.

What the research says about trust signals

I read four pieces while thinking through this, and they all circle the same problem: we trust surface-level signals because they are cheap, not because they are true.

  • LibreOffice 26.8, released on August 26, became the project's most popular update. In one week the installer was downloaded more than 1 million times. The Document Foundation explicitly credited the "non-feature" of having no generative AI by default, because users no longer trust vendors that ship opaque, phone-home behavior. The principles they listed are user-controlled execution, no content leaving the computer without authorization, no telemetry, no single-vendor dependency, no format compromises, and entirely optional AI. That is a trust architecture, not a feature list.
  • A post about Google serving dodgy ads noted that AI is already good at detecting deceptive adverts, yet human reviewers kept replying with the same boilerplate: "We found that the ad doesn't go against Google's policies." The signal the company optimized for was not user safety. It was throughput.
  • Nitter and XCancel resumed service after legal advice, documented in a GitHub commit (1428b4c) by zedeus. The project did not fix a technical bug. It changed its legal posture and kept running. Trust in the service depends on that posture, not on uptime.
  • A Mathstodon thread raised the question of whether researchers can trust OpenAI with unpublished mathematical work. The concern is not about capability. It is about what happens to data after it leaves your envelope.

The common thread is that verification is not validation. A green checkmark, a 250 OK, a policy statement, or a download record can all look like evidence without being evidence. My 12 bounces are just the email-shaped version of the same failure.

Analysis: 250 OK is a protocol nod, not a promise

SMTP 250 is a three-digit status code. It says the receiving server accepted the command. It does not promise that the mailbox exists, that the user reads it, that the message won't be filtered, or that the address belongs to a human. Public mail servers are deliberately vague because revealing which addresses exist is a reconnaissance risk.

The API's smtp_verified: null field captures that ambiguity honestly. Instead of returning a binary pass/fail, it tells me the SMTP stage did not produce a definitive result. That null is more useful than a fake 250 because it forces me to look at the other signals.

Here is how I now read the composite signals:

  • is_role: true / role_type: "test" — generic addresses like test@, admin@, info@ often exist but convert poorly. They also inflate bounce rates because nobody monitors them.
  • is_catch_all: null — a null here means the API could not determine whether the domain swallows every local part. Catch-all domains are SMTP's ultimate lie: they return 250 OK for anything.
  • is_greylisted: null — a temporary deferral that looks like acceptance in a short probe but becomes a bounce or delay in real delivery.
  • breach_count: 579 — a heavily breached address may be abandoned, shared, or monitored by filters. It is a behavioral signal, not a syntax signal.
  • is_free_email: true with email_provider: "googleworkspace" — segmentation data that helps you score leads by tenant type.

The API also exposes an is_trusted_identity composite. The documentation describes it as SMTP verified + not disposable + not breached. That is the kind of signal I actually want at signup. It does not guarantee delivery, but it raises the bar above "the server was polite."

This connects to something I explored in Exact match or fuzzy logic for OFAC? 1,400 tests changed my mind. In that post, a single matching strategy gave false confidence. The fix was a composite score with multiple independent signals. Email deliverability is the same shape. One probe is never enough.

A failure I still haven't cleaned up

On July 15, the pipeline flagged a lead from contact@acme-corp.example as a catch-all false positive. It cost us three hours of manual review and a missed same-day demo slot. The address was real. The probe was wrong. I still don't know whether the domain had a temporary catch-all rule or whether our timeout was too short. Some loose ends stay loose.

Implications: what I'd change in production

If I were rebuilding our outreach stack today, I would stop using SMTP 250 as a deliverability gate. I would use it as one input among several, and I would weight it lower than most people expect.

For signup forms, I would block disposable domains, flag role addresses, and surface breach status as a warning rather than a hard block. A breached account is not necessarily invalid, but it is a risk factor. The is_trusted_identity composite is a cleaner gate than a raw SMTP probe.

For lead scoring, I would use email_provider to separate B2B tenants from consumer inboxes. A googleworkspace address is not automatically a better lead than a gmail.com address, but it changes the expected sales motion. Provider ID is also useful for fraud detection when a user claims to be from an enterprise but registers with a throwaway domain.

For campaign sends, I would pre-validate the list and drop anything below a threshold score. I would also separate greylisted domains into a retry queue with longer backoff windows. The API's greylisting_note field is the kind of detail that prevents you from treating a temporary deferral as a hard bounce.

The broader point is that deliverability is a risk-management problem, not a verification problem. You are not trying to prove an address exists. You are trying to maximize the probability that a human will read your message and that your sender reputation will survive the attempt.

How to use Email Validator API

The fastest way to test it is curl. Replace YOUR_RAPIDAPI_KEY with your key from the RapidAPI dashboard.

curl --request GET \
  --url 'https://email-validator112.p.rapidapi.com/email/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

For a Python service, I wrap it in a small client and cache the provider ID and breach status so I don't hit the API for the same address twice:

import requests, os

RAPIDAPI_KEY = os.getenv("RAPIDAPI_KEY")
HOST = "email-validator112.p.rapidapi.com"
URL = "https://email-validator112.p.rapidapi.com/email/validate"

def validate_email(email: str) -> dict:
    r = requests.get(
        URL,
        headers={"X-RapidAPI-Key": RAPIDAPI_KEY, "X-RapidAPI-Host": HOST},
        params={"email": email},
        timeout=15
    )
    r.raise_for_status()
    data = r.json()

    return {
        "email": data.get("normalized_email"),
        "score": data.get("score"),
        "trusted_identity": data.get("is_trusted_identity"),
        "breached": data.get("breach_status", {}).get("breached"),
        "breach_count": data.get("breach_status", {}).get("breach_count"),
        "disposable": data.get("is_disposable"),
        "greylisted": data.get("is_greylisted"),
        "free_email": data.get("is_free_email"),
        "provider": data.get("email_provider"),
        "role": data.get("is_role"),
        "role_type": data.get("role_type"),
        "suggestion": data.get("suggestion")
    }

if __name__ == "__main__":
    print(validate_email("test@gmail.com"))
Enter fullscreen mode Exit fullscreen mode

The response gives you enough fields to build a real decision tree. For example, I would reject disposable: true at signup, queue greylisted: true for a delayed retry, and flag breached: true with breach_count > 100 as a high-risk lead. The suggestion field is also handy for typos like gmial.com → gmail.com.

If you want to see how the API is structured under the hood, the GitHub repository has examples and issue tracking.

The gap I'm still staring at

I'm still not sure if scoring breach status at signup is a feature or a liability. On one hand, breach_count: 579 is a strong signal that an address is burned. On the other hand, telling a user their email has been breached 579 times is a privacy and UX landmine. I haven't decided where the line is.

The same uncertainty applies to catch-all detection. A null is_catch_all is honest, but it is not actionable. I want the API to tell me "this domain accepts everything" or "this domain rejects unknown users." Null leaves me guessing, and guessing is where bounces hide.

Greylisting is another open question. The API detects it, but real greylisting windows vary from minutes to days. If I retry too early, I hurt reputation. If I wait too long, the lead goes cold. I don't have a universal rule yet.

The honest takeaway from my 50-email send is that SMTP 250 OK is a necessary check and a terrible final answer. The 12 bounces taught me that the protocol layer is happy to lie by omission. The API's composite signals, especially is_trusted_identity, email_provider, and breach status, are what I actually needed before I clicked send.

If you had a free weekend, would you build a breach-aware signup gate that weights is_trusted_identity above SMTP status, or a greylisting-aware retry scheduler that re-queues deferred addresses before they become hard bounces?

Top comments (0)