Proposal: simplify sending/testing html in EmailMessage

I’d like to propose we add text_body and html_body options to django.core.mail.EmailMessage to simplify sending html + plaintext (or html-only) email:

from django.core.mail import EmailMessage

msg = EmailMessage(
    subject="Test subject",
    text_body="plain text message body",
    html_body="<p>html message body</p>",

(Many other email APIs seem to use these names, or just text and html. Unlike the existing EmailMessage body option, text_body would always be text/plain content.)

[Edit: I’d originally proposed calling these message and html_message to match Django’s existing send_mail(), but EmailMessage already has a message() method that would conflict.]

Like other EmailMessage options, text_body and html_body would be both optional constructor params and object properties that could be set at any time prior to calling send():

msg = EmailMessage(..., text_body="Welcome to Django!")
if user.preferences.prefers_html_email:
    msg.html_body = "Welcome to <strong>Django!</strong>"

The new properties would simplify testing email content, and would work well with existing test methods like assertInHTML():

def test_email_contains_unsubscribe_links(self):
    ...  # do something that sends email
    sent_msg = django.core.mail.outbox[0]
    self.assertIn("Unsubscribe: https://...", sent_msg.text_body)
    self.assertInHTML('<a href="...">unsubscribe</a>', sent_msg.html_body)

We would make text_body and html_body compatible with existing code, implementing them as @property getters/setters that manipulate the existing body and alternatives properties. This would provide full interoperability with third-party email backends and other libraries that aren’t aware of the new properties. (Sample implementation below.)

One implication is we’d need support for alternative parts in the base EmailMessage class—this is currently handled by a separate EmailMultiAlternatives subclass. I propose hoisting all the alternatives code into EmailMessage, essentially combining the two classes. (A hollowed-out EmailMultiAlternatives would remain for compatibility—more on that in the details below.)

I believe this could also simplify the email documentation quite a bit. There would be no need to understand the difference between EmailMessage and EmailMultiAlternatives—EmailMessage would cover it all. And for the common case of sending html + plaintext messages, you wouldn’t need to know jargon like “alternative” and “MIME type” and content_subtype. (Once simplified, we might even consider finally moving the EmailMessage documentation up the page near the “only for backwards compatibility” functions at the top. :grinning:)

More nitty-gritty inside these disclosure triangles…

Some details
  • Why not use the existing body option for the plaintext (and just add html_body)? Because it would break compatibility. The body is usually text/plain, but you can set content_subtype to change that. More accurately, body represents the content of the first (primary) text part in the message, which might be text/plain or text/html or text/something-else. (Both now and with this proposed change.)

  • Trying to pass body along with either of text_body or html_body to the EmailMessage constructor would be a ValueError. (But you could freely set any of the properties after that—see the notes about interoperability above and the sample implementation below.)

  • I think we should NOT deprecate EmailMultiAlternatives as part of this. A lot of code out there uses it (because there’s been no, um, alternative :grin:). Deprecating it right away would create a lot of noise and busywork. Instead, I’d suggest leaving EmailMultiAlternatives in place as a stub class, documented as there solely to simplify maintenance of existing code. And then if/when its use has declined (in maybe a decade or so?), we could deprecate it.

  • I’d suggest these—and other future new EmailMessage options—be keyword-only arguments to EmailMessage.__init__. (The list is already pretty long, making posargs prone to error, and complicating subclassing in third-party code.)

Sample implementation of the html_body property
# (untested code)
class EmailMessage:
    def html_body(self):
        if self.content_subtype == "html" and self.body is not None:
            return self.body
            # Return the first text/html alternative.
            for alternative in self.alternatives:
                if alternative.mimetype == "text/html":
                    return alternative.content
        return None

    def html_body(self, content):
        if self.content_subtype == "html":
            self.body = content
            # Replace the first text/html alternative,
            # or if there isn't one add the new alternative at the end.
            new_alternative = EmailAlternative(content, "text/html")
            for index, alternative in enumerate(self.alternatives):
                if alternative.mimetype == "text/html":
                    self.alternatives[index] = new_alternative

The text_body property implementation would be similar, but substituting

I think I started a similar take on this issue in this thread. Happy to merge the discussions if it makes sense.

I think we agree that Django’s EmailMessage makes working with html + text email more complicated than it needs to be. (And more complex than it is in many other frameworks/APIs.) And this matters because html + text has become a common—perhaps the most common—use case.

This proposal tries to target that specific issue by moving some of the boilerplate complexity Django currently imposes on all EmailMessage users into the EmailMessage implementation. I’ve deliberately kept the scope narrow.

Your thread on class-based email seems like a much larger scope, with broad goals that also include generating email from templates. I suspect it will take a longer discussion to reach consensus on that.

The two approaches are not at all mutually exclusive. But they are separate ideas, so I think it would be helpful to keep the threads separate.

Sounds very reasonable. Thx for the answer and your thoughts in “my” thread!