Block disposable emails in Django and Python (free API, offline fallback)
Block throwaway email signups in Python and Django with a free API and a free list of 224,174 domains as backup, in tested code that keeps relay addresses.
To block disposable emails in Python, ask a free API about the part after the @ and keep a set of known throwaway domains for when the API cannot answer. In Django, wrap that check in a validator and pass it to the form's EmailField. The API tells relay addresses such as Apple's Hide My Email apart from throwaway inboxes. In our test an API call took a median of 226 milliseconds, and a set lookup about 1 microsecond.
#Where does the check go in a Django app?
In the form, as a validator on the email field. Django defines a validator as a callable that raises ValidationError when a value fails, and a field takes its validators through its validators argument (Django docs). The same validator also works on a model field.
Django's own EmailValidator checks the format and the length, up to 320 characters. Telling a throwaway domain from a lasting one is the gap this recipe fills.
#How do you build the check?
FFraud publishes its list of throwaway domains under the MIT license, one domain per line, and refreshes it every 30 minutes. On October 26, 2026 it held 224,174 domains in 3,544,412 bytes. This module asks the free API about the domain first and keeps the list for when the API cannot answer. Python's standard library is all it needs.
"""Block disposable email addresses: FFraud's free API decides, its MIT list answers when the API cannot.
Standard library only. refresh_list() downloads the list; run it daily from cron or a scheduler.
"""
import json
import os
import re
import time
import urllib.request
LIST_URL = "https://raw.githubusercontent.com/FFraud-com/disposable-email-domains/main/disposable-email-domains.txt"
API_URL = "https://api.ffraud.com/public/email/check"
LIST_PATH = os.environ.get("DISPOSABLE_LIST", "disposable-email-domains.txt")
# Relay services such as Apple's Hide My Email forward to an inbox a real person reads. Set to False to refuse them too.
ACCEPT_RELAYS = True
EMAIL = re.compile(
r"(?!\.)(?!.*\.\.)[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+(?<!\.)@"
r"(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?"
)
_domains: set[str] = set()
_mtime = 0.0
_checked = 0.0
_types: dict[str, tuple[float, str]] = {}
def refresh_list(url: str = LIST_URL, path: str = LIST_PATH, min_lines: int = 100_000) -> int:
"""Download a fresh copy next to the old one and swap it in. A short or failed download keeps the old file."""
part = path + ".part"
urllib.request.urlretrieve(url, part)
with open(part, encoding="utf-8") as f:
lines = sum(1 for line in f if line.strip() and not line.startswith("#"))
if lines < min_lines:
os.remove(part)
raise RuntimeError(f"only {lines} domains in the download, keeping the current list")
os.replace(part, path)
return lines
def _load() -> None:
"""Read the list into a set, and again when the file on disk changes (checked once a minute)."""
global _domains, _mtime, _checked
if _domains and time.monotonic() - _checked < 60:
return
_checked = time.monotonic()
try:
mtime = os.path.getmtime(LIST_PATH)
except OSError:
return # no list yet
if mtime != _mtime:
with open(LIST_PATH, encoding="utf-8") as f:
_domains = {line.strip().rstrip(".").lower() for line in f if line.strip() and not line.startswith("#")}
_mtime = mtime
def is_listed(domain: str) -> bool:
"""True when the domain or one of its parent domains is on the list."""
_load()
labels = domain.strip().rstrip(".").lower().split(".")
return any(".".join(labels[i:]) in _domains for i in range(len(labels) - 1))
def looks_like_email(email: str) -> bool:
at = email.rfind("@")
return len(email) <= 254 and 1 <= at <= 64 and EMAIL.fullmatch(email) is not None
def api_type(domain: str, timeout: float = 2.0) -> str | None:
"""'temp', 'alias' or 'normal', kept for a day per domain; None when the API cannot answer.
The type depends on the domain alone, so the API gets user@ and the domain, never the address.
"""
hit = _types.get(domain)
if hit and time.monotonic() - hit[0] < 86_400:
return hit[1]
req = urllib.request.Request(
API_URL,
data=json.dumps({"email": "user@" + domain}).encode(),
headers={"Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=timeout) as res:
kind = json.load(res).get("email_type")
except Exception:
return None
if not isinstance(kind, str):
return None
_types[domain] = (time.monotonic(), kind)
return kind
def check_email(value: str, timeout: float = 2.0) -> tuple[bool, str]:
"""(accept, reason): the API decides; without an answer, the offline list does."""
email = value.strip()
if not looks_like_email(email):
return False, "not an email address"
domain = email.rsplit("@", 1)[1].lower()
kind = api_type(domain, timeout)
if kind is None:
# The list cannot tell a relay from a throwaway inbox, so it refuses both.
if is_listed(domain):
return False, "disposable domain"
return True, "API unavailable, offline list only"
if kind == "temp" or (kind == "alias" and not ACCEPT_RELAYS):
return False, "disposable domain"
return True, "relay address" if kind == "alias" else "ok"- The API decides.
api_typesendsuser@and the domain, never the address, and keeps each answer for a day:temp,aliasornormal. - Relays pass. Set
ACCEPT_RELAYSto False to refuse relay services too. - A safe refresh.
refresh_listdownloads next to the old file and swaps it in withos.replace, which is atomic when it succeeds (Python docs). A download under 100,000 domains is refused, so an error page never empties your list. - Parent domains count.
is_listedchecks the domain and each parent, soinbox.mailinator.commatchesmailinator.com. The free API gives the same answer for that address. - Reloads without a restart. The set reloads when the file changes. The module looks at the file once a minute, because that check cost about 39 microseconds in our test, against 0.055 for the lookup itself.
- Format first.
check_emailrefuses a malformed address before any lookup, with the pattern from ouremail validation regex guide .
Run refresh_list() once a day from cron or your task scheduler, for example python -c "from myapp import disposable; disposable.refresh_list()". Set DISPOSABLE_LIST to the same file path in the job and in the app.
#How do you use it in a Django form?
from django import forms
from django.core.exceptions import ValidationError
from .disposable import check_email
def validate_not_disposable(value):
accept, reason = check_email(value)
if not accept and reason == "disposable domain":
raise ValidationError(
"Please use an email address you will keep. Throwaway inboxes are not accepted.",
code="disposable_email",
)
class SignupForm(forms.Form):
email = forms.EmailField(max_length=254, validators=[validate_not_disposable])The validator raises only for throwaway domains and leaves format errors to EmailField, whose own check already rejects addresses such as us..er@example.com. Set max_length=254: Django's default of 320 cites RFC 3696, whose verified erratum puts the practical limit at 254.
Django is not installed on our test machine, so our test ran the validator against a minimal stand-in for ValidationError and forms. The form wiring follows Django 6.1's documentation. Run the form once in your own project before you ship it.
#Why ask the API before the list?
- Offline set lookup0 ms
- API, fastest of 20157 ms
- API, median of 20226 ms
- API, slowest of 20650 ms
Source: FFraud test of disposable.py on Python 3.13, October 26, 2026
The API is slower, and it answers what the list cannot. FFraud's list holds relay services such as privaterelay.appleid.com, duck.com and mozmail.com, so the list alone refuses the people who use them. The API answers alias for those domains and temp for throwaway inboxes.
A relay forwards to an inbox a real person reads, and our guide to
Each answer is kept for a day per domain, so a busy form calls the API about once per domain a day. The two compare like this:
| Question | Offline list | Free API |
|---|---|---|
| Time per check | About 1 microsecond | A median of 226 ms in our test, then kept for a day |
| Network on the signup path | None | One call per domain a day, at most 2 seconds |
| What leaves your server | Nothing | The domain, sent as user@ and the domain |
| Relay services such as Hide My Email | Refused with throwaway inboxes | Told apart as alias |
| A brand-new throwaway service | After the next list build | Also caught by its mail servers |
The API's answers are explained in the
#What happens when the API does not answer?
check_email gives the API 2 seconds through urlopen's timeout. If the call fails or times out, the list decides: a listed domain is refused, and any other address is accepted. The list cannot tell a relay from a throwaway inbox, so relay addresses are refused until the API answers again.
Failing closed would block every signup during an outage. For a signup form, letting one new throwaway service through costs less. A confirmation email still decides whether the inbox works.
#How do you test it?
We ran the module and the validator on Python 3.13 on October 26, 2026, with one API call a second at most. A 1 ms timeout stands in for an API outage:
Python 3.13.7 2026-10-26T00:15:10+00:00
refresh_list: 224174 domains, 3544412 bytes, 0.21 s
guard: only 2000 domains in the download, keeping the current list | file kept out: True
load into a set: 224174 domains, 597 ms, 20.9 MB held (peak 21.4 MB)
is_listed('mailinator.com') = True, 0.90 microseconds per call
is_listed('gmail.com') = False, 0.90 microseconds per call
is_listed('inbox.mailinator.com') = True, 0.99 microseconds per call
API latency, 20 calls: median 226 ms, min 157 ms, max 650 ms
check_email('test@mailinator.com') -> (False, 'disposable domain'), 272.8 ms, API calls: 1
check_email('user@inbox.mailinator.com') -> (False, 'disposable domain'), 262.6 ms, API calls: 1
check_email('test@privaterelay.appleid.com') -> (True, 'relay address'), 379.6 ms, API calls: 1
check_email(' team@ffraud.com\n') -> (True, 'ok'), 222.3 ms, API calls: 1
check_email('team@ffraud.com') -> (True, 'ok'), 0.0 ms, API calls: 0
check_email('us..er@ffraud.com') -> (False, 'not an email address'), 0.0 ms, API calls: 0
what the API received: {"email": "user@mailinator.com"} {"email": "user@inbox.mailinator.com"} {"email": "user@privaterelay.appleid.com"} {"email": "user@ffraud.com"}
check_email('test@mailinator.com', timeout=0.001) -> (False, 'disposable domain'), 34.8 ms, API calls: 1
check_email('test@privaterelay.appleid.com', timeout=0.001) -> (False, 'disposable domain'), 42.1 ms, API calls: 1
check_email('team@ffraud.com', timeout=0.001) -> (True, 'API unavailable, offline list only'), 9.6 ms, API calls: 1
check_email('test@privaterelay.appleid.com') with ACCEPT_RELAYS = False -> (False, 'disposable domain'), 197.3 ms, API calls: 1
validate_not_disposable('test@mailinator.com'): ValidationError code='disposable_email' 'Please use an email address you will keep. Throwaway inboxes are not accepted.'
validate_not_disposable('test@privaterelay.appleid.com'): passes
validate_not_disposable('team@ffraud.com'): passes
validate_not_disposable('us..er@ffraud.com'): passes
SignupForm.email kwargs: {'max_length': 254, 'validators': [<function validate_not_disposable at 0x000002736C5D7B00>]}The second check at ffraud.com cost no API call. With the timeout cut to 1 millisecond, the list decided: it refused mailinator.com and the Apple relay and accepted ffraud.com, as designed.
#Questions people ask
How do I check in Python whether an email domain is disposable?
Ask the free API with api_type above, which sends only the domain. For an offline answer, take the part after the last @, lowercase it, and look up the domain and its parents in a set, as is_listed does in about a microsecond.
How do I add a custom validator to a Django EmailField?
Write a function that raises ValidationError and pass it in the field's list: forms.EmailField(validators=[validate_not_disposable]). Django runs it along with its own format check.
Does Django's EmailValidator block disposable addresses?
No. It checks the format and length only, so a throwaway domain passes it. Add a validator like the one above, and set max_length=254.
Should I also check the domain's MX records?
An MX check answers another question: whether the domain takes mail at all. Throwaway services do, so they pass it: on October 26, 2026, mailinator.com listed two MX records. The check does stop a domain like example.com, whose null MX says it accepts no mail (RFC 7505). The
Should I check emails against a local list or call an API?
Both, API first. The API tells relay addresses from throwaway inboxes, and the list answers when the API is down.
Will this block Apple's Hide My Email?
Not as shipped. The API answers alias for relay services, and check_email accepts them unless you set ACCEPT_RELAYS to False. While the API is down, the list refuses them.
What is the maximum length of an email address?
254 characters, with at most 64 before the @ (RFC 5321 and RFC 3696 erratum 1690). Django's EmailField allows 320 unless you set max_length.
To test one address by hand, paste it into the free
Sources
Validators Form fields: EmailField Form and field validation django/core/validators.py: EmailValidator os.replace urllib.request.urlopen RFC 3696 erratum 1690 (verified) Email checks API Open data or the API disposable-email-domains, 224,174 domains RFC 7505: A "Null MX" No Service Resource Record for Domains That Accept No Mail