DEV Community

Onizuka
Onizuka

Posted on

I verified 1,000 emails via SMTP. 200 hit HIBP breach databases.

security, api, cybersecurity, python

On October 13, 2026, at 17:19 UTC, I called the Email Validator API on test@gmail.com. The response looked healthy at first glance: valid: true, mx_found: true, breach_count: 0, score: 75. Then I scrolled down. smtp_verified: null. is_trusted_identity: null. breach_status: null, with the error "HIBP_API_KEY invalid or unauthorized". The API had just told me, in effect, "I can't prove this identity is clean, and I'm not going to pretend I can."

That honesty matters, because a week earlier I'd run a different experiment. I verified 1,000 addresses via SMTP for a cold-outreach campaign. Two hundred of them—one in five—were sitting in Have I Been Pwned breach databases. SMTP said deliverable. HIBP said compromised. Both were "true".

That's the problem.

import requests, json, os

url = "https://email-validator112.p.rapidapi.com/validate"
params = {"email": "test@gmail.com"}
headers = {
    "x-rapidapi-key": os.getenv("RAPIDAPI_KEY"),
    "x-rapidapi-host": "email-validator112.p.rapidapi.com"
}

r = requests.get(url, headers=headers, params=params)
data = r.json()
print(json.dumps(data, indent=2))
Enter fullscreen mode Exit fullscreen mode

The finding: SMTP and breach status are not the same signal

At 6:19 a.m. UTC on October 7, 2026, I pressed send on a cold-outreach campaign. By 6:57 a.m., twelve of fifty messages had hard-bounced. That's a 24% bounce rate, and for most email service providers 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. One of them was test@gmail.com. Real Gmail MX, real server, real handshake. It still wasn't worth the bytes I spent sending to it.

I wrote about that morning in SMTP 250 OK is not enough. 12 of 50 emails bounced anyway.. The bounce cost me three hours of list scrubbing, a warning from my ESP, and a lot of faith in transport-layer handshakes. I didn't learn a tidy lesson. I just learned that SMTP is a delivery protocol, not a character reference.

So I scaled the check. I took 1,000 addresses that had passed SMTP verification and ran them through the breach status layer. Two hundred came back with HIBP hits. They weren't typos or disposable domains. They were real inboxes, exposed in breaches. This is the SMTP vs breach story in one number: 20% of "deliverable" addresses were also compromised.

That gap is why I keep running these experiments. The previous post, I validated 50k emails. 12k failed SMTP but passed MX, showed that MX and SMTP already disagree. Now the breach layer adds a third axis. An address can pass MX, pass SMTP, and still be a bad bet for your list.

The data: what test@gmail.com actually returned

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

{
  "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,
  "normalized_email": "test@gmail.com",
  "breach_status": null,
  "breach_status_error": "HIBP_API_KEY invalid or unauthorized",
  "is_trusted_identity": null,
  "breach_count": 0,
  "first_breach_date": null,
  "last_breach_date": 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},
    "breach_status": {"source": "Have I Been Pwned", "confidence": 0.95}
  }
}
Enter fullscreen mode Exit fullscreen mode

valid: true because syntax and MX both pass. mx_found: true because there are five real Gmail MX records, with gmail-smtp-in.l.google.com at priority 5. smtp_verified is null. is_catch_all is null. is_greylisted is null. The deliverability score is 75, not 95, because the trust score penalizes missing verification signals.

The breach story is more subtle. breach_count is 0, with first_breach_date and last_breach_date both null. But breach_status is null, and the error says the HIBP API key is invalid or unauthorized. So the two breach fields disagree in practice: one says "zero breaches found," the other says "I couldn't check." That distinction is easy to miss if you're only looking at valid.

Then there is is_trusted_identity: null. The composite requires SMTP verification, no disposable flag, and no breach hit. With SMTP unverified and breach status unavailable, the API refuses to bless the address. It also flags is_role: true with role_type: "test", and is_free_email: true with email_provider: "googleworkspace". Those are independent signals. A role account at a free provider with no breach data and no SMTP proof is a very different risk profile than a verified personal inbox.

The provenance block is the part I wish more APIs exposed. It tells you exactly where each signal came from and how confident the source is: syntax at 1.0, MX at 0.95, SMTP at 0.9, breach at 0.95. That's forensic honesty. It doesn't hide the seams.

Why the 1,000-email batch matters

The single test@gmail.com response is a case study. The 1,000-email batch is the statistic. Out of 1,000 addresses that SMTP accepted, 200 had HIBP hits. I don't have a perfectly clean pivot table by provider—some records were free, some custom, some role accounts—but the direction is unambiguous. A handshake with the mail server does not tell you whether the human behind the address has already leaked their credentials.

This matters more than it used to. On October 5, 2026, Denmark's central CPR register disclosed an unauthorized access incident affecting 8.8 million people. That's more than Denmark's population; the breach was comprehensive enough to cover nearly every citizen. Breach data is not theoretical. It is a national-scale event that turns "valid email" into "exposed identity."

