Blocked email domains
What these lists are for¶
They are a list of domains you do not send email to. That is the whole purpose. It is not access control, and it is not an authorization mechanism — it answers one question, "may we send mail here?", and the answer is no.
Because that is the purpose, it is enforced where an address enters and again where an email would otherwise go out:
| Where | What happens |
|---|---|
Signup (UsermodelSignupForm.clean()) |
the address is refused, so it never enters the database |
Adding an address to an account (UsermodelAddEmailForm.clean_email) |
refused before it is stored, so no confirmation mail is attempted |
Send time (speedpycom/email_backends.py) |
the recipient is dropped before the message reaches your ESP |
The entry checks alone would not be enough. An address can still arrive by doors
that do not run the validator — an admin who sets User.email directly, a CSV
import that skips the check — and every one of those bypasses the forms. The
send-time check is what makes the rule hold however the address got in.
Do not read this as a security boundary. Anyone determined to sign up can use another domain. It stops mail going somewhere you decided not to mail, and that is all it claims.
The two lists¶
SpeedPy ships two, both consulted by
speedpycom/services/email_domains.py:
| List | File | Who owns it |
|---|---|---|
| Throwaway-mail providers (~8,000 domains) | speedpycom/data/disposable_email_blocklist.conf |
upstream — replaced wholesale on refresh |
| Your own | blocked_email_domains.txt (repo root) |
you — upstream never writes to it |
The split is the point. The bundled file is overwritten every time it is refreshed, so a domain you add to it disappears without a trace. Your domains belong in the second list.
On by default
SPEEDPY_BLOCK_DISPOSABLE_EMAIL_DOMAINS defaults to True. If you are
upgrading an existing project, signups from mailinator and similar services
start being refused as soon as you take this version. Set it to False if
you were relying on accepting them — for example if your own test suite or
demo flow uses a throwaway address.
Adding your own domains¶
One domain per line. # starts a comment, blank lines are ignored, matching is
case-insensitive.
A leading dot covers subdomains as well as the domain itself. A bare entry covers only itself — blocking every subdomain of a bare entry would surprise people, and plenty of products live on a subdomain of a domain they do not control.
For a one-off block without deploying a file change:
That is merged with the file, not a replacement for it.
Reasonable entries: a competitor you would rather not onboard, a partner who asked you to route their staff through SSO instead, a domain producing abusive signups, or a free-mail provider your plan rules exclude.
Refreshing the bundled list¶
Source: disposable-email-domains/disposable-email-domains, CC0-1.0. No attribution is required; it is recorded so you know where to get a fresh copy.
curl -sS -o speedpycom/data/disposable_email_blocklist.conf \
https://raw.githubusercontent.com/disposable-email-domains/disposable-email-domains/main/disposable_email_blocklist.conf
There is no automation on purpose. A signup gate that changes what it rejects on a schedule, with nobody watching, is not something you want. Refresh by hand, read the diff, then run:
That suite asserts the list contains none of twenty real providers. It is the most useful test in the feature, because of how this fails: one bad entry in an upstream pull request and signups from Gmail stop working. Nothing errors, nothing is logged, and nobody reports it — they cannot sign up in order to report it. You would see it as a quiet dip in conversions, weeks later.
Why this list and not a bigger one¶
There are aggregated lists with 100,000+ domains. This one has about 8,000, vetted. Refusing one real customer costs more than letting a throwaway signup through, and the large lists carry far more false positives.
What the person is told¶
One message, from email_domains.BLOCKED_EMAIL_MESSAGE, whichever list matched:
We cannot accept this email address. Please use a different one, or contact support if you think this is a mistake.
It deliberately does not say why. A different wording per reason is the diagnosis: tell somebody "disposable addresses are not accepted" and they know exactly what to try next, and "this domain is blocked" leaks a business decision that is nobody's business.
The cost is real — a legitimate customer gets a wall with no explanation — which
is why the message points at support. Keep the list short, and make sure support
can find out that an address was refused. Refusals are logged as
email_domain_refused with the domain and which list matched.
If you add another reason to refuse an address, reuse this message rather than writing a second one. Two wordings drift apart, and the difference between them is the thing you were trying not to disclose.
On the signup page the refusal appears only after the bot check passes. When reCAPTCHA is configured, the signup form withholds the blocklist and deliverability verdict until the CAPTCHA has passed. The reason is subtle: Django runs every field cleaner and renders every field error together, so a check on the email field alone would answer "is this domain blocked?" to a caller who never solved the CAPTCHA — one domain per request rebuilds the whole list, which is exactly what the list exists to prevent. So a POST with no token, or a failed one, shows only the CAPTCHA error and says nothing about the domain; a legitimate visitor whose CAPTCHA passes still sees the refusal as before.
Where it runs¶
Three enforcement points, all calling email_domains.is_blocked():
Signup — usermodel/forms.py, from the form's clean() (once the CAPTCHA
has passed — see "What the person is told"), before the MX lookup. Two in-memory
set lookups cost nothing next to a DNS query with a two-second budget, so there
is no reason to pay for DNS in order to reject mailinator.com.
Adding an address to an account — usermodel/forms.py, from
UsermodelAddEmailForm.clean_email on /accounts/email/, after allauth's own
checks. Same reason: every branch of that form sends mail to the address, so a
blocked or undeliverable one is refused before it is stored. This is a
self-service door only; the Django admin can set User.email and EmailAddress
rows directly, and allauth headless (if you enable it) builds on AddEmailForm
while ignoring ACCOUNT_FORMS, so neither runs this check — send time is the
backstop for both.
Send time — speedpycom/email_backends.py, the backend post_office
delegates to. It drops refused recipients from to, cc and bcc, and if a
message has none left it is not sent at all. The same guard also enforces the
bounce suppression list, because both answer the same question.
A message whose every recipient was dropped reports 0 sent, which is the
truth. post_office marks it sent rather than retrying, and logs
email_recipients_suppressed with the count and subject — that log line is the
only record that a specific message was withheld, so keep it somewhere support
can read.
Direct backend construction bypasses this
The guard is post_office's delegate, so it covers send_mail,
send_mass_mail, EmailMessage.send, mail_admins, allauth, and anything
else using the configured backend. It cannot cover code that builds its own
connection:
connection = get_connection("django.core.mail.backends.smtp.EmailBackend")
EmailMessage(to=["user@mailinator.com"], connection=connection).send()
Nothing can intercept that from here. Treat explicit backend construction in application code as a mistake to catch in review.
Address spellings that used to slip past¶
Both of these were real bypasses, found by review and fixed. They are worth knowing about if you write a similar check yourself:
Display names. Django sends "Customer <user@mailinator.com>" quite
happily, and splitting at the last @ yields mailinator.com> — which matches
nothing. Addresses are parsed with email.utils.parseaddr before the domain is
extracted.
Unicode domains. The bundled list contains punycode entries such as
xn--5nx.cc, and Django converts a Unicode domain to punycode on its way out.
Comparing the spellings literally let user@灵.cc through, and it was then
delivered. Every list entry and every recipient domain is canonicalised to the
same IDNA form, so the two spellings cannot disagree.
Caching¶
Both lists are read once per process and cached. They are static files, and a deploy is the natural moment for a change to take effect.
That cache is per process. Editing a list file does not reach a running web
worker or Celery worker — restart or redeploy for a change to apply everywhere.
override_settings in tests invalidates it automatically, so a test does not
have to remember to clear anything.
Failure behaviour¶
A missing, unreadable, empty or badly encoded list fails open: it is logged and treated as empty. An unreadable blocklist is your problem, and refusing all mail over it is worse than missing a block.
The consequence is worth stating plainly: if the bundled file goes missing, the
feature silently stops working. Nothing errors. The
test_the_list_is_actually_loaded test exists so that a packaging mistake fails
the suite rather than production.