From d5bebc1c26d4c0ec9eaa057aefc5b38649c0ba3b Mon Sep 17 00:00:00 2001 From: Jake Howard Date: Thu, 27 Jun 2024 12:01:19 +0100 Subject: [PATCH] Refs #35537 -- Improved documentation and test coverage for email attachments and alternatives. --- django/core/mail/__init__.py | 4 ++ django/core/mail/message.py | 6 +-- docs/releases/5.2.txt | 4 +- docs/topics/email.txt | 41 +++++++++++++---- tests/mail/tests.py | 85 ++++++++++++++++++++++++++++++++++++ 5 files changed, 128 insertions(+), 12 deletions(-) diff --git a/django/core/mail/__init__.py b/django/core/mail/__init__.py index 676326697b..b179736b15 100644 --- a/django/core/mail/__init__.py +++ b/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", ] diff --git a/django/core/mail/message.py b/django/core/mail/message.py index eb467de429..e6d0ec2dc8 100644 --- a/django/core/mail/message.py +++ b/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)) diff --git a/docs/releases/5.2.txt b/docs/releases/5.2.txt index a64bc3bd00..92bb501d61 100644 --- a/docs/releases/5.2.txt +++ b/docs/releases/5.2.txt @@ -284,7 +284,9 @@ PostgreSQL 14 and higher. Miscellaneous ------------- -* ... +* :attr:`EmailMultiAlternatives.alternatives + ` should only be added + to using :meth:`~django.core.mail.EmailMultiAlternatives.attach_alternative`. .. _deprecated-features-5.2: diff --git a/docs/topics/email.txt b/docs/topics/email.txt index e5d4f277f5..b991de4d78 100644 --- a/docs/topics/email.txt +++ b/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("

I am content.

"), 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 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/tests/mail/tests.py b/tests/mail/tests.py index a0d28eb0ce..6280bfa5c8 100644 --- a/tests/mail/tests.py +++ b/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 = "

This is html

" + 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 = "

This is html

" + 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"}