From 07fbeac6bd57d17e5ff5f1a5186f2e9636068ad5 Mon Sep 17 00:00:00 2001 From: medmunds Date: Mon, 26 Feb 2018 18:42:19 -0800 Subject: [PATCH] Feature: Add envelope_sender New EmailMessage attribute `envelope_sender` controls ESP's sender, sending domain, or return path where supported: * Mailgun: overrides SENDER_DOMAIN on individual message (domain portion only) * Mailjet: becomes `Sender` API param * Mandrill: becomes `return_path_domain` API param (domain portion only) * SparkPost: becomes `return_path` API param * Other ESPs: not believed to be supported Also support undocumented Django SMTP backend behavior, where envelope sender is given by `message.from_email` when `message.extra_headers["From"]` is set. Fixes #91. --- anymail/backends/base.py | 20 +++++++++++++-- anymail/backends/mailgun.py | 4 +++ anymail/backends/mailjet.py | 3 +++ anymail/backends/mandrill.py | 8 ++++++ anymail/backends/sparkpost.py | 3 +++ anymail/backends/test.py | 3 +++ anymail/message.py | 1 + anymail/utils.py | 16 +++++++++++- docs/esps/index.rst | 39 +++++++++++++++--------------- docs/esps/mailgun.rst | 19 ++++++++++++--- docs/esps/mailjet.rst | 5 ++++ docs/esps/mandrill.rst | 19 +++++++++++++++ docs/esps/postmark.rst | 5 ++++ docs/esps/sendgrid.rst | 4 +++ docs/esps/sendinblue.rst | 6 ++++- docs/esps/sparkpost.rst | 7 ++++++ docs/sending/anymail_additions.rst | 29 ++++++++++++++++++++++ tests/test_general_backend.py | 20 +++++++++++++++ tests/test_mailgun_backend.py | 8 +++++- tests/test_mailjet_backend.py | 6 +++++ tests/test_mandrill_backend.py | 6 +++++ tests/test_postmark_backend.py | 7 ++++++ tests/test_sendgrid_backend.py | 6 +++++ tests/test_sendgrid_v2_backend.py | 6 +++++ tests/test_sendinblue_backend.py | 6 +++++ tests/test_sparkpost_backend.py | 6 +++++ tests/test_utils.py | 12 ++++++++- 27 files changed, 246 insertions(+), 28 deletions(-) diff --git a/anymail/backends/base.py b/anymail/backends/base.py index fe52bea..89d2dde 100644 --- a/anymail/backends/base.py +++ b/anymail/backends/base.py @@ -9,8 +9,9 @@ from requests.structures import CaseInsensitiveDict from ..exceptions import AnymailCancelSend, AnymailError, AnymailUnsupportedFeature, AnymailRecipientsRefused from ..message import AnymailStatus from ..signals import pre_send, post_send -from ..utils import (Attachment, UNSET, combine, last, get_anymail_setting, parse_address_list, - force_non_lazy, force_non_lazy_list, force_non_lazy_dict, is_lazy) +from ..utils import ( + Attachment, UNSET, combine, last, get_anymail_setting, parse_address_list, parse_single_address, + force_non_lazy, force_non_lazy_list, force_non_lazy_dict, is_lazy) class AnymailBaseBackend(BaseEmailBackend): @@ -230,6 +231,7 @@ class BasePayload(object): ) anymail_message_attrs = ( # Anymail expando-props + ('envelope_sender', last, parse_single_address), ('metadata', combine, force_non_lazy_dict), ('send_at', last, 'aware_datetime'), ('tags', combine, force_non_lazy_list), @@ -293,6 +295,17 @@ class BasePayload(object): # This matches the behavior of Django's EmailMessage.message(). self.set_reply_to(parse_address_list([reply_to])) + if 'From' in headers: + # If message.extra_headers['From'] is supplied, it should override message.from_email, + # 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)) + 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 headers: self.set_extra_headers(headers) @@ -431,6 +444,9 @@ class BasePayload(object): (self.__class__.__module__, self.__class__.__name__)) # Anymail-specific payload construction + def set_envelope_sender(self, email): + self.unsupported_feature("envelope_sender") + def set_metadata(self, metadata): self.unsupported_feature("metadata") diff --git a/anymail/backends/mailgun.py b/anymail/backends/mailgun.py index ee1328d..3ba3eeb 100644 --- a/anymail/backends/mailgun.py +++ b/anymail/backends/mailgun.py @@ -162,6 +162,10 @@ class MailgunPayload(RequestsPayload): (field, (name, attachment.content, attachment.mimetype)) ) + def set_envelope_sender(self, email): + # Only the domain is used + self.sender_domain = email.domain + def set_metadata(self, metadata): for key, value in metadata.items(): self.data["v:%s" % key] = value diff --git a/anymail/backends/mailjet.py b/anymail/backends/mailjet.py index 7154ee1..2fe45d4 100644 --- a/anymail/backends/mailjet.py +++ b/anymail/backends/mailjet.py @@ -221,6 +221,9 @@ class MailjetPayload(RequestsPayload): "content": attachment.b64content }) + def set_envelope_sender(self, email): + self.data["Sender"] = email.addr_spec # ??? v3 docs unclear + def set_metadata(self, metadata): # Mailjet expects a single string payload self.data["Mj-EventPayLoad"] = self.serialize_json(metadata) diff --git a/anymail/backends/mandrill.py b/anymail/backends/mandrill.py index f6fa1b2..15f3984 100644 --- a/anymail/backends/mandrill.py +++ b/anymail/backends/mandrill.py @@ -139,6 +139,10 @@ class MandrillPayload(RequestsPayload): "content": attachment.b64content }) + def set_envelope_sender(self, email): + # Only the domain is used + self.data["message"]["return_path_domain"] = email.domain + def set_metadata(self, metadata): self.data["message"]["metadata"] = metadata @@ -268,6 +272,10 @@ class MandrillPayload(RequestsPayload): self.deprecation_warning('message.merge_vars', 'message.merge_data') self.set_merge_data(merge_vars) + def set_return_path_domain(self, domain): + self.deprecation_warning('message.return_path_domain', 'message.envelope_sender') + self.esp_extra.setdefault('message', {})['return_path_domain'] = domain + def set_template_name(self, template_name): self.deprecation_warning('message.template_name', 'message.template_id') self.set_template_id(template_name) diff --git a/anymail/backends/sparkpost.py b/anymail/backends/sparkpost.py index 95eadd2..a431e1a 100644 --- a/anymail/backends/sparkpost.py +++ b/anymail/backends/sparkpost.py @@ -172,6 +172,9 @@ class SparkPostPayload(BasePayload): 'data': attachment.b64content}) # Anymail-specific payload construction + def set_envelope_sender(self, email): + self.params['return_path'] = email.addr_spec + def set_metadata(self, metadata): self.params['metadata'] = metadata diff --git a/anymail/backends/test.py b/anymail/backends/test.py index b78b7d0..cb7a2c5 100644 --- a/anymail/backends/test.py +++ b/anymail/backends/test.py @@ -74,6 +74,9 @@ class TestPayload(BasePayload): def set_from_email(self, email): self.params['from'] = email + def set_envelope_sender(self, email): + self.params['envelope_sender'] = email.addr_spec + def set_to(self, emails): self.params['to'] = emails self.recipient_emails += [email.addr_spec for email in emails] diff --git a/anymail/message.py b/anymail/message.py index 1341c85..044e6a9 100644 --- a/anymail/message.py +++ b/anymail/message.py @@ -20,6 +20,7 @@ class AnymailMessageMixin(object): def __init__(self, *args, **kwargs): self.esp_extra = kwargs.pop('esp_extra', UNSET) + self.envelope_sender = kwargs.pop('envelope_sender', UNSET) self.metadata = kwargs.pop('metadata', UNSET) self.send_at = kwargs.pop('send_at', UNSET) self.tags = kwargs.pop('tags', UNSET) diff --git a/anymail/utils.py b/anymail/utils.py index 4ed5bb5..ba0e23b 100644 --- a/anymail/utils.py +++ b/anymail/utils.py @@ -13,7 +13,6 @@ from django.core.mail.message import sanitize_address, DEFAULT_ATTACHMENT_MIME_T from django.utils.encoding import force_text from django.utils.functional import Promise from django.utils.timezone import utc, get_fixed_timezone -# noinspection PyUnresolvedReferences from six.moves.urllib.parse import urlsplit, urlunsplit from .exceptions import AnymailConfigurationError, AnymailInvalidAddress @@ -161,6 +160,21 @@ def parse_address_list(address_list): return parsed +def parse_single_address(address): + """Parses a single EmailAddress from str address, or raises AnymailInvalidAddress + + :param str address: the fully-formatted email str to parse + :return :class:`EmailAddress`: if address contains a single email + :raises :exc:`AnymailInvalidAddress`: if address contains no or multiple emails + """ + parsed = parse_address_list([address]) + count = len(parsed) + if count > 1: + raise AnymailInvalidAddress("Only one email address is allowed; found %d in %r" % (count, address)) + else: + return parsed[0] + + class EmailAddress(object): """A sanitized, complete email address with easy access to display-name, addr-spec (email), etc. diff --git a/docs/esps/index.rst b/docs/esps/index.rst index b86d685..bfa5057 100644 --- a/docs/esps/index.rst +++ b/docs/esps/index.rst @@ -28,32 +28,33 @@ The table below summarizes the Anymail features supported for each ESP. .. currentmodule:: anymail.message -============================================ ========== ========== ========== ========== ========== ============ =========== -Email Service Provider |Mailgun| |Mailjet| |Mandrill| |Postmark| |SendGrid| |SendinBlue| |SparkPost| -============================================ ========== ========== ========== ========== ========== ============ =========== +============================================ =========== ========== =========== ========== ========== ============ =========== +Email Service Provider |Mailgun| |Mailjet| |Mandrill| |Postmark| |SendGrid| |SendinBlue| |SparkPost| +============================================ =========== ========== =========== ========== ========== ============ =========== .. rubric:: :ref:`Anymail send options ` ------------------------------------------------------------------------------------------------------------------------------------ -:attr:`~AnymailMessage.metadata` Yes Yes Yes No Yes Yes Yes -:attr:`~AnymailMessage.send_at` Yes No Yes No Yes No Yes -:attr:`~AnymailMessage.tags` Yes Max 1 tag Yes Max 1 tag Yes Max 1 tag Max 1 tag -:attr:`~AnymailMessage.track_clicks` Yes Yes Yes Yes Yes No Yes -:attr:`~AnymailMessage.track_opens` Yes Yes Yes Yes Yes No Yes +------------------------------------------------------------------------------------------------------------------------------------- +:attr:`~AnymailMessage.envelope_sender` Domain only Yes Domain only No No No Yes +:attr:`~AnymailMessage.metadata` Yes Yes Yes No Yes Yes Yes +:attr:`~AnymailMessage.send_at` Yes No Yes No Yes No Yes +:attr:`~AnymailMessage.tags` Yes Max 1 tag Yes Max 1 tag Yes Max 1 tag Max 1 tag +:attr:`~AnymailMessage.track_clicks` Yes Yes Yes Yes Yes No Yes +:attr:`~AnymailMessage.track_opens` Yes Yes Yes Yes Yes No Yes .. rubric:: :ref:`templates-and-merge` ------------------------------------------------------------------------------------------------------------------------------------ -:attr:`~AnymailMessage.template_id` No Yes Yes Yes Yes Yes Yes -:attr:`~AnymailMessage.merge_data` Yes Yes Yes No Yes No Yes -:attr:`~AnymailMessage.merge_global_data` (emulated) Yes Yes Yes Yes Yes Yes +------------------------------------------------------------------------------------------------------------------------------------- +:attr:`~AnymailMessage.template_id` No Yes Yes Yes Yes Yes Yes +:attr:`~AnymailMessage.merge_data` Yes Yes Yes No Yes No Yes +:attr:`~AnymailMessage.merge_global_data` (emulated) Yes Yes Yes Yes Yes Yes .. rubric:: :ref:`Status ` and :ref:`event tracking ` ------------------------------------------------------------------------------------------------------------------------------------ -:attr:`~AnymailMessage.anymail_status` Yes Yes Yes Yes Yes Yes Yes -|AnymailTrackingEvent| from webhooks Yes Yes Yes Yes Yes No Yes +------------------------------------------------------------------------------------------------------------------------------------- +:attr:`~AnymailMessage.anymail_status` Yes Yes Yes Yes Yes Yes Yes +|AnymailTrackingEvent| from webhooks Yes Yes Yes Yes Yes No Yes .. rubric:: :ref:`Inbound handling ` ------------------------------------------------------------------------------------------------------------------------------------ -|AnymailInboundEvent| from webhooks Yes Yes Yes Yes Yes No Yes -============================================ ========== ========== ========== ========== ========== ============ =========== +------------------------------------------------------------------------------------------------------------------------------------- +|AnymailInboundEvent| from webhooks Yes Yes Yes Yes Yes No Yes +============================================ =========== ========== =========== ========== ========== ============ =========== Trying to choose an ESP? Please **don't** start with this table. It's far more diff --git a/docs/esps/mailgun.rst b/docs/esps/mailgun.rst index 1ba3e4d..ab7f5fa 100644 --- a/docs/esps/mailgun.rst +++ b/docs/esps/mailgun.rst @@ -94,15 +94,22 @@ Mailgun account is configured for "*mail1*.example.com", you should provide If you need to override the sender domain for an individual message, -include `sender_domain` in Anymail's :attr:`~anymail.message.AnymailMessage.esp_extra` -for that message: +use Anymail's :attr:`~anymail.message.AnymailMessage.envelope_sender` +(only the domain is used; anything before the @ is ignored): .. code-block:: python message = EmailMessage(from_email="marketing@example.com", ...) - message.esp_extra = {"sender_domain": "mail2.example.com"} + message.envelope_sender = "anything@mail2.example.com" # the "anything@" is ignored +.. versionchanged:: 2.0 + + Earlier Anymail versions looked for a special `sender_domain` key in the message's + :attr:`~anymail.message.AnymailMessage.esp_extra` to override Mailgun's sender domain. + This is still supported, but may be deprecated in a future release. Using + :attr:`~anymail.message.AnymailMessage.envelope_sender` as shown above is now preferred. + .. _Mailgun sender domain: https://help.mailgun.com/hc/en-us/articles/202256730-How-do-I-pick-a-domain-name-for-my-Mailgun-account- @@ -139,6 +146,12 @@ Limitations and quirks if you need to access that metadata from an opened, clicked, or unsubscribed :ref:`tracking event ` handler. +**Envelope sender uses only domain** + Anymail's :attr:`~anymail.message.AnymailMessage.envelope_sender` is used to + select your Mailgun :ref:`sender domain `. For + obvious reasons, only the domain portion applies. You can use anything before + the @, and it will be ignored. + .. _mailgun-templates: diff --git a/docs/esps/mailjet.rst b/docs/esps/mailjet.rst index 1853a2b..e3170e1 100644 --- a/docs/esps/mailjet.rst +++ b/docs/esps/mailjet.rst @@ -126,6 +126,11 @@ Limitations and quirks **No delayed sending** Mailjet does not support :attr:`~anymail.message.AnymailMessage.send_at`. +**Envelope sender may require approval** + Anymail passes :attr:`~anymail.message.AnymailMessage.envelope_sender` to + Mailjet, but this may result in an API error if you have not received + special approval from Mailjet support to use custom senders. + **Commas in recipient names** Mailjet's v3 API does not properly handle commas in recipient display-names *if* your message also uses the ``cc`` or ``bcc`` fields. diff --git a/docs/esps/mandrill.rst b/docs/esps/mandrill.rst index b074d62..9d223fb 100644 --- a/docs/esps/mandrill.rst +++ b/docs/esps/mandrill.rst @@ -129,6 +129,18 @@ as Mandrill's more complex list of name/content dicts. .. _messages/send API: https://mandrillapp.com/api/docs/messages.JSON.html#method=send + +.. _mandrill-quirks: + +Limitations and quirks +---------------------- + +**Envelope sender uses only domain** + Anymail's :attr:`~anymail.message.AnymailMessage.envelope_sender` is used to + populate Mandrill's `'return_path_domain'`---but only the domain portion. + (Mandrill always generates its own encoded mailbox for the envelope sender.) + + .. _mandrill-templates: Batch sending/merge and ESP templates @@ -363,6 +375,13 @@ Changes to EmailMessage attributes With Anymail, set ``message.from_email = None`` or ``message.subject = None`` to use the values from the stored template. +``message.return_path_domain`` + With Anymail, set :attr:`~anymail.message.AnymailMessage.envelope_sender` + instead. You'll need to pass a valid email address (not just a domain), + but Anymail will use only the domain, and will ignore anything before the @. + + .. versionchanged:: 2.0 + **Other Mandrill-specific attributes** Djrill allowed nearly all Mandrill API parameters to be set as attributes directly on an EmailMessage. With Anymail, you diff --git a/docs/esps/postmark.rst b/docs/esps/postmark.rst index da0fe73..1760260 100644 --- a/docs/esps/postmark.rst +++ b/docs/esps/postmark.rst @@ -120,6 +120,11 @@ see :ref:`unsupported-features`. .. _several link-tracking options: http://developer.postmarkapp.com/developer-link-tracking.html +**No envelope sender overrides** + Postmark does not support overriding :attr:`~anymail.message.AnymailMessage.envelope_sender` + on individual messages. (You can configure custom return paths for each sending domain in + the Postmark control panel.) + .. _postmark-templates: diff --git a/docs/esps/sendgrid.rst b/docs/esps/sendgrid.rst index ddd6a2c..533c014 100644 --- a/docs/esps/sendgrid.rst +++ b/docs/esps/sendgrid.rst @@ -195,6 +195,10 @@ Limitations and quirks (Tested March, 2016) +**No envelope sender overrides** + SendGrid does not support overriding :attr:`~anymail.message.AnymailMessage.envelope_sender` + on individual messages. + .. _sendgrid-templates: diff --git a/docs/esps/sendinblue.rst b/docs/esps/sendinblue.rst index 247c3f9..93d47cf 100644 --- a/docs/esps/sendinblue.rst +++ b/docs/esps/sendinblue.rst @@ -1,7 +1,7 @@ .. _sendinblue-backend: SendinBlue -======== +========== Anymail integrates with the `SendinBlue`_ email service, using their `Web API v3`_. @@ -88,3 +88,7 @@ Limitations and quirks If you use a template you will suffer some limitations: you can't change the subject or/and the body, and all email's display-names will be hidden. + +**No envelope sender overrides** + SendinBlue does not support overriding :attr:`~anymail.message.AnymailMessage.envelope_sender` + on individual messages. diff --git a/docs/esps/sparkpost.rst b/docs/esps/sparkpost.rst index 7913245..694846b 100644 --- a/docs/esps/sparkpost.rst +++ b/docs/esps/sparkpost.rst @@ -127,6 +127,13 @@ Limitations and quirks (SparkPost's "recipient tags" are not available for tagging *messages*. They're associated with individual *addresses* in stored recipient lists.) +**Envelope sender may use domain only** + Anymail's :attr:`~anymail.message.AnymailMessage.envelope_sender` is used to + populate SparkPost's `'return_path'` parameter. Anymail supplies the full + email address, but depending on your SparkPost configuration, SparkPost may + use only the domain portion and substitute its own encoded mailbox before + the @. + .. _sparkpost-templates: diff --git a/docs/sending/anymail_additions.rst b/docs/sending/anymail_additions.rst index 264c603..6cf5c09 100644 --- a/docs/sending/anymail_additions.rst +++ b/docs/sending/anymail_additions.rst @@ -68,6 +68,35 @@ ESP send options (AnymailMessage) :class:`~django.core.mail.EmailMessage` you send. (You don't have to use :class:`AnymailMessage`.) + .. attribute:: envelope_sender + + .. versionadded:: 2.0 + + Set this to a `str` email address that should be used as the message's + envelope sender. If supported by your ESP, this will become the Return-Path + in the recipient's mailbox. + + (Envelope sender is also known as bounce address, MAIL FROM, envelope from, + unixfrom, SMTP FROM command, return path, and `several other terms`_. Confused? + Here's some good info on `how envelope sender relates to return path`_.) + + ESP support for envelope sender varies widely. Be sure to check Anymail's + docs for your :ref:`specific ESP ` before attempting to use it. + And note that those ESPs who do support it will often use only the domain + portion of the envelope sender address, overriding the part before the @ with + their own encoded bounce mailbox. + + [The :attr:`!envelope_sender` attribute is unique to Anymail. If you also use Django's + SMTP EmailBackend, you can portably control envelope sender by *instead* setting + ``message.extra_headers["From"]`` to the desired email *header* :mailheader:`From`, + and ``message.from_email`` to the *envelope sender*. Anymail also allows this approach, + for compatibility with the SMTP EmailBackend. See the notes `in Django's bug tracker`_.] + + .. _several other terms: https://en.wikipedia.org/wiki/Bounce_address + .. _in Django's bug tracker: https://code.djangoproject.com/ticket/9214 + .. _how envelope sender relates to return path: + https://www.postmastery.com/blog/about-the-return-path-header/ + .. attribute:: metadata Set this to a `dict` of metadata values the ESP should store diff --git a/tests/test_general_backend.py b/tests/test_general_backend.py index 1d61ca2..fe4a737 100644 --- a/tests/test_general_backend.py +++ b/tests/test_general_backend.py @@ -351,3 +351,23 @@ class SpecialHeaderTests(TestBackendTestCase): params = self.get_send_params() self.assertEqual(flatten_emails(params['reply_to']), ["header@example.com"]) self.assertEqual(params['extra_headers'], {"X-Extra": "extra"}) # Reply-To no longer there + + def test_envelope_sender(self): + """Django treats message.from_email as envelope-sender if messsage.extra_headers['From'] is set""" + # Using Anymail's envelope_sender extension + self.message.from_email = "Header From " + self.message.envelope_sender = "Envelope From " # Anymail extension + self.message.send() + params = self.get_send_params() + self.assertEqual(params['from'].address, "Header From ") + self.assertEqual(params['envelope_sender'], "envelope@bounces.example.com") + + # Using Django's undocumented message.extra_headers['From'] extension + # (see https://code.djangoproject.com/ticket/9214) + self.message.from_email = "Envelope From " + self.message.extra_headers = {"From": "Header From "} + self.message.send() + params = self.get_send_params() + 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 diff --git a/tests/test_mailgun_backend.py b/tests/test_mailgun_backend.py index 5973f69..20f9e34 100644 --- a/tests/test_mailgun_backend.py +++ b/tests/test_mailgun_backend.py @@ -417,13 +417,19 @@ class MailgunBackendAnymailFeatureTests(MailgunBackendMockAPITestCase): }) def test_sender_domain(self): - """Mailgun send domain can come from from_email or esp_extra""" + """Mailgun send domain can come from from_email, envelope_sender, or esp_extra""" # You could also use MAILGUN_SENDER_DOMAIN in your ANYMAIL settings, as in the next test. # (The mailgun_integration_tests also do that.) self.message.from_email = "Test From " self.message.send() self.assert_esp_called('/from-email.example.com/messages') # API url includes the sender-domain + self.message.from_email = "Test From " + self.message.envelope_sender = "anything@bounces.example.com" # only the domain part is used + self.message.send() + self.assert_esp_called('/bounces.example.com/messages') # overrides from_email + + self.message.from_email = "Test From " self.message.esp_extra = {'sender_domain': 'esp-extra.example.com'} self.message.send() self.assert_esp_called('/esp-extra.example.com/messages') # overrides from_email diff --git a/tests/test_mailjet_backend.py b/tests/test_mailjet_backend.py index 30e9a29..29a2bec 100644 --- a/tests/test_mailjet_backend.py +++ b/tests/test_mailjet_backend.py @@ -357,6 +357,12 @@ class MailjetBackendStandardEmailTests(MailjetBackendMockAPITestCase): class MailjetBackendAnymailFeatureTests(MailjetBackendMockAPITestCase): """Test backend support for Anymail added features""" + def test_envelope_sender(self): + self.message.envelope_sender = "bounce-handler@bounces.example.com" + self.message.send() + data = self.get_api_call_json() + self.assertEqual(data['Sender'], "bounce-handler@bounces.example.com") + def test_metadata(self): # Mailjet expects the payload to be a single string # https://dev.mailjet.com/guides/#tagging-email-messages diff --git a/tests/test_mandrill_backend.py b/tests/test_mandrill_backend.py index bc86bd3..08df68e 100644 --- a/tests/test_mandrill_backend.py +++ b/tests/test_mandrill_backend.py @@ -270,6 +270,12 @@ class MandrillBackendStandardEmailTests(MandrillBackendMockAPITestCase): class MandrillBackendAnymailFeatureTests(MandrillBackendMockAPITestCase): """Test backend support for Anymail added features""" + def test_envelope_sender(self): + self.message.envelope_sender = "anything@bounces.example.com" + self.message.send() + data = self.get_api_call_json() + self.assertEqual(data['message']['return_path_domain'], "bounces.example.com") + def test_metadata(self): self.message.metadata = {'user_id': "12345", 'items': 6} self.message.send() diff --git a/tests/test_postmark_backend.py b/tests/test_postmark_backend.py index 4d459de..55151b4 100644 --- a/tests/test_postmark_backend.py +++ b/tests/test_postmark_backend.py @@ -321,6 +321,13 @@ class PostmarkBackendStandardEmailTests(PostmarkBackendMockAPITestCase): class PostmarkBackendAnymailFeatureTests(PostmarkBackendMockAPITestCase): """Test backend support for Anymail added features""" + def test_envelope_sender(self): + # Postmark doesn't allow overriding envelope sender on individual messages. + # You can configure a custom return-path domain for each server in their control panel. + self.message.envelope_sender = "anything@bounces.example.com" + with self.assertRaisesMessage(AnymailUnsupportedFeature, 'envelope_sender'): + self.message.send() + def test_metadata(self): self.message.metadata = {'user_id': "12345", 'items': 6} with self.assertRaisesMessage(AnymailUnsupportedFeature, 'metadata'): diff --git a/tests/test_sendgrid_backend.py b/tests/test_sendgrid_backend.py index c484660..6a3c371 100644 --- a/tests/test_sendgrid_backend.py +++ b/tests/test_sendgrid_backend.py @@ -335,6 +335,12 @@ class SendGridBackendStandardEmailTests(SendGridBackendMockAPITestCase): class SendGridBackendAnymailFeatureTests(SendGridBackendMockAPITestCase): """Test backend support for Anymail added features""" + def test_envelope_sender(self): + # SendGrid does not have a way to change envelope sender. + self.message.envelope_sender = "anything@bounces.example.com" + with self.assertRaisesMessage(AnymailUnsupportedFeature, 'envelope_sender'): + self.message.send() + def test_metadata(self): self.message.metadata = {'user_id': "12345", 'items': 6, 'float': 98.6, 'long': longtype(123)} self.message.send() diff --git a/tests/test_sendgrid_v2_backend.py b/tests/test_sendgrid_v2_backend.py index 617fde0..2f908f5 100644 --- a/tests/test_sendgrid_v2_backend.py +++ b/tests/test_sendgrid_v2_backend.py @@ -343,6 +343,12 @@ class SendGridBackendStandardEmailTests(SendGridBackendMockAPITestCase): class SendGridBackendAnymailFeatureTests(SendGridBackendMockAPITestCase): """Test backend support for Anymail added features""" + def test_envelope_sender(self): + # SendGrid does not have a way to change envelope sender. + self.message.envelope_sender = "anything@bounces.example.com" + with self.assertRaisesMessage(AnymailUnsupportedFeature, 'envelope_sender'): + self.message.send() + def test_metadata(self): # Note: SendGrid doesn't handle complex types in metadata self.message.metadata = {'user_id': "12345", 'items': 6} diff --git a/tests/test_sendinblue_backend.py b/tests/test_sendinblue_backend.py index ddd8b15..8dd5db1 100644 --- a/tests/test_sendinblue_backend.py +++ b/tests/test_sendinblue_backend.py @@ -270,6 +270,12 @@ class SendinBlueBackendStandardEmailTests(SendinBlueBackendMockAPITestCase): class SendinBlueBackendAnymailFeatureTests(SendinBlueBackendMockAPITestCase): """Test backend support for Anymail added features""" + def test_envelope_sender(self): + # SendinBlue does not have a way to change envelope sender. + self.message.envelope_sender = "anything@bounces.example.com" + with self.assertRaisesMessage(AnymailUnsupportedFeature, 'envelope_sender'): + self.message.send() + def test_metadata(self): self.message.metadata = {'user_id': "12345", 'items': 6, 'float': 98.6, 'long': longtype(123)} self.message.send() diff --git a/tests/test_sparkpost_backend.py b/tests/test_sparkpost_backend.py index 4c0d3bd..2a5f394 100644 --- a/tests/test_sparkpost_backend.py +++ b/tests/test_sparkpost_backend.py @@ -327,6 +327,12 @@ class SparkPostBackendStandardEmailTests(SparkPostBackendMockAPITestCase): class SparkPostBackendAnymailFeatureTests(SparkPostBackendMockAPITestCase): """Test backend support for Anymail added features""" + def test_envelope_sender(self): + self.message.envelope_sender = "bounce-handler@bounces.example.com" + self.message.send() + params = self.get_send_params() + self.assertEqual(params['return_path'], "bounce-handler@bounces.example.com") + def test_metadata(self): self.message.metadata = {'user_id': "12345", 'items': 'spark, post'} self.message.send() diff --git a/tests/test_utils.py b/tests/test_utils.py index 507b355..f6800a4 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -20,7 +20,7 @@ except ImportError: from anymail.exceptions import AnymailInvalidAddress from anymail.utils import ( - parse_address_list, EmailAddress, + parse_address_list, parse_single_address, EmailAddress, is_lazy, force_non_lazy, force_non_lazy_dict, force_non_lazy_list, update_deep, get_request_uri, get_request_basic_auth, parse_rfc2822date, querydict_getfirst) @@ -150,6 +150,16 @@ class ParseAddressListTests(SimpleTestCase): self.assertEqual(parsed_list[0].display_name, "") self.assertEqual(parsed_list[0].addr_spec, "one@example.com") + def test_parse_one(self): + parsed = parse_single_address("one@example.com") + self.assertEqual(parsed.address, "one@example.com") + + with self.assertRaisesMessage(AnymailInvalidAddress, "Only one email address is allowed; found 2"): + parse_single_address("one@example.com, two@example.com") + + with self.assertRaisesMessage(AnymailInvalidAddress, "Invalid email address"): + parse_single_address(" ") + class LazyCoercionTests(SimpleTestCase): """Test utils.is_lazy and force_non_lazy*"""