Python's standard library won't tell you whether an email address can receive mail. email.utils.parseaddr happily accepts x@y, and even strict validators only check the format. To know whether a mailbox exists, you have to ask the mail server. The simplest way to do that is an API.
This guide shows how to verify addresses from plain Python, a Django form, and a FastAPI endpoint, with sensible error handling and tests that don't spend money.
You'll need a MailRambo API key. The free plan includes 100 verifications a month, and free test keys (mr_test_...) return canned answers.
The client: ten lines with requests
# verify.py
import os
import requests
API = "https://www.mailrambo.com/v1/verify"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['MAILRAMBO_KEY']}"
class VerificationError(Exception):
def __init__(self, status, code, message):
super().__init__(message)
self.status, self.code = status, code
def verify_email(email: str) -> dict:
r = session.post(API, json={"email": email}, timeout=30)
body = r.json()
if not r.ok:
raise VerificationError(r.status_code, body.get("error"), body.get("message"))
return body # {"email", "deliverable", "reason", "credits_remaining"}
>>> verify_email("jane@acme.com")
{'email': 'jane@acme.com', 'deliverable': True, 'reason': 'mailbox_exists', 'credits_remaining': 49}
deliverable is True only when the mailbox is confirmed. Catch-all domains, disposable providers and mailboxes that don't answer clearly all come back as False, with a reason such as catch_all, disposable, mailbox_not_found or unverifiable. Syntactically invalid input returns invalid_syntax and doesn't use a credit.
Async version with httpx
For async frameworks, use httpx.AsyncClient and reuse it across requests:
import os
import httpx
client = httpx.AsyncClient(
base_url="https://www.mailrambo.com/v1",
headers={"Authorization": f"Bearer {os.environ['MAILRAMBO_KEY']}"},
timeout=30,
)
async def verify_email(email: str) -> dict:
r = await client.post("/verify", json={"email": email})
body = r.json()
if r.is_error:
raise VerificationError(r.status_code, body.get("error"), body.get("message"))
return body
Django: validate in the form
Put verification in the form's clean_email so the error appears next to the field:
# accounts/forms.py
import logging
from django import forms
from .verify import verify_email, VerificationError
log = logging.getLogger(__name__)
MESSAGES = {
"mailbox_not_found": "We couldn't find that mailbox. Check for typos?",
"disposable": "Please use a permanent email address.",
"catch_all": "We couldn't confirm this address. Try another one?",
}
class SignupForm(forms.Form):
email = forms.EmailField()
password = forms.CharField(widget=forms.PasswordInput)
def clean_email(self):
email = self.cleaned_data["email"]
try:
result = verify_email(email)
except (VerificationError, Exception) as e:
log.warning("email verification skipped: %s", e) # fail open
return email
if not result["deliverable"]:
raise forms.ValidationError(MESSAGES.get(result["reason"], "Please use a different email address."))
return email
forms.EmailField does the cheap syntax check first, so obviously broken input never reaches the API.
Tip: add
?mode=fastfor a free check that answers in under a second (syntax, typos likegmial.com, domains with no mail server, disposable providers). Use it inline on the form and keep the full check for when you need a confirmed yes/no. See fast mode.
FastAPI: validate in the endpoint
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, EmailStr
app = FastAPI()
class Signup(BaseModel):
email: EmailStr
password: str
@app.post("/signup", status_code=201)
async def signup(data: Signup):
try:
result = await verify_email(data.email)
except Exception:
result = {"deliverable": True} # fail open if verification is unavailable
if not result["deliverable"]:
raise HTTPException(400, detail={"field": "email", "reason": result["reason"]})
# ... create the user
return {"ok": True}
Error handling that won't hurt signups
Errors are always JSON, {"error": "<code>", "message": "..."}:
| Status | error | Recommended handling |
|---|---|---|
| 401 | invalid_api_key |
Configuration bug; alert |
| 402 | insufficient_credits |
Fail open, alert, top up |
| 429 | rate_limited |
Back off and retry (600 requests/min per key; full checks are fair-use at ~5/s) |
| 503 | provider_unavailable |
Retry once; the credit was refunded |
The rule: never block a real user because verification failed. Let them through and log it.
Testing without credits
Create a test key on the API Keys page and set it in your test settings. Test keys return answers based on the part before the @:
# tests/test_signup.py (MAILRAMBO_KEY=mr_test_... in the test environment)
def test_rejects_disposable(client):
r = client.post("/signup", json={"email": "disposable@example.com", "password": "x" * 12})
assert r.status_code == 400
def test_accepts_real_mailbox(client):
r = client.post("/signup", json={"email": "deliverable@example.com", "password": "x" * 12})
assert r.status_code == 201
Other magic addresses: catch_all@, not_found@, role@, spamtrap@, unverifiable@. See test mode in the docs.
Cleaning an existing list
For a CSV or a users table, batch up to 200 addresses per call and poll:
import time, uuid
def verify_many(emails):
start = session.post(
"https://www.mailrambo.com/v1/verify/batch",
json={"emails": emails},
headers={"Idempotency-Key": str(uuid.uuid4())}, # retry-safe
timeout=30,
).json()
while True:
batch = session.get(f"https://www.mailrambo.com/v1/verify/batch/{start['batch_id']}", timeout=30).json()
if batch["status"] == "completed":
return {row["email"]: row for row in batch["results"]}
time.sleep(3)
Summary
- Syntax validation (
EmailField,EmailStr) filters garbage; an API call confirms the mailbox. - Branch on
deliverable, and usereasonfor a helpful message. - Fail open on errors, test with
mr_test_keys, and useIdempotency-Keyfor batches.
Try an address in the free email verifier, or get a free API key.