Skip to content

Commit 62bd066

Browse files
authored
MailerSend: add support (new ESP for Anymail)
Closes #298
1 parent c58640d commit 62bd066

File tree

15 files changed

+3093
-27
lines changed

15 files changed

+3093
-27
lines changed

.github/workflows/integration-test.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ jobs:
4040
# combination, to avoid rapidly consuming the testing accounts' entire send allotments.
4141
config:
4242
- { tox: django41-py310-amazon_ses, python: "3.10" }
43+
- { tox: django41-py310-mailersend, python: "3.10" }
4344
- { tox: django41-py310-mailgun, python: "3.10" }
4445
- { tox: django41-py310-mailjet, python: "3.10" }
4546
- { tox: django41-py310-mandrill, python: "3.10" }
@@ -74,6 +75,8 @@ jobs:
7475
ANYMAIL_TEST_AMAZON_SES_DOMAIN: ${{ secrets.ANYMAIL_TEST_AMAZON_SES_DOMAIN }}
7576
ANYMAIL_TEST_AMAZON_SES_REGION_NAME: ${{ secrets.ANYMAIL_TEST_AMAZON_SES_REGION_NAME }}
7677
ANYMAIL_TEST_AMAZON_SES_SECRET_ACCESS_KEY: ${{ secrets.ANYMAIL_TEST_AMAZON_SES_SECRET_ACCESS_KEY }}
78+
ANYMAIL_TEST_MAILERSEND_API_TOKEN: ${{ secrets.ANYMAIL_TEST_MAILERSEND_API_TOKEN }}
79+
ANYMAIL_TEST_MAILERSEND_DOMAIN: ${{ secrets.ANYMAIL_TEST_MAILERSEND_DOMAIN }}
7780
ANYMAIL_TEST_MAILGUN_API_KEY: ${{ secrets.ANYMAIL_TEST_MAILGUN_API_KEY }}
7881
ANYMAIL_TEST_MAILGUN_DOMAIN: ${{ secrets.ANYMAIL_TEST_MAILGUN_DOMAIN }}
7982
ANYMAIL_TEST_MAILJET_API_KEY: ${{ secrets.ANYMAIL_TEST_MAILJET_API_KEY }}

CHANGELOG.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ Deprecations
3838
require code changes, but you will need to update your IAM permissions. See
3939
`Migrating to the SES v2 API <https://anymail.dev/en/latest/esps/amazon_ses/#amazon-ses-v2>`__.
4040

41+
Features
42+
~~~~~~~~
43+
* **MailerSend:** Add support for this ESP
44+
(`docs <https://anymail.dev/en/latest/esps/mailersend/>`__).
45+
4146
Other
4247
~~~~~
4348
* Test against Django 4.2 prerelease, Python 3.11 (with Django 4.2),

README.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ a consistent API that avoids locking your code to one specific ESP
2828
Anymail currently supports these ESPs:
2929

3030
* **Amazon SES**
31+
* **MailerSend**
3132
* **Mailgun**
3233
* **Mailjet**
3334
* **Mandrill** (MailChimp transactional)

anymail/backends/mailersend.py

