From 0f581cd29d42d1b5ed1dafb67794c2f3ce6705c9 Mon Sep 17 00:00:00 2001 From: Mike Edmunds Date: Wed, 1 Apr 2026 16:59:14 -0700 Subject: [PATCH 1/3] Fixed #35514 -- Implemented dictionary-based MAILERS. See DEP 0018. Added: * MAILERS setting. * django.core.mail.mailers dict-like EmailBackend factory. * `using` argument to mail sending APIs. * `sent_using` attribute to mail.outbox messages in locmem backend. * MAILERS in startproject settings template, set to console backend. * AdminLogHandler.using argument. * BrokenLinkEmailsMiddleware.send_mail() method. Updated: * BaseEmailBackend to track the MAILERS alias used to construct it, and to report errors for unknown kwargs (OPTIONS). * EmailBackend implementations to initialize from kwargs (OPTIONS) only when MAILERS is being used. * smtp.EmailBackend to require `host` option and to default `port` option based on SSL/TLS options. * SimpleTestCase setup to substitute the locmem backend for all defined MAILERS configurations. * Django's tests that send mail to define MAILERS. Deprecated: * EMAIL_BACKEND and other backend-related EMAIL_* settings. * mail.get_connection(). * The `connection`, `fail_silently`, `auth_user`, and `auth_password` arguments to mail functions. * The EmailMessage.connection attribute. * BaseEmailBackend support for `fail_silently`. Backends that support fail_silently (SMTP, console, file) now implement it directly. * AdminEmailHandler.email_backend argument. Removed undocumented features without deprecation: * EmailMessage.get_connection() method. (send() now raises an error if a subclass has attempted to override it.) * EmailMessage.send() no longer sets self.connection to the connection used for sending. (It still _uses_ a pre-existing self.connection.) * AdminEmailHandler.connection() method. (Init now raises an error if a subclass has attempted to override it.) Thanks to Natalia Bidart for shepherding DEP 0018 and for extensive reviews and suggestions on the implementation. Thanks to Jacob Rief for the initial implementation and multiple iterations while refining the design. Co-authored-by: Jacob Rief --- django/conf/__init__.py | 86 +++ django/conf/global_settings.py | 8 + .../project_name/settings.py-tpl | 10 + django/core/mail/__init__.py | 163 ++++- django/core/mail/backends/base.py | 70 +- django/core/mail/backends/console.py | 5 +- django/core/mail/backends/filebased.py | 75 ++- django/core/mail/backends/locmem.py | 10 +- django/core/mail/backends/smtp.py | 79 ++- django/core/mail/deprecation.py | 58 ++ django/core/mail/exceptions.py | 18 + django/core/mail/handler.py | 78 +++ django/core/mail/message.py | 76 ++- django/middleware/common.py | 19 +- django/test/utils.py | 20 +- django/utils/log.py | 48 +- docs/howto/deployment/checklist.txt | 11 +- docs/howto/index.txt | 1 + docs/howto/mailers-migration.txt | 412 ++++++++++++ docs/internals/deprecation.txt | 22 + docs/ref/logging.txt | 28 +- docs/ref/settings.txt | 157 ++++- docs/releases/1.7.txt | 2 +- docs/releases/1.8.txt | 2 +- docs/releases/4.2.txt | 2 +- docs/releases/6.1.txt | 113 +++- docs/topics/email.txt | 636 ++++++++++++++---- docs/topics/testing/tools.txt | 31 +- tests/admin_views/test_actions.py | 10 +- tests/admin_views/tests.py | 4 + tests/auth_tests/test_forms.py | 9 +- tests/auth_tests/test_models.py | 5 + tests/auth_tests/test_views.py | 1 + tests/logging_tests/tests.py | 92 ++- tests/mail/__init__.py | 54 ++ tests/mail/custombackend.py | 13 +- tests/mail/test_backends.py | 333 ++++++++- tests/mail/test_deprecated.py | 348 +++++++++- tests/mail/test_handler.py | 233 +++++++ tests/mail/test_sendtestemail.py | 1 + tests/mail/tests.py | 490 +++++++++++++- tests/middleware/tests.py | 39 +- tests/project_template/test_settings.py | 10 + tests/test_client/tests.py | 5 +- tests/test_client/views.py | 2 +- tests/test_utils/tests.py | 40 ++ tests/view_tests/tests/test_debug.py | 13 +- 47 files changed, 3593 insertions(+), 349 deletions(-) create mode 100644 django/core/mail/deprecation.py create mode 100644 django/core/mail/exceptions.py create mode 100644 django/core/mail/handler.py create mode 100644 docs/howto/mailers-migration.txt create mode 100644 tests/mail/test_handler.py diff --git a/django/conf/__init__.py b/django/conf/__init__.py index 16f5d9b575dc..6d67e18209e2 100644 --- a/django/conf/__init__.py +++ b/django/conf/__init__.py @@ -31,6 +31,37 @@ "django.db.models.fields.BLANK_CHOICE_LABEL in your app's ready() method." ) +# RemovedInDjango70Warning. +DEPRECATED_EMAIL_SETTINGS = { + "EMAIL_BACKEND", + "EMAIL_FILE_PATH", + "EMAIL_HOST", + "EMAIL_HOST_PASSWORD", + "EMAIL_HOST_USER", + "EMAIL_PORT", + "EMAIL_SSL_CERTFILE", + "EMAIL_SSL_KEYFILE", + "EMAIL_TIMEOUT", + "EMAIL_USE_SSL", + "EMAIL_USE_TLS", +} +EMAIL_SETTING_DEPRECATED_MSG = ( + "The {name} setting is deprecated. Migrate to MAILERS before Django 7.0." +) + + +# RemovedInDjango70Warning. +# Must be called with the complete set of user-defined setting names (but no +# default settings). +def _check_email_settings_conflicts(explicit_settings): + deprecated = DEPRECATED_EMAIL_SETTINGS.intersection(explicit_settings) + if deprecated and "MAILERS" in explicit_settings: + deprecated_str = ", ".join(sorted(deprecated)) + raise ImproperlyConfigured( + "Deprecated email settings are not allowed when MAILERS is " + f"defined: {deprecated_str}." + ) + class SettingsReference(str): """ @@ -85,6 +116,16 @@ def __getattr__(self, name): _wrapped = self._wrapped val = getattr(_wrapped, name) + # RemovedInDjango70Warning. + if name in DEPRECATED_EMAIL_SETTINGS: + if hasattr(_wrapped, "MAILERS"): + raise AttributeError( + f"The {name} setting is not available when MAILERS is defined." + ) + _show_settings_deprecation_warning( + EMAIL_SETTING_DEPRECATED_MSG.format(name=name), RemovedInDjango70Warning + ) + # Special case some settings which require further modification. # This is done here for performance reasons so the modified value is # cached. @@ -105,6 +146,18 @@ def __setattr__(self, name, value): self.__dict__.clear() else: self.__dict__.pop(name, None) + + # RemovedInDjango70Warning. + if name == "MAILERS": + # When MAILERS is set, clear any cached values of + # deprecated settings so that __getattr__() runs again for them. + for setting in DEPRECATED_EMAIL_SETTINGS: + self.__dict__.pop(setting, None) + if name in DEPRECATED_EMAIL_SETTINGS: + _show_settings_deprecation_warning( + EMAIL_SETTING_DEPRECATED_MSG.format(name=name), RemovedInDjango70Warning + ) + super().__setattr__(name, value) def __delattr__(self, name): @@ -112,6 +165,20 @@ def __delattr__(self, name): super().__delattr__(name) self.__dict__.pop(name, None) + # RemovedInDjango70Warning. + def __dir__(self): + attrs = super().__dir__() + if hasattr(self._wrapped, "MAILERS"): + # When MAILERS is defined, filter out deprecated email + # settings that are from the global_settings defaults. + attrs = [ + name + for name in attrs + if name not in DEPRECATED_EMAIL_SETTINGS + or self._wrapped.is_overridden(name) + ] + return attrs + def configure(self, default_settings=global_settings, **options): """ Called to manually configure the settings. The 'default_settings' @@ -120,6 +187,10 @@ def configure(self, default_settings=global_settings, **options): """ if self._wrapped is not empty: raise RuntimeError("Settings already configured.") + + # RemovedInDjango70Warning. + _check_email_settings_conflicts(options.keys()) + holder = UserSettingsHolder(default_settings) for name, value in options.items(): if not name.isupper(): @@ -182,6 +253,15 @@ def __init__(self, settings_module): setattr(self, setting, setting_value) self._explicit_settings.add(setting) + # RemovedInDjango70Warning. + _check_email_settings_conflicts(self._explicit_settings) + for name in DEPRECATED_EMAIL_SETTINGS.intersection(self._explicit_settings): + warnings.warn( + EMAIL_SETTING_DEPRECATED_MSG.format(name=name), + RemovedInDjango70Warning, + skip_file_prefixes=django_file_prefixes(), + ) + if hasattr(time, "tzset") and self.TIME_ZONE: # When we can, attempt to validate the timezone. If we can't find # this file, no check happens and it's harmless. @@ -232,6 +312,12 @@ def __setattr__(self, name, value): RemovedInDjango70Warning, skip_file_prefixes=django_file_prefixes(), ) + # RemovedInDjango70Warning. + if name in DEPRECATED_EMAIL_SETTINGS: + _show_settings_deprecation_warning( + EMAIL_SETTING_DEPRECATED_MSG.format(name=name), RemovedInDjango70Warning + ) + super().__setattr__(name, value) def __delattr__(self, name): diff --git a/django/conf/global_settings.py b/django/conf/global_settings.py index b2d07cffba07..ea70c761056a 100644 --- a/django/conf/global_settings.py +++ b/django/conf/global_settings.py @@ -187,21 +187,29 @@ def gettext_noop(s): # Classes used to implement DB routing behavior. DATABASE_ROUTERS = [] +# Mailer configurations. No mailers are defined by default. +# RemovedInDjango70Warning: uncomment the next line. +# MAILERS = {} + +# RemovedInDjango70Warning. # The email backend to use. For possible shortcuts see django.core.mail. # The default is to use the SMTP backend. # Third-party backends can be specified by providing a Python path # to a module that defines an EmailBackend class. EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" +# RemovedInDjango70Warning. # Host for sending email. EMAIL_HOST = "localhost" +# RemovedInDjango70Warning. # Port for sending email. EMAIL_PORT = 25 # Whether to send SMTP 'Date' header in the local time zone or in UTC. EMAIL_USE_LOCALTIME = False +# RemovedInDjango70Warning. # Optional SMTP authentication information for EMAIL_HOST. EMAIL_HOST_USER = "" EMAIL_HOST_PASSWORD = "" diff --git a/django/conf/project_template/project_name/settings.py-tpl b/django/conf/project_template/project_name/settings.py-tpl index c2d24e4df0f0..60bb79e2964c 100644 --- a/django/conf/project_template/project_name/settings.py-tpl +++ b/django/conf/project_template/project_name/settings.py-tpl @@ -115,3 +115,13 @@ USE_TZ = True # https://docs.djangoproject.com/en/{{ docs_version }}/howto/static-files/ STATIC_URL = 'static/' + + +# Email +# https://docs.djangoproject.com/en/{{ docs_version }}/topics/email/#topic-email-configuration + +MAILERS = { + 'default': { + 'BACKEND': 'django.core.mail.backends.console.EmailBackend', + }, +} diff --git a/django/core/mail/__init__.py b/django/core/mail/__init__.py index 6a0baa48edd6..4422f708b8bd 100644 --- a/django/core/mail/__init__.py +++ b/django/core/mail/__init__.py @@ -6,6 +6,8 @@ from django.conf import settings from django.core.exceptions import ImproperlyConfigured +from django.core.mail.exceptions import InvalidMailer, MailerDoesNotExist +from django.core.mail.handler import DEFAULT_MAILER_ALIAS, MailersHandler # Imported for backwards compatibility and for the sake # of a cleaner namespace. These symbols used to be in @@ -21,17 +23,32 @@ make_msgid, ) from django.core.mail.utils import DNS_NAME, CachedDnsName -from django.utils.deprecation import RemovedInDjango70Warning, deprecate_posargs +from django.utils.deprecation import ( + RemovedInDjango70Warning, + deprecate_posargs, + warn_about_external_use, +) from django.utils.functional import Promise from django.utils.module_loading import import_string +from .deprecation import ( + AUTH_ARGS_WARNING, + CONNECTION_ARG_WARNING, + FAIL_SILENTLY_ARG_WARNING, + report_using_incompatibility, + warn_about_default_mailers_if_needed, +) + __all__ = [ + "InvalidMailer", + "MailerDoesNotExist", "CachedDnsName", "DNS_NAME", "EmailMessage", "EmailMultiAlternatives", "DEFAULT_ATTACHMENT_MIME_TYPE", "make_msgid", + "mailers", "get_connection", "send_mail", "send_mass_mail", @@ -48,6 +65,10 @@ ] +mailers = MailersHandler() + + +# RemovedInDjango70Warning. @deprecate_posargs(RemovedInDjango70Warning, ["fail_silently"]) def get_connection(backend=None, *, fail_silently=False, **kwds): """Load an email backend and return an instance of it. @@ -57,8 +78,26 @@ def get_connection(backend=None, *, fail_silently=False, **kwds): Both fail_silently and other keyword arguments are used in the constructor of the backend. """ + msg = ( + "get_connection() is deprecated. See 'Migrating email to mailers' " + "in Django's documentation for recommended replacements." + ) + warn_about_external_use(msg, RemovedInDjango70Warning, skip_frames=1) + warn_about_default_mailers_if_needed() + + if fail_silently: + kwds["fail_silently"] = fail_silently + + if mailers._is_configured: + # Support get_connection(**kwargs) from MAILERS["default"]. + if backend is not None: + raise RuntimeError( + "get_connection(backend, ...) is not supported with MAILERS." + ) + return mailers.create_connection(DEFAULT_MAILER_ALIAS, _deprecated_kwargs=kwds) + klass = import_string(backend or settings.EMAIL_BACKEND) - return klass(fail_silently=fail_silently, **kwds) + return klass(**kwds) @deprecate_posargs( @@ -82,19 +121,49 @@ def send_mail( auth_password=None, connection=None, html_message=None, + using=None, ): """ Easy wrapper for sending a single message to a recipient list. All members of the recipient list will see the other recipients in the 'To' field. If from_email is None, use the DEFAULT_FROM_EMAIL setting. - If auth_user is None, use the EMAIL_HOST_USER setting. - If auth_password is None, use the EMAIL_HOST_PASSWORD setting. Note: The API for this method is frozen. New code wanting to extend the functionality should use the EmailMessage class directly. """ + # RemovedInDjango70Warning: change entire implementation to: + # email = EmailMultiAlternatives( + # subject, message, from_email, recipient_list + # ) + # if html_message: + # email.attach_alternative(html_message, "text/html") + # return email.send(using=using) + if fail_silently: + warn_about_external_use( + FAIL_SILENTLY_ARG_WARNING, RemovedInDjango70Warning, skip_frames=1 + ) + if auth_user is not None or auth_password is not None: + warn_about_external_use( + AUTH_ARGS_WARNING, RemovedInDjango70Warning, skip_frames=1 + ) if connection is not None: + warn_about_external_use( + CONNECTION_ARG_WARNING, RemovedInDjango70Warning, skip_frames=1 + ) + + if using is not None: + report_using_incompatibility( + connection, fail_silently, auth_user, auth_password + ) + elif connection is None: + options = {"fail_silently": fail_silently} + if auth_user is not None: + options["username"] = auth_user + if auth_password is not None: + options["password"] = auth_password + connection = get_connection(**options) + else: if fail_silently: raise TypeError( "fail_silently cannot be used with a connection. " @@ -105,18 +174,13 @@ def send_mail( "auth_user and auth_password cannot be used with a connection. " "Pass auth_user and auth_password to get_connection() instead." ) - connection = connection or get_connection( - username=auth_user, - password=auth_password, - fail_silently=fail_silently, - ) mail = EmailMultiAlternatives( subject, message, from_email, recipient_list, connection=connection ) if html_message: mail.attach_alternative(html_message, "text/html") - return mail.send() + return mail.send(using=using) @deprecate_posargs( @@ -135,20 +199,47 @@ def send_mass_mail( auth_user=None, auth_password=None, connection=None, + using=None, ): """ Given a datatuple of (subject, message, from_email, recipient_list), send each message to each recipient list. Return the number of emails sent. If from_email is None, use the DEFAULT_FROM_EMAIL setting. - If auth_user and auth_password are set, use them to log in. - If auth_user is None, use the EMAIL_HOST_USER setting. - If auth_password is None, use the EMAIL_HOST_PASSWORD setting. Note: The API for this method is frozen. New code wanting to extend the functionality should use the EmailMessage class directly. """ + # RemovedInDjango70Warning: change entire implementation to: + # messages = [...] + # mailer = mailers.default if using is None else mailers[using] + # return mailer.send_messages(messages) + if fail_silently: + warn_about_external_use( + FAIL_SILENTLY_ARG_WARNING, RemovedInDjango70Warning, skip_frames=1 + ) + if auth_user is not None or auth_password is not None: + warn_about_external_use( + AUTH_ARGS_WARNING, RemovedInDjango70Warning, skip_frames=1 + ) if connection is not None: + warn_about_external_use( + CONNECTION_ARG_WARNING, RemovedInDjango70Warning, skip_frames=1 + ) + + if using is not None: + report_using_incompatibility( + connection, fail_silently, auth_user, auth_password + ) + connection = mailers[using] + elif connection is None: + options = {"fail_silently": fail_silently} + if auth_user is not None: + options["username"] = auth_user + if auth_password is not None: + options["password"] = auth_password + connection = get_connection(**options) + else: if fail_silently: raise TypeError( "fail_silently cannot be used with a connection. " @@ -159,11 +250,6 @@ def send_mass_mail( "auth_user and auth_password cannot be used with a connection. " "Pass auth_user and auth_password to get_connection() instead." ) - connection = connection or get_connection( - username=auth_user, - password=auth_password, - fail_silently=fail_silently, - ) messages = [ EmailMessage(subject, message, sender, recipient, connection=connection) for subject, message, sender, recipient in datatuple @@ -171,6 +257,7 @@ def send_mass_mail( return connection.send_messages(messages) +# RemovedInDjango70Warning: fail_silently and connection args. def _send_server_message( *, setting_name, @@ -179,12 +266,28 @@ def _send_server_message( html_message=None, fail_silently=False, connection=None, + using=None, ): - if connection is not None and fail_silently: + # RemovedInDjango70Warning: everything before `recipients = getattr(...)`. + # skip_frames=2: this helper's caller + its @deprecate_posargs decorator. + if fail_silently: + warn_about_external_use( + FAIL_SILENTLY_ARG_WARNING, RemovedInDjango70Warning, skip_frames=2 + ) + if connection is not None: + warn_about_external_use( + CONNECTION_ARG_WARNING, RemovedInDjango70Warning, skip_frames=2 + ) + + if using is not None: + report_using_incompatibility(connection, fail_silently) + elif connection is not None and fail_silently: raise TypeError( "fail_silently cannot be used with a connection. " "Pass fail_silently to get_connection() instead." ) + # ... end of RemovedInDjango70Warning. + recipients = getattr(settings, setting_name) if not recipients: return @@ -215,14 +318,21 @@ def _send_server_message( ) if html_message: mail.attach_alternative(html_message, "text/html") - mail.send(fail_silently=fail_silently) + mail.send(using=using, fail_silently=fail_silently) +# RemovedInDjango70Warning: fail_silently and connection args. @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, + using=None, ): """Send a message to the admins, as defined by the ADMINS setting.""" _send_server_message( @@ -232,14 +342,22 @@ def mail_admins( html_message=html_message, fail_silently=fail_silently, connection=connection, + using=using, ) +# RemovedInDjango70Warning: fail_silently and connection args. @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, + using=None, ): """Send a message to the managers, as defined by the MANAGERS setting.""" _send_server_message( @@ -249,6 +367,7 @@ def mail_managers( html_message=html_message, fail_silently=fail_silently, connection=connection, + using=using, ) diff --git a/django/core/mail/backends/base.py b/django/core/mail/backends/base.py index b35b964cb1c6..9061e9f65463 100644 --- a/django/core/mail/backends/base.py +++ b/django/core/mail/backends/base.py @@ -1,11 +1,20 @@ """Base email backend class.""" +from django.core.mail import InvalidMailer +from django.utils.deprecation import RemovedInDjango70Warning, warn_about_external_use + +# RemovedInDjango70Warning. +_NOT_PROVIDED = object() + class BaseEmailBackend: """ Base class for email backend implementations. - Subclasses must at least overwrite send_messages(). + Subclasses must implement at least send_messages(). + + Subclass __init__() should pass alias and unknown **kwargs to + super().__init__() for proper error reporting. open() and close() can be called indirectly by using a backend object as a context manager: @@ -15,8 +24,63 @@ class BaseEmailBackend: pass """ - def __init__(self, fail_silently=False, **kwargs): - self.fail_silently = fail_silently + # RemovedInDjango70Warning: fail_silently, _ignore_unknown_kwargs. + def __init__( + self, + fail_silently=_NOT_PROVIDED, + *, + alias=None, + _ignore_unknown_kwargs=None, + **kwargs, + ): + self.alias = alias + + # RemovedInDjango70Warning. + if fail_silently is _NOT_PROVIDED: + self._fail_silently = False + else: + self._fail_silently = fail_silently + # Force deprecation warning unless in _ignore_unknown_kwargs. + kwargs["fail_silently"] = fail_silently + if _ignore_unknown_kwargs: + for ignored in _ignore_unknown_kwargs: + kwargs.pop(ignored, None) + + if kwargs: + kwarg_names = ", ".join(repr(key) for key in kwargs.keys()) + + # RemovedInDjango70Warning. + if alias is None: + # Not being initialized from mail.mailers. Unknown kwargs are + # ignored -- with a deprecation warning if they originated from + # outside Django (either direct construction of a built-in + # backend or a super.__init__() call from a custom subclass). + # Use something more precise than self.__class__.__name__, + # which is often just "EmailBackend". + class_name = f"{type(self).__module__}.{type(self).__qualname__}" + warn_about_external_use( + f"{class_name}.__init__() does not support {kwarg_names}. " + "In Django 7.0, BaseEmailBackend will raise a TypeError " + "for unknown keyword arguments.", + RemovedInDjango70Warning, + skip_name_prefixes="django.core.mail.backends", + ) + return + + raise InvalidMailer(f"Unknown options {kwarg_names}.", alias=alias) + + # RemovedInDjango70Warning. + def __getattr__(self, name): + if name == "fail_silently": + msg = ( + "BaseEmailBackend.fail_silently is deprecated. A subclass " + "that supports fail_silently must set its own attribute." + ) + warn_about_external_use(msg, RemovedInDjango70Warning) + return self._fail_silently + raise AttributeError( + f"{type(self).__name__!r} object has no attribute {name!r}" + ) def open(self): """ diff --git a/django/core/mail/backends/console.py b/django/core/mail/backends/console.py index 2d7c778cc15e..0fe8a306ab5c 100644 --- a/django/core/mail/backends/console.py +++ b/django/core/mail/backends/console.py @@ -9,10 +9,11 @@ class EmailBackend(BaseEmailBackend): - def __init__(self, *args, **kwargs): + def __init__(self, fail_silently=False, **kwargs): self.stream = kwargs.pop("stream", sys.stdout) self._lock = threading.RLock() - super().__init__(*args, **kwargs) + super().__init__(**kwargs) + self.fail_silently = fail_silently def write_message(self, message): msg = message.message() diff --git a/django/core/mail/backends/filebased.py b/django/core/mail/backends/filebased.py index 07ca959a594b..67257738ac79 100644 --- a/django/core/mail/backends/filebased.py +++ b/django/core/mail/backends/filebased.py @@ -5,43 +5,70 @@ from django.conf import settings from django.core.exceptions import ImproperlyConfigured +from django.core.mail import InvalidMailer from django.core.mail.backends.console import EmailBackend as ConsoleEmailBackend class EmailBackend(ConsoleEmailBackend): - def __init__(self, *args, file_path=None, **kwargs): + def __init__(self, fail_silently=False, file_path=None, **kwargs): self._fname = None - if file_path is not None: - self.file_path = file_path - else: - self.file_path = getattr(settings, "EMAIL_FILE_PATH", None) - if self.file_path is None: - raise ImproperlyConfigured( - "The EMAIL_FILE_PATH setting must be set to use the file EmailBackend." - ) - self.file_path = os.path.abspath(self.file_path) + # Since we're using the console-based backend as a base, force the + # stream to be None, so we don't default to stdout. + kwargs["stream"] = None + super().__init__(fail_silently=fail_silently, **kwargs) + + # RemovedInDjango70Warning. + if self.alias is None: + # Use deprecated settings when MAILERS not enabled. + if file_path is not None: + self.file_path = file_path + else: + self.file_path = getattr(settings, "EMAIL_FILE_PATH", None) + if self.file_path is None: + raise ImproperlyConfigured( + "The EMAIL_FILE_PATH setting must be set to use the file " + "EmailBackend." + ) + self.file_path = os.path.abspath(self.file_path) + try: + os.makedirs(self.file_path, exist_ok=True) + except FileExistsError: + raise ImproperlyConfigured( + "Path for saving email messages exists, but is not a directory: %s" + % self.file_path + ) + except OSError as err: + raise ImproperlyConfigured( + "Could not create directory for saving email messages: %s (%s)" + % (self.file_path, err) + ) + # Make sure that self.file_path is writable. + if not os.access(self.file_path, os.W_OK): + raise ImproperlyConfigured( + "Could not write to directory: %s" % self.file_path + ) + return + + if file_path is None: + raise InvalidMailer("OPTIONS must define 'file_path'.", alias=self.alias) + self.file_path = os.path.abspath(file_path) try: os.makedirs(self.file_path, exist_ok=True) except FileExistsError: - raise ImproperlyConfigured( - "Path for saving email messages exists, but is not a directory: %s" - % self.file_path + raise InvalidMailer( + f"'file_path' is not a directory: {self.file_path}", + alias=self.alias, ) except OSError as err: - raise ImproperlyConfigured( - "Could not create directory for saving email messages: %s (%s)" - % (self.file_path, err) + raise InvalidMailer( + f"Could not create 'file_path': {self.file_path} ({err})", + alias=self.alias, ) - # Make sure that self.file_path is writable. if not os.access(self.file_path, os.W_OK): - raise ImproperlyConfigured( - "Could not write to directory: %s" % self.file_path + raise InvalidMailer( + f"'file_path' is not writable: {self.file_path}", + alias=self.alias, ) - # Finally, call super(). - # Since we're using the console-based backend as a base, - # force the stream to be None, so we don't default to stdout - kwargs["stream"] = None - super().__init__(*args, **kwargs) def write_message(self, message): self.stream.write(message.message().as_bytes() + b"\n") diff --git a/django/core/mail/backends/locmem.py b/django/core/mail/backends/locmem.py index f5473da952c6..43a363ea737c 100644 --- a/django/core/mail/backends/locmem.py +++ b/django/core/mail/backends/locmem.py @@ -18,6 +18,8 @@ class EmailBackend(BaseEmailBackend): The dummy outbox is accessible through the outbox instance attribute. """ + # RemovedInDjango70Warning: *args. (The only supported posarg will be + # removed from BaseEmailBackend in Django 7.0.) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) if not hasattr(mail, "outbox"): @@ -26,8 +28,10 @@ def __init__(self, *args, **kwargs): def send_messages(self, messages): """Redirect messages to the dummy outbox""" msg_count = 0 - for message in messages: # .message() triggers header validation - message.message() - mail.outbox.append(copy.deepcopy(message)) + for message in messages: + message.message() # Trigger header validation. + msg_copy = copy.deepcopy(message) + msg_copy.sent_using = self.alias + mail.outbox.append(msg_copy) msg_count += 1 return msg_count diff --git a/django/core/mail/backends/smtp.py b/django/core/mail/backends/smtp.py index 4b79d9f3e1e0..e7cca2c01d1d 100644 --- a/django/core/mail/backends/smtp.py +++ b/django/core/mail/backends/smtp.py @@ -7,8 +7,10 @@ from email.headerregistry import Address, AddressHeader from django.conf import settings +from django.core.mail import InvalidMailer from django.core.mail.backends.base import BaseEmailBackend from django.core.mail.utils import DNS_NAME +from django.utils.deprecation import RemovedInDjango70Warning, warn_about_external_use from django.utils.encoding import force_str, punycode from django.utils.functional import cached_property @@ -32,28 +34,69 @@ def __init__( ssl_certfile=None, **kwargs, ): - super().__init__(fail_silently=fail_silently) - self.host = host or settings.EMAIL_HOST - self.port = port or settings.EMAIL_PORT - self.username = settings.EMAIL_HOST_USER if username is None else username - self.password = settings.EMAIL_HOST_PASSWORD if password is None else password - self.use_tls = settings.EMAIL_USE_TLS if use_tls is None else use_tls - self.use_ssl = settings.EMAIL_USE_SSL if use_ssl is None else use_ssl - self.timeout = settings.EMAIL_TIMEOUT if timeout is None else timeout - self.ssl_keyfile = ( - settings.EMAIL_SSL_KEYFILE if ssl_keyfile is None else ssl_keyfile - ) - self.ssl_certfile = ( - settings.EMAIL_SSL_CERTFILE if ssl_certfile is None else ssl_certfile - ) - if self.use_ssl and self.use_tls: - raise ValueError( - "EMAIL_USE_TLS/EMAIL_USE_SSL are mutually exclusive, so only set " - "one of those settings to True." + # RemovedInDjango70Warning. + if "alias" not in kwargs: + msg = ( + "Directly creating EmailBackend instances is deprecated. " + "Use mail.mailers instead." ) + warn_about_external_use(msg, RemovedInDjango70Warning) + + super().__init__(**kwargs) + self.fail_silently = fail_silently self.connection = None self._lock = threading.RLock() + # RemovedInDjango70Warning. + if self.alias is None: + # Use deprecated settings when MAILERS not enabled. + self.host = host or settings.EMAIL_HOST + self.port = port or settings.EMAIL_PORT + self.username = settings.EMAIL_HOST_USER if username is None else username + self.password = ( + settings.EMAIL_HOST_PASSWORD if password is None else password + ) + self.use_tls = settings.EMAIL_USE_TLS if use_tls is None else use_tls + self.use_ssl = settings.EMAIL_USE_SSL if use_ssl is None else use_ssl + self.timeout = settings.EMAIL_TIMEOUT if timeout is None else timeout + self.ssl_keyfile = ( + settings.EMAIL_SSL_KEYFILE if ssl_keyfile is None else ssl_keyfile + ) + self.ssl_certfile = ( + settings.EMAIL_SSL_CERTFILE if ssl_certfile is None else ssl_certfile + ) + if self.use_ssl and self.use_tls: + raise ValueError( + "EMAIL_USE_TLS/EMAIL_USE_SSL are mutually exclusive, so " + "only set one of those settings to True." + ) + return + + self.host = host + self.port = port + self.username = username + self.password = password + self.use_tls = use_tls if use_tls is not None else False + self.use_ssl = use_ssl if use_ssl is not None else False + self.timeout = timeout + self.ssl_keyfile = ssl_keyfile + self.ssl_certfile = ssl_certfile + if self.host is None: + raise InvalidMailer("OPTIONS must define 'host'.", alias=self.alias) + if self.use_ssl and self.use_tls: + raise InvalidMailer( + "The 'use_ssl' and 'use_tls' OPTIONS are incompatible. " + "Set at most one of them to True.", + alias=self.alias, + ) + if self.port is None: + if self.use_ssl: + self.port = 465 + elif self.use_tls: + self.port = 587 + else: + self.port = 25 + @property def connection_class(self): return smtplib.SMTP_SSL if self.use_ssl else smtplib.SMTP diff --git a/django/core/mail/deprecation.py b/django/core/mail/deprecation.py new file mode 100644 index 000000000000..ec9fce591bce --- /dev/null +++ b/django/core/mail/deprecation.py @@ -0,0 +1,58 @@ +# RemovedInDjango70Warning: this entire file. +# Mailers-related deprecation warnings and helpers used in multiple places. +# (In a separate file to avoid circular import problems.) +import warnings + +from django.conf import DEPRECATED_EMAIL_SETTINGS, settings +from django.utils.deprecation import ( + RemovedInDjango70Warning, + django_file_prefixes, +) + +FAIL_SILENTLY_ARG_WARNING = ( + "The 'fail_silently' argument is deprecated. See 'Migrating email to " + "mailers' in Django's documentation for recommended replacements." +) +AUTH_ARGS_WARNING = ( + "The 'auth_user' and 'auth_password' arguments are deprecated. Set " + "'username' and 'password' OPTIONS in MAILERS instead." +) +CONNECTION_ARG_WARNING = ( + "The 'connection' argument is deprecated. Switch to the 'using' argument " + "with a MAILERS alias." +) +NO_DEFAULT_MAILER_WARNING = ( + "Django 7.0 will not have a default mailer. Configure " + "settings.MAILERS to avoid errors when sending email." +) + + +def report_using_incompatibility( + connection=None, fail_silently=False, auth_user=None, auth_password=None +): + """Report arguments incompatible with 'using'.""" + if connection is not None: + raise TypeError("'connection' is not compatible with 'using'.") + if fail_silently: + raise TypeError("'fail_silently' is not compatible with 'using'.") + if auth_user is not None or auth_password is not None: + raise TypeError( + "'auth_user' and 'auth_password' are not compatible with 'using'. " + "Set 'username' and 'password' OPTIONS in MAILERS instead." + ) + + +def warn_about_default_mailers_if_needed(): + if not hasattr(settings, "MAILERS"): + # If a warning about migrating to MAILERS was not already + # issued on startup (for a deprecated email setting), warn defining + # MAILERS will be required for sending email in Django 7.0. + if not any( + hasattr(settings, name) and settings.is_overridden(name) + for name in DEPRECATED_EMAIL_SETTINGS + ): + warnings.warn( + NO_DEFAULT_MAILER_WARNING, + RemovedInDjango70Warning, + skip_file_prefixes=django_file_prefixes(), + ) diff --git a/django/core/mail/exceptions.py b/django/core/mail/exceptions.py new file mode 100644 index 000000000000..3c3001143218 --- /dev/null +++ b/django/core/mail/exceptions.py @@ -0,0 +1,18 @@ +from django.core.exceptions import ImproperlyConfigured + + +class InvalidMailer(ImproperlyConfigured): + """A settings.MAILERS entry has a configuration error.""" + + def __init__(self, msg, *, alias=None): + if alias is not None: + msg = f"MAILERS[{alias!r}]: {msg}" + super().__init__(msg) + + +class MailerDoesNotExist(InvalidMailer, KeyError): + """The requested alias is not defined in settings.MAILERS.""" + + def __init__(self, *, alias): + # This is the only permitted use for this exception. + super().__init__(f"The mailer '{alias}' is not configured.") diff --git a/django/core/mail/handler.py b/django/core/mail/handler.py new file mode 100644 index 000000000000..e13dc5891a3b --- /dev/null +++ b/django/core/mail/handler.py @@ -0,0 +1,78 @@ +from django.conf import settings +from django.core.mail import InvalidMailer, MailerDoesNotExist +from django.utils.module_loading import import_string + +DEFAULT_MAILER_ALIAS = "default" + +# Default value for a MAILERS "BACKEND". (Not related to the default mailer.) +DEFAULT_MAILER_BACKEND = "django.core.mail.backends.smtp.EmailBackend" + + +class MailersHandler: + def __getitem__(self, /, alias): + return self.create_connection(alias) + + def __contains__(self, /, alias): + return alias in self.settings + + def __iter__(self): + return iter(self.settings) + + def get(self, alias, /, default=None): + try: + return self[alias] + except MailerDoesNotExist: + return default + + @property + def default(self): + return self[DEFAULT_MAILER_ALIAS] + + @property + def settings(self): + # RemovedInDjango70Warning: change to: + # return settings.MAILERS + return getattr(settings, "MAILERS", {}) + + # RemovedInDjango70Warning. + @property + def _is_configured(self): + """True if settings.py has opted into MAILERS support.""" + return hasattr(settings, "MAILERS") + + # RemovedInDjango70Warning: _deprecated_kwargs. + def create_connection(self, alias, /, *, _deprecated_kwargs=None): + # RemovedInDjango70Warning. + if not self._is_configured and alias == DEFAULT_MAILER_ALIAS: + # Create mailers.default from deprecated settings. + from django.core.mail import get_connection + + assert _deprecated_kwargs is None + return get_connection() + + try: + config = self.settings[alias] + except KeyError: + raise MailerDoesNotExist(alias=alias) from None + + options = config.get("OPTIONS", {}) + if "alias" in options: + raise InvalidMailer("OPTIONS must not define 'alias'.", alias=alias) + + # RemovedInDjango70Warning. + if _deprecated_kwargs: + # Being called from get_connection() to create default mailer + # instance with some overrides. _ignore_unknown_kwargs prevents + # BaseEmailBackend from reporting these as unknown OPTIONS. + assert alias == DEFAULT_MAILER_ALIAS + options = options | _deprecated_kwargs + options |= {"_ignore_unknown_kwargs": set(_deprecated_kwargs)} + + backend_path = config.get("BACKEND", DEFAULT_MAILER_BACKEND) + try: + backend_class = import_string(backend_path) + except ImportError as error: + raise InvalidMailer( + f"Could not find BACKEND {backend_path!r}: {error}", alias=alias + ) from error + return backend_class(alias=alias, **options) diff --git a/django/core/mail/message.py b/django/core/mail/message.py index 6eb85c6a2ad3..549b8050fe95 100644 --- a/django/core/mail/message.py +++ b/django/core/mail/message.py @@ -19,10 +19,20 @@ from django.conf import settings from django.core.mail.utils import DNS_NAME -from django.utils.deprecation import RemovedInDjango70Warning, deprecate_posargs +from django.utils.deprecation import ( + RemovedInDjango70Warning, + deprecate_posargs, + warn_about_external_use, +) from django.utils.encoding import force_bytes, force_str, punycode from django.utils.timezone import get_current_timezone +from .deprecation import ( + CONNECTION_ARG_WARNING, + FAIL_SILENTLY_ARG_WARNING, + report_using_incompatibility, +) + # RemovedInDjango70Warning. # Don't BASE64-encode UTF-8 messages so that we avoid unwanted attention from # some spam filters. @@ -303,14 +313,28 @@ def __init__( else: self.attach(*attachment) self.extra_headers = headers or {} - self.connection = connection - - def get_connection(self, fail_silently=False): - from django.core.mail import get_connection - - if not self.connection: - self.connection = get_connection(fail_silently=fail_silently) - return self.connection + # RemovedInDjango70Warning. + if connection is not None: + warn_about_external_use( + CONNECTION_ARG_WARNING, RemovedInDjango70Warning, skip_frames=1 + ) + self._connection = connection + + # RemovedInDjango70Warning: connection property. + @property + def connection(self): + msg = "The EmailMessage.connection attribute is deprecated." + warn_about_external_use(msg, RemovedInDjango70Warning) + return self._connection + + @connection.setter + def connection(self, value): + msg = ( + "The EmailMessage.connection attribute is deprecated. Switch to " + "EmailMessage.send(using=...) with a MAILERS alias." + ) + warn_about_external_use(msg, RemovedInDjango70Warning) + self._connection = value def message(self, *, policy=email.policy.default): msg = email.message.EmailMessage(policy=policy) @@ -349,20 +373,42 @@ def recipients(self): """ return [email for email in (self.to + self.cc + self.bcc) if email] - def send(self, fail_silently=False): + def send(self, fail_silently=False, *, using=None): """Send the email message.""" if not self.recipients(): # Don't bother creating the network connection if there's nobody to # send to. return 0 - if fail_silently and self.connection: - raise TypeError( - "fail_silently cannot be used with a connection. " - "Pass fail_silently to get_connection() instead." + # RemovedInDjango70Warning: replace the remainder of this method with: + # from django.core.mail import mailers + # mailer = mailers.default if using is None else mailers[using] + # return mailer.send_messages([self]) + + from django.core import mail + + if fail_silently: + warn_about_external_use(FAIL_SILENTLY_ARG_WARNING, RemovedInDjango70Warning) + if hasattr(self, "get_connection"): + raise AttributeError( + "EmailMessage no longer supports the undocumented " + "get_connection() method." ) - return self.get_connection(fail_silently).send_messages([self]) + if using is not None: + report_using_incompatibility(self._connection, fail_silently) + connection = mail.mailers[using] + elif self._connection: + connection = self._connection + if fail_silently: + raise TypeError( + "fail_silently cannot be used with a connection. " + "Pass fail_silently to get_connection() instead." + ) + else: + connection = mail.get_connection(fail_silently=fail_silently) + + return connection.send_messages([self]) def attach(self, filename=None, content=None, mimetype=None): """ diff --git a/django/middleware/common.py b/django/middleware/common.py index 870d462e6d86..8ce17b5c096c 100644 --- a/django/middleware/common.py +++ b/django/middleware/common.py @@ -3,7 +3,7 @@ from django.conf import settings from django.core.exceptions import PermissionDenied -from django.core.mail import mail_managers +from django.core.mail import MailerDoesNotExist, mail_managers, mailers from django.http import HttpResponsePermanentRedirect from django.urls import is_valid_path from django.utils.deprecation import MiddlewareMixin @@ -118,6 +118,9 @@ def process_response(self, request, response): class BrokenLinkEmailsMiddleware(MiddlewareMixin): + # Set to override the mail.mailers alias used for sending the email. + using = None + def process_response(self, request, response): """Send broken link emails for relevant 404 NOT FOUND responses.""" if response.status_code == 404 and not settings.DEBUG: @@ -128,7 +131,7 @@ def process_response(self, request, response): if not self.is_ignorable_request(request, path, domain, referer): ua = request.META.get("HTTP_USER_AGENT", "") ip = request.META.get("REMOTE_ADDR", "") - mail_managers( + self.send_mail( "Broken %slink on %s" % ( ( @@ -140,10 +143,20 @@ def process_response(self, request, response): ), "Referrer: %s\nRequested URL: %s\nUser agent: %s\n" "IP address: %s\n" % (referer, path, ua, ip), - fail_silently=True, ) return response + def send_mail(self, subject, message, *args, **kwargs): + # RemovedInDjango70Warning. + if not mailers._is_configured: + mail_managers(subject, message, *args, fail_silently=True, **kwargs) + return + + try: + mail_managers(subject, message, *args, using=self.using, **kwargs) + except MailerDoesNotExist: + pass + def is_internal_request(self, domain, referer): """ Return True if the referring URL is the same domain as the current diff --git a/django/test/utils.py b/django/test/utils.py index ec888b358882..9bb9257861e9 100644 --- a/django/test/utils.py +++ b/django/test/utils.py @@ -142,8 +142,17 @@ def setup_test_environment(debug=None): saved_data.debug = settings.DEBUG settings.DEBUG = debug - saved_data.email_backend = settings.EMAIL_BACKEND - settings.EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend" + # RemovedInDjango70Warning: Override MAILERS unconditionally; + # remove EMAIL_BACKEND override. + if hasattr(settings, "MAILERS"): + saved_data.mailers = settings.MAILERS + settings.MAILERS = { + alias: {"BACKEND": "django.core.mail.backends.locmem.EmailBackend"} + for alias in settings.MAILERS + } + else: + saved_data.email_backend = settings.EMAIL_BACKEND + settings.EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend" saved_data.template_render = Template._render saved_data.partial_template_render = PartialTemplate._render @@ -164,7 +173,12 @@ def teardown_test_environment(): settings.ALLOWED_HOSTS = saved_data.allowed_hosts settings.DEBUG = saved_data.debug - settings.EMAIL_BACKEND = saved_data.email_backend + # RemovedInDjango70Warning: Restore MAILERS unconditionally; + # remove EMAIL_BACKEND support. + if hasattr(saved_data, "mailers"): + settings.MAILERS = saved_data.mailers + if hasattr(saved_data, "email_backend"): + settings.EMAIL_BACKEND = saved_data.email_backend Template._render = saved_data.template_render PartialTemplate._render = saved_data.partial_template_render diff --git a/django/utils/log.py b/django/utils/log.py index 9a3a7d9f6220..6df00498bf6a 100644 --- a/django/utils/log.py +++ b/django/utils/log.py @@ -1,11 +1,13 @@ import logging import logging.config # needed when logging_config doesn't start with logging.config +import warnings from copy import copy from django.conf import settings from django.core import mail -from django.core.mail import get_connection +from django.core.exceptions import ImproperlyConfigured from django.core.management.color import color_style +from django.utils.deprecation import RemovedInDjango70Warning, django_file_prefixes from django.utils.module_loading import import_string request_logger = logging.getLogger("django.request") @@ -83,10 +85,36 @@ class AdminEmailHandler(logging.Handler): request data will be provided in the email report. """ - def __init__(self, include_html=False, email_backend=None, reporter_class=None): + def __init__( + self, include_html=False, email_backend=None, reporter_class=None, using=None + ): super().__init__() + + # RemovedInDjango70Warning: email_backend arg and connection error. + if email_backend: + if using: + raise ImproperlyConfigured( + "The 'email_backend' argument is not compatible with 'using'." + ) + if mail.mailers._is_configured: + raise ImproperlyConfigured( + "The 'email_backend' argument is not valid when " + "settings.MAILERS is defined." + ) + warnings.warn( + "The 'email_backend' argument is deprecated. Use 'using' instead.", + RemovedInDjango70Warning, + skip_file_prefixes=django_file_prefixes(), + ) + if hasattr(self, "connection"): + raise AttributeError( + "The undocumented AdminEmailHandler.connection() method is no longer " + "used." + ) + self.include_html = include_html self.email_backend = email_backend + self.using = using self.reporter_class = import_string( reporter_class or settings.DEFAULT_EXCEPTION_REPORTER ) @@ -135,12 +163,18 @@ def emit(self, record): self.send_mail(subject, message, html_message=html_message) def send_mail(self, subject, message, *args, **kwargs): - mail.mail_admins( - subject, message, *args, connection=self.connection(), **kwargs - ) + # RemovedInDjango70Warning. + if not mail.mailers._is_configured: + connection = mail.get_connection( + backend=self.email_backend, fail_silently=True + ) + mail.mail_admins(subject, message, *args, connection=connection, **kwargs) + return - def connection(self): - return get_connection(backend=self.email_backend, fail_silently=True) + try: + mail.mail_admins(subject, message, *args, using=self.using, **kwargs) + except mail.MailerDoesNotExist: + pass def format_subject(self, subject): """ diff --git a/docs/howto/deployment/checklist.txt b/docs/howto/deployment/checklist.txt index b062cb2dd63b..d06b136e0ebd 100644 --- a/docs/howto/deployment/checklist.txt +++ b/docs/howto/deployment/checklist.txt @@ -144,13 +144,18 @@ your application servers. If you haven't set up backups for your database, do it right now! -:setting:`EMAIL_BACKEND` and related settings ---------------------------------------------- +:setting:`MAILERS` and related settings +--------------------------------------- If your site sends emails, these values need to be set correctly. +In development, email is often configured to be printed to the console or +stored in a local file. For production, you probably want to send real email +messages. See :ref:`topic-email-configuration` for more information about +setting :setting:`MAILERS` to use production email services. + By default, Django sends email from ``webmaster@localhost`` and -``root@localhost``. However, some mail providers reject email from these +``root@localhost``. However, many mail providers reject email from these addresses. To use different sender addresses, modify the :setting:`DEFAULT_FROM_EMAIL` and :setting:`SERVER_EMAIL` settings. diff --git a/docs/howto/index.txt b/docs/howto/index.txt index 00acf5c837db..fcf50ef783d7 100644 --- a/docs/howto/index.txt +++ b/docs/howto/index.txt @@ -62,6 +62,7 @@ Other guides custom-file-storage custom-management-commands custom-shell + mailers-migration .. seealso:: diff --git a/docs/howto/mailers-migration.txt b/docs/howto/mailers-migration.txt new file mode 100644 index 000000000000..a3cdafc237ff --- /dev/null +++ b/docs/howto/mailers-migration.txt @@ -0,0 +1,412 @@ +.. _migrating-to-mailers: + +========================== +Migrating email to mailers +========================== + +Django 6.1 introduces the :setting:`MAILERS` setting, replacing +:setting:`EMAIL_BACKEND` and several other ``EMAIL_*`` settings. It also +introduces :data:`.mail.mailers` for obtaining configured email backend +instances, replacing :func:`.mail.get_connection`. A new ``using`` argument +replaces the earlier ``connection`` in Django functions that send email. + +The older functionality and settings are still available, but are deprecated +and will be removed in Django 7.0. This guide provides details on migrating +existing projects to the new mailers functionality. + +All Django projects that send email should: + +* If using any third-party packages that send email, :ref:`verify their + compatibility ` with :setting:`MAILERS` + before making other changes. + +* :ref:`Update email-related settings `. + +* Run with deprecation warnings enabled (see + :ref:`howto/upgrade-version:Resolving deprecation warnings`) to identify + other code needing updates. + + .. note:: + + Test suites often use a non-functional email backend, such as the memory + backend that Django automatically :ref:`substitutes during tests + `. As a result, running tests won't uncover + deprecations that only appear when sending email in production. + + Consider running with deprecation warnings enabled in production to catch + those deprecations. Or carefully review your code (including third-party + packages) for use of any deprecated email features that will change in + Django 7.0. + +Other updates are needed only for projects or reusable Django libraries that +use these specific features: + +* :ref:`migrating-to-mailers-get-connection` +* :ref:`migrating-to-mailers-fail-silently` +* :ref:`migrating-to-mailers-email-backends` +* :ref:`migrating-to-mailers-auth` +* :ref:`migrating-to-mailers-adminemailhandler` + +.. _migrating-to-mailers-settings: + +Migrating settings +------------------ + +Often, the only change needed to migrate to mailers is updating email-related +settings. In your project's settings, define a :setting:`MAILERS` dict with a +``"default"`` entry matching the earlier ``EMAIL_*`` settings:: + + MAILERS = { + "default": { + "BACKEND": ..., # value of EMAIL_BACKEND setting + "OPTIONS": { + ..., # values from other deprecated EMAIL_* settings + }, + }, + } + + +For example, to update these settings:: + + EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" + EMAIL_HOST = "mail.example.net" + EMAIL_USE_TLS = True + EMAIL_PORT = 587 + EMAIL_HOST_USER = "user@example.net" + EMAIL_HOST_PASSWORD = "password" + +use:: + + MAILERS = { + "default": { + "BACKEND": "django.core.mail.backends.smtp.EmailBackend", + "OPTIONS": { + "host": "mail.example.net", + "use_tls": True, + # port is not needed: it defaults to 587 with use_tls True. + "username": "user@example.net", + "password": "password", + }, + }, + } + + +.. _deprecated-email-settings: + +The complete list of deprecated ``EMAIL_*`` settings and where they should be +moved in a :setting:`MAILERS` configuration is: + +* :setting:`EMAIL_BACKEND` becomes ``"BACKEND"``. If your settings didn't + define :setting:`!EMAIL_BACKEND`, the default value was + ``"django.core.mail.backends.smtp.EmailBackend"`` (which is also the default + if a :setting:`MAILERS` configuration doesn't specify the ``"BACKEND"``). +* :setting:`EMAIL_FILE_PATH` becomes ``"file_path"`` in ``"OPTIONS"`` (with + ``"BACKEND"`` set to ``"django.core.mail.backends.filebased.EmailBackend"``). +* :setting:`EMAIL_HOST` becomes ``"host"`` in ``"OPTIONS"``. If your settings + didn't define :setting:`!EMAIL_HOST`, the default value was ``"localhost"`` + and you must add ``"host": "localhost"`` to the ``"OPTIONS"`` for an SMTP + mailer configuration. +* :setting:`EMAIL_HOST_PASSWORD` becomes ``"password"`` in ``"OPTIONS"``. +* :setting:`EMAIL_HOST_USER` becomes ``"username"`` in ``"OPTIONS"``. +* :setting:`EMAIL_PORT` becomes ``"port"`` in ``"OPTIONS"``. It can be omitted + if the connection uses the default port for its security (465 for SSL, 587 + for TLS, or 25 for an unsecured SMTP connection). +* :setting:`EMAIL_SSL_CERTFILE` becomes ``"ssl_certfile"`` in ``"OPTIONS"``. +* :setting:`EMAIL_SSL_KEYFILE` becomes ``"ssl_keyfile"`` in ``"OPTIONS"``. +* :setting:`EMAIL_TIMEOUT` becomes ``"timeout"`` in ``"OPTIONS"``. +* :setting:`EMAIL_USE_SSL` becomes ``"use_ssl"`` in ``"OPTIONS"``. +* :setting:`EMAIL_USE_TLS` becomes ``"use_tls"`` in ``"OPTIONS"``. + +For third-party or custom email backends, the available ``"OPTIONS"`` depend on +the backend. Refer to the third-party documentation, or for custom backends see +:ref:`migrating-to-mailers-email-backends` below. + +The :ref:`topic-email-configuration` topic has more information on the +``MAILERS`` setting and additional configuration examples. + +.. _migrating-to-mailers-third-party: + +Reusable library readiness +-------------------------- + +In most cases, third-party packages that send email through Django will +continue working with projects whose settings have been upgraded to use +:setting:`MAILERS`. (If they use any deprecated mail features, those will of +course cause deprecation warnings.) + +There are two deprecated features that are *not* supported when +:setting:`MAILERS` is defined in a project's settings: + +* Trying to access any of the deprecated ``EMAIL_*`` settings on + ``django.conf.settings`` (e.g., checking ``settings.EMAIL_BACKEND`` or using + ``settings.EMAIL_HOST_USER``). +* Calling :func:`mail.get_connection("path.to.EmailBackend") + <.mail.get_connection>` with a specific backend path. (Other uses of + ``get_connection()`` are still allowed, and will issue deprecation warnings.) + +If a third-party package does either of those, projects that use it cannot +upgrade to the :setting:`MAILERS` setting until the package has been updated. + +Solving "not available when MAILERS is defined" errors +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If either of these errors are raised within a third-party package, that +indicates it is not compatible with the :setting:`MAILERS` setting: + +* :samp:`AttributeError: "The {name} setting is not available when MAILERS is + defined"` where *name* is :setting:`EMAIL_BACKEND`, :setting:`EMAIL_HOST`, + or one of the other deprecated settings. +* ``RuntimeError: get_connection(backend, ...) is not supported with MAILERS.`` + +If you see these errors, check if a newer version of that dependency is +available. If not (and if there are no alternatives to that package), you will +need to remove :setting:`MAILERS` from your settings and replace it with the +equivalent :ref:`deprecated email settings ` until +the package has been updated. + +.. _migrating-to-mailers-get-connection: + +Replacing ``get_connection()`` and ``connection`` arguments +----------------------------------------------------------- + +:func:`.mail.get_connection` is deprecated in Django 6.1, as is the +``connection`` argument for passing backend instances directly to mail +functions. + +The replacement for ``get_connection()`` depends on how it is being called: + +* ``get_connection()`` called with no arguments + + Replace with :data:`.mailers.default`. For example, update this code:: + + connection = mail.get_connection() + connection.send_messages([email1, email2]) + + To:: + + connection = mail.mailers.default + connection.send_messages([email1, email2]) + + Note that ``mailers.default`` *is* the default for Django's mail-sending + functions. Code like this:: + + mail.send_mail(..., connection=mail.get_connection()) + + can be updated to:: + + mail.send_mail(...) # No connection arg needed. + +* ``get_connection(fail_silently=True)`` + + See :ref:`migrating-to-mailers-fail-silently`. + +* ``get_connection(...)`` called with any other arguments + + Define a custom :setting:`MAILERS` configuration with the desired backend + and options. Then refer to it in the ``using`` argument when sending mail, + or obtain an email backend instance from :data:`.mail.mailers` with the + configuration name. + + For example, to upgrade this code:: + + connection = mail.get_connection( + "path.to.custom.EmailBackend", option1=True, option2="value" + ) + mail.send_mail(..., connection=connection) + connection.send_messages([email1, email2]) + + Define a custom :setting:`MAILERS` configuration in your settings:: + + MAILERS = { + "default": {...}, + "custom": { + "BACKEND": "path.to.custom.EmailBackend", + "OPTIONS": { + "option1": True, + "option2": "value", + }, + }, + } + + And then use it like this:: + + mail.send_mail(..., using="custom") + mail.mailers["custom"].send_messages([email1, email2]) + +.. _migrating-to-mailers-fail-silently: + +Replacing ``fail_silently`` +--------------------------- + +The ``fail_silently`` arguments to :func:`.send_mail`, :func:`.send_mass_mail`, +:func:`.mail_admins`, :func:`.mail_managers`, and :meth:`.EmailMessage.send` +are deprecated. + +Handling of ``fail_silently`` varies depending on the email backend. A survey +of its use suggested that callers have several different expectations for its +behavior, many of which don't match the actual backend implementations. + +Calls with ``fail_silently=True`` should be updated with one of these options, +depending on the caller's intent: + +* To send a message if email has been configured but avoid raising an error + if it hasn't (e.g., in a reusable library), wrap the send call in ``try:`` / + ``except mail.MailerDoesNotExist: pass``. + +* To ignore *all* exceptions (e.g., to avoid cascading failures in an error + handler), wrap the send call in ``try:`` / ``except Exception: pass``. + +* To ignore only SMTP-related errors, wrap the send call in ``try`` / + ``except OSError: pass``. Note that this ignores both transient network + glitches *and* SMTP configuration problems (just like the existing SMTP + backend ``fail_silently`` handling). + +* To ignore end user typos in ``to`` addresses and other delivery problems, + remove the ``fail_silently`` argument. Recipient errors are not generally + detected at send time, so using ``fail_silently`` for this purpose doesn't + accomplish anything and could mask other problems like configuration errors. + + (In certain local delivery configurations, SMTP servers *may* report some + recipient errors at send time. Intercept + :exc:`~.smtplib.SMTPRecipientsRefused` and/or + :exc:`~.smtplib.SMTPResponseException`\ s with particular ``smtp_code`` + values to detect those cases.) + +* To create an email configuration that ignores certain backend-dependent + errors and reuse it for multiple sending operations, create a custom + :setting:`MAILERS` configuration with ``"fail_silently": True`` in the + :setting:`"OPTIONS" `, then refer to that configuration + with ``using`` in the send call. + +Calls with ``fail_silently=False`` should be updated to remove the +``fail_silently`` arg, as that is the default. + +.. _migrating-to-mailers-email-backends: + +Migrating custom email backends +------------------------------- + +Custom ``EmailBackend`` implementations may need to be updated for +compatibility with mailers. + +* In the backend's ``__init__()`` method, accept explicit keyword arguments for + all configuration options that can come from :setting:`"OPTIONS" + `. Backends that use custom settings for configuration can + continue to do so (or not, as they choose), but keyword arguments should take + precedence over settings. + +* Accept variable ``**kwargs`` and pass them to superclass init. (This will + include a new ``alias`` argument which must be passed to the superclass.) + Ensure that any ``**kwargs`` used by the backend are *not* passed to + superclass init, as that would generate an ``InvalidMailer`` error for + unknown OPTIONS. + +* Backends must now handle ``fail_silently`` themselves, if they want to + support it. There is no requirement to support ``fail_silently``, and + backends that don't offer it should eliminate that keyword argument. (Do not + pass an explicit ``fail_silently`` arg to superclass init.) + +* Do not accept variable positional ``*args`` or pass them to superclass init. + +The ``BaseEmailBackend`` superclass now defines a ``self.alias`` attribute. +This is useful for error messages (e.g., ``raise InvalidMailer(f"Bad host +{host}", alias=self.alias)``), but should not be used for accessing +``settings.MAILERS`` directly. All OPTIONS for a mailer configuration are +passed to backend init as keyword arguments. + +For reusable libraries that want to support compatibility with deprecated mail +functions and settings (similar to Django's built-in email backends): + +* A backend can detect it is being initialized without :setting:`MAILERS` by + checking if ``self.alias is None``. (Django's built-in backends check this to + decide whether deprecated settings should be used.) Libraries supporting + older Django versions will need to use ``getattr(self, "alias", None)``. + +* Backends that borrow Django's SMTP email settings like :setting:`EMAIL_HOST` + must *not* try to access them when :setting:`MAILERS` is in use (``self.alias + is not None``), as this will cause a "not available when MAILERS is defined" + ``AttributeError``. + +* Libraries supporting multiple Django versions can identify support for + mailers with either ``hasattr(django.core.mail, "mailers")`` or + ``django.VERSION >= (6, 1)``. + +.. _migrating-to-mailers-auth: + +Replacing ``auth_user`` and ``auth_password`` +--------------------------------------------- + +The ``auth_user`` and ``auth_password`` arguments to :func:`.mail.send_mail` +and :func:`.mail.send_mass_mail` are deprecated. To replace them, define a +custom :setting:`MAILERS` configuration with ``"username"`` and ``"password"`` +:setting:`"OPTIONS" `, and refer to that configuration with +the ``using`` argument when sending mail. + +For example, to upgrade:: + + mail.send_mail(..., auth_user="admin", auth_password="admin-password") + +Add a custom :setting:`MAILERS` configuration in your settings:: + + MAILERS = { + "default": { + "OPTIONS": { + "host": "smtp.example.com", + "username": "default-user", + "password": "default-password", + }, + }, + "admin-config": { + "OPTIONS": { + "host": "smtp.example.com", + "username": "admin", + "password": "admin-password", + }, + }, + } + +And then refer to it when sending:: + + mail.send_mail(..., using="admin-config") + +.. _migrating-to-mailers-adminemailhandler: + +Updating AdminEmailHandler ``email_backend`` +-------------------------------------------- + +The ``email_backend`` argument to the logging :class:`.AdminEmailHandler` is +deprecated. Replace it with a custom :setting:`MAILERS` configuration and refer +to that configuration with the ``using`` argument. + +For example, if your settings include:: + + LOGGING = { + # ... + "handlers": { + "mail_admins": { + "class": "django.utils.log.AdminEmailHandler", + "email_backend": "third.party.EmailBackend", + }, + }, + # ... + } + +Replace that with:: + + LOGGING = { + # ... + "handlers": { + "mail_admins": { + "class": "django.utils.log.AdminEmailHandler", + "using": "admin-logging", # defined in MAILERS + }, + }, + # ... + } + + MAILERS = { + "default": {...}, + "admin-logging": { + "BACKEND": "third.party.EmailBackend", + }, + } diff --git a/docs/internals/deprecation.txt b/docs/internals/deprecation.txt index c27648c716b6..1e6415e484eb 100644 --- a/docs/internals/deprecation.txt +++ b/docs/internals/deprecation.txt @@ -73,6 +73,28 @@ details on these changes. * The ``SQLCompiler.quote_name_unless_alias()`` method will be removed. +* The ``EMAIL_BACKEND``, ``EMAIL_FILE_PATH``, ``EMAIL_HOST``, + ``EMAIL_HOST_PASSWORD``, ``EMAIL_HOST_USER``, ``EMAIL_PORT``, + ``EMAIL_USE_TLS``, ``EMAIL_USE_SSL``, ``EMAIL_SSL_CERTFILE``, + ``EMAIL_SSL_KEYFILE``, and ``EMAIL_TIMEOUT`` settings will be removed. + +* The ``django.core.mail.get_connection()`` function will be removed. + +* The ``connection``, ``fail_silently``, ``auth_user``, and ``auth_password`` + arguments will be removed from :func:`.send_mail`, :func:`.send_mass_mail`, + :func:`.mail_admins`, :func:`.mail_managers`, the :class:`.EmailMessage` + constructor, and :meth:`.EmailMessage.send`. Support for the + ``EmailMessage.connection`` attribute will also be removed. + +* Directly creating instances of the ``smtp.EmailBackend`` class will become + unsupported, and the class documentation for that backend will be removed. + +* The ``BaseEmailBackend.__init__()`` method will raise errors for unknown + keyword arguments and will remove support for ``fail_silently``. + +* The ``email_backend`` argument of :class:`.log.AdminEmailHandler` will be + removed. + * The ``django.contrib.postgres.aggregates.BitAnd``, ``django.contrib.postgres.aggregates.BitOr``, and ``django.contrib.postgres.aggregates.BitXor`` classes will be removed. diff --git a/docs/ref/logging.txt b/docs/ref/logging.txt index 0c2718331c17..0366ffb7fcfa 100644 --- a/docs/ref/logging.txt +++ b/docs/ref/logging.txt @@ -315,7 +315,7 @@ Handlers Django provides one log handler in addition to :mod:`those provided by the Python logging module `. -.. class:: AdminEmailHandler(include_html=False, email_backend=None, reporter_class=None) +.. class:: AdminEmailHandler(include_html=False, email_backend=None, reporter_class=None, using=None) This handler sends an email to the site :setting:`ADMINS` for each log message it receives. @@ -346,20 +346,26 @@ Python logging module `. Be aware of the :ref:`security implications of logging ` when using the ``AdminEmailHandler``. - By setting the ``email_backend`` argument of ``AdminEmailHandler``, the - :ref:`email backend ` that is being used by the - handler can be overridden, like this:: + Email is sent using the default :ref:`mailer `. + This can be overridden by setting the ``using`` argument of + ``AdminEmailHandler``, like this:: "handlers": { "mail_admins": { "level": "ERROR", "class": "django.utils.log.AdminEmailHandler", - "email_backend": "django.core.mail.backends.filebased.EmailBackend", + "using": "internal", }, } - By default, an instance of the email backend specified in - :setting:`EMAIL_BACKEND` will be used. + If the specified mailer is not configured in the :setting:`MAILERS` + setting, no email will be sent and no additional error will be raised. + + By setting the deprecated ``email_backend`` argument of + ``AdminEmailHandler``, the :ref:`email backend ` that + is being used by the handler can be overridden. ``email_backend`` is not + supported when :setting:`MAILERS` is defined or when the ``using`` + argument is provided. The ``reporter_class`` argument of ``AdminEmailHandler`` allows providing an ``django.views.debug.ExceptionReporter`` subclass to customize the @@ -375,6 +381,14 @@ Python logging module `. }, } + .. versionchanged:: 6.1 + + The ``using`` argument was added. + + .. deprecated:: 6.1 + + The ``email_backend`` argument is deprecated. Use ``using`` instead. + .. method:: send_mail(subject, message, *args, **kwargs) Sends emails to admin users. To customize this behavior, you can diff --git a/docs/ref/settings.txt b/docs/ref/settings.txt index 74a5b71d10a6..1a4de5b2dabb 100644 --- a/docs/ref/settings.txt +++ b/docs/ref/settings.txt @@ -1392,16 +1392,95 @@ that are not allowed to visit any page, systemwide. Use this for bots/crawlers. This is only used if ``CommonMiddleware`` is installed (see :doc:`/topics/http/middleware`). +.. setting:: MAILERS + +``MAILERS`` +----------- + +.. versionadded:: 6.1 + +Default: no default until Django 7.0, then ``{}``. (See deprecation note +below.) + +A dictionary containing the settings for all email backends to be used with +Django. It is a nested dictionary that maps backend aliases to dictionaries +containing each mailer's backend import path and configuration options. +Example:: + + MAILERS = { + "default": { + "BACKEND": "django.core.mail.backends.smtp.EmailBackend", + "OPTIONS": { + "host": "smtp.example.net", + }, + }, + "newsletters": { + "BACKEND": "django.core.mail.backends.smtp.EmailBackend", + "OPTIONS": { + "host": "smtp.bulk-email-service.example.com", + "username": os.environ["BULK_EMAIL_SERVICE_ACCOUNT_ID"], + "password": os.environ["BULK_EMAIL_SERVICE_API_KEY"], + "use_tls": True, + }, + }, + } + +If you plan to send email through Django, configuring at least a ``"default"`` +mailer is recommended. Any number of additional mailers may also be specified. +Depending on which backend is used, other options may be required. + +See :ref:`topic-email-configuration` for more information. + +.. deprecated:: 6.1 + + In Django 7.0, the default value will change to ``{}`` (no defined email + mailers). Until then, if ``MAILERS`` is not defined in the + settings, Django will create a ``"default"`` mailer from the + deprecated :setting:`EMAIL_BACKEND` setting. Projects can opt into the + Django 7.0 behavior early by adding ``MAILERS`` and removing + ``EMAIL_BACKEND`` from their settings. + +.. setting:: MAILERS-BACKEND + +``BACKEND`` +~~~~~~~~~~~ + +Default: ``"django.core.mail.backends.smtp.EmailBackend"`` + +The Python import path to the email backend class. If ``"BACKEND"`` is omitted, +Django's :ref:`topic-email-smtp-backend` is used. + +See :ref:`topic-email-backends` for a list of Django's built-in backends and +pointers to community-maintained options. + +.. setting:: MAILERS-OPTIONS + +``OPTIONS`` +~~~~~~~~~~~ + +Default: ``{}`` + +Configuration parameters to pass to the email backend. The available and +required options vary depending on the backend. + .. setting:: EMAIL_BACKEND ``EMAIL_BACKEND`` ----------------- -Default: ``'``:class:`django.core.mail.backends.smtp.EmailBackend`\ ``'`` +Default: ``'``:ref:`django.core.mail.backends.smtp.EmailBackend +`\ ``'`` The backend to use for sending emails. For the list of available backends see :ref:`topic-email-backends`. +.. deprecated:: 6.1 + + This setting is deprecated and will be removed in Django 7.0. Its + replacement is :setting:`BACKEND ` in the + :setting:`MAILERS` ``"default"`` configuration. See + :ref:`migrating-to-mailers`. + .. setting:: EMAIL_FILE_PATH ``EMAIL_FILE_PATH`` @@ -1412,6 +1491,13 @@ Default: Not defined The directory used by the :ref:`file email backend ` to store output files. +.. deprecated:: 6.1 + + This setting is deprecated and will be removed in Django 7.0. Its + replacement is ``"file_path"`` under :setting:`OPTIONS ` + in a :setting:`MAILERS` definition that uses the file email backend. See + :ref:`migrating-to-mailers`. + .. setting:: EMAIL_HOST ``EMAIL_HOST`` @@ -1423,6 +1509,13 @@ The host to use for sending email. See also :setting:`EMAIL_PORT`. +.. deprecated:: 6.1 + + This setting is deprecated and will be removed in Django 7.0. Its + replacement is ``"host"`` under :setting:`OPTIONS ` in a + :setting:`MAILERS` definition that uses the SMTP email backend. See + :ref:`migrating-to-mailers`. + .. setting:: EMAIL_HOST_PASSWORD ``EMAIL_HOST_PASSWORD`` @@ -1437,6 +1530,13 @@ Django won't attempt authentication. See also :setting:`EMAIL_HOST_USER`. +.. deprecated:: 6.1 + + This setting is deprecated and will be removed in Django 7.0. Its + replacement is ``"password"`` under :setting:`OPTIONS ` in + a :setting:`MAILERS` definition that uses the SMTP email backend. See + :ref:`migrating-to-mailers`. + .. setting:: EMAIL_HOST_USER ``EMAIL_HOST_USER`` @@ -1449,6 +1549,13 @@ If empty, Django won't attempt authentication. See also :setting:`EMAIL_HOST_PASSWORD`. +.. deprecated:: 6.1 + + This setting is deprecated and will be removed in Django 7.0. Its + replacement is ``"username"`` under :setting:`OPTIONS ` in + a :setting:`MAILERS` definition that uses the SMTP email backend. See + :ref:`migrating-to-mailers`. + .. setting:: EMAIL_PORT ``EMAIL_PORT`` @@ -1458,6 +1565,13 @@ Default: ``25`` Port to use for the SMTP server defined in :setting:`EMAIL_HOST`. +.. deprecated:: 6.1 + + This setting is deprecated and will be removed in Django 7.0. Its + replacement is ``"port"`` under :setting:`OPTIONS ` in a + :setting:`MAILERS` definition that uses the SMTP email backend. See + :ref:`migrating-to-mailers`. + .. setting:: EMAIL_SUBJECT_PREFIX ``EMAIL_SUBJECT_PREFIX`` @@ -1491,6 +1605,13 @@ This is used for explicit TLS connections, generally on port 587. If you are experiencing hanging connections, see the implicit TLS setting :setting:`EMAIL_USE_SSL`. +.. deprecated:: 6.1 + + This setting is deprecated and will be removed in Django 7.0. Its + replacement is ``"use_tls"`` under :setting:`OPTIONS ` in + a :setting:`MAILERS` definition that uses the SMTP email backend. See + :ref:`migrating-to-mailers`. + .. setting:: EMAIL_USE_SSL ``EMAIL_USE_SSL`` @@ -1506,6 +1627,13 @@ see the explicit TLS setting :setting:`EMAIL_USE_TLS`. Note that :setting:`EMAIL_USE_TLS`/:setting:`EMAIL_USE_SSL` are mutually exclusive, so only set one of those settings to ``True``. +.. deprecated:: 6.1 + + This setting is deprecated and will be removed in Django 7.0. Its + replacement is ``"use_ssl"`` under :setting:`OPTIONS ` in + a :setting:`MAILERS` definition that uses the SMTP email backend. See + :ref:`migrating-to-mailers`. + .. setting:: EMAIL_SSL_CERTFILE ``EMAIL_SSL_CERTFILE`` @@ -1527,11 +1655,18 @@ or by using OpenSSL's ``SSL_CERT_FILE`` or ``SSL_CERT_DIR`` environment variables to specify a custom certificate bundle (if modifying the system bundle is not possible or desired). -For more complex scenarios, the SMTP -:class:`~django.core.mail.backends.smtp.EmailBackend` can be subclassed to add -root certificates to its ``ssl_context`` using +For more complex scenarios, the :ref:`SMTP EmailBackend +` can be subclassed to add root certificates to its +``ssl_context`` using :meth:`python:ssl.SSLContext.load_verify_locations`. +.. deprecated:: 6.1 + + This setting is deprecated and will be removed in Django 7.0. Its + replacement is ``"ssl_certfile"`` under :setting:`OPTIONS + ` in a :setting:`MAILERS` definition that uses the SMTP + email backend. See :ref:`migrating-to-mailers`. + .. setting:: EMAIL_SSL_KEYFILE ``EMAIL_SSL_KEYFILE`` @@ -1549,6 +1684,13 @@ They're passed to the underlying SSL connection. Please refer to the documentation of Python's :meth:`python:ssl.SSLContext.wrap_socket` function for details on how the certificate chain file and private key file are handled. +.. deprecated:: 6.1 + + This setting is deprecated and will be removed in Django 7.0. Its + replacement is ``"ssl_keyfile"`` under :setting:`OPTIONS ` + in a :setting:`MAILERS` definition that uses the SMTP email backend. See + :ref:`migrating-to-mailers`. + .. setting:: EMAIL_TIMEOUT ``EMAIL_TIMEOUT`` @@ -1559,6 +1701,13 @@ Default: ``None`` Specifies a timeout in seconds for blocking operations like the connection attempt. +.. deprecated:: 6.1 + + This setting is deprecated and will be removed in Django 7.0. Its + replacement is ``"timeout"`` under :setting:`OPTIONS ` in + a :setting:`MAILERS` definition that uses the SMTP email backend. See + :ref:`migrating-to-mailers`. + .. setting:: FILE_UPLOAD_HANDLERS ``FILE_UPLOAD_HANDLERS`` diff --git a/docs/releases/1.7.txt b/docs/releases/1.7.txt index 999cad85185d..898479d78558 100644 --- a/docs/releases/1.7.txt +++ b/docs/releases/1.7.txt @@ -571,7 +571,7 @@ Email parameter for sending a multipart :mimetype:`text/plain` and :mimetype:`text/html` email. -* The SMTP :class:`~django.core.mail.backends.smtp.EmailBackend` now accepts a +* The :ref:`SMTP EmailBackend ` now accepts a ``timeout`` parameter. File Storage diff --git a/docs/releases/1.8.txt b/docs/releases/1.8.txt index feec1c74e0a6..b560f4c94a9c 100644 --- a/docs/releases/1.8.txt +++ b/docs/releases/1.8.txt @@ -304,7 +304,7 @@ Email authentication with the :setting:`EMAIL_SSL_CERTFILE` and :setting:`EMAIL_SSL_KEYFILE` settings. -* The SMTP :class:`~django.core.mail.backends.smtp.EmailBackend` now supports +* The :ref:`SMTP EmailBackend ` now supports setting the ``timeout`` parameter with the :setting:`EMAIL_TIMEOUT` setting. * :class:`~django.core.mail.EmailMessage` and ``EmailMultiAlternatives`` now diff --git a/docs/releases/4.2.txt b/docs/releases/4.2.txt index de6523691440..2a1f50c6155d 100644 --- a/docs/releases/4.2.txt +++ b/docs/releases/4.2.txt @@ -474,7 +474,7 @@ Miscellaneous * Support for ``PROJ`` < 5 is removed. -* :class:`~django.core.mail.backends.smtp.EmailBackend` now verifies a +* :ref:`SMTP EmailBackend ` now verifies a :attr:`hostname ` and :attr:`certificates `. If you need the previous behavior that is less restrictive and not recommended, subclass diff --git a/docs/releases/6.1.txt b/docs/releases/6.1.txt index 1094108165b5..dd2a193589e3 100644 --- a/docs/releases/6.1.txt +++ b/docs/releases/6.1.txt @@ -86,6 +86,43 @@ Python-level options, as Django does not need to load objects before deleting them. As a consequence, the :attr:`~django.db.models.DB_CASCADE` option does not trigger the ``pre_delete`` or ``post_delete`` signals. +Mailers +------- + +The new :setting:`MAILERS` setting supports configuring multiple email backends +with different options, similar to existing mechanisms for :setting:`CACHES`, +:setting:`DATABASES`, :setting:`STORAGES`, and :setting:`TASKS`:: + + MAILERS = { + "default": { + "BACKEND": "django.core.mail.backends.smtp.EmailBackend", + "OPTIONS": {"host": "smtp.example.com", "use_tls": True}, + }, + "marketing": { + "BACKEND": "example_ses.EmailBackend", + "OPTIONS": {"region": "us-east-1"}, + }, + } + +You can select a mailer with the new ``using`` argument to :ref:`email sending +` functions, or obtain an email backend instance with +:data:`mail.mailers[alias] `. See +:doc:`/topics/email` for more details. + +:setting:`MAILERS` is not yet enabled by default in existing projects. It will +replace :setting:`EMAIL_BACKEND` and related ``EMAIL_*`` settings in Django +7.0. Until then, the older settings will continue to work but will issue +deprecation warnings: see the list of :ref:`email deprecations +` below. + +You can opt into the new feature at any time before Django 7.0; see +:ref:`migrating-to-mailers`. To ease the transition, +:data:`mail.mailers["default"] ` works with +either :setting:`MAILERS` or the deprecated :setting:`EMAIL_BACKEND` setting +defined. The deprecated :func:`~django.core.mail.get_connection` function will +also return an instance of the default mailer when :setting:`MAILERS` is +defined. + Minor features -------------- @@ -561,13 +598,23 @@ Dropped support for MariaDB < 10.11 Upstream support for MariaDB 10.6 ends in July 2026, and MariaDB 10.7-10.10 are short-term maintenance releases. Django 6.1 supports MariaDB 10.11 and higher. -Miscellaneous -------------- +Email +----- * Providing ``fail_silently=True``, ``auth_user``, or ``auth_password`` to mail sending functions (such as :func:`~django.core.mail.send_mail`) while also providing a ``connection`` now raises a ``TypeError``. +* The undocumented ``EmailMessage.get_connection()`` method is no longer used. + Defining it in a subclass or trying to call it now causes an error. + +* :meth:`.EmailMessage.send` no longer sets the ``connection`` property on the + ``EmailMessage``. (This behavior was never documented. The ``send()`` method + will still *use* a ``connection`` that is set on the message before sending.) + +Miscellaneous +------------- + * :class:`~django.contrib.contenttypes.fields.GenericForeignKey` now uses a separate descriptor class: the private ``GenericForeignKeyDescriptor``. @@ -592,6 +639,17 @@ Miscellaneous ``SimpleUploadedFile`` retain the previous behavior of evaluating based on the ``name`` attribute. +* The undocumented ``connection()`` method of :class:`.log.AdminEmailHandler` + has been removed and is no longer called. Subclasses overriding + :meth:`.AdminEmailHandler.send_mail` should avoid calling ``connection()``. + See :ref:`migrating-to-mailers-get-connection` if specific connection + configuration is needed. + +* The internal implementation of :class:`.BrokenLinkEmailsMiddleware` has been + updated for mailers. If you have subclassed it to customize email sending + behavior (as suggested in :doc:`/howto/error-reporting`), you may want to + review the updates in the base :class:`.BrokenLinkEmailsMiddleware` class. + * ``django.http.multipartparser.MultiPartParser`` now uses strict Base64 validation when decoding encoded request data. Previously, invalid data could be silently ignored or result in empty values. Invalid data now raises @@ -609,6 +667,54 @@ Miscellaneous Features deprecated in 6.1 ========================== +.. _mailers-deprecations: + +Email +----- + +* The :setting:`EMAIL_BACKEND`, :setting:`EMAIL_FILE_PATH`, + :setting:`EMAIL_HOST`, :setting:`EMAIL_HOST_PASSWORD`, + :setting:`EMAIL_HOST_USER`, :setting:`EMAIL_PORT`, + :setting:`EMAIL_USE_TLS`, :setting:`EMAIL_USE_SSL`, + :setting:`EMAIL_SSL_CERTFILE`, :setting:`EMAIL_SSL_KEYFILE`, and + :setting:`EMAIL_TIMEOUT` settings are deprecated. Replace them with an + :setting:`MAILERS` configuration dictionary as described in + :ref:`migrating-to-mailers`. + +* :func:`.mail.get_connection` is deprecated. See + :ref:`migrating-to-mailers-get-connection` for replacement options. + +* The ``connection`` argument to :func:`.send_mail`, :func:`.send_mass_mail`, + :func:`.mail_admins`, :func:`.mail_managers`, and :class:`.EmailMessage` is + deprecated. The ``EmailMessage.connection`` attribute is also deprecated. + Switch to the ``using`` argument with a :setting:`MAILERS` alias. + +* The ``fail_silently`` argument to :func:`.send_mail`, + :func:`.send_mass_mail`, :func:`.mail_admins`, :func:`.mail_managers`, and + :meth:`.EmailMessage.send` is deprecated. See + :ref:`migrating-to-mailers-fail-silently` for alternatives. + +* The ``auth_user`` and ``auth_password`` arguments to :func:`.send_mail` and + :func:`.send_mass_mail` are deprecated. Replace them with ``"username"`` and + ``"password"`` :setting:`OPTIONS ` in :setting:`MAILERS`. + See :ref:`migrating-to-mailers-auth`. + +* Directly constructing and using instances of the + :ref:`smtp.EmailBackend ` class is deprecated. Use + :data:`.mail.mailers` to obtain email backend instances. + +* The ``BaseEmailBackend.__init__()`` constructor no longer silently ignores + unknown keyword arguments. Custom email backend subclasses should ensure they + have consumed all supported ``**kwargs`` before forwarding the remainder to + superclass init. The base class now issues a deprecation warning for unknown + arguments, and it will treat them as errors starting in Django 7.0. See + :ref:`migrating-to-mailers-email-backends`. + +* Support for ``fail_silently`` in the ``BaseEmailBackend`` is deprecated. + A custom email backend that wants to support ``fail_silently`` should manage + its own local attribute, not pass it to the base backend constructor. See + :ref:`migrating-to-mailers-email-backends`. + Miscellaneous ------------- @@ -649,6 +755,9 @@ Miscellaneous :ref:`expressions `, is deprecated in favor of the newly introduced ``quote_name()`` method. +* The ``email_backend`` argument of :class:`.log.AdminEmailHandler` is + deprecated in favor of the newly introduced ``using`` argument. + * The ``BitAnd``, ``BitOr``, and ``BitXor`` classes in ``django.contrib.postgres.aggregates`` are deprecated in favor of the generally available :class:`~django.db.models.BitAnd`, diff --git a/docs/topics/email.txt b/docs/topics/email.txt index 27eee13d9f7f..ff5394e11474 100644 --- a/docs/topics/email.txt +++ b/docs/topics/email.txt @@ -67,30 +67,133 @@ following approach:: Configuring email ================= -By default, Django tries to send email by connecting to an `SMTP`_ server -running on localhost, with no authentication. If that doesn't match your -production environment, trying to send email will raise an error like -``connection refused`` or ``authentication failed``, or cause a connection -timeout. You will need to adjust Django's email settings to reflect your -environment. - -Django abstracts the email sending process into an :ref:`email backend -` class. The :setting:`EMAIL_BACKEND` setting controls -which backend Django uses. - -The default email backend is Django's :ref:`topic-email-smtp-backend`, which -connects to an SMTP server using the host and port specified in the -:setting:`EMAIL_HOST` and :setting:`EMAIL_PORT` settings. The -:setting:`EMAIL_HOST_USER` and :setting:`EMAIL_HOST_PASSWORD` settings, if -set, are used to authenticate to the SMTP server, and the -:setting:`EMAIL_USE_TLS` and :setting:`EMAIL_USE_SSL` settings control whether -a secure connection is used. - -SMTP is supported by nearly all email service providers (ESPs) and many hosting -environments. But there are other options: many commercial ESPs offer HTTP APIs +New Django projects are not configured to send email by default. Instead, email +is printed to ``stdout`` as a development aid (for projects created with +:djadmin:`startproject`) or results in a ``MailerDoesNotExist`` error (when the +:setting:`MAILERS` setting isn't defined). + +To enable sending real email, you will need to tell Django how to send it by +creating or editing the :setting:`MAILERS` setting. For example, to send +through an SMTP server running on the local machine:: + + MAILERS = { + "default": { + "BACKEND": "django.core.mail.backends.smtp.EmailBackend", + "OPTIONS": { + "host": "localhost", + }, + }, + } + +`SMTP`_ is supported by nearly all email service mailers and many hosting +environments. But there are other options: many commercial services offer APIs with additional sending features, and during development or testing you might -not want to send email at all. :ref:`topic-email-backends` lists several -possibilities. +not want to send email at all. + +Django abstracts the email sending process into an "email backend" class. +:ref:`topic-email-backends` lists the email backends that come with Django: the +:ref:`SMTP backend ` for production use and several +others meant for development and testing. It also covers :ref:`third-party +packages ` and :ref:`custom backends +` if Django's built-in backends don't meet your +needs. + +Django's test runner automatically :ref:`overrides the email configuration +` during testing. It substitutes the :ref:`memory backend +` for each defined mailer, preventing email from +being sent and giving test cases access to the messages. + +.. versionchanged:: 6.1 + + In earlier releases, Django defaulted to sending email through an SMTP + server running on localhost (using the now-deprecated + :setting:`EMAIL_BACKEND` and related settings). + +.. deprecated:: 6.1 + + Until Django 7.0, if the :setting:`MAILERS` setting is not defined then the + earlier behavior still applies: Django will default to using an SMTP server + on localhost (but will issue deprecation warnings). Starting in Django 7.0, + attempts to send email without :setting:`MAILERS` defined will result in a + ``MailerDoesNotExist`` error. + + Existing projects can opt into the new behavior early by adding + :setting:`MAILERS` to ``settings.py``. See :ref:`migrating-to-mailers`. + +Multiple mailers +---------------- + +Sometimes different types of email need to be sent in different ways: internal +vs. external email, different servers for users in different regions, different +services for transactional notifications and bulk marketing email, etc. + +The :setting:`MAILERS` setting can define multiple mail configurations. For +example:: + + import os + + MAILERS = { + "default": { + "BACKEND": "django.core.mail.backends.smtp.EmailBackend", + "OPTIONS": { + "host": "smtp.example.net", + "use_tls": True, + "username": os.environ["EMAIL_ACCOUNT_ID"], + "password": os.environ["EMAIL_API_KEY"], + }, + }, + "notifications": { + "BACKEND": "example.third.party.EmailBackend", + "OPTIONS": { + "api_key": os.environ["THIRD_PARTY_API_KEY"], + "region": "eu", + }, + }, + "admin": { + "BACKEND": "django.core.mail.backends.smtp.EmailBackend", + "OPTIONS": { + "host": "localhost", + }, + }, + } + +This defines three mailer configurations: + +* ``"default"`` sends through an SMTP server at ``smtp.example.net`` with a TLS + secured connection. It reads an account id and API key from environment + variables and uses them as the SMTP authentication username and password. + (Many SMTP relay services use some variation of this authentication scheme. + Check your provider's documentation for specific options to use.) + +* ``"notifications"`` sends through a hypothetical commercial email service, + using a third-party EmailBackend that connects directly to their API. + (See :ref:`topic-third-party-email-backends` for pointers on locating real, + community maintained email backend packages.) + +* ``"admin"`` sends through an SMTP server running on ``localhost``, with no + other options required. + +With this configuration, you can provide the ``using`` argument to Django's +:ref:`email sending functions ` to specify a particular +mailer configuration:: + + from django.core.mail import send_mail + + send_mail( + "Account activated", + "Congratulations, you're all ready to use our Django app!", + "from@example.com", + ["user@example.com"], + using="notifications", + ) + +If ``using`` is not specified, Django uses the mailer defined for the +``"default"`` mailer configuration. + +With reusable apps or Django features that send email for you, there may be an +option to use a specific mailer. For example, Django's logging +:class:`~django.utils.log.AdminEmailHandler` allows specifying the mailer +configuration in its ``using`` option. .. _SMTP: https://en.wikipedia.org/wiki/Simple_Mail_Transfer_Protocol @@ -147,6 +250,11 @@ if used. be a :mimetype:`multipart/alternative` email with ``message`` as the :mimetype:`text/plain` content type and ``html_message`` as the :mimetype:`text/html` content type. +* ``using``: An optional :setting:`MAILERS` alias to use to send the mail. If + unspecified, the default mailer configuration will be used. + +``fail_silently``, ``auth_user``, ``auth_password``, and ``connection`` are not +allowed with the ``using`` argument. The return value will be the number of successfully delivered messages (which can be ``0`` or ``1`` since it can only send one message). @@ -156,8 +264,17 @@ can be ``0`` or ``1`` since it can only send one message). Passing ``fail_silently`` and later parameters as positional arguments is deprecated. +.. deprecated:: 6.1 + + The ``fail_silently``, ``auth_user``, ``auth_password``, and ``connection`` + arguments are deprecated. In most cases they can be replaced by ``using`` + with an appropriate :setting:`MAILERS` configuration. See + :ref:`migrating-to-mailers`. + .. versionchanged:: 6.1 + The ``using`` argument was added. + Older versions ignored ``fail_silently=True``, ``auth_user``, and ``auth_password`` when a ``connection`` was also provided. This now raises a ``TypeError``. @@ -165,7 +282,7 @@ can be ``0`` or ``1`` since it can only send one message). ``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, using=None) ``django.core.mail.send_mass_mail()`` is intended to handle mass emailing. @@ -175,7 +292,11 @@ can be ``0`` or ``1`` since it can only send one message). ``fail_silently``, ``auth_user``, ``auth_password`` and ``connection`` have the same functions as in :func:`send_mail`. They must be given as keyword arguments -if used. +if used, and are not allowed with the ``using`` argument. + +The keyword argument ``using`` is an optional :setting:`MAILERS` alias to use +to send the mail. If unspecified, the default mailer configuration will be +used. Each separate element of ``datatuple`` results in a separate email message. As in :func:`send_mail`, recipients in the same ``recipient_list`` will all see @@ -206,9 +327,17 @@ The return value will be the number of successfully delivered messages. Passing ``fail_silently`` and later parameters as positional arguments is deprecated. +.. deprecated:: 6.1 + + The ``fail_silently``, ``auth_user``, ``auth_password``, and ``connection`` + arguments are deprecated. In most cases they can be replaced by ``using`` + with an appropriate :setting:`MAILERS` configuration. See + :ref:`migrating-to-mailers`. .. versionchanged:: 6.1 + The ``using`` argument was added. + Older versions ignored ``fail_silently=True``, ``auth_user``, and ``auth_password`` when a ``connection`` was also provided. This now raises a ``TypeError``. @@ -245,7 +374,7 @@ field:: ``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, using=None) ``django.core.mail.mail_admins()`` is a shortcut for sending an email to the site admins, as defined in the :setting:`ADMINS` setting. @@ -263,32 +392,56 @@ 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. +The keyword argument ``using`` is an optional :setting:`MAILERS` alias to use +to send the mail. If unspecified, the default mailer configuration will be +used. + .. deprecated:: 6.0 Passing ``fail_silently`` and later parameters as positional arguments is deprecated. +.. deprecated:: 6.1 + + The ``fail_silently`` and ``connection`` arguments are deprecated. In most + cases they can be replaced by ``using`` with an appropriate + :setting:`MAILERS` configuration. See :ref:`migrating-to-mailers`. + .. versionchanged:: 6.1 + The ``using`` argument was added. + Older versions ignored ``fail_silently=True`` when a ``connection`` was also provided. This now raises a ``TypeError``. ``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, using=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. +The keyword argument ``using`` is an optional :setting:`MAILERS` alias to use +to send the mail. If unspecified, the default mailer configuration will be +used. + .. deprecated:: 6.0 Passing ``fail_silently`` and later parameters as positional arguments is deprecated. +.. deprecated:: 6.1 + + The ``fail_silently`` and ``connection`` arguments are deprecated. In most + cases they can be replaced by ``using`` with an appropriate + :setting:`MAILERS` configuration. See :ref:`migrating-to-mailers`. + .. versionchanged:: 6.1 + The ``using`` argument was added. + Older versions ignored ``fail_silently=True`` when a ``connection`` was also provided. This now raises a ``TypeError``. @@ -373,12 +526,16 @@ email backend API :ref:`provides an alternative an email message. The corresponding attribute is ``extra_headers``. * ``connection``: An :ref:`email backend ` instance. - Use this parameter if you are sending the :class:`!EmailMessage` via - :meth:`send` and you want to use the same connection for multiple - messages. If omitted, a new connection is created when :meth:`send` is - called. This parameter is ignored when using + This parameter is ignored when using :ref:`send_messages() `. + .. deprecated:: 6.1 + + The ``connection`` argument is deprecated. Instead, define a + :setting:`MAILERS` configuration with the desired connection options, + and then call :meth:`EmailMessage.send(using="...") ` with that + configuration's alias. See :ref:`migrating-to-mailers`. + .. deprecated:: 6.0 Passing all except the first four parameters as positional arguments is @@ -400,21 +557,37 @@ email backend API :ref:`provides an alternative The class has the following methods: - .. method:: send(fail_silently=False) + .. method:: send(fail_silently=False, *, using=None) + + Sends the message. Returns ``1`` if the message was sent successfully, + otherwise ``0``. (An empty list of recipients returns ``0`` -- it will + not raise an exception.) + + The optional ``using`` keyword argument specifies a :setting:`MAILERS` + alias to use to send the mail. If not given, the default mailer + configuration will be used. - Sends the message. If a connection was specified when the email was - constructed, that connection will be used. Otherwise, an instance of - the default backend will be instantiated and used. If the keyword - argument ``fail_silently`` is ``True``, exceptions raised while sending - the message will be quashed. An empty list of recipients will not raise - an exception. It will return ``1`` if the message was sent - successfully, otherwise ``0``. + If a deprecated connection was specified when the email was + constructed, that connection will be used. Providing both a connection + and ``using`` will raise an error. + + If the deprecated keyword argument ``fail_silently`` is ``True``, + certain backend-dependent exceptions while sending the message will be + ignored. Providing both ``fail_silently`` and ``using`` will raise an + error. .. versionchanged:: 6.1 + The ``using`` argument was added. + Older versions ignored ``fail_silently=True`` when a ``connection`` was also provided. This now raises a ``TypeError``. + .. deprecated:: 6.1 + + The ``fail_silently`` argument is deprecated. See + :ref:`migrating-to-mailers-fail-silently` for alternatives. + .. method:: message(*, policy=email.policy.default) Constructs and returns a Python :class:`email.message.EmailMessage` @@ -425,10 +598,9 @@ email backend API :ref:`provides an alternative an :mod:`email.policy.Policy ` object. Defaults to :data:`email.policy.default`. In certain cases you may want to use :data:`~email.policy.SMTP`, :data:`~email.policy.SMTPUTF8` or a custom - policy. For example, - :class:`django.core.mail.backends.smtp.EmailBackend` uses the - :data:`~email.policy.SMTP` policy to ensure ``\r\n`` line endings as - required by the SMTP protocol. + policy. For example, the :ref:`SMTP email backend + ` uses the :data:`~email.policy.SMTP` policy + to ensure ``\r\n`` line endings as required by the SMTP protocol. If you ever need to extend Django's :class:`EmailMessage` class, you'll probably want to override this method to put the content you @@ -690,11 +862,10 @@ There are two ways to tell an email backend to reuse a connection. Both require an email backend instance obtained via :func:`get_connection`, which is documented in :ref:`topic-email-backends`. -The first approach is to obtain an email backend instance from -:func:`get_connection` and use its ``send_messages()`` method. This takes a -list of :class:`EmailMessage` (or subclass) instances, and sends them all using -that single connection. As a consequence, any :class:`connection -` set on an individual message is ignored. +The first approach is to obtain an email backend instance from :data:`mailers` +and use its ``send_messages()`` method. This takes a list of +:class:`EmailMessage` (or subclass) instances, and sends them all using that +single connection. For example, if you have a function called ``get_notification_emails()`` that returns a list of :class:`EmailMessage` objects representing some periodic @@ -703,12 +874,15 @@ email you wish to send out, you could send these emails using a single call to from django.core import mail - connection = mail.get_connection() # Use default email connection - messages = get_notification_emails() - connection.send_messages(messages) + email_list = get_notification_emails() + # Use the default mailer. You could substitute + # mail.mailers["alias"] for a specific mailer. + backend = mail.mailers.default + backend.send_messages(email_list) In this example, the call to ``send_messages()`` opens a connection on the backend, sends the list of messages, and then closes the connection again. +(This is how :func:`send_mass_mail` is implemented.) The second approach is to use the ``open()`` and ``close()`` methods on the email backend to manually control the connection. ``send_messages()`` will not @@ -717,32 +891,26 @@ manually open the connection, you can control when it is closed. For example:: from django.core import mail - connection = mail.get_connection() + # Use the "notifications" mailer configuration. + backend = mail.mailers["notifications"] # Manually open the connection. - connection.open() - - # Construct an email message that will use the connection. - email1 = mail.EmailMessage( - "Hello", - "Body goes here", - "from@example.com", - ["to1@example.com"], - connection=connection, - ) - # Send the email through its connection. The connection was already open, - # so send() leaves it open after sending. - email1.send() + backend.open() - # Construct two more email messages. (Passing None as the third argument + # Construct an email message. (Passing None as the third argument # uses settings.DEFAULT_FROM_EMAIL as the "From:" address.) + email1 = mail.EmailMessage("Hi", "Message", None, ["to1@example.com"]) + # Send the email. The connection was already open, so send_messages() + # leaves it open after sending. + backend.send_messages([email1]) + + # Construct and send two more messages. The connection is still open. email2 = mail.EmailMessage("Hi", "Message", None, ["to2@example.com"]) email3 = mail.EmailMessage("Hi", "Message", None, ["to3@example.com"]) - # Send the two messages. The connection is still open. - connection.send_messages([email2, email3]) + backend.send_messages([email2, email3]) # Because we opened it, we need to manually close the connection. - connection.close() + backend.close() When you manually open a backend's connection, you are responsible for ensuring it gets closed. The example above actually has a bug: if an exception occurs @@ -757,21 +925,16 @@ on errors:: from django.core import mail - with mail.get_connection() as connection: + # Use mail.mailers[...] as a context manager. + with mail.mailers["notifications"] as backend: # The backend connection is automatically opened inside the context. - email1 = mail.EmailMessage( - "Hello", - "Body goes here", - "from@example.com", - ["to1@example.com"], - connection=connection, - ) - email1.send() + email1 = mail.EmailMessage("Hi", "Message", None, ["to1@example.com"]) + backend.send_messages([email1]) # The connection is still open, and is reused for the second send. email2 = mail.EmailMessage("Hi", "Message", None, ["to2@example.com"]) email3 = mail.EmailMessage("Hi", "Message", None, ["to3@example.com"]) - connection.send_messages([email2, email3]) + backend.send_messages([email2, email3]) # After exiting the context (either normally or because of an error), # the backend connection is automatically closed. @@ -810,66 +973,187 @@ It can also be used as a context manager, which will automatically call SMTP backend ------------ -.. class:: backends.smtp.EmailBackend(host=None, port=None, username=None, password=None, use_tls=None, fail_silently=False, use_ssl=None, timeout=None, ssl_keyfile=None, ssl_certfile=None, **kwargs) - - This is the default backend. Email will be sent through a SMTP server. - - The value for each argument is retrieved from the matching setting if the - argument is ``None``: - - * ``host``: :setting:`EMAIL_HOST` - * ``port``: :setting:`EMAIL_PORT` - * ``username``: :setting:`EMAIL_HOST_USER` - * ``password``: :setting:`EMAIL_HOST_PASSWORD` - * ``use_tls``: :setting:`EMAIL_USE_TLS` - * ``use_ssl``: :setting:`EMAIL_USE_SSL` - * ``timeout``: :setting:`EMAIL_TIMEOUT` - * ``ssl_keyfile``: :setting:`EMAIL_SSL_KEYFILE` - * ``ssl_certfile``: :setting:`EMAIL_SSL_CERTFILE` - - The SMTP backend is the default configuration inherited by Django. If you - want to specify it explicitly, put the following in your settings:: - - EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" - - If unspecified, the default ``timeout`` will be the one provided by - :func:`socket.getdefaulttimeout`, which defaults to ``None`` (no timeout). +The SMTP email backend connects to an SMTP server to send email. To use it, set +:setting:`BACKEND ` to +``"django.core.mail.backends.smtp.EmailBackend"``. + +The SMTP backend supports these :setting:`OPTIONS `: + +* ``"host"`` (required): the SMTP server hostname or IP address. + +* ``"port"``: the port number to connect to on the SMTP host. If omitted, + uses the standard port for the connection protocol depending on the + ``"use_tls"`` and ``"use_ssl"`` options: ``587`` for TLS, ``465`` for SSL, + or ``25`` for an unsecured connection. + +* ``"username"`` and ``"password"``: set these if your server requires SMTP + authentication ("SMTP AUTH" credentials, sometimes called SMTP login). + + Although the username is often an email address, it should not be confused + with default "From:" addresses. Those are defined by the + :setting:`DEFAULT_FROM_EMAIL` and :setting:`SERVER_EMAIL` settings. + +* ``"use_tls"`` or ``"use_ssl"``: set one of these options to ``True`` to + connect to the SMTP server using a secure protocol -- ``"use_tls"`` for + explicit TLS or ``"use_ssl"`` for SSL (implicit TLS). + +* ``"ssl_certfile"`` and ``"ssl_keyfile"``: if the SMTP server's SSL/TLS + connection requires client certificate authentication, use these options to + specify the paths to a PEM-formatted certificate chain file and private key + file. (The key file can be omitted if the certificate file includes the + private key.) + + These options are not intended for use with a private certificate authority + or self-signed SMTP server certificate. See + :ref:`topic-email-smtp-private-ca` below. + + Note that these options don't result in checking certificate validity. They + are passed to the underlying SSL connection. Refer to the documentation of + Python's :meth:`ssl.SSLContext.wrap_socket` method for details on how the + certificate chain file and private key file are handled. + +* ``"timeout"``: the timeout (in seconds) for connecting to the SMTP server and + other blocking operations. If not specified, the value is obtained from + :func:`socket.getdefaulttimeout`, which defaults to no timeout (``None``) + meaning SMTP operations can block indefinitely. + +* ``"fail_silently"``: set to ``True`` to ignore certain errors while sending + a message. All :exc:`OSError`\s are ignored while opening the SMTP + connection, and :exc:`smtplib.SMTPException` errors are ignored while + communicating with the server. This will suppress both transient network + glitches and also serious configuration problems. However, it does not ignore + *all* errors, and problems with serializing the message will not fail + silently. (This option is available for backward compatibility but is not + recommended for typical use.) + +Example:: + + MAILERS = { + "default": { + "BACKEND": "django.core.mail.backends.smtp.EmailBackend", + "OPTIONS": { + "host": "smtp.example.net", + "use_tls": True, + "username": "my-app", + "password": os.environ["MY_APP_SMTP_PASSWORD"], + "timeout": 10, + }, + }, + } + +.. deprecated:: 6.1 + + When the :setting:`MAILERS` setting is not defined, Django uses the SMTP + backend as the default mailer (the default :setting:`EMAIL_BACKEND`), + connecting to localhost on port 25. This behavior will be removed in Django + 7.0, which will not have a default email mailer. + + When the SMTP backend is used without :setting:`MAILERS` defined, + the options listed above are obtained from the deprecated + :setting:`EMAIL_HOST`, :setting:`EMAIL_PORT`, :setting:`EMAIL_HOST_USER`, + :setting:`EMAIL_HOST_PASSWORD`, :setting:`EMAIL_USE_TLS`, + :setting:`EMAIL_USE_SSL`, :setting:`EMAIL_SSL_KEYFILE`, + :setting:`EMAIL_SSL_CERTFILE`, and :setting:`EMAIL_TIMEOUT` settings, + respectively. (There is no setting equivalent to the ``"fail_silently"`` + option.) + +.. RemovedInDjango70Warning: class documentation for smtp.EmailBackend. + +.. class:: backends.smtp.EmailBackend + + Directly instantiating an ``EmailBackend`` class is not recommended. Use + :data:`mailers` to obtain a backend instance. + + When constructed directly, the SMTP ``EmailBackend`` class accepts the + options listed above as keyword arguments. Default values come from the + corresponding, deprecated ``EMAIL_*`` settings. ``host`` is not required + and defaults to ``"localhost"``, and ``port`` defaults to ``25`` even if + ``use_tls`` or ``use_ssl`` is True. + + When the :setting:`MAILERS` setting is defined, attempting to directly + create an SMTP ``EmailBackend`` will raise an ``AttributeError``. + + .. deprecated:: 6.1 + + Directly constructing an instance of an ``EmailBackend`` class will be + unsupported in Django 7.0. Undocumented use will result in different + default argument handling compared to earlier releases. + +.. _topic-email-smtp-private-ca: + +Private and self-signed SMTP server certificates +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If the SMTP server uses an SSL certificate from a private certificate authority +(CA), the CA's root certificate should be added to the system CA bundle on the +client (where Django is running). Likewise, if the server uses a self-signed +certificate, it should be added to the client's system CA bundle so it can be +trusted. (The SMTP backend's ``"ssl_certfile"`` option cannot be used for CA +roots or self-signed certificates.) + +Follow platform-specific instructions for adding to the system CA bundle. If +modifying the system bundle is not possible or desired, an alternative is using +OpenSSL's ``SSL_CERT_FILE`` or ``SSL_CERT_DIR`` environment variables to +specify a custom certificate bundle. + +For more complex scenarios, the SMTP backend can be subclassed to add root +certificates to its ``ssl_context`` using +:meth:`ssl.SSLContext.load_verify_locations`. .. _topic-email-console-backend: Console backend --------------- -Instead of sending out real emails the console backend just writes the -emails that would be sent to the standard output. By default, the console -backend writes to ``stdout``. You can use a different stream-like object by -providing the ``stream`` keyword argument when constructing the connection. +Instead of sending out real emails, the console backend writes the emails that +would be sent to the standard output. To use it, set :setting:`BACKEND +` to ``"django.core.mail.backends.console.EmailBackend"``. + +The console backend supports these :setting:`OPTIONS `: -To specify this backend, put the following in your settings:: +* ``"stream"``: a stream-like object to write to. Defaults to ``stdout``. - EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" +* ``"fail_silently"``: set to ``True`` to ignore *all* errors while writing the + message to the stream, including errors serializing the message. (This option + is available for backward compatibility but is not recommended.) This backend is not intended for use in production -- it is provided as a convenience that can be used during development. +.. versionchanged:: 6.1 + + The settings file created by :djadmin:`startproject` now sets up + :setting:`MAILERS` with the console backend as the default mailer. + .. _topic-email-file-backend: File backend ------------ The file backend writes emails to a file. A new file is created for each new -session that is opened on this backend. The directory to which the files are -written is either taken from the :setting:`EMAIL_FILE_PATH` setting or from the -``file_path`` keyword when creating a connection with :func:`get_connection`. +session that is opened on this backend. To use it, set :setting:`BACKEND +` to ``"django.core.mail.backends.filebased.EmailBackend"``. -To specify this backend, put the following in your settings:: +The file backend supports these :setting:`OPTIONS `: - EMAIL_BACKEND = "django.core.mail.backends.filebased.EmailBackend" - EMAIL_FILE_PATH = "/tmp/app-messages" # change this to a proper location +* ``"file_path"`` (required): the directory to which the files are written. Can + be a string or a :class:`pathlib.Path` object. If the directory does not + exist, the file backend will attempt to create it. + +* ``"fail_silently"``: set to ``True`` to ignore *all* errors while writing the + message to the file -- including errors serializing the message -- but *not* + errors related to ensuring the file path directory exists. (This option is + available for backward compatibility but is not recommended.) This backend is not intended for use in production -- it is provided as a convenience that can be used during development. +.. deprecated:: 6.1 + + When the file backend is used without the :setting:`MAILERS` setting + defined, it will get its ``file_path`` option from the + :setting:`EMAIL_FILE_PATH` setting. + .. _topic-email-memory-backend: In-memory backend @@ -878,27 +1162,33 @@ In-memory backend The ``'locmem'`` backend stores messages in a special attribute of the ``django.core.mail`` module. The ``outbox`` attribute is created when the first message is sent. It's a list with an :class:`EmailMessage` instance for each -message that would be sent. +message that would be sent. Messages in the outbox are annotated with a +``sent_using`` attribute that identifies the :setting:`MAILERS` alias used to +send the message. -To specify this backend, put the following in your settings:: +To use the in-memory backend, set :setting:`BACKEND ` to +``"django.core.mail.backends.locmem.EmailBackend"``. It does not support any +:setting:`OPTIONS `. - EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend" +Django's test runner :ref:`automatically switches to this backend for testing +`. This backend is not intended for use in production -- it is provided as a convenience that can be used during development and testing. -Django's test runner :ref:`automatically uses this backend for testing -`. +.. versionchanged:: 6.1 + + The ``sent_using`` attribute was added to messages in the outbox. .. _topic-email-dummy-backend: Dummy backend ------------- -As the name suggests the dummy backend does nothing with your messages. To -specify this backend, put the following in your settings:: - - EMAIL_BACKEND = "django.core.mail.backends.dummy.EmailBackend" +As the name suggests the dummy backend does nothing with your messages. To use +it, set :setting:`BACKEND ` to +``"django.core.mail.backends.dummy.EmailBackend"``. It does not support any +:setting:`OPTIONS `. This backend is not intended for use in production -- it is provided as a convenience that can be used during development. @@ -932,9 +1222,10 @@ Third-party email backends are available that: Defining a custom email backend ------------------------------- -If you need to change how emails are sent you can write your own email -backend. The :setting:`EMAIL_BACKEND` setting in your settings file is then -the Python import path for your backend class. +If you need to change how emails are sent you can write your own email backend. +To use a custom backend, set :setting:`BACKEND ` to the Python +import path for your backend class and :setting:`OPTIONS ` to +any ``__init__()`` keyword arguments your backend supports. Custom email backends should subclass ``BaseEmailBackend`` that is located in the ``django.core.mail.backends.base`` module. A custom email backend must @@ -944,29 +1235,91 @@ delivered messages. If your backend has any concept of a persistent session or connection, you should also implement the ``open()`` and ``close()`` methods. Refer to ``smtp.EmailBackend`` for a reference implementation. +.. _topic-mailers: + Obtaining an instance of an email backend ----------------------------------------- -The :func:`get_connection` function in ``django.core.mail`` returns an -instance of the email backend that you can use. +The ``mailers`` factory in :mod:`!django.core.mail` returns instances of email +backends. -.. function:: get_connection(backend=None, *, fail_silently=False, **kwargs) +.. data:: mailers + + You can access the mailers configured in the :setting:`MAILERS` setting + through a dict-like object: ``django.core.mail.mailers``: + + .. code-block:: pycon + + >>> from django.core.mail import mailers + >>> mailers["notifications"] + + If the named key is not defined, a ``MailerDoesNotExist`` error will be + raised. Other configuration problems will raise an ``InvalidMailer`` error. + + .. versionadded:: 6.1 + +.. data:: mailers.default - 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. + As a shortcut, the default mailer can be accessed through + ``django.core.mail.mailers.default``: - 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. + .. code-block:: pycon - All other keyword arguments are passed directly to the constructor of the - email backend. + >>> from django.core.mail import mailers + >>> mailers.default + + This is equivalent to ``mailers["default"]``. If no default mailer has + been configured, a ``MailerDoesNotExist`` error will be raised. + + .. deprecated:: 6.1 + + If the :setting:`MAILERS` setting is not defined, ``mailers.default`` + will create an email backend instance from the deprecated + :setting:`EMAIL_BACKEND` and related settings. This supports backward + compatibility with Django 6.0 and earlier. + + This behavior (and those settings) will be removed in Django 7.0. + +.. function:: get_connection(backend=None, *, fail_silently=False, **kwargs) + + The deprecated :func:`!django.core.mail.get_connection` function creates + and returns an instance of an email backend. Its behavior depends on the + :setting:`MAILERS` setting and how the function is called. + + If the :setting:`MAILERS` setting *is* defined: + + * ``get_connection()`` with no arguments will return + :data:`mailers.default`. + * ``get_connection(...)`` called with only ``fail_silently`` or other + keyword arguments will create an instance of + ``MAILERS["default"]`` with any keywords added to the + default mailer's :setting:`OPTIONS `. + * ``get_connection(backend, ...)`` with a backend import path will raise + an error. + + If the :setting:`MAILERS` setting is *not* defined: + + * ``get_connection()`` with no arguments 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. + * If the keyword argument ``fail_silently`` is True, certain + backend-dependent exceptions during the email sending process will be + silently ignored. + * All other keyword arguments are passed directly to the constructor of the + email backend. .. deprecated:: 6.0 Passing ``fail_silently`` as positional argument is deprecated. + .. deprecated:: 6.1 + + :func:`!get_connection` is deprecated and will be removed in Django + 7.0. Switch to :data:`mailers[alias] `. See + :ref:`migrating-to-mailers-get-connection` for migration suggestions. + + Configuring email for development ================================= @@ -996,9 +1349,10 @@ anything. The :pypi:`aiosmtpd` package provides a way to accomplish this: This command will start a minimal SMTP server listening on port 8025 of localhost. This server prints to standard output all email headers and the -email body. You then only need to set the :setting:`EMAIL_HOST` and -:setting:`EMAIL_PORT` accordingly. For a more detailed discussion of SMTP -server options, see the documentation of the `aiosmtpd`_ module. +email body. You then only need to set an SMTP backend's ``"host"`` and +``"port"`` :setting:`OPTIONS ` accordingly. For a more +detailed discussion of SMTP server options, see the documentation of the +`aiosmtpd`_ module. .. _aiosmtpd: https://aiosmtpd.readthedocs.io/en/latest/ diff --git a/docs/topics/testing/tools.txt b/docs/topics/testing/tools.txt index 7c238702bfb2..b9e655adca93 100644 --- a/docs/topics/testing/tools.txt +++ b/docs/topics/testing/tools.txt @@ -2034,17 +2034,19 @@ creates. Email services ============== -If any of your Django views send email using :doc:`Django's email -functionality `, you probably don't want to send email each time -you run a test using that view. For this reason, Django's test runner -automatically redirects all Django-sent email to a dummy outbox. This lets you -test every aspect of sending email -- from the number of messages sent to the -contents of each message -- without actually sending the messages. - -The test runner accomplishes this by transparently replacing the normal -email backend with a testing backend. -(Don't worry -- this has no effect on any other email senders outside of -Django, such as your machine's mail server, if you're running one.) +If any of your code sends email using :doc:`Django's email functionality +`, you probably don't want to send email each time you run a +test that exercises that code. For this reason, Django's test runner +automatically redirects all Django-sent email to an in-memory outbox. This lets +you test every aspect of sending email -- from the number of messages sent to +the contents of each message -- without actually sending the messages. + +The test runner accomplishes this by transparently replacing the +:setting:`BACKEND ` for each configured :setting:`MAILERS` +alias with the ``'locmem'`` :ref:`memory email backend +`. (Don't worry -- this has no effect on any other +email senders outside of Django, such as your machine's mail server, if you're +running one.) .. currentmodule:: django.core.mail @@ -2082,6 +2084,13 @@ and contents:: # Verify that the subject of the first message is correct. self.assertEqual(mail.outbox[0].subject, "Subject here") + # Verify the message was sent with the "default" mailer. + self.assertEqual(mail.outbox[0].sent_using, "default") + +.. versionchanged:: 6.1 + + The ``sent_using`` attribute was added to messages in the outbox. + As noted :ref:`previously `, the test outbox is emptied at the start of every test in a Django ``*TestCase``. To empty the outbox manually, assign the empty list to ``mail.outbox``:: diff --git a/tests/admin_views/test_actions.py b/tests/admin_views/test_actions.py index fcc651867f61..32bd1266dcb1 100644 --- a/tests/admin_views/test_actions.py +++ b/tests/admin_views/test_actions.py @@ -28,7 +28,10 @@ ) -@override_settings(ROOT_URLCONF="admin_views.urls") +@override_settings( + ROOT_URLCONF="admin_views.urls", + MAILERS={"default": {"BACKEND": "django.core.mail.backends.locmem.EmailBackend"}}, +) class AdminActionsTest(TestCase): @classmethod def setUpTestData(cls): @@ -566,7 +569,10 @@ def test_model_admin_no_delete_permission_externalsubscriber(self): self.assertEqual(response.status_code, 403) -@override_settings(ROOT_URLCONF="admin_views.urls") +@override_settings( + ROOT_URLCONF="admin_views.urls", + MAILERS={"default": {"BACKEND": "django.core.mail.backends.locmem.EmailBackend"}}, +) class AdminDetailActionsTest(TestCase): @classmethod def setUpTestData(cls): diff --git a/tests/admin_views/tests.py b/tests/admin_views/tests.py index e61d3d3159ea..29516f573297 100644 --- a/tests/admin_views/tests.py +++ b/tests/admin_views/tests.py @@ -305,6 +305,9 @@ def assertContentBefore(self, response, text1, text2, failing_msg=None): ) +@override_settings( + MAILERS={"default": {"BACKEND": "django.core.mail.backends.locmem.EmailBackend"}} +) class AdminViewBasicTest(AdminViewBasicTestCase): def test_trailing_slash_required(self): """ @@ -2346,6 +2349,7 @@ def get_perm(Model, codename): }, } ], + MAILERS={"default": {"BACKEND": "django.core.mail.backends.locmem.EmailBackend"}}, ) class AdminViewPermissionsTest(TestCase): """Tests for Admin Views Permissions.""" diff --git a/tests/auth_tests/test_forms.py b/tests/auth_tests/test_forms.py index 167722c6171a..8af16696f0a3 100644 --- a/tests/auth_tests/test_forms.py +++ b/tests/auth_tests/test_forms.py @@ -1159,7 +1159,10 @@ def test_username_field_autocapitalize_none(self): ) -@override_settings(TEMPLATES=AUTH_TEMPLATES) +@override_settings( + TEMPLATES=AUTH_TEMPLATES, + MAILERS={"default": {"BACKEND": "django.core.mail.backends.locmem.EmailBackend"}}, +) class PasswordResetFormTest(TestDataMixin, TestCase): @classmethod def setUpClass(cls): @@ -1382,7 +1385,9 @@ def test_save_html_email_template_name(self): ) ) - @override_settings(EMAIL_BACKEND="mail.custombackend.FailingEmailBackend") + @override_settings( + MAILERS={"default": {"BACKEND": "mail.custombackend.FailingEmailBackend"}} + ) def test_save_send_email_exceptions_are_catched_and_logged(self): self.addCleanup(FailingEmailBackend.reset) user, username, email = self.create_dummy_user() diff --git a/tests/auth_tests/test_models.py b/tests/auth_tests/test_models.py index fd99b970d756..6155cc92f569 100644 --- a/tests/auth_tests/test_models.py +++ b/tests/auth_tests/test_models.py @@ -252,6 +252,11 @@ def test_custom_email(self): class AbstractUserTestCase(TestCase): + @override_settings( + MAILERS={ + "default": {"BACKEND": "django.core.mail.backends.locmem.EmailBackend"} + } + ) def test_email_user(self): # valid send_mail parameters kwargs = { diff --git a/tests/auth_tests/test_views.py b/tests/auth_tests/test_views.py index a3863b6233c0..9baa6ddb5fb6 100644 --- a/tests/auth_tests/test_views.py +++ b/tests/auth_tests/test_views.py @@ -61,6 +61,7 @@ def test_get_default_redirect_url_no_next_page(self): LANGUAGE_CODE="en", TEMPLATES=AUTH_TEMPLATES, ROOT_URLCONF="auth_tests.urls", + MAILERS={"default": {"BACKEND": "django.core.mail.backends.locmem.EmailBackend"}}, ) class AuthViewsTestCase(TestCase): """ diff --git a/tests/logging_tests/tests.py b/tests/logging_tests/tests.py index d4cdbe7dd97a..2e4717426ecf 100644 --- a/tests/logging_tests/tests.py +++ b/tests/logging_tests/tests.py @@ -4,17 +4,27 @@ from unittest import TestCase, mock from admin_scripts.tests import AdminScriptTestCase +from mail import ( + ignore_no_default_mailer_warning, + override_deprecated_email_settings, +) from mail.custombackend import FailingEmailBackend, OptionsCapturingBackend from django.conf import settings from django.core import mail -from django.core.exceptions import DisallowedHost, PermissionDenied, SuspiciousOperation +from django.core.exceptions import ( + DisallowedHost, + ImproperlyConfigured, + PermissionDenied, + SuspiciousOperation, +) from django.core.files.temp import NamedTemporaryFile from django.core.management import color from django.http import HttpResponse from django.http.multipartparser import MultiPartParserError from django.test import RequestFactory, SimpleTestCase, override_settings from django.test.utils import LoggingCaptureMixin +from django.utils.deprecation import RemovedInDjango70Warning from django.utils.log import ( DEFAULT_LOGGING, AdminEmailHandler, @@ -275,6 +285,9 @@ def _callback(record): self.assertEqual(collector, ["a record"]) +@override_settings( + MAILERS={"default": {"BACKEND": "django.core.mail.backends.locmem.EmailBackend"}} +) class AdminEmailHandlerTest(SimpleTestCase): logger = logging.getLogger("django") request_factory = RequestFactory() @@ -294,11 +307,13 @@ def make_log_record(self, url_path=None, *args, **kwargs): record.request = self.request_factory.get(url_path, *args, **kwargs) return record - @override_settings( + # RemovedInDjango70Warning. + @override_deprecated_email_settings( ADMINS=["admin@example.com"], EMAIL_BACKEND="mail.custombackend.FailingEmailBackend", ) def test_sends_using_fail_silently(self): + del settings.MAILERS self.addCleanup(FailingEmailBackend.reset) self.logger.error("All work and no play makes Jack a dull boy") self.assertIs(FailingEmailBackend.init_kwargs[0]["fail_silently"], True) @@ -396,17 +411,81 @@ def test_subject_accepts_newlines(self): self.assertNotIn("\r", mail.outbox[0].subject) self.assertEqual(mail.outbox[0].subject, expected_subject) + # RemovedInDjango70Warning. @override_settings(ADMINS=["admin@example.com"]) + @ignore_no_default_mailer_warning() def test_uses_custom_email_backend(self): + del settings.MAILERS self.addCleanup(OptionsCapturingBackend.reset) - handler = AdminEmailHandler( - email_backend="mail.custombackend.OptionsCapturingBackend" - ) + msg = "The 'email_backend' argument is deprecated. Use 'using' instead." + with self.assertWarnsMessage(RemovedInDjango70Warning, msg): + handler = AdminEmailHandler( + email_backend="mail.custombackend.OptionsCapturingBackend" + ) handler.emit(self.make_log_record("/")) self.assertEqual(len(mail.outbox), 0) self.assertIs(OptionsCapturingBackend.init_kwargs[0]["fail_silently"], True) self.assertEqual(len(OptionsCapturingBackend.sent_messages), 1) + @override_settings(ADMINS=["admin@example.com"]) + def test_sends_using_default_mailer(self): + handler = AdminEmailHandler() + handler.emit(self.make_log_record()) + self.assertEqual(len(mail.outbox), 1) + self.assertEqual(mail.outbox[0].sent_using, "default") + + @override_settings( + ADMINS=["admin@example.com"], + MAILERS={}, + ) + def test_no_error_when_email_not_configured(self): + handler = AdminEmailHandler() + handler.emit(self.make_log_record()) + self.assertEqual(len(mail.outbox), 0) + + @override_settings( + ADMINS=["admin@example.com"], + MAILERS={ + "custom": {"BACKEND": "django.core.mail.backends.locmem.EmailBackend"} + }, + ) + def test_using_arg(self): + handler = AdminEmailHandler(using="custom") + handler.emit(self.make_log_record()) + self.assertEqual(len(mail.outbox), 1) + self.assertEqual(mail.outbox[0].sent_using, "custom") + + # RemovedInDjango70Warning. + def test_using_conflicts_with_email_backend(self): + msg = "The 'email_backend' argument is not compatible with 'using'." + with self.assertRaisesMessage(ImproperlyConfigured, msg): + AdminEmailHandler( + email_backend="logging_tests.logconfig.MyEmailBackend", using="custom" + ) + + # RemovedInDjango70Warning. + @override_settings(MAILERS={}) + def test_email_backend_not_valid_when_mailers_defined(self): + msg = ( + "The 'email_backend' argument is not valid when " + "settings.MAILERS is defined." + ) + with self.assertRaisesMessage(ImproperlyConfigured, msg): + AdminEmailHandler(email_backend="logging_tests.logconfig.MyEmailBackend") + + # RemovedInDjango70Warning. + def test_error_when_subclass_defines_undocumented_connection_method(self): + + class CustomAdminEmailHandler(AdminEmailHandler): + def connection(self): + return mail.get_connection(some_important_option=True) + + with self.assertRaisesMessage( + AttributeError, + "The undocumented AdminEmailHandler.connection() method is no longer used.", + ): + CustomAdminEmailHandler() + @override_settings( ADMINS=["admin@example.com"], ) @@ -616,6 +695,9 @@ def test_suspicious_operation_uses_sublogger(self): @override_settings( ADMINS=["admin@example.com"], DEBUG=False, + MAILERS={ + "default": {"BACKEND": "django.core.mail.backends.locmem.EmailBackend"} + }, ) def test_suspicious_email_admins(self): self.client.get("/suspicious/") diff --git a/tests/mail/__init__.py b/tests/mail/__init__.py index e69de29bb2d1..c6d8da261a04 100644 --- a/tests/mail/__init__.py +++ b/tests/mail/__init__.py @@ -0,0 +1,54 @@ +import re +from contextlib import nullcontext + +from django.conf import DEPRECATED_EMAIL_SETTINGS, EMAIL_SETTING_DEPRECATED_MSG +from django.core.mail.deprecation import NO_DEFAULT_MAILER_WARNING +from django.test import ignore_warnings, override_settings +from django.utils.deprecation import RemovedInDjango70Warning + + +# RemovedInDjango70Warning. +class override_deprecated_email_settings(override_settings): + """Override settings, ignoring warnings for deprecated email settings. + + Like override_settings(), but suppresses deprecation warnings related to + defining the overridden settings. Other settings can be included. + + Warnings are ignored only while installing and restoring the settings + overrides, not for code within the context. + """ + + def __init__(self, **kwargs): + super().__init__(**kwargs) + deprecated_names = [ + name for name in kwargs if name in DEPRECATED_EMAIL_SETTINGS + ] + if deprecated_names: + assert "{name}" in EMAIL_SETTING_DEPRECATED_MSG + deprecated_names_re = r"|".join( + re.escape(name) for name in deprecated_names + ) + message_re = re.escape(EMAIL_SETTING_DEPRECATED_MSG).replace( + re.escape("{name}"), rf"(?:{deprecated_names_re})" + ) + self.ignore_warnings = ignore_warnings( + category=RemovedInDjango70Warning, message=message_re + ) + else: + self.ignore_warnings = nullcontext() + + def enable(self): + with self.ignore_warnings: + super().enable() + + def disable(self): + with self.ignore_warnings: + super().disable() + + +# RemovedInDjango70Warning: Remove this helper and all uses of it. +def ignore_no_default_mailer_warning(): + return ignore_warnings( + category=RemovedInDjango70Warning, + message=re.escape(NO_DEFAULT_MAILER_WARNING), + ) diff --git a/tests/mail/custombackend.py b/tests/mail/custombackend.py index 6d4f946a2b23..997d39063973 100644 --- a/tests/mail/custombackend.py +++ b/tests/mail/custombackend.py @@ -1,5 +1,3 @@ -"""A custom backend for testing.""" - from django.core.mail.backends.base import BaseEmailBackend @@ -36,8 +34,11 @@ def reset(cls): cls.sent_messages = [] def __init__(self, **kwargs): - self.init_kwargs.append(kwargs.copy()) - super().__init__(**kwargs) + self.init_kwargs.append(kwargs) + if "alias" in kwargs: + super().__init__(alias=kwargs["alias"]) + else: + super().__init__() def send_messages(self, email_messages): self.sent_messages.extend(email_messages) @@ -60,6 +61,10 @@ def test_something(self): init_kwargs = [] sent_messages = [] + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.fail_silently = kwargs.get("fail_silently", False) + def send_messages(self, email_messages): if self.fail_silently: return 0 diff --git a/tests/mail/test_backends.py b/tests/mail/test_backends.py index 9a3c20d99202..eec7568a2d7a 100644 --- a/tests/mail/test_backends.py +++ b/tests/mail/test_backends.py @@ -1,4 +1,5 @@ import os +import re import shutil import socket import ssl @@ -12,10 +13,11 @@ from django.core import mail from django.core.exceptions import ImproperlyConfigured -from django.core.mail import EmailMessage +from django.core.mail import EmailMessage, InvalidMailer from django.core.mail.backends import console, dummy, filebased, locmem, smtp from django.core.mail.backends.base import BaseEmailBackend -from django.test import SimpleTestCase, override_settings +from django.test import SimpleTestCase, ignore_warnings, override_settings +from django.utils.deprecation import RemovedInDjango70Warning from .tests import MailTestsMixin, message_from_bytes @@ -28,16 +30,40 @@ class BaseEmailBackendTests(SimpleTestCase): + def test_alias_arg_accepted(self): + backend = BaseEmailBackend(alias="test_alias") + self.assertEqual(backend.alias, "test_alias") + + # RemovedInDjango70Warning. def test_fail_silently_arg_accepted(self): + msg_init = "BaseEmailBackend.__init__() does not support 'fail_silently'." + msg_use = "BaseEmailBackend.fail_silently is deprecated." for value in [True, False]: with self.subTest(fail_silently=value): - backend = BaseEmailBackend(fail_silently=value) - self.assertIs(backend.fail_silently, value) + with self.assertWarnsMessage(RemovedInDjango70Warning, msg_init): + backend = BaseEmailBackend(fail_silently=value) + with self.assertWarnsMessage(RemovedInDjango70Warning, msg_use): + self.assertIs(backend.fail_silently, value) + + def test_unknown_kwargs_error(self): + msg = "MAILERS['test_alias']: Unknown options 'oops_typo', 'unknown'." + with self.assertRaisesMessage(InvalidMailer, msg): + BaseEmailBackend(alias="test_alias", oops_typo="foo", unknown="bar") + # RemovedInDjango70Warning. def test_unknown_kwargs_ignored(self): - backend = BaseEmailBackend(unknown_kwarg="foo") - self.assertIsInstance(backend, BaseEmailBackend) - self.assertFalse(hasattr(backend, "unknown_kwarg")) + # In compatibility mode (without alias), unknown keyword args are + # ignored with a deprecation warning. + msg = ( + "BaseEmailBackend.__init__() does not support 'oops_typo', " + "'unknown'. In Django 7.0, BaseEmailBackend will raise a " + "TypeError for unknown keyword arguments." + ) + with self.assertWarnsMessage(RemovedInDjango70Warning, msg): + backend = BaseEmailBackend(oops_typo="foo", unknown="foo") + self.assertIsInstance(backend, BaseEmailBackend) + self.assertFalse(hasattr(backend, "oops_typo")) + self.assertFalse(hasattr(backend, "unknown")) class SharedEmailBackendTests(MailTestsMixin): @@ -49,12 +75,14 @@ class SharedEmailBackendTests(MailTestsMixin): # Create an instance of the backend_class for use in this test context # (configured for use with get_mailbox_content() and flush_mailbox()). # Subclasses should override to default kwargs for testing if needed. - def create_backend(self, **kwargs): + def create_backend(self, *, alias="test_alias", **kwargs): if self.backend_class is None: raise NotImplementedError( "Subclasses of SharedEmailBackendTests must provide a " "backend_class attribute." ) + if alias is not None: + kwargs["alias"] = alias return self.backend_class(**kwargs) def get_mailbox_content(self): @@ -79,6 +107,33 @@ def get_the_message(self): ) return mailbox[0] + def test_accepts_alias(self): + backend = self.create_backend(alias="this-alias") + self.assertEqual(backend.alias, "this-alias") + + # RemovedInDjango70Warning. + def test_alias_is_optional_during_transition_to_mailers(self): + # alias=None tells create_backend() to _omit_ the `alias` arg. + backend = self.create_backend(alias=None) + self.assertIsNone(backend.alias) + + def test_create_from_mailers(self, required_options=None): + # Subclasses must override this test case if any options are required. + backend_import_path = ( + f"{self.backend_class.__module__}.{self.backend_class.__name__}" + ) + with self.settings( + MAILERS={ + "custom": { + "BACKEND": backend_import_path, + "OPTIONS": required_options or {}, + } + } + ): + backend = mail.mailers["custom"] + self.assertIsInstance(backend, self.backend_class) + self.assertEqual(backend.alias, "custom") + def test_send(self): email = EmailMessage( "Subject", "Content\n", "from@example.com", ["to@example.com"] @@ -135,15 +190,58 @@ def test_connection_can_be_used_as_contextmanager(self): backend.close.assert_called_once() + # RemovedInDjango70Warning. (But keep overrides in subclasses.) def test_fail_silently_arg_accepted(self): - for value in [True, False]: - with self.subTest(fail_silently=value): - backend = self.create_backend(fail_silently=value) - self.assertIs(backend.fail_silently, value) - + # In Django 7.0, the fail_silently arg will *not* be accepted by + # BaseEmailBackend. Backends that *do* support fail_silently must + # handle that argument themselves. Tests for those backends should + # override this test case to reflect continuing fail_silently support. + with self.subTest("Compatibility configuration"): + # Backend initialized in compatibility mode (without alias) warns + # but still sets attribute. (alias=None tells create_backend to + # _omit_ the `alias` arg.) + msg_init = "EmailBackend.__init__() does not support 'fail_silently'." + msg_use = "EmailBackend.fail_silently is deprecated." + for value in [True, False]: + with self.subTest(fail_silently=value): + with self.assertWarnsMessage(RemovedInDjango70Warning, msg_init): + backend = self.create_backend(alias=None, fail_silently=value) + with self.assertWarnsMessage(RemovedInDjango70Warning, msg_use): + self.assertIs(backend.fail_silently, value) + + with self.subTest("Updated configuration"): + # Backend initialized with alias raises error. + msg_init = "MAILERS['test_alias']: Unknown options 'fail_silently'." + with self.assertRaisesMessage(InvalidMailer, msg_init): + self.create_backend(fail_silently=True) + + def test_unknown_kwargs_error(self): + msg = "MAILERS['test_alias']: Unknown options 'oops_typo', 'unknown'." + with self.assertRaisesMessage(InvalidMailer, msg): + self.create_backend(oops_typo=True, unknown="foo") + + # RemovedInDjango70Warning. def test_unknown_kwargs_ignored(self): - backend = self.create_backend(unknown_kwarg="foo") - self.assertFalse(hasattr(backend, "unknown_kwarg")) + # In compatibility mode (without alias), unknown keyword args are + # ignored with a deprecation warning. + backend_module = self.backend_class.__module__ + msg = ( + f"{backend_module}.EmailBackend.__init__() does not support " + "'unknown_kwarg'. In Django 7.0, BaseEmailBackend will raise a " + "TypeError for unknown keyword arguments." + ) + with ( + self.assertWarnsMessage(RemovedInDjango70Warning, msg), + ignore_warnings( + category=RemovedInDjango70Warning, + message=re.escape( + "Directly creating EmailBackend instances is deprecated." + ), + ), + ): + # alias=None tells create_backend() to _omit_ the `alias` arg. + backend = self.create_backend(alias=None, unknown_kwarg="foo") + self.assertFalse(hasattr(backend, "unknown_kwarg")) class DummyBackendTests(SharedEmailBackendTests, SimpleTestCase): @@ -208,7 +306,20 @@ def test_outbox_not_mutated_after_send(self): self.assertEqual(mail.outbox[0].subject, "correct subject") self.assertEqual(mail.outbox[0].to, ["to@example.com"]) + def test_adds_sent_using_attribute(self): + email = EmailMessage("to@example.com") + locmem.EmailBackend(alias="custom").send_messages([email]) + locmem.EmailBackend().send_messages([email]) + + self.assertEqual(len(mail.outbox), 2) + self.assertEqual(mail.outbox[0].sent_using, "custom") + self.assertIsNone(mail.outbox[1].sent_using) + +@ignore_warnings( + category=RemovedInDjango70Warning, + message=r"The EMAIL_FILE_PATH setting is deprecated\.", +) class FileBackendTests(SharedEmailBackendTests, SimpleTestCase): backend_class = filebased.EmailBackend @@ -243,12 +354,26 @@ def get_mailbox_content(self): messages.extend(self.get_messages_from_filename(filename)) return messages + def test_fail_silently_arg_accepted(self): + # RemovedInDjango70Warning: remove this comment (but keep the test). + # The file backend continues to support fail_silently. Override the + # SharedEmailBackendTests case that treats it as deprecated. + for value in [True, False]: + with self.subTest(fail_silently=value): + backend = self.create_backend(fail_silently=value) + self.assertIs(backend.fail_silently, value) + + def test_create_from_mailers(self): + super().test_create_from_mailers(required_options={"file_path": self.tmp_dir}) + + # RemovedInDjango70Warning. def test_email_file_path_use_settings(self): file_path_settings = self.mkdtemp() with self.settings(EMAIL_FILE_PATH=file_path_settings): backend = filebased.EmailBackend() self.assertEqual(backend.file_path, str(file_path_settings)) + # RemovedInDjango70Warning. def test_email_file_path_override_settings(self): file_path_settings = self.mkdtemp() file_path_override = self.mkdtemp() @@ -258,43 +383,77 @@ def test_email_file_path_override_settings(self): backend = filebased.EmailBackend(file_path=file_path_override) self.assertEqual(backend.file_path, str(file_path_override)) + # RemovedInDjango70Warning. def test_error_if_email_file_path_setting_not_defined(self): msg = "The EMAIL_FILE_PATH setting must be set to use the file EmailBackend." with self.assertRaisesMessage(ImproperlyConfigured, msg): filebased.EmailBackend() + def test_file_path_option_required(self): + msg = "MAILERS['test_alias']: OPTIONS must define 'file_path'." + with self.assertRaisesMessage(InvalidMailer, msg): + filebased.EmailBackend(alias="test_alias") + + # RemovedInDjango70Warning. + @override_settings(EMAIL_FILE_PATH="/this/path/does/not/exist") + def test_ignores_settings_when_initialized_with_alias(self): + backend = self.create_backend() + self.assertEqual(backend.file_path, str(self.tmp_dir)) + def test_error_if_file_path_is_not_directory(self): tmp_file = Path(self.tmp_dir) / "ordinary-file" tmp_file.touch() if isinstance(self.tmp_dir, str): # Running the non-"PathLib" version of FileBackendTests. tmp_file = str(tmp_file) - msg = ( - f"Path for saving email messages exists, but is not a directory: {tmp_file}" - ) - with self.assertRaisesMessage(ImproperlyConfigured, msg): + msg = f"MAILERS['test_alias']: 'file_path' is not a directory: {tmp_file}" + with self.assertRaisesMessage(InvalidMailer, msg): self.create_backend(file_path=tmp_file) + # RemovedInDjango70Warning. + with self.subTest("Compatibility"): + msg = ( + "Path for saving email messages exists, but is not a " + f"directory: {tmp_file}" + ) + with self.assertRaisesMessage(ImproperlyConfigured, msg): + # alias=None tells create_backend() to _omit_ the `alias` arg. + self.create_backend(alias=None, file_path=tmp_file) + @skipIf( sys.platform == "win32", "No cross-platform means to force an OSError from os.makedirs().", ) def test_error_if_file_path_cannot_be_created(self): - msg = "Could not create directory for saving email messages: /dev/null/foo" - with self.assertRaisesMessage(ImproperlyConfigured, msg): + msg = "MAILERS['test_alias']: Could not create 'file_path': /dev/null/foo" + with self.assertRaisesMessage(InvalidMailer, msg): self.create_backend(file_path="/dev/null/foo") + # RemovedInDjango70Warning. + with self.subTest("Compatibility"): + msg = "Could not create directory for saving email messages: /dev/null/foo" + with self.assertRaisesMessage(ImproperlyConfigured, msg): + # alias=None tells create_backend() to _omit_ the `alias` arg. + self.create_backend(alias=None, file_path="/dev/null/foo") + @skipIf( sys.platform == "win32", "chmod does not reliably make directories read-only on Windows.", ) - def test_error_if_file_path_is_not_writeable(self): + def test_error_if_file_path_is_not_writable(self): os.chmod(self.tmp_dir, 0o444) self.addCleanup(os.chmod, self.tmp_dir, 0o777) - msg = f"Could not write to directory: {self.tmp_dir}" - with self.assertRaisesMessage(ImproperlyConfigured, msg): + msg = f"MAILERS['test_alias']: 'file_path' is not writable: {self.tmp_dir}" + with self.assertRaisesMessage(InvalidMailer, msg): self.create_backend(file_path=self.tmp_dir) + # RemovedInDjango70Warning. + with self.subTest("Compatibility"): + msg = f"Could not write to directory: {self.tmp_dir}" + with self.assertRaisesMessage(ImproperlyConfigured, msg): + # alias=None tells create_backend() to _omit_ the `alias` arg. + self.create_backend(alias=None, file_path=self.tmp_dir) + def test_new_file_per_instance(self): # Documented behavior: "A new file is created for each new session that # is opened on this backend." @@ -382,6 +541,15 @@ 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_fail_silently_arg_accepted(self): + # RemovedInDjango70Warning: remove this comment (but keep the test). + # The console backend continues to support fail_silently. Override the + # SharedEmailBackendTests case that treats it as deprecated. + for value in [True, False]: + with self.subTest(fail_silently=value): + backend = self.create_backend(fail_silently=value) + self.assertIs(backend.fail_silently, value) + def test_console_stream_kwarg(self): s = StringIO() backend = self.create_backend(stream=s) @@ -428,6 +596,9 @@ def flush_mailbox(self): @skipUnless(HAS_AIOSMTPD, "No aiosmtpd library detected.") +@ignore_warnings( + category=RemovedInDjango70Warning, message=r"The EMAIL_\w+ setting is deprecated\." +) class SMTPBackendTestsBase(SimpleTestCase): @classmethod def setUpClass(cls): @@ -451,6 +622,10 @@ def stop_smtp(cls): @skipUnless(HAS_AIOSMTPD, "No aiosmtpd library detected.") +@ignore_warnings( + category=RemovedInDjango70Warning, + message=re.escape("Directly creating EmailBackend instances is deprecated."), +) class SMTPBackendTests(SharedEmailBackendTests, SMTPBackendTestsBase): backend_class = smtp.EmailBackend @@ -473,6 +648,80 @@ def get_mailbox_content(self): def get_smtp_envelopes(self): return self.smtp_handler.smtp_envelopes + def test_fail_silently_arg_accepted(self): + # RemovedInDjango70Warning: remove this comment (but keep the test). + # The SMTP backend continues to support fail_silently. Override the + # SharedEmailBackendTests case that treats it as deprecated. + for value in [True, False]: + with self.subTest(fail_silently=value): + backend = self.create_backend(fail_silently=value) + self.assertIs(backend.fail_silently, value) + + def test_create_from_mailers(self): + super().test_create_from_mailers(required_options={"host": "example.com"}) + + # RemovedInDjango70Warning. + @override_settings( + EMAIL_HOST="mail.example.com", + EMAIL_PORT=822, + EMAIL_HOST_USER="username", + EMAIL_HOST_PASSWORD="password", + EMAIL_USE_TLS=True, + EMAIL_USE_SSL=None, + EMAIL_SSL_CERTFILE="foo", + EMAIL_SSL_KEYFILE="bar", + ) + def test_ignores_settings_when_initialized_with_alias(self): + backend = self.backend_class(alias="test_alias", host="local.mail") + # All properties (except host) should be defaults. + self.assertEqual(backend.host, "local.mail") + self.assertEqual(backend.port, 25) + self.assertIsNone(backend.username) + self.assertIsNone(backend.password) + self.assertIs(backend.use_tls, False) + self.assertIs(backend.use_ssl, False) + self.assertIsNone(backend.ssl_certfile) + self.assertIsNone(backend.ssl_keyfile) + + # RemovedInDjango70Warning. + def test_direct_construction_deprecated(self): + msg = ( + "Directly creating EmailBackend instances is deprecated. Use " + "mail.mailers instead." + ) + with self.assertWarnsMessage(RemovedInDjango70Warning, msg): + backend = self.backend_class(use_tls=True) + # Default values come from deprecated settings without special handling + # for port. + self.assertEqual(backend.host, "localhost") + self.assertEqual(backend.port, 25) + + def test_host_option_required(self): + msg = "MAILERS['test_alias']: OPTIONS must define 'host'." + with self.assertRaisesMessage(InvalidMailer, msg): + self.backend_class(alias="test_alias") + + def test_port_default_adapts_to_security(self): + cases = [ + ("default", {}, 25), + ("SSL", {"use_ssl": True}, 465), + ("TLS", {"use_tls": True}, 587), + ] + for case, kwargs, expected_port in cases: + with self.subTest(case): + backend = self.backend_class( + alias="test_alias", host="mail.example.com", **kwargs + ) + self.assertEqual(backend.port, expected_port) + + # RemovedInDjango70Warning: Until Django 7.0, the dynamic port default + # applies only when initialized through mail.mailers. + for case, kwargs, _ in cases: + with self.subTest(f"compatibility {case}"): + backend = self.backend_class(host="mail.example.com", **kwargs) + self.assertEqual(backend.port, 25) + + # RemovedInDjango70Warning. @override_settings( EMAIL_HOST="mail.example.com", EMAIL_PORT=822, @@ -482,6 +731,7 @@ def test_email_host_use_settings(self): self.assertEqual(backend.host, "mail.example.com") self.assertEqual(backend.port, 822) + # RemovedInDjango70Warning. @override_settings( EMAIL_HOST="mail.example.com", EMAIL_PORT=822, @@ -505,6 +755,7 @@ def test_smtp_connection_uses_host_and_port(self): "mail.example.com", 5322, local_hostname=mock.ANY ) + # RemovedInDjango70Warning. @override_settings( EMAIL_HOST_USER="not empty username", EMAIL_HOST_PASSWORD="not empty password", @@ -514,6 +765,7 @@ def test_email_authentication_use_settings(self): self.assertEqual(backend.username, "not empty username") self.assertEqual(backend.password, "not empty password") + # RemovedInDjango70Warning. @override_settings( EMAIL_HOST_USER="not empty username", EMAIL_HOST_PASSWORD="not empty password", @@ -523,6 +775,7 @@ def test_email_authentication_override_settings(self): self.assertEqual(backend.username, "username") self.assertEqual(backend.password, "password") + # RemovedInDjango70Warning. @override_settings( EMAIL_HOST_USER="not empty username", EMAIL_HOST_PASSWORD="not empty password", @@ -564,11 +817,13 @@ def test_reopen_connection(self): backend.connection = mock.Mock(spec=object()) self.assertIs(backend.open(), False) + # RemovedInDjango70Warning. @override_settings(EMAIL_USE_TLS=True) def test_email_tls_use_settings(self): backend = smtp.EmailBackend() self.assertIs(backend.use_tls, True) + # RemovedInDjango70Warning. @override_settings(EMAIL_USE_TLS=True) def test_email_tls_override_settings(self): backend = smtp.EmailBackend(use_tls=False) @@ -579,18 +834,32 @@ def test_email_tls_default_disabled(self): self.assertIs(backend.use_tls, False) def test_ssl_tls_mutually_exclusive(self): + msg = ( + "MAILERS['test_alias']: The 'use_ssl' and 'use_tls' " + "OPTIONS are incompatible. Set at most one of them to True." + ) + with self.assertRaisesMessage(InvalidMailer, msg): + self.create_backend(use_ssl=True, use_tls=True) + + # RemovedInDjango70Warning. + def test_ssl_tls_settings_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): - self.create_backend(use_ssl=True, use_tls=True) + with ( + self.settings(EMAIL_USE_SSL=True, EMAIL_USE_TLS=True), + self.assertRaisesMessage(ValueError, msg), + ): + smtp.EmailBackend() + # RemovedInDjango70Warning. @override_settings(EMAIL_USE_SSL=True) def test_email_ssl_use_settings(self): backend = smtp.EmailBackend() self.assertIs(backend.use_ssl, True) + # RemovedInDjango70Warning. @override_settings(EMAIL_USE_SSL=True) def test_email_ssl_override_settings(self): backend = smtp.EmailBackend(use_ssl=False) @@ -600,11 +869,13 @@ def test_email_ssl_default_disabled(self): backend = self.create_backend() self.assertIs(backend.use_ssl, False) + # RemovedInDjango70Warning. @override_settings(EMAIL_SSL_CERTFILE="foo") def test_email_ssl_certfile_use_settings(self): backend = smtp.EmailBackend() self.assertEqual(backend.ssl_certfile, "foo") + # RemovedInDjango70Warning. @override_settings(EMAIL_SSL_CERTFILE="foo") def test_email_ssl_certfile_override_settings(self): backend = smtp.EmailBackend(ssl_certfile="bar") @@ -614,11 +885,13 @@ def test_email_ssl_certfile_default_disabled(self): backend = self.create_backend() self.assertIsNone(backend.ssl_certfile) + # RemovedInDjango70Warning. @override_settings(EMAIL_SSL_KEYFILE="foo") def test_email_ssl_keyfile_use_settings(self): backend = smtp.EmailBackend() self.assertEqual(backend.ssl_keyfile, "foo") + # RemovedInDjango70Warning. @override_settings(EMAIL_SSL_KEYFILE="foo") def test_email_ssl_keyfile_override_settings(self): backend = smtp.EmailBackend(ssl_keyfile="bar") @@ -674,11 +947,13 @@ def __init__(self, *args, **kwargs): self.assertEqual(myemailbackend.connection.timeout, 42) myemailbackend.close() + # RemovedInDjango70Warning. @override_settings(EMAIL_TIMEOUT=10) def test_email_timeout_use_settings(self): backend = smtp.EmailBackend() self.assertEqual(backend.timeout, 10) + # RemovedInDjango70Warning. @override_settings(EMAIL_TIMEOUT=10) def test_email_timeout_override_settings(self): backend = smtp.EmailBackend(timeout=15) @@ -845,8 +1120,12 @@ class SMTPBackendStoppedServerTests(SMTPBackendTestsBase): @classmethod def setUpClass(cls): super().setUpClass() + # RemovedInDjango70Warning: alias argument can be removed (needed + # during mail.mailers transition to prevent compatibility mode). cls.backend = smtp.EmailBackend( - host=cls.smtp_controller.hostname, port=cls.smtp_controller.port + alias="test_alias", + host=cls.smtp_controller.hostname, + port=cls.smtp_controller.port, ) cls.smtp_controller.stop() diff --git a/tests/mail/test_deprecated.py b/tests/mail/test_deprecated.py index bdf01e0ef61a..2617750a8033 100644 --- a/tests/mail/test_deprecated.py +++ b/tests/mail/test_deprecated.py @@ -1,16 +1,29 @@ # RemovedInDjango70Warning: This entire file. +import re +import types +import warnings +from contextlib import contextmanager from email.mime.text import MIMEText +from unittest import mock +import django.conf +import django.utils.timezone +from django.conf import LazySettings +from django.core.exceptions import ImproperlyConfigured from django.core.mail import ( EmailAlternative, EmailAttachment, EmailMessage, EmailMultiAlternatives, + get_connection, + mailers, ) +from django.core.mail.deprecation import NO_DEFAULT_MAILER_WARNING from django.core.mail.message import forbid_multi_line_headers, sanitize_address -from django.test import SimpleTestCase, ignore_warnings +from django.test import SimpleTestCase, ignore_warnings, override_settings from django.utils.deprecation import RemovedInDjango70Warning +from . import override_deprecated_email_settings from .tests import MailTestsMixin @@ -102,6 +115,21 @@ def test_undocumented_alternative_subtype(self): with self.assertRaisesMessage(AttributeError, msg): email.message() + def test_undocumented_get_connection_override_no_longer_supported(self): + + class CustomEmailMessage(EmailMessage): + def get_connection(self, fail_silently=False): + return None + + email = CustomEmailMessage(to=["to@example.com"]) + + msg = ( + "EmailMessage no longer supports the undocumented " + "get_connection() method." + ) + with self.assertRaisesMessage(AttributeError, msg): + email.send() + @ignore_warnings(category=RemovedInDjango70Warning) class DeprecatedCompatibilityTests(SimpleTestCase): @@ -120,3 +148,321 @@ def test_attachments_mimebase_in_constructor(self): msg = EmailMessage(attachments=[txt]) payload = msg.message().get_payload() self.assertEqual(payload[0], txt) + + +class DeprecatedEmailSettingsTests(SimpleTestCase): + """Deprecations and compatibility errors related to MAILERS.""" + + deprecated_setting_defaults = { + "EMAIL_BACKEND": "django.core.mail.backends.smtp.EmailBackend", + "EMAIL_HOST": "localhost", + "EMAIL_PORT": 25, + "EMAIL_HOST_USER": "", + "EMAIL_HOST_PASSWORD": "", + "EMAIL_USE_TLS": False, + "EMAIL_USE_SSL": False, + "EMAIL_SSL_CERTFILE": None, + "EMAIL_SSL_KEYFILE": None, + "EMAIL_TIMEOUT": None, + # EMAIL_FILE_PATH does not have a default. + } + + deprecated_settings = deprecated_setting_defaults.keys() | {"EMAIL_FILE_PATH"} + + # Tests for defining settings must cover three separate cases, which go + # through different code paths in django.conf: + # - settings module (settings.py; LazySettings wraps Settings) + # - settings.configure() (LazySettings wraps UserSettingsHolder) + # - override_settings() (a.k.a. SimpleTestCase.settings(); temporarily + # inserts a UserSettingsHolder into the current LazySettings) + # + # A settings module must be simulated in these tests. (The real settings + # module can't be modified, and override_settings() isn't the same as using + # a settings module.) To do that: + # - Optionally use a self.mock_settings_module() context to populate a + # simulated settings.py. + # - Call self.init_simulated_settings() to initialize settings from a + # module (the mock_settings_module() if active, else the real one) and + # return a settings object equivalent to django.conf.settings. + + def mock_settings_module(self, **settings): + settings_module = types.ModuleType("mocked_settings") + for name, value in settings.items(): + setattr(settings_module, name, value) + # Patch the settings module import in Settings.__init__() to + # substitute the mocked settings. + return mock.patch( + "django.conf.importlib.import_module", + autospec=True, + return_value=settings_module, + ) + + def init_simulated_settings(self): + settings = LazySettings() + # Trigger LazySettings._setup() *from within Django*. (In real use, + # the first settings access is often iter(db.connections) in + # run_checks() or similar. But any settings access will work, and + # it's hard to mock db.connections due to @cached_property() usage.) + with mock.patch.object(django.utils.timezone, "settings", settings): + django.utils.timezone.now() # Reads settings.USE_TZ. + return settings + + @contextmanager + def assertNotWarnsMessage(self, category, message): + with warnings.catch_warnings(record=True) as caught_warnings: + warnings.filterwarnings( + "always", category=category, message=rf".*{re.escape(message)}" + ) + yield caught_warnings + self.assertEqual([str(warning) for warning in caught_warnings], []) + + def assertHasOnlyDefaultEmailSettings(self, settings, msg=None): + non_default_settings = [ + name for name in self.deprecated_settings if settings.is_overridden(name) + ] + if hasattr(settings, "MAILERS"): + non_default_settings.append("MAILERS") + self.assertEqual(non_default_settings, [], msg=msg) + + def test_warn_when_defining_deprecated_settings(self): + for name in self.deprecated_settings: + msg = ( + f"The {name} setting is deprecated. Migrate to " + "MAILERS before Django 7.0." + ) + settings = {name: "foo"} + with self.subTest(name=name): + with ( + self.subTest("settings module"), + self.mock_settings_module(**settings), + self.assertWarnsMessage(RemovedInDjango70Warning, msg), + ): + self.init_simulated_settings() + with ( + self.subTest("settings.configure()"), + self.assertWarnsMessage(RemovedInDjango70Warning, msg), + ): + LazySettings().configure(**settings) + with ( + self.subTest("override_settings()"), + self.assertWarnsMessage(RemovedInDjango70Warning, msg), + ): + with override_settings(**settings): + pass + + def test_multiple_deprecated_settings_are_all_reported(self): + msg_re = r"The EMAIL_(BACKEND|HOST|PORT) setting is deprecated." + settings = {"EMAIL_BACKEND": "foo", "EMAIL_HOST": "bar", "EMAIL_PORT": 2525} + expected_warning_count = len(settings) + + with self.subTest("settings module"), self.mock_settings_module(**settings): + with self.assertWarnsRegex(RemovedInDjango70Warning, msg_re) as cm: + self.init_simulated_settings() + self.assertEqual(len(cm.warnings), expected_warning_count) + + with self.subTest("settings.configure()"): + with self.assertWarnsRegex(RemovedInDjango70Warning, msg_re) as cm: + LazySettings().configure(**settings) + self.assertEqual(len(cm.warnings), expected_warning_count) + + with self.subTest("override_settings()"): + with ( + self.assertWarnsRegex(RemovedInDjango70Warning, msg_re) as cm, + override_settings(**settings), + ): + pass + # override_settings() accesses each setting twice: setattr while + # enabling and getattr while disabling (for change notification). + self.assertEqual(len(cm.warnings), 2 * expected_warning_count) + + def test_warn_about_no_default_mailer(self): + # Test precondition: the real settings object must be default. + self.assertHasOnlyDefaultEmailSettings(django.conf.settings) + + # The warning is issued if no email-backend-related settings are + # defined, but only when email is sent. Any attempt to send email + # should invoke get_connection() or mailers.create_connection(). + msg = NO_DEFAULT_MAILER_WARNING + with ( + self.subTest("get_connection()"), + self.assertWarnsMessage(RemovedInDjango70Warning, msg), + ignore_warnings( + category=RemovedInDjango70Warning, + message=re.escape("get_connection() is deprecated."), + ), + ): + get_connection() + with ( + self.subTest("mailers.default"), + self.assertWarnsMessage(RemovedInDjango70Warning, msg), + ): + _ = mailers.default + + # The warning is not issued on startup, to avoid creating noise for + # projects that don't send email at all. + with self.assertNotWarnsMessage(RemovedInDjango70Warning, msg): + settings = self.init_simulated_settings() + self.assertHasOnlyDefaultEmailSettings(settings, msg="invalid test") + + def test_no_default_mailer_warning_if_any_email_setting_defined(self): + # Test precondition: the real settings object must be default. + self.assertHasOnlyDefaultEmailSettings(django.conf.settings) + + # The warning from the previous test is not issued if any deprecated + # email setting is defined (which would result in a startup-time + # warning about MAILERS) or if MAILERS is defined. + msg = NO_DEFAULT_MAILER_WARNING + for name in self.deprecated_settings: + value = self.deprecated_setting_defaults.get(name, "foo") + with ( + self.subTest(name=name), + override_deprecated_email_settings(**{name: value}), + self.assertNotWarnsMessage(RemovedInDjango70Warning, msg), + ): + _ = mailers.default + with ( + self.subTest(name="MAILERS"), + self.settings( + MAILERS={ + "default": { + "BACKEND": "django.core.mail.backends.locmem.EmailBackend" + } + } + ), + self.assertNotWarnsMessage(RemovedInDjango70Warning, msg), + ): + _ = mailers.default + + def test_deprecated_settings_not_allowed_with_mailers(self): + for name in self.deprecated_settings: + msg = ( + "Deprecated email settings are not allowed when " + f"MAILERS is defined: {name}." + ) + settings = {name: "foo", "MAILERS": {}} + with self.subTest(name=name): + with ( + self.subTest("settings module"), + self.mock_settings_module(**settings), + self.assertRaisesMessage(ImproperlyConfigured, msg), + ): + self.init_simulated_settings() + with ( + self.subTest("settings.configure()"), + self.assertRaisesMessage(ImproperlyConfigured, msg), + ): + LazySettings().configure(**settings) + # There is intentionally no override_settings() subtest here. + # override_settings() does not check for settings conflicts. + + def test_warn_when_using_deprecated_settings(self): + for name in self.deprecated_settings: + msg = ( + f"The {name} setting is deprecated. Migrate to " + "MAILERS before Django 7.0." + ) + with ( + self.subTest(name=name), + override_deprecated_email_settings(**{name: "foo"}), + self.assertWarnsMessage(RemovedInDjango70Warning, msg), + ): + getattr(django.conf.settings, name) + + @override_settings(MAILERS={}) + def test_deprecated_settings_do_not_exist_when_mailers_defined(self): + # The global_settings defaults for the deprecated settings are hidden + # when MAILERS is defined. + for name in self.deprecated_settings: + with self.subTest(name=name): + self.assertFalse(hasattr(django.conf.settings, name)) + + @override_settings(MAILERS={}) + def test_deprecated_settings_not_in_dir_when_mailers_defined(self): + known_settings = dir(django.conf.settings) + actual_overlap = set(self.deprecated_settings) & set(known_settings) + self.assertEqual(actual_overlap, set()) + + def test_deprecated_settings_are_in_dir_without_mailers(self): + expected_settings = self.deprecated_setting_defaults.keys() + known_settings = dir(django.conf.settings) + actual_overlap = set(expected_settings) & set(known_settings) + self.assertEqual(actual_overlap, expected_settings) + + @override_settings(MAILERS={}) + def test_error_when_using_deprecated_settings_with_mailers_defined(self): + for name in self.deprecated_settings: + msg = f"The {name} setting is not available when MAILERS is defined." + with ( + self.subTest(name=name), + override_deprecated_email_settings(**{name: "foo"}), + ): + with self.assertRaisesMessage(AttributeError, msg): + getattr(django.conf.settings, name) + + @ignore_warnings(category=RemovedInDjango70Warning) + def test_direct_settings_manipulation(self): + settings = self.init_simulated_settings() + + # Accessing deprecated setting (from global defaults) caches its value + # in LazySettings.__dir__. + self.assertEqual(settings.EMAIL_PORT, 25) + + # Defining MAILERS invalidates the cache but is not an error. + settings.MAILERS = {} + + # Accessing the conflicting setting _is_ an error. + msg = "not available when MAILERS is defined" + with self.assertRaisesMessage(AttributeError, msg): + _ = settings.EMAIL_PORT # Not `25` from the cache. + + # Adding a conflicting setting is not an error, but accessing it is. + settings.EMAIL_HOST = "example.com" + with self.assertRaisesMessage(AttributeError, msg): + _ = settings.EMAIL_HOST + + # Deleting MAILERS removes the conflict and allows access to + # the deprecated settings again. + del settings.MAILERS + self.assertEqual(settings.EMAIL_PORT, 25) + self.assertEqual(settings.EMAIL_HOST, "example.com") + + @override_settings(MAILERS={}) + def test_error_when_using_conflicting_setting_via_override_settings(self): + # Overriding EMAIL_HOST when MAILERS is defined creates a + # conflict but does not cause an immediate error. + with override_deprecated_email_settings(EMAIL_HOST="example.com"): + self.assertEqual(django.conf.settings.MAILERS, {}) + # Trying to access the conflicting setting causes an error. + with self.assertRaisesMessage(AttributeError, "not available"): + _ = django.conf.settings.EMAIL_HOST + + # Both conflicting settings are defined (neither is inherited from + # default global_settings), so LazySettings.__dir__() does not hide + # them. + self.assertIn("EMAIL_HOST", dir(django.conf.settings)) + self.assertIn("MAILERS", dir(django.conf.settings)) + + del django.conf.settings.EMAIL_HOST + self.assertNotIn("EMAIL_HOST", dir(django.conf.settings)) + + @ignore_warnings(category=RemovedInDjango70Warning) + def test_deprecated_settings_defaults_unchanged(self): + # Django's test runner overrides EMAIL_BACKEND in django.conf.settings, + # so construct a fresh settings object for this test. + settings = self.init_simulated_settings() + for name, expected in self.deprecated_setting_defaults.items(): + with self.subTest(name=name): + actual = getattr(settings, name) + if expected is None: + self.assertIsNone(actual) + elif expected is True or expected is False: + self.assertIs(actual, expected) + else: + self.assertEqual(actual, expected) + + @ignore_warnings(category=RemovedInDjango70Warning) + def test_email_backend_override_during_tests(self): + self.assertEqual( + django.conf.settings.EMAIL_BACKEND, + "django.core.mail.backends.locmem.EmailBackend", + ) diff --git a/tests/mail/test_handler.py b/tests/mail/test_handler.py new file mode 100644 index 000000000000..227b7088ff7e --- /dev/null +++ b/tests/mail/test_handler.py @@ -0,0 +1,233 @@ +from django.core.mail import InvalidMailer, MailerDoesNotExist, mailers +from django.core.mail.backends import locmem, smtp +from django.test import SimpleTestCase, override_settings + +from . import ( + ignore_no_default_mailer_warning, + override_deprecated_email_settings, +) +from .custombackend import OptionsCapturingBackend + + +class MailersTests(SimpleTestCase): + def setUp(self): + self.addCleanup(OptionsCapturingBackend.reset) + + @override_settings( + MAILERS={ + "default": {"BACKEND": "django.core.mail.backends.locmem.EmailBackend"}, + "custom": {"BACKEND": "django.core.mail.backends.locmem.EmailBackend"}, + } + ) + def test_getitem(self): + with self.subTest("defined mailers"): + self.assertEqual(mailers["default"].alias, "default") + self.assertEqual(mailers["custom"].alias, "custom") + + with self.subTest("missing mailer"): + msg = "The mailer 'unknown' is not configured." + with self.assertRaisesMessage(MailerDoesNotExist, msg): + _ = mailers["unknown"] + + with self.subTest("raises KeyError"): + # mail.mailers is a mapping, so unknown keys raise KeyError. + # (MailerDoesNotExist must be a KeyError.) + with self.assertRaises(KeyError): + _ = mailers["unknown"] + + @override_settings( + MAILERS={ + "one": {"BACKEND": "mail.custombackend.OptionsCapturingBackend"}, + "two": {"BACKEND": "mail.custombackend.OptionsCapturingBackend"}, + "three": {"BACKEND": "mail.custombackend.OptionsCapturingBackend"}, + } + ) + def test_contains(self): + self.assertIn("two", mailers) + self.assertNotIn("zero", mailers) + self.assertNotIn(None, mailers) + # __contains__() does not construct any backend instance. + self.assertEqual(OptionsCapturingBackend.init_kwargs, []) + + @override_settings( + MAILERS={ + "one": {"BACKEND": "mail.custombackend.OptionsCapturingBackend"}, + "two": {"BACKEND": "mail.custombackend.OptionsCapturingBackend"}, + "three": {"BACKEND": "mail.custombackend.OptionsCapturingBackend"}, + } + ) + def test_iter(self): + self.assertEqual(list(mailers), ["one", "two", "three"]) + # __iter__() does not construct any backend instance. + self.assertEqual(OptionsCapturingBackend.init_kwargs, []) + + @override_settings( + MAILERS={ + "default": {"BACKEND": "django.core.mail.backends.locmem.EmailBackend"}, + "custom": {"BACKEND": "django.core.mail.backends.locmem.EmailBackend"}, + } + ) + def test_get(self): + with self.subTest("defined mailers"): + self.assertEqual(mailers.get("default").alias, "default") + self.assertEqual(mailers.get("custom").alias, "custom") + + with self.subTest("missing mailer"): + with self.subTest("no default arg"): + self.assertIsNone(mailers.get("unknown")) + with self.subTest("positional default arg"): + self.assertEqual(mailers.get("unknown", "foo"), "foo") + with self.subTest("keyword default arg"): + self.assertEqual(mailers.get("unknown", default="foo"), "foo") + + @override_settings( + MAILERS={ + "default": {"BACKEND": "django.core.mail.backends.locmem.EmailBackend"} + } + ) + def test_default_mailer_property(self): + backend = mailers.default + self.assertEqual(backend.alias, "default") + + # RemovedInDjango70Warning: remove override_settings (but keep the test). + # (MAILERS={} becomes the default in Django 7.0.) + @override_settings(MAILERS={}) + def test_default_mailers(self): + msg = "The mailer 'default' is not configured." + with ( + self.subTest('mailers["default"]'), + self.assertRaisesMessage(MailerDoesNotExist, msg), + ): + _ = mailers["default"] + with ( + self.subTest("mailers.default"), + self.assertRaisesMessage(MailerDoesNotExist, msg), + ): + _ = mailers.default + + with self.subTest("mailers.get()"): + self.assertIsNone(mailers.get("default")) + with self.subTest("mailers.__contains__()"): + self.assertIs("default" in mailers, False) + with self.subTest("mailers.__iter__()"): + self.assertEqual(list(mailers), []) + + @override_settings( + MAILERS={"custom": {"BACKEND": "django.core.mail.backends.locmem.EmailBackend"}} + ) + def test_default_mailer_not_required(self): + self.assertEqual(mailers["custom"].alias, "custom") + msg = "The mailer 'default' is not configured." + with self.assertRaisesMessage(MailerDoesNotExist, msg): + _ = mailers.default + + @override_settings( + MAILERS={"custom": {"BACKEND": "django.core.mail.backends.locmem.EmailBackend"}} + ) + def test_instances_not_cached(self): + self.assertIsNot(mailers["custom"], mailers["custom"]) + + @override_settings(MAILERS={"custom": {"OPTIONS": {"host": "localhost"}}}) + def test_default_backend_is_smtp(self): + # Omitting "BACKEND" gives the SMTP EmailBackend. + backend = mailers["custom"] + self.assertIsInstance(backend, smtp.EmailBackend) + + @override_settings( + MAILERS={"default": {"BACKEND": "mail.custombackend.EmailBackend"}} + ) + def test_custom_backend(self): + backend = mailers.default + self.assertTrue(hasattr(backend, "test_outbox")) + + @override_settings(MAILERS={"custom": {"BACKEND": "foo.bar"}}) + def test_invalid_backend(self): + msg = ( + "MAILERS['custom']: Could not find BACKEND 'foo.bar': No " + "module named 'foo'" + ) + with self.assertRaisesMessage(InvalidMailer, msg): + _ = mailers["custom"] + + @override_settings( + MAILERS={ + "custom": { + "BACKEND": "mail.custombackend.OptionsCapturingBackend", + "OPTIONS": {"one": 1, "false": False, "foo": "bar"}, + } + } + ) + def test_options_are_provided_to_backend_init(self): + _ = mailers["custom"] + self.assertEqual( + OptionsCapturingBackend.init_kwargs[0], + {"alias": "custom", "one": 1, "false": False, "foo": "bar"}, + ) + + @override_settings( + MAILERS={ + "custom": { + "BACKEND": "django.core.mail.backends.smtp.EmailBackend", + "OPTIONS": {"alias": "imposter", "host": "localhost"}, + } + } + ) + def test_alias_is_invalid_option(self): + msg = "MAILERS['custom']: OPTIONS must not define 'alias'." + with self.assertRaisesMessage(InvalidMailer, msg): + _ = mailers["custom"] + with self.assertRaises(MailerDoesNotExist): + _ = mailers["imposter"] + + @override_settings( + MAILERS={ + "custom": { + "BACKEND": "django.core.mail.backends.locmem.EmailBackend", + "OPTIONS": {"unknown": "foo"}, + } + } + ) + def test_unknown_options(self): + # This error message actually comes from BaseEmailBackend. + msg = "MAILERS['custom']: Unknown options 'unknown'." + with self.assertRaisesMessage(InvalidMailer, msg): + _ = mailers["custom"] + + def test_does_not_exist_is_limited_purpose(self): + # Code that wants to send email only when it has been configured will + # trap and ignore MailerDoesNotExist. If that error is used to + # report anything other than a missing alias key in MAILERS, + # unrelated configuration errors may be incorrectly silenced. The + # error's constructor is designed to discourage other uses. + msg = "MailerDoesNotExist.__init__() takes 1 positional argument" + with self.assertRaisesMessage(TypeError, msg): + MailerDoesNotExist("Some other configuration problem") + + +# RemovedInDjango70Warning. +class MailersCompatibilityTests(SimpleTestCase): + """mailers.default is usable even when MAILERS is not defined.""" + + @override_deprecated_email_settings( + EMAIL_BACKEND="mail.custombackend.OptionsCapturingBackend" + ) + def test_default_mailer_with_deprecated_settings(self): + self.addCleanup(OptionsCapturingBackend.reset) + backend = mailers.default + self.assertIsNone(backend.alias) + self.assertIsInstance(backend, OptionsCapturingBackend) + self.assertNotIn("alias", OptionsCapturingBackend.init_kwargs[0]) + + @ignore_no_default_mailer_warning() + def test_default_mailer_with_no_settings(self): + backend = mailers.default + # Django's test runner changes the default EMAIL_BACKEND to locmem. + self.assertIsInstance(backend, locmem.EmailBackend) + # In compatibility mode, backends are constructed with no 'alias' arg. + self.assertIsNone(backend.alias) + + def test_unknown_mailer_with_no_settings(self): + # Compatibility only applies to the default mailer. + msg = "The mailer 'unknown' is not configured." + with self.assertRaisesMessage(MailerDoesNotExist, msg): + _ = mailers["unknown"] diff --git a/tests/mail/test_sendtestemail.py b/tests/mail/test_sendtestemail.py index c7195ba9a1d2..1bc500237b5d 100644 --- a/tests/mail/test_sendtestemail.py +++ b/tests/mail/test_sendtestemail.py @@ -6,6 +6,7 @@ @override_settings( ADMINS=["admin@example.com", "admin_and_manager@example.com"], MANAGERS=["manager@example.com", "admin_and_manager@example.com"], + MAILERS={"default": {"BACKEND": "django.core.mail.backends.locmem.EmailBackend"}}, ) class SendTestEmailManagementCommand(SimpleTestCase): """ diff --git a/tests/mail/tests.py b/tests/mail/tests.py index b399dbfaa53e..7963b81892c1 100644 --- a/tests/mail/tests.py +++ b/tests/mail/tests.py @@ -16,6 +16,7 @@ from textwrap import dedent from unittest import mock +from django.conf import settings from django.core import mail from django.core.exceptions import ImproperlyConfigured from django.core.mail import ( @@ -24,18 +25,29 @@ EmailAttachment, EmailMessage, EmailMultiAlternatives, + MailerDoesNotExist, mail_admins, mail_managers, + mailers, send_mail, send_mass_mail, ) from django.core.mail.backends import console, dummy, filebased, locmem, smtp +from django.core.mail.deprecation import ( + AUTH_ARGS_WARNING, + CONNECTION_ARG_WARNING, + FAIL_SILENTLY_ARG_WARNING, +) from django.test import SimpleTestCase, override_settings 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 +from . import ( + custombackend, + ignore_no_default_mailer_warning, + override_deprecated_email_settings, +) from .custombackend import OptionsCapturingBackend # Check whether python/cpython#128110 has been fixed by seeing if space between @@ -235,7 +247,15 @@ def get_message_structure(self, message, level=0): structure.append(self.get_message_structure(subpart, level + 1)) return "".join(structure) + # RemovedInDjango70Warning. + def use_email_backend(self, backend): + if hasattr(settings, "MAILERS"): + return self.settings(MAILERS={"default": {"BACKEND": backend}}) + else: + return override_deprecated_email_settings(EMAIL_BACKEND=backend) + +@ignore_no_default_mailer_warning() class EmailMessageTests(MailTestsMixin, SimpleTestCase): """Tests for django.core.mail.EmailMessage and EmailMultiAlternative.""" @@ -1641,6 +1661,8 @@ def test_all_params_optional(self): email = EmailMultiAlternatives() self.assertIsInstance(email.message(), PyMessage) # force serialization. + # RemovedInDjango70Warning: connection argument. + @ignore_warnings(category=RemovedInDjango70Warning) def test_positional_arguments_order(self): """ EmailMessage class docs: "… is initialized with the following @@ -1678,8 +1700,10 @@ def test_positional_arguments_order(self): self.assertEqual( email.recipients(), ["to@example.com", "cc@example.com", "bcc@example.com"] ) - self.assertIs(email.get_connection(), connection) + self.assertIs(email.connection, connection) + # RemovedInDjango70Warning: connection argument and attribute. + @ignore_warnings(category=RemovedInDjango70Warning) def test_all_params_can_be_set_before_send(self): """ EmailMessage class docs: "All parameters … can be set at any time @@ -1740,7 +1764,7 @@ def test_all_params_can_be_set_before_send(self): email.recipients(), ["new-to@example.com", "new-cc@example.com", "new-bcc@example.com"], ) - self.assertIs(email.get_connection(), new_connection) + self.assertIs(email.connection, new_connection) self.assertNotIn("original", message.as_string()) def test_message_is_python_email_message(self): @@ -1810,6 +1834,8 @@ def test_message_policy_compat32(self): message.as_string(policy=policy.compat32), ) + # RemovedInDjango70Warning. + @ignore_warnings(category=RemovedInDjango70Warning) def test_send_fail_silently_conflict(self): email = EmailMessage( "Subject", @@ -1825,10 +1851,107 @@ def test_send_fail_silently_conflict(self): with self.assertRaisesMessage(TypeError, msg): email.send(fail_silently=True) + def test_send(self): + email = EmailMessage(to=["to@example.com"]) + email.send() + self.assertEqual(mail.outbox[0].to, ["to@example.com"]) + + # RemovedInDjango70Warning. + if not mailers._is_configured: + self.assertIsNone(mail.outbox[0].sent_using) + return + + self.assertEqual(mail.outbox[0].sent_using, "default") + + # RemovedInDjango70Warning. + def test_connection_arg_deprecated(self): + connection = object() + with self.assertWarnsMessage(RemovedInDjango70Warning, CONNECTION_ARG_WARNING): + email = EmailMessage(connection=connection) + with ignore_warnings(category=RemovedInDjango70Warning): + self.assertIs(email.connection, connection) + + # RemovedInDjango70Warning. + def test_connection_attr_deprecated(self): + email = EmailMessage() + connection = object() + msg_set = ( + "The EmailMessage.connection attribute is deprecated. Switch to " + "EmailMessage.send(using=...) with a MAILERS alias." + ) + msg_get = "The EmailMessage.connection attribute is deprecated." + with self.assertWarnsMessage(RemovedInDjango70Warning, msg_set): + email.connection = connection + with self.assertWarnsMessage(RemovedInDjango70Warning, msg_get): + self.assertIs(email.connection, connection) + + # RemovedInDjango70Warning. + def test_fail_silently_deprecated(self): + email = EmailMessage(to=["to@example.com"]) + with self.assertWarnsMessage( + RemovedInDjango70Warning, FAIL_SILENTLY_ARG_WARNING + ): + email.send(fail_silently=True) + + +# RemovedInDjango70Warning: Move override_settings and additional test cases to +# EmailMessageTests and remove this class. +@override_settings( + MAILERS={ + "default": {"BACKEND": "django.core.mail.backends.locmem.EmailBackend"}, + "custom": {"BACKEND": "django.core.mail.backends.locmem.EmailBackend"}, + } +) +class EmailMessageTestsWithMailers(EmailMessageTests): + # Repeat all EmailMessageTests with MAILERS defined. + + def test_send_using(self): + email = EmailMessage(to=["to@example.com"]) + email.send() + email.send(using="custom") + email.send() + self.assertEqual(mail.outbox[0].sent_using, "default") + self.assertEqual(mail.outbox[1].sent_using, "custom") + self.assertEqual(mail.outbox[2].sent_using, "default") + + # RemovedInDjango70Warning. + @ignore_warnings(category=RemovedInDjango70Warning) + def test_send_using_conflicts_with_connection(self): + msg = "'connection' is not compatible with 'using'." + with self.subTest("in constructor"): + email = EmailMessage(to=["to@example.com"], connection=object()) + with self.assertRaisesMessage(TypeError, msg): + email.send(using="test") + + with self.subTest("as attribute"): + email = EmailMessage(to=["to@example.com"]) + email.connection = object() + with self.assertRaisesMessage(TypeError, msg): + email.send(using="test") + + # RemovedInDjango70Warning. + @ignore_warnings(category=RemovedInDjango70Warning) + def test_send_using_conflicts_with_fail_silently(self): + msg = "'fail_silently' is not compatible with 'using'." + email = EmailMessage(to=["to@example.com"]) + with self.assertRaisesMessage(TypeError, msg): + email.send(using="test", fail_silently=True) + +@ignore_no_default_mailer_warning() class SendMailTests(SimpleTestCase, MailTestsMixin): """Tests for django.core.mail.send_mail().""" + def test_sends_using_default_mailer(self): + send_mail("subject", "body", "from@example.com", ["to@example.com"]) + + # RemovedInDjango70Warning. + if not mailers._is_configured: + self.assertIsNone(mail.outbox[0].sent_using) + return + + self.assertEqual(mail.outbox[0].sent_using, "default") + def test_plaintext_send_mail(self): """ Test send_mail without the html_message @@ -1896,23 +2019,29 @@ def test_lazy_addresses(self): self.assertEqual(message.get("from"), "tester") self.assertEqual(message.get("to"), "django") + # RemovedInDjango70Warning. def test_connection_arg(self): # Send using non-default connection. connection = custombackend.EmailBackend() - send_mail( - "Subject", - "Content", - "from@example.com", - ["to@example.com"], - connection=connection, - ) + with self.assertWarnsMessage(RemovedInDjango70Warning, CONNECTION_ARG_WARNING): + 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") + # RemovedInDjango70Warning. def test_auth_passed_to_backend_init(self): self.addCleanup(OptionsCapturingBackend.reset) - with self.settings(EMAIL_BACKEND="mail.custombackend.OptionsCapturingBackend"): + with ( + self.use_email_backend("mail.custombackend.OptionsCapturingBackend"), + self.assertWarnsMessage(RemovedInDjango70Warning, AUTH_ARGS_WARNING), + ): send_mail( "Subject", "Content", @@ -1926,9 +2055,15 @@ def test_auth_passed_to_backend_init(self): self.assertEqual(init_kwargs["username"], "user") self.assertEqual(init_kwargs["password"], "password") + # RemovedInDjango70Warning. def test_fail_silently_passed_to_backend_init(self): self.addCleanup(OptionsCapturingBackend.reset) - with self.settings(EMAIL_BACKEND="mail.custombackend.OptionsCapturingBackend"): + with ( + self.use_email_backend("mail.custombackend.OptionsCapturingBackend"), + self.assertWarnsMessage( + RemovedInDjango70Warning, FAIL_SILENTLY_ARG_WARNING + ), + ): send_mail( "Subject", "Content", @@ -1939,6 +2074,8 @@ def test_fail_silently_passed_to_backend_init(self): init_kwargs = OptionsCapturingBackend.init_kwargs[0] self.assertIs(init_kwargs["fail_silently"], True) + # RemovedInDjango70Warning. + @ignore_warnings(category=RemovedInDjango70Warning) def test_fail_silently_conflict(self): msg = ( "fail_silently cannot be used with a connection. " @@ -1954,6 +2091,8 @@ def test_fail_silently_conflict(self): connection=mail.get_connection(), ) + # RemovedInDjango70Warning. + @ignore_warnings(category=RemovedInDjango70Warning) def test_auth_conflict(self): msg = ( "auth_user and auth_password cannot be used with a connection. " @@ -1973,8 +2112,81 @@ def test_auth_conflict(self): connection=mail.get_connection(), ) + # RemovedInDjango70Warning. + @ignore_warnings(category=RemovedInDjango70Warning) + def test_using_connection_conflict(self): + msg = "'connection' is not compatible with 'using'." + with self.assertRaisesMessage(TypeError, msg): + send_mail( + "subject", + "body", + "from@example.com", + ["to@example.com"], + connection=object(), + using="default", + ) + + # RemovedInDjango70Warning. + @ignore_warnings(category=RemovedInDjango70Warning) + def test_using_fail_silently_conflict(self): + msg = "'fail_silently' is not compatible with 'using'." + with self.assertRaisesMessage(TypeError, msg): + send_mail( + "subject", + "body", + "from@example.com", + ["to@example.com"], + fail_silently=True, + using="default", + ) + + # RemovedInDjango70Warning. + @ignore_warnings(category=RemovedInDjango70Warning) + def test_using_auth_conflict(self): + msg = ( + "'auth_user' and 'auth_password' are not compatible with 'using'. " + "Set 'username' and 'password' OPTIONS in MAILERS instead." + ) + for param in ["auth_user", "auth_password"]: + with ( + self.subTest(param=param), + self.assertRaisesMessage(TypeError, msg), + ): + send_mail( + "subject", + "body", + "from@example.com", + ["to@example.com"], + using="default", + **{param: "value"}, + ) + + +# RemovedInDjango70Warning: Move override_settings and additional test cases to +# SendMailTests and remove this class. +@override_settings( + MAILERS={ + "default": {"BACKEND": "django.core.mail.backends.locmem.EmailBackend"}, + "custom": {"BACKEND": "django.core.mail.backends.locmem.EmailBackend"}, + } +) +class SendMailTestsWithMailers(SendMailTests): + # Repeat all SendMailTests with MAILERS defined. + + def test_using_arg(self): + # Send using non-default mailer. + send_mail( + "Subject", + "Content", + "from@example.com", + ["to@example.com"], + using="custom", + ) + self.assertEqual(mail.outbox[0].sent_using, "custom") + -class SendMassMailTests(SimpleTestCase): +@ignore_no_default_mailer_warning() +class SendMassMailTests(MailTestsMixin, SimpleTestCase): """Tests for django.core.mail.send_mass_mail().""" def test_send_mass_mail(self): @@ -2000,27 +2212,49 @@ def test_send_mass_mail(self): self.assertEqual(mail.outbox[1].from_email, "from2@example.com") self.assertEqual(mail.outbox[1].to, ["to2a@example.com", "to2b@example.com"]) + def test_sends_using_default_mailer(self): + send_mass_mail( + [("Subject1", "Content1", "from1@example.com", ["to1@example.com"])] + ) + + # RemovedInDjango70Warning. + if not mailers._is_configured: + self.assertIsNone(mail.outbox[0].sent_using) + return + + self.assertEqual(mail.outbox[0].sent_using, "default") + + # RemovedInDjango70Warning. def test_connection_arg(self): # Send using non-default connection. connection = 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.assertWarnsMessage(RemovedInDjango70Warning, CONNECTION_ARG_WARNING): + 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") # Connection is provided to EmailMessage objects (#17811). - self.assertIs(connection.test_outbox[0].connection, connection) - self.assertIs(connection.test_outbox[1].connection, connection) + with ignore_warnings( + category=RemovedInDjango70Warning, + message="The EmailMessage.connection attribute is deprecated.", + ): + self.assertIs(connection.test_outbox[0].connection, connection) + self.assertIs(connection.test_outbox[1].connection, connection) + # RemovedInDjango70Warning. def test_auth_passed_to_backend_init(self): self.addCleanup(OptionsCapturingBackend.reset) - with self.settings(EMAIL_BACKEND="mail.custombackend.OptionsCapturingBackend"): + with ( + self.use_email_backend("mail.custombackend.OptionsCapturingBackend"), + self.assertWarnsMessage(RemovedInDjango70Warning, AUTH_ARGS_WARNING), + ): send_mass_mail( [("Subject1", "Content1", "from1@example.com", ["to1@example.com"])], auth_user="user", @@ -2031,9 +2265,15 @@ def test_auth_passed_to_backend_init(self): self.assertEqual(init_kwargs["username"], "user") self.assertEqual(init_kwargs["password"], "password") + # RemovedInDjango70Warning. def test_fail_silently_passed_to_backend_init(self): self.addCleanup(OptionsCapturingBackend.reset) - with self.settings(EMAIL_BACKEND="mail.custombackend.OptionsCapturingBackend"): + with ( + self.use_email_backend("mail.custombackend.OptionsCapturingBackend"), + self.assertWarnsMessage( + RemovedInDjango70Warning, FAIL_SILENTLY_ARG_WARNING + ), + ): send_mass_mail( [("Subject1", "Content1", "from1@example.com", ["to1@example.com"])], fail_silently=True, @@ -2041,6 +2281,8 @@ def test_fail_silently_passed_to_backend_init(self): init_kwargs = OptionsCapturingBackend.init_kwargs[0] self.assertIs(init_kwargs["fail_silently"], True) + # RemovedInDjango70Warning. + @ignore_warnings(category=RemovedInDjango70Warning) def test_send_fail_silently_conflict(self): datatuple = (("Subject", "Message", "from@example.com", ["to@example.com"]),) msg = ( @@ -2052,6 +2294,8 @@ def test_send_fail_silently_conflict(self): datatuple, fail_silently=True, connection=mail.get_connection() ) + # RemovedInDjango70Warning. + @ignore_warnings(category=RemovedInDjango70Warning) def test_send_auth_conflict(self): datatuple = (("Subject", "Message", "from@example.com", ["to@example.com"]),) msg = ( @@ -2067,7 +2311,49 @@ def test_send_auth_conflict(self): datatuple, **{param: "value"}, connection=mail.get_connection() ) + # RemovedInDjango70Warning. + @ignore_warnings(category=RemovedInDjango70Warning) + def test_using_connection_conflict(self): + msg = "'connection' is not compatible with 'using'." + with self.assertRaisesMessage(TypeError, msg): + send_mass_mail( + [("Subject1", "Content1", "from1@example.com", ["to1@example.com"])], + connection=object(), + using="default", + ) + + # RemovedInDjango70Warning. + @ignore_warnings(category=RemovedInDjango70Warning) + def test_using_fail_silently_conflict(self): + msg = "'fail_silently' is not compatible with 'using'." + with self.assertRaisesMessage(TypeError, msg): + send_mass_mail( + [("Subject1", "Content1", "from1@example.com", ["to1@example.com"])], + fail_silently=True, + using="default", + ) + + +# RemovedInDjango70Warning: Move override_settings and additional test cases to +# SendMassMailTests and remove this class. +@override_settings( + MAILERS={ + "default": {"BACKEND": "django.core.mail.backends.locmem.EmailBackend"}, + "custom": {"BACKEND": "django.core.mail.backends.locmem.EmailBackend"}, + } +) +class SendMassMailTestsWithMailers(SendMassMailTests): + # Repeat all SendMassMailTests with MAILERS defined. + + def test_using_arg(self): + send_mass_mail( + [("Subject1", "Content1", "from1@example.com", ["to1@example.com"])], + using="custom", + ) + self.assertEqual(mail.outbox[0].sent_using, "custom") + +@ignore_no_default_mailer_warning() class MailAdminsAndManagersTests(SimpleTestCase, MailTestsMixin): """Tests for django.core.mail.mail_admins() and mail_managers().""" @@ -2154,6 +2440,19 @@ def test_empty_admins(self): mail_func("hi", "there") self.assertEqual(mail.outbox, []) + @override_settings(ADMINS=["admin@example.com"], MANAGERS=["manager@example.com"]) + def test_sends_using_default_mailer(self): + for mail_func in [mail_admins, mail_managers]: + with self.subTest(mail_func.__name__): + mail_func("Subject", "Content") + + # RemovedInDjango70Warning. + if not mailers._is_configured: + self.assertIsNone(mail.outbox[0].sent_using) + continue + + self.assertEqual(mail.outbox[0].sent_using, "default") + # RemovedInDjango70Warning. def test_deprecated_admins_managers_tuples(self): tests = ( @@ -2212,24 +2511,43 @@ def test_wrong_admins_managers(self): with self.assertRaisesMessage(ImproperlyConfigured, msg): mail_func("subject", "content") + # RemovedInDjango70Warning. @override_settings(ADMINS=["nobody@example.com"]) def test_connection_arg_mail_admins(self): # Send using non-default connection. connection = custombackend.EmailBackend() - mail_admins("Admin message", "Content", connection=connection) + with self.assertWarnsMessage(RemovedInDjango70Warning, CONNECTION_ARG_WARNING): + 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") + # RemovedInDjango70Warning. @override_settings(MANAGERS=["nobody@example.com"]) + @ignore_warnings(category=RemovedInDjango70Warning) def test_connection_arg_mail_managers(self): # Send using non-default connection. connection = custombackend.EmailBackend() - mail_managers("Manager message", "Content", connection=connection) + with self.assertWarnsMessage(RemovedInDjango70Warning, CONNECTION_ARG_WARNING): + 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") + # RemovedInDjango70Warning. + @override_settings(ADMINS=["admin@example.com"], MANAGERS=["manager@example.com"]) + def test_fail_silently_deprecated(self): + for mail_func in [mail_managers, mail_admins]: + with ( + self.subTest(mail_func=mail_func), + self.assertWarnsMessage( + RemovedInDjango70Warning, FAIL_SILENTLY_ARG_WARNING + ), + ): + mail_func("Subject", "Content", fail_silently=True) + + # RemovedInDjango70Warning. + @ignore_warnings(category=RemovedInDjango70Warning) def test_mail_admins_fail_silently_conflict(self): msg = ( "fail_silently cannot be used with a connection. " @@ -2243,6 +2561,8 @@ def test_mail_admins_fail_silently_conflict(self): connection=mail.get_connection(), ) + # RemovedInDjango70Warning. + @ignore_warnings(category=RemovedInDjango70Warning) def test_mail_managers_fail_silently_conflict(self): msg = ( "fail_silently cannot be used with a connection. " @@ -2257,9 +2577,38 @@ def test_mail_managers_fail_silently_conflict(self): ) +# RemovedInDjango70Warning: Move override_settings and additional test cases to +# MailAdminsAndManagersTests and remove this class. +@override_settings( + MAILERS={ + "default": {"BACKEND": "django.core.mail.backends.locmem.EmailBackend"}, + "custom": {"BACKEND": "django.core.mail.backends.locmem.EmailBackend"}, + } +) +class MailAdminsAndManagersTestsWithMailers(MailAdminsAndManagersTests): + # Repeat all MailAdminsAndManagersTests with MAILERS defined. + + @override_settings(ADMINS=["admin@example.com"], MANAGERS=["manager@example.com"]) + def test_using_arg(self): + for mail_func in [mail_admins, mail_managers]: + with self.subTest(mail_func.__name__): + mail_func("Subject", "Content", using="custom") + self.assertEqual(mail.outbox[0].sent_using, "custom") + + +# RemovedInDjango70Warning. +@ignore_warnings(category=RemovedInDjango70Warning) class GetConnectionTests(SimpleTestCase): """Tests for django.core.mail.get_connection().""" + def test_deprecated(self): + msg = ( + "get_connection() is deprecated. See 'Migrating email to mailers' " + "in Django's documentation for recommended replacements." + ) + with self.assertWarnsMessage(RemovedInDjango70Warning, msg): + mail.get_connection() + @override_settings(EMAIL_BACKEND="django.core.mail.backends.console.EmailBackend") def test_uses_email_backend_setting(self): connection = mail.get_connection() @@ -2324,6 +2673,71 @@ def test_arbitrary_keyword(self): c = mail.get_connection(fail_silently=True, foo="bar") self.assertIs(c.fail_silently, True) + @override_settings( + MAILERS={ + "default": { + "BACKEND": "django.core.mail.backends.smtp.EmailBackend", + "OPTIONS": {"host": "mail.example.com"}, + }, + "custom": { + "BACKEND": "django.core.mail.backends.locmem.EmailBackend", + }, + } + ) + def test_mailers_compatibility(self): + # Returns default mailer when MAILERS defined. + with self.subTest("no args"): + backend = mail.get_connection() + self.assertIsInstance(backend, smtp.EmailBackend) + self.assertEqual(backend.host, "mail.example.com") + self.assertIs(backend.fail_silently, False) + + with self.subTest("with fail_silently"): + backend = mail.get_connection(fail_silently=True) + self.assertIsInstance(backend, smtp.EmailBackend) + self.assertEqual(backend.host, "mail.example.com") + self.assertIs(backend.fail_silently, True) + # Original OPTIONS are intact. + self.assertEqual( + settings.MAILERS["default"]["OPTIONS"], + {"host": "mail.example.com"}, + ) + + with self.subTest("with other keyword args"): + backend = mail.get_connection(host="example.net") + self.assertIsInstance(backend, smtp.EmailBackend) + self.assertEqual(backend.host, "example.net") + self.assertIs(backend.fail_silently, False) + self.assertEqual( + settings.MAILERS["default"]["OPTIONS"], + {"host": "mail.example.com"}, + ) + + with self.subTest("with fail_silently and other keyword args"): + backend = mail.get_connection(host="example.net", fail_silently=True) + self.assertIsInstance(backend, smtp.EmailBackend) + self.assertEqual(backend.host, "example.net") + self.assertIs(backend.fail_silently, True) + self.assertEqual( + settings.MAILERS["default"]["OPTIONS"], + {"host": "mail.example.com"}, + ) + + with self.subTest("with backend"): + msg = "get_connection(backend, ...) is not supported with MAILERS." + with self.assertRaisesMessage(RuntimeError, msg): + mail.get_connection( + backend="django.core.mail.backends.dummy.EmailBackend" + ) + + @override_settings( + MAILERS={"custom": {"BACKEND": "django.core.mail.backends.locmem.EmailBackend"}} + ) + def test_mailers_compatibility_no_default_mailer(self): + msg = "The mailer 'default' is not configured." + with self.assertRaisesMessage(MailerDoesNotExist, msg): + mail.get_connection() + # RemovedInDjango70Warning. class DeprecatedInternalsTests(SimpleTestCase): @@ -2507,6 +2921,16 @@ def test_sanitize_address_header_injection(self): # RemovedInDjango70Warning. class MailDeprecatedPositionalArgsTests(SimpleTestCase): + def get_connection(self, *args, **kwargs): + with ( + ignore_no_default_mailer_warning(), + ignore_warnings( + category=RemovedInDjango70Warning, + message=re.escape("get_connection() is deprecated."), + ), + ): + return mail.get_connection(*args, **kwargs) + def assertDeprecatedIn70(self, params, name): return self.assertWarnsMessage( RemovedInDjango70Warning, @@ -2515,7 +2939,7 @@ def assertDeprecatedIn70(self, params, name): def test_get_connection(self): with self.assertDeprecatedIn70("'fail_silently'", "get_connection"): - mail.get_connection( + self.get_connection( "django.core.mail.backends.dummy.EmailBackend", # Deprecated positional arg: True, @@ -2536,7 +2960,7 @@ def test_send_mail(self): None, None, None, - mail.get_connection(), + self.get_connection(), "html message", ) @@ -2551,7 +2975,7 @@ def test_send_mass_mail(self): None, None, None, - mail.get_connection(), + self.get_connection(), ) def test_mail_admins(self): @@ -2563,7 +2987,7 @@ def test_mail_admins(self): "message", # Deprecated positional args: None, - mail.get_connection(), + self.get_connection(), "html message", ) @@ -2576,7 +3000,7 @@ def test_mail_managers(self): "message", # Deprecated positional args: None, - mail.get_connection(), + self.get_connection(), "html message", ) @@ -2592,7 +3016,7 @@ def test_email_message_init(self): ["to@example.com"], # Deprecated positional args: ["bcc@example.com"], - mail.get_connection(), + self.get_connection(), [EmailAttachment("file.txt", "attachment\n", "text/plain")], {"X-Header": "custom header"}, ["cc@example.com"], @@ -2612,7 +3036,7 @@ def test_email_multi_alternatives_init(self): ["to@example.com"], # Deprecated positional args: ["bcc@example.com"], - mail.get_connection(), + self.get_connection(), [EmailAttachment("file.txt", "attachment\n", "text/plain")], {"X-Header": "custom header"}, [EmailAlternative("html body", "text/html")], diff --git a/tests/middleware/tests.py b/tests/middleware/tests.py index a79fcf4577e5..e35b77e073a6 100644 --- a/tests/middleware/tests.py +++ b/tests/middleware/tests.py @@ -7,6 +7,7 @@ from unittest import mock from urllib.parse import quote +from mail import override_deprecated_email_settings from mail.custombackend import FailingEmailBackend from django.conf import settings @@ -406,6 +407,7 @@ class MyCommonMiddleware(CommonMiddleware): @override_settings( IGNORABLE_404_URLS=[re.compile(r"foo")], MANAGERS=["manager@example.com"], + MAILERS={"default": {"BACKEND": "django.core.mail.backends.locmem.EmailBackend"}}, ) class BrokenLinkEmailsMiddlewareTest(SimpleTestCase): rf = RequestFactory() @@ -501,13 +503,48 @@ def test_referer_equal_to_requested_url_without_trailing_slash_with_no_append_sl BrokenLinkEmailsMiddleware(self.get_response)(self.req) self.assertEqual(len(mail.outbox), 1) - @override_settings(EMAIL_BACKEND="mail.custombackend.FailingEmailBackend") + # RemovedInDjango70Warning. + @override_deprecated_email_settings( + EMAIL_BACKEND="mail.custombackend.FailingEmailBackend" + ) def test_sends_using_fail_silently(self): + del settings.MAILERS self.addCleanup(FailingEmailBackend.reset) self.req.META["HTTP_REFERER"] = "/another/url/" BrokenLinkEmailsMiddleware(self.get_response)(self.req) self.assertIs(FailingEmailBackend.init_kwargs[0]["fail_silently"], True) + def test_sends_using_default_mailer(self): + self.req.META["HTTP_REFERER"] = "/another/url/" + BrokenLinkEmailsMiddleware(self.get_response)(self.req) + self.assertEqual(len(mail.outbox), 1) + self.assertEqual(mail.outbox[0].sent_using, "default") + + @override_settings(MAILERS={}) + def test_no_error_when_email_not_configured(self): + self.req.META["HTTP_REFERER"] = "/another/url/" + BrokenLinkEmailsMiddleware(self.get_response)(self.req) + self.assertEqual(len(mail.outbox), 0) + + @override_settings( + MAILERS={ + "custom": {"BACKEND": "django.core.mail.backends.locmem.EmailBackend"} + }, + ) + def test_custom_send_mail(self): + class SubclassedMiddleware(BrokenLinkEmailsMiddleware): + using = "custom" + + def send_mail(self, subject, message, *args, **kwargs): + super().send_mail("custom subject", "custom message", *args, **kwargs) + + self.req.META["HTTP_REFERER"] = "/another/url/" + SubclassedMiddleware(self.get_response)(self.req) + self.assertEqual(len(mail.outbox), 1) + self.assertEqual(mail.outbox[0].sent_using, "custom") + self.assertEqual(mail.outbox[0].subject, "[Django] custom subject") + self.assertEqual(mail.outbox[0].body, "custom message") + @override_settings(ROOT_URLCONF="middleware.cond_get_urls") class ConditionalGetMiddlewareTest(SimpleTestCase): diff --git a/tests/project_template/test_settings.py b/tests/project_template/test_settings.py index ba3dee740503..2c350b263d9c 100644 --- a/tests/project_template/test_settings.py +++ b/tests/project_template/test_settings.py @@ -3,6 +3,8 @@ import tempfile from django import conf +from django.core import mail +from django.core.mail.backends import console from django.test import SimpleTestCase from django.test.utils import extend_sys_path @@ -46,3 +48,11 @@ def test_middleware_headers(self): b"X-Frame-Options: DENY", ], ) + + def test_mailers(self): + with extend_sys_path(self.temp_dir.name): + from test_settings import MAILERS + + with self.settings(MAILERS=MAILERS): + backend = mail.mailers.default + self.assertIsInstance(backend, console.EmailBackend) diff --git a/tests/test_client/tests.py b/tests/test_client/tests.py index 8e62c717eba0..bc1472d88bc5 100644 --- a/tests/test_client/tests.py +++ b/tests/test_client/tests.py @@ -61,7 +61,10 @@ async def middleware(request): return middleware -@override_settings(ROOT_URLCONF="test_client.urls") +@override_settings( + ROOT_URLCONF="test_client.urls", + MAILERS={"default": {"BACKEND": "django.core.mail.backends.locmem.EmailBackend"}}, +) class ClientTest(TestCase): @classmethod def setUpTestData(cls): diff --git a/tests/test_client/views.py b/tests/test_client/views.py index e1691ad56ddd..7bcc7dee1cc8 100644 --- a/tests/test_client/views.py +++ b/tests/test_client/views.py @@ -381,7 +381,7 @@ def mass_mail_sending_view(request): ["second@example.com", "third@example.com"], ) - c = mail.get_connection() + c = mail.mailers.default c.send_messages([m1, m2]) return HttpResponse("Mail sent") diff --git a/tests/test_utils/tests.py b/tests/test_utils/tests.py index e54b9ef62426..3a388cc74018 100644 --- a/tests/test_utils/tests.py +++ b/tests/test_utils/tests.py @@ -1,3 +1,4 @@ +import copy import os import sys import threading @@ -37,6 +38,7 @@ SimpleTestCase, TestCase, TransactionTestCase, + ignore_warnings, skipIfDBFeature, skipUnlessDBFeature, ) @@ -51,6 +53,7 @@ teardown_test_environment, ) from django.urls import NoReverseMatch, path, reverse, reverse_lazy +from django.utils.deprecation import RemovedInDjango70Warning from django.utils.html import VOID_ELEMENTS from .models import Car, Person, PossessedCar @@ -1841,6 +1844,8 @@ def test_allowed_hosts(self): setup_test_environment() self.assertEqual(settings.ALLOWED_HOSTS, ["*", "testserver"]) + # RemovedInDjango70Warning. + @ignore_warnings(category=RemovedInDjango70Warning) def test_email_backend_override(self): with ( self.mock_test_state(), @@ -1853,11 +1858,46 @@ def test_email_backend_override(self): settings.EMAIL_BACKEND, "django.core.mail.backends.locmem.EmailBackend", ) + self.assertFalse(hasattr(settings, "MAILERS")) teardown_test_environment() self.assertEqual( settings.EMAIL_BACKEND, "django.core.mail.backends.console.EmailBackend", ) + self.assertFalse(hasattr(settings, "MAILERS")) + + def test_mailers_override(self): + expected_value = { + "default": { + "OPTIONS": {"host": "localhost"}, + }, + "custom": { + "BACKEND": "path.to.custom.EmailBackend", + "OPTIONS": {"custom": "option"}, + }, + } + settings_value = copy.deepcopy(expected_value) + with self.mock_test_state(), self.settings(MAILERS=settings_value): + setup_test_environment() + self.assertEqual( + settings.MAILERS, + { + "default": { + "BACKEND": "django.core.mail.backends.locmem.EmailBackend" + }, + "custom": { + "BACKEND": "django.core.mail.backends.locmem.EmailBackend" + }, + }, + ) + # setup_test_environment() shouldn't mutate original setting value. + self.assertEqual(settings_value, expected_value) + # RemovedInDjango70Warning: Remove both EMAIL_BACKEND assertions. + self.assertFalse(hasattr(settings, "EMAIL_BACKEND")) + + teardown_test_environment() + self.assertEqual(settings.MAILERS, expected_value) + self.assertFalse(hasattr(settings, "EMAIL_BACKEND")) class OverrideSettingsTests(SimpleTestCase): diff --git a/tests/view_tests/tests/test_debug.py b/tests/view_tests/tests/test_debug.py index 439faff84eba..d8505860a3b2 100644 --- a/tests/view_tests/tests/test_debug.py +++ b/tests/view_tests/tests/test_debug.py @@ -496,6 +496,14 @@ def test_template_override_exception_reporter(self): response = self.client.get("/raises500/", headers={"accept": "text/plain"}) self.assertContains(response, "Oh dear, an error occurred!", status_code=500) + # RemovedInDjango70Warning. + @override_settings(MAILERS={}) + def test_works_with_mailers_defined(self): + with self.assertLogs("django.request", "ERROR"): + response = self.client.get("/raises500/") + self.assertContains(response, "MAILERS", status_code=500) + self.assertNotContains(response, "EMAIL_BACKEND", status_code=500) + class DebugViewQueriesAllowedTests(SimpleTestCase): # May need a query to initialize MySQL connection @@ -1586,7 +1594,10 @@ def verify_paranoid_email(self, view): self.assertNotIn(v, body) -@override_settings(ROOT_URLCONF="view_tests.urls") +@override_settings( + ROOT_URLCONF="view_tests.urls", + MAILERS={"default": {"BACKEND": "django.core.mail.backends.locmem.EmailBackend"}}, +) class ExceptionReporterFilterTests( ExceptionReportTestMixin, LoggingCaptureMixin, SimpleTestCase ): From a9e6c435942daa6d096cf44ae9be4c0f45976f24 Mon Sep 17 00:00:00 2001 From: VAIBHAVPANT07 Date: Wed, 13 May 2026 18:36:30 +0530 Subject: [PATCH 2/3] Fixed #37098 -- Added dynamically linked binary env vars to tox passenv. --- tox.ini | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 31f374abe352..8f534a035b6a 100644 --- a/tox.ini +++ b/tox.ini @@ -23,7 +23,17 @@ basepython = python3 [testenv] usedevelop = true # OBJC_DISABLE_INITIALIZE_FORK_SAFETY fixes hung tests for MacOS users. (#30806) -passenv = DJANGO_SETTINGS_MODULE,PYTHONPATH,HOME,DISPLAY,OBJC_DISABLE_INITIALIZE_FORK_SAFETY +# LIBMEMCACHED, GDAL_LIBRARY_PATH, GEOS_LIBRARY_PATH, SPATIALITE_LIBRARY_PATH specify library paths. +passenv = + DJANGO_SETTINGS_MODULE + PYTHONPATH + HOME + DISPLAY + OBJC_DISABLE_INITIALIZE_FORK_SAFETY + LIBMEMCACHED + GDAL_LIBRARY_PATH + GEOS_LIBRARY_PATH + SPATIALITE_LIBRARY_PATH setenv = PYTHONDONTWRITEBYTECODE=1 deps = From ed5486376989c08b34cc62e7c8ba4a07bd60047d Mon Sep 17 00:00:00 2001 From: Jacob Walls Date: Wed, 13 May 2026 17:18:10 -0400 Subject: [PATCH 3/3] Fixed #37092, Refs #35870 -- Added missing deprecation warnings for USE_BLANK_CHOICE_DASH. Follow-up to 63c56cda133a85a158502891c40465bc0331d3d9. Modeled on 5d80843ebc5376d00f98bf2a6aadbada4c29365c. --- django/conf/__init__.py | 19 +++++++-- .../deprecation/test_use_blank_choice_dash.py | 41 +++++++++++++++++++ 2 files changed, 56 insertions(+), 4 deletions(-) create mode 100644 tests/deprecation/test_use_blank_choice_dash.py diff --git a/django/conf/__init__.py b/django/conf/__init__.py index 6d67e18209e2..5bf4cf13ae09 100644 --- a/django/conf/__init__.py +++ b/django/conf/__init__.py @@ -25,6 +25,7 @@ DEFAULT_STORAGE_ALIAS = "default" STATICFILES_STORAGE_ALIAS = "staticfiles" +# RemovedInDjango70Warning. USE_BLANK_CHOICE_DASH_DEPRECATED_MSG = ( "The USE_BLANK_CHOICE_DASH setting is deprecated. If you wish to define " "your own default blank choice label, override " @@ -148,6 +149,11 @@ def __setattr__(self, name, value): self.__dict__.pop(name, None) # RemovedInDjango70Warning. + if name == "USE_BLANK_CHOICE_DASH": + _show_settings_deprecation_warning( + USE_BLANK_CHOICE_DASH_DEPRECATED_MSG, RemovedInDjango70Warning + ) + # RemovedInDjango70Warning. if name == "MAILERS": # When MAILERS is set, clear any cached values of # deprecated settings so that __getattr__() runs again for them. @@ -254,6 +260,13 @@ def __init__(self, settings_module): self._explicit_settings.add(setting) # RemovedInDjango70Warning. + if "USE_BLANK_CHOICE_DASH" in self._explicit_settings: + warnings.warn( + USE_BLANK_CHOICE_DASH_DEPRECATED_MSG, + RemovedInDjango70Warning, + skip_file_prefixes=django_file_prefixes(), + ) + # RemovedInDjango70Warning. _check_email_settings_conflicts(self._explicit_settings) for name in DEPRECATED_EMAIL_SETTINGS.intersection(self._explicit_settings): warnings.warn( @@ -307,10 +320,8 @@ def __getattr__(self, name): def __setattr__(self, name, value): self._deleted.discard(name) if name == "USE_BLANK_CHOICE_DASH": - warnings.warn( - USE_BLANK_CHOICE_DASH_DEPRECATED_MSG, - RemovedInDjango70Warning, - skip_file_prefixes=django_file_prefixes(), + _show_settings_deprecation_warning( + USE_BLANK_CHOICE_DASH_DEPRECATED_MSG, RemovedInDjango70Warning ) # RemovedInDjango70Warning. if name in DEPRECATED_EMAIL_SETTINGS: diff --git a/tests/deprecation/test_use_blank_choice_dash.py b/tests/deprecation/test_use_blank_choice_dash.py new file mode 100644 index 000000000000..8867568535a4 --- /dev/null +++ b/tests/deprecation/test_use_blank_choice_dash.py @@ -0,0 +1,41 @@ +import sys +from types import ModuleType + +from django.conf import ( + USE_BLANK_CHOICE_DASH_DEPRECATED_MSG, + LazySettings, + Settings, + settings, +) +from django.test import SimpleTestCase +from django.utils.deprecation import RemovedInDjango70Warning + + +# RemovedInDjango70Warning. +class UseBlankChoiceDashDeprecationTests(SimpleTestCase): + msg = USE_BLANK_CHOICE_DASH_DEPRECATED_MSG + + def test_override_settings_warning(self): + with self.assertRaisesMessage(RemovedInDjango70Warning, self.msg): + with self.settings(USE_BLANK_CHOICE_DASH=True): + pass + + def test_settings_init_warning(self): + settings_module = ModuleType("fake_settings_module") + settings_module.USE_TZ = False + settings_module.USE_BLANK_CHOICE_DASH = True + sys.modules["fake_settings_module"] = settings_module + try: + with self.assertRaisesMessage(RemovedInDjango70Warning, self.msg): + Settings("fake_settings_module") + finally: + del sys.modules["fake_settings_module"] + + def test_settings_assignment_warning(self): + settings = LazySettings() + with self.assertRaisesMessage(RemovedInDjango70Warning, self.msg): + settings.USE_BLANK_CHOICE_DASH = True + + def test_access(self): + # Warning is not raised on access. + self.assertEqual(settings.USE_BLANK_CHOICE_DASH, False)