From a71a0d9af8d22f66264bebb4fe77baa9a1bdb681 Mon Sep 17 00:00:00 2001 From: Arondit <57594292+Arondit@users.noreply.github.com> Date: Tue, 5 Mar 2024 22:38:40 +0300 Subject: [PATCH] Unisender Go: new ESP Add support for Unisender Go --------- Co-authored-by: Mike Edmunds --- .github/workflows/integration-test.yml | 5 + CHANGELOG.rst | 6 + README.rst | 1 + anymail/backends/unisender_go.py | 343 ++++++++++ anymail/urls.py | 6 + anymail/webhooks/unisender_go.py | 123 ++++ docs/esps/esp-feature-matrix.csv | 38 +- docs/esps/index.rst | 1 + docs/esps/unisender_go.rst | 426 ++++++++++++ pyproject.toml | 4 +- tests/test_unisender_go_backend.py | 895 +++++++++++++++++++++++++ tests/test_unisender_go_integration.py | 172 +++++ tests/test_unisender_go_payload.py | 299 +++++++++ tests/test_unisender_go_webhooks.py | 177 +++++ tox.ini | 1 + 15 files changed, 2477 insertions(+), 20 deletions(-) create mode 100644 anymail/backends/unisender_go.py create mode 100644 anymail/webhooks/unisender_go.py create mode 100644 docs/esps/unisender_go.rst create mode 100644 tests/test_unisender_go_backend.py create mode 100644 tests/test_unisender_go_integration.py create mode 100644 tests/test_unisender_go_payload.py create mode 100644 tests/test_unisender_go_webhooks.py diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 77fc93e6..4005d1ac 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -50,6 +50,7 @@ jobs: - { tox: django41-py310-sendgrid, python: "3.10" } - { tox: django41-py310-sendinblue, python: "3.10" } - { tox: django41-py310-sparkpost, python: "3.10" } + - { tox: django41-py310-unisender_go, python: "3.10" } steps: - name: Get code @@ -97,3 +98,7 @@ jobs: ANYMAIL_TEST_SENDINBLUE_DOMAIN: ${{ secrets.ANYMAIL_TEST_SENDINBLUE_DOMAIN }} ANYMAIL_TEST_SPARKPOST_API_KEY: ${{ secrets.ANYMAIL_TEST_SPARKPOST_API_KEY }} ANYMAIL_TEST_SPARKPOST_DOMAIN: ${{ secrets.ANYMAIL_TEST_SPARKPOST_DOMAIN }} + ANYMAIL_TEST_UNISENDER_GO_API_KEY: ${{ secrets.ANYMAIL_TEST_UNISENDER_GO_API_KEY }} + ANYMAIL_TEST_UNISENDER_GO_API_URL: ${{ vars.ANYMAIL_TEST_UNISENDER_GO_API_URL }} + ANYMAIL_TEST_UNISENDER_GO_DOMAIN: ${{ vars.ANYMAIL_TEST_UNISENDER_GO_DOMAIN }} + ANYMAIL_TEST_UNISENDER_GO_TEMPLATE_ID: ${{ vars.ANYMAIL_TEST_UNISENDER_GO_TEMPLATE_ID }} diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 89ac3f1b..f7a9df47 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -35,9 +35,14 @@ Features * **Brevo:** Add support for batch sending (`docs `__). + * **Resend:** Add support for batch sending (`docs `__). +* **Unisender Go**: Add support for this ESP + (`docs `__). + (Thanks to `@Arondit`_ for the implementation.) + v10.2 ----- @@ -1572,6 +1577,7 @@ Features .. _@ailionx: https://github.com/ailionx .. _@alee: https://github.com/alee .. _@anstosa: https://github.com/anstosa +.. _@Arondit: https://github.com/Arondit .. _@b0d0nne11: https://github.com/b0d0nne11 .. _@calvin: https://github.com/calvin .. _@chrisgrande: https://github.com/chrisgrande diff --git a/README.rst b/README.rst index 74e96aa7..11f5b007 100644 --- a/README.rst +++ b/README.rst @@ -37,6 +37,7 @@ Anymail currently supports these ESPs: * **Resend** * **SendGrid** * **SparkPost** +* **Unisender Go** Anymail includes: diff --git a/anymail/backends/unisender_go.py b/anymail/backends/unisender_go.py new file mode 100644 index 00000000..6c9cacb8 --- /dev/null +++ b/anymail/backends/unisender_go.py @@ -0,0 +1,343 @@ +from __future__ import annotations + +import re +import typing +import uuid +from datetime import datetime, timezone +from email.charset import QP, Charset +from email.headerregistry import Address + +from django.core.mail import EmailMessage +from requests import Response +from requests.structures import CaseInsensitiveDict + +from anymail.backends.base_requests import AnymailRequestsBackend, RequestsPayload +from anymail.message import AnymailRecipientStatus +from anymail.utils import Attachment, EmailAddress, get_anymail_setting, update_deep + +# Used to force RFC-2047 encoded word +# in address formatting workaround +QP_CHARSET = Charset("utf-8") +QP_CHARSET.header_encoding = QP + + +class EmailBackend(AnymailRequestsBackend): + """Unisender Go v1 Web API Email Backend""" + + esp_name = "Unisender Go" + + def __init__(self, **kwargs: typing.Any): + """Init options from Django settings""" + esp_name = self.esp_name + + self.api_key = get_anymail_setting( + "api_key", esp_name=esp_name, kwargs=kwargs, allow_bare=True + ) + + self.generate_message_id = get_anymail_setting( + "generate_message_id", esp_name=esp_name, kwargs=kwargs, default=True + ) + + # No default for api_url setting -- it depends on account's data center. E.g.: + # - https://go1.unisender.ru/ru/transactional/api/v1 + # - https://go2.unisender.ru/ru/transactional/api/v1 + api_url = get_anymail_setting("api_url", esp_name=esp_name, kwargs=kwargs) + if not api_url.endswith("/"): + api_url += "/" + + # Undocumented setting to control workarounds for Unisender Go display-name issues + # (see below). If/when Unisender Go fixes the problems, you can disable Anymail's + # workarounds by adding `"UNISENDER_GO_WORKAROUND_DISPLAY_NAME_BUGS": False` + # to your `ANYMAIL` settings. + self.workaround_display_name_bugs = get_anymail_setting( + "workaround_display_name_bugs", + esp_name=esp_name, + kwargs=kwargs, + default=True, + ) + + super().__init__(api_url, **kwargs) + + def build_message_payload( + self, message: EmailMessage, defaults: dict + ) -> UnisenderGoPayload: + return UnisenderGoPayload(message=message, defaults=defaults, backend=self) + + # Map Unisender Go "failed_email" code -> AnymailRecipientStatus.status + _unisender_failure_status = { + # "duplicate": ignored (see parse_recipient_status) + "invalid": "invalid", + "permanent_unavailable": "rejected", + "temporary_unavailable": "failed", + "unsubscribed": "rejected", + } + + def parse_recipient_status( + self, response: Response, payload: UnisenderGoPayload, message: EmailMessage + ) -> dict: + """ + Response example: + { + "status": "success", + "job_id": "1ZymBc-00041N-9X", + "emails": [ + "user@example.com", + "email@example.com", + ], + "failed_emails": { + "email1@gmail.com": "temporary_unavailable", + "bad@address": "invalid", + "email@example.com": "duplicate", + "root@example.org": "permanent_unavailable", + "olduser@example.net": "unsubscribed" + } + } + """ + parsed_response = self.deserialize_json_response(response, payload, message) + # job_id serves as message_id when not self.generate_message_id + job_id = parsed_response.get("job_id") + succeed_emails = { + recipient: AnymailRecipientStatus( + message_id=payload.message_ids.get(recipient, job_id), status="queued" + ) + for recipient in parsed_response["emails"] + } + failed_emails = { + recipient: AnymailRecipientStatus( + # Message wasn't sent to this recipient, so Unisender Go hasn't stored + # any metadata (including message_id) + message_id=None, + status=self._unisender_failure_status.get(status, "failed"), + ) + for recipient, status in parsed_response.get("failed_emails", {}).items() + if status != "duplicate" # duplicates are in both succeed and failed lists + } + return {**succeed_emails, **failed_emails} + + +class UnisenderGoPayload(RequestsPayload): + # Payload: see https://godocs.unisender.ru/web-api-ref#email-send + + data: dict + + def __init__( + self, + message: EmailMessage, + defaults: dict, + backend: EmailBackend, + *args: typing.Any, + **kwargs: typing.Any, + ): + self.generate_message_id = backend.generate_message_id + self.message_ids = CaseInsensitiveDict() # recipient -> generated message_id + + http_headers = kwargs.pop("headers", {}) + http_headers["Content-Type"] = "application/json" + http_headers["Accept"] = "application/json" + http_headers["X-API-KEY"] = backend.api_key + super().__init__( + message, defaults, backend, headers=http_headers, *args, **kwargs + ) + + def get_api_endpoint(self) -> str: + return "email/send.json" + + def init_payload(self) -> None: + self.data = { # becomes json + "headers": CaseInsensitiveDict(), + "recipients": [], + } + + def serialize_data(self) -> str: + if self.generate_message_id: + self.set_anymail_id() + + headers = self.data["headers"] + if self.is_batch(): + # Remove the all-recipient "to" header for batch sends. + # Unisender Go will construct a single-recipient "to" for each recipient. + # Unisender Go doesn't allow a "cc" header without an explicit "to" + # header, so we cannot support "cc" for batch sends. + headers.pop("to", None) + if headers.pop("cc", None): + self.unsupported_feature( + "cc with batch send (merge_data or merge_metadata)" + ) + + if not headers: + del self.data["headers"] # don't send empty headers + + return self.serialize_json({"message": self.data}) + + def set_anymail_id(self) -> None: + """Ensure each personalization has a known anymail_id for event tracking""" + for recipient in self.data["recipients"]: + # This ensures duplicate recipients get same anymail_id + # (because Unisender Go only sends to first instance of duplicate) + email_address = recipient["email"] + anymail_id = self.message_ids.get(email_address) or str(uuid.uuid4()) + recipient.setdefault("metadata", {})["anymail_id"] = anymail_id + self.message_ids[email_address] = anymail_id + + # + # Payload construction + # + + def set_from_email(self, email: EmailAddress) -> None: + self.data["from_email"] = email.addr_spec + if email.display_name: + self.data["from_name"] = email.display_name + + def _format_email_address(self, address): + """ + Return EmailAddress address formatted for use with Unisender Go to/cc headers. + + Works around a bug in Unisender Go's API that rejects To or Cc headers + containing commas, angle brackets, or @ in any display-name, despite those + names being properly enclosed in "quotes" per RFC 5322. Workaround substitutes + an RFC 2047 encoded word, which avoids the problem characters. + + Note that parens, quote chars, and other special characters appearing + in "quoted strings" don't cause problems. (Unisender Go tech support + has confirmed the problem is limited to , < > @.) + + This workaround is only necessary in the To and Cc headers. Unisender Go + properly formats commas and other characters in `to_name` and `from_name`. + (But see set_reply_to for a related issue.) + """ + formatted = address.address + if self.backend.workaround_display_name_bugs: + # Workaround: force RFC-2047 QP encoded word for display_name if it has + # prohibited chars (and isn't already encoded in the formatted address) + display_name = address.display_name + if re.search(r"[,<>@]", display_name) and display_name in formatted: + formatted = str( + Address( + display_name=QP_CHARSET.header_encode(address.display_name), + addr_spec=address.addr_spec, + ) + ) + return formatted + + def set_recipients(self, recipient_type: str, emails: list[EmailAddress]): + for email in emails: + recipient = {"email": email.addr_spec} + if email.display_name: + recipient["substitutions"] = {"to_name": email.display_name} + self.data["recipients"].append(recipient) + + if emails and recipient_type in {"to", "cc"}: + # Add "to" or "cc" header listing all recipients of type. + # See https://godocs.unisender.ru/cc-and-bcc. + # (For batch sends, these will be adjusted later in self.serialize_data.) + self.data["headers"][recipient_type] = ", ".join( + self._format_email_address(email) for email in emails + ) + + def set_subject(self, subject: str) -> None: + if subject: + self.data["subject"] = subject + + def set_reply_to(self, emails: list[EmailAddress]) -> None: + # Unisender Go only supports a single address in the reply_to API param. + if len(emails) > 1: + self.unsupported_feature("multiple reply_to addresses") + if len(emails) > 0: + reply_to = emails[0] + self.data["reply_to"] = reply_to.addr_spec + display_name = reply_to.display_name + if display_name: + if self.backend.workaround_display_name_bugs: + # Unisender Go doesn't properly "quote" (RFC 5322) a `reply_to_name` + # containing special characters (comma, parens, etc.), resulting + # in an invalid Reply-To header that can cause problems when the + # recipient tries to reply. (They *do* properly handle special chars + # in `to_name` and `from_name`; this only affects `reply_to_name`.) + if reply_to.address.startswith('"'): # requires quoted syntax + # Workaround: force RFC-2047 encoded word + display_name = QP_CHARSET.header_encode(display_name) + self.data["reply_to_name"] = display_name + + def set_extra_headers(self, headers: dict[str, str]) -> None: + self.data["headers"].update(headers) + + def set_text_body(self, body: str) -> None: + if body: + self.data.setdefault("body", {})["plaintext"] = body + + def set_html_body(self, body: str) -> None: + if body: + self.data.setdefault("body", {})["html"] = body + + def add_alternative(self, content: str, mimetype: str): + if mimetype.lower() == "text/x-amp-html": + if "amp" in self.data.get("body", {}): + self.unsupported_feature("multiple amp-html parts") + self.data.setdefault("body", {})["amp"] = content + else: + super().add_alternative(content, mimetype) + + def add_attachment(self, attachment: Attachment) -> None: + name = attachment.cid if attachment.inline else attachment.name + att = { + "content": attachment.b64content, + "type": attachment.mimetype, + "name": name or "", # required - submit empty string if unknown + } + if attachment.inline: + self.data.setdefault("inline_attachments", []).append(att) + else: + self.data.setdefault("attachments", []).append(att) + + def set_metadata(self, metadata: dict[str, str]) -> None: + self.data["global_metadata"] = metadata + + def set_send_at(self, send_at: datetime | str) -> None: + try: + # "Date and time in the format “YYYY-MM-DD hh:mm:ss” in the UTC time zone." + # If send_at is a datetime, it's guaranteed to be aware, but maybe not UTC. + # Convert to UTC, then strip tzinfo to avoid isoformat "+00:00" at end. + send_at_utc = send_at.astimezone(timezone.utc).replace(tzinfo=None) + send_at_formatted = send_at_utc.isoformat(sep=" ", timespec="seconds") + assert len(send_at_formatted) == 19 + except (AttributeError, TypeError): + # Not a datetime - caller is responsible for formatting + send_at_formatted = send_at + self.data.setdefault("options", {})["send_at"] = send_at_formatted + + def set_tags(self, tags: list[str]) -> None: + self.data["tags"] = tags + + def set_track_clicks(self, track_clicks: typing.Any): + self.data["track_links"] = 1 if track_clicks else 0 + + def set_track_opens(self, track_opens: typing.Any): + self.data["track_read"] = 1 if track_opens else 0 + + def set_template_id(self, template_id: str) -> None: + self.data["template_id"] = template_id + + def set_merge_data(self, merge_data: dict[str, dict[str, str]]) -> None: + if not merge_data: + return + assert self.data["recipients"] # must be called after set_to + for recipient in self.data["recipients"]: + recipient_email = recipient["email"] + if recipient_email in merge_data: + # (substitutions may already be present with "to_email") + recipient.setdefault("substitutions", {}).update( + merge_data[recipient_email] + ) + + def set_merge_global_data(self, merge_global_data: dict[str, str]) -> None: + self.data["global_substitutions"] = merge_global_data + + def set_merge_metadata(self, merge_metadata: dict[str, str]) -> None: + assert self.data["recipients"] # must be called after set_to + for recipient in self.data["recipients"]: + recipient_email = recipient["email"] + if recipient_email in merge_metadata: + recipient["metadata"] = merge_metadata[recipient_email] + + def set_esp_extra(self, extra: dict) -> None: + update_deep(self.data, extra) diff --git a/anymail/urls.py b/anymail/urls.py index b35cc5a2..28647b4e 100644 --- a/anymail/urls.py +++ b/anymail/urls.py @@ -23,6 +23,7 @@ SparkPostInboundWebhookView, SparkPostTrackingWebhookView, ) +from .webhooks.unisender_go import UnisenderGoTrackingWebhookView app_name = "anymail" urlpatterns = [ @@ -125,6 +126,11 @@ SparkPostTrackingWebhookView.as_view(), name="sparkpost_tracking_webhook", ), + path( + "unisender_go/tracking/", + UnisenderGoTrackingWebhookView.as_view(), + name="unisender_go_tracking_webhook", + ), # Anymail uses a combined Mandrill webhook endpoint, # to simplify Mandrill's key-validation scheme: path("mandrill/", MandrillCombinedWebhookView.as_view(), name="mandrill_webhook"), diff --git a/anymail/webhooks/unisender_go.py b/anymail/webhooks/unisender_go.py new file mode 100644 index 00000000..52d9e831 --- /dev/null +++ b/anymail/webhooks/unisender_go.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import json +import typing +from datetime import datetime, timezone +from hashlib import md5 + +from django.http import HttpRequest, HttpResponse +from django.utils.crypto import constant_time_compare + +from anymail.exceptions import AnymailWebhookValidationFailure +from anymail.signals import AnymailTrackingEvent, EventType, RejectReason, tracking +from anymail.utils import get_anymail_setting +from anymail.webhooks.base import AnymailCoreWebhookView + + +class UnisenderGoTrackingWebhookView(AnymailCoreWebhookView): + """Handler for UniSender delivery and engagement tracking webhooks""" + + # See https://godocs.unisender.ru/web-api-ref#callback-format for webhook payload + + esp_name = "Unisender Go" + signal = tracking + warn_if_no_basic_auth = False # because we validate against signature + + event_types = { + "sent": EventType.SENT, + "delivered": EventType.DELIVERED, + "opened": EventType.OPENED, + "clicked": EventType.CLICKED, + "unsubscribed": EventType.UNSUBSCRIBED, + "subscribed": EventType.SUBSCRIBED, + "spam": EventType.COMPLAINED, + "soft_bounced": EventType.BOUNCED, + "hard_bounced": EventType.BOUNCED, + } + + reject_reasons = { + "err_user_unknown": RejectReason.BOUNCED, + "err_user_inactive": RejectReason.BOUNCED, + "err_will_retry": RejectReason.BOUNCED, + "err_mailbox_discarded": RejectReason.BOUNCED, + "err_mailbox_full": RejectReason.BOUNCED, + "err_spam_rejected": RejectReason.SPAM, + "err_blacklisted": RejectReason.BLOCKED, + "err_too_large": RejectReason.BOUNCED, + "err_unsubscribed": RejectReason.UNSUBSCRIBED, + "err_unreachable": RejectReason.BOUNCED, + "err_skip_letter": RejectReason.BOUNCED, + "err_domain_inactive": RejectReason.BOUNCED, + "err_destination_misconfigured": RejectReason.BOUNCED, + "err_delivery_failed": RejectReason.OTHER, + "err_spam_skipped": RejectReason.SPAM, + "err_lost": RejectReason.OTHER, + } + + http_method_names = ["post", "head", "options", "get"] + + def get( + self, request: HttpRequest, *args: typing.Any, **kwargs: typing.Any + ) -> HttpResponse: + # Unisender Go verifies the webhook with a GET request at configuration time + return HttpResponse() + + def validate_request(self, request: HttpRequest) -> None: + """ + How Unisender GO authenticate: + Hash the whole request body text and replace api key in "auth" field by this hash. + + So it is both auth and encryption. Also, they hash JSON without spaces. + """ + request_json = json.loads(request.body.decode("utf-8")) + request_auth = request_json.get("auth", "") + request_json["auth"] = get_anymail_setting( + "api_key", esp_name=self.esp_name, allow_bare=True + ) + json_with_key = json.dumps(request_json, separators=(",", ":")) + + expected_auth = md5(json_with_key.encode("utf-8")).hexdigest() + + if not constant_time_compare(request_auth, expected_auth): + raise AnymailWebhookValidationFailure( + "Unisender Go webhook called with incorrect signature" + ) + + def parse_events(self, request: HttpRequest) -> list[AnymailTrackingEvent]: + request_json = json.loads(request.body.decode("utf-8")) + assert len(request_json["events_by_user"]) == 1 # per API docs + esp_events = request_json["events_by_user"][0]["events"] + return [ + self.esp_to_anymail_event(esp_event) + for esp_event in esp_events + if esp_event["event_name"] == "transactional_email_status" + ] + + def esp_to_anymail_event(self, esp_event: dict) -> AnymailTrackingEvent: + event_data = esp_event["event_data"] + event_type = self.event_types.get(event_data["status"], EventType.UNKNOWN) + timestamp = datetime.fromisoformat(event_data["event_time"]) + timestamp_utc = timestamp.replace(tzinfo=timezone.utc) + metadata = event_data.get("metadata", {}) + message_id = metadata.pop("anymail_id", event_data.get("job_id")) + + delivery_info = event_data.get("delivery_info", {}) + delivery_status = delivery_info.get("delivery_status", "") + if delivery_status.startswith("err"): + reject_reason = self.reject_reasons.get(delivery_status, RejectReason.OTHER) + else: + reject_reason = None + + return AnymailTrackingEvent( + event_type=event_type, + timestamp=timestamp_utc, + message_id=message_id, + event_id=None, + recipient=event_data["email"], + reject_reason=reject_reason, + mta_response=delivery_info.get("destination_response"), + metadata=metadata, + click_url=event_data.get("url"), + user_agent=delivery_info.get("user_agent"), + esp_event=event_data, + ) diff --git a/docs/esps/esp-feature-matrix.csv b/docs/esps/esp-feature-matrix.csv index d6c579fa..ea7b1f56 100644 --- a/docs/esps/esp-feature-matrix.csv +++ b/docs/esps/esp-feature-matrix.csv @@ -1,19 +1,19 @@ -Email Service Provider,:ref:`amazon-ses-backend`,:ref:`brevo-backend`,:ref:`mailersend-backend`,:ref:`mailgun-backend`,:ref:`mailjet-backend`,:ref:`mandrill-backend`,:ref:`postal-backend`,:ref:`postmark-backend`,:ref:`resend-backend`,:ref:`sendgrid-backend`,:ref:`sparkpost-backend` -.. rubric:: :ref:`Anymail send options `,,,,,,,,,,, -:attr:`~AnymailMessage.envelope_sender`,Yes,No,No,Domain only,Yes,Domain only,Yes,No,No,No,Yes -:attr:`~AnymailMessage.metadata`,Yes,Yes,No,Yes,Yes,Yes,No,Yes,Yes,Yes,Yes -:attr:`~AnymailMessage.merge_metadata`,No,Yes,No,Yes,Yes,Yes,No,Yes,Yes,Yes,Yes -:attr:`~AnymailMessage.send_at`,No,Yes,Yes,Yes,No,Yes,No,No,No,Yes,Yes -:attr:`~AnymailMessage.tags`,Yes,Yes,Yes,Yes,Max 1 tag,Yes,Max 1 tag,Max 1 tag,Yes,Yes,Max 1 tag -:attr:`~AnymailMessage.track_clicks`,No,No,Yes,Yes,Yes,Yes,No,Yes,No,Yes,Yes -:attr:`~AnymailMessage.track_opens`,No,No,Yes,Yes,Yes,Yes,No,Yes,No,Yes,Yes -:ref:`amp-email`,Yes,No,No,Yes,No,No,No,No,No,Yes,Yes -.. rubric:: :ref:`templates-and-merge`,,,,,,,,,,, -:attr:`~AnymailMessage.template_id`,Yes,Yes,Yes,Yes,Yes,Yes,No,Yes,No,Yes,Yes -:attr:`~AnymailMessage.merge_data`,Yes,Yes,Yes,Yes,Yes,Yes,No,Yes,No,Yes,Yes -:attr:`~AnymailMessage.merge_global_data`,Yes,Yes,(emulated),(emulated),Yes,Yes,No,Yes,No,Yes,Yes -.. rubric:: :ref:`Status ` and :ref:`event tracking `,,,,,,,,,,, -:attr:`~AnymailMessage.anymail_status`,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes -:class:`~anymail.signals.AnymailTrackingEvent` from webhooks,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes -.. rubric:: :ref:`Inbound handling `,,,,,,,,,,, -:class:`~anymail.signals.AnymailInboundEvent` from webhooks,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,No,Yes,Yes +Email Service Provider,:ref:`amazon-ses-backend`,:ref:`brevo-backend`,:ref:`mailersend-backend`,:ref:`mailgun-backend`,:ref:`mailjet-backend`,:ref:`mandrill-backend`,:ref:`postal-backend`,:ref:`postmark-backend`,:ref:`resend-backend`,:ref:`sendgrid-backend`,:ref:`sparkpost-backend`,:ref:`unisender-go-backend` +.. rubric:: :ref:`Anymail send options `,,,,,,,,,,,, +:attr:`~AnymailMessage.envelope_sender`,Yes,No,No,Domain only,Yes,Domain only,Yes,No,No,No,Yes,No +:attr:`~AnymailMessage.metadata`,Yes,Yes,No,Yes,Yes,Yes,No,Yes,Yes,Yes,Yes,Yes +:attr:`~AnymailMessage.merge_metadata`,No,Yes,No,Yes,Yes,Yes,No,Yes,Yes,Yes,Yes,Yes +:attr:`~AnymailMessage.send_at`,No,Yes,Yes,Yes,No,Yes,No,No,No,Yes,Yes,Yes +:attr:`~AnymailMessage.tags`,Yes,Yes,Yes,Yes,Max 1 tag,Yes,Max 1 tag,Max 1 tag,Yes,Yes,Max 1 tag,Yes +:attr:`~AnymailMessage.track_clicks`,No,No,Yes,Yes,Yes,Yes,No,Yes,No,Yes,Yes,Yes +:attr:`~AnymailMessage.track_opens`,No,No,Yes,Yes,Yes,Yes,No,Yes,No,Yes,Yes,Yes +:ref:`amp-email`,Yes,No,No,Yes,No,No,No,No,No,Yes,Yes,Yes +.. rubric:: :ref:`templates-and-merge`,,,,,,,,,,,, +:attr:`~AnymailMessage.template_id`,Yes,Yes,Yes,Yes,Yes,Yes,No,Yes,No,Yes,Yes,Yes +:attr:`~AnymailMessage.merge_data`,Yes,Yes,Yes,Yes,Yes,Yes,No,Yes,No,Yes,Yes,Yes +:attr:`~AnymailMessage.merge_global_data`,Yes,Yes,(emulated),(emulated),Yes,Yes,No,Yes,No,Yes,Yes,Yes +.. rubric:: :ref:`Status ` and :ref:`event tracking `,,,,,,,,,,,, +:attr:`~AnymailMessage.anymail_status`,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes +:class:`~anymail.signals.AnymailTrackingEvent` from webhooks,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes +.. rubric:: :ref:`Inbound handling `,,,,,,,,,,,, +:class:`~anymail.signals.AnymailInboundEvent` from webhooks,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,No,Yes,Yes,No diff --git a/docs/esps/index.rst b/docs/esps/index.rst index 19a54c65..47152965 100644 --- a/docs/esps/index.rst +++ b/docs/esps/index.rst @@ -23,6 +23,7 @@ and notes about any quirks or limitations: resend sendgrid sparkpost + unisender_go Anymail feature support diff --git a/docs/esps/unisender_go.rst b/docs/esps/unisender_go.rst new file mode 100644 index 00000000..dd2a97b7 --- /dev/null +++ b/docs/esps/unisender_go.rst @@ -0,0 +1,426 @@ +.. _unisender-go-backend: + +Unisender Go +============= + +Anymail supports sending email from Django through the `Unisender Go`_ email service, +using their `Web API`_ v1. + +.. _Unisender Go: https://go.unisender.ru +.. _Web API: https://godocs.unisender.ru/web-api-ref + +Settings +-------- + +.. rubric:: EMAIL_BACKEND + +To use Anymail's Unisender Go backend, set: + + .. code-block:: python + + EMAIL_BACKEND = "anymail.backends.unisender_go.EmailBackend" + +in your settings.py. + +.. rubric:: UNISENDER_GO_API_KEY, UNISENDER_GO_API_URL + +.. setting:: ANYMAIL_UNISENDER_GO_API_KEY +.. setting:: ANYMAIL_UNISENDER_GO_API_URL + +Required---the API key and API endpoint for your Unisender Go account or project: + + .. code-block:: python + + ANYMAIL = { + "UNISENDER_GO_API_KEY": "", + # Pick ONE of these, depending on your account (go1 vs. go2): + "UNISENDER_GO_API_URL": "https://go1.unisender.ru/ru/transactional/api/v1/", + "UNISENDER_GO_API_URL": "https://go2.unisender.ru/ru/transactional/api/v1/", + } + +Get the API key from Unisender Go's dashboard under Account > Security > API key +(Учетная запись > Безопасность > API-ключ). Or for a project-level API key, under +Settings > Projects (Настройки > Проекты). + +The correct API URL depends on which Unisender Go data center registered your account. +You must specify the full, versioned `Unisender Go API endpoint`_ as shown above +(not just the base uri). + +If trying to send mail raises an API Error "User with id ... not found" (code 114), +the likely cause is using the wrong API URL for your account. (To find which server +handles your account, log into Unisender Go's dashboard and then check hostname +in your browser's URL.) + +Anymail will also look for ``UNISENDER_GO_API_KEY`` at the +root of the settings file if neither ``ANYMAIL["UNISENDER_GO_API_KEY"]`` +nor ``ANYMAIL_UNISENDER_GO_API_KEY`` is set. + +.. _Unisender Go API endpoint: https://godocs.unisender.ru/web-api-ref#web-api + + +.. setting:: ANYMAIL_UNISENDER_GO_GENERATE_MESSAGE_ID + +.. rubric:: UNISENDER_GO_GENERATE_MESSAGE_ID + +Whether Anymail should generate a separate UUID for each recipient when sending +messages through Unisender Go, to facilitate status tracking. The UUIDs are attached +to the message as recipient metadata named "anymail_id" and available in +:attr:`anymail_status.recipients[recipient_email].message_id ` +on the message after it is sent. + +Default ``True``. You can set to ``False`` to disable generating UUIDs: + + .. code-block:: python + + ANYMAIL = { + ... + "UNISENDER_GO_GENERATE_MESSAGE_ID": False + } + +When disabled, each sent message will use Unisender Go's "job_id" as the (single) +:attr:`~anymail.message.AnymailStatus.message_id` for all recipients. +(The job_id alone may be sufficient for your tracking needs, particularly +if you only send to one recipient per message.) + + +.. _unisender-go-esp-extra: + +Additional sending options and esp_extra +---------------------------------------- + +Unisender Go offers a number of additional options you may want to use +when sending a message. You can set these for individual messages using +Anymail's :attr:`~anymail.message.AnymailMessage.esp_extra`. See the full +list of options in Unisender Go's `email/send.json`_ API documentation. + +For example: + +.. code-block:: python + + message = EmailMessage(...) + message.esp_extra = { + "global_language": "en", # Use English text for unsubscribe link + "bypass_global": 1, # Ignore system level blocked address list + "bypass_unavailable": 1, # Ignore account level blocked address list + "options": { + # Custom unsubscribe link (can use merge_data {{substitutions}}): + "unsubscribe_url": "https://example.com/unsub?u={{subscription_id}}", + "custom_backend_id": 22, # ID of dedicated IP address + } + } + +(Note that you do *not* include the API's root level ``"message"`` key in +:attr:`~!anymail.message.AnymailMessage.esp_extra`, but you must include +any nested keys---like ``"options"`` in the example above---to match +Unisender Go's API structure.) + +To set default :attr:`esp_extra` options for all messages, use Anymail's +:ref:`global send defaults ` in your settings.py. Example: + +.. code-block:: python + + ANYMAIL = { + ..., + "UNISENDER_GO_SEND_DEFAULTS": { + "esp_extra": { + # Omit the unsubscribe link for all sent messages: + "skip_unsubscribe": 1 + } + } + } + +Any options set in an individual message's +:attr:`~anymail.message.AnymailMessage.esp_extra` take precedence +over the global send defaults. + +For many of these additional options, you will need to contact Unisender Go +tech support for approval before being able to use them. + +.. _email/send.json: https://godocs.unisender.ru/web-api-ref#email-send + + +.. _unisender-go-quirks: + +Limitations and quirks +---------------------- + +**Attachment filename restrictions** + Unisender Go does not permit the slash character (``/``) in attachment filenames. + Trying to send one will result in an :exc:`~anymail.exceptions.AnymailAPIError`. + +**Restrictions on to, cc and bcc** + For non-batch sends, Unisender Go has a limit of 10 recipients each + for :attr:`to`, :attr:`cc` and :attr:`bcc`. Unisender Go does not support + cc-only or bcc-only messages. All bcc recipients must be in a domain + you have verified with Unisender Go. + + For :ref:`batch sending ` (with Anymail's + :attr:`~anymail.message.AnymailMessage.merge_data` or + :attr:`~anymail.message.AnymailMessage.merge_metadata`), Unisender Go has + a limit of 500 :attr:`to` recipients in a single message. + + Unisender Go's API does not support :attr:`cc` with batch sending. + Trying to include cc recipients in a batch send will raise an + :exc:`~anymail.exceptions.AnymailUnsupportedFeature` error. + (If you've enabled :setting:`ANYMAIL_IGNORE_UNSUPPORTED_FEATURES`, + Anymail will handle :attr:`cc` in a Unisender Go batch send as + additional :attr:`to` recipients.) + + With batch sending, Unisender Go effectively treats :attr:`bcc` recipients + as additional :attr:`to` recipients, which may not behave as you'd expect. + Each bcc in a batch send will be sent a *single* copy of the message, + with the bcc's email in the :mailheader:`To` header, and personalized using + :attr:`merge_data` for their own email address, if any. (Unlike some other + ESPs, bcc recipients in a batch send *won't* receive a separate copy of the + message personalized for each :attr:`to` email.) + +**AMP for Email** + Unisender Go supports sending AMPHTML email content. To include it, use + ``message.attach_alternative("...AMPHTML content...", "text/x-amp-html")`` + (and be sure to also include regular HTML and text bodies, too). + +**Use metadata for campaign_id** + If you want to use Unisender Go's ``campaign_id``, set it in Anymail's + :attr:`~anymail.message.AnymailMessage.metadata`. + +**Duplicate emails ignored** + Unisender Go only allows an email address to be included once in a message's + combined :attr:`to`, :attr:`cc` and :attr:`bcc` lists. If the same email + appears multiple times, the additional instances are ignored. (Unisender Go + reports them as duplicates, but Anymail does not treat this as an error.) + + Note that email addresses are case-insensitive. + +**Anymail's message_id is passed in recipient metadata** + By default, Anymail generates a unique identifier for each + :attr:`to` recipient in a message, and (effectively) adds this to the + recipients' :attr:`~anymail.message.AnymailMessage.merge_metadata` + with the key ``"anymail_id"``. + + This feature consumes one of Unisender Go's 10 available metadata slots. + To disable it, see the + :setting:`UNISENDER_GO_GENERATE_MESSAGE_ID ` + setting. + +**Recipient display names are set in merge_data** + To include a display name ("friendly name") with a :attr:`to` email address, + Unisender Go's Web API uses an entry in their per-recipient template + "substitutions," which are also used for Anymail's + :attr:`~anymail.message.AnymailMessage.merge_data`. + + To avoid conflicts, do not use ``"to_name"`` as a key in + :attr:`~anymail.message.AnymailMessage.merge_data` or + :attr:`~anymail.message.AnymailMessage.merge_global_data`. + +**No envelope sender overrides** + Unisender Go does not support overriding a message's + :attr:`~anymail.message.AnymailMessage.envelope_sender`. + + +.. _unisender-go-templates: + +Batch sending/merge and ESP templates +------------------------------------- + +Unisender Go supports :ref:`ESP stored templates `, +on-the-fly templating, and :ref:`batch sending ` with +per-recipient merge data substitutions. + +To send using a template you have created in your Unisender Go account, +set the message's :attr:`~anymail.message.AnymailMessage.template_id` +to the template's ID. (This is a UUID found at the top of the template's +"Properties" page---*not* the template name.) + +To supply template substitution data, use Anymail's +normalized :attr:`~anymail.message.AnymailMessage.merge_data` and +:attr:`~anymail.message.AnymailMessage.merge_global_data` message attributes. +You can also use +:attr:`~anymail.message.AnymailMessage.merge_metadata` to supply custom tracking +data for each recipient. + +Here is an example using a template that has slots for ``{{name}}``, +``{{order_no}}``, and ``{{ship_date}}`` substitution data: + + .. code-block:: python + + message = EmailMessage( + to=["alice@example.com", "Bob "], + ) + message.from_email = None # Use template From email and name + message.template_id = "0000aaaa-1111-2222-3333-4444bbbbcccc" + 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": "15-May", + } + message.send() + +Any :attr:`subject` provided will override the one defined in the template. +The message's :class:`from_email ` (which defaults to +your :setting:`DEFAULT_FROM_EMAIL` setting) will override the template's default sender. +If you want to use the :mailheader:`From` email and name defined with the template, +be sure to set :attr:`from_email` to ``None`` *after* creating the message, as shown above. + +Unisender Go also supports inline, on-the-fly templates. Here is the same example +using inline templates: + + .. code-block:: python + + message = EmailMessage( + from_email="shipping@example.com", + to=["alice@example.com", "Bob "], + # Use {{substitution}} variables in subject and body: + subject="Your order {{order_no}} has shipped", + body="""Hi {{name}}, + We shipped your order {{order_no}} + on {{ship_date}}.""", + ) + # (You'd probably also want to add an HTML body here.) + # The substitution data is exactly the same as in the previous example: + 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", + } + message.send() + +Note that Unisender Go doesn't allow whitespace in the substitution braces: +``{{order_no}}`` works, but ``{{ order_no }}`` causes an error. + +There are two available `Unisender Go template engines`_: "simple" and "velocity." +For templates stored in your account, you select the engine in the template's +properties. Inline templates use the simple engine by default; you can select +"velocity" using :ref:`esp_extra `: + + .. code-block:: python + + message.esp_extra = { + "template_engine": "velocity", + } + message.subject = "Your order $order_no has shipped" # Velocity syntax + +When you set per-recipient :attr:`~anymail.message.AnymailMessage.merge_data` +or :attr:`~anymail.message.AnymailMessage.merge_metadata`, Anymail will use +:ref:`batch sending ` mode so that each :attr:`to` recipient sees +only their own email address. You can set either of these attributes to an empty +dict (``message.merge_data = {}``) to force batch sending for a message that +wouldn't otherwise use it. + +Be sure to review the :ref:`restrictions above ` +before trying to use :attr:`cc` or :attr:`bcc` with Unisender Go batch sending. + +.. _Unisender Go template engines: https://godocs.unisender.ru/template-engines + + +.. _unisender-go-webhooks: + +Status tracking webhooks +------------------------ + +If you are using Anymail's normalized :ref:`status tracking `, add +the url in Unisender Go's dashboard. Where to set the webhook depends on where +you got your :setting:`UNISENDER_GO_API_KEY `: + +* If you are using an account-level API key, configure the webhook + under Settings > Webhooks (Настройки > Вебхуки). +* If you are using a project-level API key, configure the webhook + under Settings > Projects (Настройки > Проекты). + +(If you try to mix account-level and project-level API keys and webhooks, +webhook signature validation will fail, and you'll get +:exc:`~anymail.exceptions.AnymailWebhookValidationFailure` errors.) + +Enter these settings for the webhook: + +* **Notification Url:** + + :samp:`https://{yoursite.example.com}/anymail/unisender_go/tracking/` + + where *yoursite.example.com* is your Django site. + +* **Status:** set to "Active" if you have already deployed your Django project + with Anymail installed. Otherwise set to "Inactive" and update after you deploy. + + (Unisender Go performs a GET request to verify the webhook URL + when it is marked active.) + +* **Event format:** "json_post" + + (If your gateway handles decompressing incoming request bodies---e.g., Apache + with a mod_deflate *input* filter---you could also use "json_post_compressed." + Most web servers do not handle compressed input by default.) + +* **Events:** your choice. Anymail supports any combination of ``sent, delivered, + soft_bounced, hard_bounced, opened, clicked, unsubscribed, subscribed, spam``. + + Anymail does not support Unisender Go's ``spam_block`` events (but will ignore + them if you accidentally include it). + +* **Number of simultaneous requests:** depends on your web server's + capacity + + Most deployments should be able to handle the default 10. + But you may need to use a smaller number if your tracking signal + receiver uses a lot of resources (or monopolizes your database), + or if your web server isn't configured to handle that many + simultaneous requests (including requests from your site users). + +* **Use single event:** the default "No" is recommended + + Anymail can process multiple events in a single webhook call. + It invokes your signal receiver separately for each event. + But all of the events in the call (up to 100 when set to "No") + must be handled within 3 seconds total, or Unisender Go will + think the request failed and resend it. + + If your tracking signal receiver takes a long time to process + each event, you may need to change "Use single event" to "Yes" + (one event per webhook call). + +* **Additional information about delivery:** "Yes" is recommended + + (If you set this to "No", your tracking events won't include + :attr:`~anymail.signals.AnymailTrackingEvent.mta_response`, + :attr:`~anymail.signals.AnymailTrackingEvent.user_agent` or + :attr:`~anymail.signals.AnymailTrackingEvent.click_url`.) + +Note that Unisender Go does not deliver tracking events for recipient +addresses that are blocked at send time. You must check the message's +:attr:`anymail_status.recipients[recipient_email].message_id ` +immediately after sending to detect rejected recipients. + +Unisender Go implements webhook signing on the entire event payload, +and Anymail verifies this signature using your +:setting:`UNISENDER_GO_API_KEY `. +It is not necessary to use an :setting:`ANYMAIL_WEBHOOK_SECRET` +with Unisender Go, but if you have set one, you must include +the *random:random* shared secret in the Notification URL like this: + + :samp:`https://{random}:{random}@{yoursite.example.com}/anymail/unisender_go/tracking/` + +In your tracking signal receiver, the event's +:attr:`~anymail.signals.AnymailTrackingEvent.esp_event` field will be +the ``"event_data"`` object from a single, raw `"transactional_email_status" event`_. +For example, you could get the IP address that opened a message using +``event.esp_event["delivery_info"]["ip"]``. + +(Anymail does not handle Unisender Go's "transactional_spam_block" events, +and will filter these without calling your tracking signal handler.) + +.. _"transactional_email_status" event: + https://godocs.unisender.ru/web-api-ref#callback-format + + +.. _unisender-go-inbound: + +Inbound webhook +--------------- + +Unisender Go does not currently offer inbound email. + +(If this changes in the future, please open an issue +so we can add support in Anymail.) diff --git a/pyproject.toml b/pyproject.toml index ee1fb7ba..1d50510b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ authors = [ description = """\ Django email backends and webhooks for Amazon SES, Brevo (Sendinblue), MailerSend, Mailgun, Mailjet, Mandrill, Postal, Postmark, Resend, - SendGrid, and SparkPost\ + SendGrid, SparkPost and Unisender Go\ """ # readme: see tool.hatch.metadata.hooks.custom below keywords = [ @@ -25,6 +25,7 @@ keywords = [ "Postal", "Postmark", "Resend", "SendGrid", "SendinBlue", "SparkPost", + "Unisender Go", ] classifiers = [ "Development Status :: 5 - Production/Stable", @@ -74,6 +75,7 @@ resend = ["svix"] sendgrid = [] sendinblue = [] sparkpost = [] +unisender-go = [] postal = [ # Postal requires cryptography for verifying webhooks. # Cryptography's wheels are broken on darwin-arm64 before Python 3.9. diff --git a/tests/test_unisender_go_backend.py b/tests/test_unisender_go_backend.py new file mode 100644 index 00000000..a28d1a1a --- /dev/null +++ b/tests/test_unisender_go_backend.py @@ -0,0 +1,895 @@ +import json +from base64 import b64encode +from datetime import date, datetime +from decimal import Decimal +from email.mime.base import MIMEBase +from email.mime.image import MIMEImage +from unittest.mock import patch + +from django.core import mail +from django.test import SimpleTestCase, 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 AnymailMessage, attach_inline_image_file + +from .mock_requests_backend import ( + RequestsBackendMockAPITestCase, + SessionSharingTestCases, +) +from .utils import ( + SAMPLE_IMAGE_FILENAME, + AnymailTestMixin, + sample_image_content, + sample_image_path, +) + + +@tag("unisender_go") +@override_settings( + EMAIL_BACKEND="anymail.backends.unisender_go.EmailBackend", + ANYMAIL={ + "UNISENDER_GO_API_KEY": "test_api_key", + "UNISENDER_GO_API_URL": "https://go1.unisender.ru/ru/transactional/api/v1", + }, +) +class UnisenderGoBackendMockAPITestCase(RequestsBackendMockAPITestCase): + DEFAULT_RAW_RESPONSE = json.dumps( + { + "status": "success", + "job_id": "1rctPx-00021H-CcC4", + "emails": ["to@example.com"], + } + ).encode("utf-8") + DEFAULT_STATUS_CODE = 200 + DEFAULT_CONTENT_TYPE = "application/json" + + def setUp(self): + super().setUp() + + # Patch uuid4 to generate predictable message_ids for testing + patch_uuid4 = patch( + "anymail.backends.unisender_go.uuid.uuid4", + side_effect=[f"mocked-uuid-{n:d}" for n in range(1, 10)], + ) + patch_uuid4.start() + self.addCleanup(patch_uuid4.stop) + + # Simple message useful for many tests + self.message = AnymailMessage( + "Subject", "Text Body", "from@example.com", ["to@example.com"] + ) + + def set_mock_response( + self, success_emails=None, failed_emails=None, job_id=None, **kwargs + ): + """ + Pass success_emails and/or failure_emails to generate an appropriate + API response for those specific emails. Otherwise, arguments are as + for super call. + :param success_emails {list[str]}: addr-specs of emails that were delivered + :param failure_emails {dict[str,str]}: mapping of addr-spec -> failure reason + :param job_id {str}: optional specific job_id for response + """ + if success_emails or failed_emails: + assert "raw" not in kwargs + assert "json_response" not in kwargs + assert kwargs.get("status_code", 200) == 200 + kwargs["status_code"] = 200 + kwargs["json_data"] = { + "status": "success", + "job_id": job_id or "1rctPx-00021H-CcC4", + "emails": success_emails or [], + } + if failed_emails: + kwargs["json_data"]["failed_emails"] = failed_emails + + return super().set_mock_response(**kwargs) + + +@tag("unisender_go") +class UnisenderGoBackendStandardEmailTests(UnisenderGoBackendMockAPITestCase): + """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@sender.example.com", + ["to@example.com"], + fail_silently=False, + ) + self.assert_esp_called( + "https://go1.unisender.ru/ru/transactional/api/v1/email/send.json" + ) + http_headers = self.get_api_call_headers() + self.assertEqual(http_headers["X-API-KEY"], "test_api_key") + self.assertEqual(http_headers["Accept"], "application/json") + self.assertEqual(http_headers["Content-Type"], "application/json") + + data = self.get_api_call_json() + self.assertEqual(data["message"]["subject"], "Subject here") + self.assertEqual(data["message"]["body"], {"plaintext": "Here is the message."}) + self.assertEqual(data["message"]["from_email"], "from@sender.example.com") + self.assertEqual( + data["message"]["recipients"], + [ + { + "email": "to@example.com", + # make sure the backend assigned the message_id + # for event tracking and notification + "metadata": {"anymail_id": "mocked-uuid-1"}, + } + ], + ) + + 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["message"]["from_email"], "from@example.com") + self.assertEqual(data["message"]["from_name"], "From Name") + + recipients = data["message"]["recipients"] + self.assertEqual(len(recipients), 6) + self.assertEqual(recipients[0]["email"], "to1@example.com") + self.assertEqual(recipients[0]["substitutions"]["to_name"], "Recipient #1") + self.assertEqual(recipients[1]["email"], "to2@example.com") + self.assertNotIn("substitutions", recipients[1]) # to_name not needed + self.assertEqual(recipients[2]["email"], "cc1@example.com") + self.assertEqual(recipients[2]["substitutions"]["to_name"], "Carbon Copy") + self.assertEqual(recipients[3]["email"], "cc2@example.com") + self.assertNotIn("substitutions", recipients[3]) # to_name not needed + self.assertEqual(recipients[4]["email"], "bcc1@example.com") + self.assertEqual(recipients[4]["substitutions"]["to_name"], "Blind Copy") + self.assertEqual(recipients[5]["email"], "bcc2@example.com") + self.assertNotIn("substitutions", recipients[5]) # to_name not needed + + # This also covers Unisender Go's special handling for cc/bcc + headers = data["message"]["headers"] + self.assertEqual( + headers["to"], "Recipient #1 , to2@example.com" + ) + self.assertEqual( + headers["cc"], "Carbon Copy , cc2@example.com" + ) + self.assertNotIn("bcc", headers) + + def test_display_names_with_special_chars(self): + # Verify workaround for Unisender Go bug parsing to/cc headers + # with display names containing commas, angle brackets, or at sign + self.message.to = [ + '"With, Comma" ', + '"angle " ', + '"(without) special / chars" ', + ] + self.message.cc = [ + '"Someone @example.com" ', + '"[without] special & chars" ', + ] + self.message.send() + data = self.get_api_call_json() + headers = data["message"]["headers"] + # display-name with , < > @ converted to RFC 2047 encoded word; + # not necessary for display names with other special characters + self.assertEqual( + headers["to"], + "=?utf-8?q?With=2C_Comma?= , " + "=?utf-8?q?angle_=3Cbrackets=3E?= , " + '"(without) special / chars" ', + ) + self.assertEqual( + headers["cc"], + "=?utf-8?q?Someone_=40example=2Ecom?= , " + '"[without] special & chars" ', + ) + + 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["message"]["body"], {"plaintext": text_content, "html": html_content} + ) + # Don't accidentally send the html part as an attachment: + self.assertNotIn("attachments", data["message"]) + + 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.assertEqual(data["message"]["body"], {"html": html_content}) + + def test_amp_html_alternative(self): + # Unisender Go *does* support text/x-amp-html alongside text/html + self.message.attach_alternative("

