Event-tracking webhooks

Closes #3
This commit is contained in:
medmunds
2016-04-20 17:13:55 -07:00
parent 36461e57b9
commit d3f914be12
31 changed files with 2451 additions and 428 deletions

View File

@@ -1,11 +0,0 @@
# For python 3 compatibility, see http://python3porting.com/problems.html#nicer-solutions
import sys
if sys.version < '3':
def b(x):
return x
else:
import codecs
def b(x):
return codecs.latin_1_encode(x)[0]

View File

@@ -1,6 +1,6 @@
import json
from django.core.exceptions import ImproperlyConfigured
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
from requests import HTTPError
@@ -125,6 +125,14 @@ class AnymailSerializationError(AnymailError, TypeError):
super(AnymailSerializationError, self).__init__(message, *args, **kwargs)
class AnymailWebhookValidationFailure(AnymailError, SuspiciousOperation):
"""Exception when a webhook cannot be validated.
Django's SuspiciousOperation turns into
an HTTP 400 error in production.
"""
class AnymailConfigurationError(ImproperlyConfigured):
"""Exception for Anymail configuration or installation issues"""
# This deliberately doesn't inherit from AnymailError,
@@ -140,3 +148,12 @@ class AnymailImproperlyInstalled(AnymailConfigurationError, ImportError):
"with your desired backends)" % (missing_package, backend)
super(AnymailImproperlyInstalled, self).__init__(message)
# Warnings
class AnymailWarning(Warning):
"""Base warning for Anymail"""
class AnymailInsecureWebhookWarning(AnymailWarning):
"""Warns when webhook configured without any validation"""

View File

@@ -1,3 +1,82 @@
from django.dispatch import Signal
webhook_event = Signal(providing_args=['event_type', 'data'])
# Delivery and tracking events for sent messages
tracking = Signal(providing_args=['event', 'esp_name'])
# Event for receiving inbound messages
inbound = Signal(providing_args=['event', 'esp_name'])
class AnymailEvent(object):
"""Base class for normalized Anymail webhook events"""
def __init__(self, event_type, timestamp=None, event_id=None, esp_event=None, **kwargs):
self.event_type = event_type # normalized to an EventType str
self.timestamp = timestamp # normalized to an aware datetime
self.event_id = event_id # opaque str
self.esp_event = esp_event # raw event fields (e.g., parsed JSON dict or POST data QueryDict)
class AnymailTrackingEvent(AnymailEvent):
"""Normalized delivery and tracking event for sent messages"""
def __init__(self, **kwargs):
super(AnymailTrackingEvent, self).__init__(**kwargs)
self.click_url = kwargs.pop('click_url', None) # str
self.description = kwargs.pop('description', None) # str, usually human-readable, not normalized
self.message_id = kwargs.pop('message_id', None) # str, format may vary
self.metadata = kwargs.pop('metadata', None) # dict
self.mta_response = kwargs.pop('mta_response', None) # str, may include SMTP codes, not normalized
self.recipient = kwargs.pop('recipient', None) # str email address (just the email portion; no name)
self.reject_reason = kwargs.pop('reject_reason', None) # normalized to a RejectReason str
self.tags = kwargs.pop('tags', None) # list of str
self.user_agent = kwargs.pop('user_agent', None) # str
class AnymailInboundEvent(AnymailEvent):
"""Normalized inbound message event"""
def __init__(self, **kwargs):
super(AnymailInboundEvent, self).__init__(**kwargs)
class EventType:
"""Constants for normalized Anymail event types"""
# Delivery (and non-delivery) event types:
# (these match message.ANYMAIL_STATUSES where appropriate)
QUEUED = 'queued' # the ESP has accepted the message and will try to send it (possibly later)
SENT = 'sent' # the ESP has sent the message (though it may or may not get delivered)
REJECTED = 'rejected' # the ESP refused to send the messsage (e.g., suppression list, policy, invalid email)
FAILED = 'failed' # the ESP was unable to send the message (e.g., template rendering error)
BOUNCED = 'bounced' # rejected or blocked by receiving MTA
DEFERRED = 'deferred' # delayed by receiving MTA; should be followed by a later BOUNCED or DELIVERED
DELIVERED = 'delivered' # accepted by receiving MTA
AUTORESPONDED = 'autoresponded' # a bot replied
# Tracking event types:
OPENED = 'opened' # open tracking
CLICKED = 'clicked' # click tracking
COMPLAINED = 'complained' # recipient reported as spam (e.g., through feedback loop)
UNSUBSCRIBED = 'unsubscribed' # recipient attempted to unsubscribe
SUBSCRIBED = 'subscribed' # signed up for mailing list through ESP-hosted form
# Inbound event types:
INBOUND = 'inbound' # received message
INBOUND_FAILED = 'inbound_failed'
# Other:
UNKNOWN = 'unknown' # anything else
class RejectReason:
"""Constants for normalized Anymail reject/drop reasons"""
INVALID = 'invalid' # bad address format
BOUNCED = 'bounced' # (previous) bounce from recipient
TIMED_OUT = 'timed_out' # (previous) repeated failed delivery attempts
BLOCKED = 'blocked' # ESP policy suppression
SPAM = 'spam' # (previous) spam complaint from recipient
UNSUBSCRIBED = 'unsubscribed' # (previous) unsubscribe request from recipient
OTHER = 'other'

