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.
This commit is contained in:
medmunds
2018-02-26 18:42:19 -08:00
parent bd9d92f5a0
commit 07fbeac6bd
27 changed files with 246 additions and 28 deletions

View File

@@ -9,8 +9,9 @@ from requests.structures import CaseInsensitiveDict
from ..exceptions import AnymailCancelSend, AnymailError, AnymailUnsupportedFeature, AnymailRecipientsRefused from ..exceptions import AnymailCancelSend, AnymailError, AnymailUnsupportedFeature, AnymailRecipientsRefused
from ..message import AnymailStatus from ..message import AnymailStatus
from ..signals import pre_send, post_send from ..signals import pre_send, post_send
from ..utils import (Attachment, UNSET, combine, last, get_anymail_setting, parse_address_list, from ..utils import (
force_non_lazy, force_non_lazy_list, force_non_lazy_dict, is_lazy) 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): class AnymailBaseBackend(BaseEmailBackend):
@@ -230,6 +231,7 @@ class BasePayload(object):
) )
anymail_message_attrs = ( anymail_message_attrs = (
# Anymail expando-props # Anymail expando-props
('envelope_sender', last, parse_single_address),
('metadata', combine, force_non_lazy_dict), ('metadata', combine, force_non_lazy_dict),
('send_at', last, 'aware_datetime'), ('send_at', last, 'aware_datetime'),
('tags', combine, force_non_lazy_list), ('tags', combine, force_non_lazy_list),
@@ -293,6 +295,17 @@ class BasePayload(object):
# This matches the behavior of Django's EmailMessage.message(). # This matches the behavior of Django's EmailMessage.message().
self.set_reply_to(parse_address_list([reply_to])) 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: if headers:
self.set_extra_headers(headers) self.set_extra_headers(headers)
@@ -431,6 +444,9 @@ class BasePayload(object):
(self.__class__.__module__, self.__class__.__name__)) (self.__class__.__module__, self.__class__.__name__))
# Anymail-specific payload construction # Anymail-specific payload construction
def set_envelope_sender(self, email):
self.unsupported_feature("envelope_sender")
def set_metadata(self, metadata): def set_metadata(self, metadata):
self.unsupported_feature("metadata") self.unsupported_feature("metadata")

View File

@@ -162,6 +162,10 @@ class MailgunPayload(RequestsPayload):
(field, (name, attachment.content, attachment.mimetype)) (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): def set_metadata(self, metadata):
for key, value in metadata.items(): for key, value in metadata.items():
self.data["v:%s" % key] = value self.data["v:%s" % key] = value

View File

@@ -221,6 +221,9 @@ class MailjetPayload(RequestsPayload):
"content": attachment.b64content "content": attachment.b64content
}) })
def set_envelope_sender(self, email):
self.data["Sender"] = email.addr_spec # ??? v3 docs unclear
def set_metadata(self, metadata): def set_metadata(self, metadata):
# Mailjet expects a single string payload # Mailjet expects a single string payload
self.data["Mj-EventPayLoad"] = self.serialize_json(metadata) self.data["Mj-EventPayLoad"] = self.serialize_json(metadata)

View File

@@ -139,6 +139,10 @@ class MandrillPayload(RequestsPayload):
"content": attachment.b64content "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): def set_metadata(self, metadata):
self.data["message"]["metadata"] = metadata self.data["message"]["metadata"] = metadata
@@ -268,6 +272,10 @@ class MandrillPayload(RequestsPayload):
self.deprecation_warning('message.merge_vars', 'message.merge_data') self.deprecation_warning('message.merge_vars', 'message.merge_data')
self.set_merge_data(merge_vars) 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): def set_template_name(self, template_name):
self.deprecation_warning('message.template_name', 'message.template_id') self.deprecation_warning('message.template_name', 'message.template_id')
self.set_template_id(template_name) self.set_template_id(template_name)

View File

@@ -172,6 +172,9 @@ class SparkPostPayload(BasePayload):
'data': attachment.b64content}) 'data': attachment.b64content})
# Anymail-specific payload construction # Anymail-specific payload construction
def set_envelope_sender(self, email):
self.params['return_path'] = email.addr_spec
def set_metadata(self, metadata): def set_metadata(self, metadata):
self.params['metadata'] = metadata self.params['metadata'] = metadata

