Skip to content

Commit 246fcdf

Browse files
cahnamedmunds
authored andcommitted
Add support for Mailtrap
1 parent 90367b3 commit 246fcdf

File tree

12 files changed

+1332
-26
lines changed

12 files changed

+1332
-26
lines changed

.github/workflows/integration-test.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ jobs:
4444
- { tox: django52-py313-mailersend, python: "3.13" }
4545
- { tox: django52-py313-mailgun, python: "3.13" }
4646
- { tox: django52-py313-mailjet, python: "3.13" }
47+
- { tox: django41-py310-mailtrap, python: "3.13" }
4748
- { tox: django52-py313-mandrill, python: "3.13" }
4849
- { tox: django52-py313-postal, python: "3.13" }
4950
- { tox: django52-py313-postmark, python: "3.13" }
@@ -88,6 +89,10 @@ jobs:
8889
ANYMAIL_TEST_MAILJET_DOMAIN: ${{ vars.ANYMAIL_TEST_MAILJET_DOMAIN }}
8990
ANYMAIL_TEST_MAILJET_SECRET_KEY: ${{ secrets.ANYMAIL_TEST_MAILJET_SECRET_KEY }}
9091
ANYMAIL_TEST_MAILJET_TEMPLATE_ID: ${{ vars.ANYMAIL_TEST_MAILJET_TEMPLATE_ID }}
92+
ANYMAIL_TEST_MAILTRAP_API_TOKEN: ${{ secrets.ANYMAIL_TEST_MAILTRAP_API_TOKEN }}
93+
ANYMAIL_TEST_MAILTRAP_TEST_INBOX_ID: ${{ vars.ANYMAIL_TEST_MAILTRAP_TEST_INBOX_ID }}
94+
ANYMAIL_TEST_MAILTRAP_TEMPLATE_UUID: ${{ vars.ANYMAIL_TEST_MAILTRAP_TEMPLATE_UUID }}
95+
ANYMAIL_TEST_MAILTRAP_DOMAIN: ${{ vars.ANYMAIL_TEST_MAILTRAP_DOMAIN }}
9196
ANYMAIL_TEST_MANDRILL_API_KEY: ${{ secrets.ANYMAIL_TEST_MANDRILL_API_KEY }}
9297
ANYMAIL_TEST_MANDRILL_DOMAIN: ${{ secrets.ANYMAIL_TEST_MANDRILL_DOMAIN }}
9398
ANYMAIL_TEST_POSTMARK_DOMAIN: ${{ secrets.ANYMAIL_TEST_POSTMARK_DOMAIN }}

README.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ Anymail currently supports these ESPs:
3131
* **MailerSend**
3232
* **Mailgun** (Sinch transactional email)
3333
* **Mailjet** (Sinch transactional email)
34+
* **Mailtrap**
3435
* **Mandrill** (MailChimp transactional email)
3536
* **Postal** (self-hosted ESP)
3637
* **Postmark** (ActiveCampaign transactional email)

