Skip to content

Commit 34d6676

Browse files
committed
Add Postmark support
1 parent c6d6b5d commit 34d6676

File tree

6 files changed

+973
-16
lines changed

6 files changed

+973
-16
lines changed

README.rst

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,23 +25,28 @@ Anymail: Multi-ESP transactional email for Django
2525
.. This shared-intro section is also included in docs/index.rst
2626
2727
Anymail integrates several transactional email service providers (ESPs) into Django,
28-
using a consistent API that makes it (relatively) easy to switch between ESPs.
28+
with a consistent API that lets you use ESP-added features without locking your code
29+
to a particular ESP.
2930

30-
It currently supports Mailgun, Mandrill, and SendGrid. Postmark is coming soon.
31+
It currently supports Mailgun, Postmark, SendGrid, and Mandrill.
3132

3233
Anymail normalizes ESP functionality so it "just works" with Django's
3334
built-in `django.core.mail` package. It includes:
3435

3536
* Support for HTML, attachments, extra headers, and other features of
3637
`Django's built-in email <https://docs.djangoproject.com/en/stable/topics/email/>`_
3738
* Extensions that make it easy to use extra ESP functionality, like tags, metadata,
38-
and tracking, using code that's portable between ESPs
39-
* Optional support for ESP delivery status notification via webhooks and Django signals
40-
* Optional support for inbound email
39+
and tracking, with code that's portable between ESPs
40+
* Simplified inline images for HTML email
4141

42-
Anymail is released under the BSD license. It is tested against Django 1.8--1.9
43-
(including Python 3 and PyPy).
44-
Anymail uses `semantic versioning <http://semver.org/>`_.
42+
Support is planned for:
43+
44+
* Normalized sent-message tracking status notification via webhooks and Django signals
45+
* Normalized inbound email processing through your ESP
46+
47+
Anymail is released under the BSD license. It is extensively tested against Django 1.8--1.9
48+
(including Python 2.7, Python 3 and PyPy).
49+
Anymail releases follow `semantic versioning <http://semver.org/>`_.
4550

4651
.. END shared-intro
4752

anymail/backends/base_requests.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,9 +71,18 @@ def post_to_esp(self, payload, message):
7171
"""
7272
params = payload.get_request_params(self.api_url)
7373
response = self.session.request(**params)
74+
self.raise_for_status(response, payload, message)
75+
return response
76+
77+
def raise_for_status(self, response, payload, message):
78+
"""Raise AnymailRequestsAPIError if response is an HTTP error
79+
80+
Subclasses can override for custom error checking
81+
(though should defer parsing/deserialization of the body to
82+
parse_recipient_status)
83+
"""
7484
if response.status_code != 200:
7585
raise AnymailRequestsAPIError(email_message=message, payload=payload, response=response)
76-
return response
7786

7887
def deserialize_json_response(self, response, payload, message):
7988
"""Deserialize an ESP API response that's in json.