View File

@@ -74,6 +74,9 @@ class TestPayload(BasePayload):
def set_from_email(self, email): def set_from_email(self, email):
self.params['from'] = email self.params['from'] = email
def set_envelope_sender(self, email):
self.params['envelope_sender'] = email.addr_spec
def set_to(self, emails): def set_to(self, emails):
self.params['to'] = emails self.params['to'] = emails
self.recipient_emails += [email.addr_spec for email in emails] self.recipient_emails += [email.addr_spec for email in emails]

View File

@@ -20,6 +20,7 @@ class AnymailMessageMixin(object):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.esp_extra = kwargs.pop('esp_extra', UNSET) self.esp_extra = kwargs.pop('esp_extra', UNSET)
self.envelope_sender = kwargs.pop('envelope_sender', UNSET)
self.metadata = kwargs.pop('metadata', UNSET) self.metadata = kwargs.pop('metadata', UNSET)
self.send_at = kwargs.pop('send_at', UNSET) self.send_at = kwargs.pop('send_at', UNSET)
self.tags = kwargs.pop('tags', UNSET) self.tags = kwargs.pop('tags', UNSET)

View File

@@ -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.encoding import force_text
from django.utils.functional import Promise from django.utils.functional import Promise
from django.utils.timezone import utc, get_fixed_timezone from django.utils.timezone import utc, get_fixed_timezone
# noinspection PyUnresolvedReferences
from six.moves.urllib.parse import urlsplit, urlunsplit from six.moves.urllib.parse import urlsplit, urlunsplit
from .exceptions import AnymailConfigurationError, AnymailInvalidAddress from .exceptions import AnymailConfigurationError, AnymailInvalidAddress
@@ -161,6 +160,21 @@ def parse_address_list(address_list):
return parsed 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): class EmailAddress(object):
"""A sanitized, complete email address with easy access """A sanitized, complete email address with easy access
to display-name, addr-spec (email), etc. to display-name, addr-spec (email), etc.

View File

