[EDIT: just joining the discussion? Jump down to #comment 3 for the current proposal.]
I’m proposing we rework how Django’s mail APIs handle fail_silently, and seeking feedback and consensus from the group before opening a ticket.
The proposal is to hoist fail_silently handling out of individual EmailBackend implementations, and instead wrap the two places Django calls backend.send_messages() with a broad try/except to:
- make
fail_silentlymore predictable, by ignoring all exceptions consistently across all backends (including message serialization and address validation errors that currently may or may not be ignored, depending on the email backend in use) - address an API shape problem that will conflict with the upcoming dict-based
EMAIL_PROVIDERSfeature (ticket-35514, draft DEP 0018)
(This came up while drafting the EMAIL_PROVIDERS DEP, and it’s currently included in there. @nessita and I thought it might be helpful to break it into a separate task and discussion.)
Background
Most Django mail APIs take a fail_silently arg meant to ignore errors during sending. One use is avoiding cascading failures in error reporting, like in calls to mail_admins() from the logging AdminEmailHandler or mail_managers() from the BrokenLinkEmailMiddleware.
Although presented and described as a modifier to the email sending process, fail_silently is currently implemented as EmailBackend configuration (a backend __init__() param). This creates three problems:
-
Individual backend implementations apply their own, inconsistent interpretations of which errors to ignore. Django’s SMTP backend ignores only
smtplib.SMTPException(plusOSError, but only during connection setup and SSL handshaking). But there are also several third-party email backend packages,[1] and each has a slightly different approach tofail_silentlyhandling. -
If you pass both
connectionandfail_silentlyto the same mail function, Django silently ignoresfail_silentlyand instead fails loudly
. (ticket-36894. The connectionis already initialized, so it’s too late to applyfail_silently.) -
For related reasons, this conflicts with the upcoming
EMAIL_PROVIDERSfeature. The proposedmail.providers[alias]would return a fully configuredEmailBackendinstance (like similar APIs for tasks, storages, caches and db connections). Since it’s already initialized, it’s too late to changefail_silentlyfor that provider instance.
Proposal
To address all these issues, I propose[2] moving fail_silently handling out of individual EmailBackend implementations. Instead, we’d catch all exceptions where the message is being sent.
Specifically, we’d change EmailMessage.send():
class EmailMessage:
def send(self, fail_silently=False):
if not self.recipients():
return 0
- return self.get_connection(fail_silently).send_messages([self])
+ connection = self.get_connection() # no fail_silently
+ try:
+ return connection.send_messages([self])
+ except Exception:
+ if fail_silently: # see compatibility note
+ return 0
+ raise
- Errors in
get_connection()are never ignored, either before or after the change. (These are typically settings and configuration errors from theEmailBackendconstructor. Despite the name,get_connection()doesn’t actually open any connections.) - After the change, all errors in
connection.send_messages()would be ignored withfail_silently. - We’d make a similar change to
send_mass_mail()(which is the only other Django code that callssend_messages()directly; everything else delegates toEmailMessage.send()). - When we implement
EMAIL_PROVIDERS, theconnection = self.get_connection()line will get replaced withconnection = mail.providers[using](with a newusingargument for the provider alias).
This change would take effect immediately in Django 6.1, without deprecation. (It’s arguably a bug fix or cleanup: it makes EmailMessage.send(fail_silently=True) match the docs and most callers’ expectations.)
There are two related deprecations and a compatibility shim:
- The
fail_silentlyargument todjango.core.mail.get_connection()would be deprecated in Django 6.1 and removed in Django 7.0. (Note that the entireget_connection()function would also be deprecated as part ofEMAIL_PROVIDERS.) - The (inconsistent) implementations of
fail_silentlywithin Django’s built-in email backends would remain in place until Django 7.0, but would effectively be dead code in most cases. Initializing a built-in backend withfail_silently=Truewould cause a deprecation warning. (This covers code that usesget_connection(fail_silently=True)and then callssend_messages()directly on that connection.) - During the deprecation period, the line marked “see compatibility note” above would also need to check
getattr(connection, "fail_silently", False), for compatibility with connections created outsideEmailMessage.
Impact
Here are some examples of what this proposal would and would not change, for Django’s SMTP backend and fail_silently=True:
-
Configuration and backend initialization: no change (not ignored now, not ignored after)
EMAIL_BACKENDpoints to nonexistent class:ImportErroringet_connection()- Conflicting settings
EMAIL_USE_SSL = TrueandEMAIL_USE_TLS = True:ValueErrorinsmtp.EmailBackend.__init()__
-
Message serialization and address validation:
CHANGE (not ignored now, would be ignored after)- CRLF in
from_email="from@\r\nexample.com":ValueErrorinEmailMessage.message() - Invalid encoding
body="\udce9":UnicodeEncodeErrorinEmailMessage.message() - (The message is not sent in either case; the only impact is whether an error propagates out of the send call)
- CRLF in
-
SMTP connection and communication: no change (ignored now, ignored after)
- DNS failure on
EMAIL_HOST:socket.gaierrorinsmtp.EmailBackend.open() - Bad
EMAIL_HOST_USER/EMAIL_HOST_PASSWORD:SMTPAuthenticationErrorinsmtp.EmailBackend.open() - Connection interrupted during transmission:
SMTPServerDisconnectedinsmtp.EmailBackend._send()
- DNS failure on
-
RuntimeError,MemoryError:
CHANGE (not ignored now, would be ignored after)
For third-party email backends the list would be different. Some already ignore message serialization errors with fail_silently=True, so this proposal wouldn’t change their resulting behavior. In general, anything detected at __init__() time would still be raised; everything in send_messages() would be suppressed.
One other change of note is the django.mail.outbox used during testing. Django’s locmem email backend doesn’t implement fail_silently, so no errors are ignored when it is in use. In a test case, this code would raise an error now but won’t after the change:
mail_admins("bad\r\nsubject", "message", fail_silently=True)
Alternatives
We could leave fail_silently implemented as it is now and instead improve the docs (ticket-36907). But that creates a big problem for EMAIL_PROVIDERS. There’s more background and several proposed options in an earlier revision of DEP 0018. After discussion with @nessita, this seemed the cleanest (and it’s now the only option in the latest DEP revision).
I maintain one of them. ↩︎
Proper attribution (and AI disclosure): I had actually proposed several other approaches for
fail_silentlywithEMAIL_PROVIDERS, but wasn’t happy with any of them. Claude 4.6 Opus first suggested what’s proposed here, during a technical review of an early DEP 0018 draft. ↩︎