1
0
mirror of https://github.com/django/django.git synced 2025-08-21 01:09:13 +00:00

Fixed #35581 -- Updated django.core.mail to Python's modern email API.

- Changed EmailMessage.message() to construct a "modern email API"
  email.message.EmailMessage and added policy keyword arg.
- Added support for modern MIMEPart objects in EmailMessage.attach()
  (and EmailMessage constructor, EmailMessage.attachments list).
- Updated SMTP EmailBackend to use modern email.policy.SMTP.

Deprecated:
- Attaching MIMEBase objects (replace with MIMEPart)
- BadHeaderError (modern email uses ValueError)
- SafeMIMEText, SafeMIMEMultipart (unnecessary for modern email)
- django.core.mail.forbid_multi_line_headers()
  (undocumented, but exposed via `__all__` and in wide use)
- django.core.mail.message.sanitize_address()
  (undocumented, but in wide use)

Removed without deprecation (all undocumented):
- EmailMessage.mixed_subtype
- EmailMultiAlternatives.alternative_subtype
- Support for setting (undocumented) EmailMessage.encoding property
  to a legacy email.charset.Charset object

Related changes:
- Dropped tests for incorrect RFC 2047 encoding of non-ASCII email
  address localparts. This is specifically prohibited by RFC 2047, and
  not supported by any known MTA or email client. (Python still
  mis-applies encoded-word to non-ASCII localparts, but it is a bug that
  may be fixed in the future.)
- Added tests that try to discourage using Python's legacy email APIs
  in future updates to django.core.mail.
This commit is contained in:
Mike Edmunds 2024-12-23 15:01:36 -08:00 committed by Sarah Boyce
parent 20c62199ff
commit 9ab1991689
9 changed files with 846 additions and 218 deletions

View File

@ -13,13 +13,10 @@ from django.core.exceptions import ImproperlyConfigured
# backends and the subsequent reorganization (See #10355) # backends and the subsequent reorganization (See #10355)
from django.core.mail.message import ( from django.core.mail.message import (
DEFAULT_ATTACHMENT_MIME_TYPE, DEFAULT_ATTACHMENT_MIME_TYPE,
BadHeaderError,
EmailAlternative, EmailAlternative,
EmailAttachment, EmailAttachment,
EmailMessage, EmailMessage,
EmailMultiAlternatives, EmailMultiAlternatives,
SafeMIMEMultipart,
SafeMIMEText,
forbid_multi_line_headers, forbid_multi_line_headers,
make_msgid, make_msgid,
) )
@ -33,12 +30,8 @@ __all__ = [
"DNS_NAME", "DNS_NAME",
"EmailMessage", "EmailMessage",
"EmailMultiAlternatives", "EmailMultiAlternatives",
"SafeMIMEText",
"SafeMIMEMultipart",
"DEFAULT_ATTACHMENT_MIME_TYPE", "DEFAULT_ATTACHMENT_MIME_TYPE",
"make_msgid", "make_msgid",
"BadHeaderError",
"forbid_multi_line_headers",
"get_connection", "get_connection",
"send_mail", "send_mail",
"send_mass_mail", "send_mass_mail",
@ -46,6 +39,12 @@ __all__ = [
"mail_managers", "mail_managers",
"EmailAlternative", "EmailAlternative",
"EmailAttachment", "EmailAttachment",
# RemovedInDjango70Warning: When the deprecation ends, remove the last
# entries.
"BadHeaderError",
"SafeMIMEText",
"SafeMIMEMultipart",
"forbid_multi_line_headers",
] ]
@ -224,3 +223,31 @@ def mail_managers(
fail_silently=fail_silently, fail_silently=fail_silently,
connection=connection, connection=connection,
) )
# RemovedInDjango70Warning.
_deprecate_on_import = {
"BadHeaderError": "BadHeaderError is deprecated. Replace with ValueError.",
"SafeMIMEText": (
"SafeMIMEText is deprecated. The return value"
" of EmailMessage.message() is an email.message.EmailMessage."
),
"SafeMIMEMultipart": (
"SafeMIMEMultipart is deprecated. The return value"
" of EmailMessage.message() is an email.message.EmailMessage."
),
}
# RemovedInDjango70Warning.
def __getattr__(name):
try:
msg = _deprecate_on_import[name]
except KeyError:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from None
else:
# Issue deprecation warnings at time of import.
from django.core.mail import message
warnings.warn(msg, category=RemovedInDjango70Warning)
return getattr(message, name)

View File

@ -1,13 +1,15 @@
"""SMTP email backend class.""" """SMTP email backend class."""
import email.policy
import smtplib import smtplib
import ssl import ssl
import threading import threading
from email.headerregistry import Address, AddressHeader
from django.conf import settings from django.conf import settings
from django.core.mail.backends.base import BaseEmailBackend from django.core.mail.backends.base import BaseEmailBackend
from django.core.mail.message import sanitize_address
from django.core.mail.utils import DNS_NAME from django.core.mail.utils import DNS_NAME
from django.utils.encoding import force_str, punycode
from django.utils.functional import cached_property from django.utils.functional import cached_property
@ -145,18 +147,47 @@ class EmailBackend(BaseEmailBackend):
"""A helper method that does the actual sending.""" """A helper method that does the actual sending."""
if not email_message.recipients(): if not email_message.recipients():
return False return False
encoding = email_message.encoding or settings.DEFAULT_CHARSET from_email = self.prep_address(email_message.from_email)
from_email = sanitize_address(email_message.from_email, encoding) recipients = [self.prep_address(addr) for addr in email_message.recipients()]
recipients = [ message = email_message.message(policy=email.policy.SMTP)
sanitize_address(addr, encoding) for addr in email_message.recipients()
]
message = email_message.message()
try: try:
self.connection.sendmail( self.connection.sendmail(from_email, recipients, message.as_bytes())
from_email, recipients, message.as_bytes(linesep="\r\n")
)
except smtplib.SMTPException: except smtplib.SMTPException:
if not self.fail_silently: if not self.fail_silently:
raise raise
return False return False
return True return True
def prep_address(self, address, force_ascii=True):
"""
Return the addr-spec portion of an email address. Raises ValueError for
invalid addresses, including CR/NL injection.
If force_ascii is True, apply IDNA encoding to non-ASCII domains, and
raise ValueError for non-ASCII local-parts (which can't be encoded).
Otherwise, leave Unicode characters unencoded (e.g., for sending with
SMTPUTF8).
"""
address = force_str(address)
parsed = AddressHeader.value_parser(address)
defects = set(str(defect) for defect in parsed.all_defects)
# Django allows local mailboxes like "From: webmaster" (#15042).
defects.discard("addr-spec local part with no domain")
if not force_ascii:
# Non-ASCII local-part is valid with SMTPUTF8. Remove once
# https://github.com/python/cpython/issues/81074 is fixed.
defects.discard("local-part contains non-ASCII characters)")
if defects:
raise ValueError(f"Invalid address {address!r}: {'; '.join(defects)}")
mailboxes = parsed.all_mailboxes
if len(mailboxes) != 1:
raise ValueError(f"Invalid address {address!r}: must be a single address")
mailbox = mailboxes[0]
if force_ascii and mailbox.domain and not mailbox.domain.isascii():
# Re-compose an addr-spec with the IDNA encoded domain.
domain = punycode(mailbox.domain)
return str(Address(username=mailbox.local_part, domain=domain))
else:
return mailbox.addr_spec

View File