@@ -28,32 +28,33 @@ The table below summarizes the Anymail features supported for each ESP.
.. currentmodule:: anymail.message .. 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 <anymail-send-options>` .. rubric:: :ref:`Anymail send options <anymail-send-options>`
----------------------------------------------------------------------------------------------------------------------------------- -------------------------------------------------------------------------------------------------------------------------------------
:attr:`~AnymailMessage.metadata` Yes Yes Yes No Yes Yes Yes :attr:`~AnymailMessage.envelope_sender` Domain only Yes Domain only No No No Yes
:attr:`~AnymailMessage.send_at` Yes No Yes No Yes No Yes :attr:`~AnymailMessage.metadata` Yes Yes Yes No Yes Yes Yes
:attr:`~AnymailMessage.tags` Yes Max 1 tag Yes Max 1 tag Yes Max 1 tag Max 1 tag :attr:`~AnymailMessage.send_at` Yes No Yes No Yes No Yes
:attr:`~AnymailMessage.track_clicks` Yes Yes Yes Yes Yes No Yes :attr:`~AnymailMessage.tags` Yes Max 1 tag Yes Max 1 tag Yes Max 1 tag Max 1 tag
:attr:`~AnymailMessage.track_opens` Yes Yes Yes Yes Yes No Yes :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` .. rubric:: :ref:`templates-and-merge`
----------------------------------------------------------------------------------------------------------------------------------- -------------------------------------------------------------------------------------------------------------------------------------
:attr:`~AnymailMessage.template_id` No 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_data` Yes Yes Yes No Yes No Yes
:attr:`~AnymailMessage.merge_global_data` (emulated) Yes Yes Yes Yes Yes Yes :attr:`~AnymailMessage.merge_global_data` (emulated) Yes Yes Yes Yes Yes Yes
.. rubric:: :ref:`Status <esp-send-status>` and :ref:`event tracking <event-tracking>` .. rubric:: :ref:`Status <esp-send-status>` and :ref:`event tracking <event-tracking>`
----------------------------------------------------------------------------------------------------------------------------------- -------------------------------------------------------------------------------------------------------------------------------------
:attr:`~AnymailMessage.anymail_status` Yes Yes Yes Yes Yes Yes Yes :attr:`~AnymailMessage.anymail_status` Yes Yes Yes Yes Yes Yes Yes
|AnymailTrackingEvent| from webhooks Yes Yes Yes Yes Yes No Yes |AnymailTrackingEvent| from webhooks Yes Yes Yes Yes Yes No Yes
.. rubric:: :ref:`Inbound handling <inbound>` .. rubric:: :ref:`Inbound handling <inbound>`
----------------------------------------------------------------------------------------------------------------------------------- -------------------------------------------------------------------------------------------------------------------------------------
|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 Trying to choose an ESP? Please **don't** start with this table. It's far more

View File

@@ -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, If you need to override the sender domain for an individual message,
include `sender_domain` in Anymail's :attr:`~anymail.message.AnymailMessage.esp_extra` use Anymail's :attr:`~anymail.message.AnymailMessage.envelope_sender`
for that message: (only the domain is used; anything before the @ is ignored):
.. code-block:: python .. code-block:: python
message = EmailMessage(from_email="marketing@example.com", ...) 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: .. _Mailgun sender domain:
https://help.mailgun.com/hc/en-us/articles/202256730-How-do-I-pick-a-domain-name-for-my-Mailgun-account- 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 if you need to access that metadata from an opened, clicked, or unsubscribed
:ref:`tracking event <event-tracking>` handler. :ref:`tracking event <event-tracking>` handler.
**Envelope sender uses only domain**
Anymail's :attr:`~anymail.message.AnymailMessage.envelope_sender` is used to
select your Mailgun :ref:`sender domain <mailgun-sender-domain>`. For
obvious reasons, only the domain portion applies. You can use anything before
the @, and it will be ignored.
.. _mailgun-templates: .. _mailgun-templates:

View File

@@ -126,6 +126,11 @@ Limitations and quirks
**No delayed sending** **No delayed sending**
Mailjet does not support :attr:`~anymail.message.AnymailMessage.send_at`. 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** **Commas in recipient names**
Mailjet's v3 API does not properly handle commas in recipient display-names Mailjet's v3 API does not properly handle commas in recipient display-names
*if* your message also uses the ``cc`` or ``bcc`` fields. *if* your message also uses the ``cc`` or ``bcc`` fields.

View File

@@ -129,6 +129,18 @@ as Mandrill's more complex list of name/content dicts.
.. _messages/send API: .. _messages/send API:
https://mandrillapp.com/api/docs/messages.JSON.html#method=send 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: .. _mandrill-templates:
Batch sending/merge and ESP 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`` With Anymail, set ``message.from_email = None`` or ``message.subject = None``
to use the values from the stored template. 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** **Other Mandrill-specific attributes**
Djrill allowed nearly all Mandrill API parameters to be set Djrill allowed nearly all Mandrill API parameters to be set
as attributes directly on an EmailMessage. With Anymail, you as attributes directly on an EmailMessage. With Anymail, you

View File

@@ -120,6 +120,11 @@ see :ref:`unsupported-features`.
.. _several link-tracking options: .. _several link-tracking options:
http://developer.postmarkapp.com/developer-link-tracking.html 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: .. _postmark-templates:

View File

@@ -195,6 +195,10 @@ Limitations and quirks
(Tested March, 2016) (Tested March, 2016)
**No envelope sender overrides**
SendGrid does not support overriding :attr:`~anymail.message.AnymailMessage.envelope_sender`
on individual messages.
.. _sendgrid-templates: .. _sendgrid-templates:

View File

@@ -1,7 +1,7 @@
.. _sendinblue-backend: .. _sendinblue-backend:
SendinBlue SendinBlue
======== ==========
Anymail integrates with the `SendinBlue`_ email service, using their `Web API v3`_. 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: If you use a template you will suffer some limitations:
you can't change the subject or/and the body, and all email's you can't change the subject or/and the body, and all email's
display-names will be hidden. display-names will be hidden.
**No envelope sender overrides**
SendinBlue does not support overriding :attr:`~anymail.message.AnymailMessage.envelope_sender`
on individual messages.

View File

@@ -127,6 +127,13 @@ Limitations and quirks
(SparkPost's "recipient tags" are not available for tagging *messages*. (SparkPost's "recipient tags" are not available for tagging *messages*.
They're associated with individual *addresses* in stored recipient lists.) 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: .. _sparkpost-templates:

View File

@@ -68,6 +68,35 @@ ESP send options (AnymailMessage)
:class:`~django.core.mail.EmailMessage` you send. :class:`~django.core.mail.EmailMessage` you send.
(You don't have to use :class:`AnymailMessage`.) (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 <supported-esps>` 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 .. attribute:: metadata
Set this to a `dict` of metadata values the ESP should store Set this to a `dict` of metadata values the ESP should store

