Skip to content

Commit e90c10b

Browse files
authored
Add Postal support
Thanks to @tiltec for researching, implementing, testing and documenting it.
1 parent f831fe8 commit e90c10b

File tree

14 files changed

+1674
-25
lines changed

14 files changed

+1674
-25
lines changed

.github/workflows/test.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ jobs:
8888
# Install without optional extras (don't need to cover entire matrix)
8989
- { tox: django31-py37-none, python: "3.7" }
9090
- { tox: django31-py37-amazon_ses, python: "3.7" }
91+
- { tox: django31-py37-postal, python: "3.7" }
9192
# Test some specific older package versions
9293
- { tox: django22-py37-all-old_urllib3, python: "3.7" }
9394

CHANGELOG.rst

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,17 @@ Release history
2525
^^^^^^^^^^^^^^^
2626
.. This extra heading level keeps the ToC from becoming unmanageably long
2727
28+
vNext
29+
-----
30+
31+
*Unreleased changes on main branch*
32+
33+
Features
34+
~~~~~~~~
35+
36+
* **Postal:** New ESP! See `docs <https://anymail.readthedocs.io/en/latest/esps/postal>`__.
37+
(Thanks to `@tiltec`_ for researching, implementing, testing and documenting Postal support.)
38+
2839
v8.3
2940
----
3041

@@ -1252,6 +1263,7 @@ Features
12521263
.. _@swrobel: https://github.com/swrobel
12531264
.. _@tcourtqtm: https://github.com/tcourtqtm
12541265
.. _@Thorbenl: https://github.com/Thorbenl
1266+
.. _@tiltec: https://github.com/tiltec
12551267
.. _@Tobeyforce: https://github.com/Tobeyforce
12561268
.. _@varche1: https://github.com/varche1
12571269
.. _@vgrebenschikov: https://github.com/vgrebenschikov