anymail/backends/mailtrap.py

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
import sys
2+
from urllib.parse import quote
3+
4+
if sys.version_info < (3, 11):
5+
from typing_extensions import Any, Dict, List, Literal, NotRequired, TypedDict
6+
else:
7+
from typing import Any, Dict, List, Literal, NotRequired, TypedDict
8+
9+
from ..exceptions import AnymailRequestsAPIError
10+
from ..message import AnymailMessage, AnymailRecipientStatus
11+
from ..utils import Attachment, EmailAddress, get_anymail_setting, update_deep
12+
from .base_requests import AnymailRequestsBackend, RequestsPayload
13+
14+
15+
class MailtrapAddress(TypedDict):
16+
email: str
17+
name: NotRequired[str]
18+
19+
20+
class MailtrapAttachment(TypedDict):
21+
content: str
22+
type: NotRequired[str]
23+
filename: str
24+
disposition: NotRequired[Literal["attachment", "inline"]]
25+
content_id: NotRequired[str]
26+
27+
28+
MailtrapData = TypedDict(
29+
"MailtrapData",
30+
{
31+
"from": MailtrapAddress,
32+
"to": NotRequired[List[MailtrapAddress]],
33+
"cc": NotRequired[List[MailtrapAddress]],
34+
"bcc": NotRequired[List[MailtrapAddress]],
35+
"attachments": NotRequired[List[MailtrapAttachment]],
36+
"headers": NotRequired[Dict[str, str]],
37+
"custom_variables": NotRequired[Dict[str, str]],
38+
"subject": str,
39+
"text": str,
40+
"html": NotRequired[str],
41+
"category": NotRequired[str],
42+
"template_id": NotRequired[str],
43+
"template_variables": NotRequired[Dict[str, Any]],
44+
},
45+
)
46+
47+
48+
class MailtrapPayload(RequestsPayload):
49+
def __init__(
50+
self,
51+
message: AnymailMessage,
52+
defaults,
53+
backend: "EmailBackend",
54+
*args,
55+
**kwargs,
56+
):
57+
http_headers = {
58+
"Api-Token": backend.api_token,
59+
"Content-Type": "application/json",
60+
"Accept": "application/json",
61+
}
62+
# Yes, the parent sets this, but setting it here, too, gives type hints
63+
self.backend = backend
64+
self.metadata = None
65+
66+
# needed for backend.parse_recipient_status
67+
self.recipients_to: List[str] = []
68+
self.recipients_cc: List[str] = []
69+
self.recipients_bcc: List[str] = []
70+
71+
super().__init__(
72+
message, defaults, backend, *args, headers=http_headers, **kwargs
73+
)
74+
75+
def get_api_endpoint(self):
76+
if self.backend.testing_enabled:
77+
test_inbox_id = quote(self.backend.test_inbox_id, safe="")
78+
return f"send/{test_inbox_id}"
79+
return "send"
80+
81+
def serialize_data(self):
82+
return self.serialize_json(self.data)
83+
84+
#
85+
# Payload construction
86+
#
87+
88+
def init_payload(self):
89+
self.data: MailtrapData = {
90+
"from": {
91+
"email": "",
92+
},
93+
"subject": "",
94+
"text": "",
95+
}
96+
97+
@staticmethod
98+
def _mailtrap_email(email: EmailAddress) -> MailtrapAddress:
99+
"""Expand an Anymail EmailAddress into Mailtrap's {"email", "name"} dict"""
100+
result = {"email": email.addr_spec}
101+
if email.display_name:
102+
result["name"] = email.display_name
103+
return result
104+
105+
def set_from_email(self, email: EmailAddress):
106+
self.data["from"] = self._mailtrap_email(email)
107+
108+
def set_recipients(
109+
self, recipient_type: Literal["to", "cc", "bcc"], emails: List[EmailAddress]
110+
):
111+
assert recipient_type in ["to", "cc", "bcc"]
112+
if emails:
113+
self.data[recipient_type] = [
114+
self._mailtrap_email(email) for email in emails
115+
]
116+
117+
if recipient_type == "to":
118+
self.recipients_to = [email.addr_spec for email in emails]
119+
elif recipient_type == "cc":
120+
self.recipients_cc = [email.addr_spec for email in emails]
121+
elif recipient_type == "bcc":
122+
self.recipients_bcc = [email.addr_spec for email in emails]
123+
124+
def set_subject(self, subject):
125+
self.data["subject"] = subject
126+
127+
def set_reply_to(self, emails: List[EmailAddress]):
128+
self.data.setdefault("headers", {})["Reply-To"] = ", ".join(
129+
email.address for email in emails
130+
)
131+
132+
def set_extra_headers(self, headers):
133+
self.data.setdefault("headers", {}).update(headers)
134+
135+
def set_text_body(self, body):
136+
self.data["text"] = body
137+
138+
def set_html_body(self, body):
139+
if "html" in self.data:
140+
# second html body could show up through multiple alternatives,
141+
# or html body + alternative
142+
self.unsupported_feature("multiple html parts")
143+
self.data["html"] = body
144+
145+
def add_attachment(self, attachment: Attachment):
146+
att: MailtrapAttachment = {
147+
"disposition": "attachment",
148+
"filename": attachment.name,
149+
"content": attachment.b64content,
150+
}
151+
if attachment.mimetype:
152+
att["type"] = attachment.mimetype
153+
if attachment.inline:
154+
if not attachment.cid:
155+
self.unsupported_feature("inline attachment without content-id")
156+
att["disposition"] = "inline"
157+
att["content_id"] = attachment.cid
158+
elif not attachment.name:
159+
self.unsupported_feature("attachment without filename")
160+
self.data.setdefault("attachments", []).append(att)
161+
162+
def set_tags(self, tags: List[str]):
163+
if len(tags) > 1:
164+
self.unsupported_feature("multiple tags")
165+
if len(tags) > 0:
166+
self.data["category"] = tags[0]
167+
168+
def set_metadata(self, metadata):
169+
self.data.setdefault("custom_variables", {}).update(
170+
{str(k): str(v) for k, v in metadata.items()}
171+
)
172+
self.metadata = metadata # save for set_merge_metadata
173+
174+
def set_template_id(self, template_id):
175+
self.data["template_uuid"] = template_id
176+
177+
def set_merge_global_data(self, merge_global_data: Dict[str, Any]):
178+
self.data.setdefault("template_variables", {}).update(merge_global_data)
179+
180+
def set_esp_extra(self, extra):
181+
update_deep(self.data, extra)
182+
183+
184+
class EmailBackend(AnymailRequestsBackend):
185+
"""
186+
Mailtrap API Email Backend
187+
"""
188+
189+
esp_name = "Mailtrap"
190+
191+
def __init__(self, **kwargs):
192+
"""Init options from Django settings"""
193+
self.api_token = get_anymail_setting(
194+
"api_token", esp_name=self.esp_name, kwargs=kwargs, allow_bare=True
195+
)
196+
api_url = get_anymail_setting(
197+
"api_url",
198+
esp_name=self.esp_name,
199+
kwargs=kwargs,
200+
default="https://send.api.mailtrap.io/api/",
201+
)
202+
if not api_url.endswith("/"):
203+
api_url += "/"
204+
205+
test_api_url = get_anymail_setting(
206+
"test_api_url",
207+
esp_name=self.esp_name,
208+
kwargs=kwargs,
209+
default="https://sandbox.api.mailtrap.io/api/",
210+
)
211+
if not test_api_url.endswith("/"):
212+
test_api_url += "/"
213+
self.test_api_url = test_api_url
214+
215+
self.testing_enabled = get_anymail_setting(
216+
"testing",
217+
esp_name=self.esp_name,
218+
kwargs=kwargs,
219+
default=False,
220+
)
221+
222+
if self.testing_enabled:
223+
self.test_inbox_id = get_anymail_setting(
224+
"test_inbox_id",
225+
esp_name=self.esp_name,
226+
kwargs=kwargs,
227+
# (no default means required -- error if not set)
228+
)
229+
api_url = self.test_api_url
230+
else:
231+
self.test_inbox_id = None
232+
233+
super().__init__(api_url, **kwargs)
234+
235+
def build_message_payload(self, message, defaults):
236+
return MailtrapPayload(message, defaults, self)
237+
238+
def parse_recipient_status(
239+
self, response, payload: MailtrapPayload, message: AnymailMessage
240+
):
241+
parsed_response = self.deserialize_json_response(response, payload, message)
242+
243+
# TODO: how to handle fail_silently?
244+
if not self.fail_silently and (
245+
not parsed_response.get("success")
246+
or ("errors" in parsed_response and parsed_response["errors"])
247+
or ("message_ids" not in parsed_response)
248+
):
249+
raise AnymailRequestsAPIError(
250+
email_message=message, payload=payload, response=response, backend=self
251+
)
252+
else:
253+
# message-ids will be in this order
254+
recipient_status_order = [
255+
*payload.recipients_to,
256+
*payload.recipients_cc,
257+
*payload.recipients_bcc,
258+
]
259+
recipient_status = {
260+
email: AnymailRecipientStatus(
261+
message_id=parsed_response["message_ids"][0],
262+
status="sent",
263+
)
264+
for email in recipient_status_order
265+
}
266+
return recipient_status

anymail/urls.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
)
1212
from .webhooks.mailgun import MailgunInboundWebhookView, MailgunTrackingWebhookView
1313
from .webhooks.mailjet import MailjetInboundWebhookView, MailjetTrackingWebhookView
14+
from .webhooks.mailtrap import MailtrapTrackingWebhookView
1415
from .webhooks.mandrill import MandrillCombinedWebhookView
1516
from .webhooks.postal import PostalInboundWebhookView, PostalTrackingWebhookView
1617
from .webhooks.postmark import PostmarkInboundWebhookView, PostmarkTrackingWebhookView
@@ -108,6 +109,11 @@
108109
MailjetTrackingWebhookView.as_view(),
109110
name="mailjet_tracking_webhook",
110111
),
112+
path(
113+
"mailtrap/tracking/",
114+
MailtrapTrackingWebhookView.as_view(),
115+
name="mailtrap_tracking_webhook",
116+
),
111117
path(
112118
"postal/tracking/",
113119
PostalTrackingWebhookView.as_view(),

0 commit comments

Comments
 (0)