Skip to content

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

Open
wants to merge 3 commits into
base: main
Choose a base branch
from

Conversation

medmunds
Copy link
Contributor

@medmunds medmunds commented Dec 24, 2024

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

  • This PR targets the main branch.
  • The commit message is written in past tense, mentions the ticket number, and ends with a period.
  • I have checked the "Has patch" ticket flag in the Trac system.
  • I have added or updated relevant tests.
  • I have added or updated relevant docs, including release notes if applicable.
  • n/a I have attached screenshots in both light and dark modes for any UI changes.

Notes

This PR includes a few changes not covered in the original django-developers discussion. More details in the ticket, but briefly:

  • Deprecated passing legacy MIMEBase objects to EmailMessage.attach(); added support for modern MIMEPart objects instead.
  • Dropped support for the undocumented EmailMessage.mixed_subtype and EmailMultiAlternatives.alternative_subtype properties and for using legacy Charset objects in the undocumented EmailMessage.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 four five commits are (more details in the commit messages):

  1. 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" and assertEqual(generated_body, "Content") without trailing newlines, which break with the modern APIs. I've updated those tests to use trailing newlines for all text.

  2. 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.)

  3. 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.

  4. Finally switches to the modern email API. (You could review this commit in isolation if you just want to see what is actually changing.)

  5. [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.)

@medmunds medmunds changed the title Ticket 35581: Updated django.core.mail to Python's modern email API Fixed #35581 -- Updated django.core.mail to Python's modern email API. Jan 16, 2025
@knyghty
Copy link
Member

knyghty commented Feb 8, 2025

@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.

@medmunds
Copy link
Contributor Author

medmunds commented Feb 8, 2025

I see the first issue you linked is now fixed, just not backported everywhere yet.

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.

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.

The policy names get confusing: if you serialize a legacy Message using its default legacy policy.compat32, that does not have the bug. And that's what Django currently does, so it's not vulnerable. But if you serialize any legacy Message or modern EmailMessage using the modern policy.default (like this PR), that will be vulnerable to address header injection.

@medmunds medmunds force-pushed the ticket_35581 branch 2 times, most recently from a26a8e8 to e9368d7 Compare February 8, 2025 22:01
@medmunds
Copy link
Contributor Author

OK, I believe the security issues are resolved and this PR is ready for review.

@medmunds medmunds marked this pull request as ready for review February 17, 2025 19:35
@medmunds

This comment was marked as outdated.

@medmunds medmunds force-pushed the ticket_35581 branch 4 times, most recently from df3b994 to f6632ee Compare March 18, 2025 19:11
@medmunds
Copy link
Contributor Author

medmunds commented Apr 3, 2025

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.

@knyghty
Copy link
Member

knyghty commented Apr 4, 2025

@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.

@medmunds
Copy link
Contributor Author

medmunds commented Apr 4, 2025

@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.)

@sarahboyce
Copy link
Contributor

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)

Copy link
Contributor

@sarahboyce sarahboyce left a 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 👍

Copy link
Contributor

@sarahboyce sarahboyce left a 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

Comment on lines 2779 to 2789
# 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).
Copy link
Contributor

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)

Copy link
Contributor Author

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 😄 )

Copy link
Contributor Author

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.)

Comment on lines +389 to +392
.. versionchanged:: 6.0

Added the ``policy`` argument and updated the method to return
a modern Python :py:class:`~email.message.EmailMessage`.
Copy link
Contributor

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).

Copy link
Contributor Author

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.

Copy link
Contributor Author

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?)

Copy link
Contributor Author

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.)

@sarahboyce
Copy link
Contributor

@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 👍

@medmunds
Copy link
Contributor Author

Thank you for the updates @medmunds ⭐ I really appreciate your persist progress here

Thanks @sarahboyce. I really appreciate your persistence on this, too!

@sarahboyce
Copy link
Contributor

The fix for ticket-36478 is merged
Thank you for the replies to the previous comments, they all sound reasonable to me. I have a holiday coming up but will get back to this when I'm back (mid-July ish)

@apollo13
Copy link
Member

@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 👍

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.
medmunds added 2 commits July 16, 2025 12:42
…ail API.

Wrapped docs, docstrings and block comments at 79 characters
(in modified code).
@medmunds
Copy link
Contributor Author

@sarahboyce I pushed fixup commits for a missing deprecation-cleanup comment and some long comments/docstrings.

Comment on lines +184 to +192
* ``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.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps:

Suggested change
* ``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.

Comment on lines +227 to 229
:exc:`ValueError` and, hence,
will not send the email. It's your responsibility to validate all data before
passing it to the email functions.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
: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.

Comment on lines +258 to +263
.. deprecated:: 6.0

Earlier releases raised a custom ``django.core.mail.BadHeaderError``
for some invalid headers. That has been replaced with :exc:`!ValueError`.


Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
.. 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 🤔

Comment on lines +339 to +340
Added support for attaching Python's :class:`~email.message.MIMEPart`
items.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Added support for attaching Python's :class:`~email.message.MIMEPart`
items.
Support for :class:`~email.message.MIMEPart` items of ``attachments`` were
added.

Comment on lines +344 to +345
Attaching Python's legacy :class:`~email.mime.base.MIMEBase` objects
is deprecated. Use modern :class:`~email.message.MIMEPart` instead.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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.

Comment on lines +449 to +450
Added support for Python's modern email
:class:`~email.message.MIMEPart` attachments.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Added support for Python's modern email
:class:`~email.message.MIMEPart` attachments.
Support for :class:`~email.message.MIMEPart` attachments were added.

Comment on lines +454 to +455
Attaching Python's legacy :class:`email.mime.base.MIMEBase` objects
is deprecated. Use modern :class:`~email.message.MIMEPart` instead.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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.

Comment on lines +34 to +37

# Undocumented charset to use for text/* message bodies
# and attachments. Defaults to settings.DEFAULT_CHARSET.
# (Should rename to `charset` if it will be documented.)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# 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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
msg[name] = force_str(value, strings_only=True)
msg[name] = value

I can revert this without test failures

Comment on lines +13 to +16
from django.core.mail._deprecated import ( # NOQA: F401; RemovedInDjango70Warning
forbid_multi_line_headers,
sanitize_address,
)
Copy link
Contributor

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?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants