Skip to content

Commit

Permalink
Unisender Go: new ESP
Browse files Browse the repository at this point in the history
Add support for Unisender Go

---------

Co-authored-by: Mike Edmunds <[email protected]>
  • Loading branch information
Arondit and medmunds authored Mar 5, 2024
1 parent a2c0ed6 commit a71a0d9
Show file tree
Hide file tree
Showing 15 changed files with 2,477 additions and 20 deletions.
5 changes: 5 additions & 0 deletions .github/workflows/integration-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }}
6 changes: 6 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
-----
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ Anymail currently supports these ESPs:
* **Resend**
* **SendGrid**
* **SparkPost**
* **Unisender Go**

Anymail includes:

Expand Down
343 changes: 343 additions & 0 deletions anymail/backends/unisender_go.py
Original file line number Diff line number Diff line change
@@ -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": [
"[email protected]",
"[email protected]",
],
"failed_emails": {
"[email protected]": "temporary_unavailable",
"bad@address": "invalid",
"[email protected]": "duplicate",
"[email protected]": "permanent_unavailable",
"[email protected]": "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)
Loading

0 comments on commit a71a0d9

Please sign in to comment.