anymail/backends/postmark.py

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
import re
2+
3+
from ..exceptions import AnymailRequestsAPIError
4+
from ..message import AnymailRecipientStatus
5+
from ..utils import get_anymail_setting
6+
7+
from .base_requests import AnymailRequestsBackend, RequestsPayload
8+
9+
10+
class PostmarkBackend(AnymailRequestsBackend):
11+
"""
12+
Postmark API Email Backend
13+
"""
14+
15+
def __init__(self, **kwargs):
16+
"""Init options from Django settings"""
17+
self.server_token = get_anymail_setting('POSTMARK_SERVER_TOKEN', allow_bare=True)
18+
api_url = get_anymail_setting("POSTMARK_API_URL", "https://api.postmarkapp.com/")
19+
if not api_url.endswith("/"):
20+
api_url += "/"
21+
super(PostmarkBackend, self).__init__(api_url, **kwargs)
22+
23+
def build_message_payload(self, message, defaults):
24+
return PostmarkPayload(message, defaults, self)
25+
26+
def raise_for_status(self, response, payload, message):
27+
# We need to handle 422 responses in parse_recipient_status
28+
if response.status_code != 422:
29+
super(PostmarkBackend, self).raise_for_status(response, payload, message)
30+
31+
def parse_recipient_status(self, response, payload, message):
32+
parsed_response = self.deserialize_json_response(response, payload, message)
33+
try:
34+
error_code = parsed_response["ErrorCode"]
35+
msg = parsed_response["Message"]
36+
except (KeyError, TypeError):
37+
raise AnymailRequestsAPIError("Invalid Postmark API response format",
38+
email_message=message, payload=payload, response=response)
39+
40+
message_id = parsed_response.get("MessageID", None)
41+
rejected_emails = []
42+
43+
if error_code == 300: # Invalid email request
44+
# Either the From address or at least one recipient was invalid. Email not sent.
45+
if "'From' address" in msg:
46+
# Normal error
47+
raise AnymailRequestsAPIError(email_message=message, payload=payload, response=response)
48+
else:
49+
# Use AnymailRecipientsRefused logic
50+
default_status = 'invalid'
51+
elif error_code == 406: # Inactive recipient
52+
# All recipients were rejected as hard-bounce or spam-complaint. Email not sent.
53+
default_status = 'rejected'
54+
elif error_code == 0:
55+
# At least partial success, and email was sent.
56+
# Sadly, have to parse human-readable message to figure out if everyone got it.
57+
default_status = 'sent'
58+
rejected_emails = self.parse_inactive_recipients(msg)
59+
else:
60+
raise AnymailRequestsAPIError(email_message=message, payload=payload, response=response)
61+
62+
return {
63+
recipient.email: AnymailRecipientStatus(
64+
message_id=message_id,
65+
status=('rejected' if recipient.email.lower() in rejected_emails
66+
else default_status)
67+
)
68+
for recipient in payload.all_recipients
69+
}
70+
71+
def parse_inactive_recipients(self, msg):
72+
"""Return a list of 'inactive' email addresses from a Postmark "OK" response
73+
74+
:param str msg: the "Message" from the Postmark API response
75+
"""
76+
# Example msg with inactive recipients:
77+
# "Message OK, but will not deliver to these inactive addresses: [email protected], [email protected]."
78+
# " Inactive recipients are ones that have generated a hard bounce or a spam complaint."
79+
# Example msg with everything OK: "OK"
80+
match = re.search(r'inactive addresses:\s*(.*)\.\s*Inactive recipients', msg)
81+
if match:
82+
emails = match.group(1) # "[email protected], [email protected]"
83+
return [email.strip().lower() for email in emails.split(',')]
84+
else:
85+
return []
86+
87+
88+
class PostmarkPayload(RequestsPayload):
89+
90+
def __init__(self, message, defaults, backend, *args, **kwargs):
91+
headers = {
92+
'Content-Type': 'application/json',
93+
'Accept': 'application/json',
94+
# 'X-Postmark-Server-Token': see get_request_params (and set_esp_extra)
95+
}
96+
self.server_token = backend.server_token # added to headers later, so esp_extra can override
97+
self.all_recipients = [] # used for backend.parse_recipient_status
98+
super(PostmarkPayload, self).__init__(message, defaults, backend, headers=headers, *args, **kwargs)
99+
100+
def get_api_endpoint(self):
101+
return "email"
102+
103+
def get_request_params(self, api_url):
104+
params = super(PostmarkPayload, self).get_request_params(api_url)
105+
params['headers']['X-Postmark-Server-Token'] = self.server_token
106+
return params
107+
108+
def serialize_data(self):
109+
return self.serialize_json(self.data)
110+
111+
#
112+
# Payload construction
113+
#
114+
115+
def init_payload(self):
116+
self.data = {} # becomes json
117+
118+
def set_from_email(self, email):
119+
self.data["From"] = email.address
120+
121+
def set_recipients(self, recipient_type, emails):
122+
assert recipient_type in ["to", "cc", "bcc"]
123+
if emails:
124+
field = recipient_type.capitalize()
125+
self.data[field] = ', '.join([email.address for email in emails])
126+
self.all_recipients += emails # used for backend.parse_recipient_status
127+
128+
def set_subject(self, subject):
129+
self.data["Subject"] = subject
130+
131+
def set_reply_to(self, emails):
132+
if emails:
133+
reply_to = ", ".join([email.address for email in emails])
134+
self.data["ReplyTo"] = reply_to
135+
136+
def set_extra_headers(self, headers):
137+
self.data["Headers"] = [
138+
{"Name": key, "Value": value}
139+
for key, value in headers.items()
140+
]
141+
142+
def set_text_body(self, body):
143+
self.data["TextBody"] = body
144+
145+
def set_html_body(self, body):
146+
if "HtmlBody" in self.data:
147+
# second html body could show up through multiple alternatives, or html body + alternative
148+
self.unsupported_feature("multiple html parts")
149+
self.data["HtmlBody"] = body
150+
151+
def make_attachment(self, attachment):
152+
"""Returns Postmark attachment dict for attachment"""
153+
att = {
154+
"Name": attachment.name or "",
155+
"Content": attachment.b64content,
156+
"ContentType": attachment.mimetype,
157+
}
158+
if attachment.inline:
159+
att["ContentID"] = "cid:%s" % attachment.cid
160+
return att
161+
162+
def set_attachments(self, attachments):
163+
if attachments:
164+
self.data["Attachments"] = [
165+
self.make_attachment(attachment) for attachment in attachments
166+
]
167+
168+
# Postmark doesn't support metadata
169+
# def set_metadata(self, metadata):
170+
171+
# Postmark doesn't support delayed sending
172+
# def set_send_at(self, send_at):
173+
174+
def set_tags(self, tags):
175+
if len(tags) > 0:
176+
self.data["Tag"] = tags[0]
177+
if len(tags) > 1:
178+
self.unsupported_feature('multiple tags (%r)' % tags)
179+
180+
# Postmark doesn't support click-tracking
181+
# def set_track_clicks(self, track_clicks):
182+
183+
def set_track_opens(self, track_opens):
184+
self.data["TrackOpens"] = track_opens
185+
186+
def set_esp_extra(self, extra):
187+
self.data.update(extra)
188+
# Special handling for 'server_token':
189+
self.server_token = self.data.pop('server_token', self.server_token)

0 commit comments

Comments
 (0)