Unisender Go: new ESP

Add support for Unisender Go

---------

Co-authored-by: Mike Edmunds <medmunds@gmail.com>
This commit is contained in:
Arondit
2024-03-05 22:38:40 +03:00
committed by GitHub
parent a2c0ed6817
commit a71a0d9af8
15 changed files with 2477 additions and 20 deletions

View File

@@ -50,6 +50,7 @@ jobs:
- { tox: django41-py310-sendgrid, python: "3.10" } - { tox: django41-py310-sendgrid, python: "3.10" }
- { tox: django41-py310-sendinblue, python: "3.10" } - { tox: django41-py310-sendinblue, python: "3.10" }
- { tox: django41-py310-sparkpost, python: "3.10" } - { tox: django41-py310-sparkpost, python: "3.10" }
- { tox: django41-py310-unisender_go, python: "3.10" }
steps: steps:
- name: Get code - name: Get code
@@ -97,3 +98,7 @@ jobs:
ANYMAIL_TEST_SENDINBLUE_DOMAIN: ${{ secrets.ANYMAIL_TEST_SENDINBLUE_DOMAIN }} ANYMAIL_TEST_SENDINBLUE_DOMAIN: ${{ secrets.ANYMAIL_TEST_SENDINBLUE_DOMAIN }}
ANYMAIL_TEST_SPARKPOST_API_KEY: ${{ secrets.ANYMAIL_TEST_SPARKPOST_API_KEY }} ANYMAIL_TEST_SPARKPOST_API_KEY: ${{ secrets.ANYMAIL_TEST_SPARKPOST_API_KEY }}
ANYMAIL_TEST_SPARKPOST_DOMAIN: ${{ secrets.ANYMAIL_TEST_SPARKPOST_DOMAIN }} 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 }}

View File

@@ -35,9 +35,14 @@ Features
* **Brevo:** Add support for batch sending * **Brevo:** Add support for batch sending
(`docs <https://anymail.dev/en/latest/esps/brevo/#batch-sending-merge-and-esp-templates>`__). (`docs <https://anymail.dev/en/latest/esps/brevo/#batch-sending-merge-and-esp-templates>`__).
* **Resend:** Add support for batch sending * **Resend:** Add support for batch sending
(`docs <https://anymail.dev/en/latest/esps/resend/#batch-sending-merge-and-esp-templates>`__). (`docs <https://anymail.dev/en/latest/esps/resend/#batch-sending-merge-and-esp-templates>`__).
* **Unisender Go**: Add support for this ESP
(`docs <https://anymail.dev/en/latest/esps/unisender_go/>`__).
(Thanks to `@Arondit`_ for the implementation.)
v10.2 v10.2
----- -----
@@ -1572,6 +1577,7 @@ Features
.. _@ailionx: https://github.com/ailionx .. _@ailionx: https://github.com/ailionx
.. _@alee: https://github.com/alee .. _@alee: https://github.com/alee
.. _@anstosa: https://github.com/anstosa .. _@anstosa: https://github.com/anstosa
.. _@Arondit: https://github.com/Arondit
.. _@b0d0nne11: https://github.com/b0d0nne11 .. _@b0d0nne11: https://github.com/b0d0nne11
.. _@calvin: https://github.com/calvin .. _@calvin: https://github.com/calvin
.. _@chrisgrande: https://github.com/chrisgrande .. _@chrisgrande: https://github.com/chrisgrande

View File

@@ -37,6 +37,7 @@ Anymail currently supports these ESPs:
* **Resend** * **Resend**
* **SendGrid** * **SendGrid**
* **SparkPost** * **SparkPost**
* **Unisender Go**
Anymail includes: Anymail includes:

View File

@@ -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)

View File

@@ -23,6 +23,7 @@ from .webhooks.sparkpost import (
SparkPostInboundWebhookView, SparkPostInboundWebhookView,
SparkPostTrackingWebhookView, SparkPostTrackingWebhookView,
) )
from .webhooks.unisender_go import UnisenderGoTrackingWebhookView
app_name = "anymail" app_name = "anymail"
urlpatterns = [ urlpatterns = [
@@ -125,6 +126,11 @@ urlpatterns = [
SparkPostTrackingWebhookView.as_view(), SparkPostTrackingWebhookView.as_view(),
name="sparkpost_tracking_webhook", name="sparkpost_tracking_webhook",
), ),
path(
"unisender_go/tracking/",
UnisenderGoTrackingWebhookView.as_view(),
name="unisender_go_tracking_webhook",
),
# Anymail uses a combined Mandrill webhook endpoint, # Anymail uses a combined Mandrill webhook endpoint,
# to simplify Mandrill's key-validation scheme: # to simplify Mandrill's key-validation scheme:
path("mandrill/", MandrillCombinedWebhookView.as_view(), name="mandrill_webhook"), path("mandrill/", MandrillCombinedWebhookView.as_view(), name="mandrill_webhook"),

View File

@@ -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,
)