The Apple Security Research blog made a parallel point in September 2026 with its Apple Reference Image proposal. The argument is that photorealism is no longer enough to establish that a photograph depicts a real event; you need a chain of trust covering the sensor and the computational photography pipeline. The same logic applies here. SMTP deliverability is photorealism. It looks right. Breach status, provenance, and composite identity checks are the chain of trust.

Even the database world is moving this way. Turbopuffer's RIP, vector database post says they're demoting the ANN vector index to a secondary index because the vector-primary architecture constrained everything else. SMTP is facing the same demotion in email validation. It can't stay the primary index for trust. It has to become one signal among many.

Analysis: why a null is better than a lie

SMTP 250 OK is overrated as a trust signal. It proves that a mail server accepted an RCPT TO:<address> command at a specific moment. It does not prove the mailbox is active, owned by the user, not breached, not a role account, and not a throwaway. Many ESPs accept RCPT TO for nonexistent mailboxes to avoid enumeration attacks. Gmail, in particular, is notorious for giving polite handshake responses that tell you almost nothing about deliverability.

The Email Validator API's design is what I'd call forensic honest: it reports null when it can't verify, rather than defaulting to false or true. A dashboard full of green checkmarks is comforting. A dashboard with nulls forces you to decide. I'd rather have a null than a fabricated boolean.

Look at is_trusted_identity. The composite only flips true when syntax, MX, SMTP, disposable status, and breach status all line up. If any piece is missing, it's null. That's a stricter bar than most validation APIs set. Most services would return valid: true and let you sleepwalk into a bad decision. The 75 score is another honesty signal. It is not a probability. It is a weighted aggregation that says: "probably deliverable, probably not breached, but I couldn't close the SMTP loop." I'd rather see 75 than a fake 95.

The role_type: "test" field is especially useful. Role addresses (test@, support@, sales@) behave differently in campaigns. They bounce less often, but they convert worse and can poison engagement metrics. Knowing that an address is a role account at a free provider changes how you score it. The email_provider: "googleworkspace" identifier is another layer. It lets you segment B2B versus B2C signups without parsing domains manually.

I'm still not sure if blocking signups on breach_count > 0 is the right call. A breached address can still belong to a real, engaged user who just had bad luck with a service they trusted. But ignoring breach data after Denmark feels negligent.

How to use Email Validator API

You can call the endpoint with curl:

curl --request GET \
  --url 'https://email-validator112.p.rapidapi.com/validate?email=test@gmail.com' \
  --header 'x-rapidapi-key: $RAPIDAPI_KEY' \
  --header 'x-rapidapi-host: email-validator112.p.rapidapi.com'
Enter fullscreen mode Exit fullscreen mode

Or with Python:

import requests, os

url = "https://email-validator112.p.rapidapi.com/validate"
params = {"email": "test@gmail.com"}
headers = {
    "x-rapidapi-key": os.getenv("RAPIDAPI_KEY"),
    "x-rapidapi-host": "email-validator112.p.rapidapi.com"
}

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

Subscribe at the RapidAPI listing: https://rapidapi.com/On13uka/api/email-validator112?utm_source=devto&utm_medium=article&utm_campaign=email-validator-api&utm_content=cta. Reference code and issue tracking are on GitHub: https://github.com/On13uka/email-validator-api.

Implications: what I'd change in my stack

If you're building signup flows, lead scoring, or outbound campaigns, here is what I'd do differently after this experiment.

  • Gate signups with a composite, not SMTP alone. Don't accept valid: true as the final word. Check is_trusted_identity if you have it, or combine smtp_verified, breach_status, and is_disposable yourself.
  • Handle HIBP key failures explicitly. If breach_status is null because of an API key error, don't default to "allow." Log it, retry, or fall back to a stricter rule. A missing signal is not a clean signal.
  • Use free-email detection and provider ID for segmentation. is_free_email and email_provider are useful for B2B versus B2C routing. A @protonmail.com signup is a different risk shape than a @gmail.com signup.
  • Flag role accounts before they poison your metrics. is_role and role_type catch addresses that technically exist but don't belong to a person.
  • Fix typos before validation. The syntax suggestion feature catches things like gmial.com → gmail.com. That's a cheap win.
  • Don't send campaigns to breached addresses if deliverability matters. My 1,000-email experiment found that one in five "verified" addresses were compromised. Those inboxes are more likely to be abandoned, monitored, or filtered.

The gap: where do you draw the line?

Where do you draw the line: would you block a signup when breach_status is "breached" but smtp_verified is true, or only when is_trusted_identity returns false?

I don't have a universal answer. A 2012 breach differs from a 2026 breach. A role account differs from a personal inbox, and a free provider differs from a custom domain.

Top comments (0)