diff --git a/django/conf/__init__.py b/django/conf/__init__.py index 16f5d9b575dc..5bf4cf13ae09 100644 --- a/django/conf/__init__.py +++ b/django/conf/__init__.py @@ -25,12 +25,44 @@ 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 " "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 +117,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 +147,23 @@ def __setattr__(self, name, value): self.__dict__.clear() else: 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. + 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 +171,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 +193,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 +259,22 @@ def __init__(self, settings_module): setattr(self, setting, setting_value) 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( + 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. @@ -227,11 +320,15 @@ 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: + _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/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) 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 ): 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 =