mirror of
https://github.com/pacnpal/django-anymail.git
synced 2025-12-20 03:41:05 -05:00
Unisender Go: new ESP
Add support for Unisender Go --------- Co-authored-by: Mike Edmunds <medmunds@gmail.com>
This commit is contained in:
5
.github/workflows/integration-test.yml
vendored
5
.github/workflows/integration-test.yml
vendored
@@ -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 }}
|
||||
|
||||
@@ -35,9 +35,14 @@ Features
|
||||
|
||||
* **Brevo:** Add support for batch sending
|
||||
(`docs <https://anymail.dev/en/latest/esps/brevo/#batch-sending-merge-and-esp-templates>`__).
|
||||
|
||||
* **Resend:** Add support for batch sending
|
||||
(`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
|
||||
-----
|
||||
@@ -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
|
||||
|
||||
@@ -37,6 +37,7 @@ Anymail currently supports these ESPs:
|
||||
* **Resend**
|
||||
* **SendGrid**
|
||||
* **SparkPost**
|
||||
* **Unisender Go**
|
||||
|
||||
Anymail includes:
|
||||
|
||||
|
||||
343
anymail/backends/unisender_go.py
Normal file
343
anymail/backends/unisender_go.py
Normal 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)
|
||||
@@ -23,6 +23,7 @@ from .webhooks.sparkpost import (
|
||||
SparkPostInboundWebhookView,
|
||||
SparkPostTrackingWebhookView,
|
||||
)
|
||||
from .webhooks.unisender_go import UnisenderGoTrackingWebhookView
|
||||
|
||||
app_name = "anymail"
|
||||
urlpatterns = [
|
||||
@@ -125,6 +126,11 @@ urlpatterns = [
|
||||
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"),
|
||||
|
||||
123
anymail/webhooks/unisender_go.py
Normal file
123
anymail/webhooks/unisender_go.py
Normal 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,
|
||||
)
|
||||
@@ -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 <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 <esp-send-status>` and :ref:`event tracking <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 <inbound>`,,,,,,,,,,,
|
||||
: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 <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 <esp-send-status>` and :ref:`event tracking <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 <inbound>`,,,,,,,,,,,,
|
||||
:class:`~anymail.signals.AnymailInboundEvent` from webhooks,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,No,Yes,Yes,No
|
||||
|
||||
|
@@ -23,6 +23,7 @@ and notes about any quirks or limitations:
|
||||
resend
|
||||
sendgrid
|
||||
sparkpost
|
||||
unisender_go
|
||||
|
||||
|
||||
Anymail feature support
|
||||
|
||||
426
docs/esps/unisender_go.rst
Normal file
426
docs/esps/unisender_go.rst
Normal 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.)
|
||||
@@ -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.
|
||||
|
||||
895
tests/test_unisender_go_backend.py
Normal file
895
tests/test_unisender_go_backend.py
Normal 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"])
|
||||
172
tests/test_unisender_go_integration.py
Normal file
172
tests/test_unisender_go_integration.py
Normal 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()
|
||||
299
tests/test_unisender_go_payload.py
Normal file
299
tests/test_unisender_go_payload.py
Normal 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)
|
||||
177
tests/test_unisender_go_webhooks.py
Normal file
177
tests/test_unisender_go_webhooks.py
Normal 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)
|
||||
Reference in New Issue
Block a user