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