Skip to content

Commit 73897b7

Browse files
committed
Add support for Mailtrap
1 parent 35383c7 commit 73897b7

File tree

3 files changed

+349
-0
lines changed

3 files changed

+349
-0
lines changed

anymail/backends/mailtrap.py

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

anymail/webhooks/mailtrap.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import json
2+
from datetime import datetime, timezone
3+
from typing import Literal, NotRequired, TypedDict
4+
5+
from ..signals import AnymailTrackingEvent, EventType, RejectReason, tracking
6+
from .base import AnymailBaseWebhookView
7+
8+
9+
class MailtrapEvent(TypedDict):
10+
event: Literal[
11+
"delivery",
12+
"open",
13+
"click",
14+
"unsubscribe",
15+
"spam",
16+
"soft bounce",
17+
"bounce",
18+
"suspension",
19+
"reject",
20+
]
21+
message_id: str
22+
sending_stream: Literal["transactional", "bulk"]
23+
email: str
24+
timestamp: int
25+
event_id: str
26+
category: NotRequired[str]
27+
custom_variables: NotRequired[dict[str, str | int | float | bool]]
28+
reason: NotRequired[str]
29+
response: NotRequired[str]
30+
response_code: NotRequired[int]
31+
bounce_category: NotRequired[str]
32+
ip: NotRequired[str]
33+
user_agent: NotRequired[str]
34+
url: NotRequired[str]
35+
36+
37+
class MailtrapTrackingWebhookView(AnymailBaseWebhookView):
38+
"""Handler for Mailtrap delivery and engagement tracking webhooks"""
39+
40+
esp_name = "Mailtrap"
41+
signal = tracking
42+
43+
def parse_events(self, request):
44+
esp_events: list[MailtrapEvent] = json.loads(request.body.decode("utf-8")).get(
45+
"events", []
46+
)
47+
return [self.esp_to_anymail_event(esp_event) for esp_event in esp_events]
48+
49+
# https://help.mailtrap.io/article/87-statuses-and-events
50+
event_types = {
51+
# Map Mailtrap event: Anymail normalized type
52+
"delivery": EventType.DELIVERED,
53+
"open": EventType.OPENED,
54+
"click": EventType.CLICKED,
55+
"bounce": EventType.BOUNCED,
56+
"soft bounce": EventType.DEFERRED,
57+
"blocked": EventType.REJECTED,
58+
"spam": EventType.COMPLAINED,
59+
"unsubscribe": EventType.UNSUBSCRIBED,
60+
"reject": EventType.REJECTED,
61+
"suspension": EventType.DEFERRED,
62+
}
63+
64+
reject_reasons = {
65+
# Map Mailtrap event type to Anymail normalized reject_reason
66+
"bounce": RejectReason.BOUNCED,
67+
"blocked": RejectReason.BLOCKED,
68+
"spam": RejectReason.SPAM,
69+
"unsubscribe": RejectReason.UNSUBSCRIBED,
70+
"reject": RejectReason.BLOCKED,
71+
}
72+
73+
def esp_to_anymail_event(self, esp_event: MailtrapEvent):
74+
event_type = self.event_types.get(esp_event["event"], EventType.UNKNOWN)
75+
timestamp = datetime.fromtimestamp(esp_event["timestamp"], tz=timezone.utc)
76+
reject_reason = self.reject_reasons.get(esp_event["event"], RejectReason.OTHER)
77+
custom_variables = esp_event.get("custom_variables", {})
78+
tags = []
79+
if "category" in esp_event:
80+
tags.append(esp_event["category"])
81+
82+
return AnymailTrackingEvent(
83+
event_type=event_type,
84+
timestamp=timestamp,
85+
message_id=esp_event["message_id"],
86+
event_id=esp_event.get("event_id", None),
87+
recipient=esp_event.get("email", None),
88+
reject_reason=reject_reason,
89+
mta_response=esp_event.get("response_code", None),
90+
tags=tags,
91+
metadata=custom_variables,
92+
click_url=esp_event.get("url", None),
93+
user_agent=esp_event.get("user_agent", None),
94+
esp_event=esp_event,
95+
)

0 commit comments

Comments
 (0)