Browse Source

Refs #35537 -- Improved documentation and test coverage for email attachments and alternatives.

Jake Howard 9 months ago
parent
commit
d5bebc1c26
5 changed files with 128 additions and 12 deletions
  1. 4 0
      django/core/mail/__init__.py
  2. 3 3
      django/core/mail/message.py
  3. 3 1
      docs/releases/5.2.txt
  4. 33 8
      docs/topics/email.txt
  5. 85 0
      tests/mail/tests.py

+ 4 - 0
django/core/mail/__init__.py

@@ -11,6 +11,8 @@ from django.conf import settings
 from django.core.mail.message import (
     DEFAULT_ATTACHMENT_MIME_TYPE,
     BadHeaderError,
+    EmailAlternative,
+    EmailAttachment,
     EmailMessage,
     EmailMultiAlternatives,
     SafeMIMEMultipart,
@@ -37,6 +39,8 @@ __all__ = [
     "send_mass_mail",
     "mail_admins",
     "mail_managers",
+    "EmailAlternative",
+    "EmailAttachment",
 ]
 
 

+ 3 - 3
django/core/mail/message.py

@@ -191,7 +191,7 @@ class SafeMIMEMultipart(MIMEMixin, MIMEMultipart):
         MIMEMultipart.__setitem__(self, name, val)
 
 
-Alternative = namedtuple("Alternative", ["content", "mimetype"])
+EmailAlternative = namedtuple("Alternative", ["content", "mimetype"])
 EmailAttachment = namedtuple("Attachment", ["filename", "content", "mimetype"])
 
 
@@ -477,14 +477,14 @@ class EmailMultiAlternatives(EmailMessage):
             reply_to,
         )
         self.alternatives = [
-            Alternative(*alternative) for alternative in (alternatives or [])
+            EmailAlternative(*alternative) for alternative in (alternatives or [])
         ]
 
     def attach_alternative(self, content, mimetype):
         """Attach an alternative content representation."""
         if content is None or mimetype is None:
             raise ValueError("Both content and mimetype must be provided.")
-        self.alternatives.append(Alternative(content, mimetype))
+        self.alternatives.append(EmailAlternative(content, mimetype))
 
     def _create_message(self, msg):
         return self._create_attachments(self._create_alternatives(msg))

+ 3 - 1
docs/releases/5.2.txt

@@ -284,7 +284,9 @@ PostgreSQL 14 and higher.
 Miscellaneous
 -------------
 
-* ...
+* :attr:`EmailMultiAlternatives.alternatives
+  <django.core.mail.EmailMultiAlternatives.alternatives>` should only be added
+  to using :meth:`~django.core.mail.EmailMultiAlternatives.attach_alternative`.
 
 .. _deprecated-features-5.2:
 

+ 33 - 8
docs/topics/email.txt

@@ -282,13 +282,14 @@ All parameters are optional and can be set at any time prior to calling the
   new connection is created when ``send()`` is called.
 
 * ``attachments``: A list of attachments to put on the message. These can
-  be either :class:`~email.mime.base.MIMEBase` instances, or a named tuple
-  with attributes ``(filename, content, mimetype)``.
+  be instances of :class:`~email.mime.base.MIMEBase` or
+  :class:`~django.core.mail.EmailAttachment`, or a tuple with attributes
+  ``(filename, content, mimetype)``.
 
   .. versionchanged:: 5.2
 
-    In older versions, tuple items of ``attachments`` were regular tuples,
-    as opposed to named tuples.
+    Support for :class:`~django.core.mail.EmailAttachment` items of
+    ``attachments`` were added.
 
 * ``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
@@ -384,6 +385,18 @@ The class has the following methods:
   For MIME types starting with :mimetype:`text/`, binary data is handled as in
   ``attach()``.
 
+.. class:: EmailAttachment
+
+    .. versionadded:: 5.2
+
+    A named tuple to store attachments to an email.
+
+    The named tuple has the following indexes:
+
+    * ``filename``
+    * ``content``
+    * ``mimetype``
+
 Sending alternative content types
 ---------------------------------
 
@@ -404,20 +417,21 @@ Django's email library, you can do this using the
 
     .. attribute:: alternatives
 
