Skip to content

Commit db101bf

Browse files
authored
Add SparkPost support (#20)
Implement SparkPost backend and tracking webhooks. Closes #11.
1 parent 56de75e commit db101bf

File tree

12 files changed

+1555
-27
lines changed

12 files changed

+1555
-27
lines changed

.travis.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ cache:
2626
install:
2727
- pip install --upgrade setuptools pip
2828
- pip install $DJANGO
29-
- pip install .
29+
# For now, install all ESPs and test at once
30+
# (in future, might want to matrix ESPs to test cross-dependencies)
31+
- pip install .[mailgun,mandrill,postmark,sendgrid,sparkpost]
3032
- pip list
3133
script: python setup.py test

README.rst

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
Anymail: Django email backends for Mailgun, Postmark, SendGrid and more
2-
=======================================================================
1+
Anymail: Django email backends for Mailgun, Postmark, SendGrid, SparkPost and more
2+
==================================================================================
33

44
**EARLY DEVELOPMENT**
55

@@ -30,7 +30,7 @@ Anymail integrates several transactional email service providers (ESPs) into Dja
3030
with a consistent API that lets you use ESP-added features without locking your code
3131
to a particular ESP.
3232

33-
It currently fully supports Mailgun, Postmark, and SendGrid,
33+
It currently fully supports Mailgun, Postmark, SendGrid, and SparkPost,
3434
and has limited support for Mandrill.
3535

3636
Anymail normalizes ESP functionality so it "just works" with Django's
@@ -78,13 +78,16 @@ Anymail 1-2-3
7878
.. This quickstart section is also included in docs/quickstart.rst
7979
8080
This example uses Mailgun, but you can substitute Postmark or SendGrid
81-
or any other supported ESP where you see "mailgun":
81+
or SparkPost or any other supported ESP where you see "mailgun":
8282

8383
1. Install Anymail from PyPI:
8484

8585
.. code-block:: console
8686
87-
$ pip install django-anymail
87+
$ pip install django-anymail[mailgun]
88+
89+
(The `[mailgun]` part installs any additional packages needed for that ESP.
90+
Mailgun doesn't have any, but some other ESPs do.)
8891

8992

9093
2. Edit your project's ``settings.py``:

anymail/backends/sparkpost.py

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
from __future__ import absolute_import # we want the sparkpost package, not our own module
2+
3+
from .base import AnymailBaseBackend, BasePayload
4+
from ..exceptions import AnymailAPIError, AnymailImproperlyInstalled, AnymailConfigurationError
5+
from ..message import AnymailRecipientStatus
6+
from ..utils import get_anymail_setting
7+
8+
try:
9+
from sparkpost import SparkPost, SparkPostException
10+
except ImportError:
11+
raise AnymailImproperlyInstalled(missing_package='sparkpost', backend='sparkpost')
12+
13+
14+
class SparkPostBackend(AnymailBaseBackend):
15+
"""
16+
SparkPost Email Backend (using python-sparkpost client)
17+
"""
18+
19+
def __init__(self, **kwargs):
20+
"""Init options from Django settings"""
21+
super(SparkPostBackend, self).__init__(**kwargs)
22+
# SPARKPOST_API_KEY is optional - library reads from env by default
23+
self.api_key = get_anymail_setting('api_key', esp_name=self.esp_name,
24+
kwargs=kwargs, allow_bare=True, default=None)
25+
try:
26+
self.sp = SparkPost(self.api_key) # SparkPost API instance
27+
except SparkPostException as err:
28+
# This is almost certainly a missing API key
29+
raise AnymailConfigurationError(
30+
"Error initializing SparkPost: %s\n"
31+
"You may need to set ANYMAIL = {'SPARKPOST_API_KEY': ...} "
32+
"or ANYMAIL_SPARKPOST_API_KEY in your Django settings, "
33+
"or SPARKPOST_API_KEY in your environment." % str(err)
34+
)
35+
36+
# Note: SparkPost python API doesn't expose requests session sharing
37+
# (so there's no need to implement open/close connection management here)
38+
39+
def build_message_payload(self, message, defaults):
40+
return SparkPostPayload(message, defaults, self)
41+
42+
def post_to_esp(self, payload, message):
43+
params = payload.get_api_params()
44+
try:
45+
response = self.sp.transmissions.send(**params)
46+
except SparkPostException as err:
47+
raise AnymailAPIError(
48+
str(err), backend=self, email_message=message, payload=payload,
49+
response=getattr(err, 'response', None), # SparkPostAPIException requests.Response
50+
status_code=getattr(err, 'status', None), # SparkPostAPIException HTTP status_code
51+
)
52+
return response
53+
54+
def parse_recipient_status(self, response, payload, message):
55+
try:
56+
accepted = response['total_accepted_recipients']
57+
rejected = response['total_rejected_recipients']
58+
transmission_id = response['id']
59+
except (KeyError, TypeError) as err:
60+
raise AnymailAPIError(
61+
"%s in SparkPost.transmissions.send result %r" % (str(err), response),
62+
backend=self, email_message=message, payload=payload,
63+
)
64+
65+
# SparkPost doesn't (yet*) tell us *which* recipients were accepted or rejected.
66+
# (* looks like undocumented 'rcpt_to_errors' might provide this info.)
67+
# If all are one or the other, we can report a specific status;
68+
# else just report 'unknown' for all recipients.
69+
recipient_count = len(payload.all_recipients)
70+
if accepted == recipient_count and rejected == 0:
71+
status = 'queued'
72+
elif rejected == recipient_count and accepted == 0:
73+
status = 'rejected'
74+
else: # mixed results, or wrong total
75+
status = 'unknown'
76+
recipient_status = AnymailRecipientStatus(message_id=transmission_id, status=status)
77+
return {recipient.email: recipient_status for recipient in payload.all_recipients}
78+
79+
80+
class SparkPostPayload(BasePayload):
81+
def init_payload(self):
82+
self.params = {}
83+
self.all_recipients = []
84+
self.to_emails = []
85+
self.merge_data = {}
86+
87+
def get_api_params(self):
88+
# Compose recipients param from to_emails and merge_data (if any)
89+
recipients = []
90+
for email in self.to_emails:
91+
rcpt = {'address': {'email': email.email}}
92+
if email.name:
93+
rcpt['address']['name'] = email.name
94+
try:
95+
rcpt['substitution_data'] = self.merge_data[email.email]
96+
except KeyError:
97+
pass # no merge_data or none for this recipient
98+
recipients.append(rcpt)
99+
if recipients:
100+
self.params['recipients'] = recipients
101+
102+
return self.params
103+
104+
def set_from_email(self, email):
105+
self.params['from_email'] = email.address
106+
107+
def set_to(self, emails):
108+
if emails:
109+
self.to_emails = emails # bound to params['recipients'] in get_api_params
110+
self.all_recipients += emails
111+
112+
def set_cc(self, emails):
113+
if emails:
114+
self.params['cc'] = [email.address for email in emails]
115+
self.all_recipients += emails
116+
117+
def set_bcc(self, emails):
118+
if emails:
119+
self.params['bcc'] = [email.address for email in emails]
120+
self.all_recipients += emails
121+
122+
def set_subject(self, subject):
123+
self.params['subject'] = subject
124+
125+
def set_reply_to(self, emails):
126+
if emails:
127+
# reply_to is only documented as a single email, but this seems to work:
128+
self.params['reply_to'] = ', '.join([email.address for email in emails])
129+
130+
def set_extra_headers(self, headers):
131+
if headers:
132+
self.params['custom_headers'] = headers
133+
134+
def set_text_body(self, body):
135+
self.params['text'] = body
136+
137+
def set_html_body(self, body):
138+
if 'html' in self.params:
139+
# second html body could show up through multiple alternatives, or html body + alternative
140+
self.unsupported_feature("multiple html parts")
141+
self.params['html'] = body
142+
143+
def add_attachment(self, attachment):
144+
if attachment.inline:
145+
param = 'inline_images'
146+
name = attachment.cid
147+
else:
148+
param = 'attachments'
149+
name = attachment.name or ''
150+
151+
self.params.setdefault(param, []).append({
152+
'type': attachment.mimetype,
153+
'name': name,
154+
'data': attachment.b64content})
155+
156+
# Anymail-specific payload construction
157+
def set_metadata(self, metadata):
158+
self.params['metadata'] = metadata
159+
160+
def set_send_at(self, send_at):
161+
try:
162+
self.params['start_time'] = send_at.replace(microsecond=0).isoformat()
163+
except (AttributeError, TypeError):
164+
self.params['start_time'] = send_at # assume user already formatted
165+
166+
def set_tags(self, tags):
167+
if len(tags) > 0:
168+
self.params['campaign'] = tags[0]
169+
if len(tags) > 1:
170+
self.unsupported_feature('multiple tags (%r)' % tags)
171+
172+
def set_track_clicks(self, track_clicks):
173+
self.params['track_clicks'] = track_clicks
174+
175+
def set_track_opens(self, track_opens):
176+
self.params['track_opens'] = track_opens
177+
178+
def set_template_id(self, template_id):
179+
# 'template' transmissions.send param becomes 'template_id' in API json 'content'
180+
self.params['template'] = template_id
181+
182+
def set_merge_data(self, merge_data):
183+
self.merge_data = merge_data # merged into params['recipients'] in get_api_params
184+
185+
def set_merge_global_data(self, merge_global_data):
186+
self.params['substitution_data'] = merge_global_data
187+
188+
# ESP-specific payload construction
189+
def set_esp_extra(self, extra):
190+
self.params.update(extra)

anymail/urls.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from .webhooks.mandrill import MandrillTrackingWebhookView
55
from .webhooks.postmark import PostmarkTrackingWebhookView
66
from .webhooks.sendgrid import SendGridTrackingWebhookView
7+
from .webhooks.sparkpost import SparkPostTrackingWebhookView
78

89

910
app_name = 'anymail'
@@ -12,4 +13,5 @@
1213
url(r'^mandrill/tracking/$', MandrillTrackingWebhookView.as_view(), name='mandrill_tracking_webhook'),
1314
url(r'^postmark/tracking/$', PostmarkTrackingWebhookView.as_view(), name='postmark_tracking_webhook'),
1415
url(r'^sendgrid/tracking/$', SendGridTrackingWebhookView.as_view(), name='sendgrid_tracking_webhook'),
16+
url(r'^sparkpost/tracking/$', SparkPostTrackingWebhookView.as_view(), name='sparkpost_tracking_webhook'),
1517
]

anymail/webhooks/sparkpost.py

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import json
2+
from datetime import datetime
3+
4+
from django.utils.timezone import utc
5+
6+
from .base import AnymailBaseWebhookView
7+
from ..exceptions import AnymailConfigurationError
8+
from ..signals import tracking, AnymailTrackingEvent, EventType, RejectReason
9+
10+
11+
class SparkPostBaseWebhookView(AnymailBaseWebhookView):
12+
"""Base view class for SparkPost webhooks"""
13+
14+
def parse_events(self, request):
15+
raw_events = json.loads(request.body.decode('utf-8'))
16+
unwrapped_events = [self.unwrap_event(raw_event) for raw_event in raw_events]
17+
return [
18+
self.esp_to_anymail_event(event_class, event, raw_event)
19+
for (event_class, event, raw_event) in unwrapped_events
20+
if event is not None # filter out empty "ping" events
21+
]
22+
23+
def unwrap_event(self, raw_event):
24+
"""Unwraps SparkPost event structure, and returns event_class, event, raw_event
25+
26+
raw_event is of form {'msys': {event_class: {...event...}}}
27+
28+
Can return None, None, raw_event for SparkPost "ping" raw_event={'msys': {}}
29+
"""
30+
event_classes = raw_event['msys'].keys()
31+
try:
32+
(event_class,) = event_classes
33+
event = raw_event['msys'][event_class]
34+
except ValueError: # too many/not enough event_classes to unpack
35+
if len(event_classes) == 0:
36+
# Empty event (SparkPost sometimes sends as a "ping")
37+
event_class = event = None
38+
else:
39+
raise TypeError("Invalid SparkPost webhook event has multiple event classes: %r" % raw_event)
40+
return event_class, event, raw_event
41+
42+
def esp_to_anymail_event(self, event_class, event, raw_event):
43+
raise NotImplementedError()
44+
45+
46+
class SparkPostTrackingWebhookView(SparkPostBaseWebhookView):
47+
"""Handler for SparkPost message, engagement, and generation event webhooks"""
48+
49+
signal = tracking
50+
51+
event_types = {
52+
# Map SparkPost event.type: Anymail normalized type
53+
'bounce': EventType.BOUNCED,
54+
'delivery': EventType.DELIVERED,
55+
'injection': EventType.QUEUED,
56+
'spam_complaint': EventType.COMPLAINED,
57+
'out_of_band': EventType.BOUNCED,
58+
'policy_rejection': EventType.REJECTED,
59+
'delay': EventType.DEFERRED,
60+
'click': EventType.CLICKED,
61+
'open': EventType.OPENED,
62+
'generation_failure': EventType.FAILED,
63+
'generation_rejection': EventType.REJECTED,
64+
'list_unsubscribe': EventType.UNSUBSCRIBED,
65+
'link_unsubscribe': EventType.UNSUBSCRIBED,
66+
}
67+
68+
reject_reasons = {
69+
# Map SparkPost event.bounce_class: Anymail normalized reject reason.
70+
# Can also supply (RejectReason, EventType) for bounce_class that affects our event_type.
71+
# https://support.sparkpost.com/customer/portal/articles/1929896
72+
'1': RejectReason.OTHER, # Undetermined (response text could not be identified)
73+
'10': RejectReason.INVALID, # Invalid Recipient
74+
'20': RejectReason.BOUNCED, # Soft Bounce
75+
'21': RejectReason.BOUNCED, # DNS Failure
76+
'22': RejectReason.BOUNCED, # Mailbox Full
77+
'23': RejectReason.BOUNCED, # Too Large
78+
'24': RejectReason.TIMED_OUT, # Timeout
79+
'25': RejectReason.BLOCKED, # Admin Failure (configured policies)
80+
'30': RejectReason.BOUNCED, # Generic Bounce: No RCPT
81+
'40': RejectReason.BOUNCED, # Generic Bounce: unspecified reasons
82+
'50': RejectReason.BLOCKED, # Mail Block (by the receiver)
83+
'51': RejectReason.SPAM, # Spam Block (by the receiver)
84+
'52': RejectReason.SPAM, # Spam Content (by the receiver)
85+
'53': RejectReason.OTHER, # Prohibited Attachment (by the receiver)
86+
'54': RejectReason.BLOCKED, # Relaying Denied (by the receiver)
87+
'60': (RejectReason.OTHER, EventType.AUTORESPONDED), # Auto-Reply/vacation
88+
'70': RejectReason.BOUNCED, # Transient Failure
89+
'80': (RejectReason.OTHER, EventType.SUBSCRIBED), # Subscribe
90+
'90': (RejectReason.UNSUBSCRIBED, EventType.UNSUBSCRIBED), # Unsubscribe
91+
'100': (RejectReason.OTHER, EventType.AUTORESPONDED), # Challenge-Response
92+
}
93+
94+
def esp_to_anymail_event(self, event_class, event, raw_event):
95+
if event_class == 'relay_event':
96+
# This is an inbound event
97+
raise AnymailConfigurationError(
98+
"You seem to have set SparkPost's *inbound* relay webhook URL "
99+
"to Anymail's SparkPost *tracking* webhook URL.")
100+
101+
event_type = self.event_types.get(event['type'], EventType.UNKNOWN)
102+
try:
103+
timestamp = datetime.fromtimestamp(int(event['timestamp']), tz=utc)
104+
except (KeyError, TypeError, ValueError):
105+
timestamp = None
106+
107+
try:
108+
tag = event['campaign_id'] # not 'rcpt_tags' -- those don't come from sending a message
109+
tags = [tag] if tag else None
110+
except KeyError:
111+
tags = None
112+
113+
try:
114+
reject_reason = self.reject_reasons.get(event['bounce_class'], RejectReason.OTHER)
115+
try: # unpack (RejectReason, EventType) for reasons that change our event type
116+
reject_reason, event_type = reject_reason
117+
except ValueError:
118+
pass
119+
except KeyError:
120+
reject_reason = None # no bounce_class
121+
122+
return AnymailTrackingEvent(
123+
event_type=event_type,
124+
timestamp=timestamp,
125+
message_id=event.get('transmission_id', None), # not 'message_id' -- see SparkPost backend
126+
event_id=event.get('event_id', None),
127+
recipient=event.get('raw_rcpt_to', None), # preserves email case (vs. 'rcpt_to')
128+
reject_reason=reject_reason,
129+
mta_response=event.get('raw_reason', None),
130+
# description=???,
131+
tags=tags,
132+
metadata=event.get('rcpt_meta', None) or None, # message + recipient metadata
133+
click_url=event.get('target_link_url', None),
134+
user_agent=event.get('user_agent', None),
135+
esp_event=raw_event,
136+
)

0 commit comments

Comments
 (0)