Skip to content

Commit d3f914b

Browse files
committed
Event-tracking webhooks
Closes #3
1 parent 36461e5 commit d3f914b

31 files changed

+2471
-448
lines changed

anymail/compat.py

Lines changed: 0 additions & 11 deletions
This file was deleted.

anymail/exceptions.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import json
22

3-
from django.core.exceptions import ImproperlyConfigured
3+
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
44
from requests import HTTPError
55

66

@@ -125,6 +125,14 @@ def __init__(self, message=None, orig_err=None, *args, **kwargs):
125125
super(AnymailSerializationError, self).__init__(message, *args, **kwargs)
126126

127127

128+
class AnymailWebhookValidationFailure(AnymailError, SuspiciousOperation):
129+
"""Exception when a webhook cannot be validated.
130+
131+
Django's SuspiciousOperation turns into
132+
an HTTP 400 error in production.
133+
"""
134+
135+
128136
class AnymailConfigurationError(ImproperlyConfigured):
129137
"""Exception for Anymail configuration or installation issues"""
130138
# This deliberately doesn't inherit from AnymailError,
@@ -140,3 +148,12 @@ def __init__(self, missing_package, backend="<backend>"):
140148
"with your desired backends)" % (missing_package, backend)
141149
super(AnymailImproperlyInstalled, self).__init__(message)
142150

151+
152+
# Warnings
153+
154+
class AnymailWarning(Warning):
155+
"""Base warning for Anymail"""
156+
157+
158+
class AnymailInsecureWebhookWarning(AnymailWarning):
159+
"""Warns when webhook configured without any validation"""

anymail/signals.py

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,82 @@
11
from django.dispatch import Signal
22

3-
webhook_event = Signal(providing_args=['event_type', 'data'])
3+
4+
# Delivery and tracking events for sent messages
5+
tracking = Signal(providing_args=['event', 'esp_name'])
6+
7+
# Event for receiving inbound messages
8+
inbound = Signal(providing_args=['event', 'esp_name'])
9+
10+
11+
class AnymailEvent(object):
12+
"""Base class for normalized Anymail webhook events"""
13+
14+
def __init__(self, event_type, timestamp=None, event_id=None, esp_event=None, **kwargs):
15+
self.event_type = event_type # normalized to an EventType str
16+
self.timestamp = timestamp # normalized to an aware datetime
17+
self.event_id = event_id # opaque str
18+
self.esp_event = esp_event # raw event fields (e.g., parsed JSON dict or POST data QueryDict)
19+
20+
21+
class AnymailTrackingEvent(AnymailEvent):
22+
"""Normalized delivery and tracking event for sent messages"""
23+
24+
def __init__(self, **kwargs):
25+
super(AnymailTrackingEvent, self).__init__(**kwargs)
26+
self.click_url = kwargs.pop('click_url', None) # str
27+
self.description = kwargs.pop('description', None) # str, usually human-readable, not normalized
28+
self.message_id = kwargs.pop('message_id', None) # str, format may vary
29+
self.metadata = kwargs.pop('metadata', None) # dict
30+
self.mta_response = kwargs.pop('mta_response', None) # str, may include SMTP codes, not normalized
31+
self.recipient = kwargs.pop('recipient', None) # str email address (just the email portion; no name)
32+
self.reject_reason = kwargs.pop('reject_reason', None) # normalized to a RejectReason str
33+
self.tags = kwargs.pop('tags', None) # list of str
34+
self.user_agent = kwargs.pop('user_agent', None) # str
35+
36+
37+
class AnymailInboundEvent(AnymailEvent):
38+
"""Normalized inbound message event"""
39+
40+
def __init__(self, **kwargs):
41+
super(AnymailInboundEvent, self).__init__(**kwargs)
42+
43+
44+
class EventType:
45+
"""Constants for normalized Anymail event types"""
46+
47+
# Delivery (and non-delivery) event types:
48+
# (these match message.ANYMAIL_STATUSES where appropriate)
49+
QUEUED = 'queued' # the ESP has accepted the message and will try to send it (possibly later)
50+
SENT = 'sent' # the ESP has sent the message (though it may or may not get delivered)
51+
REJECTED = 'rejected' # the ESP refused to send the messsage (e.g., suppression list, policy, invalid email)
52+
FAILED = 'failed' # the ESP was unable to send the message (e.g., template rendering error)
53+
54+
BOUNCED = 'bounced' # rejected or blocked by receiving MTA
55+
DEFERRED = 'deferred' # delayed by receiving MTA; should be followed by a later BOUNCED or DELIVERED
56+
DELIVERED = 'delivered' # accepted by receiving MTA
57+
AUTORESPONDED = 'autoresponded' # a bot replied
58+
59+
# Tracking event types:
60+
OPENED = 'opened' # open tracking
61+
CLICKED = 'clicked' # click tracking
62+
COMPLAINED = 'complained' # recipient reported as spam (e.g., through feedback loop)
63+
UNSUBSCRIBED = 'unsubscribed' # recipient attempted to unsubscribe
64+
SUBSCRIBED = 'subscribed' # signed up for mailing list through ESP-hosted form
65+
66+
# Inbound event types:
67+
INBOUND = 'inbound' # received message
68+
INBOUND_FAILED = 'inbound_failed'
69+
70+
# Other:
71+
UNKNOWN = 'unknown' # anything else
72+
73+
74+
class RejectReason:
75+
"""Constants for normalized Anymail reject/drop reasons"""
76+
INVALID = 'invalid' # bad address format
77+
BOUNCED = 'bounced' # (previous) bounce from recipient
78+
TIMED_OUT = 'timed_out' # (previous) repeated failed delivery attempts
79+
BLOCKED = 'blocked' # ESP policy suppression
80+
SPAM = 'spam' # (previous) spam complaint from recipient
81+
UNSUBSCRIBED = 'unsubscribed' # (previous) unsubscribe request from recipient
82+
OTHER = 'other'

