Code Diff
diff --git a/django/core/mail/__init__.py b/django/core/mail/__init__.py
index 4f49d8575630..4d7f8bf1d929 100644
--- a/django/core/mail/__init__.py
+++ b/django/core/mail/__init__.py
@@ -13,13 +13,10 @@
# backends and the subsequent reorganization (See #10355)
from django.core.mail.message import (
DEFAULT_ATTACHMENT_MIME_TYPE,
- BadHeaderError,
EmailAlternative,
EmailAttachment,
EmailMessage,
EmailMultiAlternatives,
- SafeMIMEMultipart,
- SafeMIMEText,
forbid_multi_line_headers,
make_msgid,
)
@@ -33,12 +30,8 @@
"DNS_NAME",
"EmailMessage",
"EmailMultiAlternatives",
- "SafeMIMEText",
- "SafeMIMEMultipart",
"DEFAULT_ATTACHMENT_MIME_TYPE",
"make_msgid",
- "BadHeaderError",
- "forbid_multi_line_headers",
"get_connection",
"send_mail",
"send_mass_mail",
@@ -46,6 +39,12 @@
"mail_managers",
"EmailAlternative",
"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,
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)
diff --git a/django/core/mail/backends/smtp.py b/django/core/mail/backends/smtp.py
index 6820148ac122..4b79d9f3e1e0 100644
--- a/django/core/mail/backends/smtp.py
+++ b/django/core/mail/backends/smtp.py
@@ -1,13 +1,15 @@
"""SMTP email backend class."""
+import email.policy
import smtplib
import ssl
import threading
+from email.headerregistry import Address, AddressHeader
from django.conf import settings
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.utils.encoding import force_str, punycode
from django.utils.functional import cached_property
@@ -145,18 +147,47 @@ def _send(self, email_message):
"""A helper method that does the actual sending."""
if not email_message.recipients():
return False
- encoding = email_message.encoding or settings.DEFAULT_CHARSET
- from_email = sanitize_address(email_message.from_email, encoding)
- recipients = [
- sanitize_address(addr, encoding) for addr in email_message.recipients()
- ]
- message = email_message.message()
+ from_email = self.prep_address(email_message.from_email)
+ recipients = [self.prep_address(addr) for addr in email_message.recipients()]
+ message = email_message.message(policy=email.policy.SMTP)
try:
- self.connection.sendmail(
- from_email, recipients, message.as_bytes(linesep="\r\n")
- )
+ self.connection.sendmail(from_email, recipients, message.as_bytes())
except smtplib.SMTPException:
if not self.fail_silently:
raise
return False
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
diff --git a/django/core/mail/message.py b/django/core/mail/message.py
index 3fd2ef66563c..66d5efeb6338 100644
--- a/django/core/mail/message.py
+++ b/django/core/mail/message.py
@@ -1,17 +1,19 @@
+import email.message
+import email.policy
import mimetypes
+import warnings
from collections import namedtuple
+from datetime import datetime, timezone
from email import charset as Charset
-from email import encoders as Encoders
-from email import generator, message_from_bytes
+from email import generator
from email.errors import HeaderParseError
from email.header import Header
-from email.headerregistry import Address, parser
-from email.message import Message
+from email.headerregistry import Address, AddressHeader, parser
from email.mime.base import MIMEBase
from email.mime.message import MIMEMessage
from email.mime.multipart import MIMEMultipart
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 pathlib import Path
@@ -19,7 +21,9 @@
from django.core.mail.utils import DNS_NAME
from django.utils.deprecation import RemovedInDjango70Warning, deprecate_posargs
from django.utils.encoding import force_bytes, force_str, punycode
+from django.utils.timezone import get_current_timezone
+# RemovedInDjango70Warning.
# Don't BASE64-encode UTF-8 messages so that we avoid unwanted attention from
# some spam filters.
utf8_charset = Charset.Charset("utf-8")
@@ -31,13 +35,17 @@
# and cannot be guessed).
DEFAULT_ATTACHMENT_MIME_TYPE = "application/octet-stream"
+# RemovedInDjango70Warning.
RFC5322_EMAIL_LINE_LENGTH_LIMIT = 998
-class BadHeaderError(ValueError):
- pass
-
+# RemovedInDjango70Warning.
+# 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).
ADDRESS_HEADERS = {
"from",
@@ -54,8 +62,16 @@ class BadHeaderError(ValueError):
}
+# RemovedInDjango70Warning.
def forbid_multi_line_headers(name, val, encoding):
"""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
val = str(val) # val may be lazy
if "\n" in val or "\r" in val:
@@ -77,10 +93,20 @@ def forbid_multi_line_headers(name, val, encoding):
return name, val
+# RemovedInDjango70Warning.
def sanitize_address(addr, encoding):
"""
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
if not isinstance(addr, tuple):
addr = force_str(addr)
@@ -123,6 +149,7 @@ def sanitize_address(addr, encoding):
return formataddr((nm, parsed_address.addr_spec))
+# RemovedInDjango70Warning.
class MIMEMixin:
def as_string(self, unixfrom=False, linesep="\n"):
"""Return the entire formatted message as a string.
@@ -151,6 +178,7 @@ def as_bytes(self, unixfrom=False, linesep="\n"):
return fp.getvalue()
+# RemovedInDjango70Warning.
class SafeMIMEMessage(MIMEMixin, MIMEMessage):
def __setitem__(self, name, val):
# Per RFC 2046 Section 5.2.1, message/rfc822 attachment headers must be
@@ -159,6 +187,7 @@ def __setitem__(self, name, val):
MIMEMessage.__setitem__(self, name, val)
+# RemovedInDjango70Warning.
class SafeMIMEText(MIMEMixin, MIMEText):
def __init__(self, _text, _subtype="plain", _charset=None):
self.encoding = _charset
@@ -181,6 +210,7 @@ def set_payload(self, payload, charset=None):
MIMEText.set_payload(self, payload, charset=charset)
+# RemovedInDjango70Warning.
class SafeMIMEMultipart(MIMEMixin, MIMEMultipart):
def __init__(
self, _subtype="mixed", boundary=None, _subparts=None, encoding=None, **_params
@@ -201,8 +231,10 @@ class EmailMessage:
"""A container for email information."""
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(
RemovedInDjango70Warning,
@@ -263,7 +295,10 @@ def __init__(
self.attachments = []
if 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)
else:
self.attach(*attachment)
@@ -277,12 +312,13 @@ def get_connection(self, fail_silently=False):
self.connection = get_connection(fail_silently=fail_silently)
return self.connection
- def message(self):
- encoding = self.encoding or settings.DEFAULT_CHARSET
- msg = SafeMIMEText(self.body, self.content_subtype, encoding)
- msg = self._create_message(msg)
- msg["Subject"] = self.subject
- msg["From"] = self.extra_headers.get("From", self.from_email)
+ def message(self, *, policy=email.policy.default):
+ msg = email.message.EmailMessage(policy=policy)
+ self._add_bodies(msg)
+ self._add_attachments(msg)
+
+ 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, "Cc", self.cc)
self._set_list_header_if_not_empty(msg, "Reply-To", self.reply_to)
@@ -291,18 +327,19 @@ def message(self):
# accommodate that when doing comparisons.
header_names = [key.lower() for key in self.extra_headers]
if "date" not in header_names:
- # formatdate() uses stdlib methods to format the date, which use
- # the stdlib/OS concept of a timezone, however, Django sets the
- # TZ environment variable based on the TIME_ZONE setting which
- # will get picked up by formatdate().
- msg["Date"] = formatdate(localtime=settings.EMAIL_USE_LOCALTIME)
+ if settings.EMAIL_USE_LOCALTIME:
+ tz = get_current_timezone()
+ else:
+ tz = timezone.utc
+ msg["Date"] = datetime.now(tz)
if "message-id" not in header_names:
# Use cached DNS_NAME for performance
msg["Message-ID"] = make_msgid(domain=DNS_NAME)
for name, value in self.extra_headers.items():
# Avoid headers handled above.
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
def recipients(self):
@@ -332,7 +369,19 @@ def attach(self, filename=None, content=None, mimetype=None):
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.
"""
- 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:
raise ValueError(
"content and mimetype must not be given when a MIMEBase "
@@ -376,77 +425,75 @@ def attach_file(self, path, mimetype=None):
content = file.read()
self.attach(path.name, content, mimetype)
- def _create_message(self, msg):
- return self._create_attachments(msg)
+ def _add_bodies(self, msg):
+ if self.body or not self.attachments:
+ encoding = self.encoding or settings.DEFAULT_CHARSET
+ body = force
... [truncated]