From 60f07e81c5cc31c8897173567915fd1a6482d180 Mon Sep 17 00:00:00 2001 From: Mike Edmunds Date: Sun, 15 Mar 2026 16:20:38 -0700 Subject: [PATCH 1/5] Refs #36953 -- Removed unnecessary overrides from mail tests. Django automatically substitutes the locmem EmailBackend during tests, and SimpleTestCase empties mail.outbox before each test. --- tests/mail/tests.py | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/tests/mail/tests.py b/tests/mail/tests.py index 3ba3b9d062cb..cd78f665dfb2 100644 --- a/tests/mail/tests.py +++ b/tests/mail/tests.py @@ -1326,9 +1326,7 @@ def test_backend_arg(self): ) self.assertIsInstance(mail.get_connection(), locmem.EmailBackend) - @override_settings(EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend") def test_connection_arg_send_mail(self): - mail.outbox = [] # Send using non-default connection. connection = mail.get_connection("mail.custombackend.EmailBackend") send_mail( @@ -1342,9 +1340,7 @@ def test_connection_arg_send_mail(self): self.assertEqual(len(connection.test_outbox), 1) self.assertEqual(connection.test_outbox[0].subject, "Subject") - @override_settings(EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend") def test_connection_arg_send_mass_mail(self): - mail.outbox = [] # Send using non-default connection. connection = mail.get_connection("mail.custombackend.EmailBackend") send_mass_mail( @@ -1359,12 +1355,8 @@ def test_connection_arg_send_mass_mail(self): self.assertEqual(connection.test_outbox[0].subject, "Subject1") self.assertEqual(connection.test_outbox[1].subject, "Subject2") - @override_settings( - EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend", - ADMINS=["nobody@example.com"], - ) + @override_settings(ADMINS=["nobody@example.com"]) def test_connection_arg_mail_admins(self): - mail.outbox = [] # Send using non-default connection. connection = mail.get_connection("mail.custombackend.EmailBackend") mail_admins("Admin message", "Content", connection=connection) @@ -1372,12 +1364,8 @@ def test_connection_arg_mail_admins(self): self.assertEqual(len(connection.test_outbox), 1) self.assertEqual(connection.test_outbox[0].subject, "[Django] Admin message") - @override_settings( - EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend", - MANAGERS=["nobody@example.com"], - ) + @override_settings(MANAGERS=["nobody@example.com"]) def test_connection_arg_mail_managers(self): - mail.outbox = [] # Send using non-default connection. connection = mail.get_connection("mail.custombackend.EmailBackend") mail_managers("Manager message", "Content", connection=connection) From 36ccdcc511e8ca5dc20e881fb9a3a310a8f85871 Mon Sep 17 00:00:00 2001 From: Mike Edmunds Date: Wed, 18 Mar 2026 13:13:24 -0700 Subject: [PATCH 2/5] Refs #36953 -- Split compound mail tests. Broke apart independent cases in mail tests using subTest() or separate methods. --- tests/mail/tests.py | 332 +++++++++++++++++++++++--------------------- 1 file changed, 170 insertions(+), 162 deletions(-) diff --git a/tests/mail/tests.py b/tests/mail/tests.py index cd78f665dfb2..68d41dc2163f 100644 --- a/tests/mail/tests.py +++ b/tests/mail/tests.py @@ -327,58 +327,59 @@ def test_recipients_with_empty_strings(self): def test_cc(self): """Regression test for #7722""" - email = EmailMessage( - "Subject", - "Content", - "from@example.com", - ["to@example.com"], - cc=["cc@example.com"], - ) - message = email.message() - self.assertEqual(message["Cc"], "cc@example.com") - self.assertEqual(email.recipients(), ["to@example.com", "cc@example.com"]) + with self.subTest("Single Cc"): + email = EmailMessage( + "Subject", + "Content", + "from@example.com", + ["to@example.com"], + cc=["cc@example.com"], + ) + message = email.message() + self.assertEqual(message["Cc"], "cc@example.com") + self.assertEqual(email.recipients(), ["to@example.com", "cc@example.com"]) - # Test multiple CC with multiple To - email = EmailMessage( - "Subject", - "Content", - "from@example.com", - ["to@example.com", "other@example.com"], - cc=["cc@example.com", "cc.other@example.com"], - ) - message = email.message() - self.assertEqual(message["Cc"], "cc@example.com, cc.other@example.com") - self.assertEqual( - email.recipients(), - [ - "to@example.com", - "other@example.com", - "cc@example.com", - "cc.other@example.com", - ], - ) + with self.subTest("Multiple Cc with multiple To"): + email = EmailMessage( + "Subject", + "Content", + "from@example.com", + ["to@example.com", "other@example.com"], + cc=["cc@example.com", "cc.other@example.com"], + ) + message = email.message() + self.assertEqual(message["Cc"], "cc@example.com, cc.other@example.com") + self.assertEqual( + email.recipients(), + [ + "to@example.com", + "other@example.com", + "cc@example.com", + "cc.other@example.com", + ], + ) - # Testing with Bcc - email = EmailMessage( - "Subject", - "Content", - "from@example.com", - ["to@example.com", "other@example.com"], - cc=["cc@example.com", "cc.other@example.com"], - bcc=["bcc@example.com"], - ) - message = email.message() - self.assertEqual(message["Cc"], "cc@example.com, cc.other@example.com") - self.assertEqual( - email.recipients(), - [ - "to@example.com", - "other@example.com", - "cc@example.com", - "cc.other@example.com", - "bcc@example.com", - ], - ) + with self.subTest("Cc with Bcc"): + email = EmailMessage( + "Subject", + "Content", + "from@example.com", + ["to@example.com", "other@example.com"], + cc=["cc@example.com", "cc.other@example.com"], + bcc=["bcc@example.com"], + ) + message = email.message() + self.assertEqual(message["Cc"], "cc@example.com, cc.other@example.com") + self.assertEqual( + email.recipients(), + [ + "to@example.com", + "other@example.com", + "cc@example.com", + "cc.other@example.com", + "bcc@example.com", + ], + ) def test_cc_headers(self): message = EmailMessage( @@ -416,27 +417,29 @@ def test_bcc_not_in_headers(self): self.assertEqual(email.recipients(), ["to@example.com", "bcc@example.com"]) def test_reply_to(self): - email = EmailMessage( - "Subject", - "Content", - "from@example.com", - ["to@example.com"], - reply_to=["reply_to@example.com"], - ) - message = email.message() - self.assertEqual(message["Reply-To"], "reply_to@example.com") + with self.subTest("Single Reply-To"): + email = EmailMessage( + "Subject", + "Content", + "from@example.com", + ["to@example.com"], + reply_to=["reply_to@example.com"], + ) + message = email.message() + self.assertEqual(message["Reply-To"], "reply_to@example.com") - email = EmailMessage( - "Subject", - "Content", - "from@example.com", - ["to@example.com"], - reply_to=["reply_to1@example.com", "reply_to2@example.com"], - ) - message = email.message() - self.assertEqual( - message["Reply-To"], "reply_to1@example.com, reply_to2@example.com" - ) + with self.subTest("Multiple Reply-To"): + email = EmailMessage( + "Subject", + "Content", + "from@example.com", + ["to@example.com"], + reply_to=["reply_to1@example.com", "reply_to2@example.com"], + ) + message = email.message() + self.assertEqual( + message["Reply-To"], "reply_to1@example.com, reply_to2@example.com" + ) def test_recipients_as_tuple(self): email = EmailMessage( @@ -461,22 +464,12 @@ def test_recipients_as_tuple(self): ) def test_recipients_as_string(self): - with self.assertRaisesMessage( - TypeError, '"to" argument must be a list or tuple' - ): - EmailMessage(to="foo@example.com") - with self.assertRaisesMessage( - TypeError, '"cc" argument must be a list or tuple' - ): - EmailMessage(cc="foo@example.com") - with self.assertRaisesMessage( - TypeError, '"bcc" argument must be a list or tuple' - ): - EmailMessage(bcc="foo@example.com") - with self.assertRaisesMessage( - TypeError, '"reply_to" argument must be a list or tuple' - ): - EmailMessage(reply_to="reply_to@example.com") + for field in ["to", "cc", "bcc", "reply_to"]: + with self.subTest(field=field): + params = {field: "foo@example.com"} + message = f'"{field}" argument must be a list or tuple' + with self.assertRaisesMessage(TypeError, message): + EmailMessage(**params) def test_header_injection(self): msg = "Header values may not contain linefeed or carriage return characters" @@ -551,29 +544,33 @@ def test_to_header(self): """ Make sure we can manually set the To header (#17444) """ - email = EmailMessage( - to=["list-subscriber@example.com", "list-subscriber2@example.com"], - headers={"To": "mailing-list@example.com"}, - ) - message = email.message() - self.assertEqual(message.get_all("To"), ["mailing-list@example.com"]) - self.assertEqual( - email.to, ["list-subscriber@example.com", "list-subscriber2@example.com"] - ) + with self.subTest("With override in headers"): + email = EmailMessage( + to=["list-subscriber@example.com", "list-subscriber2@example.com"], + headers={"To": "mailing-list@example.com"}, + ) + message = email.message() + self.assertEqual(message.get_all("To"), ["mailing-list@example.com"]) + self.assertEqual( + email.to, + ["list-subscriber@example.com", "list-subscriber2@example.com"], + ) # If we don't set the To header manually, it should default to the `to` # argument to the constructor. - email = EmailMessage( - to=["list-subscriber@example.com", "list-subscriber2@example.com"], - ) - message = email.message() - self.assertEqual( - message.get_all("To"), - ["list-subscriber@example.com, list-subscriber2@example.com"], - ) - self.assertEqual( - email.to, ["list-subscriber@example.com", "list-subscriber2@example.com"] - ) + with self.subTest("Without override in headers"): + email = EmailMessage( + to=["list-subscriber@example.com", "list-subscriber2@example.com"], + ) + message = email.message() + self.assertEqual( + message.get_all("To"), + ["list-subscriber@example.com, list-subscriber2@example.com"], + ) + self.assertEqual( + email.to, + ["list-subscriber@example.com", "list-subscriber2@example.com"], + ) def test_to_in_headers_only(self): message = EmailMessage( @@ -626,29 +623,35 @@ def test_unicode_address_header(self): make sure the email addresses are parsed correctly (especially with regards to commas) """ - email = EmailMessage( - to=['"Firstname Sürname" ', "other@example.com"], - ) - parsed = message_from_bytes(email.message().as_bytes()) - self.assertEqual( - parsed["To"].addresses, - ( - Address(display_name="Firstname Sürname", addr_spec="to@example.com"), - Address(addr_spec="other@example.com"), - ), - ) + with self.subTest("Without comma in display-name"): + email = EmailMessage( + to=['"Firstname Sürname" ', "other@example.com"], + ) + parsed = message_from_bytes(email.message().as_bytes()) + self.assertEqual( + parsed["To"].addresses, + ( + Address( + display_name="Firstname Sürname", addr_spec="to@example.com" + ), + Address(addr_spec="other@example.com"), + ), + ) - email = EmailMessage( - to=['"Sürname, Firstname" ', "other@example.com"], - ) - parsed = message_from_bytes(email.message().as_bytes()) - self.assertEqual( - parsed["To"].addresses, - ( - Address(display_name="Sürname, Firstname", addr_spec="to@example.com"), - Address(addr_spec="other@example.com"), - ), - ) + with self.subTest("With comma in display-name"): + email = EmailMessage( + to=['"Sürname, Firstname" ', "other@example.com"], + ) + parsed = message_from_bytes(email.message().as_bytes()) + self.assertEqual( + parsed["To"].addresses, + ( + Address( + display_name="Sürname, Firstname", addr_spec="to@example.com" + ), + Address(addr_spec="other@example.com"), + ), + ) def test_unicode_headers(self): email = EmailMessage( @@ -1257,9 +1260,9 @@ def test_attach_mimepart_prohibits_other_params(self): "content and mimetype must not be given when a MIMEPart instance " "is provided." ) - with self.assertRaisesMessage(ValueError, msg): + with self.subTest(param="content"), self.assertRaisesMessage(ValueError, msg): email_msg.attach(txt, content="content") - with self.assertRaisesMessage(ValueError, msg): + with self.subTest(param="mimetype"), self.assertRaisesMessage(ValueError, msg): email_msg.attach(txt, mimetype="text/plain") def test_attach_content_is_required(self): @@ -1294,23 +1297,26 @@ def test_custom_backend(self): def test_backend_arg(self): """Test backend argument of mail.get_connection()""" - self.assertIsInstance( - mail.get_connection("django.core.mail.backends.smtp.EmailBackend"), - smtp.EmailBackend, - ) - self.assertIsInstance( - mail.get_connection("django.core.mail.backends.locmem.EmailBackend"), - locmem.EmailBackend, - ) - self.assertIsInstance( - mail.get_connection("django.core.mail.backends.dummy.EmailBackend"), - dummy.EmailBackend, - ) - self.assertIsInstance( - mail.get_connection("django.core.mail.backends.console.EmailBackend"), - console.EmailBackend, - ) - with tempfile.TemporaryDirectory() as tmp_dir: + cases = [ + ("django.core.mail.backends.smtp.EmailBackend", smtp.EmailBackend), + ("django.core.mail.backends.locmem.EmailBackend", locmem.EmailBackend), + ("django.core.mail.backends.dummy.EmailBackend", dummy.EmailBackend), + ("django.core.mail.backends.console.EmailBackend", console.EmailBackend), + ] + for backend_path, backend_class in cases: + with self.subTest(backend_path=backend_path): + self.assertIsInstance( + mail.get_connection(backend_path), + backend_class, + ) + + # The filebased EmailBackend requires a file_path arg. + with ( + self.subTest( + backend_path="django.core.mail.backends.filebased.EmailBackend" + ), + tempfile.TemporaryDirectory() as tmp_dir, + ): self.assertIsInstance( mail.get_connection( "django.core.mail.backends.filebased.EmailBackend", @@ -1319,12 +1325,12 @@ def test_backend_arg(self): filebased.EmailBackend, ) + def test_get_connection_raises_error_from_backend_init(self): msg = " not object" with self.assertRaisesMessage(TypeError, msg): mail.get_connection( "django.core.mail.backends.filebased.EmailBackend", file_path=object() ) - self.assertIsInstance(mail.get_connection(), locmem.EmailBackend) def test_connection_arg_send_mail(self): # Send using non-default connection. @@ -1382,27 +1388,29 @@ def test_dont_mangle_from_in_body(self): def test_body_content_transfer_encoding(self): # Shouldn't use base64 or quoted-printable, instead should detect it # can represent content with 7-bit data (#3472, #11212). - msg = EmailMessage(body="Body with only ASCII characters.") - s = msg.message().as_bytes() - self.assertIn(b"Content-Transfer-Encoding: 7bit", s) + with self.subTest("ASCII body"): + msg = EmailMessage(body="Body with only ASCII characters.") + s = msg.message().as_bytes() + self.assertIn(b"Content-Transfer-Encoding: 7bit", s) # Shouldn't use base64 or quoted-printable, instead should detect # it can represent content with 8-bit data. - msg = EmailMessage(body="Body with latin characters: àáä.") - s = msg.message().as_bytes() - self.assertIn(b"Content-Transfer-Encoding: 8bit", s) + with self.subTest("8-bit body with short lines"): + msg = EmailMessage(body="Body with latin characters: àáä.") + s = msg.message().as_bytes() + self.assertIn(b"Content-Transfer-Encoding: 8bit", s) # Long body lines that require folding should use quoted-printable or # base64, whichever is shorter. - msg = EmailMessage( - body=( + with self.subTest("8-bit body with long lines"): + body = ( "Body with non latin characters: А Б В Г Д Е Ж Ѕ З И І К Л М Н О П.\n" "Because it has a line > 78 utf-8 octets, it should be folded, and " "must then be encoded using the shorter of quoted-printable or base64." - ), - ) - s = msg.message().as_bytes() - self.assertIn(b"Content-Transfer-Encoding: quoted-printable", s) + ) + msg = EmailMessage(body=body) + s = msg.message().as_bytes() + self.assertIn(b"Content-Transfer-Encoding: quoted-printable", s) # RemovedInDjango70Warning. @ignore_warnings(category=RemovedInDjango70Warning) @@ -1670,9 +1678,9 @@ def test_localpart_only_address(self): def test_email_multi_alternatives_content_mimetype_none(self): email_msg = EmailMultiAlternatives() msg = "Both content and mimetype must be provided." - with self.assertRaisesMessage(ValueError, msg): + with self.subTest(param="mimetype"), self.assertRaisesMessage(ValueError, msg): email_msg.attach_alternative(None, "text/html") - with self.assertRaisesMessage(ValueError, msg): + with self.subTest(param="content"), self.assertRaisesMessage(ValueError, msg): email_msg.attach_alternative("

