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.

myapp/disposable.py
"""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_type sends user@ and the domain, never the address, and keeps each answer for a day: temp, alias or normal.
  • Relays pass. Set ACCEPT_RELAYS to False to refuse relay services too.
  • A safe refresh. refresh_list downloads next to the old file and swaps it in with os.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_listed checks the domain and each parent, so inbox.mailinator.com matches mailinator.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_email refuses a malformed address before any lookup, with the pattern from our email 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?

myapp/validators.py
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?

Time to check one addressOne lookup in the offline set, beside the fastest, median and slowest of 20 free API calls, October 26, 2026, in milliseconds
  1. Offline set lookup0 ms
  2. API, fastest of 20157 ms
  3. API, median of 20226 ms
  4. 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 email aliases and Hide My Email says most sites should accept them. The case for refusing them is one person opening many accounts for a free trial.

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:

QuestionOffline listFree API
Time per checkAbout 1 microsecondA median of 226 ms in our test, then kept for a day
Network on the signup pathNoneOne call per domain a day, at most 2 seconds
What leaves your serverNothingThe domain, sent as user@ and the domain
Relay services such as Hide My EmailRefused with throwaway inboxesTold apart as alias
A brand-new throwaway serviceAfter the next list buildAlso caught by its mail servers

The API's answers are explained in the email check docs. Your own latency depends on your distance to the API.

#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:

test run, October 26, 2026, 00:15 UTC
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 MX lookup shows any domain's records.

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 disposable email checker, or browse the disposable email domains list.

Sources

  1. Validators, Django documentation 6.1
  2. Form fields: EmailField, Django documentation 6.1
  3. Form and field validation, Django documentation 6.1
  4. django/core/validators.py: EmailValidator, Django on GitHub
  5. os.replace, Python documentation
  6. urllib.request.urlopen, Python documentation
  7. RFC 3696 erratum 1690 (verified), RFC Editor, April 3, 2010
  8. Email checks API, FFraud
  9. Open data or the API, FFraud
  10. disposable-email-domains, 224,174 domains, FFraud, October 26, 2026
  11. RFC 7505: A "Null MX" No Service Resource Record for Domains That Accept No Mail, IETF, June 1, 2015