Fixed #35537 -- Changed EmailMessage.attachments and EmailMultiAlternatives.alternatives to use namedtuples.

This makes it more descriptive to pull out the named fields.
This commit is contained in:
Jake Howard 2024-06-09 09:09:07 +01:00 committed by Sarah Boyce
parent 9691a00d58
commit aba0e541ca
6 changed files with 86 additions and 16 deletions

View File

@ -1,4 +1,5 @@
import mimetypes import mimetypes
from collections import namedtuple
from email import charset as Charset from email import charset as Charset
from email import encoders as Encoders from email import encoders as Encoders
from email import generator, message_from_string from email import generator, message_from_string
@ -190,6 +191,10 @@ class SafeMIMEMultipart(MIMEMixin, MIMEMultipart):
MIMEMultipart.__setitem__(self, name, val) MIMEMultipart.__setitem__(self, name, val)
Alternative = namedtuple("Alternative", ["content", "mimetype"])
EmailAttachment = namedtuple("Attachment", ["filename", "content", "mimetype"])
class EmailMessage: class EmailMessage:
"""A container for email information.""" """A container for email information."""
@ -338,7 +343,7 @@ class EmailMessage:
# actually binary, read() raises a UnicodeDecodeError. # actually binary, read() raises a UnicodeDecodeError.
mimetype = DEFAULT_ATTACHMENT_MIME_TYPE mimetype = DEFAULT_ATTACHMENT_MIME_TYPE
self.attachments.append((filename, content, mimetype)) self.attachments.append(EmailAttachment(filename, content, mimetype))
def attach_file(self, path, mimetype=None): def attach_file(self, path, mimetype=None):
""" """
@ -471,13 +476,15 @@ class EmailMultiAlternatives(EmailMessage):
cc, cc,
reply_to, reply_to,
) )
self.alternatives = alternatives or [] self.alternatives = [
Alternative(*alternative) for alternative in (alternatives or [])
]
def attach_alternative(self, content, mimetype): def attach_alternative(self, content, mimetype):
"""Attach an alternative content representation.""" """Attach an alternative content representation."""
if content is None or mimetype is None: if content is None or mimetype is None:
raise ValueError("Both content and mimetype must be provided.") raise ValueError("Both content and mimetype must be provided.")
self.alternatives.append((content, mimetype)) self.alternatives.append(Alternative(content, mimetype))
def _create_message(self, msg): def _create_message(self, msg):
return self._create_attachments(self._create_alternatives(msg)) return self._create_attachments(self._create_alternatives(msg))
@ -492,5 +499,9 @@ class EmailMultiAlternatives(EmailMessage):
if self.body: if self.body:
msg.attach(body_msg) msg.attach(body_msg)
for alternative in self.alternatives: for alternative in self.alternatives:
msg.attach(self._create_mime_attachment(*alternative)) msg.attach(
self._create_mime_attachment(
alternative.content, alternative.mimetype
)
)
return msg return msg

View File

@ -133,7 +133,15 @@ Decorators
Email Email
~~~~~ ~~~~~
* ... * Tuple items of :class:`EmailMessage.attachments
<django.core.mail.EmailMessage>` and
:class:`EmailMultiAlternatives.attachments
<django.core.mail.EmailMultiAlternatives>` are now named tuples, as opposed
to regular tuples.
* :attr:`EmailMultiAlternatives.alternatives
<django.core.mail.EmailMultiAlternatives.alternatives>` is now a list of
named tuples, as opposed to regular tuples.
Error Reporting Error Reporting
~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~

View File

@ -282,8 +282,13 @@ All parameters are optional and can be set at any time prior to calling the
new connection is created when ``send()`` is called. new connection is created when ``send()`` is called.
* ``attachments``: A list of attachments to put on the message. These can * ``attachments``: A list of attachments to put on the message. These can
be either :class:`~email.mime.base.MIMEBase` instances, or ``(filename, be either :class:`~email.mime.base.MIMEBase` instances, or a named tuple
content, mimetype)`` triples. with attributes ``(filename, content, mimetype)``.
.. versionchanged:: 5.2
In older versions, tuple items of ``attachments`` were regular tuples,
as opposed to named tuples.
* ``headers``: A dictionary of extra headers to put on the message. The * ``headers``: A dictionary of extra headers to put on the message. The
keys are the header name, values are the header values. It's up to the keys are the header name, values are the header values. It's up to the
@ -392,10 +397,10 @@ Django's email library, you can do this using the
.. class:: EmailMultiAlternatives .. class:: EmailMultiAlternatives
A subclass of :class:`~django.core.mail.EmailMessage` that has an A subclass of :class:`~django.core.mail.EmailMessage` that allows
additional ``attach_alternative()`` method for including extra versions of additional versions of the message body in the email via the
the message body in the email. All the other methods (including the class ``attach_alternative()`` method. This directly inherits all methods
initialization) are inherited directly from (including the class initialization) from
:class:`~django.core.mail.EmailMessage`. :class:`~django.core.mail.EmailMessage`.
.. method:: attach_alternative(content, mimetype) .. method:: attach_alternative(content, mimetype)
@ -415,6 +420,24 @@ Django's email library, you can do this using the
msg.attach_alternative(html_content, "text/html") msg.attach_alternative(html_content, "text/html")
msg.send() msg.send()
.. attribute:: alternatives
A list of named tuples with attributes ``(content, mimetype)``. This is
particularly useful in tests::
self.assertEqual(len(msg.alternatives), 1)
self.assertEqual(msg.alternatives[0].content, html_content)
self.assertEqual(msg.alternatives[0].mimetype, "text/html")
Alternatives should only be added using the
:meth:`~django.core.mail.EmailMultiAlternatives.attach_alternative`
method.
.. versionchanged:: 5.2
In older versions, ``alternatives`` was a list of regular tuples, as opposed
to named tuples.
Updating the default content type Updating the default content type
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

