From 06c7077e37e2371eefcb6a3146fc0cc949a256ac Mon Sep 17 00:00:00 2001 From: medmunds Date: Tue, 27 Feb 2018 13:43:58 -0800 Subject: [PATCH] Fix: flag extra_headers["To"] as unsupported Django's SMTP EmailBackend allows spoofing the To header by setting `message.extra_headers["To"]`` different from `message.to`. No current Anymail ESP supports this. Treat extra_headers["To"] as an unsupported ESP feature, to flag attempts to use it. Also document Anymail's special header handling that replicates Django's SMTP EmailBackend behavior. --- anymail/backends/base.py | 19 ++++++++++++++++-- docs/sending/django_email.rst | 38 +++++++++++++++++++++++++++++++++-- tests/test_general_backend.py | 8 ++++++++ 3 files changed, 61 insertions(+), 4 deletions(-) diff --git a/anymail/backends/base.py b/anymail/backends/base.py index 89d2dde..b7af863 100644 --- a/anymail/backends/base.py +++ b/anymail/backends/base.py @@ -300,12 +300,22 @@ class BasePayload(object): # but message.from_email should be used as the envelope_sender. See: # - https://code.djangoproject.com/ticket/9214 # - https://github.com/django/django/blob/1.8/django/core/mail/message.py#L269 - # - https://github.com/django/django/blob/1.8/django/core/mail/backends/smtp.py#L118-L123 - header_from = parse_address_list(headers.pop('From', None)) + # - https://github.com/django/django/blob/1.8/django/core/mail/backends/smtp.py#L118 + header_from = parse_address_list(headers.pop('From')) envelope_sender = parse_single_address(self.message.from_email) # must be single address self.set_from_email_list(header_from) self.set_envelope_sender(envelope_sender) + if 'To' in headers: + # If message.extra_headers['To'] is supplied, message.to is used only as the envelope + # recipients (SMTP.sendmail to_addrs), and the header To is spoofed. See: + # - https://github.com/django/django/blob/1.8/django/core/mail/message.py#L270 + # - https://github.com/django/django/blob/1.8/django/core/mail/backends/smtp.py#L119-L120 + # No current ESP supports this, so this code is mainly here to flag + # the SMTP backend's behavior as an unsupported feature in Anymail: + header_to = headers.pop('To') + self.set_spoofed_to_header(header_to) + if headers: self.set_extra_headers(headers) @@ -443,6 +453,11 @@ class BasePayload(object): raise NotImplementedError("%s.%s must implement add_attachment or set_attachments" % (self.__class__.__module__, self.__class__.__name__)) + def set_spoofed_to_header(self, header_to): + # In the unlikely case an ESP supports *completely replacing* the To message header + # without altering the actual envelope recipients, the backend can implement this. + self.unsupported_feature("spoofing `To` header") + # Anymail-specific payload construction def set_envelope_sender(self, email): self.unsupported_feature("envelope_sender") diff --git a/docs/sending/django_email.rst b/docs/sending/django_email.rst index 8d125c6..af1c665 100644 --- a/docs/sending/django_email.rst +++ b/docs/sending/django_email.rst @@ -78,7 +78,7 @@ headers, Anymail will tell your ESP to treat them as inline rather than ordinary attached files. If you want to reference an attachment from an `` in your HTML source, the attachment also needs a :mailheader:`Content-ID` header. -Anymail's comes with :func:`~message.attach_inline_image` and +Anymail comes with :func:`~message.attach_inline_image` and :func:`~message.attach_inline_image_file` convenience functions that do the right thing. See :ref:`inline-images` in the "Anymail additions" section. @@ -95,10 +95,11 @@ Additional headers ------------------ Anymail passes additional headers to your ESP. (Some ESPs may limit -which headers they'll allow.) +which headers they'll allow.) EmailMessage expects a `dict` of headers: .. code-block:: python + # Use `headers` when creating an EmailMessage msg = EmailMessage( ... headers={ "List-Unsubscribe": unsubscribe_url, @@ -106,6 +107,39 @@ which headers they'll allow.) } ) + # Or use the `extra_headers` attribute later + msg.extra_headers["In-Reply-To"] = inbound_msg["Message-ID"] + +Anymail treats header names as case-*insensitive* (because that's how email handles them). +If you supply multiple headers that differ only in case, only one of them will make it +into the resulting email. + +Django's default :class:`SMTP EmailBackend ` +has special handling for certain headers. Anymail replicates its behavior for compatibility: + +.. Django doesn't doc EmailMessage :attr:`to`, :attr:`from_email`, etc. So just link to + the :class:`EmailMessage` docs to refer to them. + +* If you supply a "Reply-To" header, it will *override* the message's + :class:`reply_to ` attribute. + +* If you supply a "From" header, it will override the message's + :class:`from_email ` and become the :mailheader:`From` field the + recipient sees. In addition, the original :class:`from_email ` value + will be used as the message's :attr:`~anymail.message.AnymailMessage.envelope_sender`, which becomes + the :mailheader:`Return-Path` at the recipient end. (Only if your ESP supports altering envelope + sender, otherwise you'll get an :ref:`unsupported feature ` error.) + +* If you supply a "To" header, you'll get an :ref:`unsupported feature ` error. + With Django's SMTP EmailBackend, this can be used to show the recipient a :mailheader:`To` address + that's different from the actual envelope recipients in the message's + :class:`to ` list. Spoofing the :mailheader:`To` header like this + is popular with spammers, and none of Anymail's supported ESPs allow it. + +.. versionchanged:: 2.0 + + Improved header-handling compatibility with Django's SMTP EmailBackend. + .. _unsupported-features: diff --git a/tests/test_general_backend.py b/tests/test_general_backend.py index fe4a737..91c9707 100644 --- a/tests/test_general_backend.py +++ b/tests/test_general_backend.py @@ -371,3 +371,11 @@ class SpecialHeaderTests(TestBackendTestCase): self.assertEqual(params['from'].address, "Header From ") self.assertEqual(params['envelope_sender'], "envelope@bounces.example.com") self.assertNotIn("From", params.get('extra_headers', {})) # From was removed from extra-headers + + def test_spoofed_to_header(self): + """Django treats message.to as envelope-recipient if message.extra_headers['To'] is set""" + # No current ESP supports this (and it's unlikely they would) + self.message.to = ["actual-recipient@example.com"] + self.message.extra_headers = {"To": "Apparent Recipient "} + with self.assertRaisesMessage(AnymailUnsupportedFeature, "spoofing `To` header"): + self.message.send()