@ -1,17 +1,19 @@
import email.message
import email.policy
import mimetypes import mimetypes
import warnings
from collections import namedtuple from collections import namedtuple
from datetime import datetime, timezone
from email import charset as Charset from email import charset as Charset
from email import encoders as Encoders from email import generator
from email import generator, message_from_bytes
from email.errors import HeaderParseError from email.errors import HeaderParseError
from email.header import Header from email.header import Header
from email.headerregistry import Address, parser from email.headerregistry import Address, AddressHeader, parser
from email.message import Message
from email.mime.base import MIMEBase from email.mime.base import MIMEBase
from email.mime.message import MIMEMessage from email.mime.message import MIMEMessage
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText from email.mime.text import MIMEText
from email.utils import formataddr, formatdate, getaddresses, make_msgid from email.utils import formataddr, getaddresses, make_msgid
from io import BytesIO, StringIO from io import BytesIO, StringIO
from pathlib import Path from pathlib import Path
@ -19,7 +21,9 @@ from django.conf import settings
from django.core.mail.utils import DNS_NAME from django.core.mail.utils import DNS_NAME
from django.utils.deprecation import RemovedInDjango70Warning, deprecate_posargs from django.utils.deprecation import RemovedInDjango70Warning, deprecate_posargs
from django.utils.encoding import force_bytes, force_str, punycode from django.utils.encoding import force_bytes, force_str, punycode
from django.utils.timezone import get_current_timezone
# RemovedInDjango70Warning.
# Don't BASE64-encode UTF-8 messages so that we avoid unwanted attention from # Don't BASE64-encode UTF-8 messages so that we avoid unwanted attention from
# some spam filters. # some spam filters.
utf8_charset = Charset.Charset("utf-8") utf8_charset = Charset.Charset("utf-8")
@ -31,13 +35,17 @@ utf8_charset_qp.body_encoding = Charset.QP
# and cannot be guessed). # and cannot be guessed).
DEFAULT_ATTACHMENT_MIME_TYPE = "application/octet-stream" DEFAULT_ATTACHMENT_MIME_TYPE = "application/octet-stream"
# RemovedInDjango70Warning.
RFC5322_EMAIL_LINE_LENGTH_LIMIT = 998 RFC5322_EMAIL_LINE_LENGTH_LIMIT = 998
class BadHeaderError(ValueError): # RemovedInDjango70Warning.
pass # BadHeaderError must be ValueError (not subclass it), so that existing code
# with `except BadHeaderError` will catch the ValueError that Python's modern
# email API raises for headers containing CR or NL.
BadHeaderError = ValueError
# RemovedInDjango70Warning.
# Header names that contain structured address data (RFC 5322). # Header names that contain structured address data (RFC 5322).
ADDRESS_HEADERS = { ADDRESS_HEADERS = {
"from", "from",
@ -54,8 +62,16 @@ ADDRESS_HEADERS = {
} }
# RemovedInDjango70Warning.
def forbid_multi_line_headers(name, val, encoding): def forbid_multi_line_headers(name, val, encoding):
"""Forbid multi-line headers to prevent header injection.""" """Forbid multi-line headers to prevent header injection."""
warnings.warn(
"The internal API forbid_multi_line_headers() is deprecated."
" Python's modern email API (with email.message.EmailMessage or"
" email.policy.default) will reject multi-line headers.",
RemovedInDjango70Warning,
)
encoding = encoding or settings.DEFAULT_CHARSET encoding = encoding or settings.DEFAULT_CHARSET
val = str(val) # val may be lazy val = str(val) # val may be lazy
if "\n" in val or "\r" in val: if "\n" in val or "\r" in val:
@ -77,10 +93,20 @@ def forbid_multi_line_headers(name, val, encoding):
return name, val return name, val
# RemovedInDjango70Warning.
def sanitize_address(addr, encoding): def sanitize_address(addr, encoding):
""" """
Format a pair of (name, address) or an email address string. Format a pair of (name, address) or an email address string.
""" """
warnings.warn(
"The internal API sanitize_address() is deprecated."
" Python's modern email API (with email.message.EmailMessage or"
" email.policy.default) will handle most required validation and"
" encoding. Use Python's email.headerregistry.Address to construct"
" formatted addresses from component parts.",
RemovedInDjango70Warning,
)
address = None address = None
if not isinstance(addr, tuple): if not isinstance(addr, tuple):
addr = force_str(addr) addr = force_str(addr)
@ -123,6 +149,7 @@ def sanitize_address(addr, encoding):
return formataddr((nm, parsed_address.addr_spec)) return formataddr((nm, parsed_address.addr_spec))
# RemovedInDjango70Warning.
class MIMEMixin: class MIMEMixin:
def as_string(self, unixfrom=False, linesep="\n"): def as_string(self, unixfrom=False, linesep="\n"):
"""Return the entire formatted message as a string. """Return the entire formatted message as a string.
@ -151,6 +178,7 @@ class MIMEMixin:
return fp.getvalue() return fp.getvalue()
# RemovedInDjango70Warning.
class SafeMIMEMessage(MIMEMixin, MIMEMessage): class SafeMIMEMessage(MIMEMixin, MIMEMessage):
def __setitem__(self, name, val): def __setitem__(self, name, val):
# Per RFC 2046 Section 5.2.1, message/rfc822 attachment headers must be # Per RFC 2046 Section 5.2.1, message/rfc822 attachment headers must be
@ -159,6 +187,7 @@ class SafeMIMEMessage(MIMEMixin, MIMEMessage):
MIMEMessage.__setitem__(self, name, val) MIMEMessage.__setitem__(self, name, val)
# RemovedInDjango70Warning.
class SafeMIMEText(MIMEMixin, MIMEText): class SafeMIMEText(MIMEMixin, MIMEText):
def __init__(self, _text, _subtype="plain", _charset=None): def __init__(self, _text, _subtype="plain", _charset=None):
self.encoding = _charset self.encoding = _charset
@ -181,6 +210,7 @@ class SafeMIMEText(MIMEMixin, MIMEText):
MIMEText.set_payload(self, payload, charset=charset) MIMEText.set_payload(self, payload, charset=charset)
# RemovedInDjango70Warning.
class SafeMIMEMultipart(MIMEMixin, MIMEMultipart): class SafeMIMEMultipart(MIMEMixin, MIMEMultipart):
def __init__( def __init__(
self, _subtype="mixed", boundary=None, _subparts=None, encoding=None, **_params self, _subtype="mixed", boundary=None, _subparts=None, encoding=None, **_params
@ -201,8 +231,10 @@ class EmailMessage:
"""A container for email information.""" """A container for email information."""
content_subtype = "plain" content_subtype = "plain"
mixed_subtype = "mixed"
encoding = None # None => use settings default # Undocumented charset to use for text/* message bodies and attachments.
# If None, defaults to settings.DEFAULT_CHARSET.
encoding = None
@deprecate_posargs( @deprecate_posargs(
RemovedInDjango70Warning, RemovedInDjango70Warning,
@ -263,7 +295,10 @@ class EmailMessage:
self.attachments = [] self.attachments = []
if attachments: if attachments:
for attachment in attachments: for attachment in attachments:
if isinstance(attachment, MIMEBase): if isinstance(attachment, email.message.MIMEPart):
self.attach(attachment)
elif isinstance(attachment, MIMEBase):
# RemovedInDjango70Warning.
self.attach(attachment) self.attach(attachment)
else: else:
self.attach(*attachment) self.attach(*attachment)
@ -277,12 +312,13 @@ class EmailMessage:
self.connection = get_connection(fail_silently=fail_silently) self.connection = get_connection(fail_silently=fail_silently)
return self.connection return self.connection
def message(self): def message(self, *, policy=email.policy.default):
encoding = self.encoding or settings.DEFAULT_CHARSET msg = email.message.EmailMessage(policy=policy)
msg = SafeMIMEText(self.body, self.content_subtype, encoding) self._add_bodies(msg)
msg = self._create_message(msg) self._add_attachments(msg)
msg["Subject"] = self.subject
msg["From"] = self.extra_headers.get("From", self.from_email) msg["Subject"] = str(self.subject)
msg["From"] = str(self.extra_headers.get("From", self.from_email))
self._set_list_header_if_not_empty(msg, "To", self.to) self._set_list_header_if_not_empty(msg, "To", self.to)
self._set_list_header_if_not_empty(msg, "Cc", self.cc) self._set_list_header_if_not_empty(msg, "Cc", self.cc)
self._set_list_header_if_not_empty(msg, "Reply-To", self.reply_to) self._set_list_header_if_not_empty(msg, "Reply-To", self.reply_to)
@ -291,18 +327,19 @@ class EmailMessage:
# accommodate that when doing comparisons. # accommodate that when doing comparisons.
header_names = [key.lower() for key in self.extra_headers] header_names = [key.lower() for key in self.extra_headers]
if "date" not in header_names: if "date" not in header_names:
# formatdate() uses stdlib methods to format the date, which use if settings.EMAIL_USE_LOCALTIME:
# the stdlib/OS concept of a timezone, however, Django sets the tz = get_current_timezone()
# TZ environment variable based on the TIME_ZONE setting which else:
# will get picked up by formatdate(). tz = timezone.utc
msg["Date"] = formatdate(localtime=settings.EMAIL_USE_LOCALTIME) msg["Date"] = datetime.now(tz)
if "message-id" not in header_names: if "message-id" not in header_names:
# Use cached DNS_NAME for performance # Use cached DNS_NAME for performance
msg["Message-ID"] = make_msgid(domain=DNS_NAME) msg["Message-ID"] = make_msgid(domain=DNS_NAME)
for name, value in self.extra_headers.items(): for name, value in self.extra_headers.items():
# Avoid headers handled above. # Avoid headers handled above.
if name.lower() not in {"from", "to", "cc", "reply-to"}: if name.lower() not in {"from", "to", "cc", "reply-to"}:
msg[name] = value msg[name] = force_str(value, strings_only=True)
self._idna_encode_address_header_domains(msg)
return msg return msg
def recipients(self): def recipients(self):
@ -332,7 +369,19 @@ class EmailMessage:
specified as content, decode it as UTF-8. If that fails, set the specified as content, decode it as UTF-8. If that fails, set the
mimetype to DEFAULT_ATTACHMENT_MIME_TYPE and don't decode the content. mimetype to DEFAULT_ATTACHMENT_MIME_TYPE and don't decode the content.
""" """
if isinstance(filename, MIMEBase): if isinstance(filename, email.message.MIMEPart):
if content is not None or mimetype is not None:
raise ValueError(
"content and mimetype must not be given when a MIMEPart "
"instance is provided."
)
self.attachments.append(filename)
elif isinstance(filename, MIMEBase):
warnings.warn(
"MIMEBase attachments are deprecated."
" Use an email.message.MIMEPart instead.",
RemovedInDjango70Warning,
)
if content is not None or mimetype is not None: if content is not None or mimetype is not None:
raise ValueError( raise ValueError(
"content and mimetype must not be given when a MIMEBase " "content and mimetype must not be given when a MIMEBase "
@ -376,77 +425,75 @@ class EmailMessage:
content = file.read() content = file.read()
self.attach(path.name, content, mimetype) self.attach(path.name, content, mimetype)
def _create_message(self, msg): def _add_bodies(self, msg):
return self._create_attachments(msg) if self.body or not self.attachments:
def _create_attachments(self, msg):
if self.attachments:
encoding = self.encoding or settings.DEFAULT_CHARSET encoding = self.encoding or settings.DEFAULT_CHARSET
body_msg = msg body = force_str(
msg = SafeMIMEMultipart(_subtype=self.mixed_subtype, encoding=encoding) self.body or "", encoding=encoding, errors="surrogateescape"
if self.body or body_msg.is_multipart(): )
msg.attach(body_msg) msg.set_content(body, subtype=self.content_subtype, charset=encoding)
def _add_attachments(self, msg):
if self.attachments:
if hasattr(self, "mixed_subtype"):
# RemovedInDjango70Warning.
raise AttributeError(
"EmailMessage no longer supports the"
" undocumented `mixed_subtype` attribute"
)
msg.make_mixed()
for attachment in self.attachments: for attachment in self.attachments:
if isinstance(attachment, MIMEBase): if isinstance(attachment, email.message.MIMEPart):
msg.attach(attachment)
elif isinstance(attachment, MIMEBase):
# RemovedInDjango70Warning.
msg.attach(attachment) msg.attach(attachment)
else: else:
msg.attach(self._create_attachment(*attachment)) self._add_attachment(msg, *attachment)
return msg
def _create_mime_attachment(self, content, mimetype): def _add_attachment(self, msg, filename, content, mimetype):
""" encoding = self.encoding or settings.DEFAULT_CHARSET
Convert the content, mimetype pair into a MIME attachment object. maintype, subtype = mimetype.split("/", 1)
If the mimetype is message/rfc822, content may be an if maintype == "text" and isinstance(content, bytes):
email.Message or EmailMessage object, as well as a str.
"""
basetype, subtype = mimetype.split("/", 1)
if basetype == "text" and isinstance(content, bytes):
# This duplicates logic from EmailMessage.attach() to properly # This duplicates logic from EmailMessage.attach() to properly
# handle EmailMessage.attachments not created through attach(). # handle EmailMessage.attachments not created through attach().
try: try:
content = content.decode() content = content.decode()
except UnicodeDecodeError: except UnicodeDecodeError:
mimetype = DEFAULT_ATTACHMENT_MIME_TYPE mimetype = DEFAULT_ATTACHMENT_MIME_TYPE
basetype, subtype = mimetype.split("/", 1) maintype, subtype = mimetype.split("/", 1)
if basetype == "text": # See email.contentmanager.set_content() docs for the cases here.
encoding = self.encoding or settings.DEFAULT_CHARSET if maintype == "text":
attachment = SafeMIMEText(content, subtype, encoding) # For text/*, content must be str, and maintype cannot be provided.
elif basetype == "message" and subtype == "rfc822": msg.add_attachment(
# Bug #18967: Per RFC 2046 Section 5.2.1, message/rfc822 content, subtype=subtype, filename=filename, charset=encoding
# attachments must not be base64 encoded. )
if isinstance(content, EmailMessage): elif maintype == "message":
# convert content into an email.Message first # For message/*, content must be email.message.EmailMessage (or
content = content.message() # legacy email.message.Message), and maintype cannot be provided.
elif not isinstance(content, Message): if isinstance(content, EmailMessage):
# For compatibility with existing code, parse the message # Django EmailMessage.
# into an email.Message object if it is not one already. content = content.message(policy=msg.policy)
content = message_from_bytes(force_bytes(content)) elif not isinstance(
content, (email.message.EmailMessage, email.message.Message)
attachment = SafeMIMEMessage(content, subtype) ):
else: content = email.message_from_bytes(
# Encode non-text attachments with base64. force_bytes(content), policy=msg.policy
attachment = MIMEBase(basetype, subtype) )
attachment.set_payload(content) msg.add_attachment(content, subtype=subtype, filename=filename)
Encoders.encode_base64(attachment) else:
return attachment # For all other types, content must be bytes-like, and both
# maintype and subtype must be provided.
def _create_attachment(self, filename, content, mimetype=None): if not isinstance(content, (bytes, bytearray, memoryview)):
""" content = force_bytes(content)
Convert the filename, content, mimetype triple into a MIME attachment msg.add_attachment(
object. content,
""" maintype=maintype,
attachment = self._create_mime_attachment(content, mimetype) subtype=subtype,
if filename: filename=filename,
try:
filename.encode("ascii")
except UnicodeEncodeError:
filename = ("utf-8", "", filename)
attachment.add_header(
"Content-Disposition", "attachment", filename=filename
) )
return attachment
def _set_list_header_if_not_empty(self, msg, header, values): def _set_list_header_if_not_empty(self, msg, header, values):
""" """
@ -459,6 +506,37 @@ class EmailMessage:
if values: if values:
msg[header] = ", ".join(str(v) for v in values) msg[header] = ", ".join(str(v) for v in values)
def _idna_encode_address_header_domains(self, msg):
"""
If msg.policy does not permit utf8 in headers, IDNA encode all
non-ASCII domains in its address headers.
"""
# Avoids a problem where Python's email incorrectly converts non-ASCII
# domains to RFC 2047 encoded-words:
# https://github.com/python/cpython/issues/83938.
# This applies to the domain only, not to the localpart (username).
# There is no RFC that permits any 7-bit encoding for non-ASCII
# characters before the '@'.
if not getattr(msg.policy, "utf8", False):
# Not using SMTPUTF8, so apply IDNA encoding in all address
# headers. IDNA encoding does not alter domains that are already
# ASCII.
for field, value in msg.items():
if isinstance(value, AddressHeader) and any(
not addr.domain.isascii() for addr in value.addresses
):
msg.replace_header(
field,
[
Address(
display_name=addr.display_name,
username=addr.username,
domain=punycode(addr.domain),
)
for addr in value.addresses
],
)
class EmailMultiAlternatives(EmailMessage): class EmailMultiAlternatives(EmailMessage):
""" """
@ -467,8 +545,6 @@ class EmailMultiAlternatives(EmailMessage):
made easier. made easier.
""" """
alternative_subtype = "alternative"
@deprecate_posargs( @deprecate_posargs(
RemovedInDjango70Warning, RemovedInDjango70Warning,
[ [
@ -522,24 +598,28 @@ class EmailMultiAlternatives(EmailMessage):
raise ValueError("Both content and mimetype must be provided.") raise ValueError("Both content and mimetype must be provided.")
self.alternatives.append(EmailAlternative(content, mimetype)) self.alternatives.append(EmailAlternative(content, mimetype))
def _create_message(self, msg): def _add_bodies(self, msg):
return self._create_attachments(self._create_alternatives(msg)) if self.body or not self.alternatives:
super()._add_bodies(msg)
def _create_alternatives(self, msg):
encoding = self.encoding or settings.DEFAULT_CHARSET
if self.alternatives: if self.alternatives:
body_msg = msg if hasattr(self, "alternative_subtype"):
msg = SafeMIMEMultipart( # RemovedInDjango70Warning.
_subtype=self.alternative_subtype, encoding=encoding raise AttributeError(
) "EmailMultiAlternatives no longer supports the"
if self.body: " undocumented `alternative_subtype` attribute"
msg.attach(body_msg)
for alternative in self.alternatives:
msg.attach(
self._create_mime_attachment(
alternative.content, alternative.mimetype
)
) )
msg.make_alternative()
encoding = self.encoding or settings.DEFAULT_CHARSET
for alternative in self.alternatives:
maintype, subtype = alternative.mimetype.split("/", 1)
content = alternative.content
if maintype == "text":
if isinstance(content, bytes):
content = content.decode()
msg.add_alternative(content, subtype=subtype, charset=encoding)
else:
content = force_bytes(content, encoding=encoding, strings_only=True)
msg.add_alternative(content, maintype=maintype, subtype=subtype)
return msg return msg
def body_contains(self, text): def body_contains(self, text):

View File

@ -41,6 +41,18 @@ details on these changes.
* The :mod:`django.core.mail` APIs will no longer accept certain parameters as * The :mod:`django.core.mail` APIs will no longer accept certain parameters as
positional arguments. These must be passed as keyword arguments instead. positional arguments. These must be passed as keyword arguments instead.
* Support for passing Python's legacy email ``email.mime.base.MIMEBase``
object to ``EmailMessage.attach()`` (or including one in the message's
``attachments`` list) will be removed.
* The ``django.core.mail.BadHeaderError`` exception will be removed.
* The ``django.core.mail.SafeMIMEText`` and ``SafeMIMEMultipart`` classes will
be removed.
* The ``django.core.mail.forbid_multi_line_headers()`` and
``django.core.mail.message.sanitize_address()`` functions will be removed.
.. _deprecation-removed-in-6.1: .. _deprecation-removed-in-6.1:
6.1 6.1

View File

@ -74,6 +74,23 @@ To get started, follow the :doc:`CSP how-to guide </howto/csp>`. For in-depth
guidance, see the :ref:`CSP security overview <security-csp>` and the guidance, see the :ref:`CSP security overview <security-csp>` and the
:doc:`reference docs </ref/csp>`. :doc:`reference docs </ref/csp>`.
Adoption of Python's modern email API
-------------------------------------
Email handling in Django now uses Python's modern email API, introduced in
Python 3.6. This API, centered around the
:py:class:`email.message.EmailMessage` class, offers a cleaner and
Unicode-friendly interface for composing and sending emails. It replaces use of
Python's older legacy (``Compat32``) API, which relied on lower-level MIME
classes (from :py:mod:`email.mime`) and required more manual handling of
message structure and encoding.
Notably, the return type of the :class:`EmailMessage.message()
<django.core.mail.EmailMessage>` method is now an instance of Python's
:py:class:`email.message.EmailMessage`. This supports the same API as the
previous ``SafeMIMEText`` and ``SafeMIMEMultipart`` return types, but is not an
instance of those now-deprecated classes.
Minor features Minor features
-------------- --------------
@ -193,7 +210,13 @@ Decorators
Email Email
~~~~~ ~~~~~
* ... * The new ``policy`` argument for :class:`EmailMessage.message()
<django.core.mail.EmailMessage>` allows specifying the email policy, the set
of rules for updating and serializing the representation of the message.
Defaults to :py:data:`email.policy.default`.
* :class:`EmailMessage.attach() <django.core.mail.EmailMessage>` now accepts a
:class:`~email.message.MIMEPart` object from Python's modern email API.
Error Reporting Error Reporting
~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~
@ -387,6 +410,22 @@ of each library are the first to add or confirm compatibility with Python 3.12:
* ``sqlparse`` 0.5.0 * ``sqlparse`` 0.5.0
* ``tblib`` 3.0.0 * ``tblib`` 3.0.0
Email
-----
* The undocumented ``mixed_subtype`` and ``alternative_subtype`` properties
of :class:`~django.core.mail.EmailMessage` and
:class:`~django.core.mail.EmailMultiAlternatives` are no longer supported.
* The undocumented ``encoding`` property of
:class:`~django.core.mail.EmailMessage` no longer supports Python legacy
:py:class:`email.charset.Charset` objects.
* As the internal implementations of :class:`~django.core.mail.EmailMessage`
and :class:`~django.core.mail.EmailMultiAlternatives` have changed
significantly, closely examine any custom subclasses that rely on overriding
undocumented, internal underscore methods.
Miscellaneous Miscellaneous
------------- -------------
@ -435,7 +474,9 @@ warning and will raise a :exc:`TypeError` when the deprecation period ends.
the first four (``subject``, ``body``, ``from_email``, and ``to``), which may the first four (``subject``, ``body``, ``from_email``, and ``to``), which may
still be passed either as positional or keyword arguments. still be passed either as positional or keyword arguments.
.. currentmodule:: None * :mod:`django.core.mail` APIs now require keyword arguments for less commonly
used parameters. Using positional arguments for these now emits a deprecation
warning and will raise a :exc:`TypeError` when the deprecation period ends:
Miscellaneous Miscellaneous
------------- -------------
@ -467,6 +508,22 @@ Miscellaneous
* Using a percent sign in a column alias or annotation is deprecated. * Using a percent sign in a column alias or annotation is deprecated.
* Support for passing Python's legacy email :class:`~email.mime.base.MIMEBase`
object to :class:`EmailMessage.attach() <django.core.mail.EmailMessage>` (or
including one in the message's ``attachments`` list) is deprecated. For
complex attachments requiring additional headers or parameters, switch to the
modern email API's :class:`~email.message.MIMEPart`.
* The ``django.core.mail.BadHeaderError`` exception is deprecated. Python's
modern email raises a :exc:`!ValueError` for email headers containing
prohibited characters.
* The ``django.core.mail.SafeMIMEText`` and ``SafeMIMEMultipart`` classes are
deprecated.
* The undocumented ``django.core.mail.forbid_multi_line_headers()`` and
``django.core.mail.message.sanitize_address()`` functions are deprecated.
Features removed in 6.0 Features removed in 6.0
======================= =======================

View File

@ -249,9 +249,8 @@ The Django email functions outlined above all protect against header injection
by forbidding newlines in header values. If any ``subject``, ``from_email`` or by forbidding newlines in header values. If any ``subject``, ``from_email`` or
``recipient_list`` contains a newline (in either Unix, Windows or Mac style), ``recipient_list`` contains a newline (in either Unix, Windows or Mac style),
the email function (e.g. :meth:`~django.core.mail.send_mail()`) will raise the email function (e.g. :meth:`~django.core.mail.send_mail()`) will raise
``django.core.mail.BadHeaderError`` (a subclass of ``ValueError``) and, hence, :exc:`ValueError` and, hence, will not send the email. It's your responsibility
will not send the email. It's your responsibility to validate all data before to validate all data before passing it to the email functions.
passing it to the email functions.
If a ``message`` contains headers at the start of the string, the headers will If a ``message`` contains headers at the start of the string, the headers will
be printed as the first bit of the email message. be printed as the first bit of the email message.
@ -260,7 +259,7 @@ Here's an example view that takes a ``subject``, ``message`` and ``from_email``
from the request's POST data, sends that to admin@example.com and redirects to from the request's POST data, sends that to admin@example.com and redirects to
"/contact/thanks/" when it's done:: "/contact/thanks/" when it's done::
from django.core.mail import BadHeaderError, send_mail from django.core.mail import send_mail
from django.http import HttpResponse, HttpResponseRedirect from django.http import HttpResponse, HttpResponseRedirect
@ -271,7 +270,7 @@ from the request's POST data, sends that to admin@example.com and redirects to
if subject and message and from_email: if subject and message and from_email:
try: try:
send_mail(subject, message, from_email, ["admin@example.com"]) send_mail(subject, message, from_email, ["admin@example.com"])
except BadHeaderError: except ValueError:
return HttpResponse("Invalid header found.") return HttpResponse("Invalid header found.")
return HttpResponseRedirect("/contact/thanks/") return HttpResponseRedirect("/contact/thanks/")
else: else:
@ -279,6 +278,12 @@ from the request's POST data, sends that to admin@example.com and redirects to
# to get proper validation errors. # to get proper validation errors.
return HttpResponse("Make sure all fields are entered and valid.") return HttpResponse("Make sure all fields are entered and valid.")
.. versionchanged:: 6.0
Older versions raised ``django.core.mail.BadHeaderError`` for some
invalid headers. This has been replaced with :exc:`!ValueError`.
.. _Header injection: http://www.nyphp.org/phundamentals/8_Preventing-Email-Header-Injection.html .. _Header injection: http://www.nyphp.org/phundamentals/8_Preventing-Email-Header-Injection.html
.. _emailmessage-and-smtpconnection: .. _emailmessage-and-smtpconnection:
@ -346,8 +351,8 @@ The following parameters must be given as keyword arguments if used:
* ``reply_to``: A list or tuple of recipient addresses used in the "Reply-To" * ``reply_to``: A list or tuple of recipient addresses used in the "Reply-To"
header when sending the email. header when sending the email.
* ``attachments``: A list of attachments to put on the message. These can * ``attachments``: A list of attachments to put on the message. Each can
be instances of :class:`~email.mime.base.MIMEBase` or be an instance of :class:`~email.message.MIMEPart` or
:class:`~django.core.mail.EmailAttachment`, or a tuple with attributes :class:`~django.core.mail.EmailAttachment`, or a tuple with attributes
``(filename, content, mimetype)``. ``(filename, content, mimetype)``.
@ -356,6 +361,17 @@ The following parameters must be given as keyword arguments if used:
Support for :class:`~django.core.mail.EmailAttachment` items of Support for :class:`~django.core.mail.EmailAttachment` items of
``attachments`` was added. ``attachments`` was added.
.. versionchanged:: 6.0
Support for :class:`~email.message.MIMEPart` objects in the ``attachments``
list was added.
.. deprecated:: 6.0
Support for Python's legacy :class:`~email.mime.base.MIMEBase` objects in
``attachments`` is deprecated. Use :class:`~email.message.MIMEPart`
instead.
* ``headers``: A dictionary of extra headers to put on the message. The * ``headers``: A dictionary of extra headers to put on the message. The
keys are the header name, values are the header values. It's up to the keys are the header name, values are the header values. It's up to the
caller to ensure header names and values are in the correct format for caller to ensure header names and values are in the correct format for
@ -396,12 +412,27 @@ The class has the following methods:
recipients will not raise an exception. It will return ``1`` if the message recipients will not raise an exception. It will return ``1`` if the message
was sent successfully, otherwise ``0``. was sent successfully, otherwise ``0``.
* ``message()`` constructs a ``django.core.mail.SafeMIMEText`` object (a * ``message(policy=email.policy.default)`` constructs and returns a Python
subclass of Python's :class:`~email.mime.text.MIMEText` class) or a :class:`email.message.EmailMessage` object representing the message to be
``django.core.mail.SafeMIMEMultipart`` object holding the message to be sent.
sent. If you ever need to extend the
:class:`~django.core.mail.EmailMessage` class, you'll probably want to The keyword argument ``policy`` allows specifying the set of rules for
override this method to put the content you want into the MIME object. updating and serializing the representation of the message. It must be an
:py:mod:`email.policy.Policy <email.policy>` object. Defaults to
:py:data:`email.policy.default`. In certain cases you may want to use
:py:data:`~email.policy.SMTP`, :py:data:`~email.policy.SMTPUTF8` or a custom
policy. For example, :class:`django.core.mail.backends.smtp.EmailBackend`
uses the :py: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:`~django.core.mail.EmailMessage`
class, you'll probably want to override this method to put the content you
want into the Python EmailMessage object.
.. versionchanged:: 6.0
The ``policy`` keyword argument was added and the return type was updated
to an instance of :py:class:`~email.message.EmailMessage`.
* ``recipients()`` returns a list of all the recipients of the message, * ``recipients()`` returns a list of all the recipients of the message,
whether they're recorded in the ``to``, ``cc`` or ``bcc`` attributes. This whether they're recorded in the ``to``, ``cc`` or ``bcc`` attributes. This
@ -410,40 +441,57 @@ The class has the following methods:
is sent. If you add another way to specify recipients in your class, they is sent. If you add another way to specify recipients in your class, they
need to be returned from this method as well. need to be returned from this method as well.
* ``attach()`` creates a new file attachment and adds it to the message. * ``attach()`` creates a new attachment and adds it to the message.
There are two ways to call ``attach()``: There are two ways to call ``attach()``:
* You can pass it a single argument that is a * You can pass it three arguments: ``filename``, ``content`` and
:class:`~email.mime.base.MIMEBase` instance. This will be inserted directly ``mimetype``. ``filename`` is the name of the file attachment as it will
into the resulting message. appear in the email, ``content`` is the data that will be contained inside
the attachment and ``mimetype`` is the optional MIME type for the
* Alternatively, you can pass ``attach()`` three arguments: attachment. If you omit ``mimetype``, the MIME content type will be guessed
``filename``, ``content`` and ``mimetype``. ``filename`` is the name from the filename of the attachment.
of the file attachment as it will appear in the email, ``content`` is
the data that will be contained inside the attachment and
``mimetype`` is the optional MIME type for the attachment. If you
omit ``mimetype``, the MIME content type will be guessed from the
filename of the attachment.
For example:: For example::
message.attach("design.png", img_data, "image/png") message.attach("design.png", img_data, "image/png")
If you specify a ``mimetype`` of :mimetype:`message/rfc822`, it will also If you specify a ``mimetype`` of :mimetype:`message/rfc822`, ``content``
accept :class:`django.core.mail.EmailMessage` and can be a :class:`django.core.mail.EmailMessage` or Python's
:py:class:`email.message.Message`. :class:`email.message.EmailMessage` or :class:`email.message.Message`.
For a ``mimetype`` starting with :mimetype:`text/`, content is expected to For a ``mimetype`` starting with :mimetype:`text/`, content is expected to
be a string. Binary data will be decoded using UTF-8, and if that fails, be a string. Binary data will be decoded using UTF-8, and if that fails,
the MIME type will be changed to :mimetype:`application/octet-stream` and the MIME type will be changed to :mimetype:`application/octet-stream` and
the data will be attached unchanged. the data will be attached unchanged.
In addition, :mimetype:`message/rfc822` attachments will no longer be * Or for attachments requiring additional headers or parameters, you can pass
base64-encoded in violation of :rfc:`2046#section-5.2.1`, which can cause ``attach()`` a single Python :class:`~email.message.MIMEPart` object.
issues with displaying the attachments in `Evolution`__ and `Thunderbird`__. This will be attached directly to the resulting message. For example,
to attach an inline image with a :mailheader:`Content-ID`::
__ https://bugzilla.gnome.org/show_bug.cgi?id=651197 cid = email.utils.make_msgid()
__ https://bugzilla.mozilla.org/show_bug.cgi?id=333880 inline_image = email.message.MIMEPart()
inline_image.set_content(
image_data_bytes,
maintype="image",
subtype="png",
disposition="inline",
cid=f"<{cid}>",
)
message.attach(inline_image)
message.attach_alternative(f'… <img src="cid:${cid}"> …', "text/html")
Python's :meth:`email.contentmanager.set_content` documentation describes
the supported arguments for ``MIMEPart.set_content()``.
.. versionchanged:: 6.0
Support for :class:`~email.message.MIMEPart` attachments was added.
.. deprecated:: 6.0
Support for :class:`email.mime.base.MIMEBase` attachments is
deprecated. Use :class:`~email.message.MIMEPart` instead.
* ``attach_file()`` creates a new attachment using a file from your * ``attach_file()`` creates a new attachment using a file from your
filesystem. Call it with the path of the file to attach and, optionally, filesystem. Call it with the path of the file to attach and, optionally,

View File

@ -1367,13 +1367,13 @@ class PasswordResetFormTest(TestDataMixin, TestCase):
self.assertTrue( self.assertTrue(
re.match( re.match(
r"^http://example.com/reset/[\w/-]+", r"^http://example.com/reset/[\w/-]+",
message.get_payload(0).get_payload(), message.get_payload(0).get_content(),
) )
) )
self.assertTrue( self.assertTrue(
re.match( re.match(
r'^<html><a href="http://example.com/reset/[\w/-]+/">Link</a></html>$', r'^<html><a href="http://example.com/reset/[\w/-]+/">Link</a></html>$',
message.get_payload(1).get_payload(), message.get_payload(1).get_content(),
) )
) )

View File

@ -0,0 +1,122 @@
# RemovedInDjango70Warning: This entire file.
from email.mime.text import MIMEText
from django.core.mail import (
EmailAlternative,
EmailAttachment,
EmailMessage,
EmailMultiAlternatives,
)
from django.core.mail.message import forbid_multi_line_headers, sanitize_address
from django.test import SimpleTestCase, ignore_warnings
from django.utils.deprecation import RemovedInDjango70Warning
from .tests import MailTestsMixin
class DeprecationWarningTests(MailTestsMixin, SimpleTestCase):
def test_deprecated_on_import(self):
"""
These items are not typically called from user code,
so generate deprecation warnings immediately at the time
they are imported from django.core.mail.
"""
cases = [
# name, msg
(
"BadHeaderError",
"BadHeaderError is deprecated. Replace with ValueError.",
),
(
"SafeMIMEText",
"SafeMIMEText is deprecated. The return value of"
" EmailMessage.message() is an email.message.EmailMessage.",
),
(
"SafeMIMEMultipart",
"SafeMIMEMultipart is deprecated. The return value of"
" EmailMessage.message() is an email.message.EmailMessage.",
),
]
for name, msg in cases:
with self.subTest(name=name):
with self.assertWarnsMessage(RemovedInDjango70Warning, msg):
__import__("django.core.mail", fromlist=[name])
def test_sanitize_address_deprecated(self):
msg = (
"The internal API sanitize_address() is deprecated."
" Python's modern email API (with email.message.EmailMessage or"
" email.policy.default) will handle most required validation and"
" encoding. Use Python's email.headerregistry.Address to construct"
" formatted addresses from component parts."
)
with self.assertWarnsMessage(RemovedInDjango70Warning, msg):
sanitize_address("to@example.com", "ascii")
def test_forbid_multi_line_headers_deprecated(self):
msg = (
"The internal API forbid_multi_line_headers() is deprecated."
" Python's modern email API (with email.message.EmailMessage or"
" email.policy.default) will reject multi-line headers."
)
with self.assertWarnsMessage(RemovedInDjango70Warning, msg):
forbid_multi_line_headers("To", "to@example.com", "ascii")
class UndocumentedFeatureErrorTests(SimpleTestCase):
"""
These undocumented features were removed without going through deprecation.
In case they were being used, they now raise errors.
"""
def test_undocumented_mixed_subtype(self):
"""
Trying to use the previously undocumented, now unsupported
EmailMessage.mixed_subtype causes an error.
"""
msg = (
"EmailMessage no longer supports"
" the undocumented `mixed_subtype` attribute"
)
email = EmailMessage(
attachments=[EmailAttachment(None, b"GIF89a...", "image/gif")]
)
email.mixed_subtype = "related"
with self.assertRaisesMessage(AttributeError, msg):
email.message()
def test_undocumented_alternative_subtype(self):
"""
Trying to use the previously undocumented, now unsupported
EmailMultiAlternatives.alternative_subtype causes an error.
"""
msg = (
"EmailMultiAlternatives no longer supports"
" the undocumented `alternative_subtype` attribute"
)
email = EmailMultiAlternatives(
alternatives=[EmailAlternative("", "text/plain")]
)
email.alternative_subtype = "multilingual"
with self.assertRaisesMessage(AttributeError, msg):
email.message()
@ignore_warnings(category=RemovedInDjango70Warning)
class DeprecatedCompatibilityTests(SimpleTestCase):
def test_bad_header_error(self):
"""
Existing code that catches deprecated BadHeaderError should be
compatible with modern email (which raises ValueError instead).
"""
from django.core.mail import BadHeaderError
with self.assertRaises(BadHeaderError):
EmailMessage(subject="Bad\r\nHeader").message()
def test_attachments_mimebase_in_constructor(self):
txt = MIMEText("content1")
msg = EmailMessage(attachments=[txt])
payload = msg.message().get_payload()
self.assertEqual(payload[0], txt)

View File

@ -1,16 +1,20 @@
import ast
import mimetypes import mimetypes
import os import os
import pickle import pickle
import re
import shutil import shutil
import socket import socket
import sys import sys
import tempfile import tempfile
from email import charset, message_from_binary_file from datetime import datetime, timezone
from email import message_from_binary_file
from email import message_from_bytes as _message_from_bytes from email import message_from_bytes as _message_from_bytes
from email import policy from email import policy
from email.headerregistry import Address from email.headerregistry import Address
from email.message import EmailMessage as PyEmailMessage from email.message import EmailMessage as PyEmailMessage
from email.message import Message as PyMessage from email.message import Message as PyMessage
from email.message import MIMEPart
from email.mime.image import MIMEImage from email.mime.image import MIMEImage
from email.mime.text import MIMEText from email.mime.text import MIMEText
from io import StringIO from io import StringIO
@ -24,7 +28,6 @@ from django.core import mail
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from django.core.mail import ( from django.core.mail import (
DNS_NAME, DNS_NAME,
BadHeaderError,
EmailAlternative, EmailAlternative,
EmailAttachment, EmailAttachment,
EmailMessage, EmailMessage,
@ -35,9 +38,8 @@ from django.core.mail import (
send_mass_mail, send_mass_mail,
) )
from django.core.mail.backends import console, dummy, filebased, locmem, smtp from django.core.mail.backends import console, dummy, filebased, locmem, smtp
from django.core.mail.message import sanitize_address
from django.test import SimpleTestCase, override_settings from django.test import SimpleTestCase, override_settings
from django.test.utils import requires_tz_support from django.test.utils import ignore_warnings, requires_tz_support
from django.utils.deprecation import RemovedInDjango70Warning from django.utils.deprecation import RemovedInDjango70Warning
from django.utils.translation import gettext_lazy from django.utils.translation import gettext_lazy
@ -248,6 +250,8 @@ class MailTests(MailTestsMixin, SimpleTestCase):
self.assertEqual(message["From"], "from@example.com") self.assertEqual(message["From"], "from@example.com")
self.assertEqual(message["To"], "to@example.com") self.assertEqual(message["To"], "to@example.com")
# RemovedInDjango70Warning.
@ignore_warnings(category=RemovedInDjango70Warning)
@mock.patch("django.core.mail.message.MIMEText.set_payload") @mock.patch("django.core.mail.message.MIMEText.set_payload")
def test_nonascii_as_string_with_ascii_charset(self, mock_set_payload): def test_nonascii_as_string_with_ascii_charset(self, mock_set_payload):
"""Line length check should encode the payload supporting """Line length check should encode the payload supporting
@ -269,6 +273,7 @@ class MailTests(MailTestsMixin, SimpleTestCase):
# This test is specific to Python's legacy MIMEText. This can be safely # This test is specific to Python's legacy MIMEText. This can be safely
# removed when EmailMessage.message() uses Python's modern email API. # removed when EmailMessage.message() uses Python's modern email API.
# (Using surrogateescape for non-utf8 is covered in test_encoding().) # (Using surrogateescape for non-utf8 is covered in test_encoding().)
from django.core.mail import SafeMIMEText
def simplified_set_payload(instance, payload, charset): def simplified_set_payload(instance, payload, charset):
instance._payload = payload instance._payload = payload
@ -279,8 +284,7 @@ class MailTests(MailTestsMixin, SimpleTestCase):
"Text heavily based in Python's text for non-ascii messages: Föö bär" "Text heavily based in Python's text for non-ascii messages: Föö bär"
).encode("iso-8859-1") ).encode("iso-8859-1")
body = text.decode("ascii", errors="surrogateescape") body = text.decode("ascii", errors="surrogateescape")
email = EmailMessage("Subject", body, "from@example.com", ["to@example.com"]) message = SafeMIMEText(body, "plain", "ascii")
message = email.message()
mock_set_payload.assert_called_once() mock_set_payload.assert_called_once()
self.assertEqual(message.get_payload(decode=True), text) self.assertEqual(message.get_payload(decode=True), text)
@ -475,7 +479,7 @@ class MailTests(MailTestsMixin, SimpleTestCase):
EmailMessage(reply_to="reply_to@example.com") EmailMessage(reply_to="reply_to@example.com")
def test_header_injection(self): def test_header_injection(self):
msg = "Header values can't contain newlines " msg = "Header values may not contain linefeed or carriage return characters"
cases = [ cases = [
{"subject": "Subject\nInjection Test"}, {"subject": "Subject\nInjection Test"},
{"subject": gettext_lazy("Lazy Subject\nInjection Test")}, {"subject": gettext_lazy("Lazy Subject\nInjection Test")},
@ -484,7 +488,7 @@ class MailTests(MailTestsMixin, SimpleTestCase):
for kwargs in cases: for kwargs in cases:
with self.subTest(case=kwargs): with self.subTest(case=kwargs):
email = EmailMessage(**kwargs) email = EmailMessage(**kwargs)
with self.assertRaisesMessage(BadHeaderError, msg): with self.assertRaisesMessage(ValueError, msg):
email.message() email.message()
def test_folding_white_space(self): def test_folding_white_space(self):
@ -497,12 +501,9 @@ class MailTests(MailTestsMixin, SimpleTestCase):
) )
message = email.message() message = email.message()
msg_bytes = message.as_bytes() msg_bytes = message.as_bytes()
# Python's legacy email wraps this more than strictly necessary
# (but uses FWS properly at each wrap). Modern email wraps it better.
self.assertIn( self.assertIn(
b"Subject: Long subject lines that get wrapped should contain a space\n" b"Subject: Long subject lines that get wrapped should contain a space\n"
b" continuation\n" b" continuation character to comply with RFC 822",
b" character to comply with RFC 822",
msg_bytes, msg_bytes,
) )
@ -522,6 +523,19 @@ class MailTests(MailTestsMixin, SimpleTestCase):
}, },
) )
def test_datetime_in_date_header(self):
"""
A datetime in headers should be passed through to Python email intact,
so that it uses the email header date format.
"""
email = EmailMessage(
headers={"Date": datetime(2001, 11, 9, 1, 8, 47, tzinfo=timezone.utc)},
)
message = email.message()
self.assertEqual(message["Date"], "Fri, 09 Nov 2001 01:08:47 +0000")
# Not the default ISO format from force_str(strings_only=False).
self.assertNotEqual(message["Date"], "2001-11-09 01:08:47+00:00")
def test_from_header(self): def test_from_header(self):
""" """
Make sure we can manually set the From header (#9214) Make sure we can manually set the From header (#9214)
@ -754,6 +768,28 @@ class MailTests(MailTestsMixin, SimpleTestCase):
self.assertIn(html_content, msg.message().as_string()) self.assertIn(html_content, msg.message().as_string())
def test_alternative_alternatives(self):
"""
Alternatives can be attached as either string or bytes
and need not use a text/* mimetype.
"""
cases = [
# (mimetype, content, expected decoded payload)
("application/x-ccmail-rtf", b"non-text\x07bytes", b"non-text\x07bytes"),
("application/x-ccmail-rtf", "non-text\x07string", b"non-text\x07string"),
("text/x-amp-html", b"text bytes\n", b"text bytes\n"),
("text/x-amp-html", "text string\n", b"text string\n"),
]
for mimetype, content, expected in cases:
with self.subTest(case=(mimetype, content)):
email = EmailMultiAlternatives()
email.attach_alternative(content, mimetype)
msg = email.message()
self.assertEqual(msg.get_content_type(), "multipart/alternative")
alternative = msg.get_payload()[0]
self.assertEqual(alternative.get_content_type(), mimetype)
self.assertEqual(alternative.get_payload(decode=True), expected)
def test_alternatives_and_attachment_serializable(self): def test_alternatives_and_attachment_serializable(self):
html_content = "<p>This is <strong>html</strong></p>" html_content = "<p>This is <strong>html</strong></p>"
mime_type = "text/html" mime_type = "text/html"
@ -774,7 +810,9 @@ class MailTests(MailTestsMixin, SimpleTestCase):
def test_none_body(self): def test_none_body(self):
msg = EmailMessage("subject", None, "from@example.com", ["to@example.com"]) msg = EmailMessage("subject", None, "from@example.com", ["to@example.com"])
self.assertEqual(msg.body, "") self.assertEqual(msg.body, "")
self.assertEqual(msg.message().get_payload(), "") # The modern email API forces trailing newlines on all text/* parts,
# even an empty body.
self.assertEqual(msg.message().get_payload(), "\n")
@mock.patch("socket.getfqdn", return_value="漢字") @mock.patch("socket.getfqdn", return_value="漢字")
def test_non_ascii_dns_non_unicode_email(self, mocked_getfqdn): def test_non_ascii_dns_non_unicode_email(self, mocked_getfqdn):
@ -795,13 +833,8 @@ class MailTests(MailTestsMixin, SimpleTestCase):
# Check that body is actually encoded with iso-8859-1. # Check that body is actually encoded with iso-8859-1.
msg_bytes = message.as_bytes() msg_bytes = message.as_bytes()
if message["Content-Transfer-Encoding"] == "quoted-printable": self.assertEqual(message["Content-Transfer-Encoding"], "8bit")
self.assertIn(b"Firstname S=FCrname is a great guy.", msg_bytes) self.assertIn(b"Firstname S\xfc", msg_bytes)
elif message["Content-Transfer-Encoding"] == "8bit":
self.assertIn(b"Firstname S\xfc", msg_bytes)
else:
self.fail("Unexpected Content-Transfer-Encoding")
parsed = message_from_bytes(msg_bytes) parsed = message_from_bytes(msg_bytes)
self.assertEqual(parsed.get_content(), "Firstname Sürname is a great guy.\n") self.assertEqual(parsed.get_content(), "Firstname Sürname is a great guy.\n")
@ -917,8 +950,11 @@ class MailTests(MailTestsMixin, SimpleTestCase):
self.assertEqual(payload[0].get_content_type(), "multipart/alternative") self.assertEqual(payload[0].get_content_type(), "multipart/alternative")
self.assertEqual(payload[1].get_content_type(), "application/pdf") self.assertEqual(payload[1].get_content_type(), "application/pdf")
def test_decoded_attachments_MIMEText(self): def test_decoded_attachment_text_MIMEPart(self):
txt = MIMEText("content1") # See also test_attach_mime_part() and
# test_attach_mime_part_in_constructor().
txt = MIMEPart()
txt.set_content("content1")
msg = EmailMessage(attachments=[txt]) msg = EmailMessage(attachments=[txt])
payload = msg.message().get_payload() payload = msg.message().get_payload()
self.assertEqual(payload[0], txt) self.assertEqual(payload[0], txt)
@ -1070,16 +1106,48 @@ class MailTests(MailTestsMixin, SimpleTestCase):
self.assertEqual(attached_message["Content-Transfer-Encoding"], "8bit") self.assertEqual(attached_message["Content-Transfer-Encoding"], "8bit")
self.assertEqual(attached_message.get_content_type(), "text/plain") self.assertEqual(attached_message.get_content_type(), "text/plain")
# RemovedInDjango70Warning.
def test_attach_mime_image(self): def test_attach_mime_image(self):
""" """
EmailMessage.attach() docs: "You can pass it EmailMessage.attach() docs: "You can pass it
a single argument that is a MIMEBase instance." a single argument that is a MIMEBase instance."
""" """
msg = (
"MIMEBase attachments are deprecated."
" Use an email.message.MIMEPart instead."
)
# This also verifies complex attachments with extra header fields. # This also verifies complex attachments with extra header fields.
email = EmailMessage() email = EmailMessage()
image = MIMEImage(b"GIF89a...", "gif") image = MIMEImage(b"GIF89a...", "gif")
image["Content-Disposition"] = "inline" image["Content-Disposition"] = "inline"
image["Content-ID"] = "<content-id@example.org>" image["Content-ID"] = "<content-id@example.org>"
with self.assertWarnsMessage(RemovedInDjango70Warning, msg):
email.attach(image)
attachments = self.get_raw_attachments(email)
self.assertEqual(len(attachments), 1)
image_att = attachments[0]
self.assertEqual(image_att.get_content_type(), "image/gif")
self.assertEqual(image_att.get_content_disposition(), "inline")
self.assertEqual(image_att["Content-ID"], "<content-id@example.org>")
self.assertEqual(image_att.get_content(), b"GIF89a...")
self.assertIsNone(image_att.get_filename())
def test_attach_mime_part(self):
"""
EmailMessage.attach() docs: "You can pass it
a single argument that is a MIMEPart object."
"""
# This also verifies complex attachments with extra header fields.
email = EmailMessage()
image = MIMEPart()
image.set_content(
b"GIF89a...",
maintype="image",
subtype="gif",
disposition="inline",
cid="<content-id@example.org>",
)
email.attach(image) email.attach(image)
attachments = self.get_raw_attachments(email) attachments = self.get_raw_attachments(email)
@ -1091,9 +1159,29 @@ class MailTests(MailTestsMixin, SimpleTestCase):
self.assertEqual(image_att.get_content(), b"GIF89a...") self.assertEqual(image_att.get_content(), b"GIF89a...")
self.assertIsNone(image_att.get_filename()) self.assertIsNone(image_att.get_filename())
# RemovedInDjango70Warning.
def test_attach_mime_image_in_constructor(self): def test_attach_mime_image_in_constructor(self):
msg = (
"MIMEBase attachments are deprecated."
" Use an email.message.MIMEPart instead."
)
image = MIMEImage(b"\x89PNG...", "png") image = MIMEImage(b"\x89PNG...", "png")
image["Content-Disposition"] = "attachment; filename=test.png" image["Content-Disposition"] = "attachment; filename=test.png"
with self.assertWarnsMessage(RemovedInDjango70Warning, msg):
email = EmailMessage(attachments=[image])
attachments = self.get_raw_attachments(email)
self.assertEqual(len(attachments), 1)
image_att = attachments[0]
self.assertEqual(image_att.get_content_type(), "image/png")
self.assertEqual(image_att.get_content(), b"\x89PNG...")
self.assertEqual(image_att.get_filename(), "test.png")
def test_attach_mime_part_in_constructor(self):
image = MIMEPart()
image.set_content(
b"\x89PNG...", maintype="image", subtype="png", filename="test.png"
)
email = EmailMessage(attachments=[image]) email = EmailMessage(attachments=[image])
attachments = self.get_raw_attachments(email) attachments = self.get_raw_attachments(email)
@ -1163,11 +1251,12 @@ class MailTests(MailTestsMixin, SimpleTestCase):
self.assertEqual(child_cte, "7bit") self.assertEqual(child_cte, "7bit")
self.assertEqual(attached_message.get_content_type(), "text/plain") self.assertEqual(attached_message.get_content_type(), "text/plain")
def test_attach_mimebase_prohibits_other_params(self): def test_attach_mimepart_prohibits_other_params(self):
email_msg = EmailMessage() email_msg = EmailMessage()
txt = MIMEText("content") txt = MIMEPart()
txt.set_content("content")
msg = ( msg = (
"content and mimetype must not be given when a MIMEBase instance " "content and mimetype must not be given when a MIMEPart instance "
"is provided." "is provided."
) )
with self.assertRaisesMessage(ValueError, msg): with self.assertRaisesMessage(ValueError, msg):
@ -1318,9 +1407,7 @@ class MailTests(MailTestsMixin, SimpleTestCase):
self.assertIn(b"Content-Transfer-Encoding: 8bit", s) self.assertIn(b"Content-Transfer-Encoding: 8bit", s)
# Long body lines that require folding should use quoted-printable or # Long body lines that require folding should use quoted-printable or
# base64, whichever is shorter. However, Python's legacy email API # base64, whichever is shorter.
# avoids re-folding non-ASCII text and just uses CTE 8bit. (The modern
# API would correctly choose base64 here. Any of these is deliverable.)
msg = EmailMessage( msg = EmailMessage(
body=( body=(
"Body with non latin characters: А Б В Г Д Е Ж Ѕ З И І К Л М Н О П.\n" "Body with non latin characters: А Б В Г Д Е Ж Ѕ З И І К Л М Н О П.\n"
@ -1329,36 +1416,17 @@ class MailTests(MailTestsMixin, SimpleTestCase):
), ),
) )
s = msg.message().as_bytes() s = msg.message().as_bytes()
self.assertIn(b"Content-Transfer-Encoding: 8bit", s) self.assertIn(b"Content-Transfer-Encoding: quoted-printable", s)
def test_custom_utf8_encoding(self):
"""A UTF-8 charset with a custom body encoding is respected."""
# Tests that the undocumented EmailMessage.encoding property allows a
# custom, legacy email.charset.Charset object. Modern email doesn't
# support that, and this test will be removed.
# (A str charset name like `msg.encoding = "iso-8859-1"` will still
# work, and is tested elsewhere.)
body = "Body with latin characters: àáä.\n"
msg = EmailMessage("Subject", body, "bounce@example.com", ["to@example.com"])
encoding = charset.Charset("utf-8")
encoding.body_encoding = charset.QP
msg.encoding = encoding
message = msg.message()
self.assertMessageHasHeaders(
message,
{
("MIME-Version", "1.0"),
("Content-Type", 'text/plain; charset="utf-8"'),
("Content-Transfer-Encoding", "quoted-printable"),
},
)
self.assertEqual(message.get_payload(), encoding.body_encode(body))
# RemovedInDjango70Warning.
@ignore_warnings(category=RemovedInDjango70Warning)
def test_sanitize_address(self): def test_sanitize_address(self):
"""Email addresses are properly sanitized.""" """Email addresses are properly sanitized."""
# Tests the internal sanitize_address() function. Many of these cases # Tests the internal sanitize_address() function. Many of these cases
# are duplicated in test_address_header_handling(), which verifies # are duplicated in test_address_header_handling(), which verifies
# headers in the generated message. # headers in the generated message.
from django.core.mail.message import sanitize_address
for email_address, encoding, expected_result in ( for email_address, encoding, expected_result in (
# ASCII addresses. # ASCII addresses.
("to@example.com", "ascii", "to@example.com"), ("to@example.com", "ascii", "to@example.com"),
@ -1389,6 +1457,7 @@ class MailTests(MailTestsMixin, SimpleTestCase):
( (
"Tó Example <tó@example.com>", "Tó Example <tó@example.com>",
"utf-8", "utf-8",
# (Not RFC-2047 compliant.)
"=?utf-8?q?T=C3=B3_Example?= <=?utf-8?b?dMOz?=@example.com>", "=?utf-8?q?T=C3=B3_Example?= <=?utf-8?b?dMOz?=@example.com>",
), ),
# IDNA addresses with display names. # IDNA addresses with display names.
@ -1445,6 +1514,8 @@ class MailTests(MailTestsMixin, SimpleTestCase):
sanitize_address(email_address, encoding), expected_result sanitize_address(email_address, encoding), expected_result
) )
# RemovedInDjango70Warning.
@ignore_warnings(category=RemovedInDjango70Warning)
def test_sanitize_address_invalid(self): def test_sanitize_address_invalid(self):
# Tests the internal sanitize_address() function. Note that Django's # Tests the internal sanitize_address() function. Note that Django's
# EmailMessage.message() will not catch these cases, as it only calls # EmailMessage.message() will not catch these cases, as it only calls
@ -1452,6 +1523,8 @@ class MailTests(MailTestsMixin, SimpleTestCase):
# Django detects these cases in the SMTP EmailBackend during sending. # Django detects these cases in the SMTP EmailBackend during sending.
# See SMTPBackendTests.test_avoids_sending_to_invalid_addresses() # See SMTPBackendTests.test_avoids_sending_to_invalid_addresses()
# below. # below.
from django.core.mail.message import sanitize_address
for email_address in ( for email_address in (
# Invalid address with two @ signs. # Invalid address with two @ signs.
"to@other.com@example.com", "to@other.com@example.com",
@ -1467,10 +1540,14 @@ class MailTests(MailTestsMixin, SimpleTestCase):
with self.assertRaisesMessage(ValueError, "Invalid address"): with self.assertRaisesMessage(ValueError, "Invalid address"):
sanitize_address(email_address, encoding="utf-8") sanitize_address(email_address, encoding="utf-8")
# RemovedInDjango70Warning.
@ignore_warnings(category=RemovedInDjango70Warning)
def test_sanitize_address_header_injection(self): def test_sanitize_address_header_injection(self):
# Tests the internal sanitize_address() function. These cases are # Tests the internal sanitize_address() function. These cases are
# duplicated in test_address_header_handling(), which verifies headers # duplicated in test_address_header_handling(), which verifies headers
# in the generated message. # in the generated message.
from django.core.mail.message import sanitize_address
msg = "Invalid address; address parts cannot contain newlines." msg = "Invalid address; address parts cannot contain newlines."
tests = [ tests = [
"Name\nInjection <to@example.com>", "Name\nInjection <to@example.com>",
@ -1549,7 +1626,7 @@ class MailTests(MailTestsMixin, SimpleTestCase):
self.assertEqual(actual, expected) self.assertEqual(actual, expected)
def test_address_header_injection(self): def test_address_header_injection(self):
msg = "Header values can't contain newlines" msg = "Header values may not contain linefeed or carriage return characters"
cases = [ cases = [
"Name\nInjection <to@example.com>", "Name\nInjection <to@example.com>",
'"Name\nInjection" <to@example.com>', '"Name\nInjection" <to@example.com>',
@ -1823,10 +1900,12 @@ class MailTests(MailTestsMixin, SimpleTestCase):
email.to = ["new-to@example.com"] email.to = ["new-to@example.com"]
email.bcc = ["new-bcc@example.com"] email.bcc = ["new-bcc@example.com"]
email.connection = new_connection email.connection = new_connection
image = MIMEPart()
image.set_content(b"GIF89a...", "image", "gif")
email.attachments = [ email.attachments = [
("new1.txt", "new attachment 1\n", "text/plain"), # plain tuple ("new1.txt", "new attachment 1\n", "text/plain"), # plain tuple
EmailAttachment("new2.txt", "new attachment 2\n", "text/csv"), EmailAttachment("new2.txt", "new attachment 2\n", "text/csv"),
MIMEImage(b"GIF89a...", "gif"), image,
] ]
email.extra_headers = {"X-Header": "new header"} email.extra_headers = {"X-Header": "new header"}
email.cc = ["new-cc@example.com"] email.cc = ["new-cc@example.com"]
@ -1855,6 +1934,73 @@ class MailTests(MailTestsMixin, SimpleTestCase):
self.assertIs(email.get_connection(), new_connection) self.assertIs(email.get_connection(), new_connection)
self.assertNotIn("original", message.as_string()) self.assertNotIn("original", message.as_string())
def test_message_is_python_email_message(self):
"""
EmailMessage.message() docs: "returns a Python
email.message.EmailMessage object."
"""
email = EmailMessage()
message = email.message()
self.assertIsInstance(message, PyMessage)
self.assertEqual(message.policy, policy.default)
def test_message_policy_smtputf8(self):
# With SMTPUTF8, the message uses utf-8 directly in headers (not
# RFC 2047 encoded-words). Note this is the only spec-compliant way
# to send to a non-ASCII localpart.
email = EmailMessage(
subject="Detta ämne innehåller icke-ASCII-tecken",
to=["nøn-åscîi@example.com"],
)
message = email.message(policy=policy.SMTPUTF8)
self.assertEqual(message.policy, policy.SMTPUTF8)
msg_bytes = message.as_bytes()
self.assertIn(
"Subject: Detta ämne innehåller icke-ASCII-tecken".encode(), msg_bytes
)
self.assertIn("To: nøn-åscîi@example.com".encode(), msg_bytes)
self.assertNotIn(b"=?utf-8?", msg_bytes) # encoded-word prefix
def test_message_policy_cte_7bit(self):
"""
Allows a policy that requires 7bit encodings.
"""
email = EmailMessage(body="Detta innehåller icke-ASCII-tecken")
email.attach("file.txt", "يحتوي هذا المرفق على أحرف غير ASCII")
# Uses 8bit by default. (Test pre-condition.)
self.assertIn(b"Content-Transfer-Encoding: 8bit", email.message().as_bytes())
# Uses something 7bit compatible when policy requires it. Should pick
# the shorter of quoted-printable (for this body) or base64 (for this
# attachment), but must not use 8bit. (Decoding to "ascii" verifies
# that.)
policy_7bit = policy.default.clone(cte_type="7bit")
msg_bytes = email.message(policy=policy_7bit).as_bytes()
msg_ascii = msg_bytes.decode("ascii")
self.assertIn("Content-Transfer-Encoding: quoted-printable", msg_ascii)
self.assertIn("Content-Transfer-Encoding: base64", msg_ascii)
self.assertNotIn("Content-Transfer-Encoding: 8bit", msg_ascii)
def test_message_policy_compat32(self):
"""
Although EmailMessage.message() doesn't support policy=compat32
(because compat32 doesn't support modern APIs), compat32 _can_ be
used with as_bytes() or as_string() on the resulting message.
"""
# This subject results in different (but equivalent) RFC 2047 encoding
# with compat32 vs. email.policy.default.
email = EmailMessage(subject="Detta ämne innehåller icke-ASCII-tecken")
message = email.message()
self.assertIn(
b"Subject: =?utf-8?q?Detta_=C3=A4mne_inneh=C3=A5ller_icke-ASCII-tecken?=\n",
message.as_bytes(policy=policy.compat32),
)
self.assertIn(
"Subject: =?utf-8?q?Detta_=C3=A4mne_inneh=C3=A5ller_icke-ASCII-tecken?=\n",
message.as_string(policy=policy.compat32),
)
# RemovedInDjango70Warning. # RemovedInDjango70Warning.
class MailDeprecatedPositionalArgsTests(SimpleTestCase): class MailDeprecatedPositionalArgsTests(SimpleTestCase):
@ -1983,7 +2129,9 @@ class MailTimeZoneTests(MailTestsMixin, SimpleTestCase):
EMAIL_USE_LOCALTIME=False creates a datetime in UTC. EMAIL_USE_LOCALTIME=False creates a datetime in UTC.
""" """
email = EmailMessage() email = EmailMessage()
self.assertEndsWith(email.message()["Date"], "-0000") # Per RFC 2822/5322 section 3.3, "The form '+0000' SHOULD be used
# to indicate a time zone at Universal Time."
self.assertEndsWith(email.message()["Date"], "+0000")
@override_settings( @override_settings(
EMAIL_USE_LOCALTIME=True, USE_TZ=True, TIME_ZONE="Africa/Algiers" EMAIL_USE_LOCALTIME=True, USE_TZ=True, TIME_ZONE="Africa/Algiers"
@ -1997,6 +2145,7 @@ class MailTimeZoneTests(MailTestsMixin, SimpleTestCase):
self.assertEndsWith(email.message()["Date"], "+0100") self.assertEndsWith(email.message()["Date"], "+0100")
# RemovedInDjango70Warning.
class PythonGlobalState(SimpleTestCase): class PythonGlobalState(SimpleTestCase):
""" """
Tests for #12422 -- Django smarts (#2472/#11212) with charset of utf-8 text Tests for #12422 -- Django smarts (#2472/#11212) with charset of utf-8 text
@ -2062,7 +2211,7 @@ class BaseEmailBackendTests(MailTestsMixin):
self.assertEqual(num_sent, 1) self.assertEqual(num_sent, 1)
message = self.get_the_message() message = self.get_the_message()
self.assertEqual(message["subject"], "Subject") self.assertEqual(message["subject"], "Subject")
self.assertEqual(message.get_payload(), "Content\n") self.assertEqual(message.get_content(), "Content\n")
self.assertEqual(message["from"], "from@example.com") self.assertEqual(message["from"], "from@example.com")
self.assertEqual(message.get_all("to"), ["to@example.com"]) self.assertEqual(message.get_all("to"), ["to@example.com"])
@ -2086,12 +2235,9 @@ class BaseEmailBackendTests(MailTestsMixin):
quoted-printable or base64 (whichever is shorter), to avoid having to quoted-printable or base64 (whichever is shorter), to avoid having to
insert newlines in a way that alters the intended text. insert newlines in a way that alters the intended text.
""" """
# Django with Python's legacy email API uses quoted-printable for both
# cases below. Python's modern API would prefer shorter base64 for the
# first case.
cases = [ cases = [
# (body, expected_cte) # (body, expected_cte)
("В южных морях " * 60, "quoted-printable"), ("В южных морях " * 60, "base64"),
("I de sørlige hav " * 58, "quoted-printable"), ("I de sørlige hav " * 58, "quoted-printable"),
] ]
for body, expected_cte in cases: for body, expected_cte in cases:
@ -2428,10 +2574,7 @@ class LocmemBackendTests(BaseEmailBackendTests, SimpleTestCase):
email_backend = "django.core.mail.backends.locmem.EmailBackend" email_backend = "django.core.mail.backends.locmem.EmailBackend"
def get_mailbox_content(self): def get_mailbox_content(self):
# Reparse as modern messages to work with shared BaseEmailBackendTests. return [m.message() for m in mail.outbox]
# (Once EmailMessage.message() uses Python's modern email API, this
# can be changed back to `[m.message() for m in mail.outbox]`.)
return [message_from_bytes(m.message().as_bytes()) for m in mail.outbox]
def flush_mailbox(self): def flush_mailbox(self):
mail.outbox = [] mail.outbox = []
@ -2453,7 +2596,7 @@ class LocmemBackendTests(BaseEmailBackendTests, SimpleTestCase):
def test_validate_multiline_headers(self): def test_validate_multiline_headers(self):
# Ticket #18861 - Validate emails when using the locmem backend # Ticket #18861 - Validate emails when using the locmem backend
with self.assertRaises(BadHeaderError): with self.assertRaises(ValueError):
send_mail( send_mail(
"Subject\nMultiline", "Content", "from@example.com", ["to@example.com"] "Subject\nMultiline", "Content", "from@example.com", ["to@example.com"]
) )
@ -2908,6 +3051,8 @@ class SMTPBackendTests(BaseEmailBackendTests, SMTPBackendTestsBase):
"to@other.com@example.com", "to@other.com@example.com",
# Invalid address without the quotes. # Invalid address without the quotes.
"to@other.com <to@example.com>", "to@other.com <to@example.com>",
# Multiple mailboxes in a single address.
"to@example.com, other@example.com",
# Other invalid addresses. # Other invalid addresses.
"@", "@",
"to@", "to@",
@ -2976,6 +3121,28 @@ class SMTPBackendTests(BaseEmailBackendTests, SMTPBackendTestsBase):
], ],
) )
def test_rejects_non_ascii_local_part(self):
"""
The SMTP EmailBackend does not currently support non-ASCII local-parts.
(That would require using the RFC 6532 SMTPUTF8 extension.) #35713.
"""
backend = smtp.EmailBackend()
backend.connection = mock.Mock(spec=object())
email = EmailMessage(to=["nø@example.dk"])
with self.assertRaisesMessage(
ValueError,
"Invalid address 'nø@example.dk': local-part contains non-ASCII characters",
):
backend.send_messages([email])
def test_prep_address_without_force_ascii(self):
# A subclass implementing SMTPUTF8 could use
# prep_address(force_ascii=False).
backend = smtp.EmailBackend()
for case in ["åh@example.dk", "oh@åh.example.dk", "åh@åh.example.dk"]:
with self.subTest(case=case):
self.assertEqual(backend.prep_address(case, force_ascii=False), case)
@skipUnless(HAS_AIOSMTPD, "No aiosmtpd library detected.") @skipUnless(HAS_AIOSMTPD, "No aiosmtpd library detected.")
class SMTPBackendStoppedServerTests(SMTPBackendTestsBase): class SMTPBackendStoppedServerTests(SMTPBackendTestsBase):
@ -3005,3 +3172,87 @@ class SMTPBackendStoppedServerTests(SMTPBackendTestsBase):
self.backend.open() self.backend.open()
self.backend.fail_silently = True self.backend.fail_silently = True
self.backend.open() self.backend.open()
class LegacyAPINotUsedTests(SimpleTestCase):
"""
Check django.core.mail does not directly import Python legacy email APIs,
with a few specific exceptions.
"""
# From "Legacy API:" in https://docs.python.org/3/library/email.html.
legacy_email_apis = {
"email.message.Message",
"email.mime",
"email.header",
"email.charset",
"email.encoders",
"email.utils",
"email.iterators",
}
allowed_exceptions = {
# Compatibility in EmailMessage.attachments special cases:
"email.message.Message",
# No replacement in modern email API:
"email.utils.make_msgid",
}
def test_no_legacy_apis_imported(self):
django_core_mail_path = Path(mail.__file__).parent
django_path = django_core_mail_path.parent.parent.parent
for abs_path in django_core_mail_path.glob("**/*.py"):
allowed_exceptions = self.allowed_exceptions.copy()
# RemovedInDjango70Warning.
# The following will be removed after the deprecation period.
if abs_path.name == "message.py":
allowed_exceptions.update(
{
"email.charset",
"email.header.Header",
"email.mime.base.MIMEBase",
"email.mime.message.MIMEMessage",
"email.mime.multipart.MIMEMultipart",
"email.mime.text.MIMEText",
"email.utils.formataddr",
"email.utils.getaddresses",
}
)
path = abs_path.relative_to(django_path)
with self.subTest(path=str(path)):
collector = self.ImportCollector(abs_path.read_text())
used_apis = collector.get_matching_imports(self.legacy_email_apis)
used_apis -= allowed_exceptions
self.assertEqual(
"\n".join(sorted(used_apis)),
"",
f"Python legacy email APIs used in {path}",
)
class ImportCollector(ast.NodeVisitor):
"""
Collect all imports from an AST as a set of fully-qualified dotted
names.
"""
def __init__(self, source=None):
self.imports = set()
if source:
tree = ast.parse(source)
self.visit(tree)
def get_matching_imports(self, base_names):
"""
Return the set of collected imports that start with any
of the fully-qualified dotted names in iterable base_names.
"""
matcher = re.compile(
r"\b(" + r"|".join(re.escape(name) for name in base_names) + r")\b"
)
return set(name for name in self.imports if matcher.match(name))
def visit_Import(self, node):
self.imports.update(alias.name for alias in node.names)
def visit_ImportFrom(self, node):
self.imports.update(f"{node.module}.{alias.name}" for alias in node.names)