anymail/backends/postal.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
from .base_requests import AnymailRequestsBackend, RequestsPayload
2+
from ..exceptions import AnymailRequestsAPIError
3+
from ..message import AnymailRecipientStatus
4+
from ..utils import get_anymail_setting
5+
6+
7+
class EmailBackend(AnymailRequestsBackend):
8+
"""
9+
Postal v1 API Email Backend
10+
"""
11+
12+
esp_name = "Postal"
13+
14+
def __init__(self, **kwargs):
15+
"""Init options from Django settings"""
16+
esp_name = self.esp_name
17+
18+
self.api_key = get_anymail_setting(
19+
"api_key", esp_name=esp_name, kwargs=kwargs, allow_bare=True
20+
)
21+
22+
# Required, as there is no hosted instance of Postal
23+
api_url = get_anymail_setting("api_url", esp_name=esp_name, kwargs=kwargs)
24+
if not api_url.endswith("/"):
25+
api_url += "/"
26+
super().__init__(api_url, **kwargs)
27+
28+
def build_message_payload(self, message, defaults):
29+
return PostalPayload(message, defaults, self)
30+
31+
def parse_recipient_status(self, response, payload, message):
32+
parsed_response = self.deserialize_json_response(response, payload, message)
33+
34+
if parsed_response["status"] != "success":
35+
raise AnymailRequestsAPIError(
36+
email_message=message, payload=payload, response=response, backend=self
37+
)
38+
39+
# If we get here, the send call was successful.
40+
messages = parsed_response["data"]["messages"]
41+
42+
return {
43+
email: AnymailRecipientStatus(message_id=details["id"], status="queued")
44+
for email, details in messages.items()
45+
}
46+
47+
48+
class PostalPayload(RequestsPayload):
49+
def __init__(self, message, defaults, backend, *args, **kwargs):
50+
http_headers = kwargs.pop("headers", {})
51+
http_headers["X-Server-API-Key"] = backend.api_key
52+
http_headers["Content-Type"] = "application/json"
53+
http_headers["Accept"] = "application/json"
54+
super().__init__(
55+
message, defaults, backend, headers=http_headers, *args, **kwargs
56+
)
57+
58+
def get_api_endpoint(self):
59+
return "api/v1/send/message"
60+
61+
def init_payload(self):
62+
self.data = {}
63+
64+
def serialize_data(self):
65+
return self.serialize_json(self.data)
66+
67+
def set_from_email(self, email):
68+
self.data["from"] = str(email)
69+
70+
def set_subject(self, subject):
71+
self.data["subject"] = subject
72+
73+
def set_to(self, emails):
74+
self.data["to"] = [str(email) for email in emails]
75+
76+
def set_cc(self, emails):
77+
self.data["cc"] = [str(email) for email in emails]
78+
79+
def set_bcc(self, emails):
80+
self.data["bcc"] = [str(email) for email in emails]
81+
82+
def set_reply_to(self, emails):
83+
if len(emails) > 1:
84+
self.unsupported_feature("multiple reply_to addresses")
85+
if len(emails) > 0:
86+
self.data["reply_to"] = str(emails[0])
87+
88+
def set_extra_headers(self, headers):
89+
self.data["headers"] = headers
90+
91+
def set_text_body(self, body):
92+
self.data["plain_body"] = body
93+
94+
def set_html_body(self, body):
95+
if "html_body" in self.data:
96+
self.unsupported_feature("multiple html parts")
97+
self.data["html_body"] = body
98+
99+
def make_attachment(self, attachment):
100+
"""Returns Postal attachment dict for attachment"""
101+
att = {
102+
"name": attachment.name or "",
103+
"data": attachment.b64content,
104+
"content_type": attachment.mimetype,
105+
}
106+
if attachment.inline:
107+
# see https://github.com/postalhq/postal/issues/731
108+
# but it might be possible with the send/raw endpoint
109+
self.unsupported_feature('inline attachments')
110+
return att
111+
112+
def set_attachments(self, attachments):
113+
if attachments:
114+
self.data["attachments"] = [
115+
self.make_attachment(attachment) for attachment in attachments
116+
]
117+
118+
def set_envelope_sender(self, email):
119+
self.data["sender"] = str(email)
120+
121+
def set_tags(self, tags):
122+
if len(tags) > 1:
123+
self.unsupported_feature("multiple tags")
124+
if len(tags) > 0:
125+
self.data["tag"] = tags[0]
126+
127+
def set_esp_extra(self, extra):
128+
self.data.update(extra)

anymail/urls.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from .webhooks.mailgun import MailgunInboundWebhookView, MailgunTrackingWebhookView
55
from .webhooks.mailjet import MailjetInboundWebhookView, MailjetTrackingWebhookView
66
from .webhooks.mandrill import MandrillCombinedWebhookView
7+
from .webhooks.postal import PostalInboundWebhookView, PostalTrackingWebhookView
78
from .webhooks.postmark import PostmarkInboundWebhookView, PostmarkTrackingWebhookView
89
from .webhooks.sendgrid import SendGridInboundWebhookView, SendGridTrackingWebhookView
910
from .webhooks.sendinblue import SendinBlueTrackingWebhookView
@@ -15,13 +16,15 @@
1516
re_path(r'^amazon_ses/inbound/$', AmazonSESInboundWebhookView.as_view(), name='amazon_ses_inbound_webhook'),
1617
re_path(r'^mailgun/inbound(_mime)?/$', MailgunInboundWebhookView.as_view(), name='mailgun_inbound_webhook'),
1718
re_path(r'^mailjet/inbound/$', MailjetInboundWebhookView.as_view(), name='mailjet_inbound_webhook'),
19+
re_path(r'^postal/inbound/$', PostalInboundWebhookView.as_view(), name='postal_inbound_webhook'),
1820
re_path(r'^postmark/inbound/$', PostmarkInboundWebhookView.as_view(), name='postmark_inbound_webhook'),
1921
re_path(r'^sendgrid/inbound/$', SendGridInboundWebhookView.as_view(), name='sendgrid_inbound_webhook'),
2022
re_path(r'^sparkpost/inbound/$', SparkPostInboundWebhookView.as_view(), name='sparkpost_inbound_webhook'),
2123

