DEV Community

Cover image for Email validation in production: regex, MX, SMTP, and the trade-offs nobody tells you
Pady
Pady

Posted on

Email validation in production: regex, MX, SMTP, and the trade-offs nobody tells you

Most email validation tutorials stop at regex. That's fine for a toy project. In production, bad emails cost you money — bounced sends hurt sender reputation, disposable signups inflate metrics, and typos mean a real user never gets your onboarding email.

This isn't a tutorial about regex. It's about what to actually check, when, and what the trade-offs are.


What to validate on the frontend

Keep it fast and non-blocking. The frontend's job is to catch obvious mistakes before the user submits, not to be the final authority.

Do:

  • Basic format check with a pragmatic validator that catches obvious mistakes
  • Typo suggestion for common domains
// Typo suggestion: if domain is close to a known provider, suggest the correction
const knownDomains = ["gmail.com", "yahoo.com", "hotmail.com", "outlook.com"]

function suggestTypo(email) {
  const [local, domain] = email.split("@")
  if (!domain) return null
  // Levenshtein distance ≤ 2
  const match = knownDomains.find(d => levenshtein(domain, d) <= 2 && domain !== d)
  return match ? `${local}@${match}` : null
}
Enter fullscreen mode Exit fullscreen mode

Don't:

  • Block submission on suspicious emails — you'll lose real users
  • Make async calls (MX, SMTP) from the frontend — latency kills conversion

UX example

user types: john@gmial.com

→ Show inline: "Did you mean john@gmail.com?"
  [Yes, fix it] [No, keep it]

→ Never block. Always let them proceed.
Enter fullscreen mode Exit fullscreen mode

What to validate on the backend

The backend is where heavier checks belong. In most apps, they should run outside the critical request path.

1. Format (again)

Always re-validate on the backend. Never trust client input.

from email_validator import validate_email, EmailNotValidError

def check_format(email: str) -> tuple[bool, str | None]:
    try:
        info = validate_email(email, check_deliverability=False)
        return True, info.normalized
    except EmailNotValidError:
        return False, None
Enter fullscreen mode Exit fullscreen mode

2. MX records

Does the domain have mail servers? This is fast (~100ms) and catches a large class of bad emails.

import dns.resolver

async def has_mx(domain: str) -> bool:
    try:
        records = await dns.asyncresolver.resolve(domain, "MX")
        return len(records) > 0
    except Exception:
        return False
Enter fullscreen mode Exit fullscreen mode

Trade-offs:

  • Fast and reliable
  • Some legitimate companies have unusual DNS setups — rare but real
  • Doesn't tell you if the specific mailbox exists

3. SMTP verification

Actually connects to the mail server and asks if the mailbox exists, without sending an email.

import aiosmtplib

async def check_smtp(email: str, mx_host: str) -> bool | None:
    smtp = aiosmtplib.SMTP(hostname=mx_host, port=25, timeout=10)
    try:
        await smtp.connect()
        await smtp.ehlo()
        await smtp.mail("verify@yourdomain.com")
        code, _ = await smtp.rcpt(email)
        return code == 250
    except Exception:
        return None  # unknown, not invalid
    finally:
        try:
            await smtp.quit()
        except Exception:
            pass
Enter fullscreen mode Exit fullscreen mode

Trade-offs — the ones nobody mentions:

  • Latency: 2–5 seconds per check. Never do this synchronously in a request.
  • Catch-all servers: Many servers return 250 for any address. You need a second check with a random address to detect this.
  • Greylisting: Some servers temporarily reject unknown senders — your SMTP check returns false even though the email is valid.
  • Port 25 blocking: Many cloud providers (AWS, GCP, Railway) block outbound port 25. You either need a VPS or a dedicated SMTP checking service.
  • False positives and negatives: SMTP checks are noisy enough that you should treat them as a signal, not as ground truth.

Verdict: Use SMTP check for high-value flows (paid signups, enterprise onboarding). Don't use it on every signup form.


What you should never block

This is where most implementations go wrong.

Role emails (admin@, info@, support@) are suspicious but not invalid. A developer testing your API will use their work address. A small business owner might use info@theircompany.com as their primary email. Flag them, don't block them.

Free providers (Gmail, Yahoo, Hotmail) are fine for most B2C products. Blocking them is a conversion killer.

Disposable emails — this is context-dependent. For a free tier with limited resources, blocking makes sense. For a paid product, you might want to let them through and restrict features instead.

Better UX than blocking:

"Temporary email addresses are allowed, but some features
(email notifications, account recovery) may not work."
Enter fullscreen mode Exit fullscreen mode

Decision flow

Input email
    │
    ├─► Format valid?
    │       No → reject with message
    │
    ├─► Typo detected? → suggest correction (non-blocking)
    │
    ├─► MX records exist?
    │       No → in most apps, reject
    │
    ├─► Disposable domain?
    │       Yes → allow with warning or restrict features
    │
    ├─► Role email?
    │       Yes → flag internally, don't block
    │
    └─► SMTP check (only for high-value flows)
            Delivered → high confidence
            Catch-all → medium confidence
            Timeout/error → unknown, don't penalize
            Rejected → invalid
Enter fullscreen mode Exit fullscreen mode

Quality score over binary valid/invalid

Binary validation loses nuance. A simple scoring model can work better:

Signal Weight Reasoning
MX valid 0.40 Hard requirement
SMTP delivered 0.30 Strong signal when available
Not disposable 0.20 Business quality signal
Not role 0.10 Engagement quality signal
def quality_score(mx: bool, smtp: bool | None, disposable: bool, role: bool) -> float:
    score = 0.0
    if mx:
        score += 0.40
    if smtp is True:
        score += 0.30
    elif smtp is None:
        score += 0.15  # unknown — partial credit
    if not disposable:
        score += 0.20
    if not role:
        score += 0.10
    return round(min(score, 1.0), 2)
Enter fullscreen mode Exit fullscreen mode

Use the score to make nuanced decisions:

  • >= 0.8 → accept silently
  • 0.5–0.8 → accept with a note
  • < 0.5 → warn or restrict

If you don't want to implement this yourself

I also published an Email Validator API that bundles these checks into a single request.

import requests

response = requests.post(
    "https://email-validator85.p.rapidapi.com/v1/validate/email",
    json={"email": "user@gmail.com", "check_smtp": False},
    headers={
        "X-RapidAPI-Key": "YOUR_KEY",
        "X-RapidAPI-Host": "email-validator85.p.rapidapi.com",
    },
)
print(response.json())
Enter fullscreen mode Exit fullscreen mode

Free tier available (100 req/month). But honestly, if you only need format + MX + basic disposable detection, the code in this post is enough to get you 80% of the way there.


Summary

  • Frontend: format + typo suggestion, never block
  • Backend: MX always, SMTP only when it matters
  • Never block role emails or free providers outright
  • Use a score, not binary valid/invalid
  • SMTP has real failure modes — account for them

Top comments (0)