-        A list of named tuples with attributes ``(content, mimetype)``. This is
-        particularly useful in tests::
+        A list of :class:`~django.core.mail.EmailAlternative` named tuples. 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:`attach_alternative`
-        method.
+        method, or passed to the constructor.
 
         .. versionchanged:: 5.2
 
             In older versions, ``alternatives`` was a list of regular tuples,
-            as opposed to named tuples.
+            as opposed to :class:`~django.core.mail.EmailAlternative` named
+            tuples.
 
     .. method:: attach_alternative(content, mimetype)
 
@@ -456,6 +470,17 @@ Django's email library, you can do this using the
                 self.assertIs(msg.body_contains("I am content"), True)
                 self.assertIs(msg.body_contains("<p>I am content.</p>"), False)
 
+.. class:: EmailAlternative
+
+    .. versionadded:: 5.2
+
+    A named tuple to store alternative versions of email content.
+
+    The named tuple has the following indexes:
+
+    * ``content``
+    * ``mimetype``
+
 Updating the default content type
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 

+ 85 - 0
tests/mail/tests.py

@@ -17,6 +17,8 @@ from unittest import mock, skipUnless
 from django.core import mail
 from django.core.mail import (
     DNS_NAME,
+    EmailAlternative,
+    EmailAttachment,
     EmailMessage,
     EmailMultiAlternatives,
     mail_admins,
@@ -557,12 +559,50 @@ class MailTests(HeadersCheckMixin, SimpleTestCase):
         mime_type = "text/html"
         msg.attach_alternative(html_content, mime_type)
 
+        self.assertIsInstance(msg.alternatives[0], EmailAlternative)
+
+        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)
+
+        self.assertIn(html_content, msg.message().as_string())
+
+    def test_alternatives_constructor(self):
+        html_content = "<p>This is <strong>html</strong></p>"
+        mime_type = "text/html"
+
+        msg = EmailMultiAlternatives(
+            alternatives=[EmailAlternative(html_content, mime_type)]
+        )
+
+        self.assertIsInstance(msg.alternatives[0], EmailAlternative)
+
         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)
 
+        self.assertIn(html_content, msg.message().as_string())
+
+    def test_alternatives_constructor_from_tuple(self):
+        html_content = "<p>This is <strong>html</strong></p>"
+        mime_type = "text/html"
+
+        msg = EmailMultiAlternatives(alternatives=[(html_content, mime_type)])
+
+        self.assertIsInstance(msg.alternatives[0], EmailAlternative)
+
+        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)
+
+        self.assertIn(html_content, msg.message().as_string())
+
     def test_none_body(self):
         msg = EmailMessage("subject", None, "from@example.com", ["to@example.com"])
         self.assertEqual(msg.body, "")
@@ -654,6 +694,51 @@ class MailTests(HeadersCheckMixin, SimpleTestCase):
         self.assertEqual(msg.attachments[0][2], mime_type)
         self.assertEqual(msg.attachments[0].mimetype, mime_type)
 
+        attachments = self.get_decoded_attachments(msg)
+        self.assertEqual(attachments[0], (file_name, file_content.encode(), mime_type))
+
+    def test_attachments_constructor(self):
+        file_name = "example.txt"
+        file_content = "Text file content"
+        mime_type = "text/plain"
+        msg = EmailMessage(
+            attachments=[EmailAttachment(file_name, file_content, mime_type)]
+        )
+
+        self.assertIsInstance(msg.attachments[0], EmailAttachment)
+
+        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)
+
+        attachments = self.get_decoded_attachments(msg)
+        self.assertEqual(attachments[0], (file_name, file_content.encode(), mime_type))
+
+    def test_attachments_constructor_from_tuple(self):
+        file_name = "example.txt"
+        file_content = "Text file content"
+        mime_type = "text/plain"
+        msg = EmailMessage(attachments=[(file_name, file_content, mime_type)])
+
+        self.assertIsInstance(msg.attachments[0], EmailAttachment)
+
+        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)
+
+        attachments = self.get_decoded_attachments(msg)
+        self.assertEqual(attachments[0], (file_name, file_content.encode(), mime_type))
+
     def test_decoded_attachments(self):
         """Regression test for #9367"""
         headers = {"Date": "Fri, 09 Nov 2001 01:08:47 -0000", "Message-ID": "foo"}