View File

@@ -351,3 +351,23 @@ class SpecialHeaderTests(TestBackendTestCase):
params = self.get_send_params() params = self.get_send_params()
self.assertEqual(flatten_emails(params['reply_to']), ["header@example.com"]) self.assertEqual(flatten_emails(params['reply_to']), ["header@example.com"])
self.assertEqual(params['extra_headers'], {"X-Extra": "extra"}) # Reply-To no longer there 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 <header@example.com>"
self.message.envelope_sender = "Envelope From <envelope@bounces.example.com>" # Anymail extension
self.message.send()
params = self.get_send_params()
self.assertEqual(params['from'].address, "Header From <header@example.com>")
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 <envelope@bounces.example.com>"
self.message.extra_headers = {"From": "Header From <header@example.com>"}
self.message.send()
params = self.get_send_params()
self.assertEqual(params['from'].address, "Header From <header@example.com>")
self.assertEqual(params['envelope_sender'], "envelope@bounces.example.com")
self.assertNotIn("From", params.get('extra_headers', {})) # From was removed from extra-headers

View File

@@ -417,13 +417,19 @@ class MailgunBackendAnymailFeatureTests(MailgunBackendMockAPITestCase):
}) })
def test_sender_domain(self): 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. # You could also use MAILGUN_SENDER_DOMAIN in your ANYMAIL settings, as in the next test.
# (The mailgun_integration_tests also do that.) # (The mailgun_integration_tests also do that.)
self.message.from_email = "Test From <from@from-email.example.com>" self.message.from_email = "Test From <from@from-email.example.com>"
self.message.send() self.message.send()
self.assert_esp_called('/from-email.example.com/messages') # API url includes the sender-domain self.assert_esp_called('/from-email.example.com/messages') # API url includes the sender-domain
self.message.from_email = "Test From <from@from-email.example.com>"
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 <from@from-email.example.com>"
self.message.esp_extra = {'sender_domain': 'esp-extra.example.com'} self.message.esp_extra = {'sender_domain': 'esp-extra.example.com'}
self.message.send() self.message.send()
self.assert_esp_called('/esp-extra.example.com/messages') # overrides from_email self.assert_esp_called('/esp-extra.example.com/messages') # overrides from_email

View File

@@ -357,6 +357,12 @@ class MailjetBackendStandardEmailTests(MailjetBackendMockAPITestCase):
class MailjetBackendAnymailFeatureTests(MailjetBackendMockAPITestCase): class MailjetBackendAnymailFeatureTests(MailjetBackendMockAPITestCase):
"""Test backend support for Anymail added features""" """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): def test_metadata(self):
# Mailjet expects the payload to be a single string # Mailjet expects the payload to be a single string
# https://dev.mailjet.com/guides/#tagging-email-messages # https://dev.mailjet.com/guides/#tagging-email-messages

View File

@@ -270,6 +270,12 @@ class MandrillBackendStandardEmailTests(MandrillBackendMockAPITestCase):
class MandrillBackendAnymailFeatureTests(MandrillBackendMockAPITestCase): class MandrillBackendAnymailFeatureTests(MandrillBackendMockAPITestCase):
"""Test backend support for Anymail added features""" """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): def test_metadata(self):
self.message.metadata = {'user_id': "12345", 'items': 6} self.message.metadata = {'user_id': "12345", 'items': 6}
self.message.send() self.message.send()