HTML

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

And AMP HTML

", "text/x-amp-html") + self.message.send() + data = self.get_api_call_json() + self.assertEqual(data["message"]["body"]["html"], "

HTML

") + self.assertEqual(data["message"]["body"]["amp"], "

And AMP HTML

") + + def test_extra_headers(self): + self.message.extra_headers = { + "X-Custom": "string", + "X-Num": 123, + "Reply-To": "noreply@example.com", + } + self.message.send() + data = self.get_api_call_json() + headers = data["message"]["headers"] + self.assertEqual(headers["X-Custom"], "string") + self.assertEqual(headers["X-Num"], 123) + + # Reply-To must be moved to separate param + self.assertNotIn("Reply-To", headers) + self.assertEqual(data["message"]["reply_to"], "noreply@example.com") + self.assertNotIn("reply_to_name", data["message"]) + + def test_extra_headers_serialization_error(self): + self.message.extra_headers = {"X-Custom": Decimal(12.5)} + with self.assertRaisesMessage(AnymailSerializationError, "Decimal"): + self.message.send() + + def test_reply_to(self): + # Unisender Go supports only a single reply-to + self.message.reply_to = ['"Reply recipient" 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["message"]["inline_attachments"], + [ + { + "name": cid, + "content": b64encode(image_data).decode("ascii"), + "type": "image/png", # (type inferred from filename) + } + ], + ) + + 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() + + image_data_b64 = b64encode(image_data).decode("ascii") + data = self.get_api_call_json() + self.assertEqual( + data["message"]["attachments"][0], + { + "name": image_filename, # the named one + "content": image_data_b64, + "type": "image/png", + }, + ) + self.assertEqual( + data["message"]["attachments"][1], + { + "name": "", # the unnamed one + "content": image_data_b64, + "type": "image/png", + }, + ) + + def test_multiple_html_alternatives(self): + # Multiple 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_non_html_alternative(self): + # Only html alternatives allowed + self.message.attach_alternative("{'not': 'allowed'}", "application/json") + with self.assertRaises(AnymailUnsupportedFeature): + self.message.send() + + def test_api_failure(self): + self.set_mock_response(status_code=400) + with self.assertRaisesMessage(AnymailAPIError, "Unisender Go API response 400"): + mail.send_mail("Subject", "Body", "from@example.com", ["to@example.com"]) + + # Make sure fail_silently is respected + self.set_mock_response(status_code=400) + sent = mail.send_mail( + "Subject", + "Body", + "from@example.com", + ["to@example.com"], + fail_silently=True, + ) + self.assertEqual(sent, 0) + + def test_api_error_includes_details(self): + """AnymailAPIError should include ESP's error message""" + self.set_mock_response( + status_code=400, + json_data=[ + { + "status": "error", + "message": "Helpful explanation from Unisender Go", + "code": 999, + }, + ], + ) + with self.assertRaisesMessage( + AnymailAPIError, "Helpful explanation from Unisender Go" + ): + self.message.send() + + +@tag("unisender_go") +class UnisenderGoBackendAnymailFeatureTests(UnisenderGoBackendMockAPITestCase): + """Test backend support for Anymail added features""" + + def test_envelope_sender(self): + # Unisender Go does not have a way to change envelope sender. + self.message.envelope_sender = "anything@bounces.example.com" + with self.assertRaisesMessage(AnymailUnsupportedFeature, "envelope_sender"): + self.message.send() + + def test_metadata(self): + self.message.metadata = {"user_id": "12345", "items": 6, "float": 98.6} + self.message.send() + data = self.get_api_call_json() + self.assertEqual( + data["message"]["global_metadata"], + { + "user_id": "12345", + "items": 6, + "float": 98.6, + }, + ) + + 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-naive datetime assumed to be Django current_timezone + self.message.send_at = datetime(2022, 10, 11, 12, 13, 14, 567) + self.message.send() + data = self.get_api_call_json() + self.assertEqual( + data["message"]["options"]["send_at"], "2022-10-11 06:13:14" + ) # 12:13 UTC+6 == 06:13 UTC + + # 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["message"]["options"]["send_at"], "2016-03-04 13:06:07" + ) # 05:06 UTC-8 == 13:06 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["message"]["options"]["send_at"], "2022-10-21 18:00:00" + ) # 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["message"]["options"]["send_at"], "2022-05-06 07:08:09" + ) + + # String passed unchanged (this is *not* portable between ESPs) + self.message.send_at = "2013-11-12 01:02:03" + self.message.send() + data = self.get_api_call_json() + self.assertEqual( + data["message"]["options"]["send_at"], "2013-11-12 01:02:03" + ) + + def test_tags(self): + self.message.tags = ["receipt", "repeat-user"] + self.message.send() + data = self.get_api_call_json() + self.assertCountEqual(data["message"]["tags"], ["receipt", "repeat-user"]) + + def test_tracking(self): + # Test one way... + self.message.track_clicks = False + self.message.track_opens = True + self.message.send() + data = self.get_api_call_json() + self.assertEqual(data["message"]["track_links"], 0) + self.assertEqual(data["message"]["track_read"], 1) + + # ...and the opposite way + self.message.track_clicks = True + self.message.track_opens = False + self.message.send() + data = self.get_api_call_json() + self.assertEqual(data["message"]["track_links"], 1) + self.assertEqual(data["message"]["track_read"], 0) + + def test_template_id(self): + self.message.template_id = "5997fcf6-2b9f-484d-acd5-7e9a99f0dc1f" + self.message.send() + data = self.get_api_call_json() + self.assertEqual( + data["message"]["template_id"], "5997fcf6-2b9f-484d-acd5-7e9a99f0dc1f" + ) + + def test_merge_data(self): + self.message.from_email = "from@example.com" + self.message.to = [ + "alice@example.com", + "Bob ", + "celia@example.com", + ] + self.message.merge_data = { + "alice@example.com": {"name": "Alice", "group": "Developers"}, + "bob@example.com": {"name": "Robert"}, # and leave group undefined + # and no data for celia@example.com + } + self.message.merge_global_data = { + "group": "Users", + "site": "ExampleCo", + } + self.message.send() + + data = self.get_api_call_json() + recipients = data["message"]["recipients"] + self.assertEqual(recipients[0]["email"], "alice@example.com") + self.assertEqual( + recipients[0]["substitutions"], {"name": "Alice", "group": "Developers"} + ) + self.assertEqual(recipients[1]["email"], "bob@example.com") + self.assertEqual( + # Make sure email display name (as "to_name") is combined with merge_data + recipients[1]["substitutions"], + {"name": "Robert", "to_name": "Bob"}, + ) + self.assertEqual(recipients[2]["email"], "celia@example.com") + self.assertNotIn("substitutions", recipients[2]) + self.assertEqual( + data["message"]["global_substitutions"], + {"group": "Users", "site": "ExampleCo"}, + ) + + # For batch send, must not include common "to" header + headers = data["message"].get("headers", {}) + self.assertNotIn("to", headers) + self.assertNotIn("cc", headers) + + 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"}, + } + self.message.send() + data = self.get_api_call_json() + recipients = data["message"]["recipients"] + # anymail_id added to other recipient metadata + self.assertEqual( + recipients[0]["metadata"], + { + "anymail_id": "mocked-uuid-1", + "order_id": 123, + }, + ) + self.assertEqual( + recipients[1]["metadata"], + { + "anymail_id": "mocked-uuid-2", + "order_id": 678, + "tier": "premium", + }, + ) + + # For batch send, must not include common "to" header + headers = data["message"].get("headers", {}) + self.assertNotIn("to", headers) + self.assertNotIn("cc", headers) + + def test_cc_unsupported_with_batch_send(self): + self.message.merge_data = {} + self.message.cc = ["cc@example.com"] + with self.assertRaisesMessage( + AnymailUnsupportedFeature, + "cc with batch send (merge_data or merge_metadata)", + ): + self.message.send() + + @override_settings(ANYMAIL_IGNORE_UNSUPPORTED_FEATURES=True) + def test_ignore_unsupported_cc_with_batch_send(self): + self.message.merge_data = {} + self.message.cc = ["cc@example.com"] + self.message.bcc = ["bcc@example.com"] + self.message.send() + self.assertEqual(self.message.anymail_status.status, {"queued"}) + data = self.get_api_call_json() + # Unisender Go prohibits "cc" header without "to" header, + # and we can't include a "to" header for batch send, + # so make sure we've removed the "cc" header when ignoring unsupported cc + headers = data["message"].get("headers", {}) + self.assertNotIn("cc", headers) + self.assertNotIn("to", headers) + + @override_settings(ANYMAIL_UNISENDER_GO_GENERATE_MESSAGE_ID=False) + 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() + message_data = self.get_api_call_json()["message"] + self.assertNotIn("attachments", message_data) + self.assertNotIn("from_name", message_data) + self.assertNotIn("global_substitutions", message_data) + self.assertNotIn("global_metadata", message_data) + self.assertNotIn("inline_attachments", message_data) + self.assertNotIn("options", message_data) + self.assertNotIn("reply_to", message_data) + self.assertNotIn("reply_to_name", message_data) + self.assertNotIn("tags", message_data) + self.assertNotIn("template_id", message_data) + self.assertNotIn("track_links", message_data) + self.assertNotIn("track_read", message_data) + + for recipient_data in message_data["recipients"]: + self.assertNotIn("metadata", recipient_data) + self.assertNotIn("substitutions", recipient_data) + + def test_esp_extra(self): + self.message.send_at = "2022-02-22 22:22:22" + self.message.esp_extra = { + "global_language": "en", + "skip_unsubscribe": 1, + "template_engine": "velocity", + "options": { + "unsubscribe_url": "https://example.com/unsubscribe?id={{user_id}}", + "smtp_pool_id": "custom-smtp-pool", + }, + } + self.message.send() + data = self.get_api_call_json() + # merged from esp_extra: + self.assertEqual(data["message"]["global_language"], "en") + self.assertEqual(data["message"]["skip_unsubscribe"], 1) + self.assertEqual(data["message"]["template_engine"], "velocity") + self.assertEqual( + data["message"]["options"], + { # deep merge + "send_at": "2022-02-22 22:22:22", + "unsubscribe_url": "https://example.com/unsubscribe?id={{user_id}}", + "smtp_pool_id": "custom-smtp-pool", + }, + ) + + # noinspection PyUnresolvedReferences + def test_send_attaches_anymail_status(self): + """The anymail_status should be attached to the message when it is sent""" + msg = mail.EmailMessage( + "Subject", + "Message", + "from@example.com", + ["to@example.com"], + ) + sent = msg.send() + self.assertEqual(sent, 1) + self.assertEqual(msg.anymail_status.status, {"queued"}) + self.assertEqual(msg.anymail_status.message_id, "mocked-uuid-1") + self.assertEqual( + msg.anymail_status.recipients["to@example.com"].status, "queued" + ) + self.assertEqual( + msg.anymail_status.recipients["to@example.com"].message_id, "mocked-uuid-1" + ) + self.assertEqual( + msg.anymail_status.esp_response.content, self.DEFAULT_RAW_RESPONSE + ) + + def test_batch_recipients_get_unique_message_ids(self): + """In a batch send, each recipient should get a distinct message_id""" + # Unisender Go *always* uses batch send; no need to force by setting merge_data. + self.set_mock_response(success_emails=["to1@example.com", "to2@example.com"]) + msg = mail.EmailMessage( + "Subject", + "Message", + "from@example.com", + ["to1@example.com", "Someone Else "], + ) + msg.send() + self.assertEqual( + msg.anymail_status.message_id, {"mocked-uuid-1", "mocked-uuid-2"} + ) + self.assertEqual( + msg.anymail_status.recipients["to1@example.com"].message_id, "mocked-uuid-1" + ) + self.assertEqual( + msg.anymail_status.recipients["to2@example.com"].message_id, "mocked-uuid-2" + ) + + def test_rejected_recipient_status(self): + self.message.to = [ + "duplicate@example.com", + "Again ", + "Duplicate@example.com", # addresses are case-insensitive + "bounce@example.com", + "mailbox-full@example.com", + "webmaster@localhost", + "spam-report@example.com", + ] + self.set_mock_response( + # Note "duplicate" email will appear in both success and failed lists + # (because Unisender Go sends the first one, fails remaining duplicates) + success_emails=["duplicate@example.com"], + failed_emails={ + "duplicate@example.com": "duplicate", + "Duplicate@example.com": "duplicate", + "bounce@example.com": "permanent_unavailable", + "mailbox-full@example.com": "temporary_unavailable", + "webmaster@localhost": "invalid", + "spam-report@example.com": "unsubscribed", + }, + ) + self.message.send() + recipient_status = self.message.anymail_status.recipients + self.assertEqual(recipient_status["duplicate@example.com"].status, "queued") + self.assertEqual( + # duplicate uses _first_ message_id (because first instance will be sent) + recipient_status["duplicate@example.com"].message_id, + "mocked-uuid-1", + ) + self.assertEqual(recipient_status["bounce@example.com"].status, "rejected") + self.assertIsNone(recipient_status["bounce@example.com"].message_id) + self.assertEqual(recipient_status["mailbox-full@example.com"].status, "failed") + self.assertIsNone(recipient_status["mailbox-full@example.com"].message_id) + self.assertEqual(recipient_status["webmaster@localhost"].status, "invalid") + self.assertIsNone(recipient_status["webmaster@localhost"].message_id) + self.assertEqual(recipient_status["spam-report@example.com"].status, "rejected") + self.assertIsNone(recipient_status["spam-report@example.com"].message_id) + + @override_settings(ANYMAIL_UNISENDER_GO_GENERATE_MESSAGE_ID=False) + def test_disable_generate_message_id(self): + """ + When not generating per-recipient message_id, + use Unisender Go's job_id for all recipients. + """ + self.set_mock_response( + success_emails=["to1@example.com", "to2@example.com"], + job_id="123456-000HHH-CcCc", + ) + self.message.to = ["to1@example.com", "to2@example.com"] + self.message.send() + self.assertEqual(self.message.anymail_status.message_id, "123456-000HHH-CcCc") + recipient_status = self.message.anymail_status.recipients + self.assertEqual( + recipient_status["to1@example.com"].message_id, "123456-000HHH-CcCc" + ) + self.assertEqual( + recipient_status["to2@example.com"].message_id, "123456-000HHH-CcCc" + ) + + # 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=500) + 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) + + def test_json_serialization_errors(self): + """Try to provide more information about non-json-serializable data""" + self.message.metadata = {"total": Decimal("19.99")} + with self.assertRaises(AnymailSerializationError) as cm: + self.message.send() + 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 Unisender Go", str(err)) + # original message: + self.assertRegex(str(err), r"Decimal.*is not JSON serializable") + + +@tag("unisender_go") +class UnisenderGoBackendRecipientsRefusedTests(UnisenderGoBackendMockAPITestCase): + """ + Should raise AnymailRecipientsRefused when *all* recipients are rejected or invalid + """ + + def test_recipients_refused(self): + self.message.to = ["invalid@localhost", "reject@example.com"] + self.set_mock_response( + failed_emails={ + "invalid@localhost": "invalid", + "reject@example.com": "permanent_unavailable", + } + ) + with self.assertRaises(AnymailRecipientsRefused): + self.message.send() + + def test_fail_silently(self): + self.message.to = ["invalid@localhost", "reject@example.com"] + self.set_mock_response( + failed_emails={ + "invalid@localhost": "invalid", + "reject@example.com": "permanent_unavailable", + } + ) + sent = self.message.send(fail_silently=True) + self.assertEqual(sent, 0) + + def test_mixed_response(self): + """If *any* recipients are valid or queued, no exception is raised""" + self.message.to = [ + "invalid@localhost", + "valid@example.com", + "reject@example.com", + "also.valid@example.com", + ] + self.set_mock_response( + success_emails=["valid@example.com", "also.valid@example.com"], + failed_emails={ + "invalid@localhost": "invalid", + "reject@example.com": "permanent_unavailable", + }, + ) + sent = self.message.send() + # one message sent, successfully, to 2 of 4 recipients: + self.assertEqual(sent, 1) + status = self.message.anymail_status + self.assertEqual(status.recipients["invalid@localhost"].status, "invalid") + 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.message.to = ["invalid@localhost", "reject@example.com"] + self.set_mock_response( + failed_emails={ + "invalid@localhost": "invalid", + "reject@example.com": "permanent_unavailable", + } + ) + sent = self.message.send() + self.assertEqual(sent, 1) # refused message is included in sent count + + +@tag("unisender_go") +class UnisenderGoBackendSessionSharingTestCase( + SessionSharingTestCases, UnisenderGoBackendMockAPITestCase +): + """Requests session sharing tests""" + + pass # tests are defined in SessionSharingTestCases + + +@tag("unisender_go") +@override_settings(EMAIL_BACKEND="anymail.backends.unisender_go.EmailBackend") +class UnisenderGoBackendImproperlyConfiguredTests(AnymailTestMixin, SimpleTestCase): + """Test ESP backend without required settings in place""" + + def test_missing_auth(self): + with self.assertRaisesRegex( + AnymailConfigurationError, r"\bUNISENDER_GO_API_KEY\b" + ): + mail.send_mail("Subject", "Message", "from@example.com", ["to@example.com"]) diff --git a/tests/test_unisender_go_integration.py b/tests/test_unisender_go_integration.py new file mode 100644 index 00000000..863172e4 --- /dev/null +++ b/tests/test_unisender_go_integration.py @@ -0,0 +1,172 @@ +import os +import unittest +from datetime import datetime, timedelta +from email.headerregistry import Address + +from django.test import SimpleTestCase, override_settings, tag + +from anymail.exceptions import AnymailAPIError +from anymail.message import AnymailMessage + +from .utils import AnymailTestMixin + +ANYMAIL_TEST_UNISENDER_GO_API_KEY = os.getenv("ANYMAIL_TEST_UNISENDER_GO_API_KEY") +ANYMAIL_TEST_UNISENDER_GO_API_URL = os.getenv("ANYMAIL_TEST_UNISENDER_GO_API_URL") +ANYMAIL_TEST_UNISENDER_GO_DOMAIN = os.getenv("ANYMAIL_TEST_UNISENDER_GO_DOMAIN") +ANYMAIL_TEST_UNISENDER_GO_TEMPLATE_ID = os.getenv( + "ANYMAIL_TEST_UNISENDER_GO_TEMPLATE_ID" +) + + +@tag("unisender_go", "live") +@unittest.skipUnless( + ANYMAIL_TEST_UNISENDER_GO_API_KEY + and ANYMAIL_TEST_UNISENDER_GO_API_URL + and ANYMAIL_TEST_UNISENDER_GO_DOMAIN, + "Set ANYMAIL_TEST_UNISENDER_GO_API_KEY, ANYMAIL_TEST_UNISENDER_GO_API_URL" + " and ANYMAIL_TEST_UNISENDER_GO_DOMAIN environment variables to run Unisender Go" + " integration tests", +) +@override_settings( + ANYMAIL_UNISENDER_GO_API_KEY=ANYMAIL_TEST_UNISENDER_GO_API_KEY, + ANYMAIL_UNISENDER_GO_API_URL=ANYMAIL_TEST_UNISENDER_GO_API_URL, + EMAIL_BACKEND="anymail.backends.unisender_go.EmailBackend", +) +class UnisenderGoBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): + """ + Unisender Go API integration tests + + These tests run against the **live** Unisender Go API, using the + environment variable `ANYMAIL_TEST_UNISENDER_GO_API_KEY` as the API key, + `ANYMAIL_UNISENDER_GO_API_URL` as the API URL where that key was issued, + and `ANYMAIL_TEST_UNISENDER_GO_DOMAIN` to construct the sender addresses. + If any of those variables are not set, these tests won't run. + + To run the template test, also set ANYMAIL_TEST_UNISENDER_GO_TEMPLATE_ID + to a valid template in your account. + + The tests send actual email to a sink address at anymail.dev. + """ + + def setUp(self): + super().setUp() + self.from_email = f"from@{ANYMAIL_TEST_UNISENDER_GO_DOMAIN}" + self.message = AnymailMessage( + "Anymail Unisender Go 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 Unisender Go 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") # Unisender Go always queues + self.assertRegex(message_id, r".+") + # 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) + message = AnymailMessage( + subject="Anymail Unisender Go all-options integration test", + body="This is the text body", + from_email=str( + Address(display_name="Test From, with comma", addr_spec=self.from_email) + ), + to=["test+to1@anymail.dev", '"Recipient 2, OK?" '], + cc=["test+cc1@anymail.dev", '"Copy 2, OK?" '], + bcc=[ + f"test+bcc1@{ANYMAIL_TEST_UNISENDER_GO_DOMAIN}", + f'"BCC 2, OK?" ', + ], + # Unisender Go only supports a single reply-to: + reply_to=['"Reply, with comma (and parens)" '], + headers={"X-Anymail-Test": "value", "X-Anymail-Count": 3}, + metadata={"meta1": "simple string", "meta2": 2}, + send_at=send_at, + tags=["tag 1", "tag 2"], + track_opens=False, + track_clicks=False, + esp_extra={ + "global_language": "en", + "options": {"unsubscribe_url": "https://example.com/unsubscribe?id=1"}, + }, + ) + message.attach_alternative("