View File

@ -467,7 +467,7 @@ class AdminEmailHandlerTest(SimpleTestCase):
msg = mail.outbox[0] msg = mail.outbox[0]
self.assertEqual(msg.subject, "[Django] ERROR: message") self.assertEqual(msg.subject, "[Django] ERROR: message")
self.assertEqual(len(msg.alternatives), 1) self.assertEqual(len(msg.alternatives), 1)
body_html = str(msg.alternatives[0][0]) body_html = str(msg.alternatives[0].content)
self.assertIn('<div id="traceback">', body_html) self.assertIn('<div id="traceback">', body_html)
self.assertNotIn("<form", body_html) self.assertNotIn("<form", body_html)

View File

@ -550,6 +550,18 @@ class MailTests(HeadersCheckMixin, SimpleTestCase):
msg.attach("example.txt", "Text file content", "text/plain") msg.attach("example.txt", "Text file content", "text/plain")
self.assertIn(html_content, msg.message().as_string()) self.assertIn(html_content, msg.message().as_string())
def test_alternatives(self):
msg = EmailMultiAlternatives()
html_content = "<p>This is <strong>html</strong></p>"
mime_type = "text/html"
msg.attach_alternative(html_content, mime_type)
self.assertEqual(msg.alternatives[0][0], html_content)
self.assertEqual(msg.alternatives[0].content, html_content)
self.assertEqual(msg.alternatives[0][1], mime_type)
self.assertEqual(msg.alternatives[0].mimetype, mime_type)
def test_none_body(self): def test_none_body(self):
msg = EmailMessage("subject", None, "from@example.com", ["to@example.com"]) msg = EmailMessage("subject", None, "from@example.com", ["to@example.com"])
self.assertEqual(msg.body, "") self.assertEqual(msg.body, "")
@ -626,6 +638,22 @@ class MailTests(HeadersCheckMixin, SimpleTestCase):
) )
def test_attachments(self): def test_attachments(self):
msg = EmailMessage()
file_name = "example.txt"
file_content = "Text file content"
mime_type = "text/plain"
msg.attach(file_name, file_content, mime_type)
self.assertEqual(msg.attachments[0][0], file_name)
self.assertEqual(msg.attachments[0].filename, file_name)
self.assertEqual(msg.attachments[0][1], file_content)
self.assertEqual(msg.attachments[0].content, file_content)
self.assertEqual(msg.attachments[0][2], mime_type)
self.assertEqual(msg.attachments[0].mimetype, mime_type)
def test_decoded_attachments(self):
"""Regression test for #9367""" """Regression test for #9367"""
headers = {"Date": "Fri, 09 Nov 2001 01:08:47 -0000", "Message-ID": "foo"} headers = {"Date": "Fri, 09 Nov 2001 01:08:47 -0000", "Message-ID": "foo"}
subject, from_email, to = "hello", "from@example.com", "to@example.com" subject, from_email, to = "hello", "from@example.com", "to@example.com"
@ -645,14 +673,14 @@ class MailTests(HeadersCheckMixin, SimpleTestCase):
self.assertEqual(payload[0].get_content_type(), "multipart/alternative") self.assertEqual(payload[0].get_content_type(), "multipart/alternative")
self.assertEqual(payload[1].get_content_type(), "application/pdf") self.assertEqual(payload[1].get_content_type(), "application/pdf")
def test_attachments_two_tuple(self): def test_decoded_attachments_two_tuple(self):
msg = EmailMessage(attachments=[("filename1", "content1")]) msg = EmailMessage(attachments=[("filename1", "content1")])
filename, content, mimetype = self.get_decoded_attachments(msg)[0] filename, content, mimetype = self.get_decoded_attachments(msg)[0]
self.assertEqual(filename, "filename1") self.assertEqual(filename, "filename1")
self.assertEqual(content, b"content1") self.assertEqual(content, b"content1")
self.assertEqual(mimetype, "application/octet-stream") self.assertEqual(mimetype, "application/octet-stream")
def test_attachments_MIMEText(self): def test_decoded_attachments_MIMEText(self):
txt = MIMEText("content1") txt = MIMEText("content1")
msg = EmailMessage(attachments=[txt]) msg = EmailMessage(attachments=[txt])
payload = msg.message().get_payload() payload = msg.message().get_payload()

View File

@ -1463,7 +1463,7 @@ class ExceptionReportTestMixin:
self.assertNotIn("worcestershire", body_plain) self.assertNotIn("worcestershire", body_plain)
# Frames vars are shown in html email reports. # Frames vars are shown in html email reports.
body_html = str(email.alternatives[0][0]) body_html = str(email.alternatives[0].content)
self.assertIn("cooked_eggs", body_html) self.assertIn("cooked_eggs", body_html)
self.assertIn("scrambled", body_html) self.assertIn("scrambled", body_html)
self.assertIn("sauce", body_html) self.assertIn("sauce", body_html)
@ -1499,7 +1499,7 @@ class ExceptionReportTestMixin:
self.assertNotIn("worcestershire", body_plain) self.assertNotIn("worcestershire", body_plain)
# Frames vars are shown in html email reports. # Frames vars are shown in html email reports.
body_html = str(email.alternatives[0][0]) body_html = str(email.alternatives[0].content)
self.assertIn("cooked_eggs", body_html) self.assertIn("cooked_eggs", body_html)
self.assertIn("scrambled", body_html) self.assertIn("scrambled", body_html)
self.assertIn("sauce", body_html) self.assertIn("sauce", body_html)