View File

@@ -321,6 +321,13 @@ class PostmarkBackendStandardEmailTests(PostmarkBackendMockAPITestCase):
class PostmarkBackendAnymailFeatureTests(PostmarkBackendMockAPITestCase): class PostmarkBackendAnymailFeatureTests(PostmarkBackendMockAPITestCase):
"""Test backend support for Anymail added features""" """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): def test_metadata(self):
self.message.metadata = {'user_id': "12345", 'items': 6} self.message.metadata = {'user_id': "12345", 'items': 6}
with self.assertRaisesMessage(AnymailUnsupportedFeature, 'metadata'): with self.assertRaisesMessage(AnymailUnsupportedFeature, 'metadata'):

View File

@@ -335,6 +335,12 @@ class SendGridBackendStandardEmailTests(SendGridBackendMockAPITestCase):
class SendGridBackendAnymailFeatureTests(SendGridBackendMockAPITestCase): class SendGridBackendAnymailFeatureTests(SendGridBackendMockAPITestCase):
"""Test backend support for Anymail added features""" """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): def test_metadata(self):
self.message.metadata = {'user_id': "12345", 'items': 6, 'float': 98.6, 'long': longtype(123)} self.message.metadata = {'user_id': "12345", 'items': 6, 'float': 98.6, 'long': longtype(123)}
self.message.send() self.message.send()

View File

@@ -343,6 +343,12 @@ class SendGridBackendStandardEmailTests(SendGridBackendMockAPITestCase):
class SendGridBackendAnymailFeatureTests(SendGridBackendMockAPITestCase): class SendGridBackendAnymailFeatureTests(SendGridBackendMockAPITestCase):
"""Test backend support for Anymail added features""" """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): def test_metadata(self):
# Note: SendGrid doesn't handle complex types in metadata # Note: SendGrid doesn't handle complex types in metadata
self.message.metadata = {'user_id': "12345", 'items': 6} self.message.metadata = {'user_id': "12345", 'items': 6}

View File

@@ -270,6 +270,12 @@ class SendinBlueBackendStandardEmailTests(SendinBlueBackendMockAPITestCase):
class SendinBlueBackendAnymailFeatureTests(SendinBlueBackendMockAPITestCase): class SendinBlueBackendAnymailFeatureTests(SendinBlueBackendMockAPITestCase):
"""Test backend support for Anymail added features""" """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): def test_metadata(self):
self.message.metadata = {'user_id': "12345", 'items': 6, 'float': 98.6, 'long': longtype(123)} self.message.metadata = {'user_id': "12345", 'items': 6, 'float': 98.6, 'long': longtype(123)}
self.message.send() self.message.send()

View File

@@ -327,6 +327,12 @@ class SparkPostBackendStandardEmailTests(SparkPostBackendMockAPITestCase):
class SparkPostBackendAnymailFeatureTests(SparkPostBackendMockAPITestCase): class SparkPostBackendAnymailFeatureTests(SparkPostBackendMockAPITestCase):
"""Test backend support for Anymail added features""" """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): def test_metadata(self):
self.message.metadata = {'user_id': "12345", 'items': 'spark, post'} self.message.metadata = {'user_id': "12345", 'items': 'spark, post'}
self.message.send() self.message.send()

View File

@@ -20,7 +20,7 @@ except ImportError:
from anymail.exceptions import AnymailInvalidAddress from anymail.exceptions import AnymailInvalidAddress
from anymail.utils import ( 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, is_lazy, force_non_lazy, force_non_lazy_dict, force_non_lazy_list,
update_deep, update_deep,
get_request_uri, get_request_basic_auth, parse_rfc2822date, querydict_getfirst) 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].display_name, "")
self.assertEqual(parsed_list[0].addr_spec, "one@example.com") 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): class LazyCoercionTests(SimpleTestCase):
"""Test utils.is_lazy and force_non_lazy*""" """Test utils.is_lazy and force_non_lazy*"""