Skip to content

Commit 90367b3

Browse files
hleroymedmunds
andauthored
Scaleway: new ESP
Add support for Scaleway Transactional Email backend. (Tracking webhooks will be added separately, later.) --------- Co-authored-by: Mike Edmunds <[email protected]>
1 parent 4b2d6f8 commit 90367b3

File tree

10 files changed

+934
-22
lines changed

10 files changed

+934
-22
lines changed

CHANGELOG.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ vNext
3333
Features
3434
~~~~~~~~
3535

36+
* **Scaleway:** Add support for this ESP.
37+
(See `docs <https://anymail.dev/en/latest/esps/scaleway/>`__.
38+
Thanks to `@hleroy`_ for the contribution.)
39+
3640
* **SendGrid:** Add optional signature verification for tracking webhooks.
3741
To support this, Anymail now includes the :pypi:`cryptography` package when
3842
installed with the ``django-anymail[sendgrid]`` extra.
@@ -1832,6 +1836,7 @@ Features
18321836
.. _@fdemmer: https://github.com/fdemmer
18331837
.. _@Flexonze: https://github.com/Flexonze
18341838
.. _@gdvalderrama: https://github.com/gdvalderrama
1839+
.. _@hleroy: https://github.com/hleroy
18351840
.. _@Honza-m: https://github.com/Honza-m
18361841
.. _@izimobil: https://github.com/izimobil
18371842
.. _@janneThoft: https://github.com/janneThoft

README.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ Anymail currently supports these ESPs:
3535
* **Postal** (self-hosted ESP)
3636
* **Postmark** (ActiveCampaign transactional email)
3737
* **Resend**
38+
* **Scaleway TEM**
3839
* **SendGrid** (Twilio transactional email; no longer tested)
3940
* **SparkPost** (Bird transactional email)
4041
* **Unisender Go**

anymail/backends/scaleway.py

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
from urllib.parse import quote
2+
3+
from ..exceptions import AnymailAPIError
4+
from ..message import AnymailRecipientStatus
5+
from ..utils import get_anymail_setting
6+
from .base_requests import AnymailRequestsBackend, RequestsPayload
7+
8+
9+
class EmailBackend(AnymailRequestsBackend):
10+
"""
11+
Scaleway Transactional Email API Backend
12+
"""
13+
14+
esp_name = "Scaleway"
15+
16+
def __init__(self, **kwargs):
17+
"""Init options from Django settings"""
18+
esp_name = self.esp_name
19+
self.secret_key = get_anymail_setting(
20+
"secret_key", esp_name=esp_name, kwargs=kwargs, allow_bare=True
21+
)
22+
self.project_id = get_anymail_setting(
23+
"project_id", esp_name=esp_name, kwargs=kwargs
24+
)
25+
region = get_anymail_setting(
26+
"region", esp_name=esp_name, kwargs=kwargs, default="fr-par"
27+
)
28+
api_url_template = get_anymail_setting(
29+
"api_url",
30+
esp_name=esp_name,
31+
kwargs=kwargs,
32+
default=(
33+
"https://api.scaleway.com/transactional-email/v1alpha1/regions/{region}/"
34+
),
35+
)
36+
api_url = api_url_template.format(region=quote(region, safe=""))
37+
if not api_url.endswith("/"):
38+
api_url += "/"
39+
super().__init__(api_url, **kwargs)
40+
41+
def build_message_payload(self, message, defaults):
42+
return ScalewayPayload(message, defaults, self)
43+
44+
_recipient_status_map = {
45+
# Scaleway send status -> Anymail status.
46+
# (In practice, only "sending" seems to be reported.
47+
# Invalid addresses cause an API failure.
48+
# Blocked addresses show as "sending" and are rejected later.)
49+
"unknown": "unknown",
50+
"new": "queued",
51+
"sending": "queued",
52+
"sent": "sent",
53+
"failed": "failed",
54+
"canceled": "failed",
55+
}
56+
57+
def parse_recipient_status(self, response, payload, message):
58+
parsed_response = self.deserialize_json_response(response, payload, message)
59+
statuses = {}
60+
try:
61+
emails = parsed_response["emails"]
62+
except (KeyError, TypeError):
63+
raise AnymailAPIError(
64+
"Invalid response from Scaleway API",
65+
email_message=message,
66+
payload=payload,
67+
response=response,
68+
backend=self,
69+
)
70+
71+
for email_info in emails:
72+
recipient = email_info.get("mail_rcpt")
73+
message_id = email_info.get("id")
74+
status = email_info.get("status")
75+
anymail_status = AnymailRecipientStatus(
76+
message_id=message_id,
77+
status=self._recipient_status_map.get(status, "unknown"),
78+
)
79+
if recipient:
80+
statuses[recipient] = anymail_status
81+
return statuses
82+
83+
84+
class ScalewayPayload(RequestsPayload):
85+
def __init__(self, message, defaults, backend, *args, **kwargs):
86+
self.project_id = backend.project_id
87+
http_headers = kwargs.pop("headers", {})
88+
http_headers["X-Auth-Token"] = backend.secret_key
89+
http_headers["Content-Type"] = "application/json"
90+
super().__init__(
91+
message, defaults, backend, headers=http_headers, *args, **kwargs
92+
)
93+
94+
def get_api_endpoint(self):
95+
return "emails"
96+
97+
def init_payload(self):
98+
self.data = {
99+
"project_id": self.project_id,
100+
}
101+
102+
def _scaleway_email(self, email):
103+
"""Expand an Anymail EmailAddress into Scaleway's {"email", "name"} dict"""
104+
result = {"email": email.addr_spec}
105+
if email.display_name:
106+
result["name"] = email.display_name
107+
return result
108+
109+
def set_from_email(self, email):
110+
self.data["from"] = self._scaleway_email(email)
111+
112+
def set_recipients(self, recipient_type, emails):
113+
assert recipient_type in {"to", "cc", "bcc"}
114+
if emails:
115+
self.data[recipient_type] = [
116+
self._scaleway_email(email) for email in emails
117+
]
118+
119+
def set_subject(self, subject):
120+
if subject:
121+
self.data["subject"] = subject
122+
123+
def set_text_body(self, body):
124+
if body:
125+
self.data["text"] = body
126+
127+
def set_html_body(self, html):
128+
if html:
129+
self.data["html"] = html
130+
131+
def add_attachment(self, attachment):
132+
if attachment.inline:
133+
self.unsupported_feature("inline attachments")
134+
self.data.setdefault("attachments", []).append(
135+
{
136+
"name": attachment.name,
137+
"type": attachment.mimetype,
138+
"content": attachment.b64content,
139+
}
140+
)
141+
142+
def set_extra_headers(self, headers):
143+
self.data.setdefault("additional_headers", []).extend(
144+
[{"key": key, "value": str(value)} for key, value in headers.items()]
145+
)
146+
147+
def set_reply_to(self, emails):
148+
if emails:
149+
reply_to_string = ", ".join([str(email) for email in emails])
150+
self.data.setdefault("additional_headers", []).append(
151+
{"key": "Reply-To", "value": reply_to_string}
152+
)
153+
154+
def set_tags(self, tags):
155+
if tags:
156+
self.data.setdefault("additional_headers", []).append(
157+
{"key": "X-Tags", "value": self.serialize_json(tags)}
158+
)
159+
160+
def set_metadata(self, metadata):
161+
if metadata:
162+
self.data.setdefault("additional_headers", []).append(
163+
{"key": "X-Metadata", "value": self.serialize_json(metadata)}
164+
)
165+
166+
def set_esp_extra(self, extra):
167+
self.data.update(extra)
168+
169+
def serialize_data(self):
170+
return self.serialize_json(self.data)

