MailerSend: add support (new ESP for Anymail)

Closes #298
This commit is contained in:
Mike Edmunds
2023-03-10 17:22:20 -08:00
committed by GitHub
parent c58640d438
commit 62bd0669af
15 changed files with 3093 additions and 27 deletions

View File

@@ -40,6 +40,7 @@ jobs:
# combination, to avoid rapidly consuming the testing accounts' entire send allotments. # combination, to avoid rapidly consuming the testing accounts' entire send allotments.
config: config:
- { tox: django41-py310-amazon_ses, python: "3.10" } - { tox: django41-py310-amazon_ses, python: "3.10" }
- { tox: django41-py310-mailersend, python: "3.10" }
- { tox: django41-py310-mailgun, python: "3.10" } - { tox: django41-py310-mailgun, python: "3.10" }
- { tox: django41-py310-mailjet, python: "3.10" } - { tox: django41-py310-mailjet, python: "3.10" }
- { tox: django41-py310-mandrill, python: "3.10" } - { tox: django41-py310-mandrill, python: "3.10" }
@@ -74,6 +75,8 @@ jobs:
ANYMAIL_TEST_AMAZON_SES_DOMAIN: ${{ secrets.ANYMAIL_TEST_AMAZON_SES_DOMAIN }} ANYMAIL_TEST_AMAZON_SES_DOMAIN: ${{ secrets.ANYMAIL_TEST_AMAZON_SES_DOMAIN }}
ANYMAIL_TEST_AMAZON_SES_REGION_NAME: ${{ secrets.ANYMAIL_TEST_AMAZON_SES_REGION_NAME }} ANYMAIL_TEST_AMAZON_SES_REGION_NAME: ${{ secrets.ANYMAIL_TEST_AMAZON_SES_REGION_NAME }}
ANYMAIL_TEST_AMAZON_SES_SECRET_ACCESS_KEY: ${{ secrets.ANYMAIL_TEST_AMAZON_SES_SECRET_ACCESS_KEY }} ANYMAIL_TEST_AMAZON_SES_SECRET_ACCESS_KEY: ${{ secrets.ANYMAIL_TEST_AMAZON_SES_SECRET_ACCESS_KEY }}
ANYMAIL_TEST_MAILERSEND_API_TOKEN: ${{ secrets.ANYMAIL_TEST_MAILERSEND_API_TOKEN }}
ANYMAIL_TEST_MAILERSEND_DOMAIN: ${{ secrets.ANYMAIL_TEST_MAILERSEND_DOMAIN }}
ANYMAIL_TEST_MAILGUN_API_KEY: ${{ secrets.ANYMAIL_TEST_MAILGUN_API_KEY }} ANYMAIL_TEST_MAILGUN_API_KEY: ${{ secrets.ANYMAIL_TEST_MAILGUN_API_KEY }}
ANYMAIL_TEST_MAILGUN_DOMAIN: ${{ secrets.ANYMAIL_TEST_MAILGUN_DOMAIN }} ANYMAIL_TEST_MAILGUN_DOMAIN: ${{ secrets.ANYMAIL_TEST_MAILGUN_DOMAIN }}
ANYMAIL_TEST_MAILJET_API_KEY: ${{ secrets.ANYMAIL_TEST_MAILJET_API_KEY }} ANYMAIL_TEST_MAILJET_API_KEY: ${{ secrets.ANYMAIL_TEST_MAILJET_API_KEY }}

View File

@@ -38,6 +38,11 @@ Deprecations
require code changes, but you will need to update your IAM permissions. See require code changes, but you will need to update your IAM permissions. See
`Migrating to the SES v2 API <https://anymail.dev/en/latest/esps/amazon_ses/#amazon-ses-v2>`__. `Migrating to the SES v2 API <https://anymail.dev/en/latest/esps/amazon_ses/#amazon-ses-v2>`__.
Features
~~~~~~~~
* **MailerSend:** Add support for this ESP
(`docs <https://anymail.dev/en/latest/esps/mailersend/>`__).
Other Other
~~~~~ ~~~~~
* Test against Django 4.2 prerelease, Python 3.11 (with Django 4.2), * Test against Django 4.2 prerelease, Python 3.11 (with Django 4.2),

View File

@@ -28,6 +28,7 @@ a consistent API that avoids locking your code to one specific ESP
Anymail currently supports these ESPs: Anymail currently supports these ESPs:
* **Amazon SES** * **Amazon SES**
* **MailerSend**
* **Mailgun** * **Mailgun**
* **Mailjet** * **Mailjet**
* **Mandrill** (MailChimp transactional) * **Mandrill** (MailChimp transactional)

View File

@@ -0,0 +1,338 @@
import mimetypes
from ..exceptions import AnymailRequestsAPIError, AnymailUnsupportedFeature
from ..message import AnymailRecipientStatus
from ..utils import CaseInsensitiveCasePreservingDict, get_anymail_setting, update_deep
from .base_requests import AnymailRequestsBackend, RequestsPayload
class EmailBackend(AnymailRequestsBackend):
"""
MailerSend Email Backend
"""
esp_name = "MailerSend"
def __init__(self, **kwargs):
"""Init options from Django settings"""
esp_name = self.esp_name
self.api_token = get_anymail_setting(
"api_token", esp_name=esp_name, kwargs=kwargs, allow_bare=True
)
api_url = get_anymail_setting(
"api_url",
esp_name=esp_name,
kwargs=kwargs,
default="https://api.mailersend.com/v1/",
)
if not api_url.endswith("/"):
api_url += "/"
#: Can set to "use-bulk-email" or "expose-to-list" or default None
self.batch_send_mode = get_anymail_setting(
"batch_send_mode", default=None, esp_name=esp_name, kwargs=kwargs
)
super().__init__(api_url, **kwargs)
def build_message_payload(self, message, defaults):
return MailerSendPayload(message, defaults, self)
def parse_recipient_status(self, response, payload, message):
# The "email" API endpoint responds with an empty text/html body
# if no warnings, otherwise json with suppression info.
# The "bulk-email" API endpoint always returns json.
if response.headers["Content-Type"] == "application/json":
parsed_response = self.deserialize_json_response(response, payload, message)
else:
parsed_response = {}
try:
# "email" API endpoint success or SOME_SUPPRESSED
message_id = response.headers["X-Message-Id"]
default_status = "queued"
except KeyError:
try:
# "bulk-email" API endpoint
bulk_id = parsed_response["bulk_email_id"]
# Add "bulk:" prefix to distinguish from actual message_id.
message_id = f"bulk:{bulk_id}"
# Status is determined later; must query API to find out
default_status = "unknown"
except KeyError:
# "email" API endpoint with ALL_SUPPRESSED
message_id = None
default_status = "failed"
# Don't swallow errors (which should have been handled with a non-2xx
# status, earlier) or any warnings that we won't consume below.
errors = parsed_response.get("errors", [])
warnings = parsed_response.get("warnings", [])
if errors or any(
warning["type"] not in ("ALL_SUPPRESSED", "SOME_SUPPRESSED")
for warning in warnings
):
raise AnymailRequestsAPIError(
"Unexpected MailerSend API response errors/warnings",
email_message=message,
payload=payload,
response=response,
backend=self,
)
# Collect a list of all problem recipients from any suppression warnings.
# (warnings[].recipients[].reason[] will contain some combination of
# "hard_bounced", "spam_complaint", "unsubscribed", and/or
# "blocklisted", all of which map to Anymail's "rejected" status.)
try:
# warning["type"] is guaranteed to be {ALL,SOME}_SUPPRESSED at this point.
rejected_emails = [
recipient["email"]
for warning in warnings
for recipient in warning["recipients"]
]
except (KeyError, TypeError) as err:
raise AnymailRequestsAPIError(
f"Unexpected MailerSend API response format: {err!s}",
email_message=message,
payload=payload,
response=response,
backend=self,
) from None
recipient_status = CaseInsensitiveCasePreservingDict(
{
recipient.addr_spec: AnymailRecipientStatus(
message_id=message_id, status=default_status
)
for recipient in payload.all_recipients
}
)
for rejected_email in rejected_emails:
recipient_status[rejected_email] = AnymailRecipientStatus(
message_id=None, status="rejected"
)
return dict(recipient_status)
class MailerSendPayload(RequestsPayload):
def __init__(self, message, defaults, backend, *args, **kwargs):
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
# Token may be changed in set_esp_extra below:
"Authorization": f"Bearer {backend.api_token}",
}
self.all_recipients = [] # needed for parse_recipient_status
self.merge_data = {} # late bound
self.merge_global_data = None # late bound
self.batch_send_mode = backend.batch_send_mode # can override in esp_extra
super().__init__(message, defaults, backend, headers=headers, *args, **kwargs)
def get_api_endpoint(self):
if self.is_batch():
# MailerSend's "email" endpoint supports per-recipient customizations
# (merge_data) for batch sending, but exposes the complete "To" list
# to all recipients. This conflicts with Anymail's batch send model, which
# expects each recipient can only see their own "To" email.
#
# MailerSend's "bulk-email" endpoint can send separate messages to each
# "To" email, but doesn't return a message_id. (It returns a batch_email_id
# that can later resolve to message_ids by polling a status API.)
#
# Since either of these would cause unexpected behavior, require the user
# to opt into one via batch_send_mode.
if self.batch_send_mode == "use-bulk-email":
return "bulk-email"
elif self.batch_send_mode == "expose-to-list":
return "email"
elif len(self.data["to"]) <= 1:
# With only one "to", exposing the recipient list is moot.
# (This covers the common case of single-recipient template merge.)
return "email"
else:
# Unconditionally raise, even if IGNORE_UNSUPPORTED_FEATURES enabled.
# We can't guess which API to use for this send.
raise AnymailUnsupportedFeature(
f"{self.esp_name} requires MAILERSEND_BATCH_SEND_MODE set to either"
f" 'use-bulk-email' or 'expose-to-list' for using batch send"
f" (merge_data) with multiple recipients. See the Anymail docs."
)
else:
return "email"
def serialize_data(self):
api_endpoint = self.get_api_endpoint()
needs_personalization = self.merge_data or self.merge_global_data
if api_endpoint == "email":
if needs_personalization:
self.data["personalization"] = [
self.personalization_for_email(to["email"])
for to in self.data["to"]
]
data = self.data
elif api_endpoint == "bulk-email":
# Burst the payload into individual bulk-email recipients:
data = []
for to in self.data["to"]:
recipient_data = self.data.copy()
recipient_data["to"] = [to]
if needs_personalization:
recipient_data["personalization"] = [
self.personalization_for_email(to["email"])
]
data.append(recipient_data)
else:
raise AssertionError(
f"MailerSendPayload.serialize_data missing"
f" case for api_endpoint {api_endpoint!r}"
)
return self.serialize_json(data)
def personalization_for_email(self, email):
"""
Return a MailerSend personalization object for email address.
Composes merge_global_data and merge_data[email].
"""
if email in self.merge_data:
if self.merge_global_data:
recipient_data = self.merge_global_data.copy()
recipient_data.update(self.merge_data[email])
else:
recipient_data = self.merge_data[email]
elif self.merge_global_data:
recipient_data = self.merge_global_data
else:
recipient_data = {}
return {"email": email, "data": recipient_data}
#
# Payload construction
#
def make_mailersend_email(self, email):
"""Return MailerSend email/name object for an EmailAddress"""
obj = {"email": email.addr_spec}
if email.display_name:
obj["name"] = email.display_name
return obj
def init_payload(self):
self.data = {} # becomes json
def set_from_email(self, email):
self.data["from"] = self.make_mailersend_email(email)
def set_recipients(self, recipient_type, emails):
assert recipient_type in ["to", "cc", "bcc"]
if emails:
self.data[recipient_type] = [
self.make_mailersend_email(email) for email in emails
]
self.all_recipients += emails
def set_subject(self, subject):
self.data["subject"] = subject
def set_reply_to(self, emails):
if len(emails) > 1:
self.unsupported_feature("multiple reply_to emails")
elif emails:
self.data["reply_to"] = self.make_mailersend_email(emails[0])
def set_extra_headers(self, headers):
# MailerSend doesn't support arbitrary email headers, but has
# individual API params for In-Reply-To and Precedence: bulk.
# (headers is a CaseInsensitiveDict, and is a copy so safe to modify.)
in_reply_to = headers.pop("In-Reply-To", None)
if in_reply_to is not None:
self.data["in_reply_to"] = in_reply_to
precedence = headers.pop("Precedence", None)
if precedence is not None:
# Overrides MailerSend domain-level setting
is_bulk = precedence.lower() in ("bulk", "junk", "list")
self.data["precedence_bulk"] = is_bulk
if headers:
self.unsupported_feature("most extra_headers (see docs)")
def set_text_body(self, body):
self.data["text"] = body
def set_html_body(self, body):
if "html" in self.data:
# second html body could show up through multiple alternatives,
# or html body + alternative
self.unsupported_feature("multiple html parts")
self.data["html"] = body
def add_attachment(self, attachment):
# Add a MailerSend attachments[] object for attachment:
attachment_object = {
"filename": attachment.name,
"content": attachment.b64content,
"disposition": "attachment",
}
if not attachment_object["filename"]:
# MailerSend requires filename, and determines mimetype from it
# (even for inline attachments). For unnamed attachments, try
# to generate a generic filename with the correct extension:
ext = mimetypes.guess_extension(attachment.mimetype, strict=False)
if ext is not None:
attachment_object["filename"] = f"attachment{ext}"
if attachment.inline:
attachment_object["disposition"] = "inline"
attachment_object["id"] = attachment.cid
self.data.setdefault("attachments", []).append(attachment_object)
# MailerSend doesn't have metadata
# def set_metadata(self, metadata):
def set_send_at(self, send_at):
# Backend has converted pretty much everything to
# a datetime by here; MailerSend expects unix timestamp
self.data["send_at"] = int(send_at.timestamp()) # strip microseconds
def set_tags(self, tags):
if tags:
self.data["tags"] = tags
def set_track_clicks(self, track_clicks):
self.data.setdefault("settings", {})["track_clicks"] = track_clicks
def set_track_opens(self, track_opens):
self.data.setdefault("settings", {})["track_opens"] = track_opens
def set_template_id(self, template_id):
self.data["template_id"] = template_id
def set_merge_data(self, merge_data):
# late bound in serialize_data
self.merge_data = merge_data
def set_merge_global_data(self, merge_global_data):
# late bound in serialize_data
self.merge_global_data = merge_global_data
# MailerSend doesn't have metadata
# def set_merge_metadata(self, merge_metadata):
def set_esp_extra(self, extra):
# Deep merge to allow (e.g.,) {"settings": {"track_content": True}}:
update_deep(self.data, extra)
# Allow overriding api_token on individual message:
try:
api_token = self.data.pop("api_token")
except KeyError:
pass
else:
self.headers["Authorization"] = f"Bearer {api_token}"
# Allow overriding batch_send_mode on individual message:
try:
self.batch_send_mode = self.data.pop("batch_send_mode")
except KeyError:
pass

View File