View File

@@ -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` 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 <anymail-send-options>`,,,,,,,,,,, .. rubric:: :ref:`Anymail send options <anymail-send-options>`,,,,,,,,,,,,
:attr:`~AnymailMessage.envelope_sender`,Yes,No,No,Domain only,Yes,Domain only,Yes,No,No,No,Yes :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 :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 :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 :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 :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 :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 :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 :ref:`amp-email`,Yes,No,No,Yes,No,No,No,No,No,Yes,Yes,Yes
.. rubric:: :ref:`templates-and-merge`,,,,,,,,,,, .. rubric:: :ref:`templates-and-merge`,,,,,,,,,,,,
:attr:`~AnymailMessage.template_id`,Yes,Yes,Yes,Yes,Yes,Yes,No,Yes,No,Yes,Yes :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 :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 :attr:`~AnymailMessage.merge_global_data`,Yes,Yes,(emulated),(emulated),Yes,Yes,No,Yes,No,Yes,Yes,Yes
.. rubric:: :ref:`Status <esp-send-status>` and :ref:`event tracking <event-tracking>`,,,,,,,,,,, .. rubric:: :ref:`Status <esp-send-status>` and :ref:`event tracking <event-tracking>`,,,,,,,,,,,,
:attr:`~AnymailMessage.anymail_status`,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes :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 :class:`~anymail.signals.AnymailTrackingEvent` from webhooks,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes
.. rubric:: :ref:`Inbound handling <inbound>`,,,,,,,,,,, .. rubric:: :ref:`Inbound handling <inbound>`,,,,,,,,,,,,
:class:`~anymail.signals.AnymailInboundEvent` from webhooks,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,No,Yes,Yes :class:`~anymail.signals.AnymailInboundEvent` from webhooks,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,No,Yes,Yes,No
1 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`
2 .. rubric:: :ref:`Anymail send options <anymail-send-options>`
3 :attr:`~AnymailMessage.envelope_sender` Yes No No Domain only Yes Domain only Yes No No No Yes No
4 :attr:`~AnymailMessage.metadata` Yes Yes No Yes Yes Yes No Yes Yes Yes Yes Yes
5 :attr:`~AnymailMessage.merge_metadata` No Yes No Yes Yes Yes No Yes Yes Yes Yes Yes
6 :attr:`~AnymailMessage.send_at` No Yes Yes Yes No Yes No No No Yes Yes Yes
7 :attr:`~AnymailMessage.tags` Yes Yes Yes Yes Max 1 tag Yes Max 1 tag Max 1 tag Yes Yes Max 1 tag Yes
8 :attr:`~AnymailMessage.track_clicks` No No Yes Yes Yes Yes No Yes No Yes Yes Yes
9 :attr:`~AnymailMessage.track_opens` No No Yes Yes Yes Yes No Yes No Yes Yes Yes
10 :ref:`amp-email` Yes No No Yes No No No No No Yes Yes Yes
11 .. rubric:: :ref:`templates-and-merge`
12 :attr:`~AnymailMessage.template_id` Yes Yes Yes Yes Yes Yes No Yes No Yes Yes Yes
13 :attr:`~AnymailMessage.merge_data` Yes Yes Yes Yes Yes Yes No Yes No Yes Yes Yes
14 :attr:`~AnymailMessage.merge_global_data` Yes Yes (emulated) (emulated) Yes Yes No Yes No Yes Yes Yes
15 .. rubric:: :ref:`Status <esp-send-status>` and :ref:`event tracking <event-tracking>`
16 :attr:`~AnymailMessage.anymail_status` Yes Yes Yes Yes Yes Yes Yes Yes Yes Yes Yes Yes
17 :class:`~anymail.signals.AnymailTrackingEvent` from webhooks Yes Yes Yes Yes Yes Yes Yes Yes Yes Yes Yes Yes
18 .. rubric:: :ref:`Inbound handling <inbound>`
19 :class:`~anymail.signals.AnymailInboundEvent` from webhooks Yes Yes Yes Yes Yes Yes Yes Yes No Yes Yes No

View File

@@ -23,6 +23,7 @@ and notes about any quirks or limitations:
resend resend
sendgrid sendgrid
sparkpost sparkpost
unisender_go
Anymail feature support Anymail feature support

426
docs/esps/unisender_go.rst Normal file
View File

@@ -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": "<your 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 <anymail.message.AnymailStatus.recipients>`
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 <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 <batch-send>` (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 <ANYMAIL_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 <esp-stored-templates>`,
on-the-fly templating, and :ref:`batch sending <batch-send>` 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 <bob@example.com>"],
)
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 <django.core.mail.EmailMessage>` (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 <bob@example.com>"],
# 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 <unisender-go-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 <batch-send>` 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 <unisender-go-quirks>`
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 <event-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 <ANYMAIL_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 <anymail.message.AnymailStatus.recipients>`
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 <ANYMAIL_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.)

