From 23529b662793cdf4725d5f8ff58f0df94b343365 Mon Sep 17 00:00:00 2001 From: Mike Edmunds Date: Wed, 18 Jun 2025 20:34:34 -0700 Subject: [PATCH] Fixed #36478 -- Fixed inconsistent mail attachment handling. Fixed an inconsistency between EmailMessage.attach() and .attachments when attaching bytes content with a text/* mimetype. The attach() function decodes UTF-8 bytes if possible and otherwise changes the mimetype to application/octet-stream to preserve the content's unknown encoding (refs #27007). Providing equivalent content directly in EmailMessage.attachments did not apply the same logic, leading to an "AttributeError: 'bytes' object has no attribute 'encode'" in SafeMIMEText.set_payload(). Updated EmailMessage._create_mime_attachment() to match attach()'s handling for text/* mimetypes with bytes content. Updated test cases to accurately cover behavior on both paths. --- django/core/mail/message.py | 9 +++++++++ tests/mail/tests.py | 39 +++++++++++++++++++++++++++++++------ 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/django/core/mail/message.py b/django/core/mail/message.py index 421e353bfa..51af560b12 100644 --- a/django/core/mail/message.py +++ b/django/core/mail/message.py @@ -387,6 +387,15 @@ class EmailMessage: email.Message or EmailMessage object, as well as a str. """ basetype, subtype = mimetype.split("/", 1) + if basetype == "text" and isinstance(content, bytes): + # This duplicates logic from EmailMessage.attach() to properly + # handle EmailMessage.attachments not created through attach(). + try: + content = content.decode() + except UnicodeDecodeError: + mimetype = DEFAULT_ATTACHMENT_MIME_TYPE + basetype, subtype = mimetype.split("/", 1) + if basetype == "text": encoding = self.encoding or settings.DEFAULT_CHARSET attachment = SafeMIMEText(content, subtype, encoding) diff --git a/tests/mail/tests.py b/tests/mail/tests.py index 301e589409..b0fef71e98 100644 --- a/tests/mail/tests.py +++ b/tests/mail/tests.py @@ -901,12 +901,39 @@ class MailTests(MailTestsMixin, SimpleTestCase): self.assertEqual(actual.mimetype, expected_mimetype) def test_attach_text_as_bytes(self): - msg = EmailMessage() - msg.attach("file.txt", b"file content\n") - filename, content, mimetype = self.get_decoded_attachments(msg)[0] - self.assertEqual(filename, "file.txt") - self.assertEqual(content, "file content\n") - self.assertEqual(mimetype, "text/plain") + """ + For text/* attachments, EmailMessage.attach() decodes bytes as UTF-8 + if possible and changes to DEFAULT_ATTACHMENT_MIME_TYPE if not. + """ + email = EmailMessage() + # Mimetype guessing identifies these as text/plain from the .txt extensions. + email.attach("utf8.txt", "ütƒ-8\n".encode()) + email.attach("not-utf8.txt", b"\x86unknown-encoding\n") + attachments = self.get_decoded_attachments(email) + self.assertEqual(attachments[0], ("utf8.txt", "ütƒ-8\n", "text/plain")) + self.assertEqual( + attachments[1], + ("not-utf8.txt", b"\x86unknown-encoding\n", "application/octet-stream"), + ) + + def test_attach_text_as_bytes_using_property(self): + """ + The logic described in test_attach_text_as_bytes() also applies + when directly setting the EmailMessage.attachments property. + """ + email = EmailMessage() + email.attachments = [ + ("utf8.txt", "ütƒ-8\n".encode(), "text/plain"), + ("not-utf8.txt", b"\x86unknown-encoding\n", "text/plain"), + ] + attachments = self.get_decoded_attachments(email) + self.assertEqual(len(attachments), 2) + attachments = self.get_decoded_attachments(email) + self.assertEqual(attachments[0], ("utf8.txt", "ütƒ-8\n", "text/plain")) + self.assertEqual( + attachments[1], + ("not-utf8.txt", b"\x86unknown-encoding\n", "application/octet-stream"), + ) def test_attach_utf8_text_as_bytes(self): """