Opened 3 weeks ago

Closed 2 weeks ago

Last modified 12 hours ago

#37429 closed Bug (needsnewfeatureprocess)

Cannot create multipart alternative mail with subparts

Reported by: jddupas Owned by:
Component: Core (Mail) Version: 6.1
Severity: Normal Keywords: email
Cc: Mike Edmunds Triage Stage: Unreviewed
Has patch: no Needs documentation: no
Needs tests: no Patch needs improvement: no
Easy pickings: no UI/UX: no

Description (last modified by jddupas)

I want to create a mail with a plain/text alternative, and an html alternative containing images referenced by CID.
It used to be possible before Django 6, but the new API fails when trying to build such mail:

multipart/alternative
├── text/plain; charset=UTF-8
└── multipart/related; type="text/html"
    ├── text/html; charset=UTF-8
    └── image/png
        Content-ID: <image001>
        Content-Disposition: inline

I tried to create the HTML + image part using a MIMEPart(), but there is no way to attach it to the Django EmailMultiAlternatives.

The attach_alternative() method does not support taking a MimePart as content parameter. It requires a content-type.
If the content-type is text, in _add_bodies(), it calls add_alternative() with a charset parameter, and fails as the charset argument is not supported by the content manager when inserting a MimePart.
If the content-type is something else, _add_bodies() converts the whole MimePart into base64 and insert it unstructured.

If I use the attach() method instead, the part is properly inserted, but the whole messages switch to mixed content-type, and not alternative content-type.

Attachments (1)

test_mail_structure.py​ (4.1 KB ) - added by Mike Edmunds 13 hours ago.

Download all attachments as: .zip

Change History (9)

comment:1 by jddupas, 3 weeks ago

Description: modified (diff)

comment:2 by jddupas, 3 weeks ago

Description: modified (diff)

comment:3 by jddupas, 3 weeks ago

One way to fix this, would be to modifiy EmailMultiAlternatives._add_bodies() to support MIMEPart:

                if maintype == "text":
                    if isinstance(content, bytes):
                        content = content.decode()
                    msg.add_alternative(content, subtype=subtype, charset=encoding)
                elif isinstance(content, email.message.MIMEPart):
                    msg.attach(content)
                else:
                    content = force_bytes(content, encoding=encoding, strings_only=True)
                    msg.add_alternative(content, maintype=maintype, subtype=subtype)

As the MIMEPart already define its content type, it should just be attached to the python Message.

comment:4 by NAYAN SHARMA, 3 weeks ago

Has patch: set
Triage Stage: Unreviewed → Accepted

comment:5 by NAYAN SHARMA, 3 weeks ago

Has patch: unset
Triage Stage: Accepted → Unreviewed

comment:6 by NAYAN SHARMA, 3 weeks ago

I've prepared a patch for this ticket and added a regression test for MIMEPart alternatives containing nested MIME structures.

The PR checker reports that the Trac ticket isn't ready for a pull request. Could someone clarify whether any further triage is needed before the proposed change can be submitted for review?

comment:7 by Sarah Boyce, 2 weeks ago

Cc: Mike Edmunds added
Resolution: → needsinfo
Status: new → closed

Hello hello
Can you share the code you used to build this message before Django 6.0?
This would help us understand whether this is a regression or whether the release notes should explain the upgrade path more clearly.

The docs suggest overriding EmailMessage.message() to customize the resulting message. See ​https://docs.djangoproject.com/en/dev/topics/email/#django.core.mail.EmailMessage.message
Would that approach work for you? Maybe something along the lines of:

from email import policy

from django.core.mail import EmailMultiAlternatives


class InlineImageEmail(EmailMultiAlternatives):
    inline_images = None

    def message(self, *, policy=policy.default):
        message = super().message(policy=policy)
        if self.inline_images:
            html = message.get_body(preferencelist=("html",))
            for content, cid in self.inline_images:
                html.add_related(
                    content,
                    maintype="image",
                    subtype="png",
                    cid=cid,
                    disposition="inline",
                )
            html.set_param("type", "text/html")
        return message


email = InlineImageEmail(
    subject="Hello",
    body="Hello",
    from_email="from@example.com",
    to=["to@example.com"],
)
email.inline_images = [
    (first_image_bytes, "<image001>"),
    (second_image_bytes, "<image002>"),
]
email.attach_alternative(
    '<p>Hello</p><img src="cid:image001"><img src="cid:image002">',
    "text/html",
)
email.send()

Please reopen the ticket with more details if the above is not useful

by Mike Edmunds, 13 hours ago

Attachment: test_mail_structure.py​ added

comment:8 by Mike Edmunds, 12 hours ago

Resolution: needsinfo → needsnewfeatureprocess

I agree this is a hole in Django's email functionality. Django ought to have a straightforward way to construct an email message where inline images are correctly contained in a multipart/related part with their associated text/html content, rather than handled as multipart/mixed attachments.

But I don't think this is a new hole: I can't find any way to construct a message with the structure below in Django 5.2. There is a real need here, but it's a new feature request, not lost functionality in Django 6.0 (from what I can tell).

attachment:test_mail_structure.py​ has the various approaches I tried to create a message with this structure in Django 5.2, using legacy MIMEObject types compatible with the legacy email APIs Django used back then. None of them worked:

multipart/alternative
     text/plain
     multipart/related
         text/html
         image/gif: Content-Disposition: inline; Content-ID: <image-1>

Sarah's suggestion in comment:7 works with any EmailBackend that calls EmailMessage.message() to convert the Django email to a Python email.EmailMessage (like the SMTP backend). But it won't help with third-party email backends that inspect the Django email's attachments and alternatives directly to figure out what to send (such as django-anymail for most ESP APIs).

The docs cover passing a MIMEPart object to ​EmailMessage.attach() and give the specific example of an inline image. This places the image in the multipart/mixed content, which is not technically correct but is displayed as expected (inline in the html) by most email clients. (It's a reasonable workaround. Fwiw, django-anymail specifically detects this case and extracts inline attachments for ESP APIs that support them.)

multipart/mixed
    multipart/alternative
        text/plain
        text/html
    image/gif: Content-Disposition: inline; Content-ID: <image-1>

The proposal in comment:3 to add a new special case in EmailMultiAlternatives._add_bodies() would provide a solution, but leads to increasingly awkward calling code that mixes parts of Python's email API with Django's mail:

from email.message import MIMEPart
from django.core.mail import EmailMultiAlternatives

email = EmailMultiAlternatives(body="Text body")
html_part = MIMEPart()
html_part.set_content('HTML body <img src="cid:image-1">', subtype="html")
html_part.add_related(b"GIF89a...", maintype="image", subtype="png", cid="<image-1>")
email.attach_alternative(html_part, "multipart/related")

That new special case would also greatly complicate third-party backends that don't use EmailMessage.message(), as they would have to inspect and unpack arbitrary MIMEPart trees and try to translate them into ESP API fields.

I think trying to mix the two APIs like this is the wrong approach. The django.core.mail APIs were meant to be "wrappers for Python’s email and smtplib modules to simplify composing and sending email" [first paragraph of the email docs; emphasis added]. But since django.core.mail was created, Python has introduced modern email APIs that are considerably simpler than the legacy APIs Django was trying to simplify. Adding more arcane special cases to Django feels like a step in the wrong direction.

Given that inline images (for html email) are a fairly common need, I'd like to see Django add first class support for them. Perhaps inlines parallel to attachments and alternatives. Marking this "needsnewfeatureprocess" with that in mind.

Note: See TracTickets for help on using tickets.
Back to Top