content

", None) def test_mime_structure(self): From a8ef1a697bbe7b74141cd74c26d15ed35dd7817f Mon Sep 17 00:00:00 2001 From: Mike Edmunds Date: Thu, 19 Mar 2026 11:22:57 -0700 Subject: [PATCH 3/5] Refs #36953 -- Split apart catchall MailTests. Replaced large MailTests class with smaller classes focused on specific django.core.mail APIs: - EmailMessageTests: covering EmailMessage and EmailMultiAlternatives classes (the bulk of the former MailTests cases). - SendMailTests, SendMassMailTests, MailAdminsAndManagersTests: covering the function-based mail APIs. - GetConnectionTests: covering get_connection(). - DeprecatedInternalsTests: covering deprecated internal methods used in deprecated functionality. - DummyBackendTests: covering the dummy EmailBackend. In the process, moved the two cases from MailTimeZoneTests into the new EmailMessageTests, as they related to EmailMessage Date headers. --- tests/mail/tests.py | 764 +++++++++++++++++++++++--------------------- 1 file changed, 398 insertions(+), 366 deletions(-) diff --git a/tests/mail/tests.py b/tests/mail/tests.py index 68d41dc2163f..902215a3a280 100644 --- a/tests/mail/tests.py +++ b/tests/mail/tests.py @@ -235,9 +235,9 @@ def get_message_structure(self, message, level=0): return "".join(structure) -class MailTests(MailTestsMixin, SimpleTestCase): +class EmailMessageTests(MailTestsMixin, SimpleTestCase): """ - Non-backend specific tests. + Tests for django.core.mail.EmailMessage and EmailMultiAlternative. """ def test_ascii(self): @@ -250,44 +250,6 @@ def test_ascii(self): self.assertEqual(message["From"], "from@example.com") self.assertEqual(message["To"], "to@example.com") - # RemovedInDjango70Warning. - @ignore_warnings(category=RemovedInDjango70Warning) - @mock.patch("django.core.mail.message.MIMEText.set_payload") - def test_nonascii_as_string_with_ascii_charset(self, mock_set_payload): - """Line length check should encode the payload supporting - `surrogateescape`. - - Following https://github.com/python/cpython/issues/76511, newer - versions of Python (3.12.3 and 3.13+) ensure that a message's - payload is encoded with the provided charset and `surrogateescape` is - used as the error handling strategy. - - This test is heavily based on the test from the fix for the bug above. - Line length checks in SafeMIMEText's set_payload should also use the - same error handling strategy to avoid errors such as: - - UnicodeEncodeError: 'utf-8' codec can't encode <...>: surrogates not - allowed - - """ - # This test is specific to Python's legacy MIMEText. This can be safely - # removed when EmailMessage.message() uses Python's modern email API. - # (Using surrogateescape for non-utf8 is covered in test_encoding().) - from django.core.mail import SafeMIMEText - - def simplified_set_payload(instance, payload, charset): - instance._payload = payload - - mock_set_payload.side_effect = simplified_set_payload - - text = ( - "Text heavily based in Python's text for non-ascii messages: Föö bär" - ).encode("iso-8859-1") - body = text.decode("ascii", errors="surrogateescape") - message = SafeMIMEText(body, "plain", "ascii") - mock_set_payload.assert_called_once() - self.assertEqual(message.get_payload(decode=True), text) - def test_multiple_recipients(self): email = EmailMessage( "Subject", @@ -529,6 +491,31 @@ def test_datetime_in_date_header(self): # Not the default ISO format from force_str(strings_only=False). self.assertNotEqual(message["Date"], "2001-11-09 01:08:47+00:00") + @requires_tz_support + @override_settings( + EMAIL_USE_LOCALTIME=False, USE_TZ=True, TIME_ZONE="Africa/Algiers" + ) + def test_date_header_utc(self): + """ + EMAIL_USE_LOCALTIME=False creates a datetime in UTC. + """ + email = EmailMessage() + # Per RFC 2822/5322 section 3.3, "The form '+0000' SHOULD be used + # to indicate a time zone at Universal Time." + self.assertEndsWith(email.message()["Date"], "+0000") + + @requires_tz_support + @override_settings( + EMAIL_USE_LOCALTIME=True, USE_TZ=True, TIME_ZONE="Africa/Algiers" + ) + def test_date_header_localtime(self): + """ + EMAIL_USE_LOCALTIME=True creates a datetime in the local time zone. + """ + email = EmailMessage() + # Africa/Algiers is UTC+1 year round. + self.assertEndsWith(email.message()["Date"], "+0100") + def test_from_header(self): """ Make sure we can manually set the From header (#9214) @@ -1271,114 +1258,6 @@ def test_attach_content_is_required(self): with self.assertRaisesMessage(ValueError, msg): email_msg.attach("file.txt", mimetype="application/pdf") - def test_dummy_backend(self): - """ - Make sure that dummy backends returns correct number of sent messages - """ - connection = dummy.EmailBackend() - email = EmailMessage(to=["to@example.com"]) - self.assertEqual(connection.send_messages([email, email, email]), 3) - - def test_arbitrary_keyword(self): - """ - Make sure that get_connection() accepts arbitrary keyword that might be - used with custom backends. - """ - c = mail.get_connection(fail_silently=True, foo="bar") - self.assertTrue(c.fail_silently) - - def test_custom_backend(self): - """Test custom backend defined in this suite.""" - conn = mail.get_connection("mail.custombackend.EmailBackend") - self.assertTrue(hasattr(conn, "test_outbox")) - email = EmailMessage(to=["to@example.com"]) - conn.send_messages([email]) - self.assertEqual(len(conn.test_outbox), 1) - - def test_backend_arg(self): - """Test backend argument of mail.get_connection()""" - cases = [ - ("django.core.mail.backends.smtp.EmailBackend", smtp.EmailBackend), - ("django.core.mail.backends.locmem.EmailBackend", locmem.EmailBackend), - ("django.core.mail.backends.dummy.EmailBackend", dummy.EmailBackend), - ("django.core.mail.backends.console.EmailBackend", console.EmailBackend), - ] - for backend_path, backend_class in cases: - with self.subTest(backend_path=backend_path): - self.assertIsInstance( - mail.get_connection(backend_path), - backend_class, - ) - - # The filebased EmailBackend requires a file_path arg. - with ( - self.subTest( - backend_path="django.core.mail.backends.filebased.EmailBackend" - ), - tempfile.TemporaryDirectory() as tmp_dir, - ): - self.assertIsInstance( - mail.get_connection( - "django.core.mail.backends.filebased.EmailBackend", - file_path=tmp_dir, - ), - filebased.EmailBackend, - ) - - def test_get_connection_raises_error_from_backend_init(self): - msg = " not object" - with self.assertRaisesMessage(TypeError, msg): - mail.get_connection( - "django.core.mail.backends.filebased.EmailBackend", file_path=object() - ) - - def test_connection_arg_send_mail(self): - # Send using non-default connection. - connection = mail.get_connection("mail.custombackend.EmailBackend") - send_mail( - "Subject", - "Content", - "from@example.com", - ["to@example.com"], - connection=connection, - ) - self.assertEqual(mail.outbox, []) - self.assertEqual(len(connection.test_outbox), 1) - self.assertEqual(connection.test_outbox[0].subject, "Subject") - - def test_connection_arg_send_mass_mail(self): - # Send using non-default connection. - connection = mail.get_connection("mail.custombackend.EmailBackend") - send_mass_mail( - [ - ("Subject1", "Content1", "from1@example.com", ["to1@example.com"]), - ("Subject2", "Content2", "from2@example.com", ["to2@example.com"]), - ], - connection=connection, - ) - self.assertEqual(mail.outbox, []) - self.assertEqual(len(connection.test_outbox), 2) - self.assertEqual(connection.test_outbox[0].subject, "Subject1") - self.assertEqual(connection.test_outbox[1].subject, "Subject2") - - @override_settings(ADMINS=["nobody@example.com"]) - def test_connection_arg_mail_admins(self): - # Send using non-default connection. - connection = mail.get_connection("mail.custombackend.EmailBackend") - mail_admins("Admin message", "Content", connection=connection) - self.assertEqual(mail.outbox, []) - self.assertEqual(len(connection.test_outbox), 1) - self.assertEqual(connection.test_outbox[0].subject, "[Django] Admin message") - - @override_settings(MANAGERS=["nobody@example.com"]) - def test_connection_arg_mail_managers(self): - # Send using non-default connection. - connection = mail.get_connection("mail.custombackend.EmailBackend") - mail_managers("Manager message", "Content", connection=connection) - self.assertEqual(mail.outbox, []) - self.assertEqual(len(connection.test_outbox), 1) - self.assertEqual(connection.test_outbox[0].subject, "[Django] Manager message") - def test_dont_mangle_from_in_body(self): # Regression for #13433 - Make sure that EmailMessage doesn't mangle # 'From ' in message body. @@ -1412,193 +1291,51 @@ def test_body_content_transfer_encoding(self): s = msg.message().as_bytes() self.assertIn(b"Content-Transfer-Encoding: quoted-printable", s) - # RemovedInDjango70Warning. - @ignore_warnings(category=RemovedInDjango70Warning) - def test_sanitize_address(self): - """Email addresses are properly sanitized.""" - # Tests the internal sanitize_address() function. Many of these cases - # are duplicated in test_address_header_handling(), which verifies - # headers in the generated message. - from django.core.mail.message import sanitize_address - - for email_address, encoding, expected_result in ( - # ASCII addresses. - ("to@example.com", "ascii", "to@example.com"), - ("to@example.com", "utf-8", "to@example.com"), - (("A name", "to@example.com"), "ascii", "A name "), - ( - ("A name", "to@example.com"), - "utf-8", - "A name ", - ), - ("localpartonly", "ascii", "localpartonly"), - # ASCII addresses with display names. - ("A name ", "ascii", "A name "), - ("A name ", "utf-8", "A name "), - ('"A name" ', "ascii", "A name "), - ('"A name" ', "utf-8", "A name "), - # Unicode addresses: IDNA encoded domain supported per RFC-5890. - ("to@éxample.com", "utf-8", "to@xn--xample-9ua.com"), - # The next three cases should be removed when fixing #35713. - # (An 'encoded-word' localpart is prohibited by RFC-2047, and not - # supported by any known mail service.) - ("tó@example.com", "utf-8", "=?utf-8?b?dMOz?=@example.com"), - ( - ("Tó Example", "tó@example.com"), - "utf-8", - "=?utf-8?q?T=C3=B3_Example?= <=?utf-8?b?dMOz?=@example.com>", - ), + def test_address_header_handling(self): + # This verifies the modern email API's address header handling. + cases = [ + # (address, expected_display_name, expected_addr_spec) + ("to@example.com", "", "to@example.com"), + # Addresses with display-names. + ("A name ", "A name", "to@example.com"), + ('"A name" ', "A name", "to@example.com"), ( - "Tó Example ", - "utf-8", - # (Not RFC-2047 compliant.) - "=?utf-8?q?T=C3=B3_Example?= <=?utf-8?b?dMOz?=@example.com>", + '"Comma, requires quotes" ', + "Comma, requires quotes", + "to@example.com", ), - # IDNA addresses with display names. + ('"to@other.com" ', "to@other.com", "to@example.com"), + # Non-ASCII addr-spec: IDNA encoding for domain. + # (Note: no RFC permits encoding a non-ASCII localpart.) + ("to@éxample.com", "", "to@xn--xample-9ua.com"), ( "To Example ", - "ascii", - "To Example ", + "To Example", + "to@xn--xample-9ua.com", ), + # Pre-encoded IDNA domain is left as is. + # (Make sure IDNA 2008 is not downgraded to IDNA 2003.) + ("to@xn--fa-hia.example.com", "", "to@xn--fa-hia.example.com"), ( - "To Example ", - "utf-8", - "To Example ", + "", + "", + "to@xn--10cl1a0b660p.example.com", ), - # Addresses with two @ signs. - ('"to@other.com"@example.com', "utf-8", r'"to@other.com"@example.com'), ( - '"to@other.com" ', - "utf-8", - '"to@other.com" ', + '"Display, Name" ', + "Display, Name", + "to@xn--nxasmm1c.example.com", ), + # Non-ASCII display-name. + ("Tó Example ", "Tó Example", "to@example.com"), + # Addresses with two @ signs (quoted-string localpart). + ('"to@other.com"@example.com', "", '"to@other.com"@example.com'), ( - ("To Example", "to@other.com@example.com"), - "utf-8", 'To Example <"to@other.com"@example.com>', + "To Example", + '"to@other.com"@example.com', ), - # Addresses with long unicode display names. - ( - "Tó Example very long" * 4 + " ", - "utf-8", - "=?utf-8?q?T=C3=B3_Example_very_longT=C3=B3_Example_very_longT" - "=C3=B3_Example_?=\n" - " =?utf-8?q?very_longT=C3=B3_Example_very_long?= " - "", - ), - ( - ("Tó Example very long" * 4, "to@example.com"), - "utf-8", - "=?utf-8?q?T=C3=B3_Example_very_longT=C3=B3_Example_very_longT" - "=C3=B3_Example_?=\n" - " =?utf-8?q?very_longT=C3=B3_Example_very_long?= " - "", - ), - # Address with long display name and unicode domain. - ( - ("To Example very long" * 4, "to@exampl€.com"), - "utf-8", - "To Example very longTo Example very longTo Example very longT" - "o Example very\n" - " long ", - ), - ): - with self.subTest(email_address=email_address, encoding=encoding): - self.assertEqual( - sanitize_address(email_address, encoding), expected_result - ) - - # RemovedInDjango70Warning. - @ignore_warnings(category=RemovedInDjango70Warning) - def test_sanitize_address_invalid(self): - # Tests the internal sanitize_address() function. Note that Django's - # EmailMessage.message() will not catch these cases, as it only calls - # sanitize_address() if an address also includes non-ASCII chars. - # Django detects these cases in the SMTP EmailBackend during sending. - # See SMTPBackendTests.test_avoids_sending_to_invalid_addresses() - # below. - from django.core.mail.message import sanitize_address - - for email_address in ( - # Invalid address with two @ signs. - "to@other.com@example.com", - # Invalid address without the quotes. - "to@other.com ", - # Other invalid addresses. - "@", - "to@", - "@example.com", - ("", ""), - ): - with self.subTest(email_address=email_address): - with self.assertRaisesMessage(ValueError, "Invalid address"): - sanitize_address(email_address, encoding="utf-8") - - # RemovedInDjango70Warning. - @ignore_warnings(category=RemovedInDjango70Warning) - def test_sanitize_address_header_injection(self): - # Tests the internal sanitize_address() function. These cases are - # duplicated in test_address_header_handling(), which verifies headers - # in the generated message. - from django.core.mail.message import sanitize_address - - msg = "Invalid address; address parts cannot contain newlines." - tests = [ - "Name\nInjection ", - ("Name\nInjection", "to@xample.com"), - "Name ", - ("Name", "to\ninjection@example.com"), - ] - for email_address in tests: - with self.subTest(email_address=email_address): - with self.assertRaisesMessage(ValueError, msg): - sanitize_address(email_address, encoding="utf-8") - - def test_address_header_handling(self): - # This verifies the modern email API's address header handling. - cases = [ - # (address, expected_display_name, expected_addr_spec) - ("to@example.com", "", "to@example.com"), - # Addresses with display-names. - ("A name ", "A name", "to@example.com"), - ('"A name" ', "A name", "to@example.com"), - ( - '"Comma, requires quotes" ', - "Comma, requires quotes", - "to@example.com", - ), - ('"to@other.com" ', "to@other.com", "to@example.com"), - # Non-ASCII addr-spec: IDNA encoding for domain. - # (Note: no RFC permits encoding a non-ASCII localpart.) - ("to@éxample.com", "", "to@xn--xample-9ua.com"), - ( - "To Example ", - "To Example", - "to@xn--xample-9ua.com", - ), - # Pre-encoded IDNA domain is left as is. - # (Make sure IDNA 2008 is not downgraded to IDNA 2003.) - ("to@xn--fa-hia.example.com", "", "to@xn--fa-hia.example.com"), - ( - "", - "", - "to@xn--10cl1a0b660p.example.com", - ), - ( - '"Display, Name" ', - "Display, Name", - "to@xn--nxasmm1c.example.com", - ), - # Non-ASCII display-name. - ("Tó Example ", "Tó Example", "to@example.com"), - # Addresses with two @ signs (quoted-string localpart). - ('"to@other.com"@example.com', "", '"to@other.com"@example.com'), - ( - 'To Example <"to@other.com"@example.com>', - "To Example", - '"to@other.com"@example.com', - ), - # Addresses with long non-ASCII display names. + # Addresses with long non-ASCII display names. ( "Tó Example very long" * 4 + " ", "Tó Example very long" * 4, @@ -1995,7 +1732,42 @@ def test_message_policy_compat32(self): message.as_string(policy=policy.compat32), ) - def test_send_mail_fail_silently_conflict(self): + def test_send_fail_silently_conflict(self): + email = mail.EmailMessage( + "Subject", + "Body", + "from@example.com", + ["to@example.com"], + connection=mail.get_connection(), + ) + msg = ( + "fail_silently cannot be used with a connection. " + "Pass fail_silently to get_connection() instead." + ) + with self.assertRaisesMessage(TypeError, msg): + email.send(fail_silently=True) + + +class SendMailTests(SimpleTestCase): + """ + Tests for django.core.mail.send_mail(). + """ + + def test_connection_arg(self): + # Send using non-default connection. + connection = mail.get_connection("mail.custombackend.EmailBackend") + send_mail( + "Subject", + "Content", + "from@example.com", + ["to@example.com"], + connection=connection, + ) + self.assertEqual(mail.outbox, []) + self.assertEqual(len(connection.test_outbox), 1) + self.assertEqual(connection.test_outbox[0].subject, "Subject") + + def test_fail_silently_conflict(self): msg = ( "fail_silently cannot be used with a connection. " "Pass fail_silently to get_connection() instead." @@ -2010,7 +1782,7 @@ def test_send_mail_fail_silently_conflict(self): connection=mail.get_connection(), ) - def test_send_mail_auth_conflict(self): + def test_auth_conflict(self): msg = ( "auth_user and auth_password cannot be used with a connection. " "Pass auth_user and auth_password to get_connection() instead." @@ -2029,22 +1801,28 @@ def test_send_mail_auth_conflict(self): connection=mail.get_connection(), ) - def test_email_message_send_fail_silently_conflict(self): - email = mail.EmailMessage( - "Subject", - "Body", - "from@example.com", - ["to@example.com"], - connection=mail.get_connection(), - ) - msg = ( - "fail_silently cannot be used with a connection. " - "Pass fail_silently to get_connection() instead." + +class SendMassMailTests(SimpleTestCase): + """ + Tests for django.core.mail.send_mass_mail(). + """ + + def test_connection_arg(self): + # Send using non-default connection. + connection = mail.get_connection("mail.custombackend.EmailBackend") + send_mass_mail( + [ + ("Subject1", "Content1", "from1@example.com", ["to1@example.com"]), + ("Subject2", "Content2", "from2@example.com", ["to2@example.com"]), + ], + connection=connection, ) - with self.assertRaisesMessage(TypeError, msg): - email.send(fail_silently=True) + self.assertEqual(mail.outbox, []) + self.assertEqual(len(connection.test_outbox), 2) + self.assertEqual(connection.test_outbox[0].subject, "Subject1") + self.assertEqual(connection.test_outbox[1].subject, "Subject2") - def test_send_mass_mail_fail_silently_conflict(self): + def test_send_fail_silently_conflict(self): datatuple = (("Subject", "Message", "from@example.com", ["to@example.com"]),) msg = ( "fail_silently cannot be used with a connection. " @@ -2055,7 +1833,7 @@ def test_send_mass_mail_fail_silently_conflict(self): datatuple, fail_silently=True, connection=mail.get_connection() ) - def test_send_mass_mail_auth_conflict(self): + def test_send_auth_conflict(self): datatuple = (("Subject", "Message", "from@example.com", ["to@example.com"]),) msg = ( "auth_user and auth_password cannot be used with a connection. " @@ -2070,6 +1848,30 @@ def test_send_mass_mail_auth_conflict(self): datatuple, **{param: "value"}, connection=mail.get_connection() ) + +class MailAdminsAndManagersTests(SimpleTestCase): + """ + Tests for django.core.mail.mail_admins() and mail_managers(). + """ + + @override_settings(ADMINS=["nobody@example.com"]) + def test_connection_arg_mail_admins(self): + # Send using non-default connection. + connection = mail.get_connection("mail.custombackend.EmailBackend") + mail_admins("Admin message", "Content", connection=connection) + self.assertEqual(mail.outbox, []) + self.assertEqual(len(connection.test_outbox), 1) + self.assertEqual(connection.test_outbox[0].subject, "[Django] Admin message") + + @override_settings(MANAGERS=["nobody@example.com"]) + def test_connection_arg_mail_managers(self): + # Send using non-default connection. + connection = mail.get_connection("mail.custombackend.EmailBackend") + mail_managers("Manager message", "Content", connection=connection) + self.assertEqual(mail.outbox, []) + self.assertEqual(len(connection.test_outbox), 1) + self.assertEqual(connection.test_outbox[0].subject, "[Django] Manager message") + def test_mail_admins_fail_silently_conflict(self): msg = ( "fail_silently cannot be used with a connection. " @@ -2097,6 +1899,244 @@ def test_mail_managers_fail_silently_conflict(self): ) +class GetConnectionTests(SimpleTestCase): + """ + Tests for django.core.mail.get_connection(). + """ + + def test_backend_arg(self): + """Test backend argument of mail.get_connection()""" + cases = [ + ("django.core.mail.backends.smtp.EmailBackend", smtp.EmailBackend), + ("django.core.mail.backends.locmem.EmailBackend", locmem.EmailBackend), + ("django.core.mail.backends.dummy.EmailBackend", dummy.EmailBackend), + ("django.core.mail.backends.console.EmailBackend", console.EmailBackend), + ] + for backend_path, backend_class in cases: + with self.subTest(backend_path=backend_path): + self.assertIsInstance( + mail.get_connection(backend_path), + backend_class, + ) + + # The filebased EmailBackend requires a file_path arg. + with ( + self.subTest( + backend_path="django.core.mail.backends.filebased.EmailBackend" + ), + tempfile.TemporaryDirectory() as tmp_dir, + ): + self.assertIsInstance( + mail.get_connection( + "django.core.mail.backends.filebased.EmailBackend", + file_path=tmp_dir, + ), + filebased.EmailBackend, + ) + + def test_custom_backend(self): + """Test custom backend defined in this suite.""" + conn = mail.get_connection("mail.custombackend.EmailBackend") + self.assertTrue(hasattr(conn, "test_outbox")) + email = EmailMessage(to=["to@example.com"]) + conn.send_messages([email]) + self.assertEqual(len(conn.test_outbox), 1) + + def test_raises_error_from_backend_init(self): + msg = " not object" + with self.assertRaisesMessage(TypeError, msg): + mail.get_connection( + "django.core.mail.backends.filebased.EmailBackend", file_path=object() + ) + + def test_arbitrary_keyword(self): + """ + Make sure that get_connection() accepts arbitrary keyword that might be + used with custom backends. + """ + c = mail.get_connection(fail_silently=True, foo="bar") + self.assertTrue(c.fail_silently) + + +# RemovedInDjango70Warning. +class DeprecatedInternalsTests(SimpleTestCase): + @ignore_warnings(category=RemovedInDjango70Warning) + @mock.patch("django.core.mail.message.MIMEText.set_payload") + def test_nonascii_as_string_with_ascii_charset(self, mock_set_payload): + """Line length check should encode the payload supporting + `surrogateescape`. + + Following https://github.com/python/cpython/issues/76511, newer + versions of Python (3.12.3 and 3.13+) ensure that a message's + payload is encoded with the provided charset and `surrogateescape` is + used as the error handling strategy. + + This test is heavily based on the test from the fix for the bug above. + Line length checks in SafeMIMEText's set_payload should also use the + same error handling strategy to avoid errors such as: + + UnicodeEncodeError: 'utf-8' codec can't encode <...>: surrogates not + allowed + + """ + # This test is specific to Python's legacy MIMEText. This can be safely + # removed when EmailMessage.message() uses Python's modern email API. + # (Using surrogateescape for non-utf8 is covered in test_encoding().) + from django.core.mail import SafeMIMEText + + def simplified_set_payload(instance, payload, charset): + instance._payload = payload + + mock_set_payload.side_effect = simplified_set_payload + + text = ( + "Text heavily based in Python's text for non-ascii messages: Föö bär" + ).encode("iso-8859-1") + body = text.decode("ascii", errors="surrogateescape") + message = SafeMIMEText(body, "plain", "ascii") + mock_set_payload.assert_called_once() + self.assertEqual(message.get_payload(decode=True), text) + + @ignore_warnings(category=RemovedInDjango70Warning) + def test_sanitize_address(self): + """Email addresses are properly sanitized.""" + # Tests the internal sanitize_address() function. Many of these cases + # are duplicated in test_address_header_handling(), which verifies + # headers in the generated message. + from django.core.mail.message import sanitize_address + + for email_address, encoding, expected_result in ( + # ASCII addresses. + ("to@example.com", "ascii", "to@example.com"), + ("to@example.com", "utf-8", "to@example.com"), + (("A name", "to@example.com"), "ascii", "A name "), + ( + ("A name", "to@example.com"), + "utf-8", + "A name ", + ), + ("localpartonly", "ascii", "localpartonly"), + # ASCII addresses with display names. + ("A name ", "ascii", "A name "), + ("A name ", "utf-8", "A name "), + ('"A name" ', "ascii", "A name "), + ('"A name" ', "utf-8", "A name "), + # Unicode addresses: IDNA encoded domain supported per RFC-5890. + ("to@éxample.com", "utf-8", "to@xn--xample-9ua.com"), + # The next three cases should be removed when fixing #35713. + # (An 'encoded-word' localpart is prohibited by RFC-2047, and not + # supported by any known mail service.) + ("tó@example.com", "utf-8", "=?utf-8?b?dMOz?=@example.com"), + ( + ("Tó Example", "tó@example.com"), + "utf-8", + "=?utf-8?q?T=C3=B3_Example?= <=?utf-8?b?dMOz?=@example.com>", + ), + ( + "Tó Example ", + "utf-8", + # (Not RFC-2047 compliant.) + "=?utf-8?q?T=C3=B3_Example?= <=?utf-8?b?dMOz?=@example.com>", + ), + # IDNA addresses with display names. + ( + "To Example ", + "ascii", + "To Example ", + ), + ( + "To Example ", + "utf-8", + "To Example ", + ), + # Addresses with two @ signs. + ('"to@other.com"@example.com', "utf-8", r'"to@other.com"@example.com'), + ( + '"to@other.com" ', + "utf-8", + '"to@other.com" ', + ), + ( + ("To Example", "to@other.com@example.com"), + "utf-8", + 'To Example <"to@other.com"@example.com>', + ), + # Addresses with long unicode display names. + ( + "Tó Example very long" * 4 + " ", + "utf-8", + "=?utf-8?q?T=C3=B3_Example_very_longT=C3=B3_Example_very_longT" + "=C3=B3_Example_?=\n" + " =?utf-8?q?very_longT=C3=B3_Example_very_long?= " + "", + ), + ( + ("Tó Example very long" * 4, "to@example.com"), + "utf-8", + "=?utf-8?q?T=C3=B3_Example_very_longT=C3=B3_Example_very_longT" + "=C3=B3_Example_?=\n" + " =?utf-8?q?very_longT=C3=B3_Example_very_long?= " + "", + ), + # Address with long display name and unicode domain. + ( + ("To Example very long" * 4, "to@exampl€.com"), + "utf-8", + "To Example very longTo Example very longTo Example very longT" + "o Example very\n" + " long ", + ), + ): + with self.subTest(email_address=email_address, encoding=encoding): + self.assertEqual( + sanitize_address(email_address, encoding), expected_result + ) + + @ignore_warnings(category=RemovedInDjango70Warning) + def test_sanitize_address_invalid(self): + # Tests the internal sanitize_address() function. Note that Django's + # EmailMessage.message() will not catch these cases, as it only calls + # sanitize_address() if an address also includes non-ASCII chars. + # Django detects these cases in the SMTP EmailBackend during sending. + # See SMTPBackendTests.test_avoids_sending_to_invalid_addresses() + # below. + from django.core.mail.message import sanitize_address + + for email_address in ( + # Invalid address with two @ signs. + "to@other.com@example.com", + # Invalid address without the quotes. + "to@other.com ", + # Other invalid addresses. + "@", + "to@", + "@example.com", + ("", ""), + ): + with self.subTest(email_address=email_address): + with self.assertRaisesMessage(ValueError, "Invalid address"): + sanitize_address(email_address, encoding="utf-8") + + @ignore_warnings(category=RemovedInDjango70Warning) + def test_sanitize_address_header_injection(self): + # Tests the internal sanitize_address() function. These cases are + # duplicated in test_address_header_handling(), which verifies headers + # in the generated message. + from django.core.mail.message import sanitize_address + + msg = "Invalid address; address parts cannot contain newlines." + tests = [ + "Name\nInjection ", + ("Name\nInjection", "to@xample.com"), + "Name ", + ("Name", "to\ninjection@example.com"), + ] + for email_address in tests: + with self.subTest(email_address=email_address): + with self.assertRaisesMessage(ValueError, msg): + sanitize_address(email_address, encoding="utf-8") + + # RemovedInDjango70Warning. class MailDeprecatedPositionalArgsTests(SimpleTestCase): @@ -2214,32 +2254,6 @@ def test_email_multi_alternatives_init(self): ) -@requires_tz_support -class MailTimeZoneTests(MailTestsMixin, SimpleTestCase): - @override_settings( - EMAIL_USE_LOCALTIME=False, USE_TZ=True, TIME_ZONE="Africa/Algiers" - ) - def test_date_header_utc(self): - """ - EMAIL_USE_LOCALTIME=False creates a datetime in UTC. - """ - email = EmailMessage() - # Per RFC 2822/5322 section 3.3, "The form '+0000' SHOULD be used - # to indicate a time zone at Universal Time." - self.assertEndsWith(email.message()["Date"], "+0000") - - @override_settings( - EMAIL_USE_LOCALTIME=True, USE_TZ=True, TIME_ZONE="Africa/Algiers" - ) - def test_date_header_localtime(self): - """ - EMAIL_USE_LOCALTIME=True creates a datetime in the local time zone. - """ - email = EmailMessage() - # Africa/Algiers is UTC+1 year round. - self.assertEndsWith(email.message()["Date"], "+0100") - - # RemovedInDjango70Warning. class PythonGlobalState(SimpleTestCase): """ @@ -2665,6 +2679,24 @@ def close(): self.assertTrue(closed[0]) +class DummyBackendTests(BaseEmailBackendTests, SimpleTestCase): + email_backend = "django.core.mail.backends.dummy.EmailBackend" + + def get_mailbox_content(self): + # Shared tests that examine the content of sent messages are not + # meaningful: the dummy backend immediately discards sent messages, + # so it's not possible to retrieve them. + self.skipTest("Dummy backend discards sent messages") + + def flush_mailbox(self): + pass + + def test_send_messages_returns_sent_count(self): + connection = dummy.EmailBackend() + email = EmailMessage(to=["to@example.com"]) + self.assertEqual(connection.send_messages([email, email, email]), 3) + + class LocmemBackendTests(BaseEmailBackendTests, SimpleTestCase): email_backend = "django.core.mail.backends.locmem.EmailBackend" From 3ef48ca6c10830914b09abd20ef48a705e1fbfcd Mon Sep 17 00:00:00 2001 From: Mike Edmunds Date: Thu, 19 Mar 2026 13:59:34 -0700 Subject: [PATCH 4/5] Refs #36953 -- Moved non-backend-dependent BaseEmailBackendTests. Relocated BaseEmailBackendTests that are _not_ dependent on the email backend. - In general, moved test cases to EmailMessageTests or SendMailTests as appropriate, and changed them to work with the testing outbox. - Replaced BaseEmailBackendTests.test_send_verbose_name() with EmailMessageTests.test_unicode_display_name_in_from_email(). (EmailMessageTests.test_address_header_handling() also partly covers the behavior, as well as Python's own message serialization tests.) - Removed BaseEmailBackendTests.test_message_cc_header(), which was already covered by EmailMessageTests.test_cc*() (and Python's own message serialization tests). - Replaced BaseEmailBackendTests.test_idn_send() with EmailMessageTests.test_idn_addresses() to cover from_email and cc. (EmailMessageTests.test_address_header_handling() already covered to.) - Removed BaseEmailBackendTests.test_recipient_without_domain(), which was partly covered by EmailMessageTests.test_localpart_only_address(). Updated the latter to cover a localpart-only from_email. - Updated docstrings and comments to clarify a few tests that _do_ depend on the email backend. --- tests/mail/tests.py | 596 ++++++++++++++++++++++---------------------- 1 file changed, 296 insertions(+), 300 deletions(-) diff --git a/tests/mail/tests.py b/tests/mail/tests.py index 902215a3a280..e756e49d6eee 100644 --- a/tests/mail/tests.py +++ b/tests/mail/tests.py @@ -184,6 +184,20 @@ def assertEndsWith(self, first, second): "First string doesn't end with the second.", ) + def get_outbox_message(self, index=0, expected_count=1): + """ + Return a (Django) EmailMessage from the locmem outbox. + + If expected_count is not None, first assert that the outbox has exactly + the expected number of messages. + """ + if expected_count is not None: + assert expected_count > index # Require valid args. + self.assertEqual(len(mail.outbox), expected_count) + else: + self.assertGreater(len(mail.outbox), index) + return mail.outbox[index].message() + def get_raw_attachments(self, django_message): """ Return a list of the raw attachment parts in the MIME message generated @@ -1291,6 +1305,39 @@ def test_body_content_transfer_encoding(self): s = msg.message().as_bytes() self.assertIn(b"Content-Transfer-Encoding: quoted-printable", s) + def test_long_lines(self): + """ + Email line length is limited to 998 chars by the RFC 5322 Section + 2.1.1. A message body containing longer lines is converted to + quoted-printable or base64 (whichever is shorter), to avoid having to + insert newlines in a way that alters the intended text. + """ + cases = [ + # (body, expected_cte) + ("В южных морях " * 60, "base64"), + ("I de sørlige hav " * 58, "quoted-printable"), + ] + for body, expected_cte in cases: + mail.outbox = [] + with self.subTest(body=f"{body[:10]}…", expected_cte=expected_cte): + # Test precondition: Body is a single line < 998 characters, + # but utf-8 encoding of body is > 998 octets (forcing a CTE + # that avoids inserting newlines). + self.assertLess(len(body), 998) + self.assertGreater(len(body.encode()), 998) + + email = EmailMessage(body=body, to=["to@example.com"]) + email.send() + message = self.get_outbox_message() + self.assertMessageHasHeaders( + message, + { + ("MIME-Version", "1.0"), + ("Content-Type", 'text/plain; charset="utf-8"'), + ("Content-Transfer-Encoding", expected_cte), + }, + ) + def test_address_header_handling(self): # This verifies the modern email API's address header handling. cases = [ @@ -1399,6 +1446,23 @@ def test_address_header_injection(self): with self.assertRaisesMessage(ValueError, msg): email.message() + def test_idn_addresses(self): + """ + IDNA encoding is applied to non-ASCII domains in address headers + (#14301). + + See also test_address_header_handling() for several variations in the + to field. + """ + email = EmailMessage( + from_email="from@öäü.com", to=["to@öäü.com"], cc=["cc@öäü.com"] + ) + email.send() + message = self.get_outbox_message() + self.assertEqual(message.get("from"), "from@xn--4ca9at.com") + self.assertEqual(message.get("to"), "to@xn--4ca9at.com") + self.assertEqual(message.get("cc"), "cc@xn--4ca9at.com") + def test_localpart_only_address(self): """ Django allows sending to a localpart-only email address @@ -1406,12 +1470,40 @@ def test_localpart_only_address(self): is accepted by some SMTP servers for local delivery. Regression for #15042. """ - email = EmailMessage(to=["localpartonly"]) + email = EmailMessage(from_email="django", to=["localpartonly"]) parsed = message_from_bytes(email.message().as_bytes()) + self.assertEqual( + parsed["From"].addresses, (Address(username="django", domain=""),) + ) self.assertEqual( parsed["To"].addresses, (Address(username="localpartonly", domain=""),) ) + def test_lazy_addresses(self): + """ + Email sending should support lazy email addresses (#24416). + """ + _ = gettext_lazy + email = EmailMessage( + from_email=_("tester"), + to=[_("to1"), _("to2")], + cc=[_("cc1"), _("cc2")], + bcc=[_("bcc")], + reply_to=[_("reply")], + ) + self.assertEqual(email.recipients(), ["to1", "to2", "cc1", "cc2", "bcc"]) + email.send() + message = self.get_outbox_message() + self.assertEqual(message.get("from"), "tester") + self.assertEqual(message.get("to"), "to1, to2") + self.assertEqual(message.get("cc"), "cc1, cc2") + self.assertEqual(message.get("Reply-To"), "reply") + + def test_unicode_display_name_in_from_email(self): + email = EmailMessage(from_email='"Firstname Sürname" ') + parsed = message_from_bytes(email.message().as_bytes()) + self.assertEqual(parsed["From"], "Firstname Sürname ") + def test_email_multi_alternatives_content_mimetype_none(self): email_msg = EmailMultiAlternatives() msg = "Both content and mimetype must be provided." @@ -1748,11 +1840,65 @@ def test_send_fail_silently_conflict(self): email.send(fail_silently=True) -class SendMailTests(SimpleTestCase): +class SendMailTests(SimpleTestCase, MailTestsMixin): """ Tests for django.core.mail.send_mail(). """ + def test_plaintext_send_mail(self): + """ + Test send_mail without the html_message + regression test for adding html_message parameter to send_mail() + """ + send_mail("Subject", "Content\n", "sender@example.com", ["nobody@example.com"]) + message = self.get_outbox_message() + + self.assertEqual(message.get("subject"), "Subject") + self.assertEqual(message.get_all("to"), ["nobody@example.com"]) + self.assertFalse(message.is_multipart()) + self.assertEqual(message.get_content(), "Content\n") + self.assertEqual(message.get_content_type(), "text/plain") + + def test_html_send_mail(self): + """Test html_message argument to send_mail""" + send_mail( + "Subject", + "Content\n", + "sender@example.com", + ["nobody@example.com"], + html_message="HTML Content\n", + ) + message = self.get_outbox_message() + + self.assertEqual(message.get("subject"), "Subject") + self.assertEqual(message.get_all("to"), ["nobody@example.com"]) + self.assertTrue(message.is_multipart()) + self.assertEqual(len(message.get_payload()), 2) + self.assertEqual(message.get_payload(0).get_content(), "Content\n") + self.assertEqual(message.get_payload(0).get_content_type(), "text/plain") + self.assertEqual(message.get_payload(1).get_content(), "HTML Content\n") + self.assertEqual(message.get_payload(1).get_content_type(), "text/html") + + def test_idn_addresses(self): + """ + IDNA encoding is applied to non-ASCII domains in address headers + (#14301). + """ + self.assertTrue(send_mail("Subject", "Content", "from@öäü.com", ["to@öäü.com"])) + message = self.get_outbox_message() + self.assertEqual(message.get("from"), "from@xn--4ca9at.com") + self.assertEqual(message.get("to"), "to@xn--4ca9at.com") + + def test_lazy_addresses(self): + """ + Email sending should support lazy email addresses (#24416). + """ + _ = gettext_lazy + self.assertTrue(send_mail("Subject", "Content", _("tester"), [_("django")])) + message = self.get_outbox_message() + self.assertEqual(message.get("from"), "tester") + self.assertEqual(message.get("to"), "django") + def test_connection_arg(self): # Send using non-default connection. connection = mail.get_connection("mail.custombackend.EmailBackend") @@ -1849,11 +1995,152 @@ def test_send_auth_conflict(self): ) -class MailAdminsAndManagersTests(SimpleTestCase): +class MailAdminsAndManagersTests(SimpleTestCase, MailTestsMixin): """ Tests for django.core.mail.mail_admins() and mail_managers(). """ + def test_mail_admins_and_managers(self): + tests = ( + # The ADMINS and MANAGERS settings are lists of email strings. + ['"Name, Full" '], + # Lists and tuples are interchangeable. + ["test@example.com", "other@example.com"], + ("test@example.com", "other@example.com"), + # Lazy strings are supported. + [gettext_lazy("test@example.com")], + ) + for setting, mail_func in ( + ("ADMINS", mail_admins), + ("MANAGERS", mail_managers), + ): + for value in tests: + mail.outbox = [] + with ( + self.subTest(setting=setting, value=value), + self.settings(**{setting: value}), + ): + mail_func("subject", "content") + message = self.get_outbox_message() + expected_to = ", ".join([str(address) for address in value]) + self.assertEqual(message.get_all("to"), [expected_to]) + + @override_settings(MANAGERS=["nobody@example.com"]) + def test_html_mail_managers(self): + """Test html_message argument to mail_managers""" + mail_managers("Subject", "Content\n", html_message="HTML Content\n") + message = self.get_outbox_message() + + self.assertEqual(message.get("subject"), "[Django] Subject") + self.assertEqual(message.get_all("to"), ["nobody@example.com"]) + self.assertTrue(message.is_multipart()) + self.assertEqual(len(message.get_payload()), 2) + self.assertEqual(message.get_payload(0).get_content(), "Content\n") + self.assertEqual(message.get_payload(0).get_content_type(), "text/plain") + self.assertEqual(message.get_payload(1).get_content(), "HTML Content\n") + self.assertEqual(message.get_payload(1).get_content_type(), "text/html") + + @override_settings(ADMINS=["nobody@example.com"]) + def test_html_mail_admins(self): + """Test html_message argument to mail_admins""" + mail_admins("Subject", "Content\n", html_message="HTML Content\n") + message = self.get_outbox_message() + + self.assertEqual(message.get("subject"), "[Django] Subject") + self.assertEqual(message.get_all("to"), ["nobody@example.com"]) + self.assertTrue(message.is_multipart()) + self.assertEqual(len(message.get_payload()), 2) + self.assertEqual(message.get_payload(0).get_content(), "Content\n") + self.assertEqual(message.get_payload(0).get_content_type(), "text/plain") + self.assertEqual(message.get_payload(1).get_content(), "HTML Content\n") + self.assertEqual(message.get_payload(1).get_content_type(), "text/html") + + @override_settings( + ADMINS=["nobody+admin@example.com"], + MANAGERS=["nobody+manager@example.com"], + ) + def test_manager_and_admin_mail_prefix(self): + """ + String prefix + lazy translated subject = bad output + Regression for #13494 + """ + for mail_func in [mail_managers, mail_admins]: + mail.outbox = [] + with self.subTest(mail_func=mail_func): + mail_func(gettext_lazy("Subject"), "Content") + message = self.get_outbox_message() + self.assertEqual(message.get("subject"), "[Django] Subject") + + @override_settings(ADMINS=[], MANAGERS=[]) + def test_empty_admins(self): + """ + mail_admins/mail_managers doesn't connect to the mail server + if there are no recipients (#9383) + """ + for mail_func in [mail_managers, mail_admins]: + mail.outbox = [] + with self.subTest(mail_func=mail_func): + mail_func("hi", "there") + self.assertEqual(mail.outbox, []) + + # RemovedInDjango70Warning. + def test_deprecated_admins_managers_tuples(self): + tests = ( + [("nobody", "nobody@example.com"), ("other", "other@example.com")], + [["nobody", "nobody@example.com"], ["other", "other@example.com"]], + ) + for setting, mail_func in ( + ("ADMINS", mail_admins), + ("MANAGERS", mail_managers), + ): + msg = ( + f"Using (name, address) pairs in the {setting} setting is deprecated." + " Replace with a list of email address strings." + ) + for value in tests: + mail.outbox = [] + with ( + self.subTest(setting=setting, value=value), + self.settings(**{setting: value}), + ): + with self.assertWarnsMessage(RemovedInDjango70Warning, msg): + mail_func("subject", "content") + message = self.get_outbox_message() + expected_to = ", ".join([str(address) for _, address in value]) + self.assertEqual(message.get_all("to"), [expected_to]) + + def test_wrong_admins_managers(self): + tests = ( + "test@example.com", + gettext_lazy("test@example.com"), + # RemovedInDjango70Warning: uncomment these cases when support for + # deprecated (name, address) tuples is removed. + # [ + # ("nobody", "nobody@example.com"), + # ("other", "other@example.com") + # ], + # [ + # ["nobody", "nobody@example.com"], + # ["other", "other@example.com"] + # ], + [("name", "test", "example.com")], + [("Name 998 octets (forcing a CTE - # that avoids inserting newlines). - self.assertLess(len(body), 998) - self.assertGreater(len(body.encode()), 998) - - email = EmailMessage(body=body, to=["to@example.com"]) - email.send() - message = self.get_the_message() - self.assertMessageHasHeaders( - message, - { - ("MIME-Version", "1.0"), - ("Content-Type", 'text/plain; charset="utf-8"'), - ("Content-Transfer-Encoding", expected_cte), - }, - ) - def test_send_many(self): email1 = EmailMessage(to=["to-1@example.com"]) email2 = EmailMessage(to=["to-2@example.com"]) @@ -2385,269 +2639,6 @@ def test_send_many(self): self.assertEqual(messages[1]["To"], "to-2@example.com") self.flush_mailbox() - def test_send_verbose_name(self): - email = EmailMessage( - from_email='"Firstname Sürname" ', - to=["to@example.com"], - ) - email.send() - message = self.get_the_message() - self.assertEqual(message["from"], "Firstname Sürname ") - - def test_plaintext_send_mail(self): - """ - Test send_mail without the html_message - regression test for adding html_message parameter to send_mail() - """ - send_mail("Subject", "Content\n", "sender@example.com", ["nobody@example.com"]) - message = self.get_the_message() - - self.assertEqual(message.get("subject"), "Subject") - self.assertEqual(message.get_all("to"), ["nobody@example.com"]) - self.assertFalse(message.is_multipart()) - self.assertEqual(message.get_content(), "Content\n") - self.assertEqual(message.get_content_type(), "text/plain") - - def test_html_send_mail(self): - """Test html_message argument to send_mail""" - send_mail( - "Subject", - "Content\n", - "sender@example.com", - ["nobody@example.com"], - html_message="HTML Content\n", - ) - message = self.get_the_message() - - self.assertEqual(message.get("subject"), "Subject") - self.assertEqual(message.get_all("to"), ["nobody@example.com"]) - self.assertTrue(message.is_multipart()) - self.assertEqual(len(message.get_payload()), 2) - self.assertEqual(message.get_payload(0).get_content(), "Content\n") - self.assertEqual(message.get_payload(0).get_content_type(), "text/plain") - self.assertEqual(message.get_payload(1).get_content(), "HTML Content\n") - self.assertEqual(message.get_payload(1).get_content_type(), "text/html") - - def test_mail_admins_and_managers(self): - tests = ( - # The ADMINS and MANAGERS settings are lists of email strings. - ['"Name, Full" '], - # Lists and tuples are interchangeable. - ["test@example.com", "other@example.com"], - ("test@example.com", "other@example.com"), - # Lazy strings are supported. - [gettext_lazy("test@example.com")], - ) - for setting, mail_func in ( - ("ADMINS", mail_admins), - ("MANAGERS", mail_managers), - ): - for value in tests: - self.flush_mailbox() - with ( - self.subTest(setting=setting, value=value), - self.settings(**{setting: value}), - ): - mail_func("subject", "content") - message = self.get_the_message() - expected_to = ", ".join([str(address) for address in value]) - self.assertEqual(message.get_all("to"), [expected_to]) - - @override_settings(MANAGERS=["nobody@example.com"]) - def test_html_mail_managers(self): - """Test html_message argument to mail_managers""" - mail_managers("Subject", "Content\n", html_message="HTML Content\n") - message = self.get_the_message() - - self.assertEqual(message.get("subject"), "[Django] Subject") - self.assertEqual(message.get_all("to"), ["nobody@example.com"]) - self.assertTrue(message.is_multipart()) - self.assertEqual(len(message.get_payload()), 2) - self.assertEqual(message.get_payload(0).get_content(), "Content\n") - self.assertEqual(message.get_payload(0).get_content_type(), "text/plain") - self.assertEqual(message.get_payload(1).get_content(), "HTML Content\n") - self.assertEqual(message.get_payload(1).get_content_type(), "text/html") - - @override_settings(ADMINS=["nobody@example.com"]) - def test_html_mail_admins(self): - """Test html_message argument to mail_admins""" - mail_admins("Subject", "Content\n", html_message="HTML Content\n") - message = self.get_the_message() - - self.assertEqual(message.get("subject"), "[Django] Subject") - self.assertEqual(message.get_all("to"), ["nobody@example.com"]) - self.assertTrue(message.is_multipart()) - self.assertEqual(len(message.get_payload()), 2) - self.assertEqual(message.get_payload(0).get_content(), "Content\n") - self.assertEqual(message.get_payload(0).get_content_type(), "text/plain") - self.assertEqual(message.get_payload(1).get_content(), "HTML Content\n") - self.assertEqual(message.get_payload(1).get_content_type(), "text/html") - - @override_settings( - ADMINS=["nobody+admin@example.com"], - MANAGERS=["nobody+manager@example.com"], - ) - def test_manager_and_admin_mail_prefix(self): - """ - String prefix + lazy translated subject = bad output - Regression for #13494 - """ - for mail_func in [mail_managers, mail_admins]: - with self.subTest(mail_func=mail_func): - mail_func(gettext_lazy("Subject"), "Content") - message = self.get_the_message() - self.assertEqual(message.get("subject"), "[Django] Subject") - self.flush_mailbox() - - @override_settings(ADMINS=[], MANAGERS=[]) - def test_empty_admins(self): - """ - mail_admins/mail_managers doesn't connect to the mail server - if there are no recipients (#9383) - """ - for mail_func in [mail_managers, mail_admins]: - with self.subTest(mail_func=mail_func): - mail_func("hi", "there") - self.assertEqual(self.get_mailbox_content(), []) - - # RemovedInDjango70Warning. - def test_deprecated_admins_managers_tuples(self): - tests = ( - [("nobody", "nobody@example.com"), ("other", "other@example.com")], - [["nobody", "nobody@example.com"], ["other", "other@example.com"]], - ) - for setting, mail_func in ( - ("ADMINS", mail_admins), - ("MANAGERS", mail_managers), - ): - msg = ( - f"Using (name, address) pairs in the {setting} setting is deprecated." - " Replace with a list of email address strings." - ) - for value in tests: - self.flush_mailbox() - with ( - self.subTest(setting=setting, value=value), - self.settings(**{setting: value}), - ): - with self.assertWarnsMessage(RemovedInDjango70Warning, msg): - mail_func("subject", "content") - message = self.get_the_message() - expected_to = ", ".join([str(address) for _, address in value]) - self.assertEqual(message.get_all("to"), [expected_to]) - - def test_wrong_admins_managers(self): - tests = ( - "test@example.com", - gettext_lazy("test@example.com"), - # RemovedInDjango70Warning: uncomment these cases when support for - # deprecated (name, address) tuples is removed. - # [ - # ("nobody", "nobody@example.com"), - # ("other", "other@example.com") - # ], - # [ - # ["nobody", "nobody@example.com"], - # ["other", "other@example.com"] - # ], - [("name", "test", "example.com")], - [("Name Date: Thu, 19 Mar 2026 14:10:42 -0700 Subject: [PATCH 5/5] Fixed #36953 -- Split EmailBackend tests to separate file. Moved tests for specific email backends from tests/mail/tests.py to test_backends.py to reduce file size and discourage adding non-backend-specific tests to BaseEmailBackendTests. --- tests/mail/test_backends.py | 756 ++++++++++++++++++++++++++++++++++++ tests/mail/tests.py | 745 +---------------------------------- 2 files changed, 757 insertions(+), 744 deletions(-) create mode 100644 tests/mail/test_backends.py diff --git a/tests/mail/test_backends.py b/tests/mail/test_backends.py new file mode 100644 index 000000000000..1ac627bf6ab0 --- /dev/null +++ b/tests/mail/test_backends.py @@ -0,0 +1,756 @@ +import os +import shutil +import socket +import sys +import tempfile +from email import message_from_binary_file, policy +from io import StringIO +from pathlib import Path +from smtplib import SMTP, SMTPException +from ssl import SSLError +from unittest import mock, skipUnless + +from django.core import mail +from django.core.mail import EmailMessage, send_mail +from django.core.mail.backends import dummy, locmem, smtp +from django.test import SimpleTestCase, override_settings + +from .tests import MailTestsMixin, message_from_bytes + +try: + from aiosmtpd.controller import Controller + + HAS_AIOSMTPD = True +except ImportError: + HAS_AIOSMTPD = False + + +class BaseEmailBackendTests(MailTestsMixin): + """ + Shared test cases repeated for each EmailBackend. + """ + + email_backend = None + + @classmethod + def setUpClass(cls): + cls.enterClassContext(override_settings(EMAIL_BACKEND=cls.email_backend)) + super().setUpClass() + + def get_mailbox_content(self): + raise NotImplementedError( + "subclasses of BaseEmailBackendTests must provide a get_mailbox_content() " + "method" + ) + + def flush_mailbox(self): + raise NotImplementedError( + "subclasses of BaseEmailBackendTests may require a flush_mailbox() method" + ) + + def get_the_message(self): + mailbox = self.get_mailbox_content() + self.assertEqual( + len(mailbox), + 1, + "Expected exactly one message, got %d.\n%r" + % (len(mailbox), [m.as_string() for m in mailbox]), + ) + return mailbox[0] + + def test_send(self): + email = EmailMessage( + "Subject", "Content\n", "from@example.com", ["to@example.com"] + ) + num_sent = mail.get_connection().send_messages([email]) + self.assertEqual(num_sent, 1) + message = self.get_the_message() + self.assertEqual(message["subject"], "Subject") + self.assertEqual(message.get_content(), "Content\n") + self.assertEqual(message["from"], "from@example.com") + self.assertEqual(message.get_all("to"), ["to@example.com"]) + + def test_send_unicode(self): + email = EmailMessage( + "Chère maman", + "Je t'aime très fort\n", + "from@example.com", + ["to@example.com"], + ) + num_sent = mail.get_connection().send_messages([email]) + self.assertEqual(num_sent, 1) + message = self.get_the_message() + self.assertEqual(message["subject"], "Chère maman") + self.assertEqual(message.get_content(), "Je t'aime très fort\n") + + def test_send_many(self): + email1 = EmailMessage(to=["to-1@example.com"]) + email2 = EmailMessage(to=["to-2@example.com"]) + # send_messages() may take a list or an iterator. + emails_lists = ([email1, email2], iter((email1, email2))) + for emails_list in emails_lists: + with self.subTest(emails_list=repr(emails_list)): + num_sent = mail.get_connection().send_messages(emails_list) + self.assertEqual(num_sent, 2) + messages = self.get_mailbox_content() + self.assertEqual(len(messages), 2) + self.assertEqual(messages[0]["To"], "to-1@example.com") + self.assertEqual(messages[1]["To"], "to-2@example.com") + self.flush_mailbox() + + def test_close_connection(self): + """ + Connection can be closed (even when not explicitly opened) + """ + conn = mail.get_connection(username="", password="") + conn.close() + + def test_use_as_contextmanager(self): + """ + The connection can be used as a contextmanager. + """ + opened = [False] + closed = [False] + conn = mail.get_connection(username="", password="") + + def open(): + opened[0] = True + + conn.open = open + + def close(): + closed[0] = True + + conn.close = close + with conn as same_conn: + self.assertTrue(opened[0]) + self.assertIs(same_conn, conn) + self.assertFalse(closed[0]) + self.assertTrue(closed[0]) + + +class DummyBackendTests(BaseEmailBackendTests, SimpleTestCase): + email_backend = "django.core.mail.backends.dummy.EmailBackend" + + def get_mailbox_content(self): + # Shared tests that examine the content of sent messages are not + # meaningful: the dummy backend immediately discards sent messages, + # so it's not possible to retrieve them. + self.skipTest("Dummy backend discards sent messages") + + def flush_mailbox(self): + pass + + def test_send_messages_returns_sent_count(self): + connection = dummy.EmailBackend() + email = EmailMessage(to=["to@example.com"]) + self.assertEqual(connection.send_messages([email, email, email]), 3) + + +class LocmemBackendTests(BaseEmailBackendTests, SimpleTestCase): + email_backend = "django.core.mail.backends.locmem.EmailBackend" + + def get_mailbox_content(self): + return [m.message() for m in mail.outbox] + + def flush_mailbox(self): + mail.outbox = [] + + def tearDown(self): + super().tearDown() + mail.outbox = [] + + def test_locmem_shared_messages(self): + """ + Make sure that the locmen backend populates the outbox. + """ + connection = locmem.EmailBackend() + connection2 = locmem.EmailBackend() + email = EmailMessage(to=["to@example.com"]) + connection.send_messages([email]) + connection2.send_messages([email]) + self.assertEqual(len(mail.outbox), 2) + + def test_validate_multiline_headers(self): + # Headers are validated when using the locmem backend (#18861). + # (See also EmailMessageTests.test_header_injection().) + with self.assertRaises(ValueError): + send_mail( + "Subject\nMultiline", "Content", "from@example.com", ["to@example.com"] + ) + + def test_outbox_not_mutated_after_send(self): + email = EmailMessage( + subject="correct subject", + to=["to@example.com"], + ) + email.send() + email.subject = "other subject" + email.to.append("other@example.com") + self.assertEqual(mail.outbox[0].subject, "correct subject") + self.assertEqual(mail.outbox[0].to, ["to@example.com"]) + + +class FileBackendTests(BaseEmailBackendTests, SimpleTestCase): + email_backend = "django.core.mail.backends.filebased.EmailBackend" + + def setUp(self): + super().setUp() + self.tmp_dir = self.mkdtemp() + self.addCleanup(shutil.rmtree, self.tmp_dir) + _settings_override = override_settings(EMAIL_FILE_PATH=self.tmp_dir) + _settings_override.enable() + self.addCleanup(_settings_override.disable) + + def mkdtemp(self): + return tempfile.mkdtemp() + + def flush_mailbox(self): + for filename in os.listdir(self.tmp_dir): + os.unlink(os.path.join(self.tmp_dir, filename)) + + def get_mailbox_content(self): + messages = [] + for filename in os.listdir(self.tmp_dir): + with open(os.path.join(self.tmp_dir, filename), "rb") as fp: + session = fp.read().split(b"\n" + (b"-" * 79) + b"\n") + messages.extend(message_from_bytes(m) for m in session if m) + return messages + + def test_file_sessions(self): + """Make sure opening a connection creates a new file""" + msg = EmailMessage( + "Subject", + "Content", + "bounce@example.com", + ["to@example.com"], + headers={"From": "from@example.com"}, + ) + connection = mail.get_connection() + connection.send_messages([msg]) + + self.assertEqual(len(os.listdir(self.tmp_dir)), 1) + with open(os.path.join(self.tmp_dir, os.listdir(self.tmp_dir)[0]), "rb") as fp: + message = message_from_binary_file(fp, policy=policy.default) + self.assertEqual(message.get_content_type(), "text/plain") + self.assertEqual(message.get("subject"), "Subject") + self.assertEqual(message.get("from"), "from@example.com") + self.assertEqual(message.get("to"), "to@example.com") + + connection2 = mail.get_connection() + connection2.send_messages([msg]) + self.assertEqual(len(os.listdir(self.tmp_dir)), 2) + + connection.send_messages([msg]) + self.assertEqual(len(os.listdir(self.tmp_dir)), 2) + + msg.connection = mail.get_connection() + self.assertTrue(connection.open()) + msg.send() + self.assertEqual(len(os.listdir(self.tmp_dir)), 3) + msg.send() + self.assertEqual(len(os.listdir(self.tmp_dir)), 3) + + connection.close() + + +class FileBackendPathLibTests(FileBackendTests): + """ + Repeat FileBackendTests cases using a Path object as file_path. + """ + + def mkdtemp(self): + tmp_dir = super().mkdtemp() + return Path(tmp_dir) + + +class ConsoleBackendTests(BaseEmailBackendTests, SimpleTestCase): + email_backend = "django.core.mail.backends.console.EmailBackend" + + def setUp(self): + super().setUp() + self.__stdout = sys.stdout + self.stream = sys.stdout = StringIO() + + def tearDown(self): + del self.stream + sys.stdout = self.__stdout + del self.__stdout + super().tearDown() + + def flush_mailbox(self): + self.stream = sys.stdout = StringIO() + + def get_mailbox_content(self): + messages = self.stream.getvalue().split("\n" + ("-" * 79) + "\n") + return [message_from_bytes(m.encode()) for m in messages if m] + + def test_console_stream_kwarg(self): + """ + The console backend can be pointed at an arbitrary stream. + """ + s = StringIO() + connection = mail.get_connection( + "django.core.mail.backends.console.EmailBackend", stream=s + ) + send_mail( + "Subject", + "Content", + "from@example.com", + ["to@example.com"], + connection=connection, + ) + message = s.getvalue().split("\n" + ("-" * 79) + "\n")[0].encode() + self.assertMessageHasHeaders( + message, + { + ("MIME-Version", "1.0"), + ("Content-Type", 'text/plain; charset="utf-8"'), + ("Content-Transfer-Encoding", "7bit"), + ("Subject", "Subject"), + ("From", "from@example.com"), + ("To", "to@example.com"), + }, + ) + self.assertIn(b"\nDate: ", message) + + +class SMTPHandler: + def __init__(self, *args, **kwargs): + self.mailbox = [] + self.smtp_envelopes = [] + + async def handle_DATA(self, server, session, envelope): + data = envelope.content + mail_from = envelope.mail_from + + # Convert SMTP's CRNL to NL, to simplify content checks in shared test + # cases. + message = message_from_bytes(data.replace(b"\r\n", b"\n")) + try: + header_from = message["from"].addresses[0].addr_spec + except (KeyError, IndexError): + header_from = None + + if mail_from != header_from: + return f"553 '{mail_from}' != '{header_from}'" + self.mailbox.append(message) + self.smtp_envelopes.append( + { + "mail_from": envelope.mail_from, + "rcpt_tos": envelope.rcpt_tos, + } + ) + return "250 OK" + + def flush_mailbox(self): + self.mailbox[:] = [] + self.smtp_envelopes[:] = [] + + +@skipUnless(HAS_AIOSMTPD, "No aiosmtpd library detected.") +class SMTPBackendTestsBase(SimpleTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + # Find a free port. + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + port = s.getsockname()[1] + cls.smtp_handler = SMTPHandler() + cls.smtp_controller = Controller( + cls.smtp_handler, + hostname="127.0.0.1", + port=port, + ) + cls._settings_override = override_settings( + EMAIL_HOST=cls.smtp_controller.hostname, + EMAIL_PORT=cls.smtp_controller.port, + ) + cls._settings_override.enable() + cls.addClassCleanup(cls._settings_override.disable) + cls.smtp_controller.start() + cls.addClassCleanup(cls.stop_smtp) + + @classmethod + def stop_smtp(cls): + cls.smtp_controller.stop() + + +@skipUnless(HAS_AIOSMTPD, "No aiosmtpd library detected.") +class SMTPBackendTests(BaseEmailBackendTests, SMTPBackendTestsBase): + email_backend = "django.core.mail.backends.smtp.EmailBackend" + + def setUp(self): + super().setUp() + self.smtp_handler.flush_mailbox() + self.addCleanup(self.smtp_handler.flush_mailbox) + + def flush_mailbox(self): + self.smtp_handler.flush_mailbox() + + def get_mailbox_content(self): + return self.smtp_handler.mailbox + + def get_smtp_envelopes(self): + return self.smtp_handler.smtp_envelopes + + @override_settings( + EMAIL_HOST_USER="not empty username", + EMAIL_HOST_PASSWORD="not empty password", + ) + def test_email_authentication_use_settings(self): + backend = smtp.EmailBackend() + self.assertEqual(backend.username, "not empty username") + self.assertEqual(backend.password, "not empty password") + + @override_settings( + EMAIL_HOST_USER="not empty username", + EMAIL_HOST_PASSWORD="not empty password", + ) + def test_email_authentication_override_settings(self): + backend = smtp.EmailBackend(username="username", password="password") + self.assertEqual(backend.username, "username") + self.assertEqual(backend.password, "password") + + @override_settings( + EMAIL_HOST_USER="not empty username", + EMAIL_HOST_PASSWORD="not empty password", + ) + def test_email_disabled_authentication(self): + backend = smtp.EmailBackend(username="", password="") + self.assertEqual(backend.username, "") + self.assertEqual(backend.password, "") + + def test_auth_attempted(self): + """ + Opening the backend with non empty username/password tries + to authenticate against the SMTP server. + """ + backend = smtp.EmailBackend( + username="not empty username", password="not empty password" + ) + with self.assertRaisesMessage( + SMTPException, "SMTP AUTH extension not supported by server." + ): + with backend: + pass + + def test_server_open(self): + """ + open() returns whether it opened a connection. + """ + backend = smtp.EmailBackend(username="", password="") + self.assertIsNone(backend.connection) + opened = backend.open() + backend.close() + self.assertIs(opened, True) + + def test_reopen_connection(self): + backend = smtp.EmailBackend() + # Simulate an already open connection. + backend.connection = mock.Mock(spec=object()) + self.assertIs(backend.open(), False) + + @override_settings(EMAIL_USE_TLS=True) + def test_email_tls_use_settings(self): + backend = smtp.EmailBackend() + self.assertTrue(backend.use_tls) + + @override_settings(EMAIL_USE_TLS=True) + def test_email_tls_override_settings(self): + backend = smtp.EmailBackend(use_tls=False) + self.assertFalse(backend.use_tls) + + def test_email_tls_default_disabled(self): + backend = smtp.EmailBackend() + self.assertFalse(backend.use_tls) + + def test_ssl_tls_mutually_exclusive(self): + msg = ( + "EMAIL_USE_TLS/EMAIL_USE_SSL are mutually exclusive, so only set " + "one of those settings to True." + ) + with self.assertRaisesMessage(ValueError, msg): + smtp.EmailBackend(use_ssl=True, use_tls=True) + + @override_settings(EMAIL_USE_SSL=True) + def test_email_ssl_use_settings(self): + backend = smtp.EmailBackend() + self.assertTrue(backend.use_ssl) + + @override_settings(EMAIL_USE_SSL=True) + def test_email_ssl_override_settings(self): + backend = smtp.EmailBackend(use_ssl=False) + self.assertFalse(backend.use_ssl) + + def test_email_ssl_default_disabled(self): + backend = smtp.EmailBackend() + self.assertFalse(backend.use_ssl) + + @override_settings(EMAIL_SSL_CERTFILE="foo") + def test_email_ssl_certfile_use_settings(self): + backend = smtp.EmailBackend() + self.assertEqual(backend.ssl_certfile, "foo") + + @override_settings(EMAIL_SSL_CERTFILE="foo") + def test_email_ssl_certfile_override_settings(self): + backend = smtp.EmailBackend(ssl_certfile="bar") + self.assertEqual(backend.ssl_certfile, "bar") + + def test_email_ssl_certfile_default_disabled(self): + backend = smtp.EmailBackend() + self.assertIsNone(backend.ssl_certfile) + + @override_settings(EMAIL_SSL_KEYFILE="foo") + def test_email_ssl_keyfile_use_settings(self): + backend = smtp.EmailBackend() + self.assertEqual(backend.ssl_keyfile, "foo") + + @override_settings(EMAIL_SSL_KEYFILE="foo") + def test_email_ssl_keyfile_override_settings(self): + backend = smtp.EmailBackend(ssl_keyfile="bar") + self.assertEqual(backend.ssl_keyfile, "bar") + + def test_email_ssl_keyfile_default_disabled(self): + backend = smtp.EmailBackend() + self.assertIsNone(backend.ssl_keyfile) + + @override_settings(EMAIL_USE_TLS=True) + def test_email_tls_attempts_starttls(self): + backend = smtp.EmailBackend() + self.assertTrue(backend.use_tls) + with self.assertRaisesMessage( + SMTPException, "STARTTLS extension not supported by server." + ): + with backend: + pass + + @override_settings(EMAIL_USE_SSL=True) + def test_email_ssl_attempts_ssl_connection(self): + backend = smtp.EmailBackend() + self.assertTrue(backend.use_ssl) + with self.assertRaises(SSLError): + with backend: + pass + + def test_connection_timeout_default(self): + """The connection's timeout value is None by default.""" + connection = mail.get_connection("django.core.mail.backends.smtp.EmailBackend") + self.assertIsNone(connection.timeout) + + def test_connection_timeout_custom(self): + """The timeout parameter can be customized.""" + + class MyEmailBackend(smtp.EmailBackend): + def __init__(self, *args, **kwargs): + kwargs.setdefault("timeout", 42) + super().__init__(*args, **kwargs) + + myemailbackend = MyEmailBackend() + myemailbackend.open() + self.assertEqual(myemailbackend.timeout, 42) + self.assertEqual(myemailbackend.connection.timeout, 42) + myemailbackend.close() + + @override_settings(EMAIL_TIMEOUT=10) + def test_email_timeout_override_settings(self): + backend = smtp.EmailBackend() + self.assertEqual(backend.timeout, 10) + + def test_email_msg_uses_crlf(self): + """#23063 -- RFC-compliant messages are sent over SMTP.""" + send = SMTP.send + try: + smtp_messages = [] + + def mock_send(self, s): + smtp_messages.append(s) + return send(self, s) + + SMTP.send = mock_send + + email = EmailMessage( + "Subject", "Content", "from@example.com", ["to@example.com"] + ) + mail.get_connection().send_messages([email]) + + # Find the actual message + msg = None + for i, m in enumerate(smtp_messages): + if m[:4] == "data": + msg = smtp_messages[i + 1] + break + + self.assertTrue(msg) + + msg = msg.decode() + # The message only contains CRLF and not combinations of CRLF, LF, + # and CR. + msg = msg.replace("\r\n", "") + self.assertNotIn("\r", msg) + self.assertNotIn("\n", msg) + + finally: + SMTP.send = send + + def test_send_messages_after_open_failed(self): + """ + send_messages() shouldn't try to send messages if open() raises an + exception after initializing the connection. + """ + backend = smtp.EmailBackend() + # Simulate connection initialization success and a subsequent + # connection exception. + backend.connection = mock.Mock(spec=object()) + backend.open = lambda: None + email = EmailMessage(to=["to@example.com"]) + self.assertEqual(backend.send_messages([email]), 0) + + def test_send_messages_empty_list(self): + backend = smtp.EmailBackend() + backend.connection = mock.Mock(spec=object()) + self.assertEqual(backend.send_messages([]), 0) + + def test_send_messages_zero_sent(self): + """A message isn't sent if it doesn't have any recipients.""" + backend = smtp.EmailBackend() + backend.connection = mock.Mock(spec=object()) + email = EmailMessage("Subject", "Content", "from@example.com", to=[]) + sent = backend.send_messages([email]) + self.assertEqual(sent, 0) + + def test_avoids_sending_to_invalid_addresses(self): + """ + Verify invalid addresses can't sneak into SMTP commands through + EmailMessage.all_recipients() (which is distinct from message header + fields). + """ + backend = smtp.EmailBackend() + backend.connection = mock.Mock() + for email_address in ( + # Invalid address with two @ signs. + "to@other.com@example.com", + # Invalid address without the quotes. + "to@other.com ", + # Multiple mailboxes in a single address. + "to@example.com, other@example.com", + # Other invalid addresses. + "@", + "to@", + "@example.com", + # CR/NL in addr-spec. (SMTP strips display-name.) + '"evil@example.com\r\nto"@example.com', + "to\nevil@example.com", + ): + with self.subTest(email_address=email_address): + # Use bcc (which is only processed by SMTP backend) to ensure + # error is coming from SMTP backend, not + # EmailMessage.message(). + email = EmailMessage(bcc=[email_address]) + with self.assertRaisesMessage(ValueError, "Invalid address"): + backend.send_messages([email]) + + def test_encodes_idna_in_smtp_commands(self): + """ + SMTP backend must encode non-ASCII domains for the SMTP envelope + (which can be distinct from the email headers). + """ + email = EmailMessage( + from_email="lists@discussão.example.org", + to=["To Example "], + bcc=["monitor@discussão.example.org"], + headers={ + "From": "Gestor de listas ", + "To": "Discussão Django ", + }, + ) + backend = smtp.EmailBackend() + backend.send_messages([email]) + envelope = self.get_smtp_envelopes()[0] + self.assertEqual(envelope["mail_from"], "lists@xn--discusso-xza.example.org") + self.assertEqual( + envelope["rcpt_tos"], + ["to@xn--p8s937b.example.com", "monitor@xn--discusso-xza.example.org"], + ) + + def test_does_not_reencode_idna(self): + """ + SMTP backend should not downgrade IDNA 2008 to IDNA 2003. + + Django does not currently handle IDNA 2008 encoding, but should retain + it for addresses that have been pre-encoded. + """ + # Test all four EmailMessage attrs accessed by the SMTP email backend. + # These are IDNA 2008 encoded domains that would be different + # in IDNA 2003, from https://www.unicode.org/reports/tr46/#Deviations. + email = EmailMessage( + from_email='"βόλος" ', + to=['"faß" '], + cc=['"ශ්‍රී" '], + bcc=['"نامه‌ای." '], + ) + backend = smtp.EmailBackend() + backend.send_messages([email]) + envelope = self.get_smtp_envelopes()[0] + self.assertEqual(envelope["mail_from"], "from@xn--fa-hia.example.com") + self.assertEqual( + envelope["rcpt_tos"], + [ + "to@xn--10cl1a0b660p.example.com", + "cc@xn--nxasmm1c.example.com", + "bcc@xn--mgba3gch31f060k.example.com", + ], + ) + + def test_rejects_non_ascii_local_part(self): + """ + The SMTP EmailBackend does not currently support non-ASCII local-parts. + (That would require using the RFC 6532 SMTPUTF8 extension.) #35713. + """ + backend = smtp.EmailBackend() + backend.connection = mock.Mock(spec=object()) + email = EmailMessage(to=["nø@example.dk"]) + with self.assertRaisesMessage( + ValueError, + "Invalid address 'nø@example.dk': local-part contains non-ASCII characters", + ): + backend.send_messages([email]) + + def test_prep_address_without_force_ascii(self): + # A subclass implementing SMTPUTF8 could use + # prep_address(force_ascii=False). + backend = smtp.EmailBackend() + for case in ["åh@example.dk", "oh@åh.example.dk", "åh@åh.example.dk"]: + with self.subTest(case=case): + self.assertEqual(backend.prep_address(case, force_ascii=False), case) + + +@skipUnless(HAS_AIOSMTPD, "No aiosmtpd library detected.") +class SMTPBackendStoppedServerTests(SMTPBackendTestsBase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.backend = smtp.EmailBackend(username="", password="") + cls.smtp_controller.stop() + + @classmethod + def stop_smtp(cls): + # SMTP controller is stopped in setUpClass(). + pass + + def test_server_stopped(self): + """ + Closing the backend while the SMTP server is stopped doesn't raise an + exception. + """ + self.backend.close() + + def test_fail_silently_on_connection_error(self): + """ + A socket connection error is silenced with fail_silently=True. + """ + with self.assertRaises(ConnectionError): + self.backend.open() + self.backend.fail_silently = True + self.backend.open() diff --git a/tests/mail/tests.py b/tests/mail/tests.py index e756e49d6eee..902b1868ce27 100644 --- a/tests/mail/tests.py +++ b/tests/mail/tests.py @@ -1,14 +1,9 @@ import ast import mimetypes -import os import pickle import re -import shutil -import socket -import sys import tempfile from datetime import datetime, timezone -from email import message_from_binary_file from email import message_from_bytes as _message_from_bytes from email import policy from email.headerregistry import Address @@ -17,12 +12,9 @@ from email.message import MIMEPart from email.mime.image import MIMEImage from email.mime.text import MIMEText -from io import StringIO from pathlib import Path -from smtplib import SMTP, SMTPException -from ssl import SSLError from textwrap import dedent -from unittest import mock, skipUnless +from unittest import mock from django.core import mail from django.core.exceptions import ImproperlyConfigured @@ -43,14 +35,6 @@ from django.utils.deprecation import RemovedInDjango70Warning from django.utils.translation import gettext_lazy -try: - from aiosmtpd.controller import Controller - - HAS_AIOSMTPD = True -except ImportError: - HAS_AIOSMTPD = False - - # Check whether python/cpython#128110 has been fixed by seeing if space between # encoded-words is ignored (as required by RFC 2047 section 6.2). NEEDS_CPYTHON_128110_WORKAROUND = ( @@ -2570,733 +2554,6 @@ def test_8bit_non_latin(self): self.assertIn("Content-Transfer-Encoding: base64", txt.as_string()) -class BaseEmailBackendTests(MailTestsMixin): - email_backend = None - - @classmethod - def setUpClass(cls): - cls.enterClassContext(override_settings(EMAIL_BACKEND=cls.email_backend)) - super().setUpClass() - - def get_mailbox_content(self): - raise NotImplementedError( - "subclasses of BaseEmailBackendTests must provide a get_mailbox_content() " - "method" - ) - - def flush_mailbox(self): - raise NotImplementedError( - "subclasses of BaseEmailBackendTests may require a flush_mailbox() method" - ) - - def get_the_message(self): - mailbox = self.get_mailbox_content() - self.assertEqual( - len(mailbox), - 1, - "Expected exactly one message, got %d.\n%r" - % (len(mailbox), [m.as_string() for m in mailbox]), - ) - return mailbox[0] - - def test_send(self): - email = EmailMessage( - "Subject", "Content\n", "from@example.com", ["to@example.com"] - ) - num_sent = mail.get_connection().send_messages([email]) - self.assertEqual(num_sent, 1) - message = self.get_the_message() - self.assertEqual(message["subject"], "Subject") - self.assertEqual(message.get_content(), "Content\n") - self.assertEqual(message["from"], "from@example.com") - self.assertEqual(message.get_all("to"), ["to@example.com"]) - - def test_send_unicode(self): - email = EmailMessage( - "Chère maman", - "Je t'aime très fort\n", - "from@example.com", - ["to@example.com"], - ) - num_sent = mail.get_connection().send_messages([email]) - self.assertEqual(num_sent, 1) - message = self.get_the_message() - self.assertEqual(message["subject"], "Chère maman") - self.assertEqual(message.get_content(), "Je t'aime très fort\n") - - def test_send_many(self): - email1 = EmailMessage(to=["to-1@example.com"]) - email2 = EmailMessage(to=["to-2@example.com"]) - # send_messages() may take a list or an iterator. - emails_lists = ([email1, email2], iter((email1, email2))) - for emails_list in emails_lists: - with self.subTest(emails_list=repr(emails_list)): - num_sent = mail.get_connection().send_messages(emails_list) - self.assertEqual(num_sent, 2) - messages = self.get_mailbox_content() - self.assertEqual(len(messages), 2) - self.assertEqual(messages[0]["To"], "to-1@example.com") - self.assertEqual(messages[1]["To"], "to-2@example.com") - self.flush_mailbox() - - def test_close_connection(self): - """ - Connection can be closed (even when not explicitly opened) - """ - conn = mail.get_connection(username="", password="") - conn.close() - - def test_use_as_contextmanager(self): - """ - The connection can be used as a contextmanager. - """ - opened = [False] - closed = [False] - conn = mail.get_connection(username="", password="") - - def open(): - opened[0] = True - - conn.open = open - - def close(): - closed[0] = True - - conn.close = close - with conn as same_conn: - self.assertTrue(opened[0]) - self.assertIs(same_conn, conn) - self.assertFalse(closed[0]) - self.assertTrue(closed[0]) - - -class DummyBackendTests(BaseEmailBackendTests, SimpleTestCase): - email_backend = "django.core.mail.backends.dummy.EmailBackend" - - def get_mailbox_content(self): - # Shared tests that examine the content of sent messages are not - # meaningful: the dummy backend immediately discards sent messages, - # so it's not possible to retrieve them. - self.skipTest("Dummy backend discards sent messages") - - def flush_mailbox(self): - pass - - def test_send_messages_returns_sent_count(self): - connection = dummy.EmailBackend() - email = EmailMessage(to=["to@example.com"]) - self.assertEqual(connection.send_messages([email, email, email]), 3) - - -class LocmemBackendTests(BaseEmailBackendTests, SimpleTestCase): - email_backend = "django.core.mail.backends.locmem.EmailBackend" - - def get_mailbox_content(self): - return [m.message() for m in mail.outbox] - - def flush_mailbox(self): - mail.outbox = [] - - def tearDown(self): - super().tearDown() - mail.outbox = [] - - def test_locmem_shared_messages(self): - """ - Make sure that the locmen backend populates the outbox. - """ - connection = locmem.EmailBackend() - connection2 = locmem.EmailBackend() - email = EmailMessage(to=["to@example.com"]) - connection.send_messages([email]) - connection2.send_messages([email]) - self.assertEqual(len(mail.outbox), 2) - - def test_validate_multiline_headers(self): - # Headers are validated when using the locmem backend (#18861). - # (See also EmailMessageTests.test_header_injection().) - with self.assertRaises(ValueError): - send_mail( - "Subject\nMultiline", "Content", "from@example.com", ["to@example.com"] - ) - - def test_outbox_not_mutated_after_send(self): - email = EmailMessage( - subject="correct subject", - to=["to@example.com"], - ) - email.send() - email.subject = "other subject" - email.to.append("other@example.com") - self.assertEqual(mail.outbox[0].subject, "correct subject") - self.assertEqual(mail.outbox[0].to, ["to@example.com"]) - - -class FileBackendTests(BaseEmailBackendTests, SimpleTestCase): - email_backend = "django.core.mail.backends.filebased.EmailBackend" - - def setUp(self): - super().setUp() - self.tmp_dir = self.mkdtemp() - self.addCleanup(shutil.rmtree, self.tmp_dir) - _settings_override = override_settings(EMAIL_FILE_PATH=self.tmp_dir) - _settings_override.enable() - self.addCleanup(_settings_override.disable) - - def mkdtemp(self): - return tempfile.mkdtemp() - - def flush_mailbox(self): - for filename in os.listdir(self.tmp_dir): - os.unlink(os.path.join(self.tmp_dir, filename)) - - def get_mailbox_content(self): - messages = [] - for filename in os.listdir(self.tmp_dir): - with open(os.path.join(self.tmp_dir, filename), "rb") as fp: - session = fp.read().split(b"\n" + (b"-" * 79) + b"\n") - messages.extend(message_from_bytes(m) for m in session if m) - return messages - - def test_file_sessions(self): - """Make sure opening a connection creates a new file""" - msg = EmailMessage( - "Subject", - "Content", - "bounce@example.com", - ["to@example.com"], - headers={"From": "from@example.com"}, - ) - connection = mail.get_connection() - connection.send_messages([msg]) - - self.assertEqual(len(os.listdir(self.tmp_dir)), 1) - with open(os.path.join(self.tmp_dir, os.listdir(self.tmp_dir)[0]), "rb") as fp: - message = message_from_binary_file(fp, policy=policy.default) - self.assertEqual(message.get_content_type(), "text/plain") - self.assertEqual(message.get("subject"), "Subject") - self.assertEqual(message.get("from"), "from@example.com") - self.assertEqual(message.get("to"), "to@example.com") - - connection2 = mail.get_connection() - connection2.send_messages([msg]) - self.assertEqual(len(os.listdir(self.tmp_dir)), 2) - - connection.send_messages([msg]) - self.assertEqual(len(os.listdir(self.tmp_dir)), 2) - - msg.connection = mail.get_connection() - self.assertTrue(connection.open()) - msg.send() - self.assertEqual(len(os.listdir(self.tmp_dir)), 3) - msg.send() - self.assertEqual(len(os.listdir(self.tmp_dir)), 3) - - connection.close() - - -class FileBackendPathLibTests(FileBackendTests): - """ - Repeat FileBackendTests cases using a Path object as file_path. - """ - - def mkdtemp(self): - tmp_dir = super().mkdtemp() - return Path(tmp_dir) - - -class ConsoleBackendTests(BaseEmailBackendTests, SimpleTestCase): - email_backend = "django.core.mail.backends.console.EmailBackend" - - def setUp(self): - super().setUp() - self.__stdout = sys.stdout - self.stream = sys.stdout = StringIO() - - def tearDown(self): - del self.stream - sys.stdout = self.__stdout - del self.__stdout - super().tearDown() - - def flush_mailbox(self): - self.stream = sys.stdout = StringIO() - - def get_mailbox_content(self): - messages = self.stream.getvalue().split("\n" + ("-" * 79) + "\n") - return [message_from_bytes(m.encode()) for m in messages if m] - - def test_console_stream_kwarg(self): - """ - The console backend can be pointed at an arbitrary stream. - """ - s = StringIO() - connection = mail.get_connection( - "django.core.mail.backends.console.EmailBackend", stream=s - ) - send_mail( - "Subject", - "Content", - "from@example.com", - ["to@example.com"], - connection=connection, - ) - message = s.getvalue().split("\n" + ("-" * 79) + "\n")[0].encode() - self.assertMessageHasHeaders( - message, - { - ("MIME-Version", "1.0"), - ("Content-Type", 'text/plain; charset="utf-8"'), - ("Content-Transfer-Encoding", "7bit"), - ("Subject", "Subject"), - ("From", "from@example.com"), - ("To", "to@example.com"), - }, - ) - self.assertIn(b"\nDate: ", message) - - -class SMTPHandler: - def __init__(self, *args, **kwargs): - self.mailbox = [] - self.smtp_envelopes = [] - - async def handle_DATA(self, server, session, envelope): - data = envelope.content - mail_from = envelope.mail_from - - # Convert SMTP's CRNL to NL, to simplify content checks in shared test - # cases. - message = message_from_bytes(data.replace(b"\r\n", b"\n")) - try: - header_from = message["from"].addresses[0].addr_spec - except (KeyError, IndexError): - header_from = None - - if mail_from != header_from: - return f"553 '{mail_from}' != '{header_from}'" - self.mailbox.append(message) - self.smtp_envelopes.append( - { - "mail_from": envelope.mail_from, - "rcpt_tos": envelope.rcpt_tos, - } - ) - return "250 OK" - - def flush_mailbox(self): - self.mailbox[:] = [] - self.smtp_envelopes[:] = [] - - -@skipUnless(HAS_AIOSMTPD, "No aiosmtpd library detected.") -class SMTPBackendTestsBase(SimpleTestCase): - @classmethod - def setUpClass(cls): - super().setUpClass() - # Find a free port. - with socket.socket() as s: - s.bind(("127.0.0.1", 0)) - port = s.getsockname()[1] - cls.smtp_handler = SMTPHandler() - cls.smtp_controller = Controller( - cls.smtp_handler, - hostname="127.0.0.1", - port=port, - ) - cls._settings_override = override_settings( - EMAIL_HOST=cls.smtp_controller.hostname, - EMAIL_PORT=cls.smtp_controller.port, - ) - cls._settings_override.enable() - cls.addClassCleanup(cls._settings_override.disable) - cls.smtp_controller.start() - cls.addClassCleanup(cls.stop_smtp) - - @classmethod - def stop_smtp(cls): - cls.smtp_controller.stop() - - -@skipUnless(HAS_AIOSMTPD, "No aiosmtpd library detected.") -class SMTPBackendTests(BaseEmailBackendTests, SMTPBackendTestsBase): - email_backend = "django.core.mail.backends.smtp.EmailBackend" - - def setUp(self): - super().setUp() - self.smtp_handler.flush_mailbox() - self.addCleanup(self.smtp_handler.flush_mailbox) - - def flush_mailbox(self): - self.smtp_handler.flush_mailbox() - - def get_mailbox_content(self): - return self.smtp_handler.mailbox - - def get_smtp_envelopes(self): - return self.smtp_handler.smtp_envelopes - - @override_settings( - EMAIL_HOST_USER="not empty username", - EMAIL_HOST_PASSWORD="not empty password", - ) - def test_email_authentication_use_settings(self): - backend = smtp.EmailBackend() - self.assertEqual(backend.username, "not empty username") - self.assertEqual(backend.password, "not empty password") - - @override_settings( - EMAIL_HOST_USER="not empty username", - EMAIL_HOST_PASSWORD="not empty password", - ) - def test_email_authentication_override_settings(self): - backend = smtp.EmailBackend(username="username", password="password") - self.assertEqual(backend.username, "username") - self.assertEqual(backend.password, "password") - - @override_settings( - EMAIL_HOST_USER="not empty username", - EMAIL_HOST_PASSWORD="not empty password", - ) - def test_email_disabled_authentication(self): - backend = smtp.EmailBackend(username="", password="") - self.assertEqual(backend.username, "") - self.assertEqual(backend.password, "") - - def test_auth_attempted(self): - """ - Opening the backend with non empty username/password tries - to authenticate against the SMTP server. - """ - backend = smtp.EmailBackend( - username="not empty username", password="not empty password" - ) - with self.assertRaisesMessage( - SMTPException, "SMTP AUTH extension not supported by server." - ): - with backend: - pass - - def test_server_open(self): - """ - open() returns whether it opened a connection. - """ - backend = smtp.EmailBackend(username="", password="") - self.assertIsNone(backend.connection) - opened = backend.open() - backend.close() - self.assertIs(opened, True) - - def test_reopen_connection(self): - backend = smtp.EmailBackend() - # Simulate an already open connection. - backend.connection = mock.Mock(spec=object()) - self.assertIs(backend.open(), False) - - @override_settings(EMAIL_USE_TLS=True) - def test_email_tls_use_settings(self): - backend = smtp.EmailBackend() - self.assertTrue(backend.use_tls) - - @override_settings(EMAIL_USE_TLS=True) - def test_email_tls_override_settings(self): - backend = smtp.EmailBackend(use_tls=False) - self.assertFalse(backend.use_tls) - - def test_email_tls_default_disabled(self): - backend = smtp.EmailBackend() - self.assertFalse(backend.use_tls) - - def test_ssl_tls_mutually_exclusive(self): - msg = ( - "EMAIL_USE_TLS/EMAIL_USE_SSL are mutually exclusive, so only set " - "one of those settings to True." - ) - with self.assertRaisesMessage(ValueError, msg): - smtp.EmailBackend(use_ssl=True, use_tls=True) - - @override_settings(EMAIL_USE_SSL=True) - def test_email_ssl_use_settings(self): - backend = smtp.EmailBackend() - self.assertTrue(backend.use_ssl) - - @override_settings(EMAIL_USE_SSL=True) - def test_email_ssl_override_settings(self): - backend = smtp.EmailBackend(use_ssl=False) - self.assertFalse(backend.use_ssl) - - def test_email_ssl_default_disabled(self): - backend = smtp.EmailBackend() - self.assertFalse(backend.use_ssl) - - @override_settings(EMAIL_SSL_CERTFILE="foo") - def test_email_ssl_certfile_use_settings(self): - backend = smtp.EmailBackend() - self.assertEqual(backend.ssl_certfile, "foo") - - @override_settings(EMAIL_SSL_CERTFILE="foo") - def test_email_ssl_certfile_override_settings(self): - backend = smtp.EmailBackend(ssl_certfile="bar") - self.assertEqual(backend.ssl_certfile, "bar") - - def test_email_ssl_certfile_default_disabled(self): - backend = smtp.EmailBackend() - self.assertIsNone(backend.ssl_certfile) - - @override_settings(EMAIL_SSL_KEYFILE="foo") - def test_email_ssl_keyfile_use_settings(self): - backend = smtp.EmailBackend() - self.assertEqual(backend.ssl_keyfile, "foo") - - @override_settings(EMAIL_SSL_KEYFILE="foo") - def test_email_ssl_keyfile_override_settings(self): - backend = smtp.EmailBackend(ssl_keyfile="bar") - self.assertEqual(backend.ssl_keyfile, "bar") - - def test_email_ssl_keyfile_default_disabled(self): - backend = smtp.EmailBackend() - self.assertIsNone(backend.ssl_keyfile) - - @override_settings(EMAIL_USE_TLS=True) - def test_email_tls_attempts_starttls(self): - backend = smtp.EmailBackend() - self.assertTrue(backend.use_tls) - with self.assertRaisesMessage( - SMTPException, "STARTTLS extension not supported by server." - ): - with backend: - pass - - @override_settings(EMAIL_USE_SSL=True) - def test_email_ssl_attempts_ssl_connection(self): - backend = smtp.EmailBackend() - self.assertTrue(backend.use_ssl) - with self.assertRaises(SSLError): - with backend: - pass - - def test_connection_timeout_default(self): - """The connection's timeout value is None by default.""" - connection = mail.get_connection("django.core.mail.backends.smtp.EmailBackend") - self.assertIsNone(connection.timeout) - - def test_connection_timeout_custom(self): - """The timeout parameter can be customized.""" - - class MyEmailBackend(smtp.EmailBackend): - def __init__(self, *args, **kwargs): - kwargs.setdefault("timeout", 42) - super().__init__(*args, **kwargs) - - myemailbackend = MyEmailBackend() - myemailbackend.open() - self.assertEqual(myemailbackend.timeout, 42) - self.assertEqual(myemailbackend.connection.timeout, 42) - myemailbackend.close() - - @override_settings(EMAIL_TIMEOUT=10) - def test_email_timeout_override_settings(self): - backend = smtp.EmailBackend() - self.assertEqual(backend.timeout, 10) - - def test_email_msg_uses_crlf(self): - """#23063 -- RFC-compliant messages are sent over SMTP.""" - send = SMTP.send - try: - smtp_messages = [] - - def mock_send(self, s): - smtp_messages.append(s) - return send(self, s) - - SMTP.send = mock_send - - email = EmailMessage( - "Subject", "Content", "from@example.com", ["to@example.com"] - ) - mail.get_connection().send_messages([email]) - - # Find the actual message - msg = None - for i, m in enumerate(smtp_messages): - if m[:4] == "data": - msg = smtp_messages[i + 1] - break - - self.assertTrue(msg) - - msg = msg.decode() - # The message only contains CRLF and not combinations of CRLF, LF, - # and CR. - msg = msg.replace("\r\n", "") - self.assertNotIn("\r", msg) - self.assertNotIn("\n", msg) - - finally: - SMTP.send = send - - def test_send_messages_after_open_failed(self): - """ - send_messages() shouldn't try to send messages if open() raises an - exception after initializing the connection. - """ - backend = smtp.EmailBackend() - # Simulate connection initialization success and a subsequent - # connection exception. - backend.connection = mock.Mock(spec=object()) - backend.open = lambda: None - email = EmailMessage(to=["to@example.com"]) - self.assertEqual(backend.send_messages([email]), 0) - - def test_send_messages_empty_list(self): - backend = smtp.EmailBackend() - backend.connection = mock.Mock(spec=object()) - self.assertEqual(backend.send_messages([]), 0) - - def test_send_messages_zero_sent(self): - """A message isn't sent if it doesn't have any recipients.""" - backend = smtp.EmailBackend() - backend.connection = mock.Mock(spec=object()) - email = EmailMessage("Subject", "Content", "from@example.com", to=[]) - sent = backend.send_messages([email]) - self.assertEqual(sent, 0) - - def test_avoids_sending_to_invalid_addresses(self): - """ - Verify invalid addresses can't sneak into SMTP commands through - EmailMessage.all_recipients() (which is distinct from message header - fields). - """ - backend = smtp.EmailBackend() - backend.connection = mock.Mock() - for email_address in ( - # Invalid address with two @ signs. - "to@other.com@example.com", - # Invalid address without the quotes. - "to@other.com ", - # Multiple mailboxes in a single address. - "to@example.com, other@example.com", - # Other invalid addresses. - "@", - "to@", - "@example.com", - # CR/NL in addr-spec. (SMTP strips display-name.) - '"evil@example.com\r\nto"@example.com', - "to\nevil@example.com", - ): - with self.subTest(email_address=email_address): - # Use bcc (which is only processed by SMTP backend) to ensure - # error is coming from SMTP backend, not - # EmailMessage.message(). - email = EmailMessage(bcc=[email_address]) - with self.assertRaisesMessage(ValueError, "Invalid address"): - backend.send_messages([email]) - - def test_encodes_idna_in_smtp_commands(self): - """ - SMTP backend must encode non-ASCII domains for the SMTP envelope - (which can be distinct from the email headers). - """ - email = EmailMessage( - from_email="lists@discussão.example.org", - to=["To Example "], - bcc=["monitor@discussão.example.org"], - headers={ - "From": "Gestor de listas ", - "To": "Discussão Django ", - }, - ) - backend = smtp.EmailBackend() - backend.send_messages([email]) - envelope = self.get_smtp_envelopes()[0] - self.assertEqual(envelope["mail_from"], "lists@xn--discusso-xza.example.org") - self.assertEqual( - envelope["rcpt_tos"], - ["to@xn--p8s937b.example.com", "monitor@xn--discusso-xza.example.org"], - ) - - def test_does_not_reencode_idna(self): - """ - SMTP backend should not downgrade IDNA 2008 to IDNA 2003. - - Django does not currently handle IDNA 2008 encoding, but should retain - it for addresses that have been pre-encoded. - """ - # Test all four EmailMessage attrs accessed by the SMTP email backend. - # These are IDNA 2008 encoded domains that would be different - # in IDNA 2003, from https://www.unicode.org/reports/tr46/#Deviations. - email = EmailMessage( - from_email='"βόλος" ', - to=['"faß" '], - cc=['"ශ්‍රී" '], - bcc=['"نامه‌ای." '], - ) - backend = smtp.EmailBackend() - backend.send_messages([email]) - envelope = self.get_smtp_envelopes()[0] - self.assertEqual(envelope["mail_from"], "from@xn--fa-hia.example.com") - self.assertEqual( - envelope["rcpt_tos"], - [ - "to@xn--10cl1a0b660p.example.com", - "cc@xn--nxasmm1c.example.com", - "bcc@xn--mgba3gch31f060k.example.com", - ], - ) - - def test_rejects_non_ascii_local_part(self): - """ - The SMTP EmailBackend does not currently support non-ASCII local-parts. - (That would require using the RFC 6532 SMTPUTF8 extension.) #35713. - """ - backend = smtp.EmailBackend() - backend.connection = mock.Mock(spec=object()) - email = EmailMessage(to=["nø@example.dk"]) - with self.assertRaisesMessage( - ValueError, - "Invalid address 'nø@example.dk': local-part contains non-ASCII characters", - ): - backend.send_messages([email]) - - def test_prep_address_without_force_ascii(self): - # A subclass implementing SMTPUTF8 could use - # prep_address(force_ascii=False). - backend = smtp.EmailBackend() - for case in ["åh@example.dk", "oh@åh.example.dk", "åh@åh.example.dk"]: - with self.subTest(case=case): - self.assertEqual(backend.prep_address(case, force_ascii=False), case) - - -@skipUnless(HAS_AIOSMTPD, "No aiosmtpd library detected.") -class SMTPBackendStoppedServerTests(SMTPBackendTestsBase): - @classmethod - def setUpClass(cls): - super().setUpClass() - cls.backend = smtp.EmailBackend(username="", password="") - cls.smtp_controller.stop() - - @classmethod - def stop_smtp(cls): - # SMTP controller is stopped in setUpClass(). - pass - - def test_server_stopped(self): - """ - Closing the backend while the SMTP server is stopped doesn't raise an - exception. - """ - self.backend.close() - - def test_fail_silently_on_connection_error(self): - """ - A socket connection error is silenced with fail_silently=True. - """ - with self.assertRaises(ConnectionError): - self.backend.open() - self.backend.fail_silently = True - self.backend.open() - - class LegacyAPINotUsedTests(SimpleTestCase): """ Check django.core.mail does not directly import Python legacy email APIs,