Beginner Guides

FastAPI Email Verification API: How I Built It

Dimuthu Harshana By Dimuthu Harshana July 9, 2026 10 min read

My Mautic contact list was filling up with garbage. Fake domains, throwaway addresses, bot signups that never open a single email. Every campaign I sent had a chunk of it just bouncing into nowhere.

Quick Answer: I built a small FastAPI email verification API — it checks every email against four things: valid syntax, a disposable-domain blocklist, role-based prefixes like info@ or admin@, and a live DNS MX record lookup, before anything is trusted. It’s live on my own tools API today, and you can build your own copy in about 10 minutes.

What You Need

  • Python 3.9 or newer
  • About 10 minutes and a terminal
  • A Mautic instance or any signup form, if you want to wire this in for real — optional for just trying the API
  • Basic FastAPI familiarity helps, but every route here is short enough to follow cold

What I Tried First — And Why It Wasn’t Enough

My first fix was a cleanup script, not a filter. I wrote mautic-cleanup.py to scan every contact already in Mautic through its REST API, flag anything on a disposable-domain list or still unconfirmed after 30 days, and delete the flagged ones.

Advertisement

Get free WordPress & AI tips

Join 500+ readers. No spam, unsubscribe anytime.

DISPOSABLE_DOMAINS = {
    "mailinator.com", "guerrillamail.com", "tempmail.com", "throwaway.email",
    "yopmail.com", "sharklasers.com", "trashmail.com", "dispostable.com",
    # ...786 domains total in my full list
}
# In production this loads from a text file instead of a hardcoded set —
# same idea, just easier to keep adding to as new disposable domains show up.

CUTOFF_DAYS = 30  # unconfirmed contacts older than this are considered bots

def is_bot(contact):
    reasons = []
    email = (contact.get("fields", {}).get("core", {}).get("email", {}).get("value") or "").lower()
    if not email:
        reasons.append("no email")
        return reasons

    domain = email.split("@")[-1] if "@" in email else ""
    if domain in DISPOSABLE_DOMAINS:
        reasons.append(f"disposable domain: {domain}")

    confirmed = contact.get("fields", {}).get("core", {}).get("confirmed", {}).get("value")
    date_added_str = contact.get("dateAdded", "")
    if str(confirmed) in ("0", "false", "") and date_added_str:
        date_added = datetime.fromisoformat(date_added_str.replace("Z", "+00:00"))
        age_days = (datetime.now(timezone.utc) - date_added).days
        if age_days >= CUTOFF_DAYS:
            reasons.append(f"unconfirmed {age_days}d")

    return reasons

Any contact that comes back with a reason gets flagged. The script pulls every contact through Mautic’s /api/v2/contacts endpoint first, paginating 200 at a time, then runs this check against each one.

It ran as a dry run by default and only deleted with --delete. It worked — I cleared out a real batch of bots the first time I ran it. But it’s reactive: junk still landed in Mautic first, and I was cleaning up a mess instead of stopping it at the door.

What Actually Fixed It — Building a FastAPI Email Verification API

The real fix was moving the check to before a signup gets accepted, not after. So I built a standalone FastAPI endpoint — /email/verify — that scores every address before anything touches Mautic.

I keep it as its own router file, app/routers/email_tools.py, with the disposable-domain list in a separate app/data/disposable_domains.txt so it’s not baked into the code. One line in main.py wires it into the app:

app.include_router(email_tools.router, tags=["email_tools"])

That’s the same pattern most FastAPI apps use to stay organized as you add more tools — each feature gets its own router file instead of one giant main.py.

The Four Checks

Each email goes through four gates, in order, and stops at the first one it fails:

import re
import dns.resolver

SYNTAX_RE = re.compile(r'^[^@\s]+@[^@\s]+\.[^@\s]{2,}$')

ROLE_PREFIXES = {
    "info", "admin", "support", "noreply", "no-reply", "postmaster",
    "webmaster", "contact", "sales", "help", "billing", "abuse",
    "security", "hostmaster", "mailer-daemon", "marketing", "newsletter",
}

def check_mx(domain: str) -> bool:
    try:
        dns.resolver.resolve(domain, "MX")
        return True
    except Exception:
        return False

