Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Unisender Go: new ESP #352

Merged
merged 34 commits into from
Mar 5, 2024
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
c4ca211
Unisender Go: Implement backend for sending emails
Arondit Jan 10, 2024
009a47a
Unisender Go: Implement webhooks for getting info about email status …
Arondit Jan 10, 2024
624ba44
Unisender Go: Test backend and webhook
Arondit Jan 10, 2024
6c30ede
Unisender Go: Add info and infrastructure details about new ESP Unise…
Arondit Jan 10, 2024
c10da29
Unisender Go: Write docs about new ESP
Arondit Jan 10, 2024
77d414b
Unisender Go: Fix style problems
Arondit Jan 11, 2024
ef75750
Unisender Go: Fix failing test
Arondit Jan 11, 2024
441c884
Unisender Go: Fix failing test and fix docs typo
Arondit Jan 12, 2024
9d8974d
Unisender Go: Fix docs inconsistency and add more info about extra op…
Arondit Jan 22, 2024
80f382f
Unisender Go: Add fields to backend, fix typos, test changes
Arondit Jan 22, 2024
e707ec3
Unisender Go: Fix esp_extra parameters getting (use update_deep)
Arondit Jan 24, 2024
3a6f13a
Merge branch 'main' into feature/unisender-esp
Arondit Jan 24, 2024
819efb8
Merge branch 'main' into feature/unisender-esp
medmunds Feb 20, 2024
1cd4718
Update esp-feature-matrix in docs
medmunds Feb 20, 2024
7c71e4b
Unisender Go: Fix anymail config usage
Arondit Feb 20, 2024
b86aff9
Unisender Go: Fix backend inconsistency (add some set_, fix parse_rec…
Arondit Feb 20, 2024
7ccc985
Merge remote-tracking branch 'origin/feature/unisender-esp' into feat…
Arondit Feb 20, 2024
03c064b
Add live integration tests
medmunds Feb 21, 2024
a755ece
Unisender Go: Fix integration tests (not support merge_metadata, fix …
Arondit Feb 22, 2024
8dcc27b
Revert a755ece0150c5dbb305b7b40570eeeaaee0fca20
medmunds Feb 25, 2024
21155ee
Add backend tests, fix several issues
medmunds Feb 25, 2024
56d7611
Fix tracking webhook issues
medmunds Feb 29, 2024
599ed4c
Use "anymail_id" for metadata tracking id
medmunds Feb 29, 2024
7d6e98b
Use job_id as message_id if generation disabled
medmunds Feb 29, 2024
ebec01f
Rearrange backend methods for consistency
medmunds Mar 2, 2024
80d870f
Support cc/bcc
medmunds Mar 2, 2024
2c02202
Merge branch 'main' into fork/feature/unisender-esp
medmunds Mar 2, 2024
ed25682
Update docs
medmunds Mar 3, 2024
036ef24
Provide variables to integration test workflow
medmunds Mar 3, 2024
58aab53
Workaround Unisender Go display-name bugs
medmunds Mar 3, 2024
5c5f926
Unisender Go: Fix integrations bcc test and add unit test for cc/bcc
Arondit Mar 4, 2024
2435af9
pyproject.toml cosmetic tweaks
medmunds Mar 5, 2024
dd47e56
Add unisender_go to integration workflow
medmunds Mar 5, 2024
8d40a36
Update display-name workaround
medmunds Mar 5, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,16 @@ Release history
^^^^^^^^^^^^^^^
.. This extra heading level keeps the ToC from becoming unmanageably long

vNext
-----

*unreleased changes*

Features
~~~~~~~~
* **Unisender GO**: Add support for this ESP
(`docs <https://godocs.unisender.ru/>`__).
Arondit marked this conversation as resolved.
Show resolved Hide resolved

v10.2
-----

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
286 changes: 286 additions & 0 deletions anymail/backends/unisender_go.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,286 @@
from __future__ import annotations

import datetime
import typing
import uuid

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.exceptions import AnymailConfigurationError
from anymail.message import AnymailRecipientStatus
from anymail.utils import Attachment, EmailAddress, get_anymail_setting


class EmailBackend(AnymailRequestsBackend):
"""Unsidender GO v1 API Email Backend"""

esp_name = "UnisenderGo"
Arondit marked this conversation as resolved.
Show resolved Hide resolved

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
)
self.merge_field_format = get_anymail_setting(
"merge_field_format", esp_name=esp_name, kwargs=kwargs, default=None
)

api_url = get_anymail_setting(
"api_url", esp_name=esp_name, kwargs=kwargs, default=None
) # Don't set default, because url depends on location
Arondit marked this conversation as resolved.
Show resolved Hide resolved
# url template is https://go<number>.unisender.<lang>/<lang>/transactional/api/v1

if api_url is None:
raise AnymailConfigurationError("api_url required")
super().__init__(api_url, **kwargs)

def build_message_payload(
self, message: EmailMessage, defaults: dict
) -> UnisenderGoPayload:
return UnisenderGoPayload(message, defaults, self)

def parse_recipient_status(
self, response: Response, payload: UnisenderGoPayload, message: EmailMessage
) -> dict:
return {
recip.addr_spec: AnymailRecipientStatus(
message_id=payload.message_ids.get(recip.addr_spec), status="queued"
)
for recip in payload.all_recipients
}
Arondit marked this conversation as resolved.
Show resolved Hide resolved


class UnisenderGoPayload(RequestsPayload):
"""
API EXAMPLE:

request_body = {
"message": {
"recipients": [
{
"email": "[email protected]",
"substitutions": {
"CustomerId": 12452,
"to_name": "John Smith"
},
"metadata": {
"campaign_id": "c77f4f4e-3561-49f7-9f07-c35be01b4f43",
"customer_hash": "b253ac7"
}
}
],
"template_id": "string",
"tags": [
"string1"
],
"skip_unsubscribe": 0,
"global_language": "string",
"template_engine": "simple",
"global_substitutions": {
"property1": "string",
"property2": "string"
},
"global_metadata": {
"property1": "string",
"property2": "string"
},
"body": {
"html": "<b>Hello, {{to_name}}</b>",
"plaintext": "Hello, {{to_name}}",
"amp": "<!doctype html>Some HTML staff</html>"
},
"subject": "string",
"from_email": "[email protected]",
"from_name": "John Smith",
"reply_to": "[email protected]",
"track_links": 0,
"track_read": 0,
"bypass_global": 0,
"bypass_unavailable": 0,
"bypass_unsubscribed": 0,
"bypass_complained": 0,
"headers": {
"X-MyHeader": "some data",
"List-Unsubscribe": (
"<mailto: [email protected]?subject=unsubscribe>, "
"<http://www.example.com/unsubscribe/{{CustomerId}}>"
)
},
"attachments": [
{
"type": "text/plain",
"name": "readme.txt",
"content": "SGVsbG8sIHdvcmxkIQ=="
}
],
"inline_attachments": [
{
"type": "image/gif",
"name": "IMAGECID1",
"content": "R0lGODdhAwADAIABAP+rAP///ywAAAAAAwADAAACBIQRBwUAOw=="
}
],
"options": {
"send_at": "2021-11-19 10:00:00",
"unsubscribe_url": "https://example.org/unsubscribe/{{CustomerId}}",
"custom_backend_id": 0,
"smtp_pool_id": "string"
}
}
}
"""

data: dict

def __init__(
self,
message: EmailMessage,
defaults: dict,
backend: EmailBackend,
*args: typing.Any,
**kwargs: typing.Any,
):
self.all_recipients: list[
EmailAddress
] = [] # used for backend.parse_recipient_status
self.generate_message_id = backend.generate_message_id
self.message_ids: dict = {} # recipient -> generated message_id mapping
self.merge_data: dict = {} # late-bound per-recipient data
self.merge_global_data: dict = {}
self.merge_metadata: dict = {}
medmunds marked this conversation as resolved.
Show resolved Hide resolved

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 = {"headers": CaseInsensitiveDict()} # becomes json

# by default, Unisender Go adds unsubscribe link
# to fix this, you have to request tech support
if (
get_anymail_setting(
"skip_unsubscribe", esp_name=self.esp_name, default=False
Arondit marked this conversation as resolved.
Show resolved Hide resolved
)
is True
):
self.data["skip_unsubscribe"] = 1

def serialize_data(self) -> str:
"""Performs any necessary serialization on self.data, and returns the result."""
if self.generate_message_id:
self.set_anymail_id()

if not self.data["headers"]:
del self.data["headers"] # don't send empty headers

return self.serialize_json({"message": self.data})

def set_merge_data(self, merge_data: dict[str, dict[str, str]]) -> None:
if not merge_data:
return
for recipient in self.data["recipients"]:
recipient_email = recipient["email"]
recipient.setdefault("substitutions", {})
recipient["substitutions"] = {
**merge_data[recipient_email],
**recipient["substitutions"],
}

def set_merge_global_data(self, merge_global_data: dict[str, str]) -> None:
self.data.setdefault("global_substitutions", {})
self.data["global_substitutions"] = {
**self.data["global_substitutions"],
**merge_global_data,
}
Arondit marked this conversation as resolved.
Show resolved Hide resolved

def set_anymail_id(self) -> None:
"""Ensure each personalization has a known anymail_id for later event tracking"""
for recipient in self.data["recipients"]:
anymail_id = str(uuid.uuid4())

recipient.setdefault("metadata", {})
recipient["metadata"]["message_id"] = anymail_id

email_address = recipient["email"]
self.message_ids[email_address] = anymail_id

def set_from_email(self, email: EmailAddress) -> None:
self.data["from_email"] = email.addr_spec
self.data["from_name"] = email.display_name

def set_recipients(self, recipient_type: str, emails: list[EmailAddress]) -> None:
if not emails:
return
self.data["recipients"] = [
{"email": email.addr_spec, "substitutions": {"to_name": email.display_name}}
for email in emails
Arondit marked this conversation as resolved.
Show resolved Hide resolved
]
self.all_recipients += emails

def set_subject(self, subject: str) -> None:
if subject != "": # see note in set_text_body about template rendering
self.data["subject"] = subject

def set_reply_to(self, emails: list[EmailAddress]) -> None:
# Unisunder 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:
self.data["reply_to"] = emails[0].addr_spec

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 == "":
return
if "body" not in self.data:
self.data["body"] = {}
self.data["body"]["plaintext"] = body

def set_html_body(self, body: str) -> None:
if body == "":
return
if "body" not in self.data:
self.data["body"] = {}
self.data["body"]["html"] = body

def add_attachment(self, attachment: Attachment) -> None:
att = {
"content": attachment.b64content,
"type": attachment.mimetype,
"name": attachment.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)

Arondit marked this conversation as resolved.
Show resolved Hide resolved
def set_metadata(self, metadata: dict[str, str]) -> None:
self.data["global_metadata"] = metadata

def set_send_at(self, send_at: datetime.datetime) -> None:
self.data.setdefault("options", {})["send_at"] = send_at

def set_tags(self, tags: dict[str, str]) -> None:
Arondit marked this conversation as resolved.
Show resolved Hide resolved
self.data["tags"] = tags

Arondit marked this conversation as resolved.
Show resolved Hide resolved
def set_template_id(self, template_id: str) -> None:
self.data["template_id"] = template_id
Arondit marked this conversation as resolved.
Show resolved Hide resolved
6 changes: 6 additions & 0 deletions anymail/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
SparkPostInboundWebhookView,
SparkPostTrackingWebhookView,
)
from .webhooks.unisender_go import UnisenderGoTrackingWebhookView

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