Lines changed: 338 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,338 @@
1+
import mimetypes
2+
3+
from ..exceptions import AnymailRequestsAPIError, AnymailUnsupportedFeature
4+
from ..message import AnymailRecipientStatus
5+
from ..utils import CaseInsensitiveCasePreservingDict, get_anymail_setting, update_deep
6+
from .base_requests import AnymailRequestsBackend, RequestsPayload
7+
8+
9+
class EmailBackend(AnymailRequestsBackend):
10+
"""
11+
MailerSend Email Backend
12+
"""
13+
14+
esp_name = "MailerSend"
15+
16+
def __init__(self, **kwargs):
17+
"""Init options from Django settings"""
18+
esp_name = self.esp_name
19+
self.api_token = get_anymail_setting(
20+
"api_token", esp_name=esp_name, kwargs=kwargs, allow_bare=True
21+
)
22+
api_url = get_anymail_setting(
23+
"api_url",
24+
esp_name=esp_name,
25+
kwargs=kwargs,
26+
default="https://api.mailersend.com/v1/",
27+
)
28+
if not api_url.endswith("/"):
29+
api_url += "/"
30+
31+
#: Can set to "use-bulk-email" or "expose-to-list" or default None
32+
self.batch_send_mode = get_anymail_setting(
33+
"batch_send_mode", default=None, esp_name=esp_name, kwargs=kwargs
34+
)
35+
super().__init__(api_url, **kwargs)
36+
37+
def build_message_payload(self, message, defaults):
38+
return MailerSendPayload(message, defaults, self)
39+
40+
def parse_recipient_status(self, response, payload, message):
41+
# The "email" API endpoint responds with an empty text/html body
42+
# if no warnings, otherwise json with suppression info.
43+
# The "bulk-email" API endpoint always returns json.
44+
if response.headers["Content-Type"] == "application/json":
45+
parsed_response = self.deserialize_json_response(response, payload, message)
46+
else:
47+
parsed_response = {}
48+
49+
try:
50+
# "email" API endpoint success or SOME_SUPPRESSED
51+
message_id = response.headers["X-Message-Id"]
52+
default_status = "queued"
53+
except KeyError:
54+
try:
55+
# "bulk-email" API endpoint
56+
bulk_id = parsed_response["bulk_email_id"]
57+
# Add "bulk:" prefix to distinguish from actual message_id.
58+
message_id = f"bulk:{bulk_id}"
59+
# Status is determined later; must query API to find out
60+
default_status = "unknown"
61+
except KeyError:
62+
# "email" API endpoint with ALL_SUPPRESSED
63+
message_id = None
64+
default_status = "failed"
65+
66+
# Don't swallow errors (which should have been handled with a non-2xx
67+
# status, earlier) or any warnings that we won't consume below.
68+
errors = parsed_response.get("errors", [])
69+
warnings = parsed_response.get("warnings", [])
70+
if errors or any(
71+
warning["type"] not in ("ALL_SUPPRESSED", "SOME_SUPPRESSED")
72+
for warning in warnings
73+
):
74+
raise AnymailRequestsAPIError(
75+
"Unexpected MailerSend API response errors/warnings",
76+
email_message=message,
77+
payload=payload,
78+
response=response,
79+
backend=self,
80+
)
81+
82+
# Collect a list of all problem recipients from any suppression warnings.
83+
# (warnings[].recipients[].reason[] will contain some combination of
84+
# "hard_bounced", "spam_complaint", "unsubscribed", and/or
85+
# "blocklisted", all of which map to Anymail's "rejected" status.)
86+
try:
87+
# warning["type"] is guaranteed to be {ALL,SOME}_SUPPRESSED at this point.
88+
rejected_emails = [
89+
recipient["email"]
90+
for warning in warnings
91+
for recipient in warning["recipients"]
92+
]
93+
except (KeyError, TypeError) as err:
94+
raise AnymailRequestsAPIError(
95+
f"Unexpected MailerSend API response format: {err!s}",
96+
email_message=message,
97+
payload=payload,
98+
response=response,
99+
backend=self,
100+
) from None
101+
102+
recipient_status = CaseInsensitiveCasePreservingDict(
103+
{
104+
recipient.addr_spec: AnymailRecipientStatus(
105+
message_id=message_id, status=default_status
106+
)
107+
for recipient in payload.all_recipients
108+
}
109+
)
110+
for rejected_email in rejected_emails:
111+
recipient_status[rejected_email] = AnymailRecipientStatus(
112+
message_id=None, status="rejected"
113+
)
114+
115+
return dict(recipient_status)
116+
117+
118+
class MailerSendPayload(RequestsPayload):
119+
def __init__(self, message, defaults, backend, *args, **kwargs):
120+
headers = {
121+
"Content-Type": "application/json",
122+
"Accept": "application/json",
123+
# Token may be changed in set_esp_extra below:
124+
"Authorization": f"Bearer {backend.api_token}",
125+
}
126+
self.all_recipients = [] # needed for parse_recipient_status
127+
self.merge_data = {} # late bound
128+
self.merge_global_data = None # late bound
129+
self.batch_send_mode = backend.batch_send_mode # can override in esp_extra
130+
super().__init__(message, defaults, backend, headers=headers, *args, **kwargs)
131+
132+
def get_api_endpoint(self):
133+
if self.is_batch():
134+
# MailerSend's "email" endpoint supports per-recipient customizations
135+
# (merge_data) for batch sending, but exposes the complete "To" list
136+
# to all recipients. This conflicts with Anymail's batch send model, which
137+
# expects each recipient can only see their own "To" email.
138+
#
139+
# MailerSend's "bulk-email" endpoint can send separate messages to each
140+
# "To" email, but doesn't return a message_id. (It returns a batch_email_id
141+
# that can later resolve to message_ids by polling a status API.)
142+
#
143+
# Since either of these would cause unexpected behavior, require the user
144+
# to opt into one via batch_send_mode.
145+
if self.batch_send_mode == "use-bulk-email":
146+
return "bulk-email"
147+
elif self.batch_send_mode == "expose-to-list":
148+
return "email"
149+
elif len(self.data["to"]) <= 1:
150+
# With only one "to", exposing the recipient list is moot.
151+
# (This covers the common case of single-recipient template merge.)
152+
return "email"
153+
else:
154+
# Unconditionally raise, even if IGNORE_UNSUPPORTED_FEATURES enabled.
155+
# We can't guess which API to use for this send.
156+
raise AnymailUnsupportedFeature(
157+
f"{self.esp_name} requires MAILERSEND_BATCH_SEND_MODE set to either"
158+
f" 'use-bulk-email' or 'expose-to-list' for using batch send"
159+
f" (merge_data) with multiple recipients. See the Anymail docs."
160+
)
161+
else:
162+
return "email"
163+
164+
def serialize_data(self):
165+
api_endpoint = self.get_api_endpoint()
166+
needs_personalization = self.merge_data or self.merge_global_data
167+
if api_endpoint == "email":
168+
if needs_personalization:
169+
self.data["personalization"] = [
170+
self.personalization_for_email(to["email"])
171+
for to in self.data["to"]
172+
]
173+
data = self.data
174+
elif api_endpoint == "bulk-email":
175+
# Burst the payload into individual bulk-email recipients:
176+
data = []
177+
for to in self.data["to"]:
178+
recipient_data = self.data.copy()
179+
recipient_data["to"] = [to]
180+
if needs_personalization:
181+
recipient_data["personalization"] = [
182+
self.personalization_for_email(to["email"])
183+
]
184+
data.append(recipient_data)
185+
else:
186+
raise AssertionError(
187+
f"MailerSendPayload.serialize_data missing"
188+
f" case for api_endpoint {api_endpoint!r}"
189+
)
190+
return self.serialize_json(data)
191+
192+
def personalization_for_email(self, email):
193+
"""
194+
Return a MailerSend personalization object for email address.
195+
196+
Composes merge_global_data and merge_data[email].
197+
"""
198+
if email in self.merge_data:
199+
if self.merge_global_data:
200+
recipient_data = self.merge_global_data.copy()
201+
recipient_data.update(self.merge_data[email])
202+
else:
203+
recipient_data = self.merge_data[email]
204+
elif self.merge_global_data:
205+
recipient_data = self.merge_global_data
206+
else:
207+
recipient_data = {}
208+
return {"email": email, "data": recipient_data}
209+
210+
#
211+
# Payload construction
212+
#
213+
214+
def make_mailersend_email(self, email):
215+
"""Return MailerSend email/name object for an EmailAddress"""
216+
obj = {"email": email.addr_spec}
217+
if email.display_name:
218+
obj["name"] = email.display_name
219+
return obj
220+
221+
def init_payload(self):
222+
self.data = {} # becomes json
223+
224+
def set_from_email(self, email):
225+
self.data["from"] = self.make_mailersend_email(email)
226+
227+
def set_recipients(self, recipient_type, emails):
228+
assert recipient_type in ["to", "cc", "bcc"]
229+
if emails:
230+
self.data[recipient_type] = [
231+
self.make_mailersend_email(email) for email in emails
232+
]
233+
self.all_recipients += emails
234+
235+
def set_subject(self, subject):
236+
self.data["subject"] = subject
237+
238+
def set_reply_to(self, emails):
239+
if len(emails) > 1:
240+
self.unsupported_feature("multiple reply_to emails")
241+
elif emails:
242+
self.data["reply_to"] = self.make_mailersend_email(emails[0])
243+
244+
def set_extra_headers(self, headers):
245+
# MailerSend doesn't support arbitrary email headers, but has
246+
# individual API params for In-Reply-To and Precedence: bulk.
247+
# (headers is a CaseInsensitiveDict, and is a copy so safe to modify.)
248+
in_reply_to = headers.pop("In-Reply-To", None)
249+
if in_reply_to is not None:
250+
self.data["in_reply_to"] = in_reply_to
251+
252+
precedence = headers.pop("Precedence", None)
253+
if precedence is not None:
254+
# Overrides MailerSend domain-level setting
255+
is_bulk = precedence.lower() in ("bulk", "junk", "list")
256+
self.data["precedence_bulk"] = is_bulk
257+
258+
if headers:
259+
self.unsupported_feature("most extra_headers (see docs)")
260+
261+
def set_text_body(self, body):
262+
self.data["text"] = body
263+
264+
def set_html_body(self, body):
265+
if "html" in self.data:
266+
# second html body could show up through multiple alternatives,
267+
# or html body + alternative
268+
self.unsupported_feature("multiple html parts")
269+
self.data["html"] = body
270+
271+
def add_attachment(self, attachment):
272+
# Add a MailerSend attachments[] object for attachment:
273+
attachment_object = {
274+
"filename": attachment.name,
275+
"content": attachment.b64content,
276+
"disposition": "attachment",
277+
}
278+
if not attachment_object["filename"]:
279+
# MailerSend requires filename, and determines mimetype from it
280+
# (even for inline attachments). For unnamed attachments, try
281+
# to generate a generic filename with the correct extension:
282+
ext = mimetypes.guess_extension(attachment.mimetype, strict=False)
283+
if ext is not None:
284+
attachment_object["filename"] = f"attachment{ext}"
285+
if attachment.inline:
286+
attachment_object["disposition"] = "inline"
287+
attachment_object["id"] = attachment.cid
288+
self.data.setdefault("attachments", []).append(attachment_object)
289+
290+
# MailerSend doesn't have metadata
291+
# def set_metadata(self, metadata):
292+
293+
def set_send_at(self, send_at):
294+
# Backend has converted pretty much everything to
295+
# a datetime by here; MailerSend expects unix timestamp
296+
self.data["send_at"] = int(send_at.timestamp()) # strip microseconds
297+
298+
def set_tags(self, tags):
299+
if tags:
300+
self.data["tags"] = tags
301+
302+
def set_track_clicks(self, track_clicks):
303+
self.data.setdefault("settings", {})["track_clicks"] = track_clicks
304+
305+
def set_track_opens(self, track_opens):
306+
self.data.setdefault("settings", {})["track_opens"] = track_opens
307+
308+
def set_template_id(self, template_id):
309+
self.data["template_id"] = template_id
310+
311+
def set_merge_data(self, merge_data):
312+
# late bound in serialize_data
313+
self.merge_data = merge_data
314+
315+
def set_merge_global_data(self, merge_global_data):
316+
# late bound in serialize_data
317+
self.merge_global_data = merge_global_data
318+
319+
# MailerSend doesn't have metadata
320+
# def set_merge_metadata(self, merge_metadata):
321+
322+
def set_esp_extra(self, extra):
323+
# Deep merge to allow (e.g.,) {"settings": {"track_content": True}}:
324+
update_deep(self.data, extra)
325+
326+
# Allow overriding api_token on individual message:
327+
try:
328+
api_token = self.data.pop("api_token")
329+
except KeyError:
330+
pass
331+
else:
332+
self.headers["Authorization"] = f"Bearer {api_token}"
333+
334+
# Allow overriding batch_send_mode on individual message:
335+
try:
336+
self.batch_send_mode = self.data.pop("batch_send_mode")
337+
except KeyError:
338+
pass

0 commit comments

Comments
 (0)