-
-
Notifications
You must be signed in to change notification settings - Fork 32.7k
Fixed #35581 -- Updated django.core.mail to Python's modern email API. #18966
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
cdce21d
to
b193ed7
Compare
@medmunds I see the first issue you linked is now fixed, just not backported everywhere yet. For python/cpython#121284, I notice that on the issue it says "modern or legacy" - if that's the case, should it block this work? It seems like it is already an issue. |
Yes. And the auto backports are failing—I'll try to take a look at those. But since the 3.12 backport was merged, and the next version of Django requires Python 3.12+, this is no longer a blocker.
The policy names get confusing: if you serialize a legacy Message using its default legacy |
a26a8e8
to
e9368d7
Compare
e9368d7
to
8dbecc8
Compare
OK, I believe the security issues are resolved and this PR is ready for review.
|
This comment was marked as outdated.
This comment was marked as outdated.
df3b994
to
f6632ee
Compare
Cpython has now also merged a fix for fix for python/cpython#121284. It should show up in Python 3.12.10 and 3.13.3. So both Python security issues will be fixed in all (latest) supported Python releases before this is likely to get merged. I'm not sure whether to keep or remove the workaround. |
@medmunds I suspect it's safe to remove, as this won't land until 6.0, which only supports 3.12+, and we state that we only support the latest patch release. |
@knyghty thanks. I'll remove the workaround once those Pythons are released and django-ci's runners get updated. I think it makes sense to leave the tests in place, just in case. (Incidentally, both security fixes have been backported to Python 3.9, 3.10, and 3.11 as well.) |
I agree it's safe to remove the workaround. It may even be worth dropping the test to confirm the python behavior is fixed (as we try not to test Python behavior but I understand why it was added in this case) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I have made #19573 which resolves conflicts and drops the Python workaround in case this is useful
I had a quick look at the test coverage and deprecation instructions also 👍
ebade77
to
50cc3ae
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thank you for the updates @medmunds ⭐ I really appreciate your persist progress here
tests/mail/tests.py
Outdated
# Check whether python/cpython#128110 has been fixed by seeing if space | ||
# between encoded-words is ignored (as required by RFC 2047 section 6.2). |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think the changes that relate to python/cpython#128110 should be pulled into a separate commit.
I assume the hope is that this will be fixed and these changes are not required? Does this feel like a blocker to move to the new mail api?
It's strange to me to only see references to working around a Python bug within our test suite (as we use message_from_bytes
in our code)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's strange to me to only see references to working around a Python bug within our test suite (as we use
message_from_bytes
in our code)
Ah, I'd been thinking message_from_bytes was only used in the test suite. But you're correct, there is a single use in django.core.mail. You can add a message/rfc822 attachment with EmailMessage.attach("filename.eml", b"From: ...\r\nTo: ...\r\n...", "message/rfc822"). We use message_from_bytes to convert the content to a message attachment.
The Python bug is not a security issue, and I don't believe should be a blocker for this PR.
Unlike that one relatively narrow case in django.core.mail, the tests use message_from_bytes a lot, and go out of their way to test cases impacted by the bug, so a workaround in the tests made sense to me.
There a few options:
- Pull the workaround into django.core.mail. I'm confident the workaround is sufficiently good for purposes of the test cases; I'm less confident about treating it as a supported feature.
- Wait for the cpython fix to land. (Realistically, that could happen next month, next year, or five years from now. Also, since it's not a security issue, backports will be limited.)
- Recognize that Python modern email has bugs too, and that they're different bugs from legacy email. (I mentioned that as a risk in the original discussion.) This is one of those bugs. If a similar non-security bug were reported against django.core.mail due to the legacy APIs, I suspect we would close it as wontfix, point to the upstream issue, and suggest the user implement their own workarounds. (I could even preemptively open and close a Django ticket for this issue once this PR is merged 😄 )
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(Pulled workaround into a separate commit, but kept it test-only.)
.. versionchanged:: 6.0 | ||
|
||
Added the ``policy`` argument and updated the method to return | ||
a modern Python :py:class:`~email.message.EmailMessage`. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That the return type of a documented function has changed feels like a breaking change (especially as there is a note about overwriting this in the docs).
We might want to have a way for folks to opt-in/out to the return format (and that this opt-in/out method is deprecated).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We might want to have a way for folks to opt-in/out to the return format (and that this opt-in/out method is deprecated).
Hmm, the old return format is "a django.core.mail.SafeMIMEText
object (a subclass of Python’s MIMEText
class) or a django.core.mail.SafeMIMEMultipart
object."
That is also the total extent of Django's documentation for those classes.
As part of the django-developers discussion last year, I looked into how SafeMIMEText and SafeMIMEMultipart are being used in public codebases. As the return value of EmailMessage.message(), usage was either:
- For type checking
- To call documented methods of Python's email.message.Message (primarily
as_bytes()
)
email.message.Message is the root class of all legacy MIME* classes and modern email.message.EmailMessage, so existing code using documented APIs should continue to work with the new return type. (Other than varying constructor args, there are no documented APIs on MIMEText or any of its base classes until you get to email.message.Message.)
So an opt-in for the old return type seems relevant only for type checking usage. There are a few approaches we could consider:
- In django.core.mail, set (deprecated) SafeMIMEText = SafeMIMEMultipart = email.message.EmailMessage. This would make type checking strictly accurate for the new return type. But it would also break a lot of real world code that uses the SafeMIME classes off label (mainly to safely construct attachments to pass to EmailMessage.attach()).
- Change the documented return type of EmailMessage.message() to email.message.Message in Django 6.0, and make no other changes for now. Wait until Django 7.0 to land this PR.
- Add a setting or EmailMessage option that opts into/out of the old SafeMIME objects. I worry that would involve a substantial reworking of this PR to offer both code paths in parallel. (Maybe you had a better approach in mind?)
- Suggest that django-stubs declare the return type of EmailMessage.message() to be email.message.Message, which is accurate both before and after this PR.
Since Django doesn't officially support type checking, my preference would be the last one.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ugh, sorry, here's a much shorter version of what I wanted to say:
The new return type of EmailMessage.message() supports all of the documented behavior of the old return type, with one exception: isinstance(SafeMIMEText) won't be true for the new return type.
In my public code search last year, I don't recall seeing any usage of isinstance() checks on EmailMessage.message() return values. And the ways I can think to maintain isinstance() compatibility would either break a lot of code I did see that uses the SafeMIME classes for other (albeit undocumented) purposes, or would involve maintaining two versions of EmailMessage.message() and everything below it.
(Maybe I should mention in the release notes that Python's email.message.EmailMessage supports the same API as SafeMIMEText and SafeMIMEMultipart?)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(I've updated the 6.0 release notes with this information.)
@apollo13 👋 I noticed you were involved in the Django developer discussion of this change and done some work Django email work in the past. If you have any insights or opinions on this PR, they're very welcome 👍 |
Thanks @sarahboyce. I really appreciate your persistence on this, too! |
The fix for ticket-36478 is merged |
I fear I do not have the time to do so currently. That said, the thoughtfulness and planning Mike has been putting into this (from his earlier changes and design specs) is so stellar that I do not think there is any chance that we'd end up with anything worse than we have now (I actually believe the end result will be massively better). If bugs occur afterwards feel free to blame me for not reviewing ;) |
- Changed EmailMessage.message() to construct a "modern email API" email.message.EmailMessage. Added ``policy`` keyword arg. - Added support for modern MIMEPart objects in EmailMessage.attach() (and EmailMessage constructor, EmailMessage.attachments list). - Moved now-unnecessary SafeMIME*, forbid_multi_line_headers(), and related code to django.core.mail._deprecated (list below). - Updated SMTP EmailBackend to use modern email.policy.SMTP. Deprecated: - Attaching MIMEBase objects (replace with MIMEPart) - BadHeaderError (modern email uses ValueError) - SafeMIMEText, SafeMIMEMultipart (unnecessary for modern email) - django.core.mail.forbid_multi_line_headers() (undocumented, but exposed via `__all__` and in wide use) - django.core.mail.message.sanitize_address() (undocumented, but in wide use) - (All but sanitize_address() issue deprecation warning on import, to catch `except` clause and type-checking-only usage.) Removed without deprecation (all undocumented): - EmailMessage.mixed_subtype - EmailMultiAlternatives.alternative_subtype - Support for setting (undocumented) EmailMessage.encoding property to a legacy email.charset.Charset object Related changes: - Dropped tests for incorrect RFC 2047 encoding of non-ASCII email address localparts. This is specifically prohibited by RFC 2047, and not supported by any known MTA or email client. (Python still mis-applies encoded-word to non-ASCII localparts, but it is a bug that may be fixed in the future.) - Added tests that try to discourage using Python's legacy email APIs in future updates to django.core.mail.
…ail API. (Missed a 7.0 cleanup item.)
…ail API. Wrapped docs, docstrings and block comments at 79 characters (in modified code).
@sarahboyce I pushed fixup commits for a missing deprecation-cleanup comment and some long comments/docstrings. |
* ``EmailMessage.message()`` now uses Python's "modern email" API and accepts | ||
a new ``policy`` argument. The return value is now an instance of Python's | ||
:class:`email.message.EmailMessage`. (The new return type supports the same | ||
API as the previous ``SafeMIMEText`` and ``SafeMIMEMultipart`` return types, | ||
but is not an instance of those now-deprecated classes.) | ||
|
||
* ``EmailMessage.attach()`` now accepts a :class:`~email.message.MIMEPart` | ||
object from Python's modern email API. | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Perhaps:
* ``EmailMessage.message()`` now uses Python's "modern email" API and accepts | |
a new ``policy`` argument. The return value is now an instance of Python's | |
:class:`email.message.EmailMessage`. (The new return type supports the same | |
API as the previous ``SafeMIMEText`` and ``SafeMIMEMultipart`` return types, | |
but is not an instance of those now-deprecated classes.) | |
* ``EmailMessage.attach()`` now accepts a :class:`~email.message.MIMEPart` | |
object from Python's modern email API. | |
* The new ``policy`` argument for ``EmailMessage.message()`` allows specifying | |
the email policy, the set of rules for updating and serializing the | |
representation of the message. Defaults to :py:data:`email.policy.default`. | |
* ``EmailMessage.attach()`` now accepts a :class:`~email.message.MIMEPart` | |
object from Python's modern email API. |
I am also thinking we have a section in the "What's new", something like:
Adoption of Python’s modern email API
-------------------------------------
Email handling in Django now uses Python's modern email API, introduced in
Python 3.6. This API, centered around the
:py:class:`email.message.EmailMessage` class, offers a cleaner and
Unicode-friendly interface for composing and sending emails. It replaces use of
Python's older legacy (``Compat32``) API, which relied on lower-level MIME
classes (from :py:mod:`email.mime`) and required more manual handling of
message structure and encoding.
Notably, return type of ``EmailMessage.message()`` is now an instance of
Python's :py:class:`email.message.EmailMessage`. This supports the same API as
the previous ``SafeMIMEText`` and ``SafeMIMEMultipart`` return types, but is
not an instance of those now-deprecated classes.
:exc:`ValueError` and, hence, | ||
will not send the email. It's your responsibility to validate all data before | ||
passing it to the email functions. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
:exc:`ValueError` and, hence, | |
will not send the email. It's your responsibility to validate all data before | |
passing it to the email functions. | |
:exc:`ValueError` and, hence, will not send the email. It's your responsibility | |
to validate all data before passing it to the email functions. |
.. deprecated:: 6.0 | ||
|
||
Earlier releases raised a custom ``django.core.mail.BadHeaderError`` | ||
for some invalid headers. That has been replaced with :exc:`!ValueError`. | ||
|
||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
.. deprecated:: 6.0 | |
Earlier releases raised a custom ``django.core.mail.BadHeaderError`` | |
for some invalid headers. That has been replaced with :exc:`!ValueError`. | |
.. versionchanged:: 6.0 | |
In older versions, ``django.core.mail.BadHeaderError`` was raised for some | |
invalid headers. This has been replaced with :exc:`!ValueError`. | |
I think as this behavior is not deprecated but just changed, this is more appropriate 🤔
Added support for attaching Python's :class:`~email.message.MIMEPart` | ||
items. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added support for attaching Python's :class:`~email.message.MIMEPart` | |
items. | |
Support for :class:`~email.message.MIMEPart` items of ``attachments`` were | |
added. |
Attaching Python's legacy :class:`~email.mime.base.MIMEBase` objects | ||
is deprecated. Use modern :class:`~email.message.MIMEPart` instead. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Attaching Python's legacy :class:`~email.mime.base.MIMEBase` objects | |
is deprecated. Use modern :class:`~email.message.MIMEPart` instead. | |
Support for Python's legacy :class:`~email.mime.base.MIMEBase` items of | |
``attachments`` is deprecated. Use :class:`~email.message.MIMEPart` | |
instead. |
Added support for Python's modern email | ||
:class:`~email.message.MIMEPart` attachments. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added support for Python's modern email | |
:class:`~email.message.MIMEPart` attachments. | |
Support for :class:`~email.message.MIMEPart` attachments were added. |
Attaching Python's legacy :class:`email.mime.base.MIMEBase` objects | ||
is deprecated. Use modern :class:`~email.message.MIMEPart` instead. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Attaching Python's legacy :class:`email.mime.base.MIMEBase` objects | |
is deprecated. Use modern :class:`~email.message.MIMEPart` instead. | |
Support for :class:`email.mime.base.MIMEBase` attachments is | |
deprecated. Use :class:`~email.message.MIMEPart` instead. |
|
||
# Undocumented charset to use for text/* message bodies | ||
# and attachments. Defaults to settings.DEFAULT_CHARSET. | ||
# (Should rename to `charset` if it will be documented.) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
# Undocumented charset to use for text/* message bodies | |
# and attachments. Defaults to settings.DEFAULT_CHARSET. | |
# (Should rename to `charset` if it will be documented.) |
I'm not sure this comment helps me, are we refering to encoding here?
if "message-id" not in header_names: | ||
# Use cached DNS_NAME for performance | ||
msg["Message-ID"] = make_msgid(domain=DNS_NAME) | ||
for name, value in self.extra_headers.items(): | ||
# Avoid headers handled above. | ||
if name.lower() not in {"from", "to", "cc", "reply-to"}: | ||
msg[name] = value | ||
msg[name] = force_str(value, strings_only=True) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
msg[name] = force_str(value, strings_only=True) | |
msg[name] = value |
I can revert this without test failures
from django.core.mail._deprecated import ( # NOQA: F401; RemovedInDjango70Warning | ||
forbid_multi_line_headers, | ||
sanitize_address, | ||
) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this has been done so the import path can stay the same? Would we not want to do BadHeaderError
, SafeMIMEText
, SafeMIMEMultipart
as well?
Trac ticket number
ticket-35581
Branch description
Switch django.core.mail from using Python's "legacy email API" to its newer "modern email API."
I believe this PR is ready for review, but I've marked it "draft" for now. It should not be merged until fixes land for two security issues in Python's modern email API: python/cpython#80222 and python/cpython#121284. (I've included tests in this PR that will fail until they've been addressed.)[Edit: The security issues have been addressed.]Checklist
main
branch.Notes
This PR includes a few changes not covered in the original django-developers discussion. More details in the ticket, but briefly:
EmailMessage.attach()
; added support for modern MIMEPart objects instead.EmailMessage.mixed_subtype
andEmailMultiAlternatives.alternative_subtype
properties and for using legacy Charset objects in the undocumentedEmailMessage.encoding
property. (As these are undocumented, dropped without deprecation. But attempting to use any of them will result in an error.)I've split the changes into separate commits that may be easier to review individually. The first three commits update tests for django.core.mail only, without changing the implementation. These updated tests should pass whether django.core.mail uses Python's legacy or modern email API to generate messages.
The
fourfive commits are (more details in the commit messages):Adds trailing newlines to all text content (bodies and attachments) in mail tests. This is because Python's modern API forces trailing newlines in text (email: set_content() always assumes trailing EOL python/cpython#121515).
In the real world, most email text content probably already has a newline at the end, or it won't cause a problem to add one. Unfortunately, a lot of our test cases include variations on
body="Content"
andassertEqual(generated_body, "Content")
without trailing newlines, which break with the modern APIs. I've updated those tests to use trailing newlines for all text.Prepares the mail tests for switching to the modern API by reducing dependencies on specific implementation details. (E.g., checking that a message sent with non-ASCII content will parse correctly to the original content at the receiving end, rather than checking for specific encodings and byte sequences.)
This commit also adds comments to describe some tests that will be relocated or removed in the modern email update. (And then commit 4 removes those comments.)
Adds tests for the two Python issues email: folding of quoted string in display_name violates RFC python/cpython#80222 and email: invalid RFC 2047 address header after refolding with email.policy.default python/cpython#121284.
These pass with the legacy API, but currently fail with the modern one.
I don't believe there's any practical workaround Django could implement, so until they're fixed in Python the next commit must not be merged.Finally switches to the modern email API. (You could review this commit in isolation if you just want to see what is actually changing.)
[Edit: added.] Implements a workaround for one of the security issues. (And the other is fixed in Python 3.12.9 and 3.13.2.)