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 in the two places where an email would otherwise go out:
| Where | What happens |
|---|---|
Signup (usermodel/forms.py::clean_email) |
the address is refused, so it never enters the database |
Send time (speedpycom/email_backends.py) |
the recipient is dropped before the message reaches your ESP |
Signup alone would not be enough. An address can arrive by other doors — a hand-typed team invitation, a CSV import, an address a user changes later — and every one of those bypasses the signup form. 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.
Where it runs¶
Two enforcement points, both calling email_domains.is_blocked():
Signup — usermodel/forms.py::clean_email, before the MX lookup. Two
in-memory set lookups cost nothing next to a DNS query with a five-second
budget, so there is no reason to pay for DNS in order to reject
mailinator.com.
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.