View File

@@ -14,7 +14,7 @@ authors = [
description = """\ description = """\
Django email backends and webhooks for Amazon SES, Brevo (Sendinblue), Django email backends and webhooks for Amazon SES, Brevo (Sendinblue),
MailerSend, Mailgun, Mailjet, Mandrill, Postal, Postmark, Resend, MailerSend, Mailgun, Mailjet, Mandrill, Postal, Postmark, Resend,
SendGrid, and SparkPost\ SendGrid, SparkPost and Unisender Go\
""" """
# readme: see tool.hatch.metadata.hooks.custom below # readme: see tool.hatch.metadata.hooks.custom below
keywords = [ keywords = [
@@ -25,6 +25,7 @@ keywords = [
"Postal", "Postmark", "Postal", "Postmark",
"Resend", "Resend",
"SendGrid", "SendinBlue", "SparkPost", "SendGrid", "SendinBlue", "SparkPost",
"Unisender Go",
] ]
classifiers = [ classifiers = [
"Development Status :: 5 - Production/Stable", "Development Status :: 5 - Production/Stable",
@@ -74,6 +75,7 @@ resend = ["svix"]
sendgrid = [] sendgrid = []
sendinblue = [] sendinblue = []
sparkpost = [] sparkpost = []
unisender-go = []
postal = [ postal = [
# Postal requires cryptography for verifying webhooks. # Postal requires cryptography for verifying webhooks.
# Cryptography's wheels are broken on darwin-arm64 before Python 3.9. # Cryptography's wheels are broken on darwin-arm64 before Python 3.9.

View File

@@ -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 <from@example.com>",
["Recipient #1 <to1@example.com>", "to2@example.com"],
cc=["Carbon Copy <cc1@example.com>", "cc2@example.com"],
bcc=["Blind Copy <bcc1@example.com>", "bcc2@example.com"],
)
msg.send()
data = self.get_api_call_json()
self.assertEqual(data["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 <to1@example.com>, to2@example.com"
)
self.assertEqual(
headers["cc"], "Carbon Copy <cc1@example.com>, 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" <to1@example.com>',
'"angle <brackets>" <to2@example.com>',
'"(without) special / chars" <to3@example.com>',
]
self.message.cc = [
'"Someone @example.com" <cc1@example.com>',
'"[without] special & chars" <cc2@example.com>',
]
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?= <to1@example.com>, "
"=?utf-8?q?angle_=3Cbrackets=3E?= <to2@example.com>, "
'"(without) special / chars" <to3@example.com>',
)
self.assertEqual(
headers["cc"],
"=?utf-8?q?Someone_=40example=2Ecom?= <cc1@example.com>, "
'"[without] special & chars" <cc2@example.com>',
)
def test_html_message(self):
text_content = "This is an important message."
html_content = "<p>This is an <strong>important</strong> message.</p>"
email = mail.EmailMultiAlternatives(
"Subject", text_content, "from@example.com", ["to@example.com"]
)
email.attach_alternative(html_content, "text/html")
email.send()
data = self.get_api_call_json()
self.assertEqual(
data["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 = "<p>This is an <strong>important</strong> message.</p>"
email = mail.EmailMessage(
"Subject", html_content, "from@example.com", ["to@example.com"]
)
email.content_subtype = "html" # Main content is now text/html
email.send()
data = self.get_api_call_json()
self.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("<p>HTML</p>", "text/html")
self.message.attach_alternative("<p>And AMP HTML</p>", "text/x-amp-html")
self.message.send()
data = self.get_api_call_json()
self.assertEqual(data["message"]["body"]["html"], "<p>HTML</p>")
self.assertEqual(data["message"]["body"]["amp"], "<p>And AMP HTML</p>")
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" <reply@example.com']
self.message.send()
data = self.get_api_call_json()
self.assertEqual(data["message"]["reply_to"], "reply@example.com")
self.assertEqual(data["message"]["reply_to_name"], "Reply recipient")
def test_reply_to_name_workaround(self):
# Check workaround for reply-to display-name containing special chars
self.message.reply_to = ['"Reply (parens)" <reply@example.com']
self.message.send()
data = self.get_api_call_json()
# Special chars force RFC 2047 encoded word
self.assertEqual(
data["message"]["reply_to_name"], "=?utf-8?q?Reply_=28parens=29?="
)
def test_attachments(self):
text_content = "* Item one\n* Item two\n* Item three"
self.message.attach(
filename="test.txt", content=text_content, mimetype="text/plain"
)
# Should guess mimetype if not provided...
png_content = b"PNG\xb4 pretend this is the contents of a png file"
self.message.attach(filename="test.png", content=png_content)
# Should work with a MIMEBase object (also tests no filename)...
pdf_content = b"PDF\xb4 pretend this is valid pdf data"
mimeattachment = MIMEBase("application", "pdf")
mimeattachment.set_payload(pdf_content)
self.message.attach(mimeattachment)
self.message.send()
data = self.get_api_call_json()
attachments = data["message"]["attachments"]
self.assertEqual(len(attachments), 3)
self.assertEqual(
attachments[0],
{
"name": "test.txt",
"content": b64encode(text_content.encode("utf-8")).decode("ascii"),
"type": "text/plain",
},
)
self.assertEqual(
attachments[1],
{
"name": "test.png",
"content": b64encode(png_content).decode("ascii"),
"type": "image/png", # (type inferred from filename)
},
)
self.assertEqual(
attachments[2],
{
"name": "", # no filename -- but param is required
"content": b64encode(pdf_content).decode("ascii"),
"type": "application/pdf",
},
)
def test_embedded_images(self):
image_filename = SAMPLE_IMAGE_FILENAME
image_path = sample_image_path(image_filename)
image_data = sample_image_content(image_filename)
cid = attach_inline_image_file(self.message, image_path) # Read from a png file
html_content = (
'<p>This has an <img src="cid:%s" alt="inline" /> image.</p>' % cid
)
self.message.attach_alternative(html_content, "text/html")
self.message.send()
data = self.get_api_call_json()
self.assertEqual(
data["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("<p>First html is OK</p>", "text/html")
self.message.attach_alternative("<p>But not second html</p>", "text/html")
with self.assertRaises(AnymailUnsupportedFeature):
self.message.send()
def test_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 <bob@example.com>",
"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 <bob@example.com>"]
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 <to2@example.com>"],
)
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>",
"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"])

View File

@@ -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("<p>HTML content</p>", "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?" <test+to2@anymail.dev>'],
cc=["test+cc1@anymail.dev", '"Copy 2, OK?" <test+cc2@anymail.dev>'],
bcc=[
f"test+bcc1@{ANYMAIL_TEST_UNISENDER_GO_DOMAIN}",
f'"BCC 2, OK?" <bcc2@{ANYMAIL_TEST_UNISENDER_GO_DOMAIN}>',
],
# Unisender Go only supports a single reply-to:
reply_to=['"Reply, with comma (and parens)" <reply@example.com>'],
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("<p>HTML content</p>", "text/html")
message.attach_alternative("<p>AMP HTML content</p>", "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+to1@anymail.dev>", "test+to2@anymail.dev"],
reply_to=["Do not reply <reply@example.dev>"],
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()

View File

@@ -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)

View File

@@ -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)

View File

@@ -67,6 +67,7 @@ setenv =
postmark: ANYMAIL_ONLY_TEST=postmark postmark: ANYMAIL_ONLY_TEST=postmark
resend: ANYMAIL_ONLY_TEST=resend resend: ANYMAIL_ONLY_TEST=resend
sendgrid: ANYMAIL_ONLY_TEST=sendgrid sendgrid: ANYMAIL_ONLY_TEST=sendgrid
unisender_go: ANYMAIL_ONLY_TEST=unisender_go
sendinblue: ANYMAIL_ONLY_TEST=sendinblue sendinblue: ANYMAIL_ONLY_TEST=sendinblue
sparkpost: ANYMAIL_ONLY_TEST=sparkpost sparkpost: ANYMAIL_ONLY_TEST=sparkpost
ignore_outcome = ignore_outcome =