From 62bd0669aff5b0fe158c0fa07088db6c702dc11e Mon Sep 17 00:00:00 2001 From: Mike Edmunds Date: Fri, 10 Mar 2023 17:22:20 -0800 Subject: [PATCH] MailerSend: add support (new ESP for Anymail) Closes #298 --- .github/workflows/integration-test.yml | 3 + CHANGELOG.rst | 5 + README.rst | 1 + anymail/backends/mailersend.py | 338 +++++++++ anymail/urls.py | 14 + anymail/webhooks/mailersend.py | 209 ++++++ docs/esps/index.rst | 46 +- docs/esps/mailersend.rst | 546 +++++++++++++++ setup.py | 7 +- tests/mock_requests_backend.py | 28 +- tests/test_mailersend_backend.py | 920 +++++++++++++++++++++++++ tests/test_mailersend_inbound.py | 353 ++++++++++ tests/test_mailersend_integration.py | 184 +++++ tests/test_mailersend_webhooks.py | 465 +++++++++++++ tox.ini | 1 + 15 files changed, 3093 insertions(+), 27 deletions(-) create mode 100644 anymail/backends/mailersend.py create mode 100644 anymail/webhooks/mailersend.py create mode 100644 docs/esps/mailersend.rst create mode 100644 tests/test_mailersend_backend.py create mode 100644 tests/test_mailersend_inbound.py create mode 100644 tests/test_mailersend_integration.py create mode 100644 tests/test_mailersend_webhooks.py diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index b4599b9..3c1b0cb 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -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 }} diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 88096b4..e1cc8cf 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -38,6 +38,11 @@ Deprecations require code changes, but you will need to update your IAM permissions. See `Migrating to the SES v2 API `__. +Features +~~~~~~~~ +* **MailerSend:** Add support for this ESP + (`docs `__). + Other ~~~~~ * Test against Django 4.2 prerelease, Python 3.11 (with Django 4.2), diff --git a/README.rst b/README.rst index b93e180..9add0e2 100644 --- a/README.rst +++ b/README.rst @@ -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) diff --git a/anymail/backends/mailersend.py b/anymail/backends/mailersend.py new file mode 100644 index 0000000..5554066 --- /dev/null +++ b/anymail/backends/mailersend.py @@ -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 diff --git a/anymail/urls.py b/anymail/urls.py index eace41e..083708d 100644 --- a/anymail/urls.py +++ b/anymail/urls.py @@ -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(), diff --git a/anymail/webhooks/mailersend.py b/anymail/webhooks/mailersend.py new file mode 100644 index 0000000..a89439f --- /dev/null +++ b/anymail/webhooks/mailersend.py @@ -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, + ) diff --git a/docs/esps/index.rst b/docs/esps/index.rst index 9d3c7b6..d4617ee 100644 --- a/docs/esps/index.rst +++ b/docs/esps/index.rst @@ -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 ` ---------------------------------------------------------------------------------------------------------------------------------------------------------------- -: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 ` and :ref:`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 ` ---------------------------------------------------------------------------------------------------------------------------------------------------------------- -|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` diff --git a/docs/esps/mailersend.rst b/docs/esps/mailersend.rst new file mode 100644 index 0000000..531e977 --- /dev/null +++ b/docs/esps/mailersend.rst @@ -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": "", + } + +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 `. (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 `"), 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 ` 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": "", + } + +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": "", + } + +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 ` for an individual +message by providing ``"api-token"``, and +:setting:`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 `, on-the-fly +templating, and :ref:`batch sending ` 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 "] + ) + # (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 "] + ) + 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 ` 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 ` setting---or +as ``"batch-send-mode"`` in the message's :ref:`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 ` + will be ``{"unknown"}``, because MailerSend detects errors and rejected + recipients at a later time. + +* The + :attr:`message.anymail_status.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 ` + 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 `, +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 `, 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 ` + so that Anymail can verify calls to the webhook: + + .. code-block:: python + + ANYMAIL = { + # ... + MAILERSEND_SIGNING_SECRET = "" + } + +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 ` 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 `, 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 ` + so that Anymail can verify calls to the inbound endpoint: + + .. code-block:: python + + ANYMAIL = { + # ... + MAILERSEND_INBOUND_SECRET = "" + } + + Note that this is a *different* secret from the + :setting:`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 diff --git a/setup.py b/setup.py index a0dcc88..cb13f68 100644 --- a/setup.py +++ b/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": [], diff --git a/tests/mock_requests_backend.py b/tests/mock_requests_backend.py index e63f54f..881198a 100644 --- a/tests/mock_requests_backend.py +++ b/tests/mock_requests_backend.py @@ -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 diff --git a/tests/test_mailersend_backend.py b/tests/test_mailersend_backend.py new file mode 100644 index 0000000..3bf23a8 --- /dev/null +++ b/tests/test_mailersend_backend.py @@ -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 ", + ["Recipient #1 ", "to2@example.com"], + cc=["Carbon Copy ", "cc2@example.com"], + bcc=["Blind Copy ", "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 = "