View File

@@ -1,11 +1,15 @@
try:
from django.conf.urls import url
except ImportError:
from django.conf.urls.defaults import url
from django.conf.urls import url
from .views import DjrillWebhookView
from .webhooks.mailgun import MailgunTrackingWebhookView
from .webhooks.mandrill import MandrillTrackingWebhookView
from .webhooks.postmark import PostmarkTrackingWebhookView
from .webhooks.sendgrid import SendGridTrackingWebhookView
app_name = 'anymail'
urlpatterns = [
url(r'^webhook/$', DjrillWebhookView.as_view(), name='djrill_webhook'),
url(r'^mailgun/tracking/$', MailgunTrackingWebhookView.as_view(), name='mailgun_tracking_webhook'),
url(r'^mandrill/tracking/$', MandrillTrackingWebhookView.as_view(), name='mandrill_tracking_webhook'),
url(r'^postmark/tracking/$', PostmarkTrackingWebhookView.as_view(), name='postmark_tracking_webhook'),
url(r'^sendgrid/tracking/$', SendGridTrackingWebhookView.as_view(), name='sendgrid_tracking_webhook'),
]

View File

@@ -70,6 +70,29 @@ def last(*args):
return UNSET
def getfirst(dct, keys, default=UNSET):
"""Returns the value of the first of keys found in dict dct.
>>> getfirst({'a': 1, 'b': 2}, ['c', 'a'])
1
>>> getfirst({'a': 1, 'b': 2}, ['b', 'a'])
2
>>> getfirst({'a': 1, 'b': 2}, ['c'])
KeyError
>>> getfirst({'a': 1, 'b': 2}, ['c'], None)
None
"""
for key in keys:
try:
return dct[key]
except KeyError:
pass
if default is UNSET:
raise KeyError("None of %s found in dict" % ', '.join(keys))
else:
return default
class ParsedEmail(object):
"""A sanitized, full email address with separate name and email properties"""
@@ -215,6 +238,27 @@ def get_anymail_setting(name, default=UNSET, esp_name=None, kwargs=None, allow_b
return default
def collect_all_methods(cls, method_name):
"""Return list of all `method_name` methods for cls and its superclass chain.
List is in MRO order, with no duplicates. Methods are unbound.
(This is used to simplify mixins and subclasses that contribute to a method set,
without requiring superclass chaining, and without requiring cooperating
superclasses.)
"""
methods = []
for ancestor in cls.__mro__:
try:
validator = getattr(ancestor, method_name)
except AttributeError:
pass
else:
if validator not in methods:
methods.append(validator)
return methods
EPOCH = datetime(1970, 1, 1, tzinfo=utc)

View File

@@ -1,99 +0,0 @@
import hashlib
import hmac
import json
from base64 import b64encode
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.http import HttpResponse
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt
from django.views.generic import View
from .compat import b
from .signals import webhook_event
class DjrillWebhookSecretMixin(object):
@method_decorator(csrf_exempt)
def dispatch(self, request, *args, **kwargs):
secret = getattr(settings, 'DJRILL_WEBHOOK_SECRET', None)
secret_name = getattr(settings, 'DJRILL_WEBHOOK_SECRET_NAME', 'secret')
if secret is None:
raise ImproperlyConfigured(
"You have not set DJRILL_WEBHOOK_SECRET in the settings file.")
if request.GET.get(secret_name) != secret:
return HttpResponse(status=403)
return super(DjrillWebhookSecretMixin, self).dispatch(
request, *args, **kwargs)
class DjrillWebhookSignatureMixin(object):
@method_decorator(csrf_exempt)
def dispatch(self, request, *args, **kwargs):
signature_key = getattr(settings, 'DJRILL_WEBHOOK_SIGNATURE_KEY', None)
if signature_key and request.method == "POST":
# Make webhook url an explicit setting to make sure that this is the exact same string
# that the user entered in Mandrill
post_string = getattr(settings, "DJRILL_WEBHOOK_URL", None)
if post_string is None:
raise ImproperlyConfigured(
"You have set DJRILL_WEBHOOK_SIGNATURE_KEY, but haven't set DJRILL_WEBHOOK_URL in the settings file.")
signature = request.META.get("HTTP_X_MANDRILL_SIGNATURE", None)
if not signature:
return HttpResponse(status=403, content="X-Mandrill-Signature not set")
# The querydict is a bit special, see https://docs.djangoproject.com/en/dev/ref/request-response/#querydict-objects
# Mandrill needs it to be sorted and added to the hash
post_lists = sorted(request.POST.lists())
for value_list in post_lists:
for item in value_list[1]:
post_string += "%s%s" % (value_list[0], item)
hash_string = b64encode(hmac.new(key=b(signature_key), msg=b(post_string), digestmod=hashlib.sha1).digest())
if signature != hash_string:
return HttpResponse(status=403, content="Signature doesn't match")
return super(DjrillWebhookSignatureMixin, self).dispatch(
request, *args, **kwargs)
class DjrillWebhookView(DjrillWebhookSecretMixin, DjrillWebhookSignatureMixin, View):
def head(self, request, *args, **kwargs):
return HttpResponse()
def post(self, request, *args, **kwargs):
try:
data = json.loads(request.POST.get('mandrill_events'))
except TypeError:
return HttpResponse(status=400)
for event in data:
webhook_event.send(
sender=None, event_type=self.get_event_type(event), data=event)
return HttpResponse()
def get_event_type(self, event):
try:
# Message event: https://mandrill.zendesk.com/hc/en-us/articles/205583307
# Inbound event: https://mandrill.zendesk.com/hc/en-us/articles/205583207
event_type = event['event']
except KeyError:
try:
# Sync event: https://mandrill.zendesk.com/hc/en-us/articles/205583297
# Synthesize an event_type like "whitelist_add" or "blacklist_change"
event_type = "%s_%s" % (event['type'], event['action'])
except KeyError:
# Unknown future event format
event_type = None
return event_type

View File

139
anymail/webhooks/base.py Normal file
View File

@@ -0,0 +1,139 @@
import base64
import re
import six
import warnings
from django.http import HttpResponse
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt
from django.views.generic import View
from ..exceptions import AnymailInsecureWebhookWarning, AnymailWebhookValidationFailure
from ..utils import get_anymail_setting, collect_all_methods
class AnymailBasicAuthMixin(object):
"""Implements webhook basic auth as mixin to AnymailBaseWebhookView."""
# Whether to warn if basic auth is not configured.
# For most ESPs, basic auth is the only webhook security,
# so the default is True. Subclasses can set False if
# they enforce other security (like signed webhooks).
warn_if_no_basic_auth = True
# List of allowable HTTP basic-auth 'user:pass' strings.
basic_auth = None # (Declaring class attr allows override by kwargs in View.as_view.)
def __init__(self, **kwargs):
self.basic_auth = get_anymail_setting('webhook_authorization', default=[],
kwargs=kwargs) # no esp_name -- auth is shared between ESPs
# Allow a single string:
if isinstance(self.basic_auth, six.string_types):
self.basic_auth = [self.basic_auth]
if self.warn_if_no_basic_auth and len(self.basic_auth) < 1:
warnings.warn(
"Your Anymail webhooks are insecure and open to anyone on the web. "
"You should set WEBHOOK_AUTHORIZATION in your ANYMAIL settings. "
"See 'Securing webhooks' in the Anymail docs.",
AnymailInsecureWebhookWarning)
# noinspection PyArgumentList
super(AnymailBasicAuthMixin, self).__init__(**kwargs)
def validate_request(self, request):
"""If configured for webhook basic auth, validate request has correct auth."""
if self.basic_auth:
valid = False
try:
authtype, authdata = request.META['HTTP_AUTHORIZATION'].split()
if authtype.lower() == "basic":
auth = base64.b64decode(authdata).decode('utf-8')
if auth in self.basic_auth:
valid = True
except (IndexError, KeyError, TypeError, ValueError):
valid = False
if not valid:
# noinspection PyUnresolvedReferences
raise AnymailWebhookValidationFailure(
"Missing or invalid basic auth in Anymail %s webhook" % self.esp_name)
# Mixin note: Django's View.__init__ doesn't cooperate with chaining,
# so all mixins that need __init__ must appear before View in MRO.
class AnymailBaseWebhookView(AnymailBasicAuthMixin, View):
"""Base view for processing ESP event webhooks
ESP-specific implementations should subclass
and implement parse_events. They may also
want to implement validate_request
if additional security is available.
"""
def __init__(self, **kwargs):
super(AnymailBaseWebhookView, self).__init__(**kwargs)
self.validators = collect_all_methods(self.__class__, 'validate_request')
# Subclass implementation:
# Where to send events: either ..signals.inbound or ..signals.tracking
signal = None
def validate_request(self, request):
"""Check validity of webhook post, or raise AnymailWebhookValidationFailure.
AnymailBaseWebhookView includes basic auth validation.
Subclasses can implement (or provide via mixins) if the ESP supports
additional validation (such as signature checking).
*All* definitions of this method in the class chain (including mixins)
will be called. There is no need to chain to the superclass.
(See self.run_validators and collect_all_methods.)
"""
# if request.POST['signature'] != expected_signature:
# raise AnymailWebhookValidationFailure("...message...")
# (else just do nothing)
pass
def parse_events(self, request):
"""Return a list of normalized AnymailWebhookEvent extracted from ESP post data.
Subclasses must implement.
"""
raise NotImplementedError()
# HTTP handlers (subclasses shouldn't need to override):
http_method_names = ["post", "head", "options"]
def head(self, request, *args, **kwargs):
# Some ESPs verify the webhook with a HEAD request at configuration time
return HttpResponse()
@method_decorator(csrf_exempt)
def post(self, request, *args, **kwargs):
# Normal Django exception handling will do the right thing:
# - AnymailWebhookValidationFailure will turn into an HTTP 400 response
# (via Django SuspiciousOperation handling)
# - Any other errors (e.g., in signal dispatch) will turn into HTTP 500
# responses (via normal Django error handling). ESPs generally
# treat that as "try again later".
self.run_validators(request)
events = self.parse_events(request)
esp_name = self.esp_name
for event in events:
self.signal.send(sender=self.__class__, event=event, esp_name=esp_name)
return HttpResponse()
# Request validation (subclasses shouldn't need to override):
def run_validators(self, request):
for validator in self.validators:
validator(self, request)
@property
def esp_name(self):
"""
Read-only name of the ESP for this webhook view.
(E.g., MailgunTrackingWebhookView will return "Mailgun")
"""
return re.sub(r'(Tracking|Inbox)WebhookView$', "", self.__class__.__name__)

133
anymail/webhooks/mailgun.py Normal file
View File

@@ -0,0 +1,133 @@
import json
from datetime import datetime
import hashlib
import hmac
from django.utils.timezone import utc
from .base import AnymailBaseWebhookView
from ..exceptions import AnymailWebhookValidationFailure
from ..signals import tracking, AnymailTrackingEvent, EventType, RejectReason
from ..utils import get_anymail_setting, combine
class MailgunBaseWebhookView(AnymailBaseWebhookView):
"""Base view class for Mailgun webhooks"""
warn_if_no_basic_auth = False # because we validate against signature
api_key = None # (Declaring class attr allows override by kwargs in View.as_view.)
def __init__(self, **kwargs):
api_key = get_anymail_setting('api_key', esp_name=self.esp_name,
kwargs=kwargs, allow_bare=True)
self.api_key = api_key.encode('ascii') # hmac.new requires bytes key in python 3
super(MailgunBaseWebhookView, self).__init__(**kwargs)
def validate_request(self, request):
super(MailgunBaseWebhookView, self).validate_request(request) # first check basic auth if enabled
try:
token = request.POST['token']
timestamp = request.POST['timestamp']
signature = str(request.POST['signature']) # force to same type as hexdigest() (for python2)
except KeyError:
raise AnymailWebhookValidationFailure("Mailgun webhook called without required security fields")
expected_signature = hmac.new(key=self.api_key, msg='{}{}'.format(timestamp, token).encode('ascii'),
digestmod=hashlib.sha256).hexdigest()
if not hmac.compare_digest(signature, expected_signature):
raise AnymailWebhookValidationFailure("Mailgun webhook called with incorrect signature")
def parse_events(self, request):
return [self.esp_to_anymail_event(request.POST)]
def esp_to_anymail_event(self, esp_event):
raise NotImplementedError()
class MailgunTrackingWebhookView(MailgunBaseWebhookView):
"""Handler for Mailgun delivery and engagement tracking webhooks"""
signal = tracking
event_types = {
# Map Mailgun event: Anymail normalized type
'delivered': EventType.DELIVERED,
'dropped': EventType.REJECTED,
'bounced': EventType.BOUNCED,
'complained': EventType.COMPLAINED,
'unsubscribed': EventType.UNSUBSCRIBED,
'opened': EventType.OPENED,
'clicked': EventType.CLICKED,
# Mailgun does not send events corresponding to QUEUED or DEFERRED
}
reject_reasons = {
# Map Mailgun (SMTP) error codes to Anymail normalized reject_reason.
# By default, we will treat anything 400-599 as REJECT_BOUNCED
# so only exceptions are listed here.
499: RejectReason.TIMED_OUT, # unable to connect to MX (also covers invalid recipients)
# These 6xx codes appear to be Mailgun extensions to SMTP
# (and don't seem to be documented anywhere):
605: RejectReason.BOUNCED, # previous bounce
607: RejectReason.SPAM, # previous spam complaint
}
def esp_to_anymail_event(self, esp_event):
# esp_event is a Django QueryDict (from request.POST),
# which has multi-valued fields, but is *not* case-insensitive
event_type = self.event_types.get(esp_event['event'], EventType.UNKNOWN)
timestamp = datetime.fromtimestamp(int(esp_event['timestamp']), tz=utc)
# Message-Id is not documented for every event, but seems to always be included.
# (It's sometimes spelled as 'message-id', lowercase, and missing the <angle-brackets>.)
message_id = esp_event.get('Message-Id', esp_event.get('message-id', None))
if message_id and not message_id.startswith('<'):
message_id = "<{}>".format(message_id)
description = esp_event.get('description', None)
mta_response = esp_event.get('error', esp_event.get('notification', None))
reject_reason = None
try:
mta_status = int(esp_event['code'])
except (KeyError, TypeError):
pass
else:
reject_reason = self.reject_reasons.get(
mta_status,
RejectReason.BOUNCED if 400 <= mta_status < 600
else RejectReason.OTHER)
# Mailgun merges metadata fields with the other event fields.
# However, it also includes the original message headers,
# which have the metadata separately as X-Mailgun-Variables.
try:
headers = json.loads(esp_event['message-headers'])
except (KeyError, ):
metadata = None
else:
variables = [value for [field, value] in headers
if field == 'X-Mailgun-Variables']
if len(variables) >= 1:
# Each X-Mailgun-Variables value is JSON. Parse and merge them all into single dict:
metadata = combine(*[json.loads(value) for value in variables])
else:
metadata = None
# tags are sometimes delivered as X-Mailgun-Tag fields, sometimes as tag
tags = esp_event.getlist('tag', esp_event.getlist('X-Mailgun-Tag', None))
return AnymailTrackingEvent(
event_type=event_type,
timestamp=timestamp,
message_id=message_id,
event_id=esp_event.get('token', None),
recipient=esp_event.get('recipient', None),
reject_reason=reject_reason,
description=description,
mta_response=mta_response,
tags=tags,
metadata=metadata,
click_url=esp_event.get('url', None),
user_agent=esp_event.get('user-agent', None),
esp_event=esp_event,
)

View File

@@ -0,0 +1,140 @@
import json
from datetime import datetime
import hashlib
import hmac
from base64 import b64encode
from django.utils.timezone import utc
from .base import AnymailBaseWebhookView
from ..exceptions import AnymailWebhookValidationFailure, AnymailConfigurationError
from ..signals import tracking, AnymailTrackingEvent, EventType
from ..utils import get_anymail_setting, getfirst
class MandrillSignatureMixin(object):
"""Validates Mandrill webhook signature"""
# These can be set from kwargs in View.as_view, or pulled from settings in init:
webhook_key = None # required
webhook_url = None # optional; defaults to actual url used
def __init__(self, **kwargs):
# noinspection PyUnresolvedReferences
esp_name = self.esp_name
webhook_key = get_anymail_setting('webhook_key', esp_name=esp_name,
kwargs=kwargs, allow_bare=True)
self.webhook_key = webhook_key.encode('ascii') # hmac.new requires bytes key in python 3
self.webhook_url = get_anymail_setting('webhook_url', esp_name=esp_name, default=None,
kwargs=kwargs, allow_bare=True)
# noinspection PyArgumentList
super(MandrillSignatureMixin, self).__init__(**kwargs)
def validate_request(self, request):
try:
signature = request.META["HTTP_X_MANDRILL_SIGNATURE"]
except KeyError:
raise AnymailWebhookValidationFailure("X-Mandrill-Signature header missing from webhook POST")
# Mandrill signs the exact URL plus the sorted POST params:
signed_data = self.webhook_url or request.build_absolute_uri()
params = request.POST.dict()
for key in sorted(params.keys()):
signed_data += key + params[key]
expected_signature = b64encode(hmac.new(key=self.webhook_key, msg=signed_data.encode('utf-8'),
digestmod=hashlib.sha1).digest())
if not hmac.compare_digest(signature, expected_signature):
raise AnymailWebhookValidationFailure("Mandrill webhook called with incorrect signature")
class MandrillBaseWebhookView(MandrillSignatureMixin, AnymailBaseWebhookView):
"""Base view class for Mandrill webhooks"""
warn_if_no_basic_auth = False # because we validate against signature
def parse_events(self, request):
esp_events = json.loads(request.POST['mandrill_events'])
return [self.esp_to_anymail_event(esp_event) for esp_event in esp_events]
def esp_to_anymail_event(self, esp_event):
raise NotImplementedError()
class MandrillTrackingWebhookView(MandrillBaseWebhookView):
signal = tracking
event_types = {
# Message events:
'send': EventType.SENT,
'deferral': EventType.DEFERRED,
'hard_bounce': EventType.BOUNCED,
'soft_bounce': EventType.DEFERRED,
'open': EventType.OPENED,
'click': EventType.CLICKED,
'spam': EventType.COMPLAINED,
'unsub': EventType.UNSUBSCRIBED,
'reject': EventType.REJECTED,
# Sync events (we don't really normalize these well):
'whitelist': EventType.UNKNOWN,
'blacklist': EventType.UNKNOWN,
# Inbound events:
'inbound': EventType.INBOUND,
}
def esp_to_anymail_event(self, esp_event):
esp_type = getfirst(esp_event, ['event', 'type'], None)
event_type = self.event_types.get(esp_type, EventType.UNKNOWN)
if event_type == EventType.INBOUND:
raise AnymailConfigurationError(
"You seem to have set Mandrill's *inbound* webhook URL "
"to Anymail's Mandrill *tracking* webhook URL.")
try:
timestamp = datetime.fromtimestamp(esp_event['ts'], tz=utc)
except (KeyError, ValueError):
timestamp = None
try:
recipient = esp_event['msg']['email']
except KeyError:
try:
recipient = esp_event['reject']['email'] # sync events
except KeyError:
recipient = None
try:
mta_response = esp_event['msg']['diag']
except KeyError:
mta_response = None
try:
description = getfirst(esp_event['reject'], ['detail', 'reason'])
except KeyError:
description = None
try:
metadata = esp_event['msg']['metadata']
except KeyError:
metadata = None
try:
tags = esp_event['msg']['tags']
except KeyError:
tags = None
return AnymailTrackingEvent(
click_url=esp_event.get('url', None),
description=description,
esp_event=esp_event,
event_type=event_type,
message_id=esp_event.get('_id', None),
metadata=metadata,
mta_response=mta_response,
recipient=recipient,
reject_reason=None, # probably map esp_event['msg']['bounce_description'], but insufficient docs
tags=tags,
timestamp=timestamp,
user_agent=esp_event.get('user_agent', None),
)

View File

@@ -0,0 +1,104 @@
import json
from django.utils.dateparse import parse_datetime
from .base import AnymailBaseWebhookView
from ..exceptions import AnymailConfigurationError
from ..signals import tracking, AnymailTrackingEvent, EventType, RejectReason
from ..utils import getfirst
class PostmarkBaseWebhookView(AnymailBaseWebhookView):
"""Base view class for Postmark webhooks"""
def parse_events(self, request):
esp_event = json.loads(request.body.decode('utf-8'))
return [self.esp_to_anymail_event(esp_event)]
def esp_to_anymail_event(self, esp_event):
raise NotImplementedError()
class PostmarkTrackingWebhookView(PostmarkBaseWebhookView):
"""Handler for Postmark delivery and engagement tracking webhooks"""
signal = tracking
event_types = {
# Map Postmark event type: Anymail normalized (event type, reject reason)
'HardBounce': (EventType.BOUNCED, RejectReason.BOUNCED),
'Transient': (EventType.DEFERRED, None),
'Unsubscribe': (EventType.UNSUBSCRIBED, RejectReason.UNSUBSCRIBED),
'Subscribe': (EventType.SUBSCRIBED, None),
'AutoResponder': (EventType.AUTORESPONDED, None),
'AddressChange': (EventType.AUTORESPONDED, None),
'DnsError': (EventType.DEFERRED, None), # "temporary DNS error"
'SpamNotification': (EventType.COMPLAINED, RejectReason.SPAM),
'OpenRelayTest': (EventType.DEFERRED, None), # Receiving MTA is testing Postmark
'Unknown': (EventType.UNKNOWN, None),
'SoftBounce': (EventType.DEFERRED, RejectReason.BOUNCED), # until HardBounce later
'VirusNotification': (EventType.BOUNCED, RejectReason.OTHER),
'ChallengeVerification': (EventType.AUTORESPONDED, None),
'BadEmailAddress': (EventType.REJECTED, RejectReason.INVALID),
'SpamComplaint': (EventType.COMPLAINED, RejectReason.SPAM),
'ManuallyDeactivated': (EventType.REJECTED, RejectReason.BLOCKED),
'Unconfirmed': (EventType.REJECTED, None),
'Blocked': (EventType.REJECTED, RejectReason.BLOCKED),
'SMTPApiError': (EventType.FAILED, None), # could occur if user also using Postmark SMTP directly
'InboundError': (EventType.INBOUND_FAILED, None),
'DMARCPolicy': (EventType.REJECTED, RejectReason.BLOCKED),
'TemplateRenderingFailed': (EventType.FAILED, None),
# Postmark does not report DELIVERED
# Postmark does not report CLICKED (because it doesn't implement click-tracking)
# OPENED doesn't have a Type field; detected separately below
# INBOUND doesn't have a Type field; should come in through different webhook
}
def esp_to_anymail_event(self, esp_event):
reject_reason = None
try:
esp_type = esp_event['Type']
event_type, reject_reason = self.event_types.get(esp_type, (EventType.UNKNOWN, None))
except KeyError:
if 'FirstOpen' in esp_event:
event_type = EventType.OPENED
elif 'From' in esp_event:
# This is an inbound event
raise AnymailConfigurationError(
"You seem to have set Postmark's *inbound* webhook URL "
"to Anymail's Postmark *tracking* webhook URL.")
else:
event_type = EventType.UNKNOWN
recipient = getfirst(esp_event, ['Email', 'Recipient'], None) # Email for bounce; Recipient for open
try:
timestr = getfirst(esp_event, ['BouncedAt', 'ReceivedAt'])
except KeyError:
timestamp = None
else:
timestamp = parse_datetime(timestr)
try:
event_id = str(esp_event['ID']) # only in bounce events
except KeyError:
event_id = None
try:
tags = [esp_event['Tag']]
except KeyError:
tags = None
return AnymailTrackingEvent(
description=esp_event.get('Description', None),
esp_event=esp_event,
event_id=event_id,
event_type=event_type,
message_id=esp_event.get('MessageID', None),
mta_response=esp_event.get('Details', None),
recipient=recipient,
reject_reason=reject_reason,
tags=tags,
timestamp=timestamp,
user_agent=esp_event.get('UserAgent', None),
)

View File

@@ -0,0 +1,123 @@
import json
from datetime import datetime
from django.utils.timezone import utc
from .base import AnymailBaseWebhookView
from ..signals import tracking, AnymailTrackingEvent, EventType, RejectReason
class SendGridBaseWebhookView(AnymailBaseWebhookView):
"""Base view class for SendGrid webhooks"""
def parse_events(self, request):
esp_events = json.loads(request.body.decode('utf-8'))
return [self.esp_to_anymail_event(esp_event) for esp_event in esp_events]
def esp_to_anymail_event(self, esp_event):
raise NotImplementedError()
class SendGridTrackingWebhookView(SendGridBaseWebhookView):
"""Handler for SendGrid delivery and engagement tracking webhooks"""
signal = tracking
event_types = {
# Map SendGrid event: Anymail normalized type
'bounce': EventType.BOUNCED,
'deferred': EventType.DEFERRED,
'delivered': EventType.DELIVERED,
'dropped': EventType.REJECTED,
'processed': EventType.QUEUED,
'click': EventType.CLICKED,
'open': EventType.OPENED,
'spamreport': EventType.COMPLAINED,
'unsubscribe': EventType.UNSUBSCRIBED,
'group_unsubscribe': EventType.UNSUBSCRIBED,
'group_resubscribe': EventType.SUBSCRIBED,
}
reject_reasons = {
# Map SendGrid reason/type strings (lowercased) to Anymail normalized reject_reason
'invalid': RejectReason.INVALID,
'unsubscribed address': RejectReason.UNSUBSCRIBED,
'bounce': RejectReason.BOUNCED,
'blocked': RejectReason.BLOCKED,
'expired': RejectReason.TIMED_OUT,
}
def esp_to_anymail_event(self, esp_event):
event_type = self.event_types.get(esp_event['event'], EventType.UNKNOWN)
try:
timestamp = datetime.fromtimestamp(esp_event['timestamp'], tz=utc)
except (KeyError, ValueError):
timestamp = None
if esp_event['event'] == 'dropped':
mta_response = None # dropped at ESP before even getting to MTA
reason = esp_event.get('type', esp_event.get('reason', '')) # cause could be in 'type' or 'reason'
reject_reason = self.reject_reasons.get(reason.lower(), RejectReason.OTHER)
else:
# MTA response is in 'response' for delivered; 'reason' for bounce
mta_response = esp_event.get('response', esp_event.get('reason', None))
reject_reason = None
# SendGrid merges metadata ('unique_args') with the event.
# We can (sort of) split metadata back out by filtering known
# SendGrid event params, though this can miss metadata keys
# that duplicate SendGrid params, and can accidentally include
# non-metadata keys if SendGrid modifies their event records.
metadata_keys = set(esp_event.keys()) - self.sendgrid_event_keys
if len(metadata_keys) > 0:
metadata = {key: esp_event[key] for key in metadata_keys}
else:
metadata = None
return AnymailTrackingEvent(
event_type=event_type,
timestamp=timestamp,
message_id=esp_event.get('smtp-id', None),
event_id=esp_event.get('sg_event_id', None),
recipient=esp_event.get('email', None),
reject_reason=reject_reason,
mta_response=mta_response,
tags=esp_event.get('category', None),
metadata=metadata,
click_url=esp_event.get('url', None),
user_agent=esp_event.get('useragent', None),
esp_event=esp_event,
)
# Known keys in SendGrid events (used to recover metadata above)
sendgrid_event_keys = {
'asm_group_id',
'attempt', # MTA deferred count
'category',
'cert_err',
'email',
'event',
'ip',
'marketing_campaign_id',
'marketing_campaign_name',
'newsletter', # ???
'nlvx_campaign_id',
'nlvx_campaign_split_id',
'nlvx_user_id',
'pool',
'post_type',
'reason', # MTA bounce/drop reason; SendGrid suppression reason
'response', # MTA deferred/delivered message
'send_at',
'sg_event_id',
'sg_message_id',
'smtp-id',
'status', # SMTP status code
'timestamp',
'tls',
'type', # suppression reject reason ("bounce", "blocked", "expired")
'url', # click tracking
'url_offset', # click tracking
'useragent', # click/open tracking
}