mirror of
https://github.com/pacnpal/django-anymail.git
synced 2025-12-19 19:31:06 -05:00
3
.github/workflows/integration-test.yml
vendored
3
.github/workflows/integration-test.yml
vendored
@@ -40,6 +40,7 @@ jobs:
|
||||
# combination, to avoid rapidly consuming the testing accounts' entire send allotments.
|
||||
config:
|
||||
- { 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-mailjet, 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_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_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_DOMAIN: ${{ secrets.ANYMAIL_TEST_MAILGUN_DOMAIN }}
|
||||
ANYMAIL_TEST_MAILJET_API_KEY: ${{ secrets.ANYMAIL_TEST_MAILJET_API_KEY }}
|
||||
|
||||
@@ -38,6 +38,11 @@ Deprecations
|
||||
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>`__.
|
||||
|
||||
Features
|
||||
~~~~~~~~
|
||||
* **MailerSend:** Add support for this ESP
|
||||
(`docs <https://anymail.dev/en/latest/esps/mailersend/>`__).
|
||||
|
||||
Other
|
||||
~~~~~
|
||||
* Test against Django 4.2 prerelease, Python 3.11 (with Django 4.2),
|
||||
|
||||
@@ -28,6 +28,7 @@ a consistent API that avoids locking your code to one specific ESP
|
||||
Anymail currently supports these ESPs:
|
||||
|
||||
* **Amazon SES**
|
||||
* **MailerSend**
|
||||
* **Mailgun**
|
||||
* **Mailjet**
|
||||
* **Mandrill** (MailChimp transactional)
|
||||
|
||||
338
anymail/backends/mailersend.py
Normal file
338
anymail/backends/mailersend.py
Normal 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
|
||||
@@ -4,6 +4,10 @@ from .webhooks.amazon_ses import (
|
||||
AmazonSESInboundWebhookView,
|
||||
AmazonSESTrackingWebhookView,
|
||||
)
|
||||
from .webhooks.mailersend import (
|
||||
MailerSendInboundWebhookView,
|
||||
MailerSendTrackingWebhookView,
|
||||
)
|
||||
from .webhooks.mailgun import MailgunInboundWebhookView, MailgunTrackingWebhookView
|
||||
from .webhooks.mailjet import MailjetInboundWebhookView, MailjetTrackingWebhookView
|
||||
from .webhooks.mandrill import MandrillCombinedWebhookView
|
||||
@@ -23,6 +27,11 @@ urlpatterns = [
|
||||
AmazonSESInboundWebhookView.as_view(),
|
||||
name="amazon_ses_inbound_webhook",
|
||||
),
|
||||
path(
|
||||
"mailersend/inbound/",
|
||||
MailerSendInboundWebhookView.as_view(),
|
||||
name="mailersend_inbound_webhook",
|
||||
),
|
||||
re_path(
|
||||
# Mailgun delivers inbound messages differently based on whether
|
||||
# the webhook url contains "mime" (anywhere). You can use either
|
||||
@@ -62,6 +71,11 @@ urlpatterns = [
|
||||
AmazonSESTrackingWebhookView.as_view(),
|
||||
name="amazon_ses_tracking_webhook",
|
||||
),
|
||||
path(
|
||||
"mailersend/tracking/",
|
||||
MailerSendTrackingWebhookView.as_view(),
|
||||
name="mailersend_tracking_webhook",
|
||||
),
|
||||
path(
|
||||
"mailgun/tracking/",
|
||||
MailgunTrackingWebhookView.as_view(),
|
||||
|
||||
209
anymail/webhooks/mailersend.py
Normal file
209
anymail/webhooks/mailersend.py
Normal 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,
|
||||
)
|
||||
@@ -13,6 +13,7 @@ and notes about any quirks or limitations:
|
||||
:maxdepth: 1
|
||||
|
||||
amazon_ses
|
||||
mailersend
|
||||
mailgun
|
||||
mailjet
|
||||
mandrill
|
||||
@@ -32,35 +33,35 @@ The table below summarizes the Anymail features supported for each ESP.
|
||||
|
||||
.. 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>`
|
||||
---------------------------------------------------------------------------------------------------------------------------------------------------------------
|
||||
:attr:`~AnymailMessage.envelope_sender` Yes Domain only Yes Domain only Yes No No No Yes
|
||||
:attr:`~AnymailMessage.metadata` Yes Yes Yes Yes No Yes Yes Yes Yes
|
||||
:attr:`~AnymailMessage.merge_metadata` No Yes Yes Yes No Yes Yes No Yes
|
||||
:attr:`~AnymailMessage.send_at` No 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.track_clicks` No Yes Yes Yes No Yes Yes No Yes
|
||||
:attr:`~AnymailMessage.track_opens` No Yes Yes Yes No Yes Yes No Yes
|
||||
:ref:`amp-email` Yes Yes No No No No Yes No Yes
|
||||
-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------
|
||||
:attr:`~AnymailMessage.envelope_sender` Yes No Domain only Yes Domain only Yes No No No Yes
|
||||
:attr:`~AnymailMessage.metadata` Yes No Yes Yes Yes No Yes Yes Yes Yes
|
||||
:attr:`~AnymailMessage.merge_metadata` No No Yes Yes Yes No Yes Yes No Yes
|
||||
:attr:`~AnymailMessage.send_at` No Yes Yes No Yes No No Yes Yes Yes
|
||||
: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 Yes No Yes Yes No Yes
|
||||
:attr:`~AnymailMessage.track_opens` No Yes Yes Yes Yes No Yes Yes No Yes
|
||||
:ref:`amp-email` Yes No Yes No No No No Yes No Yes
|
||||
|
||||
.. rubric:: :ref:`templates-and-merge`
|
||||
---------------------------------------------------------------------------------------------------------------------------------------------------------------
|
||||
:attr:`~AnymailMessage.template_id` 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_global_data` Yes (emulated) 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 Yes No Yes Yes No 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>`
|
||||
---------------------------------------------------------------------------------------------------------------------------------------------------------------
|
||||
:attr:`~AnymailMessage.anymail_status` Yes Yes Yes Yes Yes Yes Yes Yes Yes
|
||||
|AnymailTrackingEvent| from webhooks 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 Yes
|
||||
|
||||
.. 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
|
||||
@@ -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.)
|
||||
|
||||
.. |Amazon SES| replace:: :ref:`amazon-ses-backend`
|
||||
.. |MailerSend| replace:: :ref:`mailersend-backend`
|
||||
.. |Mailgun| replace:: :ref:`mailgun-backend`
|
||||
.. |Mailjet| replace:: :ref:`mailjet-backend`
|
||||
.. |Mandrill| replace:: :ref:`mandrill-backend`
|
||||
|
||||
546
docs/esps/mailersend.rst
Normal file
546
docs/esps/mailersend.rst
Normal 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
|
||||
7
setup.py
7
setup.py
@@ -56,12 +56,12 @@ setup(
|
||||
name="django-anymail",
|
||||
version=version,
|
||||
description=(
|
||||
"Django email backends and webhooks for Amazon SES, Mailgun, Mailjet,"
|
||||
" Mandrill, Postal, Postmark, SendGrid, SendinBlue, and SparkPost"
|
||||
"Django email backends and webhooks for Amazon SES, MailerSend, Mailgun,"
|
||||
" Mailjet, Mandrill, Postal, Postmark, SendGrid, SendinBlue, and SparkPost"
|
||||
),
|
||||
keywords=(
|
||||
"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"
|
||||
),
|
||||
author="Mike Edmunds and Anymail contributors",
|
||||
@@ -76,6 +76,7 @@ setup(
|
||||
# This can be used if particular backends have unique dependencies.
|
||||
# For simplicity, requests is included in the base requirements.
|
||||
"amazon_ses": ["boto3"],
|
||||
"mailersend": [],
|
||||
"mailgun": [],
|
||||
"mailjet": [],
|
||||
"mandrill": [],
|
||||
|
||||
@@ -17,6 +17,7 @@ class RequestsBackendMockAPITestCase(AnymailTestMixin, SimpleTestCase):
|
||||
"""TestCase that mocks API calls through requests"""
|
||||
|
||||
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
|
||||
|
||||
class MockResponse(requests.Response):
|
||||
@@ -26,6 +27,7 @@ class RequestsBackendMockAPITestCase(AnymailTestMixin, SimpleTestCase):
|
||||
self,
|
||||
status_code=200,
|
||||
raw=b"RESPONSE",
|
||||
content_type=None,
|
||||
encoding="utf-8",
|
||||
reason=None,
|
||||
test_case=None,
|
||||
@@ -35,6 +37,8 @@ class RequestsBackendMockAPITestCase(AnymailTestMixin, SimpleTestCase):
|
||||
self.encoding = encoding
|
||||
self.reason = reason or ("OK" if 200 <= status_code < 300 else "ERROR")
|
||||
self.raw = BytesIO(raw)
|
||||
if content_type is not None:
|
||||
self.headers["Content-Type"] = content_type
|
||||
self.test_case = test_case
|
||||
|
||||
@property
|
||||
@@ -54,12 +58,32 @@ class RequestsBackendMockAPITestCase(AnymailTestMixin, SimpleTestCase):
|
||||
self.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:
|
||||
raw = self.DEFAULT_RAW_RESPONSE
|
||||
if content_type is UNSET:
|
||||
content_type = self.DEFAULT_CONTENT_TYPE
|
||||
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
|
||||
return mock_response
|
||||
|
||||
920
tests/test_mailersend_backend.py
Normal file
920
tests/test_mailersend_backend.py
Normal 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()
|
||||
353
tests/test_mailersend_inbound.py
Normal file
353
tests/test_mailersend_inbound.py
Normal 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"},
|
||||
},
|
||||
)
|
||||
184
tests/test_mailersend_integration.py
Normal file
184
tests/test_mailersend_integration.py
Normal 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()
|
||||
465
tests/test_mailersend_webhooks.py
Normal file
465
tests/test_mailersend_webhooks.py
Normal 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": "..."},
|
||||
},
|
||||
)
|
||||
1
tox.ini
1
tox.ini
@@ -49,6 +49,7 @@ setenv =
|
||||
# tell runtests.py to limit some test tags based on extras factor
|
||||
none: ANYMAIL_SKIP_TESTS=amazon_ses,postal
|
||||
amazon_ses: ANYMAIL_ONLY_TEST=amazon_ses
|
||||
mailersend: ANYMAIL_ONLY_TEST=mailersend
|
||||
mailgun: ANYMAIL_ONLY_TEST=mailgun
|
||||
mailjet: ANYMAIL_ONLY_TEST=mailjet
|
||||
mandrill: ANYMAIL_ONLY_TEST=mandrill
|
||||
|
||||
Reference in New Issue
Block a user