2224
re_path(r'^amazon_ses/tracking/$', AmazonSESTrackingWebhookView.as_view(), name='amazon_ses_tracking_webhook'),
2325
re_path(r'^mailgun/tracking/$', MailgunTrackingWebhookView.as_view(), name='mailgun_tracking_webhook'),
2426
re_path(r'^mailjet/tracking/$', MailjetTrackingWebhookView.as_view(), name='mailjet_tracking_webhook'),
27+
re_path(r'^postal/tracking/$', PostalTrackingWebhookView.as_view(), name='postal_tracking_webhook'),
2528
re_path(r'^postmark/tracking/$', PostmarkTrackingWebhookView.as_view(), name='postmark_tracking_webhook'),
2629
re_path(r'^sendgrid/tracking/$', SendGridTrackingWebhookView.as_view(), name='sendgrid_tracking_webhook'),
2730
re_path(r'^sendinblue/tracking/$', SendinBlueTrackingWebhookView.as_view(), name='sendinblue_tracking_webhook'),

anymail/webhooks/postal.py

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import binascii
2+
import json
3+
from base64 import b64decode
4+
from datetime import datetime
5+
6+
7+
from django.utils.timezone import utc
8+
9+
from .base import AnymailBaseWebhookView
10+
from ..exceptions import (
11+
AnymailInvalidAddress,
12+
AnymailWebhookValidationFailure,
13+
AnymailImproperlyInstalled,
14+
_LazyError,
15+
AnymailConfigurationError,
16+
)
17+
from ..inbound import AnymailInboundMessage
18+
from ..signals import (
19+
inbound,
20+
tracking,
21+
AnymailInboundEvent,
22+
AnymailTrackingEvent,
23+
EventType,
24+
RejectReason,
25+
)
26+
from ..utils import parse_single_address, get_anymail_setting
27+
28+
try:
29+
from cryptography.hazmat.primitives import serialization, hashes
30+
from cryptography.hazmat.backends import default_backend
31+
from cryptography.hazmat.primitives.asymmetric import padding
32+
from cryptography.exceptions import InvalidSignature
33+
except ImportError:
34+
# This module gets imported by anymail.urls, so don't complain about cryptography missing
35+
# unless one of the Postal webhook views is actually used and needs it
36+
error = _LazyError(AnymailImproperlyInstalled(missing_package='cryptography', backend='postal'))
37+
serialization = error
38+
hashes = error
39+
default_backend = error
40+
padding = error
41+
InvalidSignature = object
42+
43+
44+
class PostalBaseWebhookView(AnymailBaseWebhookView):
45+
"""Base view class for Postal webhooks"""
46+
47+
esp_name = "Postal"
48+
49+
warn_if_no_basic_auth = False
50+
51+
# These can be set from kwargs in View.as_view, or pulled from settings in init:
52+
webhook_key = None
53+
54+
def __init__(self, **kwargs):
55+
self.webhook_key = get_anymail_setting('webhook_key', esp_name=self.esp_name, kwargs=kwargs, allow_bare=True)
56+
57+
super().__init__(**kwargs)
58+
59+
def validate_request(self, request):
60+
try:
61+
signature = request.META["HTTP_X_POSTAL_SIGNATURE"]
62+
except KeyError:
63+
raise AnymailWebhookValidationFailure("X-Postal-Signature header missing from webhook")
64+
65+
public_key = serialization.load_pem_public_key(
66+
('-----BEGIN PUBLIC KEY-----\n' + self.webhook_key + '\n-----END PUBLIC KEY-----').encode(),
67+
backend=default_backend()
68+
)
69+
70+
try:
71+
public_key.verify(
72+
b64decode(signature),
73+
request.body,
74+
padding.PKCS1v15(),
75+
hashes.SHA1()
76+
)
77+
except (InvalidSignature, binascii.Error):
78+
raise AnymailWebhookValidationFailure(
79+
"Postal webhook called with incorrect signature")
80+
81+
82+
class PostalTrackingWebhookView(PostalBaseWebhookView):
83+
"""Handler for Postal message, engagement, and generation event webhooks"""
84+
85+
signal = tracking
86+
87+
def parse_events(self, request):
88+
esp_event = json.loads(request.body.decode("utf-8"))
89+
90+
if 'rcpt_to' in esp_event:
91+
raise AnymailConfigurationError(
92+
"You seem to have set Postal's *inbound* webhook "
93+
"to Anymail's Postal *tracking* webhook URL.")
94+
95+
raw_timestamp = esp_event.get("timestamp")
96+
timestamp = (
97+
datetime.fromtimestamp(int(raw_timestamp), tz=utc)
98+
if raw_timestamp
99+
else None
100+
)
101+
102+
payload = esp_event.get("payload", {})
103+
104+
status_types = {
105+
"Sent": EventType.DELIVERED,
106+
"SoftFail": EventType.DEFERRED,
107+
"HardFail": EventType.FAILED,
108+
"Held": EventType.QUEUED,
109+
}
110+
111+
if "status" in payload:
112+
event_type = status_types.get(payload["status"], EventType.UNKNOWN)
113+
elif "bounce" in payload:
114+
event_type = EventType.BOUNCED
115+
elif "url" in payload:
116+
event_type = EventType.CLICKED
117+
else:
118+
event_type = EventType.UNKNOWN
119+
120+
description = payload.get("details")
121+
mta_response = payload.get("output")
122+
123+
# extract message-related fields
124+
message = payload.get("message") or payload.get("original_message", {})
125+
message_id = message.get("id")
126+
tag = message.get("tag")
127+
recipient = None
128+
message_to = message.get("to")
129+
if message_to is not None:
130+
try:
131+
recipient = parse_single_address(message_to).addr_spec
132+
except AnymailInvalidAddress:
133+
pass
134+
135+
if message.get("direction") == "incoming":
136+
# Let's ignore tracking events about an inbound emails.
137+
# This happens when an inbound email could not be forwarded.
138+
# The email didn't originate from Anymail, so the user can't do much about it.
139+
# It is part of normal Postal operation, not a configuration error.
140+
return []
141+
142+
# only for MessageLinkClicked
143+
click_url = payload.get("url")
144+
user_agent = payload.get("user_agent")
145+
146+
event = AnymailTrackingEvent(
147+
event_type=event_type,
148+
timestamp=timestamp,
149+
event_id=esp_event.get('uuid'),
150+
esp_event=esp_event,
151+
click_url=click_url,
152+
description=description,
153+
message_id=message_id,
154+
metadata=None,
155+
mta_response=mta_response,
156+
recipient=recipient,
157+
reject_reason=RejectReason.BOUNCED if event_type == EventType.BOUNCED else None,
158+
tags=[tag],
159+
user_agent=user_agent,
160+
)
161+
162+
return [event]
163+
164+
165+
class PostalInboundWebhookView(PostalBaseWebhookView):
166+
"""Handler for Postal inbound relay webhook"""
167+
168+
signal = inbound
169+
170+
def parse_events(self, request):
171+
esp_event = json.loads(request.body.decode("utf-8"))
172+
173+
if 'status' in esp_event:
174+
raise AnymailConfigurationError(
175+
"You seem to have set Postal's *tracking* webhook "
176+
"to Anymail's Postal *inbound* webhook URL.")
177+
178+
raw_mime = esp_event["message"]
179+
if esp_event.get("base64") is True:
180+
raw_mime = b64decode(esp_event["message"]).decode("utf-8")
181+
message = AnymailInboundMessage.parse_raw_mime(raw_mime)
182+
183+
message.envelope_sender = esp_event.get('mail_from', None)
184+
message.envelope_recipient = esp_event.get('rcpt_to', None)
185+
186+
event = AnymailInboundEvent(
187+
event_type=EventType.INBOUND,
188+
timestamp=None,
189+
event_id=esp_event.get("id"),
190+
esp_event=esp_event,
191+
message=message,
192+
)
193+
194+
return [event]

0 commit comments

Comments
 (0)