Skip to content

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.

# blocked_email_domains.txt
rival.example
.corp.example      # also covers mail.corp.example

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:

SPEEDPY_BLOCKED_EMAIL_DOMAINS=rival.example,another.example

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:

python manage.py test speedpycom.tests.test_email_domains

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():

Signupusermodel/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 timespeedpycom/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.