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:
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,
|
||||
)
|
||||
Reference in New Issue
Block a user