@@ -4,6 +4,10 @@ from .webhooks.amazon_ses import (
AmazonSESInboundWebhookView, AmazonSESInboundWebhookView,
AmazonSESTrackingWebhookView, AmazonSESTrackingWebhookView,
) )
from .webhooks.mailersend import (
MailerSendInboundWebhookView,
MailerSendTrackingWebhookView,
)
from .webhooks.mailgun import MailgunInboundWebhookView, MailgunTrackingWebhookView from .webhooks.mailgun import MailgunInboundWebhookView, MailgunTrackingWebhookView
from .webhooks.mailjet import MailjetInboundWebhookView, MailjetTrackingWebhookView from .webhooks.mailjet import MailjetInboundWebhookView, MailjetTrackingWebhookView
from .webhooks.mandrill import MandrillCombinedWebhookView from .webhooks.mandrill import MandrillCombinedWebhookView
@@ -23,6 +27,11 @@ urlpatterns = [
AmazonSESInboundWebhookView.as_view(), AmazonSESInboundWebhookView.as_view(),
name="amazon_ses_inbound_webhook", name="amazon_ses_inbound_webhook",
), ),
path(
"mailersend/inbound/",
MailerSendInboundWebhookView.as_view(),
name="mailersend_inbound_webhook",
),
re_path( re_path(
# Mailgun delivers inbound messages differently based on whether # Mailgun delivers inbound messages differently based on whether
# the webhook url contains "mime" (anywhere). You can use either # the webhook url contains "mime" (anywhere). You can use either
@@ -62,6 +71,11 @@ urlpatterns = [
AmazonSESTrackingWebhookView.as_view(), AmazonSESTrackingWebhookView.as_view(),
name="amazon_ses_tracking_webhook", name="amazon_ses_tracking_webhook",
), ),
path(
"mailersend/tracking/",
MailerSendTrackingWebhookView.as_view(),
name="mailersend_tracking_webhook",
),
path( path(
"mailgun/tracking/", "mailgun/tracking/",
MailgunTrackingWebhookView.as_view(), MailgunTrackingWebhookView.as_view(),

View File

@@ -0,0 +1,209 @@
import hashlib
import hmac
import json
from django.utils.crypto import constant_time_compare
from django.utils.dateparse import parse_datetime
from ..exceptions import AnymailConfigurationError, AnymailWebhookValidationFailure
from ..inbound import AnymailInboundMessage
from ..signals import (
AnymailInboundEvent,
AnymailTrackingEvent,
EventType,
RejectReason,
inbound,
tracking,
)
from ..utils import get_anymail_setting
from .base import AnymailBaseWebhookView
class MailerSendBaseWebhookView(AnymailBaseWebhookView):
"""Base view class for MailerSend webhooks"""
esp_name = "MailerSend"
warn_if_no_basic_auth = False # because we validate against signature
def __init__(self, _secret_name, **kwargs):
signing_secret = get_anymail_setting(
_secret_name,
esp_name=self.esp_name,
kwargs=kwargs,
)
# hmac.new requires bytes key:
self.signing_secret = signing_secret.encode("ascii")
self._secret_setting_name = f"{self.esp_name}_{_secret_name}".upper()
super().__init__(**kwargs)
def validate_request(self, request):
super().validate_request(request) # first check basic auth if enabled
try:
signature = request.headers["Signature"]
except KeyError:
raise AnymailWebhookValidationFailure(
"MailerSend webhook called without signature"
) from None
expected_signature = hmac.new(
key=self.signing_secret,
msg=request.body,
digestmod=hashlib.sha256,
).hexdigest()
if not constant_time_compare(signature, expected_signature):
raise AnymailWebhookValidationFailure(
f"MailerSend webhook called with incorrect signature"
f" (check Anymail {self._secret_setting_name} setting)"
)
class MailerSendTrackingWebhookView(MailerSendBaseWebhookView):
"""Handler for MailerSend delivery and engagement tracking webhooks"""
signal = tracking
# (Declaring class attr allows override by kwargs in View.as_view.)
signing_secret = None
def __init__(self, **kwargs):
super().__init__(_secret_name="signing_secret", **kwargs)
def parse_events(self, request):
esp_event = json.loads(request.body.decode("utf-8"))
event_type = esp_event.get("type")
if event_type == "inbound.message":
raise AnymailConfigurationError(
"You seem to have set MailerSend's *inbound* route endpoint"
" to Anymail's MailerSend *activity tracking* webhook URL. "
)
return [self.esp_to_anymail_event(esp_event)]
event_types = {
# Map MailerSend activity.type: Anymail normalized type
"sent": EventType.SENT,
"delivered": EventType.DELIVERED,
"soft_bounced": EventType.BOUNCED,
"hard_bounced": EventType.BOUNCED,
"opened": EventType.OPENED,
"clicked": EventType.CLICKED,
"unsubscribed": EventType.UNSUBSCRIBED,
"spam_complaint": EventType.COMPLAINED,
}
morph_reject_reasons = {
# Map MailerSend morph.object (type): Anymail normalized RejectReason
"recipient_bounce": RejectReason.BOUNCED,
"spam_complaint": RejectReason.SPAM,
"recipient_unsubscribe": RejectReason.UNSUBSCRIBED,
# any others?
}
def esp_to_anymail_event(self, esp_event):
activity_data = esp_event.get("data", {})
email_data = activity_data.get("email", {})
message_data = email_data.get("message", {})
recipient_data = email_data.get("recipient", {})
event_type = self.event_types.get(activity_data["type"], EventType.UNKNOWN)
event_id = activity_data.get("id")
recipient = recipient_data.get("email")
message_id = message_data.get("id")
tags = email_data.get("tags", [])
try:
timestamp = parse_datetime(activity_data["created_at"])
except KeyError:
timestamp = None
# Additional, event-specific info is included in a "morph" record.
try:
morph_data = activity_data["morph"]
morph_object = morph_data["object"] # the object type of morph_data
except (KeyError, TypeError):
reject_reason = None
description = None
click_url = None
else:
# It seems like email_data["status"] should map to a reject_reason, but in
# reality status is most often just (the undocumented) "rejected" and the
# morph_object has more accurate info.
reject_reason = self.morph_reject_reasons.get(morph_object)
description = morph_data.get("readable_reason") or morph_data.get("reason")
click_url = morph_data.get("url") # object="click"
# user_ip = morph_data.get("ip") # object="click" or "open"
return AnymailTrackingEvent(
event_type=event_type,
timestamp=timestamp,
message_id=message_id,
event_id=event_id,
recipient=recipient,
reject_reason=reject_reason,
description=description,
tags=tags,
click_url=click_url,
esp_event=esp_event,
)
class MailerSendInboundWebhookView(MailerSendBaseWebhookView):
"""Handler for MailerSend inbound webhook"""
signal = inbound
# (Declaring class attr allows override by kwargs in View.as_view.)
inbound_secret = None
def __init__(self, **kwargs):
super().__init__(_secret_name="inbound_secret", **kwargs)
def parse_events(self, request):
esp_event = json.loads(request.body.decode("utf-8"))
event_type = esp_event.get("type")
if event_type != "inbound.message":
raise AnymailConfigurationError(
f"You seem to have set MailerSend's *{event_type}* webhook "
"to Anymail's MailerSend *inbound* webhook URL. "
)
return [self.esp_to_anymail_event(esp_event)]
def esp_to_anymail_event(self, esp_event):
message_data = esp_event.get("data")
event_id = message_data.get("id")
try:
timestamp = parse_datetime(message_data["created_at"])
except (KeyError, TypeError):
timestamp = None
message = AnymailInboundMessage.parse_raw_mime(message_data.get("raw"))
try:
message.envelope_sender = message_data["sender"]["email"]
# (also available as X-Envelope-From header)
except KeyError:
pass
try:
# There can be multiple rcptTo if the same message is sent
# to multiple inbound recipients. Just use the first.
envelope_recipients = [
recipient["email"] for recipient in message_data["recipients"]["rcptTo"]
]
message.envelope_recipient = envelope_recipients[0]
except (KeyError, IndexError):
pass
# MailerSend doesn't seem to provide any spam annotations.
# SPF seems to be verified, but format is undocumented:
# "spf_check": {"code": "+", "value": None}
# DKIM doesn't appear to be verified yet:
# "dkim_check": False,
return AnymailInboundEvent(
event_type=EventType.INBOUND,
timestamp=timestamp,
event_id=event_id,
esp_event=esp_event,
message=message,
)

View File

@@ -13,6 +13,7 @@ and notes about any quirks or limitations:
:maxdepth: 1 :maxdepth: 1
amazon_ses amazon_ses
mailersend
mailgun mailgun
mailjet mailjet
mandrill mandrill
@@ -32,35 +33,35 @@ The table below summarizes the Anymail features supported for each ESP.
.. rst-class:: sticky-left .. rst-class:: sticky-left
============================================ ============ =========== ========== =========== ========== ========== ========== ============ =========== ============================================ ============ ============ =========== ========== =========== ========== ========== ========== ============ ===========
Email Service Provider |Amazon SES| |Mailgun| |Mailjet| |Mandrill| |Postal| |Postmark| |SendGrid| |Sendinblue| |SparkPost| Email Service Provider |Amazon SES| |MailerSend| |Mailgun| |Mailjet| |Mandrill| |Postal| |Postmark| |SendGrid| |Sendinblue| |SparkPost|
============================================ ============ =========== ========== =========== ========== ========== ========== ============ =========== ============================================ ============ ============ =========== ========== =========== ========== ========== ========== ============ ===========
.. rubric:: :ref:`Anymail send options <anymail-send-options>` .. rubric:: :ref:`Anymail send options <anymail-send-options>`
--------------------------------------------------------------------------------------------------------------------------------------------------------------- -----------------------------------------------------------------------------------------------------------------------------------------------------------------------------
:attr:`~AnymailMessage.envelope_sender` Yes Domain only Yes Domain only Yes No No No Yes :attr:`~AnymailMessage.envelope_sender` Yes No Domain only Yes Domain only Yes No No No Yes
:attr:`~AnymailMessage.metadata` Yes Yes Yes Yes No Yes Yes Yes Yes :attr:`~AnymailMessage.metadata` Yes No Yes Yes Yes No Yes Yes Yes Yes
:attr:`~AnymailMessage.merge_metadata` No Yes Yes Yes No Yes Yes No Yes :attr:`~AnymailMessage.merge_metadata` No No Yes Yes Yes No Yes Yes No Yes
:attr:`~AnymailMessage.send_at` No Yes No Yes No No Yes Yes Yes :attr:`~AnymailMessage.send_at` No Yes Yes No Yes No No Yes Yes Yes
:attr:`~AnymailMessage.tags` Yes Yes Max 1 tag Yes Max 1 tag Max 1 tag Yes Yes Max 1 tag :attr:`~AnymailMessage.tags` Yes Yes Yes Max 1 tag Yes Max 1 tag Max 1 tag Yes Yes Max 1 tag
:attr:`~AnymailMessage.track_clicks` No Yes Yes Yes No Yes Yes No Yes :attr:`~AnymailMessage.track_clicks` No Yes Yes Yes Yes No Yes Yes No Yes
:attr:`~AnymailMessage.track_opens` No Yes Yes Yes No Yes Yes No Yes :attr:`~AnymailMessage.track_opens` No Yes Yes Yes Yes No Yes Yes No Yes
:ref:`amp-email` Yes Yes No No No No Yes No Yes :ref:`amp-email` Yes No Yes No No No No Yes No Yes
.. rubric:: :ref:`templates-and-merge` .. rubric:: :ref:`templates-and-merge`
--------------------------------------------------------------------------------------------------------------------------------------------------------------- -----------------------------------------------------------------------------------------------------------------------------------------------------------------------------
:attr:`~AnymailMessage.template_id` Yes Yes Yes Yes No Yes Yes Yes Yes :attr:`~AnymailMessage.template_id` Yes Yes Yes Yes Yes No Yes Yes Yes Yes
:attr:`~AnymailMessage.merge_data` Yes Yes Yes Yes No Yes Yes No Yes :attr:`~AnymailMessage.merge_data` Yes Yes Yes Yes Yes No Yes Yes No Yes
:attr:`~AnymailMessage.merge_global_data` Yes (emulated) Yes Yes No Yes Yes Yes Yes :attr:`~AnymailMessage.merge_global_data` Yes (emulated) (emulated) Yes Yes No 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 Yes Yes :attr:`~AnymailMessage.anymail_status` Yes Yes Yes Yes Yes Yes Yes Yes Yes Yes
|AnymailTrackingEvent| from webhooks Yes Yes Yes Yes Yes Yes Yes Yes Yes |AnymailTrackingEvent| from webhooks Yes Yes Yes Yes Yes Yes Yes Yes Yes Yes
.. rubric:: :ref:`Inbound handling <inbound>` .. rubric:: :ref:`Inbound handling <inbound>`
--------------------------------------------------------------------------------------------------------------------------------------------------------------- -----------------------------------------------------------------------------------------------------------------------------------------------------------------------------
|AnymailInboundEvent| from webhooks Yes Yes Yes Yes Yes Yes Yes No Yes |AnymailInboundEvent| from webhooks Yes Yes Yes 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
@@ -69,6 +70,7 @@ and support for developers. The *number* of extra features an ESP offers is almo
meaningless. (And even specific features don't matter if you don't plan to use them.) meaningless. (And even specific features don't matter if you don't plan to use them.)
.. |Amazon SES| replace:: :ref:`amazon-ses-backend` .. |Amazon SES| replace:: :ref:`amazon-ses-backend`
.. |MailerSend| replace:: :ref:`mailersend-backend`
.. |Mailgun| replace:: :ref:`mailgun-backend` .. |Mailgun| replace:: :ref:`mailgun-backend`
.. |Mailjet| replace:: :ref:`mailjet-backend` .. |Mailjet| replace:: :ref:`mailjet-backend`
.. |Mandrill| replace:: :ref:`mandrill-backend` .. |Mandrill| replace:: :ref:`mandrill-backend`

546
docs/esps/mailersend.rst Normal file
View File

@@ -0,0 +1,546 @@
.. _mailersend-backend:
MailerSend
==========
Anymail integrates Django with the `MailerSend`_ transactional
email service, using their `email API`_ endpoint.
.. _MailerSend: https://www.mailersend.com/
.. _email API: https://developers.mailersend.com/api/v1/email.html
Settings
--------
.. rubric:: EMAIL_BACKEND
To use Anymail's MailerSend backend, set:
.. code-block:: python
EMAIL_BACKEND = "anymail.backends.mailersend.EmailBackend"
in your settings.py.
.. setting:: ANYMAIL_MAILERSEND_API_TOKEN
.. rubric:: MAILERSEND_API_TOKEN
Required for sending. A MailerSend API token generated in your MailerSend
`Email domains settings`_. For the token permission level, "custom access"
is recommended, with full access to email and *no access* for all other features.
.. code-block:: python
ANYMAIL = {
...
"MAILERSEND_API_TOKEN": "<your API token>",
}
Anymail will also look for ``MAILERSEND_API_TOKEN`` at the
root of the settings file if neither ``ANYMAIL["MAILERSEND_API_TOKEN"]``
nor ``ANYMAIL_MAILERSEND_API_TOKEN`` is set.
If your Django project sends email from multiple MailerSend domains,
you will need a separate API token for each domain. Use the token matching
your :setting:`DEFAULT_FROM_EMAIL` domain in settings.py, and then override
where necessary for individual emails by setting ``"api_token"`` in the
message's :ref:`esp_extra <mailersend-esp-extra>`. (You could centralize
this logic using Anymail's :ref:`pre-send-signal`.)
.. setting:: ANYMAIL_MAILERSEND_BATCH_SEND_MODE
.. rubric:: MAILERSEND_BATCH_SEND_MODE
If you are using Anymail's :attr:`~anymail.message.AnymailMessage.merge_data`
with multiple recipients (":ref:`batch sending <batch-send>`"), set this to
indicate how to handle the batch. See :ref:`mailersend-batch-send` below
for more information.
Choices are ``"use-bulk-email"`` or ``"expose-to-list"``. The default ``None``
will raise an error if :attr:`~!anymail.message.AnymailMessage.merge_data` is
used with more than one ``to`` recipient.
.. code-block:: python
ANYMAIL = {
...
"MAILERSEND_BATCH_SEND_MODE": "use-bulk-email",
}
.. setting:: ANYMAIL_MAILERSEND_SIGNING_SECRET
.. rubric:: MAILERSEND_SIGNING_SECRET
The MailerSend webhook signing secret needed to verify webhook posts.
Required if you are using activity tracking, otherwise not necessary.
(This is separate from Anymail's
:setting:`WEBHOOK_SECRET <ANYMAIL_WEBHOOK_SECRET>` setting.)
Find this in your MailerSend `Email domains settings`_: after adding
a webhook, look for the "signing secret" on the webhook's management page.
.. code-block:: python
ANYMAIL = {
...
"MAILERSEND_SIGNING_SECRET": "<secret from webhook management page>",
}
MailerSend generates a unique secret for each webhook; if you edit
your webhook you will need to update this setting with the new signing secret.
(Also, inbound routes use a *different* secret, with a different setting---see
below.)
.. setting:: ANYMAIL_MAILERSEND_INBOUND_SECRET
.. rubric:: MAILERSEND_INBOUND_SECRET
The MailerSend inbound route secret needed to verify inbound notifications.
Required if you are using inbound routing, otherwise not necessary.
Find this in your MailerSend `Email domains settings`_: after you have
added an inbound route, look for the "secret" immediately below the route url
on the management page for that inbound route.
.. code-block:: python
ANYMAIL = {
...
"MAILERSEND_INBOUND_SECRET": "<secret from inbound management page>",
}
MailerSend generates a unique secret for each inbound route url; if you edit
your route you will need to update this setting with the new secret.
(Also, activity tracking webhooks use a *different* secret, with a different
setting---see above.)
.. setting:: ANYMAIL_MAILERSEND_API_URL
.. rubric:: MAILERSEND_API_URL
The base url for calling the MailerSend API.
The default is ``MAILERSEND_API_URL = "https://api.mailersend.com/v1/"``.
(It's unlikely you would need to change this.)
.. _Email domains settings: https://app.mailersend.com/domains
.. _mailersend-esp-extra:
exp_extra support
-----------------
Anymail's MailerSend backend will pass :attr:`~anymail.message.AnymailMessage.esp_extra`
values directly to MailerSend's `email API`_.
In addition, you can override the
:setting:`MAILERSEND_API_TOKEN <ANYMAIL_MAILERSEND_API_TOKEN>` for an individual
message by providing ``"api-token"``, and
:setting:`MAILERSEND_BATCH_SEND_MODE <ANYMAIL_MAILERSEND_BATCH_SEND_MODE>`
by providing ``"batch-send-mode"`` in the
:attr:`~!anymail.message.AnymailMessage.esp_extra` dict.
Example:
.. code-block:: python
message = AnymailMessage(...)
message.esp_extra = {
# override your MailerSend domain's content tracking default:
"settings": {"track_content": False},
# use a different MAILERSEND_API_TOKEN for this message:
"api_token": MAILERSEND_API_TOKEN_FOR_MARKETING_DOMAIN,
# override the MAILERSEND_BATCH_SEND_MODE setting
# just for this message:
"batch_send_mode": "use-bulk-email",
}
Nested values are merged deeply. When sending using MailerSend's bulk-email API
endpoint, the :attr:`~!anymail.message.AnymailMessage.esp_extra` params are merged
into the payload for every individual message in the batch.
.. _mailersend-quirks:
Limitations and quirks
----------------------
MailerSend does not support a few features offered by some other ESPs.
Anymail normally raises an :exc:`~anymail.exceptions.AnymailUnsupportedFeature`
error when you try to send a message using features that MailerSend doesn't support
You can tell Anymail to suppress these errors and send the messages anyway --
see :ref:`unsupported-features`.
**Attachments require filenames, ignore content type**
MailerSend requires every attachment (even inline ones) to have a filename.
And it determines the content type of the attachment from the filename extension.
If you try to send an attachment without a filename, Anymail will substitute
"attachment*.ext*" using an appropriate *.ext* for the content type.
If you try to send an attachment whose content type doesn't match its filename
extension, MailerSend will change the content type to match the extension.
(E.g., the filename "data.txt" will always be sent as "text/plain",
even if you specified a "text/csv" content type.)
**Single Reply-To**
MailerSend only supports a single Reply-To address.
If your message has multiple reply addresses, you'll get an
:exc:`~anymail.exceptions.AnymailUnsupportedFeature` error---or
if you've enabled :setting:`ANYMAIL_IGNORE_UNSUPPORTED_FEATURES`,
Anymail will use only the first one.
**Limited extra headers**
MailerSend does not allow most extra headers. There are two exceptions:
* You can include :mailheader:`In-Reply-To` in extra headers, set to
a message-id (without the angle brackets).
* You can include :mailheader:`Precedence` in extra headers to override
the "Add precedence bulk header" option from your MailerSend domain
advanced settings (look under "More settings").
Anymail will set MailerSend's ``precedence_bulk`` param to ``true``
if your extra headers have :mailheader:`Precedence` set to ``"bulk"`` or
``"list"`` or ``"junk"``, or ``false`` for any other value.
Any other extra headers will raise an
:exc:`~anymail.exceptions.AnymailUnsupportedFeature` error.
**No metadata support**
MailerSend does not support Anymail's
:attr:`~anymail.message.AnymailMessage.metadata` or
:attr:`~anymail.message.AnymailMessage.merge_metadata` features.
**No envelope sender overrides**
MailerSend does not support overriding
:attr:`~anymail.message.AnymailMessage.envelope_sender` on individual messages.
(To use a `MailerSend sender identity`_, set the verified identity's
email address as the message's
:attr:`~!django.core.email.message.EmailMessage.from_email`.)
.. _MailerSend sender identity:
https://www.mailersend.com/help/send-email-on-behalf-of-clients
**API rate limits**
MailerSend provides `rate limit headers`_ with each API call response.
To access them after a successful send, use (e.g.,)
``message.anymail_status.esp_response.headers["x-ratelimit-remaining"]``.
If you exceed a rate limit, you'll get an :exc:`~anymail.exceptions.AnymailAPIError`
with ``error.status_code == 429``, and can determine how many seconds to wait
from ``error.response.headers["retry-after"]``.
.. _rate limit headers:
https://developers.mailersend.com/general.html#rate-limits
.. _mailersend-templates:
Batch sending/merge and ESP templates
-------------------------------------
MailerSend supports :ref:`ESP stored templates <esp-stored-templates>`, on-the-fly
templating, and :ref:`batch sending <batch-send>` with per-recipient merge data.
MailerSend's approaches to batch sending don't align perfectly with Anymail's;
be sure to read :ref:`mailersend-batch-send` below to understand the options.
MailerSend offers two different syntaxes for substituting data into templates:
"`simple personalization`_" and "`advanced personalization`_." Anymail supports
*only* the more flexible advanced personalization syntax. If you have MailerSend
templates using the "simple" syntax (``{$variable_name}``), you'll need to convert
them to the "advanced" syntax (``{{ variable_name }}``) for use with Anymail's
:attr:`~anymail.message.AnymailMessage.merge_data` and
:attr:`~anymail.message.AnymailMessage.merge_global_data`.
Here's an example defining an on-the-fly template that uses MailerSend advanced
personalization variables:
.. code-block:: python
message = EmailMessage(
from_email="shipping@example.com",
subject="Your order {{ order_no }} has shipped",
body="""Hi {{ name }},
We shipped your order {{ order_no }}
on {{ ship_date }}.""",
to=["alice@example.com", "Bob <bob@example.com>"]
)
# (you'd probably also set a similar html body with variables)
message.merge_data = {
"alice@example.com": {"name": "Alice", "order_no": "12345"},
"bob@example.com": {"name": "Bob", "order_no": "54321"},
}
message.merge_global_data = {
"ship_date": "May 15" # Anymail maps globals to all recipients
}
# (see discussion of batch-send-mode below)
message.esp_extra = {
"batch-send-mode": "use-bulk-email"
}
To send the same message with a `MailerSend stored template`_ from your account,
set :attr:`~anymail.message.AnymailMessage.template_id`, and omit any plain-text
or html `~!django.core.mail.EmailMessage.body`. If you've set a subject in your
MailerSend template's default settings, you can omit
`~!django.core.mail.EmailMessage.subject` (otherwise you must include it).
And if your template default settings specify the *From* email, that will override
`~!django.core.mail.EmailMessage.from_email`. Example:
.. code-block:: python
message = EmailMessage(
from_email="shipping@example.com",
# (subject and body from template)
to=["alice@example.com", "Bob <bob@example.com>"]
)
message.template_id = "vzq12345678" # id of template in our account
# ... set merge_data and merge_global_data as above
MailerSend does not natively support global merge data. Anymail emulates
the capability by copying any :attr:`~anymail.message.AnymailMessage.merge_global_data`
values to every recipient.
.. _simple personalization:
https://www.mailersend.com/help/how-to-use-variables#simple-personalization
.. _advanced personalization:
https://www.mailersend.com/help/how-to-use-variables#advanced-personalization
.. _MailerSend stored template:
https://www.mailersend.com/help/how-to-create-a-template
.. _mailersend-batch-send:
Batch send mode
~~~~~~~~~~~~~~~
Anymail's model for :ref:`batch sending <batch-send>` is that each recipient
receives a separate email personalized for them, and that each recipient sees
*only their own email address* in the message's :mailheader:`To` header.
MailerSend has a `bulk-email API`_ that matches Anymail's batch sending model,
but operates completely asynchronously, which can complicate status tracking
and error handling.
MailerSend also supports batch sending personalized emails through
its regular `email API`_, which avoids the bulk-email limitations but
exposes the entire :mailheader:`To` list to all recipients.
If you want to use Anymail's :attr:`~anymail.message.AnymailMessage.merge_data`
for batch sending to multiple `~!django.core.mail.EmailMessage.to` recipients,
you must select one of these two approaches by specifying either ``"use-bulk-email"``
or ``"expose-to-list"`` in your Anymail
:setting:`MAILERSEND_BATCH_SEND_MODE <ANYMAIL_MAILERSEND_BATCH_SEND_MODE>` setting---or
as ``"batch-send-mode"`` in the message's :ref:`esp_extra <mailersend-esp-extra>`.
.. caution::
Using the ``"expose-to-list"`` MailerSend batch send mode will reveal
*all* of the message's :mailheader:`To` email addresses to *every*
recipient of the message.
If you use the ``"use-bulk-email"`` MailerSend batch send mode:
* The
:attr:`message.anymail_status.status <anymail.message.AnymailStatus.status>`
will be ``{"unknown"}``, because MailerSend detects errors and rejected
recipients at a later time.
* The
:attr:`message.anymail_status.message_id <anymail.message.AnymailStatus.message_id>`
will be a MailerSend ``bulk_email_id``, prefixed with ``"bulk:"`` to
distinguish it from a regular ``message_id``.
* You will need to poll MailerSend's `bulk-email status API`_ to determine
whether the send was successful, partially successful, or failed,
and to determine the
:attr:`event.message_id <anymail.signals.AnymailTrackingEvent.message_id>`
that will be sent to status tracking webhooks.
* Be aware that rate limits for the bulk-email API are significantly lower
than MailerSend's regular email API.
Rather than one of these batch sending options, an often-simpler approach is
to loop over your recipient list and send a separate message for each.
You can still use templates and :attr:`~!anymail.message.AnymailMessage.merge_data`:
.. code-block:: python
# How to "manually" send a batch of emails to one recipient at a time.
# (There's no need to specify a MailerSend "batch-send-mode".)
to_list = ["alice@example.com", "bob@example.com"]
merge_data = {
"alice@example.com": {"name": "Alice", "order_no": "12345"},
"bob@example.com": {"name": "Bob", "order_no": "54321"},
}
merge_global_data = {
"ship_date": "May 15",
}
for to_email in to_list:
message = AnymailMessage(
# just one recipient per message:
to=[to_email],
# provide template variables for this one recipient:
merge_global_data = merge_global_data | merge_data[to_email],
# any other attributes you want:
template_id = "vzq12345678",
from_email="shipping@example.com",
)
try:
message.send()
except AnymailAPIError:
# Handle error -- e.g., schedule for retry later.
else:
# Either successful send or to_email is rejected.
# message.anymail_status will be {"queued"} or {"rejected"}.
# message.anymail_status.message_id can be stored to match
# with event.message_id in a status tracking signal receiver.
.. _bulk-email API:
https://developers.mailersend.com/api/v1/email.html#send-bulk-emails
.. _bulk-email status API:
https://developers.mailersend.com/api/v1/email.html#get-bulk-email-status
.. _mailersend-webhooks:
Status tracking webhooks
------------------------
If you are using Anymail's normalized :ref:`status tracking <event-tracking>`,
follow MailerSend's instructions to `add a webhook to your domain`_.
* Enter this Anymail tracking URL as the webhook's "Endpoint URL"
(where *yoursite.example.com* is your Django site):
:samp:`https://{yoursite.example.com}/anymail/mailersend/tracking/`
Because MailerSend implements webhook signing, it's not necessary to use Anymail's
shared webhook secret for security with MailerSend webhooks. However, it doesn't
hurt to use both. If you *have* set an Anymail
:setting:`WEBHOOK_SECRET <ANYMAIL_WEBHOOK_SECRET>`, include that *random:random*
shared secret in the webhook endpoint URL:
:samp:`https://{random}:{random}@{yoursite.example.com}/anymail/mailersend/tracking/`
* For "Events to send", select any or all events you want to track.
* After you have saved the webhook, go back into MailerSend's webhook
management page, and reveal and copy the MailerSend "webhook signing secret".
Provide that in your settings.py ``ANYMAIL`` settings as
:setting:`MAILERSEND_SIGNING_SECRET <ANYMAIL_MAILERSEND_SIGNING_SECRET>`
so that Anymail can verify calls to the webhook:
.. code-block:: python
ANYMAIL = {
# ...
MAILERSEND_SIGNING_SECRET = "<secret you copied>"
}
For troubleshooting, MailerSend provides a helpful log of calls to the webhook.
See "`About webhook attempts`_" in their documentation for more details.
.. note::
MailerSend has a relatively short three second timeout for webhook calls.
Be sure to avoid any lengthy operations in your Anymail tracking signal
receiver function, or MailerSend will consider the notification failed
at retry it. The event's :attr:`~anymail.signals.AnymailTrackingEvent.event_id`
field can help identify duplicate notifications.
MailerSend retries webhook notifications only twice, with delays of 10
and then 100 seconds. If your webhook is ever offline for more than
a couple minutes, you many miss some tracking events. You can use
MailerSend's activity API to query for events that may have been missed.
MailerSend will report these Anymail
:attr:`~anymail.signals.AnymailTrackingEvent.event_type`\s:
sent, delivered, bounced, complained, unsubscribed, opened, and clicked.
The event's :attr:`~anymail.signals.AnymailTrackingEvent.esp_event` field will be
the *complete* parsed MailerSend webhook payload, including an additional wrapper
object not shown in their documentation. The activity data in MailerSend's
`webhook payload example`_ is available as ``event.esp_event["data"]``.
.. _add a webhook to your domain:
https://www.mailersend.com/help/webhooks#adding-webhooks
.. _About webhook attempts:
https://www.mailersend.com/help/webhooks#webhook-attempts
.. _webhook payload example:
https://developers.mailersend.com/api/v1/webhooks.html#payload-example
.. _mailersend-inbound:
Inbound routing
---------------
If you want to receive email from MailerSend through Anymail's normalized
:ref:`inbound <inbound>` handling, follow MailerSend's guide to
`How to set up an inbound route`_.
* For "Route to" (in their step 8), enter this Anymail inbound route endpoint URL
(where *yoursite.example.com* is your Django site):
:samp:`https://{yoursite.example.com}/anymail/mailersend/inbound/`
Because MailerSend signs its inbound notifications, it's not necessary to use Anymail's
shared webhook secret for security with MailerSend inbound routing. However, it doesn't
hurt to use both. If you *have* set an Anymail
:setting:`WEBHOOK_SECRET <ANYMAIL_WEBHOOK_SECRET>`, include that *random:random*
shared secret in the inbound route endpoint URL:
:samp:`https://{random}:{random}@{yoursite.example.com}/anymail/mailersend/inbound/`
* After you have saved the inbound route, go back into MailerSend's inbound route
management page, and copy the "Secret" displayed immediately below the "Route to" URL.
Provide that in your settings.py ``ANYMAIL`` settings as
:setting:`MAILERSEND_INBOUND_SECRET <ANYMAIL_MAILERSEND_INBOUND_SECRET>`
so that Anymail can verify calls to the inbound endpoint:
.. code-block:: python
ANYMAIL = {
# ...
MAILERSEND_INBOUND_SECRET = "<secret you copied>"
}
Note that this is a *different* secret from the
:setting:`MAILERSEND_SIGNING_SECRET <ANYMAIL_MAILERSEND_SIGNING_SECRET>`
used to verify activity tracking webhooks. If you are using both features,
be sure to include both settings.
For troubleshooting, MailerSend provides a helpful inbound activity log
near the end of the route management page. See `Where to find inbound emails`_
in their docs for more details.
.. note::
MailerSend imposes a three second limit on all notifications.
If your inbound signal receiver function takes too long,
MailerSend may think the notification failed. To avoid problems,
it's essential you offload any lengthy operations to a background task.
MailerSend does not retry failed inbound notifications.
If your Django app is ever unreachable for any reason,
**you will miss inbound mail** that arrives during that time.
.. _How to set up an inbound route:
https://www.mailersend.com/help/inbound-route
.. _Where to find inbound emails:
https://www.mailersend.com/help/inbound-route#where

View File

@@ -56,12 +56,12 @@ setup(
name="django-anymail", name="django-anymail",
version=version, version=version,
description=( description=(
"Django email backends and webhooks for Amazon SES, Mailgun, Mailjet," "Django email backends and webhooks for Amazon SES, MailerSend, Mailgun,"
" Mandrill, Postal, Postmark, SendGrid, SendinBlue, and SparkPost" " Mailjet, Mandrill, Postal, Postmark, SendGrid, SendinBlue, and SparkPost"
), ),
keywords=( keywords=(
"Django, email, email backend, ESP, transactional mail," "Django, email, email backend, ESP, transactional mail,"
" Amazon SES, Mailgun, Mailjet, Mandrill, Postal, Postmark," " Amazon SES, MailerSend, Mailgun, Mailjet, Mandrill, Postal, Postmark,"
" SendGrid, SendinBlue, SparkPost" " SendGrid, SendinBlue, SparkPost"
), ),
author="Mike Edmunds and Anymail contributors", author="Mike Edmunds and Anymail contributors",
@@ -76,6 +76,7 @@ setup(
# This can be used if particular backends have unique dependencies. # This can be used if particular backends have unique dependencies.
# For simplicity, requests is included in the base requirements. # For simplicity, requests is included in the base requirements.
"amazon_ses": ["boto3"], "amazon_ses": ["boto3"],
"mailersend": [],
"mailgun": [], "mailgun": [],
"mailjet": [], "mailjet": [],
"mandrill": [], "mandrill": [],

View File

@@ -17,6 +17,7 @@ class RequestsBackendMockAPITestCase(AnymailTestMixin, SimpleTestCase):
"""TestCase that mocks API calls through requests""" """TestCase that mocks API calls through requests"""
DEFAULT_RAW_RESPONSE = b"""{"subclass": "should override"}""" DEFAULT_RAW_RESPONSE = b"""{"subclass": "should override"}"""
DEFAULT_CONTENT_TYPE = None # e.g., "application/json"
DEFAULT_STATUS_CODE = 200 # most APIs use '200 OK' for success DEFAULT_STATUS_CODE = 200 # most APIs use '200 OK' for success
class MockResponse(requests.Response): class MockResponse(requests.Response):
@@ -26,6 +27,7 @@ class RequestsBackendMockAPITestCase(AnymailTestMixin, SimpleTestCase):
self, self,
status_code=200, status_code=200,
raw=b"RESPONSE", raw=b"RESPONSE",
content_type=None,
encoding="utf-8", encoding="utf-8",
reason=None, reason=None,
test_case=None, test_case=None,
@@ -35,6 +37,8 @@ class RequestsBackendMockAPITestCase(AnymailTestMixin, SimpleTestCase):
self.encoding = encoding self.encoding = encoding
self.reason = reason or ("OK" if 200 <= status_code < 300 else "ERROR") self.reason = reason or ("OK" if 200 <= status_code < 300 else "ERROR")
self.raw = BytesIO(raw) self.raw = BytesIO(raw)
if content_type is not None:
self.headers["Content-Type"] = content_type
self.test_case = test_case self.test_case = test_case
@property @property
@@ -54,12 +58,32 @@ class RequestsBackendMockAPITestCase(AnymailTestMixin, SimpleTestCase):
self.set_mock_response() self.set_mock_response()
def set_mock_response( def set_mock_response(
self, status_code=DEFAULT_STATUS_CODE, raw=UNSET, encoding="utf-8", reason=None self,
status_code=UNSET,
raw=UNSET,
json_data=UNSET,
encoding="utf-8",
content_type=UNSET,
reason=None,
): ):
if status_code is UNSET:
status_code = self.DEFAULT_STATUS_CODE
if json_data is not UNSET:
assert raw is UNSET, "provide json_data or raw, not both"
raw = json.dumps(json_data).encode(encoding)
if content_type is UNSET:
content_type = "application/json"
if raw is UNSET: if raw is UNSET:
raw = self.DEFAULT_RAW_RESPONSE raw = self.DEFAULT_RAW_RESPONSE
if content_type is UNSET:
content_type = self.DEFAULT_CONTENT_TYPE
mock_response = self.MockResponse( mock_response = self.MockResponse(
status_code, raw=raw, encoding=encoding, reason=reason, test_case=self status_code,
raw=raw,
content_type=content_type,
encoding=encoding,
reason=reason,
test_case=self,
) )
self.mock_request.return_value = mock_response self.mock_request.return_value = mock_response
return mock_response return mock_response

View File

@@ -0,0 +1,920 @@
from calendar import timegm
from datetime import date, datetime
from decimal import Decimal
from email.mime.base import MIMEBase
from email.mime.image import MIMEImage
from django.core import mail
from django.test import override_settings, tag
from django.utils.timezone import (
get_fixed_timezone,
override as override_current_timezone,
)
from anymail.exceptions import (
AnymailAPIError,
AnymailConfigurationError,
AnymailRecipientsRefused,
AnymailSerializationError,
AnymailUnsupportedFeature,
)
from anymail.message import attach_inline_image_file
from .mock_requests_backend import RequestsBackendMockAPITestCase
from .utils import (
SAMPLE_IMAGE_FILENAME,
decode_att,
sample_image_content,
sample_image_path,
)
@tag("mailersend")
@override_settings(
EMAIL_BACKEND="anymail.backends.mailersend.EmailBackend",
ANYMAIL={"MAILERSEND_API_TOKEN": "test_api_token"},
)
class MailerSendBackendMockAPITestCase(RequestsBackendMockAPITestCase):
"""TestCase that uses MailerSend EmailBackend with a mocked API"""
DEFAULT_STATUS_CODE = 202
DEFAULT_RAW_RESPONSE = b""
DEFAULT_CONTENT_TYPE = "text/html"
def setUp(self):
super().setUp()
# Simple message useful for many tests
self.message = mail.EmailMultiAlternatives(
"Subject", "Text Body", "from@example.com", ["to@example.com"]
)
def set_mock_success(self, message_id="1234567890abcdef"):
response = self.set_mock_response()
if message_id is not None:
response.headers["x-message-id"] = message_id
return response
def set_mock_rejected(
self, rejections, warning_type="ALL_SUPPRESSED", message_id="1234567890abcdef"
):
"""rejections should be a dict of {email: [reject_reason, ...], ...}"""
if warning_type == "ALL_SUPPRESSED":
message_id = None
response = self.set_mock_response(
json_data={
"warnings": [
{
"type": warning_type,
"recipients": [
{"email": email, "reasons": reasons}
for email, reasons in rejections.items()
],
}
]
}
)
if message_id is not None:
response.headers["x-message-id"] = message_id
return response
@tag("mailersend")
class MailerSendBackendStandardEmailTests(MailerSendBackendMockAPITestCase):
"""Test backend support for Django standard email features"""
def test_send_mail(self):
"""Test basic API for simple send"""
mail.send_mail(
"Subject here",
"Here is the message.",
"from@example.com",
["to@example.com"],
fail_silently=False,
)
self.assert_esp_called("/v1/email")
headers = self.get_api_call_headers()
self.assertEqual(headers["Authorization"], "Bearer test_api_token")
data = self.get_api_call_json()
self.assertEqual(data["subject"], "Subject here")
self.assertEqual(data["text"], "Here is the message.")
self.assertEqual(data["from"], {"email": "from@example.com"})
self.assertEqual(data["to"], [{"email": "to@example.com"}])
def test_name_addr(self):
"""Make sure RFC2822 name-addr format (with display-name) is allowed
(Test both sender and recipient addresses)
"""
msg = mail.EmailMessage(
"Subject",
"Message",
"From Name <from@example.com>",
["Recipient #1 <to1@example.com>", "to2@example.com"],
cc=["Carbon Copy <cc1@example.com>", "cc2@example.com"],
bcc=["Blind Copy <bcc1@example.com>", "bcc2@example.com"],
)
msg.send()
data = self.get_api_call_json()
self.assertEqual(
data["from"], {"email": "from@example.com", "name": "From Name"}
)
self.assertEqual(
data["to"],
[
{"email": "to1@example.com", "name": "Recipient #1"},
{"email": "to2@example.com"},
],
)
self.assertEqual(
data["cc"],
[
{"email": "cc1@example.com", "name": "Carbon Copy"},
{"email": "cc2@example.com"},
],
)
self.assertEqual(
data["bcc"],
[
{"email": "bcc1@example.com", "name": "Blind Copy"},
{"email": "bcc2@example.com"},
],
)
def test_custom_headers(self):
email = mail.EmailMessage(
"Subject",
"Body goes here",
"from@example.com",
["to1@example.com"],
headers={
"Reply-To": "another@example.com",
"In-Reply-To": "12345@example.com",
"X-MyHeader": "my value",
"Message-ID": "mycustommsgid@example.com",
"Precedence": "Bulk",
},
)
with self.assertRaisesMessage(AnymailUnsupportedFeature, "extra_headers"):
email.send()
def test_supported_custom_headers(self):
email = mail.EmailMessage(
"Subject",
"Body goes here",
"from@example.com",
["to1@example.com"],
headers={
"Reply-To": "another@example.com",
"In-Reply-To": "12345@example.com",
"Precedence": "Bulk",
},
)
email.send()
data = self.get_api_call_json()
self.assertEqual(data["reply_to"], {"email": "another@example.com"})
self.assertEqual(data["in_reply_to"], "12345@example.com")
self.assertIs(data["precedence_bulk"], True)
def test_html_message(self):
text_content = "This is an important message."
html_content = "<p>This is an <strong>important</strong> message.</p>"
email = mail.EmailMultiAlternatives(
"Subject", text_content, "from@example.com", ["to@example.com"]
)
email.attach_alternative(html_content, "text/html")
email.send()
data = self.get_api_call_json()
self.assertEqual(data["text"], text_content)
self.assertEqual(data["html"], html_content)
# Don't accidentally send the html part as an attachment:
self.assertNotIn("attachments", data)
def test_html_only_message(self):
html_content = "<p>This is an <strong>important</strong> message.</p>"
email = mail.EmailMessage(
"Subject", html_content, "from@example.com", ["to@example.com"]
)
email.content_subtype = "html" # Main content is now text/html
email.send()
data = self.get_api_call_json()
self.assertNotIn("text", data)
self.assertEqual(data["html"], html_content)
def test_reply_to(self):
email = mail.EmailMessage(
"Subject",
"Body goes here",
"from@example.com",
["to1@example.com"],
reply_to=["Reply Name <reply@example.com>"],
)
email.send()
data = self.get_api_call_json()
self.assertEqual(
data["reply_to"], {"email": "reply@example.com", "name": "Reply Name"}
)
def test_attachments(self):
text_content = "* Item one\n* Item two\n* Item three"
self.message.attach(
filename="test.txt", content=text_content, mimetype="text/plain"
)
# Should guess mimetype if not provided...
png_content = b"PNG\xb4 pretend this is the contents of a png file"
self.message.attach(filename="test.png", content=png_content)
# Should work with a MIMEBase object (also tests no filename)...
pdf_content = b"PDF\xb4 pretend this is valid pdf params"
mimeattachment = MIMEBase("application", "pdf")
mimeattachment.set_payload(pdf_content)
self.message.attach(mimeattachment)
self.message.send()
data = self.get_api_call_json()
attachments = data["attachments"]
self.assertEqual(len(attachments), 3)
self.assertEqual(attachments[0]["disposition"], "attachment")
self.assertEqual(attachments[0]["filename"], "test.txt")
self.assertEqual(
decode_att(attachments[0]["content"]).decode("ascii"), text_content
)
self.assertEqual(attachments[1]["disposition"], "attachment")
self.assertEqual(attachments[1]["filename"], "test.png")
self.assertEqual(decode_att(attachments[1]["content"]), png_content)
self.assertEqual(attachments[2]["disposition"], "attachment")
self.assertEqual(attachments[2]["filename"], "attachment.pdf") # generated
self.assertEqual(decode_att(attachments[2]["content"]), pdf_content)
def test_unicode_attachment_correctly_decoded(self):
# Slight modification from the Django unicode docs:
# http://django.readthedocs.org/en/latest/ref/unicode.html#email
self.message.attach(
"Une pièce jointe.html", "<p>\u2019</p>", mimetype="text/html"
)
self.message.send()
data = self.get_api_call_json()
attachments = data["attachments"]
self.assertEqual(len(attachments), 1)
def test_embedded_images(self):
image_filename = SAMPLE_IMAGE_FILENAME
image_path = sample_image_path(image_filename)
image_data = sample_image_content(image_filename)
cid = attach_inline_image_file(self.message, image_path)
html_content = (
'<p>This has an <img src="cid:%s" alt="inline" /> image.</p>' % cid
)
self.message.attach_alternative(html_content, "text/html")
self.message.send()
data = self.get_api_call_json()
self.assertEqual(data["html"], html_content)
self.assertEqual(len(data["attachments"]), 1)
self.assertEqual(data["attachments"][0]["disposition"], "inline")
self.assertEqual(data["attachments"][0]["filename"], image_filename)
self.assertEqual(data["attachments"][0]["id"], cid)
self.assertEqual(decode_att(data["attachments"][0]["content"]), image_data)
def test_attached_images(self):
image_filename = SAMPLE_IMAGE_FILENAME
image_path = sample_image_path(image_filename)
image_data = sample_image_content(image_filename)
# option 1: attach as a file
self.message.attach_file(image_path)
# option 2: construct the MIMEImage and attach it directly
image = MIMEImage(image_data)
self.message.attach(image)
self.message.send()
data = self.get_api_call_json()
attachments = data["attachments"]
self.assertEqual(len(attachments), 2)
self.assertEqual(attachments[0]["disposition"], "attachment")
self.assertEqual(attachments[0]["filename"], image_filename)
self.assertEqual(decode_att(attachments[0]["content"]), image_data)
self.assertNotIn("id", attachments[0]) # not inline
self.assertEqual(attachments[1]["disposition"], "attachment")
self.assertEqual(attachments[1]["filename"], "attachment.png") # generated
self.assertEqual(decode_att(attachments[1]["content"]), image_data)
self.assertNotIn("id", attachments[0]) # not inline
def test_multiple_html_alternatives(self):
# Multiple text/html alternatives not allowed
self.message.attach_alternative("<p>First html is OK</p>", "text/html")
self.message.attach_alternative("<p>But not second html</p>", "text/html")
with self.assertRaises(AnymailUnsupportedFeature):
self.message.send()
def test_html_alternative(self):
# Only html alternatives allowed
self.message.attach_alternative("{'not': 'allowed'}", "application/json")
with self.assertRaises(AnymailUnsupportedFeature):
self.message.send()
def test_alternatives_fail_silently(self):
# Make sure fail_silently is respected
self.message.attach_alternative("{'not': 'allowed'}", "application/json")
sent = self.message.send(fail_silently=True)
self.assert_esp_not_called("API should not be called when send fails silently")
self.assertEqual(sent, 0)
def test_suppress_empty_address_lists(self):
"""Empty cc, bcc, and reply_to shouldn't generate empty headers"""
self.message.send()
data = self.get_api_call_json()
self.assertNotIn("cc", data)
self.assertNotIn("bcc", data)
self.assertNotIn("reply_to", data)
# MailerSend requires at least one "to" address
# def test_empty_to(self):
# self.message.to = []
# self.message.cc = ["cc@example.com"]
# self.message.send()
# data = self.get_api_call_json()
def test_api_failure(self):
raw_errors = {
"message": "Helpful ESP explanation",
"errors": {"some.field": ["The some.field must be valid."]},
}
self.set_mock_response(
status_code=422, reason="UNPROCESSABLE ENTITY", json_data=raw_errors
)
# Error string includes ESP response:
with self.assertRaisesMessage(AnymailAPIError, "Helpful ESP explanation"):
self.message.send()
def test_api_failure_fail_silently(self):
# Make sure fail_silently is respected
self.set_mock_response(status_code=422)
sent = self.message.send(fail_silently=True)
self.assertEqual(sent, 0)
@tag("mailersend")
class MailerSendBackendAnymailFeatureTests(MailerSendBackendMockAPITestCase):
"""Test backend support for Anymail added features"""
def test_envelope_sender(self):
self.message.envelope_sender = "bounce-handler@bounces.example.com"
with self.assertRaisesMessage(AnymailUnsupportedFeature, "envelope_sender"):
self.message.send()
def test_metadata(self):
self.message.metadata = {"user_id": "12345", "items": "mailer, send"}
with self.assertRaisesMessage(AnymailUnsupportedFeature, "metadata"):
self.message.send()
def test_send_at(self):
utc_plus_6 = get_fixed_timezone(6 * 60)
utc_minus_8 = get_fixed_timezone(-8 * 60)
with override_current_timezone(utc_plus_6):
# Timezone-aware datetime converted to UTC:
self.message.send_at = datetime(2016, 3, 4, 5, 6, 7, tzinfo=utc_minus_8)
self.message.send()
data = self.get_api_call_json()
self.assertEqual(
data["send_at"], timegm((2016, 3, 4, 13, 6, 7))
) # 05:06 UTC-8 == 13:06 UTC
# Timezone-naive datetime assumed to be Django current_timezone
self.message.send_at = datetime(
2022, 10, 11, 12, 13, 14, 567
) # microseconds should get stripped
self.message.send()
data = self.get_api_call_json()
self.assertEqual(
data["send_at"], timegm((2022, 10, 11, 6, 13, 14))
) # 12:13 UTC+6 == 06:13 UTC
# Date-only treated as midnight in current timezone
self.message.send_at = date(2022, 10, 22)
self.message.send()
data = self.get_api_call_json()
self.assertEqual(
data["send_at"], timegm((2022, 10, 21, 18, 0, 0))
) # 00:00 UTC+6 == 18:00-1d UTC
# POSIX timestamp
self.message.send_at = 1651820889 # 2022-05-06 07:08:09 UTC
self.message.send()
data = self.get_api_call_json()
self.assertEqual(data["send_at"], 1651820889)
def test_tags(self):
self.message.tags = ["receipt", "repeat-user"]
self.message.send()
data = self.get_api_call_json()
self.assertEqual(data["tags"], ["receipt", "repeat-user"])
def test_tracking(self):
# Test one way...
self.message.track_opens = True
self.message.track_clicks = False
self.message.send()
data = self.get_api_call_json()
self.assertEqual(data["settings"]["track_opens"], True)
self.assertEqual(data["settings"]["track_clicks"], False)
# ...and the opposite way
self.message.track_opens = False
self.message.track_clicks = True
self.message.send()
data = self.get_api_call_json()
self.assertEqual(data["settings"]["track_opens"], False)
self.assertEqual(data["settings"]["track_clicks"], True)
def test_template_id(self):
message = mail.EmailMultiAlternatives(
from_email="from@example.com", to=["to@example.com"]
)
message.template_id = "zyxwvut98765"
message.send()
data = self.get_api_call_json()
self.assertEqual(data["template_id"], "zyxwvut98765")
# With a template, MailerSend always ignores "text" and "html" params.
# For "subject", "from_email", and "reply_to" params, MailerSend ignores them
# if the corresponding field is set in the template's "Default settings",
# otherwise it uses the params. (And "subject" and "from_email" are required
# if no default setting for the template.)
# For "tags", MailerSend uses the param value if provided, otherwise
# the template default if any. (It does not attempt to combine them.)
@override_settings(ANYMAIL_MAILERSEND_BATCH_SEND_MODE="expose-to-list")
def test_merge_data_expose_to_list(self):
self.message.to = ["alice@example.com", "Bob <bob@example.com>"]
self.message.cc = ["cc@example.com"]
self.message.body = "Hi {{ name }}. Welcome to {{ group }} at {{ site }}."
self.message.merge_data = {
"alice@example.com": {"name": "Alice", "group": "Developers"},
"bob@example.com": {"name": "Bob"}, # and leave group undefined
"nobody@example.com": {"name": "Not a recipient for this message"},
}
self.message.merge_global_data = {"group": "Users", "site": "ExampleCo"}
self.message.send()
# BATCH_SEND_MODE="expose-to-list" uses 'email' API endpoint:
self.assert_esp_called("/v1/email")
data = self.get_api_call_json()
# personalization param covers all recipients:
self.assertEqual(
data["personalization"],
[
{
"email": "alice@example.com",
"data": {
"name": "Alice",
"group": "Developers",
"site": "ExampleCo",
},
},
{
"email": "bob@example.com",
"data": {"name": "Bob", "group": "Users", "site": "ExampleCo"},
},
# No personalization record for "nobody@example.com" -- including a
# personalization for email not found in "to" param causes API error.
],
)
@override_settings(ANYMAIL_MAILERSEND_BATCH_SEND_MODE="use-bulk-email")
def test_merge_data_use_bulk_email(self):
self.set_mock_response(
json_data={
"message": "The bulk email is being processed.",
"bulk_email_id": "12345abcde",
}
)
self.message.to = ["alice@example.com", "Bob <bob@example.com>"]
self.message.cc = ["cc@example.com"]
self.message.body = "Hi {{ name }}. Welcome to {{ group }} at {{ site }}."
self.message.merge_data = {
"alice@example.com": {"name": "Alice", "group": "Developers"},
"bob@example.com": {"name": "Bob"}, # and leave group undefined
"nobody@example.com": {"name": "Not a recipient for this message"},
}
self.message.merge_global_data = {"group": "Users", "site": "ExampleCo"}
self.message.send()
# BATCH_SEND_MODE="use-bulk-email" uses 'bulk-email' API endpoint:
self.assert_esp_called("/v1/bulk-email")
data = self.get_api_call_json()
self.assertEqual(len(data), 2) # batch of 2 separate emails
# "to" split to separate messages:
self.assertEqual(data[0]["to"], [{"email": "alice@example.com"}])
self.assertEqual(data[1]["to"], [{"email": "bob@example.com", "name": "Bob"}])
# "cc" appears in both:
self.assertEqual(data[0]["cc"], [{"email": "cc@example.com"}])
self.assertEqual(data[1]["cc"], [{"email": "cc@example.com"}])
# "personalization" param matches only single recipient:
self.assertEqual(
data[0]["personalization"],
[
{
"email": "alice@example.com",
"data": {
"name": "Alice",
"group": "Developers",
"site": "ExampleCo",
},
}
],
)
self.assertEqual(
data[1]["personalization"],
[
{
"email": "bob@example.com",
"data": {"name": "Bob", "group": "Users", "site": "ExampleCo"},
}
],
)
def test_merge_data_single_recipient(self):
# BATCH_SEND_MODE=None default uses 'email' for single recipient
self.message.to = ["Bob <bob@example.com>"]
self.message.cc = ["cc@example.com"]
self.message.body = "Hi {{ name }}. Welcome to {{ group }} at {{ site }}."
self.message.merge_data = {
"alice@example.com": {"name": "Alice", "group": "Developers"},
"bob@example.com": {"name": "Bob"}, # and leave group undefined
"nobody@example.com": {"name": "Not a recipient for this message"},
}
self.message.merge_global_data = {"group": "Users", "site": "ExampleCo"}
self.message.send()
self.assert_esp_called("/v1/email")
data = self.get_api_call_json()
self.assertEqual(
data["personalization"],
[
{
"email": "bob@example.com",
"data": {"name": "Bob", "group": "Users", "site": "ExampleCo"},
},
# No personalization record for merge_data emails not in "to" list.
],
)
def test_merge_data_ambiguous(self):
# Multiple recipients require a non-default MAILERSEND_BATCH_SEND_MODE
self.message.to = ["alice@example.com", "Bob <bob@example.com>"]
self.message.cc = ["cc@example.com"]
self.message.body = "Hi {{ name }}. Welcome to {{ group }} at {{ site }}."
# (even an empty merge_data dict should trigger Anymail batch send)
self.message.merge_data = {}
with self.assertRaisesMessage(
AnymailUnsupportedFeature, "MAILERSEND_BATCH_SEND_MODE"
):
self.message.send()
def test_merge_metadata(self):
self.message.to = ["alice@example.com", "Bob <bob@example.com>"]
self.message.merge_metadata = {
"alice@example.com": {"order_id": 123},
"bob@example.com": {"order_id": 678, "tier": "premium"},
}
with self.assertRaisesMessage(AnymailUnsupportedFeature, "merge_metadata"):
self.message.send()
def test_default_omits_options(self):
"""Make sure by default we don't send any ESP-specific options.
Options not specified by the caller should be omitted entirely from
the API call (*not* sent as False or empty). This ensures
that your ESP account settings apply by default.
"""
self.message.send()
data = self.get_api_call_json()
self.assertNotIn("cc", data)
self.assertNotIn("bcc", data)
self.assertNotIn("reply_to", data)
self.assertNotIn("html", data)
self.assertNotIn("attachments", data)
self.assertNotIn("template_id", data)
self.assertNotIn("tags", data)
self.assertNotIn("variables", data)
self.assertNotIn("personalization", data)
self.assertNotIn("precedence_bulk", data)
self.assertNotIn("send_at", data)
self.assertNotIn("in_reply_to", data)
self.assertNotIn("settings", data)
def test_esp_extra(self):
self.message.track_clicks = True # test deep merge of "settings"
self.message.esp_extra = {
"variables": [
{
"email": "to@example.com",
"substitutions": [{"var": "order_id", "value": "12345"}],
}
],
"settings": {
"track_content": True,
},
}
self.message.track_clicks = True
self.message.send()
data = self.get_api_call_json()
self.assertEqual(
data["variables"],
[
{
"email": "to@example.com",
"substitutions": [{"var": "order_id", "value": "12345"}],
}
],
)
self.assertEqual(
data["settings"],
{
"track_content": True,
"track_clicks": True, # deep merge
},
)
def test_esp_extra_settings_overrides(self):
"""esp_extra can override batch_send_mode and api_token settings"""
self.message.merge_data = {} # trigger batch send
self.message.esp_extra = {
"api_token": "token-from-esp-extra",
"batch_send_mode": "use-bulk-email",
"hypothetical_future_mailersend_param": 123,
}
self.message.send()
self.assert_esp_called("/v1/bulk-email") # batch_send_mode from esp_extra
headers = self.get_api_call_headers()
self.assertEqual(headers["Authorization"], "Bearer token-from-esp-extra")
data = self.get_api_call_json()
self.assertEqual(len(data), 1) # payload burst for batch
self.assertNotIn("api_token", data[0]) # not in API payload
self.assertNotIn("batch_send_mode", data[0]) # not sent to API
# But other esp_extra params sent:
self.assertEqual(data[0]["hypothetical_future_mailersend_param"], 123)
def test_send_attaches_anymail_status(self):
"""The anymail_status should be attached to the message when it is sent"""
self.set_mock_success(message_id="12345abcde")
msg = mail.EmailMessage(
"Subject", "Message", "from@example.com", ["to1@example.com"]
)
sent = msg.send()
self.assertEqual(sent, 1)
self.assertEqual(msg.anymail_status.status, {"queued"})
self.assertEqual(msg.anymail_status.message_id, "12345abcde")
self.assertEqual(
msg.anymail_status.recipients["to1@example.com"].status, "queued"
)
self.assertEqual(
msg.anymail_status.recipients["to1@example.com"].message_id,
"12345abcde",
)
@override_settings(
ANYMAIL_IGNORE_RECIPIENT_STATUS=True # exception is tested later
)
def test_send_all_rejected(self):
"""The anymail_status should be 'rejected' when all recipients rejected"""
self.set_mock_rejected(
{"to1@example.com": ["blocklisted"], "to2@example.com": ["hard_bounced"]}
)
msg = mail.EmailMessage(
"Subject",
"Message",
"from@example.com",
["to1@example.com", "to2@example.com"],
)
msg.send()
self.assertEqual(msg.anymail_status.status, {"rejected"})
recipients = msg.anymail_status.recipients
self.assertEqual(recipients["to1@example.com"].status, "rejected")
self.assertIsNone(recipients["to1@example.com"].message_id)
self.assertEqual(recipients["to2@example.com"].status, "rejected")
self.assertIsNone(recipients["to2@example.com"].message_id)
def test_send_some_rejected(self):
"""
The anymail_status should identify which recipients are rejected.
"""
self.set_mock_rejected(
{"to1@example.com": ["blocklisted"]},
warning_type="SOME_SUPPRESSED",
message_id="12345abcde",
)
msg = mail.EmailMessage(
"Subject",
"Message",
"from@example.com",
["to1@example.com", "to2@example.com"],
)
msg.send()
self.assertEqual(msg.anymail_status.status, {"rejected", "queued"})
recipients = msg.anymail_status.recipients
self.assertEqual(recipients["to1@example.com"].status, "rejected")
self.assertIsNone(recipients["to1@example.com"].message_id)
self.assertEqual(recipients["to2@example.com"].status, "queued")
self.assertEqual(recipients["to2@example.com"].message_id, "12345abcde")
# noinspection PyUnresolvedReferences
@override_settings(ANYMAIL_MAILERSEND_BATCH_SEND_MODE="use-bulk-email")
def test_bulk_send_response(self):
self.set_mock_response(
json_data={
"message": "The bulk email is being processed.",
"bulk_email_id": "12345abcde",
}
)
self.message.merge_data = {} # trigger batch behavior
self.message.send()
# Unknown status for bulk send (until you poll the status API):
self.assertEqual(self.message.anymail_status.status, {"unknown"})
# Unknown message_id for bulk send, so provide batch id with "bulk:" prefix:
self.assertEqual(self.message.anymail_status.message_id, "bulk:12345abcde")
# noinspection PyUnresolvedReferences
def test_send_failed_anymail_status(self):
"""If the send fails, anymail_status should contain initial values"""
self.set_mock_response(status_code=400)
sent = self.message.send(fail_silently=True)
self.assertEqual(sent, 0)
self.assertIsNone(self.message.anymail_status.status)
self.assertIsNone(self.message.anymail_status.message_id)
self.assertEqual(self.message.anymail_status.recipients, {})
self.assertIsNone(self.message.anymail_status.esp_response)
# noinspection PyUnresolvedReferences
def test_unhandled_warnings(self):
# Non-suppression warnings should turn a 202 accepted response into an error
response_content = {"warnings": [{"type": "UNKNOWN_WARNING"}]}
self.set_mock_response(status_code=202, json_data=response_content)
with self.assertRaisesMessage(AnymailAPIError, "UNKNOWN_WARNING"):
self.message.send()
self.assertIsNone(self.message.anymail_status.status)
self.assertIsNone(self.message.anymail_status.message_id)
self.assertEqual(self.message.anymail_status.recipients, {})
self.assertEqual(
self.message.anymail_status.esp_response.json(), response_content
)
def test_json_serialization_errors(self):
"""Try to provide more information about non-json-serializable data"""
self.message.tags = [Decimal("19.99")] # yeah, don't do this
with self.assertRaises(AnymailSerializationError) as cm:
self.message.send()
print(self.get_api_call_json())
err = cm.exception
self.assertIsInstance(err, TypeError) # compatibility with json.dumps
# our added context:
self.assertIn("Don't know how to send this data to MailerSend", str(err))
# original message:
self.assertRegex(str(err), r"Decimal.*is not JSON serializable")
@tag("mailersend")
class MailerSendBackendRecipientsRefusedTests(MailerSendBackendMockAPITestCase):
"""
Should raise AnymailRecipientsRefused when *all* recipients are rejected or invalid
"""
def test_recipients_refused(self):
self.set_mock_rejected(
{
"invalid@localhost": ["hard_bounced"],
"reject@example.com": ["blocklisted"],
}
)
msg = mail.EmailMessage(
"Subject",
"Body",
"from@example.com",
["invalid@localhost", "reject@example.com"],
)
with self.assertRaises(AnymailRecipientsRefused):
msg.send()
def test_fail_silently(self):
self.set_mock_rejected(
{
"invalid@localhost": ["hard_bounced"],
"reject@example.com": ["blocklisted"],
}
)
sent = mail.send_mail(
"Subject",
"Body",
"from@example.com",
["invalid@localhost", "reject@example.com"],
fail_silently=True,
)
self.assertEqual(sent, 0)
def test_mixed_response(self):
"""If *any* recipients are valid or queued, no exception is raised"""
self.set_mock_rejected(
{
"invalid@localhost": ["hard_bounced"],
"reject@example.com": ["blocklisted"],
},
warning_type="SOME_SUPPRESSED",
)
msg = mail.EmailMessage(
"Subject",
"Body",
"from@example.com",
[
"invalid@localhost",
"valid@example.com",
"reject@example.com",
"also.valid@example.com",
],
)
sent = msg.send()
# one message sent, successfully, to 2 of 4 recipients:
self.assertEqual(sent, 1)
status = msg.anymail_status
self.assertEqual(status.recipients["invalid@localhost"].status, "rejected")
self.assertEqual(status.recipients["valid@example.com"].status, "queued")
self.assertEqual(status.recipients["reject@example.com"].status, "rejected")
self.assertEqual(status.recipients["also.valid@example.com"].status, "queued")
@override_settings(ANYMAIL_IGNORE_RECIPIENT_STATUS=True)
def test_settings_override(self):
"""No exception with ignore setting"""
self.set_mock_rejected(
{
"invalid@localhost": ["hard_bounced"],
"reject@example.com": ["blocklisted"],
},
)
sent = mail.send_mail(
"Subject",
"Body",
"from@example.com",
["invalid@localhost", "reject@example.com"],
)
self.assertEqual(sent, 1) # refused message is included in sent count
@tag("mailersend")
class MailerSendBackendConfigurationTests(MailerSendBackendMockAPITestCase):
"""Test various MailerSend client options"""
@override_settings(
# clear MAILERSEND_API_TOKEN from MailerSendBackendMockAPITestCase:
ANYMAIL={}
)
def test_missing_api_token(self):
with self.assertRaises(AnymailConfigurationError) as cm:
mail.send_mail("Subject", "Message", "from@example.com", ["to@example.com"])
errmsg = str(cm.exception)
# Make sure the error mentions the different places to set the key
self.assertRegex(errmsg, r"\bMAILERSEND_API_TOKEN\b")
self.assertRegex(errmsg, r"\bANYMAIL_MAILERSEND_API_TOKEN\b")
@override_settings(
ANYMAIL={
"MAILERSEND_API_URL": "https://api.dev.mailersend.com/v2",
"MAILERSEND_API_TOKEN": "test_api_key",
}
)
def test_mailersend_api_url(self):
mail.send_mail("Subject", "Message", "from@example.com", ["to@example.com"])
self.assert_esp_called("https://api.dev.mailersend.com/v2/email")
# can also override on individual connection
connection = mail.get_connection(api_url="https://api.mailersend.com/vNext")
mail.send_mail(
"Subject",
"Message",
"from@example.com",
["to@example.com"],
connection=connection,
)
self.assert_esp_called("https://api.mailersend.com/vNext/email")
@override_settings(ANYMAIL={"MAILERSEND_API_TOKEN": "bad_token"})
def test_invalid_api_key(self):
self.set_mock_response(
status_code=401,
reason="UNAUTHORIZED",
json_data={"message": "Unauthenticated."},
)
with self.assertRaisesMessage(AnymailAPIError, "Unauthenticated"):
self.message.send()

View File

@@ -0,0 +1,353 @@
import json
from datetime import datetime, timezone
from textwrap import dedent
from unittest.mock import ANY
from django.core.exceptions import ImproperlyConfigured
from django.test import override_settings, tag
from anymail.exceptions import AnymailConfigurationError
from anymail.inbound import AnymailInboundMessage
from anymail.signals import AnymailInboundEvent
from anymail.webhooks.mailersend import MailerSendInboundWebhookView
from .test_mailersend_webhooks import (
TEST_WEBHOOK_SIGNING_SECRET,
MailerSendWebhookTestCase,
)
from .utils import sample_image_content
from .webhook_cases import WebhookBasicAuthTestCase
@tag("mailersend")
@override_settings(ANYMAIL_MAILERSEND_INBOUND_SECRET=TEST_WEBHOOK_SIGNING_SECRET)
class MailerSendInboundSecurityTestCase(
MailerSendWebhookTestCase, WebhookBasicAuthTestCase
):
should_warn_if_no_auth = False # because we check webhook signature
def call_webhook(self):
return self.client_post_signed(
"/anymail/mailersend/inbound/",
{"type": "inbound.message", "data": {"raw": "..."}},
secret=TEST_WEBHOOK_SIGNING_SECRET,
)
# Additional tests are in WebhookBasicAuthTestCase
def test_verifies_correct_signature(self):
response = self.client_post_signed(
"/anymail/mailersend/inbound/",
{"type": "inbound.message", "data": {"raw": "..."}},
secret=TEST_WEBHOOK_SIGNING_SECRET,
)
self.assertEqual(response.status_code, 200)
def test_verifies_missing_signature(self):
response = self.client.post(
"/anymail/mailersend/inbound/",
content_type="application/json",
data=json.dumps({"type": "inbound.message", "data": {"raw": "..."}}),
)
self.assertEqual(response.status_code, 400)
def test_verifies_bad_signature(self):
# This also verifies that the error log references the correct setting to check.
with self.assertLogs() as logs:
response = self.client_post_signed(
"/anymail/mailersend/inbound/",
{"type": "inbound.message", "data": {"raw": "..."}},
secret="wrong signing key",
)
# SuspiciousOperation causes 400 response (even in test client):
self.assertEqual(response.status_code, 400)
self.assertIn("check Anymail MAILERSEND_INBOUND_SECRET", logs.output[0])
@tag("mailersend")
class MailerSendInboundSettingsTestCase(MailerSendWebhookTestCase):
def test_requires_inbound_secret(self):
with self.assertRaisesMessage(
ImproperlyConfigured, "MAILERSEND_INBOUND_SECRET"
):
self.client_post_signed(
"/anymail/mailersend/inbound/",
{
"type": "inbound.message",
"data": {"object": "message", "raw": "..."},
},
)
@override_settings(
ANYMAIL={
"MAILERSEND_INBOUND_SECRET": "inbound secret",
"MAILERSEND_WEBHOOK_SIGNING_SECRET": "webhook secret",
}
)
def test_webhook_signing_secret_is_different(self):
response = self.client_post_signed(
"/anymail/mailersend/inbound/",
{
"type": "inbound.message",
"data": {"object": "message", "raw": "..."},
},
secret="inbound secret",
)
self.assertEqual(response.status_code, 200)
@override_settings(ANYMAIL_MAILERSEND_INBOUND_SECRET="settings secret")
def test_inbound_secret_view_params(self):
"""Webhook signing secret can be provided as a view param"""
view = MailerSendInboundWebhookView.as_view(inbound_secret="view-level secret")
view_instance = view.view_class(**view.view_initkwargs)
self.assertEqual(view_instance.signing_secret, b"view-level secret")
@tag("mailersend")
@override_settings(ANYMAIL_MAILERSEND_INBOUND_SECRET=TEST_WEBHOOK_SIGNING_SECRET)
class MailerSendInboundTestCase(MailerSendWebhookTestCase):
# Since Anymail just parses the raw MIME message through the Python email
# package, there aren't really a lot of different cases to test here.
# (We don't need to re-test the whole email.parser.)
def test_inbound(self):
# This is an actual (sanitized) inbound payload received from MailerSend:
raw_event = {
"type": "inbound.message",
"inbound_id": "[inbound-route-id-redacted]",
"url": "https://test.anymail.dev/anymail/mailersend/inbound/",
"created_at": "2023-03-04T02:22:16.417935Z",
"data": {
"object": "message",
"id": "6402ab57f79d39d7e10f2523",
"recipients": {
"rcptTo": [{"email": "envelope-recipient@example.com"}],
"to": {
"raw": "Recipient <to@example.com>",
"data": [{"email": "to@example.com", "name": "Recipient"}],
},
},
"from": {
"email": "sender@example.org",
"name": "Sender Name",
"raw": "Sender Name <sender@example.org>",
},
"sender": {"email": "envelope-sender@example.org"},
"subject": "Testing inbound \ud83c\udf0e",
"date": "Fri, 3 Mar 2023 18:22:03 -0800",
"headers": {
"X-Envelope-From": "<envelope-sender@example.org>",
# Multiple-instance headers appear as arrays:
"Received": [
"from example.org (mail.example.org [10.10.10.10])\r\n"
" by inbound.mailersend.net with ESMTPS id ...\r\n"
" Sat, 04 Mar 2023 02:22:15 +0000 (UTC)",
"by mail.example.org with SMTP id ...\r\n"
" for <envelope-recipient@example.com>;\r\n"
" Fri, 03 Mar 2023 18:22:15 -0800 (PST)",
],
"DKIM-Signature": "v=1; a=rsa-sha256; c=relaxed/relaxed; ...",
"MIME-Version": "1.0",
"From": "Sender Name <sender@example.org>",
"Date": "Fri, 3 Mar 2023 18:22:03 -0800",
"Message-ID": "<AzjSdSHsmvXUeZGTPQ@mail.example.org>",
"Subject": "=?UTF-8?Q?Testing_inbound_=F0=9F=8C=8E?=",
"To": "Recipient <to@example.com>",
"Content-Type": 'multipart/mixed; boundary="000000000000e5575c05f609bab6"',
},
"text": "This is a *test*!\r\n\r\n[image: sample_image.png]\r\n",
"html": (
"<p>This is a <b>test</b>!</p>"
'<img src="cid:ii_letc8ro50" alt="sample_image.png">'
),
"raw": dedent(
"""\
X-Envelope-From: <envelope-sender@example.org>
Received: from example.org (mail.example.org [10.10.10.10])
by inbound.mailersend.net with ESMTPS id ...
Sat, 04 Mar 2023 02:22:15 +0000 (UTC)
Received: by mail.example.org with SMTP id ...
for <envelope-recipient@example.com>;
Fri, 03 Mar 2023 18:22:15 -0800 (PST)
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; ...
MIME-Version: 1.0
From: Sender Name <sender@example.org>
Date: Fri, 3 Mar 2023 18:22:03 -0800
Message-ID: <AzjSdSHsmvXUeZGTPQ@mail.example.org>
Subject: =?UTF-8?Q?Testing_inbound_=F0=9F=8C=8E?=
To: Recipient <to@example.com>
Content-Type: multipart/mixed; boundary="000000000000e5575c05f609bab6"
--000000000000e5575c05f609bab6
Content-Type: multipart/related; boundary="000000000000e5575b05f609bab5"
--000000000000e5575b05f609bab5
Content-Type: multipart/alternative; boundary="000000000000e5575a05f609bab4"
--000000000000e5575a05f609bab4
Content-Type: text/plain; charset="UTF-8"
This is a *test*!
[image: sample_image.png]
--000000000000e5575a05f609bab4
Content-Type: text/html; charset="UTF-8"
<p>This is a <b>test</b>!</p>
<img src="cid:ii_letc8ro50" alt="sample_image.png">
--000000000000e5575a05f609bab4--
--000000000000e5575b05f609bab5
Content-Type: image/png; name="sample_image.png"
Content-Disposition: inline; filename="sample_image.png"
Content-Transfer-Encoding: base64
Content-ID: <ii_letc8ro50>
iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAABHNCSVQICAgIfAhkiAAAAAlwSFlz
AAALEgAACxIB0t1+/AAAABR0RVh0Q3JlYXRpb24gVGltZQAzLzEvMTNoZNRjAAAAHHRFWHRTb2Z0
d2FyZQBBZG9iZSBGaXJld29ya3MgQ1M1cbXjNgAAAZ1JREFUWIXtl7FKA0EQhr+TgIFgo5BXyBUp
fIGksLawUNAXWFFfwCJgBAtfIJFMLXgQn8BSwdpCiPcKAdOIoI2x2Dmyd7kYwXhp9odluX/uZv6d
nZu7DXowxiKZi0IAUHKCvxcsoAIEpST4IawVGb0Hb0BlpcigefACvAAvwAsoTTGGlwwzBAyivLUP
EZrOM10AhGOH2wWugVVlHoAdhJHrPC8DNR0JGsAAQ9mxNzBOMNjS4Qrq69U5EKmf12ywWVsQI4QI
IbCn3Gnmnk7uk1bokfooI7QRDlQIGCdzPwiYh0idtXNs2zq3UqwVEiDcu/R0DVjUnFpItuPSscfA
FXCGSfEAdZ2fVeQ68OjYWwi3ycVvMhABGwgfKXZScHeZ+4c6VzN8FbuYukvOykCs+z8PJ0xqIXYE
d4ALoKlVH2IIgUHWwd/6gNAFPjPcCPvKNTDcYAj1lXzKc7GIRrSZI6yJzcQ+dtV9bD+IkHThBj34
4j9/yYxupaQbXPJLNqsGFgeZ6qwpLP1b4AV4AV5AoKfjpR5OwR6VKwULCAC+AQV4W9Ps4uZQAAAA
AElFTkSuQmCC
--000000000000e5575b05f609bab5--
--000000000000e5575c05f609bab6
Content-Type: text/csv; charset="US-ASCII"; name="sample_data.csv"
Content-Disposition: attachment; filename="sample_data.csv"
Content-Transfer-Encoding: quoted-printable
Product,Price
Widget,33.20
--000000000000e5575c05f609bab6--"""
).replace("\n", "\r\n"),
"attachments": [
{
"file_name": "sample_image.png",
"content_type": "image/png",
"content_disposition": "inline",
"content_id": "ii_letc8ro50",
"size": 579,
"content": (
"iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAABHNCSVQICAgIfAhki"
"AAAAAlwSFlzAAALEgAACxIB0t1+/AAAABR0RVh0Q3JlYXRpb24gVGltZQAzLzEvMT"
"NoZNRjAAAAHHRFWHRTb2Z0d2FyZQBBZG9iZSBGaXJld29ya3MgQ1M1cbXjNgAAAZ1"
"JREFUWIXtl7FKA0EQhr+TgIFgo5BXyBUpfIGksLawUNAXWFFfwCJgBAtfIJFMLXgQ"
"n8BSwdpCiPcKAdOIoI2x2Dmyd7kYwXhp9odluX/uZv6dnZu7DXowxiKZi0IAUHKCv"
"xcsoAIEpST4IawVGb0Hb0BlpcigefACvAAvwAsoTTGGlwwzBAyivLUPEZrOM10AhG"
"OH2wWugVVlHoAdhJHrPC8DNR0JGsAAQ9mxNzBOMNjS4Qrq69U5EKmf12ywWVsQI4Q"
"IIbCn3Gnmnk7uk1bokfooI7QRDlQIGCdzPwiYh0idtXNs2zq3UqwVEiDcu/R0DVjU"
"nFpItuPSscfAFXCGSfEAdZ2fVeQ68OjYWwi3ycVvMhABGwgfKXZScHeZ+4c6VzN8F"
"buYukvOykCs+z8PJ0xqIXYEd4ALoKlVH2IIgUHWwd/6gNAFPjPcCPvKNTDcYAj1lX"
"zKc7GIRrSZI6yJzcQ+dtV9bD+IkHThBj344j9/yYxupaQbXPJLNqsGFgeZ6qwpLP1"
"b4AV4AV5AoKfjpR5OwR6VKwULCAC+AQV4W9Ps4uZQAAAAAElFTkSuQmCC"
),
},
{
"file_name": "sample_data.csv",
"content_type": "text/csv",
"content_disposition": "attachment",
"size": 26,
"content": "UHJvZHVjdCxQcmljZQpXaWRnZXQsMzMuMjA=",
},
],
"spf_check": {"code": "+", "value": None},
"dkim_check": False,
"created_at": "2023-03-04T02:22:15.525000Z",
},
}
response = self.client_post_signed("/anymail/mailersend/inbound/", raw_event)
self.assertEqual(response.status_code, 200)
kwargs = self.assert_handler_called_once_with(
self.inbound_handler,
sender=MailerSendInboundWebhookView,
event=ANY,
esp_name="MailerSend",
)
# AnymailInboundEvent
event = kwargs["event"]
self.assertIsInstance(event, AnymailInboundEvent)
self.assertEqual(event.event_type, "inbound")
self.assertEqual(
event.timestamp,
# "2023-03-04T02:22:15.525000Z"
datetime(2023, 3, 4, 2, 22, 15, microsecond=525000, tzinfo=timezone.utc),
)
self.assertEqual(event.event_id, "6402ab57f79d39d7e10f2523")
self.assertIsInstance(event.message, AnymailInboundMessage)
# (The raw_event subject contains a "\N{EARTH GLOBE AMERICAS}" (🌎)
# character in the escaped form "\ud83c\udf0e", which won't compare equal
# until unescaped. Passing through json dumps/loads resolves the escapes.)
self.assertEqual(event.esp_event, json.loads(json.dumps(raw_event)))
# AnymailInboundMessage - convenience properties
message = event.message
self.assertEqual(message.from_email.display_name, "Sender Name")
self.assertEqual(message.from_email.addr_spec, "sender@example.org")
self.assertEqual(str(message.to[0]), "Recipient <to@example.com>")
self.assertEqual(message.subject, "Testing inbound 🌎")
self.assertEqual(message.date.isoformat(" "), "2023-03-03 18:22:03-08:00")
self.assertEqual(
message.text, "This is a *test*!\r\n\r\n[image: sample_image.png]\r\n"
)
self.assertHTMLEqual(
message.html,
"<p>This is a <b>test</b>!</p>"
'<img src="cid:ii_letc8ro50" alt="sample_image.png">',
)
self.assertEqual(message.envelope_sender, "envelope-sender@example.org")
self.assertEqual(message.envelope_recipient, "envelope-recipient@example.com")
# MailerSend inbound doesn't provide these:
self.assertIsNone(message.stripped_text)
self.assertIsNone(message.stripped_html)
self.assertIsNone(message.spam_detected)
self.assertIsNone(message.spam_score)
# AnymailInboundMessage - other headers
self.assertEqual(message["Message-ID"], "<AzjSdSHsmvXUeZGTPQ@mail.example.org>")
self.assertEqual(
message.get_all("Received"),
[
"from example.org (mail.example.org [10.10.10.10]) by inbound.mailersend.net"
" with ESMTPS id ... Sat, 04 Mar 2023 02:22:15 +0000 (UTC)",
"by mail.example.org with SMTP id ... for <envelope-recipient@example.com>;"
" Fri, 03 Mar 2023 18:22:15 -0800 (PST)",
],
)
inlines = message.inline_attachments
self.assertEqual(len(inlines), 1)
inline = inlines["ii_letc8ro50"]
self.assertEqual(inline.get_filename(), "sample_image.png")
self.assertEqual(inline.get_content_type(), "image/png")
self.assertEqual(inline.get_content_bytes(), sample_image_content())
attachments = message.attachments
self.assertEqual(len(attachments), 1)
self.assertEqual(attachments[0].get_filename(), "sample_data.csv")
self.assertEqual(attachments[0].get_content_type(), "text/csv")
self.assertEqual(
attachments[0].get_content_text(), "Product,Price\r\nWidget,33.20"
)
def test_misconfigured_inbound(self):
errmsg = (
"You seem to have set MailerSend's *activity.sent* webhook"
" to Anymail's MailerSend *inbound* webhook URL."
)
with self.assertRaisesMessage(AnymailConfigurationError, errmsg):
self.client_post_signed(
"/anymail/mailersend/inbound/",
{
"type": "activity.sent",
"data": {"object": "activity", "type": "sent"},
},
)

View File

@@ -0,0 +1,184 @@
import os
import unittest
from datetime import datetime, timedelta
from email.utils import formataddr
from django.test import SimpleTestCase, override_settings, tag
from anymail.exceptions import AnymailAPIError
from anymail.message import AnymailMessage
from .utils import AnymailTestMixin, sample_image_path
ANYMAIL_TEST_MAILERSEND_API_TOKEN = os.getenv("ANYMAIL_TEST_MAILERSEND_API_TOKEN")
ANYMAIL_TEST_MAILERSEND_DOMAIN = os.getenv("ANYMAIL_TEST_MAILERSEND_DOMAIN")
@tag("mailersend", "live")
@unittest.skipUnless(
ANYMAIL_TEST_MAILERSEND_API_TOKEN and ANYMAIL_TEST_MAILERSEND_DOMAIN,
"Set ANYMAIL_TEST_MAILERSEND_API_TOKEN and ANYMAIL_TEST_MAILERSEND_DOMAIN"
" environment variables to run MailerSend integration tests",
)
@override_settings(
ANYMAIL={
"MAILERSEND_API_TOKEN": ANYMAIL_TEST_MAILERSEND_API_TOKEN,
},
EMAIL_BACKEND="anymail.backends.mailersend.EmailBackend",
)
class MailerSendBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
"""MailerSend API integration tests
These tests run against the **live** MailerSend API, using the
environment variable `ANYMAIL_TEST_MAILERSEND_API_TOKEN` as the API token
and `ANYMAIL_TEST_MAILERSEND_DOMAIN` as the sender domain.
If those variables are not set, these tests won't run.
"""
def setUp(self):
super().setUp()
self.from_email = f"from@{ANYMAIL_TEST_MAILERSEND_DOMAIN}"
self.message = AnymailMessage(
"Anymail MailerSend integration test",
"Text content",
self.from_email,
["test+to1@anymail.dev"],
)
self.message.attach_alternative("<p>HTML content</p>", "text/html")
def test_simple_send(self):
# Example of getting the MailerSend send status and message id from the message
sent_count = self.message.send()
self.assertEqual(sent_count, 1)
anymail_status = self.message.anymail_status
sent_status = anymail_status.recipients["test+to1@anymail.dev"].status
message_id = anymail_status.recipients["test+to1@anymail.dev"].message_id
self.assertEqual(sent_status, "queued") # MailerSend queues
# don't know what it'll be, but it should exist (and not be a bulk id):
self.assertGreater(len(message_id), 0)
self.assertFalse(message_id.startswith("bulk:"))
# set of all recipient statuses:
self.assertEqual(anymail_status.status, {sent_status})
self.assertEqual(anymail_status.message_id, message_id)
def test_all_options(self):
send_at = datetime.now() + timedelta(minutes=2)
from_email = formataddr(("Test From, with comma", self.from_email))
message = AnymailMessage(
subject="Anymail MailerSend all-options integration test",
body="This is the text body",
from_email=from_email,
to=[
"test+to1@anymail.dev",
"Recipient 2 <test+to2@anymail.dev>",
"test+bounce@anymail.dev", # will be rejected
],
cc=["test+cc1@anymail.dev", "Copy 2 <test+cc2@anymail.dev>"],
bcc=["test+bcc1@anymail.dev", "Blind Copy 2 <test+bcc2@anymail.dev>"],
# MailerSend only supports single reply_to:
reply_to=["Reply <reply@example.com>"],
# MailerSend supports very limited extra headers:
headers={"Precedence": "bulk", "In-Reply-To": "earlier-id@anymail.dev"},
send_at=send_at,
tags=["tag 1", "tag 2"],
track_clicks=False,
track_opens=True,
)
message.attach("attachment1.txt", "Here is some\ntext for you", "text/plain")
message.attach("vedhæftet fil.csv", "ID,Name\n1,3", "text/csv")
cid = message.attach_inline_image_file(
sample_image_path(), domain=ANYMAIL_TEST_MAILERSEND_DOMAIN
)
message.attach_alternative(
f"<div>This is the <i>html</i> body <img src='cid:{cid}'></div>",
"text/html",
)
message.send()
# First two recipients should be queued, other should be bounced
self.assertEqual(message.anymail_status.status, {"queued", "rejected"})
recipient_status = message.anymail_status.recipients
self.assertEqual(recipient_status["test+to1@anymail.dev"].status, "queued")
self.assertEqual(recipient_status["test+to2@anymail.dev"].status, "queued")
self.assertEqual(recipient_status["test+bounce@anymail.dev"].status, "rejected")
def test_stored_template(self):
message = AnymailMessage(
# id of a real template named in Anymail's MailerSend test account:
template_id="vywj2lpokkm47oqz",
to=[
"test+to1@anymail.dev",
"test+to2@anymail.dev",
"test+bounce@anymail.dev", # will be rejected
],
merge_data={
"test+to1@anymail.dev": {"name": "First Recipient", "order": 12345},
"test+to2@anymail.dev": {"name": "Second Recipient", "order": 67890},
"test+bounce@anymail.dev": {"name": "Bounces", "order": 3},
},
merge_global_data={"date": "yesterday"},
esp_extra={
# CAREFUL: with "expose-to-list", all recipients will see
# every other recipient's email address. (See docs!)
"batch_send_mode": "expose-to-list"
},
)
message.from_email = None # use template From
message.send()
recipient_status = message.anymail_status.recipients
self.assertEqual(recipient_status["test+to1@anymail.dev"].status, "queued")
self.assertEqual(recipient_status["test+to2@anymail.dev"].status, "queued")
self.assertEqual(recipient_status["test+bounce@anymail.dev"].status, "rejected")
self.assertFalse(
recipient_status["test+to1@anymail.dev"].message_id.startswith("bulk:")
)
self.assertIsNone(recipient_status["test+bounce@anymail.dev"].message_id)
def test_batch_send_mode_bulk(self):
# Same test as above, but with batch_send_mode "use-bulk-email".
# (Uses different API; status is handled very differently.)
message = AnymailMessage(
# id of a real template named in Anymail's MailerSend test account:
template_id="vywj2lpokkm47oqz",
to=[
"test+to1@anymail.dev",
"test+to2@anymail.dev",
"test+bounce@anymail.dev", # will be rejected
],
merge_data={
"test+to1@anymail.dev": {"name": "First Recipient", "order": 12345},
"test+to2@anymail.dev": {"name": "Second Recipient", "order": 67890},
"test+bounce@anymail.dev": {"name": "Bounces", "order": 3},
},
merge_global_data={"date": "yesterday"},
esp_extra={"batch_send_mode": "use-bulk-email"},
)
message.from_email = None # use template From
message.send()
recipient_status = message.anymail_status.recipients
# With use-bulk-email, must poll bulk-email status API to determine status:
self.assertEqual(recipient_status["test+to1@anymail.dev"].status, "unknown")
self.assertEqual(recipient_status["test+to2@anymail.dev"].status, "unknown")
self.assertEqual(recipient_status["test+bounce@anymail.dev"].status, "unknown")
# With use-bulk-email, message_id will be MailerSend's bulk_email_id
# rather than an actual message_id. Anymail adds "bulk:" to differentiate:
self.assertTrue(
recipient_status["test+to1@anymail.dev"].message_id.startswith("bulk:")
)
self.assertTrue(
recipient_status["test+bounce@anymail.dev"].message_id.startswith("bulk:")
)
@override_settings(
ANYMAIL={
"MAILERSEND_API_TOKEN": "Hey, that's not an API token",
}
)
def test_invalid_api_key(self):
with self.assertRaisesMessage(AnymailAPIError, "Unauthenticated"):
self.message.send()

View File

@@ -0,0 +1,465 @@
import hashlib
import hmac
import json
from datetime import datetime, timezone
from unittest.mock import ANY
from django.core.exceptions import ImproperlyConfigured
from django.test import override_settings, tag
from anymail.exceptions import AnymailConfigurationError
from anymail.signals import AnymailTrackingEvent
from anymail.webhooks.mailersend import MailerSendTrackingWebhookView
from .webhook_cases import WebhookBasicAuthTestCase, WebhookTestCase
TEST_WEBHOOK_SIGNING_SECRET = "TEST_WEBHOOK_SIGNING_SECRET"
def mailersend_signature(data, secret):
"""Generate a MailerSend webhook signature for data with secret"""
# https://developers.mailersend.com/api/v1/webhooks.html#security
return hmac.new(
key=secret.encode("ascii"),
msg=data,
digestmod=hashlib.sha256,
).hexdigest()
class MailerSendWebhookTestCase(WebhookTestCase):
def client_post_signed(self, url, json_data, secret=TEST_WEBHOOK_SIGNING_SECRET):
"""Return self.client.post(url, serialized json_data) signed with secret"""
# MailerSend for some reason backslash-escapes all forward slashes ("/")
# in its webhook payloads ("https:\/\/www..."). This is unnecessary, but
# harmless. We emulate it here to make sure it won't cause problems.
data = json.dumps(json_data).replace("/", "\\/").encode("ascii")
signature = mailersend_signature(data, secret)
return self.client.post(
url, content_type="application/json", data=data, HTTP_SIGNATURE=signature
)
@tag("mailersend")
class MailerSendWebhookSettingsTestCase(MailerSendWebhookTestCase):
def test_requires_signing_secret(self):
with self.assertRaisesMessage(
ImproperlyConfigured, "MAILERSEND_SIGNING_SECRET"
):
self.client_post_signed(
"/anymail/mailersend/tracking/", {"data": {"type": "sent"}}
)
@override_settings(
ANYMAIL={
"MAILERSEND_SIGNING_SECRET": "webhook secret",
"MAILERSEND_INBOUND_SECRET": "inbound secret",
}
)
def test_inbound_secret_is_different(self):
response = self.client_post_signed(
"/anymail/mailersend/tracking/",
{"data": {"type": "sent"}},
secret="webhook secret",
)
self.assertEqual(response.status_code, 200)
@override_settings(ANYMAIL_MAILERSEND_SIGNING_SECRET="settings secret")
def test_signing_secret_view_params(self):
"""Webhook signing secret can be provided as a view param"""
view = MailerSendTrackingWebhookView.as_view(signing_secret="view-level secret")
view_instance = view.view_class(**view.view_initkwargs)
self.assertEqual(view_instance.signing_secret, b"view-level secret")
@tag("mailersend")
@override_settings(ANYMAIL_MAILERSEND_SIGNING_SECRET=TEST_WEBHOOK_SIGNING_SECRET)
class MailerSendWebhookSecurityTestCase(
MailerSendWebhookTestCase, WebhookBasicAuthTestCase
):
should_warn_if_no_auth = False # because we check webhook signature
def call_webhook(self):
return self.client_post_signed(
"/anymail/mailersend/tracking/",
{"data": {"type": "sent"}},
secret=TEST_WEBHOOK_SIGNING_SECRET,
)
# Additional tests are in WebhookBasicAuthTestCase
def test_verifies_correct_signature(self):
response = self.client_post_signed(
"/anymail/mailersend/tracking/",
{"data": {"type": "sent"}},
secret=TEST_WEBHOOK_SIGNING_SECRET,
)
self.assertEqual(response.status_code, 200)
def test_verifies_missing_signature(self):
response = self.client.post(
"/anymail/mailersend/tracking/",
content_type="application/json",
data=json.dumps({"data": {"type": "sent"}}),
)
self.assertEqual(response.status_code, 400)
def test_verifies_bad_signature(self):
# This also verifies that the error log references the correct setting to check.
with self.assertLogs() as logs:
response = self.client_post_signed(
"/anymail/mailersend/tracking/",
{"data": {"type": "sent"}},
secret="wrong signing key",
)
# SuspiciousOperation causes 400 response (even in test client):
self.assertEqual(response.status_code, 400)
self.assertIn("check Anymail MAILERSEND_SIGNING_SECRET", logs.output[0])
@tag("mailersend")
@override_settings(ANYMAIL_MAILERSEND_SIGNING_SECRET=TEST_WEBHOOK_SIGNING_SECRET)
class MailerSendTestCase(MailerSendWebhookTestCase):
def test_sent_event(self):
# This is an actual, complete (sanitized) "sent" event as received from
# MailerSend. (For brevity, later tests omit several payload fields that
# Anymail doesn't use.)
raw_event = {
"type": "activity.sent",
"domain_id": "[domain-id-redacted]",
"created_at": "2023-02-27T21:09:49.520507Z",
"webhook_id": "[webhook-id-redacted]",
"url": "https://test.anymail.dev/anymail/mailersend/tracking/",
"data": {
"object": "activity",
"id": "63fd1c1d31b9c750540fe85c",
"type": "sent",
"created_at": "2023-02-27T21:09:49.506000Z",
"email": {
"object": "email",
"id": "63fd1c1de225707fa905f0a8",
"created_at": "2023-02-27T21:09:49.141000Z",
"from": "sender@mailersend.anymail.dev",
"subject": "Test webhooks",
"status": "sent",
"tags": ["tag1", "Tag 2"],
"message": {
"object": "message",
"id": "63fd1c1d5f010335ed07066b",
"created_at": "2023-02-27T21:09:49.061000Z",
},
"recipient": {
"object": "recipient",
"id": "63f3bb1965d98aa98c07d6b7",
"email": "recipient@example.com",
"created_at": "2023-02-20T18:25:29.162000Z",
},
},
"morph": None,
"template_id": "",
},
}
response = self.client_post_signed("/anymail/mailersend/tracking/", raw_event)
self.assertEqual(response.status_code, 200)
kwargs = self.assert_handler_called_once_with(
self.tracking_handler,
sender=MailerSendTrackingWebhookView,
event=ANY,
esp_name="MailerSend",
)
event = kwargs["event"]
self.assertIsInstance(event, AnymailTrackingEvent)
self.assertEqual(event.event_type, "sent")
# event.timestamp comes from data.created_at:
self.assertEqual(
event.timestamp,
# "2023-02-27T21:09:49.506000Z"
datetime(2023, 2, 27, 21, 9, 49, microsecond=506000, tzinfo=timezone.utc),
)
# event.message_id matches the message.anymail_status.message_id when the
# message was sent. It comes from data.email.message.id:
self.assertEqual(event.message_id, "63fd1c1d5f010335ed07066b")
# event.event_id is unique for each event, and comes from data.id:
self.assertEqual(event.event_id, "63fd1c1d31b9c750540fe85c")
self.assertEqual(event.recipient, "recipient@example.com")
self.assertEqual(event.tags, ["tag1", "Tag 2"])
self.assertEqual(event.metadata, {}) # MailerSend doesn't support metadata
self.assertEqual(event.esp_event, raw_event)
# You can construct the sent Message-ID header (which is different from the
# event.message_id, and is unique per recipient) from esp_event.data.email.id:
sent_message_id = f"<{event.esp_event['data']['email']['id']}@mailersend.net>"
self.assertEqual(sent_message_id, "<63fd1c1de225707fa905f0a8@mailersend.net>")
def test_delivered_event(self):
raw_event = {
"type": "activity.delivered",
"data": {
"object": "activity",
"id": "63fd1c1fcfbe46145d003a7b",
"type": "delivered",
"created_at": "2023-02-27T21:09:51.865000Z",
"email": {
"status": "delivered",
"message": {
"id": "63fd1c1d5f010335ed07066b",
},
"recipient": {
"email": "recipient@example.com",
},
},
"morph": None,
},
}
response = self.client_post_signed("/anymail/mailersend/tracking/", raw_event)
self.assertEqual(response.status_code, 200)
kwargs = self.assert_handler_called_once_with(
self.tracking_handler,
sender=MailerSendTrackingWebhookView,
event=ANY,
esp_name="MailerSend",
)
event = kwargs["event"]
self.assertIsInstance(event, AnymailTrackingEvent)
self.assertEqual(event.event_type, "delivered")
self.assertEqual(
event.timestamp,
# "2023-02-27T21:09:51.865000Z"
datetime(2023, 2, 27, 21, 9, 51, microsecond=865000, tzinfo=timezone.utc),
)
self.assertEqual(event.message_id, "63fd1c1d5f010335ed07066b")
self.assertEqual(event.event_id, "63fd1c1fcfbe46145d003a7b")
self.assertEqual(event.recipient, "recipient@example.com")
def test_hard_bounced_event(self):
raw_event = {
"type": "activity.hard_bounced",
"data": {
"id": "63fd251d5c00f8e52001fce6",
"type": "hard_bounced",
"created_at": "2023-02-27T21:48:13.593000Z",
"email": {
"status": "rejected",
"message": {
"id": "63fd25194d5edba3da09e044",
},
"recipient": {
"email": "invalid@example.com",
},
},
"morph": {
"object": "recipient_bounce",
"reason": "Host or domain name not found",
},
},
}
response = self.client_post_signed("/anymail/mailersend/tracking/", raw_event)
self.assertEqual(response.status_code, 200)
kwargs = self.assert_handler_called_once_with(
self.tracking_handler,
sender=MailerSendTrackingWebhookView,
event=ANY,
esp_name="MailerSend",
)
event = kwargs["event"]
self.assertEqual(event.event_type, "bounced")
self.assertEqual(event.recipient, "invalid@example.com")
self.assertEqual(event.reject_reason, "bounced")
self.assertEqual(event.description, "Host or domain name not found")
self.assertIsNone(event.mta_response) # raw MTA info not provided
def test_soft_bounced_event(self):
raw_event = {
"type": "activity.soft_bounced",
"data": {
"object": "activity",
"id": "62f114f8165fe0d8db0288e5",
"type": "soft_bounced",
"created_at": "2022-08-08T13:51:52.747000Z",
"email": {
"status": "rejected",
"tags": None,
"message": {
"id": "62fb66bef54a112e920b5493",
},
"recipient": {
"email": "notauser@example.com",
},
},
"morph": {
"object": "recipient_bounce",
"reason": "Unknown reason",
},
},
}
response = self.client_post_signed("/anymail/mailersend/tracking/", raw_event)
self.assertEqual(response.status_code, 200)
kwargs = self.assert_handler_called_once_with(
self.tracking_handler,
sender=MailerSendTrackingWebhookView,
event=ANY,
esp_name="MailerSend",
)
event = kwargs["event"]
self.assertEqual(event.event_type, "bounced")
self.assertEqual(event.recipient, "notauser@example.com")
self.assertEqual(event.reject_reason, "bounced")
self.assertEqual(event.description, "Unknown reason")
self.assertIsNone(event.mta_response) # raw MTA info not provided
def test_spam_complaint_event(self):
raw_event = {
"type": "activity.spam_complaint",
"data": {
"id": "62f114f8165fe0d8db0288e5",
"type": "spam_complaint",
"created_at": "2022-08-08T13:51:52.747000Z",
"email": {
"status": "delivered",
"message": {
"id": "62fb66bef54a112e920b5493",
},
"recipient": {
"email": "recipient@example.com",
},
},
"morph": {
"object": "spam_complaint",
"reason": None,
},
},
}
response = self.client_post_signed("/anymail/mailersend/tracking/", raw_event)
self.assertEqual(response.status_code, 200)
kwargs = self.assert_handler_called_once_with(
self.tracking_handler,
sender=MailerSendTrackingWebhookView,
event=ANY,
esp_name="MailerSend",
)
event = kwargs["event"]
self.assertEqual(event.event_type, "complained")
self.assertEqual(event.recipient, "recipient@example.com")
def test_unsubscribed_event(self):
raw_event = {
"type": "activity.unsubscribed",
"data": {
"id": "63fd21c23f2bdd360e07d6b2",
"type": "unsubscribed",
"created_at": "2023-02-27T21:33:54.791000Z",
"email": {
"status": "delivered",
"message": {
"id": "63fd1c1d5f010335ed07066b",
},
"recipient": {
"email": "recipient@example.com",
},
},
"morph": {
"object": "recipient_unsubscribe",
"reason": "option_3",
"readable_reason": "I get too many emails",
},
},
}
response = self.client_post_signed("/anymail/mailersend/tracking/", raw_event)
self.assertEqual(response.status_code, 200)
kwargs = self.assert_handler_called_once_with(
self.tracking_handler,
sender=MailerSendTrackingWebhookView,
event=ANY,
esp_name="MailerSend",
)
event = kwargs["event"]
self.assertEqual(event.event_type, "unsubscribed")
self.assertEqual(event.recipient, "recipient@example.com")
self.assertEqual(event.description, "I get too many emails")
def test_opened_event(self):
raw_event = {
"type": "activity.opened",
"data": {
"id": "63fd1e05532bd6cc700a793b",
"type": "opened",
"created_at": "2023-02-27T21:17:57.025000Z",
"email": {
"status": "delivered",
"message": {
"id": "63fd1c1d5f010335ed07066b",
},
"recipient": {
"email": "recipient@example.com",
},
},
"morph": {
"object": "open",
"id": "63fd1e05532bd6cc700a793a",
"created_at": "2023-02-27T21:17:57.018000Z",
"ip": "10.10.10.10",
},
},
}
response = self.client_post_signed("/anymail/mailersend/tracking/", raw_event)
self.assertEqual(response.status_code, 200)
kwargs = self.assert_handler_called_once_with(
self.tracking_handler,
sender=MailerSendTrackingWebhookView,
event=ANY,
esp_name="MailerSend",
)
event = kwargs["event"]
self.assertEqual(event.event_type, "opened")
self.assertEqual(event.recipient, "recipient@example.com")
def test_clicked_event(self):
raw_event = {
"type": "activity.clicked",
"data": {
"id": "63fd1d23afa3c770b00da7d3",
"type": "clicked",
"created_at": "2023-02-27T21:14:11.691000Z",
"email": {
"status": "delivered",
"message": {
"id": "63fd1c1d5f010335ed07066b",
},
"recipient": {
"email": "recipient@example.com",
},
},
"morph": {
"object": "click",
"id": "63fd1d23afa3c770b00da7d2",
"created_at": "2023-02-27T21:14:11.679000Z",
"ip": "10.10.10.10",
"url": "https://example.com/test",
},
},
}
response = self.client_post_signed("/anymail/mailersend/tracking/", raw_event)
self.assertEqual(response.status_code, 200)
kwargs = self.assert_handler_called_once_with(
self.tracking_handler,
sender=MailerSendTrackingWebhookView,
event=ANY,
esp_name="MailerSend",
)
event = kwargs["event"]
self.assertEqual(event.event_type, "clicked")
self.assertEqual(event.recipient, "recipient@example.com")
self.assertEqual(event.click_url, "https://example.com/test")
def test_misconfigured_inbound(self):
errmsg = (
"You seem to have set MailerSend's *inbound* route endpoint"
" to Anymail's MailerSend *activity tracking* webhook URL."
)
with self.assertRaisesMessage(AnymailConfigurationError, errmsg):
self.client_post_signed(
"/anymail/mailersend/tracking/",
{
"type": "inbound.message",
"data": {"object": "message", "raw": "..."},
},
)

View File

@@ -49,6 +49,7 @@ setenv =
# tell runtests.py to limit some test tags based on extras factor # tell runtests.py to limit some test tags based on extras factor
none: ANYMAIL_SKIP_TESTS=amazon_ses,postal none: ANYMAIL_SKIP_TESTS=amazon_ses,postal
amazon_ses: ANYMAIL_ONLY_TEST=amazon_ses amazon_ses: ANYMAIL_ONLY_TEST=amazon_ses
mailersend: ANYMAIL_ONLY_TEST=mailersend
mailgun: ANYMAIL_ONLY_TEST=mailgun mailgun: ANYMAIL_ONLY_TEST=mailgun
mailjet: ANYMAIL_ONLY_TEST=mailjet mailjet: ANYMAIL_ONLY_TEST=mailjet
mandrill: ANYMAIL_ONLY_TEST=mandrill mandrill: ANYMAIL_ONLY_TEST=mandrill