def verify_single(email: str) -> dict:
    email = email.strip().lower()
    checks = {"syntax": False, "has_mx": False, "is_disposable": False, "is_role_based": False}
    score = 0

    if not SYNTAX_RE.match(email):
        return {"email": email, "is_valid": False, "score": 0, "checks": checks, "reason": "Invalid email format"}
    checks["syntax"] = True
    score += 25

    local, domain = email.split("@", 1)

    if domain in DISPOSABLE_DOMAINS:
        checks["is_disposable"] = True
        return {"email": email, "is_valid": False, "score": score, "checks": checks, "reason": "Disposable email domain not accepted"}
    score += 25

    if local in ROLE_PREFIXES:
        checks["is_role_based"] = True
        return {"email": email, "is_valid": False, "score": score, "checks": checks, "reason": "Role-based addresses not accepted (e.g. info@, admin@)"}
    score += 25

    has_mx = check_mx(domain)
    checks["has_mx"] = has_mx
    if not has_mx:
        return {"email": email, "is_valid": False, "score": score, "checks": checks, "reason": "Email domain has no mail server (MX record missing)"}
    score += 25

    return {"email": email, "is_valid": True, "score": score, "checks": checks, "reason": None}

The scoring stops early. If the syntax is wrong, you get a score of 0 back immediately — there’s no point checking MX records on something that isn’t even shaped like an email. Each passed check adds 25 points, so a fully valid address always scores 100.

💡 Dimu’s Note: the role-based rejection was the one I almost skipped. But info@company.com is a real inbox someone reads — just not the person who signed up for your newsletter. Blocking it kept my open-rate numbers honest.

Why the Checks Run in a Thread Executor

dns.resolver.resolve() is blocking — it waits on a real network call, and so does reading the disposable-domain file. Call either one directly inside an async def route and it freezes the whole event loop, not just that one request. Every other request waiting on your API stalls with it.

The fix is one line per call:

loop = asyncio.get_event_loop()
result = await loop.run_in_executor(None, verify_single, email)

run_in_executor pushes the blocking function onto a background thread pool and hands control straight back to the event loop. Your API stays responsive to every other request while that one DNS lookup is still working. This is the part most FastAPI tutorials skip — and it’s the difference between an endpoint that scales and one that falls over the moment two people hit it at once.

Three Endpoints

I exposed the checker three ways, so it fits however I end up calling it:

@router.get("/email/verify")
async def verify_email_get(email: str = Query(...)):
    loop = asyncio.get_event_loop()
    result = await loop.run_in_executor(None, verify_single, email)
    return {"success": True, "message": f"Email is {'valid' if result['is_valid'] else 'invalid'}", "result": result}

@router.post("/email/verify")
async def verify_email_post(body: dict):
    email = body.get("email", "")
    loop = asyncio.get_event_loop()
    result = await loop.run_in_executor(None, verify_single, email)
    return {"success": True, "message": f"Email is {'valid' if result['is_valid'] else 'invalid'}", "result": result}

@router.post("/email/verify-bulk")
async def verify_email_bulk(body: BulkEmailVerifyRequest):
    emails = body.emails[:50]  # hard cap so one request can't hammer DNS 5000 times
    loop = asyncio.get_event_loop()
    tasks = [loop.run_in_executor(None, verify_single, e) for e in emails]
    results = await asyncio.gather(*tasks)
    valid_count = sum(1 for r in results if r["is_valid"])
    return {"success": True, "result": {"results": results, "summary": {"total": len(results), "valid": valid_count, "invalid": len(results) - valid_count}}}

The bulk endpoint gathers all the executor calls concurrently with asyncio.gather, so 50 emails don’t verify one at a time — they all run in parallel threads at once.

Try It Yourself — Self-Host in About 10 Minutes

You don’t need my full tools API to run this — it’s one router file. Here’s the minimal setup:

pip install fastapi uvicorn dnspython

Drop verify_single, check_mx, and the three routes above into a single main.py, wire the router into a FastAPI app, then run it:

from fastapi import FastAPI, APIRouter, Query
from pydantic import BaseModel
from typing import List

router = APIRouter()

class BulkEmailVerifyRequest(BaseModel):
    emails: List[str]