HTML content

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

AMP HTML content

", "text/x-amp-html") + + message.attach("attachment1.txt", "Here is some\ntext for you", "text/plain") + message.attach("attachment2.csv", "ID,Name\n1,Amy Lina", "text/csv") + + message.send() + self.assertEqual(message.anymail_status.status, {"queued"}) + 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.assertRegex(recipient_status["test+to1@anymail.dev"].message_id, r".+") + self.assertRegex(recipient_status["test+to2@anymail.dev"].message_id, r".+") + # Anymail generates unique message_id for each recipient: + self.assertNotEqual( + recipient_status["test+to1@anymail.dev"].message_id, + recipient_status["test+to2@anymail.dev"].message_id, + ) + + @unittest.skipUnless( + ANYMAIL_TEST_UNISENDER_GO_TEMPLATE_ID, + "Set ANYMAIL_TEST_UNISENDER_GO_TEMPLATE_ID to run the" + " Unisender Go template integration test", + ) + def test_template(self): + """ + To run this test, create a template in your account containing + "{{order_id}}" and "{{ship_date}}" substitutions, and set + ANYMAIL_TEST_UNISENDER_GO_TEMPLATE_ID to the template's id. + """ + message = AnymailMessage( + # This is an actual template in the Anymail test account: + template_id=ANYMAIL_TEST_UNISENDER_GO_TEMPLATE_ID, + to=["Recipient 1 ", "test+to2@anymail.dev"], + reply_to=["Do not reply "], + tags=["using-template"], + merge_data={ + "test+to1@anymail.dev": {"order_id": "12345"}, + "test+to2@anymail.dev": {"order_id": "23456"}, + }, + merge_global_data={"ship_date": "yesterday"}, + metadata={"customer-id": "unknown", "meta2": 2}, + merge_metadata={ + "test+to1@anymail.dev": {"customer-id": "ZXK9123"}, + "test+to2@anymail.dev": {"customer-id": "ZZT4192"}, + }, + ) + message.from_email = None # use template sender + message.attach("attachment1.txt", "Here is some\ntext", "text/plain") + + message.send() + # Unisender Go always queues: + self.assertEqual(message.anymail_status.status, {"queued"}) + 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.assertRegex(recipient_status["test+to1@anymail.dev"].message_id, r".+") + self.assertRegex(recipient_status["test+to2@anymail.dev"].message_id, r".+") + # Anymail generates unique message_id for each recipient: + self.assertNotEqual( + recipient_status["test+to1@anymail.dev"].message_id, + recipient_status["test+to2@anymail.dev"].message_id, + ) + + @override_settings(ANYMAIL_UNISENDER_GO_API_KEY="Hey, that's not an API key!") + def test_invalid_api_key(self): + # Make sure the exception message includes Unisender Go's response: + with self.assertRaisesMessage(AnymailAPIError, "Can not decode key"): + self.message.send() diff --git a/tests/test_unisender_go_payload.py b/tests/test_unisender_go_payload.py new file mode 100644 index 00000000..c32d3253 --- /dev/null +++ b/tests/test_unisender_go_payload.py @@ -0,0 +1,299 @@ +from __future__ import annotations + +from email.headerregistry import Address + +from django.test import SimpleTestCase, override_settings, tag +from requests.structures import CaseInsensitiveDict + +from anymail.backends.unisender_go import EmailBackend, UnisenderGoPayload +from anymail.message import AnymailMessage + +TEMPLATE_ID = "template_id" +FROM_EMAIL = "sender@test.test" +FROM_NAME = "test name" +TO_EMAIL = "receiver@test.test" +TO_NAME = "receiver" +OTHER_TO_EMAIL = "receiver1@test.test" +OTHER_TO_NAME = "receiver1" +SUBJECT = "subject" +GLOBAL_DATA = {"arg": "arg"} +SUBSTITUTION_ONE = {"arg1": "arg1"} +SUBSTITUTION_TWO = {"arg2": "arg2"} + + +@tag("unisender_go") +@override_settings(ANYMAIL_UNISENDER_GO_API_KEY=None, ANYMAIL_UNISENDER_GO_API_URL="") +class TestUnisenderGoPayload(SimpleTestCase): + def test_unisender_go_payload__full(self): + substitutions = {TO_EMAIL: SUBSTITUTION_ONE, OTHER_TO_EMAIL: SUBSTITUTION_TWO} + email = AnymailMessage( + template_id=TEMPLATE_ID, + subject=SUBJECT, + merge_global_data=GLOBAL_DATA, + from_email=str(Address(display_name=FROM_NAME, addr_spec=FROM_EMAIL)), + to=[ + str(Address(display_name=TO_NAME, addr_spec=TO_EMAIL)), + str(Address(display_name=OTHER_TO_NAME, addr_spec=OTHER_TO_EMAIL)), + ], + merge_data=substitutions, + ) + backend = EmailBackend() + + payload = UnisenderGoPayload( + message=email, backend=backend, defaults=backend.send_defaults + ) + expected_payload = { + "from_email": FROM_EMAIL, + "from_name": FROM_NAME, + "global_substitutions": GLOBAL_DATA, + "headers": { + "to": ", ".join(email.to), + }, + "recipients": [ + { + "email": TO_EMAIL, + "substitutions": {**SUBSTITUTION_ONE, "to_name": TO_NAME}, + }, + { + "email": OTHER_TO_EMAIL, + "substitutions": {**SUBSTITUTION_TWO, "to_name": OTHER_TO_NAME}, + }, + ], + "subject": SUBJECT, + "template_id": TEMPLATE_ID, + } + + self.assertEqual(payload.data, expected_payload) + + def test_unisender_go_payload__cc_bcc(self): + cc_to_email = "receiver_cc@test.test" + bcc_to_email = "receiver_bcc@test.test" + email = AnymailMessage( + template_id=TEMPLATE_ID, + subject=SUBJECT, + merge_global_data=GLOBAL_DATA, + from_email=f"{FROM_NAME} <{FROM_EMAIL}>", + to=[ + str(Address(display_name=TO_NAME, addr_spec=TO_EMAIL)), + str(Address(display_name=OTHER_TO_NAME, addr_spec=OTHER_TO_EMAIL)), + ], + cc=[cc_to_email], + bcc=[bcc_to_email], + ) + backend = EmailBackend() + + payload = UnisenderGoPayload( + message=email, backend=backend, defaults=backend.send_defaults + ) + expected_headers = { + "To": f"{TO_NAME} <{TO_EMAIL}>, {OTHER_TO_NAME} <{OTHER_TO_EMAIL}>", + "CC": cc_to_email, + } + expected_headers = CaseInsensitiveDict(expected_headers) + expected_recipients = [ + { + "email": TO_EMAIL, + "substitutions": {"to_name": TO_NAME}, + }, + { + "email": OTHER_TO_EMAIL, + "substitutions": {"to_name": OTHER_TO_NAME}, + }, + {"email": cc_to_email}, + {"email": bcc_to_email}, + ] + + self.assertEqual(payload.data["headers"], expected_headers) + self.assertCountEqual(payload.data["recipients"], expected_recipients) + + def test_unisender_go_payload__parse_from__with_name(self): + email = AnymailMessage( + subject=SUBJECT, + merge_global_data=GLOBAL_DATA, + from_email=str(Address(display_name=FROM_NAME, addr_spec=FROM_EMAIL)), + to=[TO_EMAIL], + ) + backend = EmailBackend() + + payload = UnisenderGoPayload( + message=email, backend=backend, defaults=backend.send_defaults + ) + expected_payload = { + "from_email": FROM_EMAIL, + "from_name": FROM_NAME, + "global_substitutions": GLOBAL_DATA, + "headers": {"to": TO_EMAIL}, + "recipients": [{"email": TO_EMAIL}], + "subject": SUBJECT, + } + + self.assertEqual(payload.data, expected_payload) + + def test_unisender_go_payload__parse_from__without_name(self): + email = AnymailMessage( + subject=SUBJECT, + merge_global_data=GLOBAL_DATA, + from_email=FROM_EMAIL, + to=[TO_EMAIL], + ) + backend = EmailBackend() + + payload = UnisenderGoPayload( + message=email, backend=backend, defaults=backend.send_defaults + ) + expected_payload = { + "from_email": FROM_EMAIL, + "global_substitutions": GLOBAL_DATA, + "headers": {"to": TO_EMAIL}, + "recipients": [{"email": TO_EMAIL}], + "subject": SUBJECT, + } + + self.assertEqual(payload.data, expected_payload) + + @override_settings( + ANYMAIL={"UNISENDER_GO_SEND_DEFAULTS": {"esp_extra": {"skip_unsubscribe": 1}}}, + ) + def test_unisender_go_payload__parse_from__with_unsub__in_settings(self): + email = AnymailMessage( + subject=SUBJECT, + merge_global_data=GLOBAL_DATA, + from_email=f"{FROM_NAME} <{FROM_EMAIL}>", + to=[TO_EMAIL], + ) + backend = EmailBackend() + + payload = UnisenderGoPayload( + message=email, backend=backend, defaults=backend.send_defaults + ) + expected_payload = { + "from_email": FROM_EMAIL, + "from_name": FROM_NAME, + "global_substitutions": GLOBAL_DATA, + "headers": {"to": TO_EMAIL}, + "recipients": [{"email": TO_EMAIL}], + "subject": SUBJECT, + "skip_unsubscribe": 1, + } + + self.assertEqual(payload.data, expected_payload) + + @override_settings( + ANYMAIL={"UNISENDER_GO_SEND_DEFAULTS": {"esp_extra": {"skip_unsubscribe": 0}}}, + ) + def test_unisender_go_payload__parse_from__with_unsub__in_args(self): + email = AnymailMessage( + subject=SUBJECT, + merge_global_data=GLOBAL_DATA, + from_email=f"{FROM_NAME} <{FROM_EMAIL}>", + to=[TO_EMAIL], + esp_extra={"skip_unsubscribe": 1}, + ) + backend = EmailBackend() + + payload = UnisenderGoPayload( + message=email, backend=backend, defaults=backend.send_defaults + ) + expected_payload = { + "from_email": FROM_EMAIL, + "from_name": FROM_NAME, + "global_substitutions": GLOBAL_DATA, + "headers": {"to": TO_EMAIL}, + "recipients": [{"email": TO_EMAIL}], + "subject": SUBJECT, + "skip_unsubscribe": 1, + } + + self.assertEqual(payload.data, expected_payload) + + @override_settings( + ANYMAIL={ + "UNISENDER_GO_SEND_DEFAULTS": {"esp_extra": {"global_language": "en"}} + }, + ) + def test_unisender_go_payload__parse_from__global_language__in_settings(self): + email = AnymailMessage( + subject=SUBJECT, + merge_global_data=GLOBAL_DATA, + from_email=f"{FROM_NAME} <{FROM_EMAIL}>", + to=[TO_EMAIL], + ) + backend = EmailBackend() + + payload = UnisenderGoPayload( + message=email, backend=backend, defaults=backend.send_defaults + ) + expected_payload = { + "from_email": FROM_EMAIL, + "from_name": FROM_NAME, + "global_substitutions": GLOBAL_DATA, + "headers": {"to": TO_EMAIL}, + "recipients": [{"email": TO_EMAIL}], + "subject": SUBJECT, + "global_language": "en", + } + + self.assertEqual(payload.data, expected_payload) + + @override_settings( + ANYMAIL={ + "UNISENDER_GO_SEND_DEFAULTS": {"esp_extra": {"global_language": "fr"}} + }, + ) + def test_unisender_go_payload__parse_from__global_language__in_args(self): + email = AnymailMessage( + subject=SUBJECT, + merge_global_data=GLOBAL_DATA, + from_email=f"{FROM_NAME} <{FROM_EMAIL}>", + to=[TO_EMAIL], + esp_extra={"global_language": "en"}, + ) + backend = EmailBackend() + + payload = UnisenderGoPayload( + message=email, backend=backend, defaults=backend.send_defaults + ) + expected_payload = { + "from_email": FROM_EMAIL, + "from_name": FROM_NAME, + "global_substitutions": GLOBAL_DATA, + "headers": {"to": TO_EMAIL}, + "recipients": [{"email": TO_EMAIL}], + "subject": SUBJECT, + "global_language": "en", + } + + self.assertEqual(payload.data, expected_payload) + + def test_unisender_go_payload__parse_from__bypass_esp_extra(self): + email = AnymailMessage( + subject=SUBJECT, + merge_global_data=GLOBAL_DATA, + from_email=f"{FROM_NAME} <{FROM_EMAIL}>", + to=[TO_EMAIL], + esp_extra={ + "bypass_global": 1, + "bypass_unavailable": 1, + "bypass_unsubscribed": 1, + "bypass_complained": 1, + }, + ) + backend = EmailBackend() + + payload = UnisenderGoPayload( + message=email, backend=backend, defaults=backend.send_defaults + ) + expected_payload = { + "from_email": FROM_EMAIL, + "from_name": FROM_NAME, + "global_substitutions": GLOBAL_DATA, + "headers": {"to": TO_EMAIL}, + "recipients": [{"email": TO_EMAIL}], + "subject": SUBJECT, + "bypass_global": 1, + "bypass_unavailable": 1, + "bypass_unsubscribed": 1, + "bypass_complained": 1, + } + + self.assertEqual(payload.data, expected_payload) diff --git a/tests/test_unisender_go_webhooks.py b/tests/test_unisender_go_webhooks.py new file mode 100644 index 00000000..970800bd --- /dev/null +++ b/tests/test_unisender_go_webhooks.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +import datetime +import hashlib +import uuid +from datetime import timezone + +from django.test import RequestFactory, SimpleTestCase, override_settings, tag + +from anymail.exceptions import AnymailWebhookValidationFailure +from anymail.signals import EventType, RejectReason +from anymail.webhooks.unisender_go import UnisenderGoTrackingWebhookView + +EVENT_TYPE = EventType.SENT +EVENT_TIME = "2015-11-30 15:09:42" +EVENT_DATETIME = datetime.datetime(2015, 11, 30, 15, 9, 42, tzinfo=timezone.utc) +JOB_ID = "1a3Q2V-0000OZ-S0" +DELIVERY_RESPONSE = "550 Spam rejected" +UNISENDER_TEST_EMAIL = "recipient.email@example.com" +TEST_API_KEY = "api_key" +TEST_EMAIL_ID = str(uuid.uuid4()) +UNISENDER_TEST_DEFAULT_EXAMPLE = { + "auth": TEST_API_KEY, + "events_by_user": [ + { + "user_id": 456, + "project_id": "6432890213745872", + "project_name": "MyProject", + "events": [ + { + "event_name": "transactional_email_status", + "event_data": { + "job_id": JOB_ID, + "metadata": {"key1": "val1", "anymail_id": TEST_EMAIL_ID}, + "email": UNISENDER_TEST_EMAIL, + "status": EVENT_TYPE, + "event_time": EVENT_TIME, + "url": "http://some.url.com", + "delivery_info": { + "delivery_status": "err_delivery_failed", + "destination_response": DELIVERY_RESPONSE, + "user_agent": ( + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/57.0.2987.133 Safari/537.36" + ), + "ip": "111.111.111.111", + }, + }, + }, + { + "event_name": "transactional_spam_block", + "event_data": { + "block_time": "YYYY-MM-DD HH:MM:SS", + "block_type": "one_smtp", + "domain": "domain_name", + "SMTP_blocks_count": 8, + "domain_status": "blocked", + }, + }, + ], + } + ], +} +EXAMPLE_WITHOUT_DELIVERY_INFO = { + "auth": "", + "events_by_user": [ + { + "events": [ + { + "event_name": "transactional_email_status", + "event_data": { + "job_id": JOB_ID, + "metadata": {}, + "email": UNISENDER_TEST_EMAIL, + "status": EVENT_TYPE, + "event_time": EVENT_TIME, + }, + } + ] + } + ], +} +REQUEST_JSON = '{"auth":"api_key","key":"value"}' +REQUEST_JSON_MD5 = "8c64386327f53722434f44021a7a0d40" # md5 hash of REQUEST_JSON +REQUEST_DATA_AUTH = {"auth": REQUEST_JSON_MD5, "key": "value"} + + +def _request_json_to_dict_with_hashed_key(request_json: bytes) -> dict[str, str]: + new_auth = hashlib.md5(request_json).hexdigest() + return {"auth": new_auth, "key": "value"} + + +@tag("unisender_go") +class TestUnisenderGoWebhooks(SimpleTestCase): + def test_sent_event(self): + request = RequestFactory().post( + path="/", + data=UNISENDER_TEST_DEFAULT_EXAMPLE, + content_type="application/json", + ) + view = UnisenderGoTrackingWebhookView() + + events = view.parse_events(request) + event = events[0] + + self.assertEqual(len(events), 1) + self.assertEqual(event.event_type, EVENT_TYPE) + self.assertEqual(event.timestamp, EVENT_DATETIME) + self.assertIsNone(event.event_id) + self.assertEqual(event.recipient, UNISENDER_TEST_EMAIL) + self.assertEqual(event.reject_reason, RejectReason.OTHER) + self.assertEqual(event.mta_response, DELIVERY_RESPONSE) + self.assertDictEqual(event.metadata, {"key1": "val1"}) + + def test_without_delivery_info(self): + request = RequestFactory().post( + path="/", + data=EXAMPLE_WITHOUT_DELIVERY_INFO, + content_type="application/json", + ) + view = UnisenderGoTrackingWebhookView() + + events = view.parse_events(request) + + self.assertEqual(len(events), 1) + # Without metadata["anymail_id"], message_id uses the job_id. + # (This covers messages sent with "UNISENDER_GO_GENERATE_MESSAGE_ID": False.) + self.assertEqual(events[0].message_id, JOB_ID) + + @override_settings(ANYMAIL_UNISENDER_GO_API_KEY=TEST_API_KEY) + def test_check_authorization(self): + """Asserts that nothing is failing""" + request_data = _request_json_to_dict_with_hashed_key( + b'{"auth":"api_key","key":"value"}', + ) + request = RequestFactory().post( + path="/", data=request_data, content_type="application/json" + ) + view = UnisenderGoTrackingWebhookView() + + view.validate_request(request) + + @override_settings(ANYMAIL_UNISENDER_GO_API_KEY=TEST_API_KEY) + def test_check_authorization__fail__ordinar_quoters(self): + request_json = b"{'auth':'api_key','key':'value'}" + request_data = _request_json_to_dict_with_hashed_key(request_json) + request = RequestFactory().post( + path="/", data=request_data, content_type="application/json" + ) + view = UnisenderGoTrackingWebhookView() + + with self.assertRaises(AnymailWebhookValidationFailure): + view.validate_request(request) + + @override_settings(ANYMAIL_UNISENDER_GO_API_KEY=TEST_API_KEY) + def test_check_authorization__fail__spaces_after_semicolon(self): + request_json = b'{"auth": "api_key","key": "value"}' + request_data = _request_json_to_dict_with_hashed_key(request_json) + request = RequestFactory().post( + path="/", data=request_data, content_type="application/json" + ) + view = UnisenderGoTrackingWebhookView() + + with self.assertRaises(AnymailWebhookValidationFailure): + view.validate_request(request) + + @override_settings(ANYMAIL_UNISENDER_GO_API_KEY=TEST_API_KEY) + def test_check_authorization__fail__spaces_after_comma(self): + request_json = b'{"auth":"api_key", "key":"value"}' + request_data = _request_json_to_dict_with_hashed_key(request_json) + request = RequestFactory().post( + path="/", data=request_data, content_type="application/json" + ) + view = UnisenderGoTrackingWebhookView() + + with self.assertRaises(AnymailWebhookValidationFailure): + view.validate_request(request) diff --git a/tox.ini b/tox.ini index c550d314..97dd7061 100644 --- a/tox.ini +++ b/tox.ini @@ -67,6 +67,7 @@ setenv = postmark: ANYMAIL_ONLY_TEST=postmark resend: ANYMAIL_ONLY_TEST=resend sendgrid: ANYMAIL_ONLY_TEST=sendgrid + unisender_go: ANYMAIL_ONLY_TEST=unisender_go sendinblue: ANYMAIL_ONLY_TEST=sendinblue sparkpost: ANYMAIL_ONLY_TEST=sparkpost ignore_outcome =