From c8fefebecaa58fe4258d573addefb3629bde3e4d Mon Sep 17 00:00:00 2001 From: Mike Edmunds Date: Sat, 2 May 2026 13:01:48 -0700 Subject: Refs #35514 -- Simplified mail tests. * Removed unnecessary empty username/password args and unnecessary test email content. * Replaced monkeypatching with mock.patch. Minimized scope of patches. * Added missing assertions in some tests. * Cleaned up uses of assertTrue() and assertFalse(). * Removed implicit assumption in LocmemBackendTests that mail is always sent by the locmem backend during tests. * Made FileBackendTests tmp_dir cleanup more robust, and added helpers to simplify test cases. * Split FileBackendTests.test_sessions() into three distinct cases (and removed unrelated, duplicative verification of message content). * Used mock to simplify and improve accuracy of SMTP AUTH test. * Replaced `get_connection("mail.custombackend.EmailBackend")` with direct `custombackend.EmailBackend()` construction to avoid soon-to- be-deprecated usage of `get_connection(backend_path)` in cases that aren't trying to test creating a connection from an import path. --- tests/mail/test_backends.py | 239 +++++++++++++++++++++----------------------- tests/mail/tests.py | 27 ++--- 2 files changed, 131 insertions(+), 135 deletions(-) diff --git a/tests/mail/test_backends.py b/tests/mail/test_backends.py index dec386a8f9..f330145967 100644 --- a/tests/mail/test_backends.py +++ b/tests/mail/test_backends.py @@ -3,15 +3,14 @@ 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 smtplib import 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 import EmailMessage from django.core.mail.backends import dummy, locmem, smtp from django.test import SimpleTestCase, override_settings @@ -98,28 +97,20 @@ class SharedEmailBackendTests(MailTestsMixin): self.flush_mailbox() def test_connection_can_be_closed_even_if_not_opened(self): - backend = mail.get_connection(username="", password="") + backend = mail.get_connection() backend.close() def test_connection_can_be_used_as_contextmanager(self): - opened = [False] - closed = [False] - backend = mail.get_connection(username="", password="") - - def open(): - opened[0] = True - - backend.open = open - - def close(): - closed[0] = True + backend = mail.get_connection() + backend.open = mock.Mock() + backend.close = mock.Mock() - backend.close = close with backend as backend_cm: - self.assertTrue(opened[0]) + backend.open.assert_called_once() self.assertIs(backend_cm, backend) - self.assertFalse(closed[0]) - self.assertTrue(closed[0]) + backend.close.assert_not_called() + + backend.close.assert_called_once() class DummyBackendTests(SharedEmailBackendTests, SimpleTestCase): @@ -167,17 +158,18 @@ class LocmemBackendTests(SharedEmailBackendTests, SimpleTestCase): def test_validate_multiline_headers(self): # Headers are validated when using the locmem backend (#18861). # (See also EmailMessageTests.test_header_injection().) + email = EmailMessage(subject="Subject\nMultiline", to=["to@example.com"]) + backend = locmem.EmailBackend() with self.assertRaises(ValueError): - send_mail( - "Subject\nMultiline", "Content", "from@example.com", ["to@example.com"] - ) + backend.send_messages([email]) def test_outbox_not_mutated_after_send(self): email = EmailMessage( subject="correct subject", to=["to@example.com"], ) - email.send() + backend = locmem.EmailBackend() + backend.send_messages([email]) email.subject = "other subject" email.to.append("other@example.com") self.assertEqual(mail.outbox[0].subject, "correct subject") @@ -190,61 +182,89 @@ class FileBackendTests(SharedEmailBackendTests, SimpleTestCase): 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() + tmp_dir = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, tmp_dir) + return tmp_dir + + def get_filenames(self): + return os.listdir(self.tmp_dir) + + def get_messages_from_filename(self, filename): + with open(os.path.join(self.tmp_dir, filename), "rb") as fp: + messages = fp.read().split(b"\n" + (b"-" * 79) + b"\n") + return [message_from_bytes(m) for m in messages if m] def flush_mailbox(self): - for filename in os.listdir(self.tmp_dir): + for filename in self.get_filenames(): 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) + for filename in self.get_filenames(): + messages.extend(self.get_messages_from_filename(filename)) 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"}, - ) - backend = mail.get_connection() - backend.send_messages([msg]) + def test_new_file_per_instance(self): + # Documented behavior: "A new file is created for each new session that + # is opened on this backend." + email = EmailMessage(to=["to@example.com"]) + self.assertEqual(len(self.get_filenames()), 0) - 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") + backend1 = mail.get_connection() + backend1.send_messages([email]) + self.assertEqual(len(self.get_filenames()), 1) backend2 = mail.get_connection() - backend2.send_messages([msg]) - self.assertEqual(len(os.listdir(self.tmp_dir)), 2) + backend2.send_messages([email]) + self.assertEqual(len(self.get_filenames()), 2) - backend.send_messages([msg]) - self.assertEqual(len(os.listdir(self.tmp_dir)), 2) + def test_multiple_messages_same_connection_single_file_reused(self): + self.assertEqual(len(self.get_filenames()), 0) + backend = mail.get_connection() - msg.connection = mail.get_connection() - self.assertTrue(backend.open()) - msg.send() - self.assertEqual(len(os.listdir(self.tmp_dir)), 3) - msg.send() - self.assertEqual(len(os.listdir(self.tmp_dir)), 3) + self.assertIs(backend.open(), True) + backend.send_messages([EmailMessage(to=["one@example.com"])]) + filenames = self.get_filenames() + self.assertEqual(len(filenames), 1) + + # Send a second message while connection is still open. + backend.send_messages([EmailMessage(to=["two@example.com"])]) + self.assertEqual(self.get_filenames(), filenames) backend.close() + self.assertEqual(self.get_filenames(), filenames) + + messages = self.get_messages_from_filename(filenames[0]) + self.assertEqual(len(messages), 2) + self.assertEqual(messages[0]["to"], "one@example.com") + self.assertEqual(messages[1]["to"], "two@example.com") + + def test_reopening_connection_uses_same_file(self): + self.assertEqual(len(self.get_filenames()), 0) + + backend = mail.get_connection() + self.assertIs(backend.open(), True) + backend.send_messages([EmailMessage(to=["one@example.com"])]) + backend.close() + filenames = self.get_filenames() + self.assertEqual(len(filenames), 1) + + # Reopen the connection. + self.assertIs(backend.open(), True) + backend.send_messages([EmailMessage(to=["two@example.com"])]) + self.assertEqual(self.get_filenames(), filenames) + backend.close() + self.assertEqual(self.get_filenames(), filenames) + + messages = self.get_messages_from_filename(filenames[0]) + self.assertEqual(len(messages), 2) + self.assertEqual(messages[0]["to"], "one@example.com") + self.assertEqual(messages[1]["to"], "two@example.com") class FileBackendPathLibTests(FileBackendTests): @@ -281,24 +301,11 @@ class ConsoleBackendTests(SharedEmailBackendTests, SimpleTestCase): backend = mail.get_connection( "django.core.mail.backends.console.EmailBackend", stream=s ) - send_mail( - "Subject", - "Content", - "from@example.com", - ["to@example.com"], - connection=backend, - ) + backend.send_messages([EmailMessage(to=["to@example.com"])]) 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"), - }, + {("To", "to@example.com")}, ) self.assertIn(b"\nDate: ", message) @@ -418,17 +425,19 @@ class SMTPBackendTests(SharedEmailBackendTests, SMTPBackendTestsBase): 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 + with mock.patch("smtplib.SMTP.login") as mock_smtp_login, backend: + # Using backend as context manager opens the connection and + # attempts login. + pass + mock_smtp_login.assert_called_once_with( + "not empty username", "not empty password" + ) def test_server_open(self): """ open() returns whether it opened a connection. """ - backend = smtp.EmailBackend(username="", password="") + backend = smtp.EmailBackend() self.assertIsNone(backend.connection) opened = backend.open() backend.close() @@ -443,16 +452,16 @@ class SMTPBackendTests(SharedEmailBackendTests, SMTPBackendTestsBase): @override_settings(EMAIL_USE_TLS=True) def test_email_tls_use_settings(self): backend = smtp.EmailBackend() - self.assertTrue(backend.use_tls) + self.assertIs(backend.use_tls, True) @override_settings(EMAIL_USE_TLS=True) def test_email_tls_override_settings(self): backend = smtp.EmailBackend(use_tls=False) - self.assertFalse(backend.use_tls) + self.assertIs(backend.use_tls, False) def test_email_tls_default_disabled(self): backend = smtp.EmailBackend() - self.assertFalse(backend.use_tls) + self.assertIs(backend.use_tls, False) def test_ssl_tls_mutually_exclusive(self): msg = ( @@ -465,16 +474,16 @@ class SMTPBackendTests(SharedEmailBackendTests, SMTPBackendTestsBase): @override_settings(EMAIL_USE_SSL=True) def test_email_ssl_use_settings(self): backend = smtp.EmailBackend() - self.assertTrue(backend.use_ssl) + self.assertIs(backend.use_ssl, True) @override_settings(EMAIL_USE_SSL=True) def test_email_ssl_override_settings(self): backend = smtp.EmailBackend(use_ssl=False) - self.assertFalse(backend.use_ssl) + self.assertIs(backend.use_ssl, False) def test_email_ssl_default_disabled(self): backend = smtp.EmailBackend() - self.assertFalse(backend.use_ssl) + self.assertIs(backend.use_ssl, False) @override_settings(EMAIL_SSL_CERTFILE="foo") def test_email_ssl_certfile_use_settings(self): @@ -507,7 +516,7 @@ class SMTPBackendTests(SharedEmailBackendTests, SMTPBackendTestsBase): @override_settings(EMAIL_USE_TLS=True) def test_email_tls_attempts_starttls(self): backend = smtp.EmailBackend() - self.assertTrue(backend.use_tls) + self.assertIs(backend.use_tls, True) with self.assertRaisesMessage( SMTPException, "STARTTLS extension not supported by server." ): @@ -517,7 +526,7 @@ class SMTPBackendTests(SharedEmailBackendTests, SMTPBackendTestsBase): @override_settings(EMAIL_USE_SSL=True) def test_email_ssl_attempts_ssl_connection(self): backend = smtp.EmailBackend() - self.assertTrue(backend.use_ssl) + self.assertIs(backend.use_ssl, True) with self.assertRaises(SSLError): with backend: pass @@ -546,39 +555,21 @@ class SMTPBackendTests(SharedEmailBackendTests, SMTPBackendTestsBase): self.assertEqual(backend.timeout, 10) def test_serialized_message_uses_crlf_line_ending(self): - 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 (#23063). - msg = msg.replace("\r\n", "") - self.assertNotIn("\r", msg) - self.assertNotIn("\n", msg) + backend = mail.get_connection() + with ( + backend, + mock.patch.object(backend.connection, "sendmail") as mock_sendmail, + ): + backend.send_messages([EmailMessage(to=["to@example.com"])]) - finally: - SMTP.send = send + # The third argument to SMTP.sendmail() is the serialized message. + mock_sendmail.assert_called_once() + msg = mock_sendmail.call_args.args[2].decode() + # The message only contains CRLF and not combinations of CRLF, LF, and + # CR (#23063). + msg = msg.replace("\r\n", "") + self.assertNotIn("\r", msg) + self.assertNotIn("\n", msg) def test_send_messages_after_open_failed(self): """ @@ -588,23 +579,25 @@ class SMTPBackendTests(SharedEmailBackendTests, SMTPBackendTestsBase): backend = smtp.EmailBackend() # Simulate connection initialization success and a subsequent # connection exception. - backend.connection = mock.Mock(spec=object()) + backend.connection = mock.Mock() backend.open = lambda: None email = EmailMessage(to=["to@example.com"]) self.assertEqual(backend.send_messages([email]), 0) def test_send_messages_with_empty_list_does_not_open_connection(self): backend = smtp.EmailBackend() - backend.connection = mock.Mock(spec=object()) + backend.open = mock.Mock() self.assertEqual(backend.send_messages([]), 0) + backend.open.assert_not_called() 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()) + backend.connection = mock.Mock() email = EmailMessage("Subject", "Content", "from@example.com", to=[]) sent = backend.send_messages([email]) self.assertEqual(sent, 0) + backend.connection.sendmail.assert_not_called() def test_avoids_sending_to_invalid_addresses(self): """ @@ -717,7 +710,7 @@ class SMTPBackendStoppedServerTests(SMTPBackendTestsBase): @classmethod def setUpClass(cls): super().setUpClass() - cls.backend = smtp.EmailBackend(username="", password="") + cls.backend = smtp.EmailBackend() cls.smtp_controller.stop() @classmethod diff --git a/tests/mail/tests.py b/tests/mail/tests.py index 8508d7f88f..c0e548f3de 100644 --- a/tests/mail/tests.py +++ b/tests/mail/tests.py @@ -35,6 +35,8 @@ from django.test.utils import ignore_warnings, requires_tz_support from django.utils.deprecation import RemovedInDjango70Warning from django.utils.translation import gettext_lazy +from . import custombackend + # 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 = ( @@ -800,12 +802,13 @@ class EmailMessageTests(MailTestsMixin, SimpleTestCase): # even an empty body. self.assertEqual(msg.message().get_payload(), "\n") - @mock.patch("socket.getfqdn", return_value="漢字") - def test_non_ascii_dns_non_unicode_email(self, mocked_getfqdn): + def test_non_ascii_dns_non_unicode_email(self): delattr(DNS_NAME, "_fqdn") email = EmailMessage() email.encoding = "iso-8859-1" - self.assertIn("@xn--p8s937b>", email.message()["Message-ID"]) + with mock.patch("socket.getfqdn", return_value="漢字"): + message = email.message() + self.assertIn("@xn--p8s937b>", message["Message-ID"]) def test_encoding(self): """ @@ -1683,8 +1686,8 @@ class EmailMessageTests(MailTestsMixin, SimpleTestCase): """ # This is meant to verify EmailMessage.__init__() doesn't apply any # special processing that would be missing for properties set later. - original_connection = mail.get_connection(username="original") - new_connection = mail.get_connection(username="new") + original_connection = mail.get_connection() + new_connection = mail.get_connection() email = EmailMessage( "original subject", "original body\n", @@ -1864,7 +1867,7 @@ class SendMailTests(SimpleTestCase, MailTestsMixin): IDNA encoding is applied to non-ASCII domains in address headers (#14301). """ - self.assertTrue(send_mail("Subject", "Content", "from@öäü.com", ["to@öäü.com"])) + 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") @@ -1874,14 +1877,14 @@ class SendMailTests(SimpleTestCase, MailTestsMixin): Email sending should support lazy email addresses (#24416). """ _ = gettext_lazy - self.assertTrue(send_mail("Subject", "Content", _("tester"), [_("django")])) + 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") + connection = custombackend.EmailBackend() send_mail( "Subject", "Content", @@ -1933,7 +1936,7 @@ class SendMassMailTests(SimpleTestCase): def test_connection_arg(self): # Send using non-default connection. - connection = mail.get_connection("mail.custombackend.EmailBackend") + connection = custombackend.EmailBackend() send_mass_mail( [ ("Subject1", "Content1", "from1@example.com", ["to1@example.com"]), @@ -2120,7 +2123,7 @@ class MailAdminsAndManagersTests(SimpleTestCase, MailTestsMixin): @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") + connection = custombackend.EmailBackend() mail_admins("Admin message", "Content", connection=connection) self.assertEqual(mail.outbox, []) self.assertEqual(len(connection.test_outbox), 1) @@ -2129,7 +2132,7 @@ class MailAdminsAndManagersTests(SimpleTestCase, MailTestsMixin): @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") + connection = custombackend.EmailBackend() mail_managers("Manager message", "Content", connection=connection) self.assertEqual(mail.outbox, []) self.assertEqual(len(connection.test_outbox), 1) @@ -2216,7 +2219,7 @@ class GetConnectionTests(SimpleTestCase): used with custom backends. """ c = mail.get_connection(fail_silently=True, foo="bar") - self.assertTrue(c.fail_silently) + self.assertIs(c.fail_silently, True) # RemovedInDjango70Warning. -- cgit v1.3