docs/esps/esp-feature-matrix.csv

Lines changed: 21 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,21 @@
1-
Email Service Provider,:ref:`amazon-ses-backend`,:ref:`brevo-backend`,:ref:`mailersend-backend`,:ref:`mailgun-backend`,:ref:`mailjet-backend`,:ref:`mandrill-backend`,:ref:`postal-backend`,:ref:`postmark-backend`,:ref:`resend-backend`,:ref:`sendgrid-backend`,:ref:`sparkpost-backend`,:ref:`unisender-go-backend`
2-
Anymail support status [#support-status]_,Full,Full,Full,Full,Full,Limited,Limited,Full,Full,**Unsupported**,Full,Full
3-
.. rubric:: :ref:`Anymail send options <anymail-send-options>`,,,,,,,,,,,,
4-
:attr:`~AnymailMessage.envelope_sender`,Yes,No,No,Domain only,Yes,Domain only,Yes,No,No,No,Yes,No
5-
:attr:`~AnymailMessage.merge_headers`,Yes [#caveats]_,Yes,No,Yes,Yes,No,No,Yes,Yes,Yes,Yes [#caveats]_,Yes [#caveats]_
6-
:attr:`~AnymailMessage.metadata`,Yes,Yes,No,Yes,Yes,Yes,No,Yes,Yes,Yes,Yes,Yes
7-
:attr:`~AnymailMessage.merge_metadata`,Yes [#caveats]_,Yes,No,Yes,Yes,Yes,No,Yes,Yes,Yes,Yes,Yes
8-
:attr:`~AnymailMessage.send_at`,No,Yes,Yes,Yes,No,Yes,No,No,Yes,Yes,Yes,Yes
9-
:attr:`~AnymailMessage.tags`,Yes,Yes,Yes,Yes,Max 1 tag,Yes,Max 1 tag,Max 1 tag,Yes,Yes,Max 1 tag,Yes
10-
:attr:`~AnymailMessage.track_clicks`,No [#nocontrol]_,No [#nocontrol]_,Yes,Yes,Yes,Yes,No,Yes,No,Yes,Yes,Yes
11-
:attr:`~AnymailMessage.track_opens`,No [#nocontrol]_,No [#nocontrol]_,Yes,Yes,Yes,Yes,No,Yes,No,Yes,Yes,Yes
12-
:ref:`amp-email`,Yes,No,No,Yes,No,No,No,No,No,Yes,Yes,Yes
13-
.. rubric:: :ref:`templates-and-merge`,,,,,,,,,,,,
14-
:attr:`~AnymailMessage.template_id`,Yes,Yes,Yes,Yes,Yes,Yes,No,Yes,No,Yes,Yes,Yes
15-
:attr:`~AnymailMessage.merge_data`,Yes [#caveats]_,Yes,Yes,Yes,Yes,Yes,No,Yes,No,Yes,Yes,Yes
16-
:attr:`~AnymailMessage.merge_global_data`,Yes [#caveats]_,Yes,Yes,Yes,Yes,Yes,No,Yes,No,Yes,Yes,Yes
17-
.. rubric:: :ref:`Status <esp-send-status>` and :ref:`event tracking <event-tracking>`,,,,,,,,,,,,
18-
:attr:`~AnymailMessage.anymail_status`,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes
19-
:class:`~anymail.signals.AnymailTrackingEvent` from webhooks,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes
20-
.. rubric:: :ref:`Inbound handling <inbound>`,,,,,,,,,,,,
21-
:class:`~anymail.signals.AnymailInboundEvent` from webhooks,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,No,Yes,Yes,No
1+
Email Service Provider,:ref:`amazon-ses-backend`,:ref:`brevo-backend`,:ref:`mailersend-backend`,:ref:`mailgun-backend`,:ref:`mailjet-backend`,:ref:`mandrill-backend`,:ref:`postal-backend`,:ref:`postmark-backend`,:ref:`resend-backend`,:ref:`scaleway-backend`,:ref:`sendgrid-backend`,:ref:`sparkpost-backend`,:ref:`unisender-go-backend`
2+
Anymail support status [#support-status]_,Full,Full,Full,Full,Full,Limited,Limited,Full,Full,Full,**Unsupported**,Full,Full
3+
.. rubric:: :ref:`Anymail send options <anymail-send-options>`,,,,,,,,,,,,,
4+
:attr:`~AnymailMessage.envelope_sender`,Yes,No,No,Domain only,Yes,Domain only,Yes,No,No,No,No,Yes,No
5+
:attr:`~AnymailMessage.merge_headers`,Yes [#caveats]_,Yes,No,Yes,Yes,No,No,Yes,Yes,No,Yes,Yes [#caveats]_,Yes [#caveats]_
6+
:attr:`~AnymailMessage.metadata`,Yes,Yes,No,Yes,Yes,Yes,No,Yes,Yes,Yes,Yes,Yes,Yes
7+
:attr:`~AnymailMessage.merge_metadata`,Yes [#caveats]_,Yes,No,Yes,Yes,Yes,No,Yes,Yes,No,Yes,Yes,Yes
8+
:attr:`~AnymailMessage.send_at`,No,Yes,Yes,Yes,No,Yes,No,No,Yes,No,Yes,Yes,Yes
9+
:attr:`~AnymailMessage.tags`,Yes,Yes,Yes,Yes,Max 1 tag,Yes,Max 1 tag,Max 1 tag,Yes,Yes,Yes,Max 1 tag,Yes
10+
:attr:`~AnymailMessage.track_clicks`,No [#nocontrol]_,No [#nocontrol]_,Yes,Yes,Yes,Yes,No,Yes,No,No,Yes,Yes,Yes
11+
:attr:`~AnymailMessage.track_opens`,No [#nocontrol]_,No [#nocontrol]_,Yes,Yes,Yes,Yes,No,Yes,No,No,Yes,Yes,Yes
12+
:ref:`amp-email`,Yes,No,No,Yes,No,No,No,No,No,No,Yes,Yes,Yes
13+
.. rubric:: :ref:`templates-and-merge`,,,,,,,,,,,,,
14+
:attr:`~AnymailMessage.template_id`,Yes,Yes,Yes,Yes,Yes,Yes,No,Yes,No,No,Yes,Yes,Yes
15+
:attr:`~AnymailMessage.merge_data`,Yes [#caveats]_,Yes,Yes,Yes,Yes,Yes,No,Yes,No,No,Yes,Yes,Yes
16+
:attr:`~AnymailMessage.merge_global_data`,Yes [#caveats]_,Yes,Yes,Yes,Yes,Yes,No,Yes,No,No,Yes,Yes,Yes
17+
.. rubric:: :ref:`Status <esp-send-status>` and :ref:`event tracking <event-tracking>`,,,,,,,,,,,,,
18+
:attr:`~AnymailMessage.anymail_status`,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes
19+
:class:`~anymail.signals.AnymailTrackingEvent` from webhooks,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Not yet,Yes,Yes,Yes
20+
.. rubric:: :ref:`Inbound handling <inbound>`,,,,,,,,,,,,,
21+
:class:`~anymail.signals.AnymailInboundEvent` from webhooks,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,No,No,Yes,Yes,No

docs/esps/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ and notes about any quirks or limitations:
2121
postal
2222
postmark
2323
resend
24+
scaleway
2425
sendgrid
2526
sparkpost
2627
unisender_go

0 commit comments

Comments
 (0)