Browse Source

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

This makes it more descriptive to pull out the named fields.
Jake Howard 9 months ago
parent
commit
aba0e541ca

+ 15 - 4
django/core/mail/message.py

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

+ 9 - 1
docs/releases/5.2.txt

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

+ 29 - 6
docs/topics/email.txt

@@ -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,
-  content, mimetype)`` triples.
+  be either :class:`~email.mime.base.MIMEBase` instances, or a named tuple
+  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
-    additional ``attach_alternative()`` method for including extra versions of
-    the message body in the email. All the other methods (including the class
-    initialization) are inherited directly from
+    A subclass of :class:`~django.core.mail.EmailMessage` that allows
+    additional versions of the message body in the email via the
+    ``attach_alternative()`` method. This directly inherits all methods
+    (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
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
 

+ 1 - 1
tests/logging_tests/tests.py

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

+ 30 - 2
tests/mail/tests.py

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

+ 2 - 2
tests/view_tests/tests/test_debug.py

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