This is an important message.

" + 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 = "

This is an important message.

" + 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 "], + ) + 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", "

\u2019

", 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 = ( + '

This has an inline image.

' % 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("

First html is OK

", "text/html") + self.message.attach_alternative("

But not second html

", "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 "] + 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 "] + 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 "] + 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 "] + 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 "] + 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() diff --git a/tests/test_mailersend_inbound.py b/tests/test_mailersend_inbound.py new file mode 100644 index 0000000..413c7a9 --- /dev/null +++ b/tests/test_mailersend_inbound.py @@ -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 ", + "data": [{"email": "to@example.com", "name": "Recipient"}], + }, + }, + "from": { + "email": "sender@example.org", + "name": "Sender Name", + "raw": "Sender Name ", + }, + "sender": {"email": "envelope-sender@example.org"}, + "subject": "Testing inbound \ud83c\udf0e", + "date": "Fri, 3 Mar 2023 18:22:03 -0800", + "headers": { + "X-Envelope-From": "", + # 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 ;\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 ", + "Date": "Fri, 3 Mar 2023 18:22:03 -0800", + "Message-ID": "", + "Subject": "=?UTF-8?Q?Testing_inbound_=F0=9F=8C=8E?=", + "To": "Recipient ", + "Content-Type": 'multipart/mixed; boundary="000000000000e5575c05f609bab6"', + }, + "text": "This is a *test*!\r\n\r\n[image: sample_image.png]\r\n", + "html": ( + "

This is a test!

" + 'sample_image.png' + ), + "raw": dedent( + """\ + X-Envelope-From: + 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 ; + 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 + Date: Fri, 3 Mar 2023 18:22:03 -0800 + Message-ID: + Subject: =?UTF-8?Q?Testing_inbound_=F0=9F=8C=8E?= + To: Recipient + 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" + +

This is a test!

+ 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: + + 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 ") + 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, + "

This is a test!

" + '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"], "") + 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 ;" + " 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"}, + }, + ) diff --git a/tests/test_mailersend_integration.py b/tests/test_mailersend_integration.py new file mode 100644 index 0000000..1b804d2 --- /dev/null +++ b/tests/test_mailersend_integration.py @@ -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("

HTML content

", "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+bounce@anymail.dev", # will be rejected + ], + cc=["test+cc1@anymail.dev", "Copy 2 "], + bcc=["test+bcc1@anymail.dev", "Blind Copy 2 "], + # MailerSend only supports single reply_to: + reply_to=["Reply "], + # 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"
This is the html body
", + "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() diff --git a/tests/test_mailersend_webhooks.py b/tests/test_mailersend_webhooks.py new file mode 100644 index 0000000..b04f8df --- /dev/null +++ b/tests/test_mailersend_webhooks.py @@ -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": "..."}, + }, + ) diff --git a/tox.ini b/tox.ini index f6b2eb0..e889b3b 100644 --- a/tox.ini +++ b/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