From fc793fc303a3d516ab51bb21aa317031caabe7b4 Mon Sep 17 00:00:00 2001 From: Mike Edmunds Date: Wed, 16 Jul 2025 15:01:49 -0700 Subject: [PATCH] Fixed #36163 -- Deprecated most positional arguments in django.core.mail. In public mail APIs, changed less frequently used parameters from keyword-or-positional to keyword-only, emitting a warning during the required deprecation period. --- django/core/mail/__init__.py | 42 ++++++++-- django/core/mail/message.py | 38 +++++++-- docs/internals/deprecation.txt | 3 + docs/releases/6.0.txt | 20 +++++ docs/topics/email.txt | 66 +++++++++++---- tests/auth_tests/test_forms.py | 2 +- tests/mail/tests.py | 145 ++++++++++++++++++++++++++++++--- 7 files changed, 277 insertions(+), 39 deletions(-) diff --git a/django/core/mail/__init__.py b/django/core/mail/__init__.py index e822f1d2a2..4f49d85756 100644 --- a/django/core/mail/__init__.py +++ b/django/core/mail/__init__.py @@ -24,7 +24,7 @@ from django.core.mail.message import ( make_msgid, ) from django.core.mail.utils import DNS_NAME, CachedDnsName -from django.utils.deprecation import RemovedInDjango70Warning +from django.utils.deprecation import RemovedInDjango70Warning, deprecate_posargs from django.utils.functional import Promise from django.utils.module_loading import import_string @@ -49,7 +49,8 @@ __all__ = [ ] -def get_connection(backend=None, fail_silently=False, **kwds): +@deprecate_posargs(RemovedInDjango70Warning, ["fail_silently"]) +def get_connection(backend=None, *, fail_silently=False, **kwds): """Load an email backend and return an instance of it. If backend is None (default), use settings.EMAIL_BACKEND. @@ -61,11 +62,22 @@ def get_connection(backend=None, fail_silently=False, **kwds): return klass(fail_silently=fail_silently, **kwds) +@deprecate_posargs( + RemovedInDjango70Warning, + [ + "fail_silently", + "auth_user", + "auth_password", + "connection", + "html_message", + ], +) def send_mail( subject, message, from_email, recipient_list, + *, fail_silently=False, auth_user=None, auth_password=None, @@ -97,8 +109,22 @@ def send_mail( return mail.send() +@deprecate_posargs( + RemovedInDjango70Warning, + [ + "fail_silently", + "auth_user", + "auth_password", + "connection", + ], +) def send_mass_mail( - datatuple, fail_silently=False, auth_user=None, auth_password=None, connection=None + datatuple, + *, + fail_silently=False, + auth_user=None, + auth_password=None, + connection=None, ): """ Given a datatuple of (subject, message, from_email, recipient_list), send @@ -166,8 +192,11 @@ def _send_server_message( mail.send(fail_silently=fail_silently) +@deprecate_posargs( + RemovedInDjango70Warning, ["fail_silently", "connection", "html_message"] +) def mail_admins( - subject, message, fail_silently=False, connection=None, html_message=None + subject, message, *, fail_silently=False, connection=None, html_message=None ): """Send a message to the admins, as defined by the ADMINS setting.""" _send_server_message( @@ -180,8 +209,11 @@ def mail_admins( ) +@deprecate_posargs( + RemovedInDjango70Warning, ["fail_silently", "connection", "html_message"] +) def mail_managers( - subject, message, fail_silently=False, connection=None, html_message=None + subject, message, *, fail_silently=False, connection=None, html_message=None ): """Send a message to the managers, as defined by the MANAGERS setting.""" _send_server_message( diff --git a/django/core/mail/message.py b/django/core/mail/message.py index 51af560b12..93269d0310 100644 --- a/django/core/mail/message.py +++ b/django/core/mail/message.py @@ -17,6 +17,7 @@ from pathlib import Path from django.conf import settings from django.core.mail.utils import DNS_NAME +from django.utils.deprecation import RemovedInDjango70Warning, deprecate_posargs from django.utils.encoding import force_bytes, force_str, punycode # Don't BASE64-encode UTF-8 messages so that we avoid unwanted attention from @@ -202,12 +203,24 @@ class EmailMessage: mixed_subtype = "mixed" encoding = None # None => use settings default + @deprecate_posargs( + RemovedInDjango70Warning, + [ + "bcc", + "connection", + "attachments", + "headers", + "cc", + "reply_to", + ], + ) def __init__( self, subject="", body="", from_email=None, to=None, + *, bcc=None, connection=None, attachments=None, @@ -455,12 +468,25 @@ class EmailMultiAlternatives(EmailMessage): alternative_subtype = "alternative" + @deprecate_posargs( + RemovedInDjango70Warning, + [ + "bcc", + "connection", + "attachments", + "headers", + "alternatives", + "cc", + "reply_to", + ], + ) def __init__( self, subject="", body="", from_email=None, to=None, + *, bcc=None, connection=None, attachments=None, @@ -478,12 +504,12 @@ class EmailMultiAlternatives(EmailMessage): body, from_email, to, - bcc, - connection, - attachments, - headers, - cc, - reply_to, + bcc=bcc, + connection=connection, + attachments=attachments, + headers=headers, + cc=cc, + reply_to=reply_to, ) self.alternatives = [ EmailAlternative(*alternative) for alternative in (alternatives or []) diff --git a/docs/internals/deprecation.txt b/docs/internals/deprecation.txt index fb37d43849..98b8eade75 100644 --- a/docs/internals/deprecation.txt +++ b/docs/internals/deprecation.txt @@ -38,6 +38,9 @@ details on these changes. argument of ``django.core.paginator.Paginator`` and ``django.core.paginator.AsyncPaginator`` will no longer be allowed. +* The :mod:`django.core.mail` APIs will no longer accept certain parameters as + positional arguments. These must be passed as keyword arguments instead. + .. _deprecation-removed-in-6.1: 6.1 diff --git a/docs/releases/6.0.txt b/docs/releases/6.0.txt index a7306ba755..3f4ce44a75 100644 --- a/docs/releases/6.0.txt +++ b/docs/releases/6.0.txt @@ -405,6 +405,26 @@ Miscellaneous Features deprecated in 6.0 ========================== +Positional arguments in ``django.core.mail`` APIs +------------------------------------------------- + +.. currentmodule:: django.core.mail + +:mod:`django.core.mail` APIs now require keyword arguments for less commonly +used parameters. Using positional arguments for these now emits a deprecation +warning and will raise a :exc:`TypeError` when the deprecation period ends. + +* All *optional* parameters (``fail_silently`` and later) must be passed as + keyword arguments to :func:`get_connection`, :func:`mail_admins`, + :func:`mail_managers`, :func:`send_mail`, and :func:`send_mass_mail`. + +* All parameters must be passed as keyword arguments when creating an + :class:`EmailMessage` or :class:`EmailMultiAlternatives` instance, except for + the first four (``subject``, ``body``, ``from_email``, and ``to``), which may + still be passed either as positional or keyword arguments. + +.. currentmodule:: None + Miscellaneous ------------- diff --git a/docs/topics/email.txt b/docs/topics/email.txt index 77419d4eab..ec87f0337b 100644 --- a/docs/topics/email.txt +++ b/docs/topics/email.txt @@ -76,7 +76,7 @@ a secure connection is used. ``send_mail()`` =============== -.. function:: send_mail(subject, message, from_email, recipient_list, fail_silently=False, auth_user=None, auth_password=None, connection=None, html_message=None) +.. function:: send_mail(subject, message, from_email, recipient_list, *, fail_silently=False, auth_user=None, auth_password=None, connection=None, html_message=None) In most cases, you can send email using ``django.core.mail.send_mail()``. @@ -90,6 +90,10 @@ are required. * ``recipient_list``: A list of strings, each an email address. Each member of ``recipient_list`` will see the other recipients in the "To:" field of the email message. + +The following parameters are optional, and must be given as keyword arguments +if used. + * ``fail_silently``: A boolean. When it's ``False``, ``send_mail()`` will raise an :exc:`smtplib.SMTPException` if an error occurs. See the :mod:`smtplib` docs for a list of possible exceptions, all of which are subclasses of @@ -112,10 +116,15 @@ are required. The return value will be the number of successfully delivered messages (which can be ``0`` or ``1`` since it can only send one message). +.. deprecated:: 6.0 + + Passing ``fail_silently`` and later parameters as positional arguments is + deprecated. + ``send_mass_mail()`` ==================== -.. function:: send_mass_mail(datatuple, fail_silently=False, auth_user=None, auth_password=None, connection=None) +.. function:: send_mass_mail(datatuple, *, fail_silently=False, auth_user=None, auth_password=None, connection=None) ``django.core.mail.send_mass_mail()`` is intended to handle mass emailing. @@ -123,8 +132,9 @@ can be ``0`` or ``1`` since it can only send one message). (subject, message, from_email, recipient_list) -``fail_silently``, ``auth_user`` and ``auth_password`` have the same functions -as in :meth:`~django.core.mail.send_mail()`. +``fail_silently``, ``auth_user``, ``auth_password`` and ``connection`` have the +same functions as in :meth:`~django.core.mail.send_mail()`. They must be given +as keyword arguments if used. Each separate element of ``datatuple`` results in a separate email message. As in :meth:`~django.core.mail.send_mail()`, recipients in the same @@ -151,6 +161,11 @@ mail server would be opened:: The return value will be the number of successfully delivered messages. +.. deprecated:: 6.0 + + Passing ``fail_silently`` and later parameters as positional arguments is + deprecated. + ``send_mass_mail()`` vs. ``send_mail()`` ---------------------------------------- @@ -164,7 +179,7 @@ a single connection for all of its messages. This makes ``mail_admins()`` ================= -.. function:: mail_admins(subject, message, fail_silently=False, connection=None, html_message=None) +.. function:: mail_admins(subject, message, *, fail_silently=False, connection=None, html_message=None) ``django.core.mail.mail_admins()`` is a shortcut for sending an email to the site admins, as defined in the :setting:`ADMINS` setting. @@ -182,15 +197,25 @@ If ``html_message`` is provided, the resulting email will be a :mimetype:`text/plain` content type and ``html_message`` as the :mimetype:`text/html` content type. +.. deprecated:: 6.0 + + Passing ``fail_silently`` and later parameters as positional arguments is + deprecated. + ``mail_managers()`` =================== -.. function:: mail_managers(subject, message, fail_silently=False, connection=None, html_message=None) +.. function:: mail_managers(subject, message, *, fail_silently=False, connection=None, html_message=None) ``django.core.mail.mail_managers()`` is just like ``mail_admins()``, except it sends an email to the site managers, as defined in the :setting:`MANAGERS` setting. +.. deprecated:: 6.0 + + Passing ``fail_silently`` and later parameters as positional arguments is + deprecated. + Examples ======== @@ -294,9 +319,11 @@ email backend API :ref:`provides an alternative .. class:: EmailMessage The :class:`~django.core.mail.EmailMessage` class is initialized with the -following parameters (in the given order, if positional arguments are used). -All parameters are optional and can be set at any time prior to calling the -``send()`` method. +following parameters. All parameters are optional and can be set at any time +prior to calling the ``send()`` method. + +The first four parameters can be passed as positional or keyword arguments, +but must be in the given order if positional arguments are used: * ``subject``: The subject line of the email. @@ -308,6 +335,8 @@ All parameters are optional and can be set at any time prior to calling the * ``to``: A list or tuple of recipient addresses. +The following parameters must be given as keyword arguments if used: + * ``bcc``: A list or tuple of addresses used in the "Bcc" header when sending the email. @@ -338,6 +367,11 @@ All parameters are optional and can be set at any time prior to calling the * ``reply_to``: A list or tuple of recipient addresses used in the "Reply-To" header when sending the email. +.. deprecated:: 6.0 + + Passing all except the first four parameters as positional arguments is + deprecated. + For example:: from django.core.mail import EmailMessage @@ -347,7 +381,7 @@ For example:: "Body goes here", "from@example.com", ["to1@example.com", "to2@example.com"], - ["bcc@example.com"], + bcc=["bcc@example.com"], reply_to=["another@example.com"], headers={"Message-ID": "foo"}, ) @@ -582,15 +616,15 @@ instance of the email backend that you can use. .. currentmodule:: django.core.mail -.. function:: get_connection(backend=None, fail_silently=False, **kwargs) +.. function:: get_connection(backend=None, *, fail_silently=False, **kwargs) By default, a call to ``get_connection()`` will return an instance of the email backend specified in :setting:`EMAIL_BACKEND`. If you specify the ``backend`` argument, an instance of that backend will be instantiated. -The ``fail_silently`` argument controls how the backend should handle errors. -If ``fail_silently`` is True, exceptions during the email sending process -will be silently ignored. +The keyword-only ``fail_silently`` argument controls how the backend should +handle errors. If ``fail_silently`` is True, exceptions during the email +sending process will be silently ignored. All other keyword arguments are passed directly to the constructor of the email backend. @@ -600,6 +634,10 @@ SMTP backend (which is the default), these backends are only useful during testing and development. If you have special email sending requirements, you can :ref:`write your own email backend `. +.. deprecated:: 6.0 + + Passing ``fail_silently`` as positional argument is deprecated. + .. _topic-email-smtp-backend: SMTP backend diff --git a/tests/auth_tests/test_forms.py b/tests/auth_tests/test_forms.py index df91f100f5..ebfaa8b051 100644 --- a/tests/auth_tests/test_forms.py +++ b/tests/auth_tests/test_forms.py @@ -1266,7 +1266,7 @@ class PasswordResetFormTest(TestDataMixin, TestCase): "Sorry to hear you forgot your password.", None, [to_email], - ["site_monitor@example.com"], + bcc=["site_monitor@example.com"], headers={"Reply-To": "webmaster@example.com"}, alternatives=[ ("Really sorry to hear you forgot your password.", "text/html") diff --git a/tests/mail/tests.py b/tests/mail/tests.py index 8501fc97e7..bab1b4be83 100644 --- a/tests/mail/tests.py +++ b/tests/mail/tests.py @@ -1751,13 +1751,13 @@ class MailTests(MailTestsMixin, SimpleTestCase): "body\n", "from@example.com", ["to@example.com"], - ["bcc@example.com"], - connection, - [EmailAttachment("file.txt", "attachment\n", "text/plain")], - {"X-Header": "custom header"}, - ["cc@example.com"], - ["reply-to@example.com"], - # (New options can be added below here, ideally as keyword-only args.) + # (New options can be added below here as keyword-only args.) + bcc=["bcc@example.com"], + connection=connection, + attachments=[EmailAttachment("file.txt", "attachment\n", "text/plain")], + headers={"X-Header": "custom header"}, + cc=["cc@example.com"], + reply_to=["reply-to@example.com"], ) message = email.message() @@ -1791,12 +1791,14 @@ class MailTests(MailTestsMixin, SimpleTestCase): "original body\n", "original-from@example.com", ["original-to@example.com"], - ["original-bcc@example.com"], - original_connection, - [EmailAttachment("original.txt", "original attachment\n", "text/plain")], - {"X-Header": "original header"}, - ["original-cc@example.com"], - ["original-reply-to@example.com"], + bcc=["original-bcc@example.com"], + connection=original_connection, + attachments=[ + EmailAttachment("original.txt", "original attachment\n", "text/plain") + ], + headers={"X-Header": "original header"}, + cc=["original-cc@example.com"], + reply_to=["original-reply-to@example.com"], ) email.subject = "new subject" email.body = "new body\n" @@ -1837,6 +1839,123 @@ class MailTests(MailTestsMixin, SimpleTestCase): self.assertNotIn("original", message.as_string()) +# RemovedInDjango70Warning. +class MailDeprecatedPositionalArgsTests(SimpleTestCase): + + def assertDeprecatedIn70(self, params, name): + return self.assertWarnsMessage( + RemovedInDjango70Warning, + f"Passing positional argument(s) {params} to {name}() is deprecated.", + ) + + def test_get_connection(self): + with self.assertDeprecatedIn70("'fail_silently'", "get_connection"): + mail.get_connection( + "django.core.mail.backends.dummy.EmailBackend", + # Deprecated positional arg: + True, + ) + + def test_send_mail(self): + with self.assertDeprecatedIn70( + "'fail_silently', 'auth_user', 'auth_password', 'connection', " + "'html_message'", + "send_mail", + ): + send_mail( + "subject", + "message", + "from@example.com", + ["to@example.com"], + # Deprecated positional args: + True, + "username", + "password", + mail.get_connection(), + "html message", + ) + + def test_send_mass_mail(self): + with self.assertDeprecatedIn70( + "'fail_silently', 'auth_user', 'auth_password', 'connection'", + "send_mass_mail", + ): + send_mass_mail( + [], + # Deprecated positional args: + True, + "username", + "password", + mail.get_connection(), + ) + + def test_mail_admins(self): + with self.assertDeprecatedIn70( + "'fail_silently', 'connection', 'html_message'", "mail_admins" + ): + mail_admins( + "subject", + "message", + # Deprecated positional args: + True, + mail.get_connection(), + "html message", + ) + + def test_mail_managers(self): + with self.assertDeprecatedIn70( + "'fail_silently', 'connection', 'html_message'", "mail_managers" + ): + mail_managers( + "subject", + "message", + # Deprecated positional args: + True, + mail.get_connection(), + "html message", + ) + + def test_email_message_init(self): + with self.assertDeprecatedIn70( + "'bcc', 'connection', 'attachments', 'headers', 'cc', 'reply_to'", + "EmailMessage", + ): + EmailMessage( + "subject", + "body\n", + "from@example.com", + ["to@example.com"], + # Deprecated positional args: + ["bcc@example.com"], + mail.get_connection(), + [EmailAttachment("file.txt", "attachment\n", "text/plain")], + {"X-Header": "custom header"}, + ["cc@example.com"], + ["reply-to@example.com"], + ) + + def test_email_multi_alternatives_init(self): + with self.assertDeprecatedIn70( + "'bcc', 'connection', 'attachments', 'headers', 'alternatives', 'cc', " + "'reply_to'", + "EmailMultiAlternatives", + ): + EmailMultiAlternatives( + "subject", + "body\n", + "from@example.com", + ["to@example.com"], + # Deprecated positional args: + ["bcc@example.com"], + mail.get_connection(), + [EmailAttachment("file.txt", "attachment\n", "text/plain")], + {"X-Header": "custom header"}, + [EmailAlternative("html body", "text/html")], + ["cc@example.com"], + ["reply-to@example.com"], + ) + + @requires_tz_support class MailTimeZoneTests(MailTestsMixin, SimpleTestCase): @override_settings(