I want to propose updating django.core.mail to replace use of Python's legacy email.message.Message (and other legacy email APIs) with email.message.EmailMessage (and other modern APIs).
If there's interest, I can put together a more detailed proposal and/or ticket, but was hoping to get some initial feedback first. (I searched for relevant discussions in django-developers and the issue tracker, and didn't find any. Apologies if this has come up before.)
[Note: I maintain django-anymail, which implements Django integration with several transactional email service providers.]
Background
Since Python ~3.6, Python's email package has included two largely separate implementations:
(See https://docs.python.org/3/library/email.html, especially toward the end.)
django.core.mail currently uses the legacy API.
Why switch?
There are no plans to deprecate Python's legacy email API, and it's working, so why change Django?
Fewer bugs: The modern API fixes a lot of bugs in the legacy API. My understanding is legacy bugs will generally not be fixed. (And in fact, there are some cases where the legacy API deliberately replicates earlier buggy behavior.)
Django #35497 is an example of a legacy bug (with a problematic proposed workaround) which is just fixed in the modern API.
Simpler, safer code: A substantial portion of django.core.mail's internals implements workarounds and security fixes for problems in the legacy API. This would be greatly simplified—maybe eliminated completely—using the modern API.
Examples: the modern API prevents CR/NL injections in message headers. It serializes and folds address headers properly—even ones with unicode names. It enforces single-instance headers where appropriate.
Easier to work with: The modern API adds some nice conveniences for developers working in django.core.mail, third-party library developers, and (depending on what we choose to expose) users of Django's mail APIs.
Examples: populating the "Date" header with a datetime or an address header with Address objects—without needing intricate knowledge of email header formats. Using email.policy to generate a 7-bit clean serialization (without having to muck about with the MIME parts).
Concerns & risks
Compatibility and security, of course…
Backwards compatibility (for API users): django.core.mail largely insulates callers from the underlying Python email package. There are a few places where this leaks (e.g., attachments allows legacy email MIMEBase objects), but in general the switch should be transparent. (And I have some ideas for supporting the other cases.)
Backwards compatibility (for third-party libraries): Some libraries may use internals I'd propose removing (e.g., SafeMIME and friends); we'd handle this through deprecation.
Backwards compatibility (bug-level): There's probably some code out there that unintentionally depends on legacy email bugs (or the specific ways Django works around them). I don't have any examples, but I also don't have a good solution for when they surface. Plus, while Python's modern email API is pretty mature at this point, there are still new bugs being reported against it. Email is complicated.
Security: As noted above, the modern API should be more secure than the legacy one. But we also have a nice collection of email security tests—which mostly don't depend on internal implementation. We'd keep these.
--
You received this message because you are subscribed to the Google Groups "Django developers (Contributions to Django itself)" group.
To unsubscribe from this group and stop receiving emails from it, send an email to django-develop...@googlegroups.com.
To view this discussion on the web visit https://groups.google.com/d/msgid/django-developers/f410ad79-a034-4275-88a7-22e7626c06fdn%40googlegroups.com.
--
To view this discussion on the web visit https://groups.google.com/d/msgid/django-developers/8f4716b9-0856-46bd-af16-0ce715f195b2n%40googlegroups.com.
To view this discussion on the web visit https://groups.google.com/d/msgid/django-developers/CA%2BX4dQSCwndDFcOid0Lyi4PtUJw264DTZK-r6vSNNoRh%2B4gBTQ%40mail.gmail.com.
Since the early feedback seems positive (though we're still waiting for more votes), here's some additional detail on the changes I think would be involved to update django.core.mail to use Python's modern email API.
(See my earlier message for background on Python's legacy vs. modern email APIs and why updating is useful.)
Note: Django and Python both have classes named EmailMessage. I'm using "Django EmailMessage" to refer to django.core.mail.message.EmailMessage, and "Python EmailMessage" (or sometimes just "modern API") to refer to Python's modern email.message.EmailMessage.
Update tests.mail.tests to use modern email APIs
Where a test relies on legacy implementation details, try to rewrite it to be implementation agnostic if possible; otherwise try to retain the spirit of the test using modern APIs.
Retain all security related tests, with updates as appropriate. (Even where we know Python's modern API handles the security for us, it doesn't hurt to double check.)
Probably need to add some cases for existing behavior not currently covered by tests, particularly around attachments and alternatives.
Remove a test case only if it's truly no longer relevant and can't be usefully updated. (And perhaps leave behind a brief comment explaining why.)
(Legacy email APIs used in tests.mail.tests: email.charset, email.header.Header, MIMEText, parseaddr. Also message_from_… currently defaults to legacy policy.compat32.)
Update django.core.mail.message.EmailMessage to use modern email APIs
Change Django's EmailMessage.message() to construct a modern email.message.EmailMessage rather than a SafeMIME object (which is based on legacy email.message.Message with policy=compat32). Add a message(policy=default) param forwarded to Python's EmailMessage constructor.
Hoist alternative part handling from Django's EmailMultiAlternatives into Django's base EmailMessage to simplify the code. (More on this below.)
In _create_alternatives(), use modern add_alternative() and friends to replace legacy SafeMIME objects.
In _create_attachments(), use modern add_attachment(). Handle (legacy Python) MIMEBase objects as deprecated, and convert to modern equivalent. This is a relatively complex topic; I'll post a separate message about it later. (I also have some questions about how best to handle content-disposition "inline" and whether we want to somehow support multipart/related.)
Remove the private Django EmailMessage methods _create_mime_attachment() and _create_attachment() (without deprecation).
(Legacy APIs used in django.core.mail.message: email.message.Message, email.charset, email.encoders, email.header, email.mime, email.utils)
Deprecate unused internal APIs from django.core.mail.message
Django will no longer need these (Python's modern email API covers their functionality), but they may be in use by third-party libraries:
(Items marked ^ are exposed in django.core.mail via __all__. I haven't looked into the reason for that.)
Update django.core.mail.__init__ to avoid EmailMultiAlternatives, and django.core.mail.backend.smtp to replace sanitize_address. (More details below.)
Update docs
Legacy MIMEBase attachments: I'll post a separate message about this sometime later. (Maybe next week. It's a long topic, plus I'm still experimenting.)
EmailMultiAlternatives: I'd like to get rid of the distinction between Django's EmailMessage and EmailMultiAlternatives, and just move the logic for alternative parts into base EmailMessage. (And then simplify the docs.)
EmailMultiAlternatives is additional complexity for users, and it isn't really helpful with Python's modern add_alternative() API.
We could deprecate EmailMultiAlternatives, but that would be a very noisy deprecation. (Everything sending plaintext + html email today has to use EmailMultiAlternatives.) My preference would just be to leave a stub class with a doc string:
BadAddressHeader: is a ValueError subclass raised only by forbid_multi_line_headers()—which would be deprecated. (The modern email API raises a ValueError for CR/NL in headers.)
Do we want to issue a deprecation warning for this? The warning has to be on import, not on use. (There's a way to do that with a module-level __getattr()__, which I see we've done before in db.models.enums.)
Otherwise I'm inclined to just write BadAddressHeader = ValueError in the deprecated code. (forbid_multi_line_headers() will warn about deprecation when it's called.)
Same question about all the deprecated constants (like RFC5322_EMAIL_LINE_LENGTH_LIMIT).
sanitize_address(): This function is called from two places in Django:
sanitize_address() does a bunch of things:
The modern email APIs handle all of this for message headers. (Except the punycode part, which is arguably wrong in message header fields.)
However, I believe at least some of this is necessary for smtplib. In particular, the envelope from and recipient addresses have to be ascii only. (Unless SMTP.sendmail() is called with the "SMTPUTF8" option—which is not currently possible with Django's SMTP EmailBackend—and the SMTP server supports that option.)
I'd suggest we deprecate sanitize_address(), and create a simpler private function in the SMTP EmailBackend to perform the SMTP-specific ascii conversion.
Other stuff: I haven't yet investigated what to do with:
--
You received this message because you are subscribed to the Google Groups "Django developers (Contributions to Django itself)" group.
To unsubscribe from this group and stop receiving emails from it, send an email to django-develop...@googlegroups.com.
To view this discussion on the web visit https://groups.google.com/d/msgid/django-developers/0041c2f3-2ba4-4e76-b7df-b3dc025eb4ean%40googlegroups.com.
To view this discussion on the web visit https://groups.google.com/d/msgid/django-developers/CAHoz%3DMbHArxHYnnEbWQi7AdL%3D9%2ByAheUQZQ1UoPkfS2QOvNXkA%40mail.gmail.com.
Deprecate unused internal APIs from django.core.mail.message
Django will no longer need these (Python's modern email API covers their functionality), but they may be in use by third-party libraries:
- utf8_charset
- utf8_charset_qp
- RFC5322_EMAIL_LINE_LENGTH_LIMIT
- BadHeaderError^ (more details below)
- ADDRESS_HEADERS
- forbid_multi_line_headers()^
- sanitize_address() (more details below)
- MIMEMixin
- SafeMIMEMessage
- SafeMIMEText^
- SafeMIMEMultipart^
(Items marked ^ are exposed in django.core.mail via __all__. I haven't looked into the reason for that.)
Incidentally, I thought there was (used to be?) a policy that internal undocumented APIs were fair game for use by third-party libraries, subclassing, etc., so long as they didn't start with an underscore. (But "private" underscore APIs could have breaking changes at any time.) Am I remembering that wrong? Or was internal API stability only guaranteed for patch-level releases?
On Saturday, July 6, 2024 at 12:30:32 AM UTC+2 Mike Edmunds wrote:Incidentally, I thought there was (used to be?) a policy that internal undocumented APIs were fair game for use by third-party libraries, subclassing, etc., so long as they didn't start with an underscore. (But "private" underscore APIs could have breaking changes at any time.) Am I remembering that wrong? Or was internal API stability only guaranteed for patch-level releases?The API stability contract is documented here: https://docs.djangoproject.com/en/5.0/misc/api-stability/ -- Everything documented is considered part of the API, the rest is private.So yeah, it seems like you are remembering that wrong.
I am writing to inform you that we have reviewed and decided to accept the proposal to upgrade django.core.mail
to Python's modern email API. We believe that this upgrade will bring significant improvements to our system's performance and maintainability.
I would also like to extend my sincere apologies for the delay in our response. I received the proposal seven days ago, and I regret not being able to get back to you sooner. Thank you for your patience and understanding in this matter.
We are looking forward to the successful implementation of this upgrade and are excited about the enhancements it will bring to our project. Please let us know the next steps and how we can assist in the process.
Thank you once again for your proposal and for your continued support.
Best regards,
- PANKAJ NEEMA
--
You received this message because you are subscribed to the Google Groups "Django developers (Contributions to Django itself)" group.
To unsubscribe from this group and stop receiving emails from it, send an email to django-develop...@googlegroups.com.
To view this discussion on the web visit https://groups.google.com/d/msgid/django-developers/95e993a0-4b61-47d8-a365-d63676187f43n%40googlegroups.com.
To view this discussion on the web visit https://groups.google.com/d/msgid/django-developers/CAPD84HnRTfZ-z1R5%3D0zNeLwdnbaECgRbCHOFV9TiFfK9bC0dQw%40mail.gmail.com.
To view this discussion on the web visit https://groups.google.com/d/msgid/django-developers/CAPRrvgxwxop48-ZFZdKvuLxG0CYUbMD4uFCaSWC2P-q2_fxeiQ%40mail.gmail.com.