# ...paste verify_single(), check_mx(), and the three @router routes here...

app = FastAPI()
app.include_router(router)
uvicorn main:app --host 0.0.0.0 --port 8000

Then test it:

curl "http://localhost:8000/email/verify?email=test@mailinator.com"
{
  "success": true,
  "message": "Email is invalid",
  "result": {
    "email": "test@mailinator.com",
    "is_valid": false,
    "score": 25,
    "checks": {"syntax": true, "has_mx": false, "is_disposable": true, "is_role_based": false},
    "reason": "Disposable email domain not accepted"
  }
}

FastAPI also builds an interactive docs page automatically — mine’s at /customdocs since I renamed the default route, yours will sit at localhost:8000/docs if you didn’t touch it. You can test all three endpoints straight from the browser there, no curl needed.

If you’re putting this on a real domain instead of localhost, I run mine on a Contabo VPS — cheap enough that a small side API like this barely registers on the bill.

Quick Reference

Endpoint Method Body / Query Use case
/email/verify GET ?email=user@example.com Quick single check, e.g. from a frontend form
/email/verify POST {“email”: “…”} Same check, from server-to-server calls
/email/verify-bulk POST {“emails”: […]}, max 50 Cleaning an existing list before import
curl -X POST "http://localhost:8000/email/verify" \
  -H "Content-Type: application/json" \
  -d '{"email": "someone@gmail.com"}'
curl -X POST "http://localhost:8000/email/verify-bulk" \
  -H "Content-Type: application/json" \
  -d '{"emails": ["a@gmail.com", "b@mailinator.com", "info@company.com"]}'

Advanced Tips

My live version sits behind an API key, since it shares one FastAPI app with a dozen other tools I’ve built. Instead of decorating every single route with a dependency, I gate the whole app in one place:

class APIKeyMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request: Request, call_next):
        if request.url.path not in WHITELISTED_PATHS:
            api_key = request.headers.get(API_KEY_NAME)
            if api_key != API_KEY:
                return JSONResponse(status_code=401, content={"detail": "Invalid API key"})
        return await call_next(request)

app.add_middleware(APIKeyMiddleware)

One middleware class, one line to register it, every route protected except the ones I explicitly whitelist, like the docs page. If you’re only running the email verifier alone, you probably don’t need this yet — but the moment you add a second tool to the same app, it’s worth doing before you’re pasting the same Depends() onto every route.

The 50-email cap on the bulk endpoint is doing similar work. It’s not a technical limit, it’s a guardrail — without it, one request could try to trigger 5,000 DNS lookups and tie up your thread pool for everyone else.

Frequently Asked Questions

Does this replace SMTP-level email verification?

No. This checks that the domain can receive mail via its MX record, not that the specific mailbox exists. Full SMTP verification means connecting to the mail server and asking directly — slower, and a lot of providers block or fake that response anyway.

Why not just use a paid email verification API?

Cost, mostly. Most paid APIs charge per lookup once you’re past a small free tier. For a Mautic list that’s mostly WordPress and dev-tool signups, syntax plus disposable plus role plus MX catches the obvious junk without a monthly bill.

How do I keep the disposable domain list current?

Mine started small and grew every time a new one slipped through in Mautic — it’s at 786 domains now. There are public lists on GitHub you can seed from, then keep adding whatever gets past you.

Will this catch every bot signup?

No — a bot using a real Gmail address passes every check here. This stops the lazy, high-volume junk: disposable domains, malformed addresses, dead domains — which was most of what was hitting my Mautic list.

Final Thoughts

The real lesson wasn’t the code — it was catching the problem at the door instead of mopping up after. mautic-cleanup.py still runs occasionally as a backstop, but the verification API is what actually changed my numbers.

This endpoint isn’t wired into my Mautic signup form yet — that’s next. I’m also planning to open a version of this up as a free public tool on aibuilttools.com, where you’ll be able to grab an API key by joining my list — if that’s useful to you, keep an eye on that page.

Advertisement
Dimuthu Harshana
Written by

Leave a Reply

This site contains affiliate links. If you make a purchase through one of these links, we may earn a small commission at no extra cost to you. Learn more.