anymail/urls.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
1-
try:
2-
from django.conf.urls import url
3-
except ImportError:
4-
from django.conf.urls.defaults import url
1+
from django.conf.urls import url
52

6-
from .views import DjrillWebhookView
3+
from .webhooks.mailgun import MailgunTrackingWebhookView
4+
from .webhooks.mandrill import MandrillTrackingWebhookView
5+
from .webhooks.postmark import PostmarkTrackingWebhookView
6+
from .webhooks.sendgrid import SendGridTrackingWebhookView
77

88

9+
app_name = 'anymail'
910
urlpatterns = [
10-
url(r'^webhook/$', DjrillWebhookView.as_view(), name='djrill_webhook'),
11+
url(r'^mailgun/tracking/$', MailgunTrackingWebhookView.as_view(), name='mailgun_tracking_webhook'),
12+
url(r'^mandrill/tracking/$', MandrillTrackingWebhookView.as_view(), name='mandrill_tracking_webhook'),
13+
url(r'^postmark/tracking/$', PostmarkTrackingWebhookView.as_view(), name='postmark_tracking_webhook'),
14+
url(r'^sendgrid/tracking/$', SendGridTrackingWebhookView.as_view(), name='sendgrid_tracking_webhook'),
1115
]

anymail/utils.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,29 @@ def last(*args):
7070
return UNSET
7171

7272

73+
def getfirst(dct, keys, default=UNSET):
74+
"""Returns the value of the first of keys found in dict dct.
75+
76+
>>> getfirst({'a': 1, 'b': 2}, ['c', 'a'])
77+
1
78+
>>> getfirst({'a': 1, 'b': 2}, ['b', 'a'])
79+
2
80+
>>> getfirst({'a': 1, 'b': 2}, ['c'])
81+
KeyError
82+
>>> getfirst({'a': 1, 'b': 2}, ['c'], None)
83+
None
84+
"""
85+
for key in keys:
86+
try:
87+
return dct[key]
88+
except KeyError:
89+
pass
90+
if default is UNSET:
91+
raise KeyError("None of %s found in dict" % ', '.join(keys))
92+
else:
93+
return default
94+
95+
7396
class ParsedEmail(object):
7497
"""A sanitized, full email address with separate name and email properties"""
7598

@@ -215,6 +238,27 @@ def get_anymail_setting(name, default=UNSET, esp_name=None, kwargs=None, allow_b
215238
return default
216239

217240

241+
def collect_all_methods(cls, method_name):
242+
"""Return list of all `method_name` methods for cls and its superclass chain.
243+
244+
List is in MRO order, with no duplicates. Methods are unbound.
245+
246+
(This is used to simplify mixins and subclasses that contribute to a method set,
247+
without requiring superclass chaining, and without requiring cooperating
248+
superclasses.)
249+
"""
250+
methods = []
251+
for ancestor in cls.__mro__:
252+
try:
253+
validator = getattr(ancestor, method_name)
254+
except AttributeError:
255+
pass
256+
else:
257+
if validator not in methods:
258+
methods.append(validator)
259+
return methods
260+
261+
218262
EPOCH = datetime(1970, 1, 1, tzinfo=utc)
219263

220264

anymail/views.py

Lines changed: 0 additions & 99 deletions
This file was deleted.

anymail/webhooks/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)