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 .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
}

View File

@@ -24,6 +24,8 @@ folks from `brack3t`_ who developed the original version of Djrill.
.. _Djrill: https://github.com/brack3t/Djrill
.. _reporting-bugs:
Bugs
----

View File

@@ -36,13 +36,13 @@ Email Service Provider |Mailgun| |Mandrill| |Postmark|
:attr:`~AnymailMessage.track_clicks` Yes Yes No Yes
:attr:`~AnymailMessage.track_opens` Yes Yes Yes Yes
.. rubric:: :ref:`Status <esp-send-status>` and tracking
.. rubric:: :ref:`Status <esp-send-status>` and :ref:`event tracking <event-tracking>`
-------------------------------------------------------------------------------------------
:attr:`~AnymailMessage.anymail_status` Yes Yes Yes Yes
|AnymailTrackingEvent| from webhooks Yes Yes Yes Yes
=========================================== ========= ========== ========== ==========
.. Status tracking webhooks (coming)...
.. .. rubric:: :ref:`inbound`
.. -------------------------------------------------------------------------------------------
.. Inbound webhooks (coming)...
@@ -56,6 +56,7 @@ meaningless. (And even specific features don't matter if you don't plan to use t
.. |Mandrill| replace:: :ref:`mandrill-backend`
.. |Postmark| replace:: :ref:`postmark-backend`
.. |SendGrid| replace:: :ref:`sendgrid-backend`
.. |AnymailTrackingEvent| replace:: :class:`~anymail.signals.AnymailTrackingEvent`
Other ESPs

View File

@@ -103,3 +103,35 @@ values directly to Mailgun. You can use any of the (non-file) parameters listed
}
.. _Mailgun sending docs: https://documentation.mailgun.com/api-sending.html#sending
.. _mailgun-webhooks:
Status tracking webhooks
------------------------
If you are using Anymail's normalized :ref:`status tracking <event-tracking>`, enter
the url in your `Mailgun dashboard`_ on the "Webhooks" tab. Mailgun allows you to enter
a different URL for each event type: just enter this same Anymail tracking URL
for all events you want to receive:
:samp:`https://{random}:{random}@{yoursite.example.com}/anymail/mailgun/tracking/`
* *random:random* is an :setting:`ANYMAIL_WEBHOOK_AUTHORIZATION` shared secret
* *yoursite.example.com* is your Django site
If you use multiple Mailgun sending domains, you'll need to enter the webhook
URLs for each of them, using the selector on the left side of Mailgun's dashboard.
Mailgun implements a limited form of webhook signing, and Anymail will verify
these signatures (based on your :setting:`MAILGUN_API_KEY <ANYMAIL_MAILGUN_API_KEY>`
Anymail setting).
Mailgun will report these Anymail :attr:`~anymail.signals.AnymailTrackingEvent.event_type`\s:
delivered, rejected, bounced, complained, unsubscribed, opened, clicked.
The event's :attr:`~anymail.signals.AnymailTrackingEvent.esp_event` field will be
a Django :class:`~django.http.QueryDict` object of `Mailgun event fields`_.
.. _Mailgun dashboard: https://mailgun.com/app/dashboard
.. _Mailgun event fields: https://documentation.mailgun.com/user_manual.html#webhooks

View File

@@ -56,6 +56,31 @@ root of the settings file if neither ``ANYMAIL["MANDRILL_API_KEY"]``
nor ``ANYMAIL_MANDRILL_API_KEY`` is set.
.. setting:: ANYMAIL_MANDRILL_WEBHOOK_KEY
.. rubric:: MANDRILL_WEBHOOK_KEY
Required if using Anymail's webhooks. The "webhook authentication key"
issued by Mandrill.
`More info <https://mandrill.zendesk.com/hc/en-us/articles/205583257>`_
in Mandrill's KB.
.. setting:: ANYMAIL_MANDRILL_WEBHOOK_URL
.. rubric:: MANDRILL_WEBHOOK_URL
Required only if using Anymail's webhooks *and* the hostname your
Django server sees is different from the public webhook URL
you provided Mandrill. (E.g., if you have a proxy in front
of your Django server that forwards
"https\://yoursite.example.com" to "http\://localhost:8000/").
If you are seeing :exc:`AnymailWebhookValidationFailure` errors
from your webhooks, set this to the exact webhook URL you entered
in Mandrill's settings.
.. setting:: ANYMAIL_MANDRILL_API_URL
.. rubric:: MANDRILL_API_URL
@@ -76,6 +101,41 @@ Anymail's Mandrill backend does not yet implement the
:attr:`~anymail.message.AnymailMessage.esp_extra` feature.
.. _mandrill-webhooks:
Status tracking webhooks
------------------------
If you are using Anymail's normalized :ref:`status tracking <event-tracking>`,
follow `Mandrill's instructions`_ to add Anymail's webhook URL:
:samp:`https://{random}:{random}@{yoursite.example.com}/anymail/mandrill/tracking/`
* *random:random* is an :setting:`ANYMAIL_WEBHOOK_AUTHORIZATION` shared secret
* *yoursite.example.com* is your Django site
Be sure to check the boxes in the Mandrill settings for the event types you want to receive.
The same Anymail tracking URL can handle all Mandrill "message" and "sync" events.
Mandrill implements webhook signing on the entire event payload, and Anymail will
verify the signature. You must set :setting:`ANYMAIL_MANDRILL_WEBHOOK_KEY` to the
webhook key authentication key issued by Mandrill. You may also need to set
:setting:`ANYMAIL_MANDRILL_WEBHOOK_URL` depending on your server config.
Mandrill will report these Anymail :attr:`~anymail.signals.AnymailTrackingEvent.event_type`\s:
sent, rejected, deferred, bounced, opened, clicked, complained, unsubscribed. Mandrill does
not support delivered events. Mandrill "whitelist" and "blacklist" sync events will show up
as Anymail's unknown event_type.
The event's :attr:`~anymail.signals.AnymailTrackingEvent.esp_event` field will be
a `dict` of Mandrill event fields, for a single event. (Although Mandrill calls
webhooks with batches of events, Anymail will invoke your signal receiver separately
for each event in the batch.)
.. _Mandrill's instructions:
https://mandrill.zendesk.com/hc/en-us/articles/205583217-Introduction-to-Webhooks
.. _migrating-from-djrill:
Migrating from Djrill
@@ -123,6 +183,16 @@ Changes to settings
(or just `IGNORE_RECIPIENT_STATUS` in the :setting:`ANYMAIL`
settings dict).
``DJRILL_WEBHOOK_SECRET`` and ``DJRILL_WEBHOOK_SECRET_NAME``
Replaced with HTTP basic auth. See :ref:`securing-webhooks`.
``DJRILL_WEBHOOK_SIGNATURE_KEY``
Use :setting:`ANYMAIL_MANDRILL_WEBHOOK_KEY` instead.
``DJRILL_WEBHOOK_URL``
Use :setting:`ANYMAIL_MANDRILL_WEBHOOK_URL`, or eliminate if
your Django server is not behind a proxy that changes hostnames.
Changes to EmailMessage attributes
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -182,3 +252,25 @@ Changes to EmailMessage attributes
Or better yet, use Anymail's new :ref:`inline-images`
helper functions to attach your inline images.
Changes to webhooks
~~~~~~~~~~~~~~~~~~~
Anymail uses HTTP basic auth as a shared secret for validating webhook
calls, rather than Djrill's "secret" query parameter. See
:ref:`securing-webhooks`. (A slight advantage of basic auth over query
parameters is that most logging and analytics systems are aware of the
need to keep auth secret.)
Anymail replaces `djrill.signals.webhook_event` with
`anymail.signals.tracking` and (in a future release)
`anymail.signals.inbound`. Anymail parses and normalizes
the event data passed to the signal receiver: see :ref:`event-tracking`.
The equivalent of Djrill's ``data`` parameter is available
to your signal receiver as
:attr:`event.esp_event <anymail.signals.AnymailTrackingEvent.esp_event>`,
and for most events, the equivalent of Djrill's ``event_type`` parameter
is `event.esp_event['event']`. But consider working with Anymail's
normalized :class:`~anymail.signals.AnymailTrackingEvent` instead.

View File

@@ -113,3 +113,37 @@ see :ref:`unsupported-features`.
**No delayed sending**
Postmark does not support :attr:`~anymail.message.AnymailMessage.send_at`.
.. _postmark-webhooks:
Status tracking webhooks
------------------------
If you are using Anymail's normalized :ref:`status tracking <event-tracking>`, enter
the url in your `Postmark account settings`_, under Servers > *your server name* >
Settings > Outbound > Webhooks. You should enter this same Anymail tracking URL
for both the "Bounce webhook" and "Opens webhook" (if you want to receive both
types of events):
:samp:`https://{random}:{random}@{yoursite.example.com}/anymail/postmark/tracking/`
* *random:random* is an :setting:`ANYMAIL_WEBHOOK_AUTHORIZATION` shared secret
* *yoursite.example.com* is your Django site
Anymail doesn't care about the "include bounce content" and "post only on first open"
Postmark webhook settings: whether to use them is your choice.
If you use multiple Postmark servers, you'll need to repeat entering the webhook
settings for each of them.
Postmark will report these Anymail :attr:`~anymail.signals.AnymailTrackingEvent.event_type`\s:
rejected, failed, bounced, deferred, autoresponded, opened, complained, unsubscribed, subscribed.
(Postmark does not support sent, delivered, or clicked events.)
The event's :attr:`~anymail.signals.AnymailTrackingEvent.esp_event` field will be
a `dict` of Postmark `bounce <http://developer.postmarkapp.com/developer-bounce-webhook.html>`_
or `open <http://developer.postmarkapp.com/developer-open-webhook.html>`_ webhook data.
.. _Postmark account settings: https://account.postmarkapp.com/servers

View File

@@ -174,3 +174,31 @@ Limitations and quirks
actually OK with that.)
(Tested March, 2016)
.. _sendgrid-webhooks:
Status tracking webhooks
------------------------
If you are using Anymail's normalized :ref:`status tracking <event-tracking>`, enter
the url in your `SendGrid mail settings`_, under "Event Notification":
:samp:`https://{random}:{random}@{yoursite.example.com}/anymail/sendgrid/tracking/`
* *random:random* is an :setting:`ANYMAIL_WEBHOOK_AUTHORIZATION` shared secret
* *yoursite.example.com* is your Django site
Be sure to check the boxes in the SendGrid settings for the event types you want to receive.
SendGrid will report these Anymail :attr:`~anymail.signals.AnymailTrackingEvent.event_type`\s:
queued, rejected, bounced, deferred, delivered, opened, clicked, complained, unsubscribed,
subscribed.
The event's :attr:`~anymail.signals.AnymailTrackingEvent.esp_event` field will be
a `dict` of `Sendgrid event`_ fields, for a single event. (Although SendGrid calls
webhooks with batches of events, Anymail will invoke your signal receiver separately
for each event in the batch.)
.. _SendGrid mail settings: https://app.sendgrid.com/settings/mail_settings
.. _Sendgrid event: https://sendgrid.com/docs/API_Reference/Webhooks/event.html

View File

@@ -8,11 +8,5 @@ Receiving inbound email
Normalized inbound email handling is coming soon to Anymail.
.. _inbound-webhooks:
Configuring inbound webhooks
----------------------------
Inbound email signals
---------------------

View File

@@ -41,6 +41,9 @@ To use Anymail for sending email, edit your Django project's :file:`settings.py`
"MAILGUN_API_KEY" = "<your Mailgun key>",
}
The exact settings vary by ESP.
See the :ref:`supported ESPs <supported-esps>` section for specifics.
3. Change your existing Django :setting:`EMAIL_BACKEND` to the Anymail backend
for your ESP. For example, to send using Mailgun by default:
@@ -52,39 +55,140 @@ To use Anymail for sending email, edit your Django project's :file:`settings.py`
use :ref:`multiple Anymail backends <multiple-backends>` to send particular
messages through different ESPs.)
The exact backend name and required settings vary by ESP.
See the :ref:`supported ESPs <supported-esps>` section for specifics.
Also, if you don't already have a :setting:`DEFAULT_FROM_EMAIL` in your settings,
Finally, if you don't already have a :setting:`DEFAULT_FROM_EMAIL` in your settings,
this is a good time to add one. (Django's default is "webmaster\@localhost",
which some ESPs will reject.)
With the settings above, you are ready to send outgoing email through your ESP.
If you also want to enable status tracking or inbound email, continue with the
optional settings below. Otherwise, skip ahead to :ref:`sending-email`.
Configuring status tracking webhooks
------------------------------------
Anymail can optionally connect to your ESPs event webhooks to notify your app
.. _webhooks-configuration:
Configuring status tracking webhooks (optional)
-----------------------------------------------
Anymail can optionally connect to your ESP's event webhooks to notify your app
of status like bounced and rejected emails, successful delivery, message opens
and clicks, and other tracking.
If you aren't using Anymail's webhooks, skip this section.
.. warning::
Webhooks are ordinary urls, and are wide open to the internet.
You must use care to **avoid creating security vulnerabilities**
that could expose your users' emails and other private information,
or subject your app to malicious input data.
At a minimum, your site should **use SSL** (https), and you should
configure **webhook authorization** as described below.
See :ref:`securing-webhooks` for additional information.
If you want to use Anymail's status tracking webhooks, follow the steps above
to :ref:`configure an Anymail backend <backend-configuration>`, and then
follow the instructions in the :ref:`event-tracking` section to set up
the delivery webhooks.
to :ref:`configure an Anymail backend <backend-configuration>`, and then:
1. In your :file:`settings.py`, add
:setting:`WEBHOOK_AUTHORIZATION <ANYMAIL_WEBHOOK_AUTHORIZATION>`
to the ``ANYMAIL`` block:
.. code-block:: python
ANYMAIL = {
...
'WEBHOOK_AUTHORIZATION': '<a random string>:<another random string>',
}
This setting should be a string with two sequences of random characters,
separated by a colon. It is used as a shared secret, known only to your ESP
and your Django app, to ensure nobody else can call your webhooks.
We suggest using 16 characters (or more) for each half of the
secret. Always generate a new, random secret just for this purpose.
(*Don't* use your Django secret key or ESP's API key.)
An easy way to generate a random secret is to run this command in
a shell:
.. code-block:: console
$ python -c "from django.utils import crypto; print(':'.join(crypto.get_random_string(16) for _ in range(2)))"
(This setting is actually an HTTP basic auth string. You can also set it
to a list of auth strings, to simplify credential rotation or use different auth
with different ESPs. See :setting:`ANYMAIL_WEBHOOK_AUTHORIZATION` in the
:ref:`securing-webhooks` docs for more details.)
Configuring inbound email
-------------------------
2. In your project's :file:`urls.py`, add routing for the Anymail webhook urls:
Anymail can optionally connect to your ESPs inbound webhook to notify your app
of inbound messages.
.. code-block:: python
If you want to use inbound email with Anymail, first follow the first two
:ref:`backend configuration <backend-configuration>` steps above. (You can
skip changing your :setting:`EMAIL_BACKEND` if you don't want to us Anymail
for *sending* messages.) Then follow the instructions in the
:ref:`inbound-webhooks` section to set up the inbound webhooks.
from django.conf.urls import include, url
urlpatterns = [
...
url(r'^anymail/', include('anymail.urls')),
]
(You can change the "anymail" prefix in the first parameter to
:func:`~django.conf.urls.url` if you'd like the webhooks to be served
at some other URL. Just match whatever you use in the webhook URL you give
your ESP in the next step.)
3. Enter the webhook URL(s) into your ESP's dashboard or control panel.
In most cases, the URL will be:
:samp:`https://{random}:{random}@{yoursite.example.com}/anymail/{esp}/tracking/`
* "https" (rather than http) is *strongly recommended*
* *random:random* is the WEBHOOK_AUTHORIZATION string you created in step 1
* *yoursite.example.com* is your Django site
* "anymail" is the url prefix (from step 2)
* *esp* is the lowercase name of your ESP (e.g., "sendgrid" or "mailgun")
* "tracking" is used for Anymail's sent-mail event tracking webhooks
Some ESPs support different webhooks for different tracking events. You can
usually enter the same Anymail webhook URL for all of them (or all that you
want to receive). But be sure to check the specific details for your ESP
under :ref:`supported-esps`.
Also, some ESPs try to validate the webhook URL immediately when you enter it.
If so, you'll need to deploy your Django project to your live server before you
can complete this step.
See :ref:`event-tracking` for information on creating signal handlers and the
status tracking events you can receive.
.. _inbound-configuration:
Configuring inbound email (optional)
------------------------------------
(Coming soon -- not yet implemented)
.. Anymail can optionally connect to your ESP's inbound webhook to notify your app
.. of incoming messages.
..
.. If you aren't using your EPS's inbound email, skip this section.
..
.. If you want to use inbound email with Anymail, follow the steps above
.. for setting up :ref:`status tracking webhooks <webhooks-configuration>`,
.. but enter the webhook URL in your ESP's "inbound email" settings,
.. substituting "inbound" for "tracking" at the end of the url:
..
.. :samp:`https://{random}:{random}@{yoursite.example.com}/anymail/{esp}/inbound/`
..
.. Then see :ref:`inbound` for information on creating a signal handler
.. for receiving inbound email notifications in your code.
..
.. (Note: if you are only using your ESP for inbound email, not sending messages,
.. there's no need to change your project's EMAIL_BACKEND.)
.. setting:: ANYMAIL
@@ -163,3 +267,19 @@ Whether Anymail should raise :exc:`~anymail.exceptions.AnymailUnsupportedFeature
errors for email with features that can't be accurately communicated to the ESP.
Set to `True` to ignore these problems and send the email anyway. See
:ref:`unsupported-features`. (Default `False`.)
.. rubric:: WEBHOOK_AUTHORIZATION
A `'random:random'` shared secret string. Anymail will reject incoming webhook calls
from your ESP that don't include this authorization. You can also give a list of
shared secret strings, and Anymail will allow ESP webhook calls that match any of them
(to facilitate credential rotation). See :ref:`securing-webhooks`.
Default is unset, which leaves your webhooks insecure. Anymail
will warn if you try to use webhooks with setting up authorization.
This is actually implemented using HTTP basic authorization, and the string is
technically a "username:password" format. But you should *not* use any real
username or password for this shared secret.

View File

@@ -1,178 +1,272 @@
.. module:: anymail.signals
.. _event-tracking:
Tracking sent mail status
=========================
Anymail provides normalized handling for your ESP's event-tracking webhooks.
You can use this to be notified when sent messages have been delivered,
bounced, been opened or had links clicked, among other things.
Webhook support is optional. If you haven't yet, you'll need to
:ref:`configure webhooks <webhooks-configuration>` in your Django
project. (You may also want to review :ref:`securing-webhooks`.)
Once you've enabled webhooks, Anymail will send a ``anymail.signals.tracking``
custom Django :mod:`signal <django.dispatch>` for each ESP tracking event it receives.
You can connect your own receiver function to this signal for further processing.
Be sure to read Django's `listening to signals`_ docs for information on defining
and connecting signal receivers.
Example:
.. code-block:: python
from anymail.signals import tracking
from django.dispatch import receiver
@receiver(tracking) # add weak=False if inside some other function/class
def handle_bounce(sender, event, esp_name, **kwargs):
if event.event_type == 'bounced':
print("Message %s to %s bounced" % (
event.message_id, event.recipient))
@receiver(tracking)
def handle_click(sender, event, esp_name, **kwargs):
if event.event_type == 'clicked':
print("Recipient %s clicked url %s" % (
event.recipient, event.click_url))
You can define individual signal receivers, or create one big one for all
event types, which ever you prefer. You can even handle the same event
in multiple receivers, if that makes your code cleaner. These
:ref:`signal receiver functions <signal-receivers>` are documented
in more detail below.
Note that your tracking signal recevier(s) will be called for all tracking
webhook types you've enabled at your ESP, so you should always check the
:attr:`~AnymailTrackingEvent.event_type` as shown in the examples above
to ensure you're processing the expected events.
Some ESPs batch up multiple events into a single webhook call. Anymail will
invoke your signal receiver once, separately, for each event in the batch.
Normalized tracking event
-------------------------
.. class:: AnymailTrackingEvent
The `event` parameter to Anymail's `tracking`
:ref:`signal receiver <signal-receivers>`
is an object with the following attributes:
.. attribute:: event_type
A normalized `str` identifying the type of tracking event.
.. note::
Normalized event-tracking webhooks and signals are coming
to Anymail soon.
Most ESPs will send some, but *not all* of these event types.
Check the :ref:`specific ESP <supported-esps>` docs for more
details. In particular, very few ESPs implement the "sent" and
"delivered" events.
One of:
* `'queued'`: the ESP has accepted the message
and will try to send it (possibly at a later time).
* `'sent'`: the ESP has sent the message
(though it may or may not get successfully delivered).
* `'rejected'`: the ESP refused to send the messsage
(e.g., because of a suppression list, ESP policy, or invalid email).
Additional info may be in :attr:`reject_reason`.
* `'failed'`: the ESP was unable to send the message
(e.g., because of an error rendering an ESP template)
* `'bounced'`: the message was rejected or blocked by receiving MTA
(message transfer agent---the receiving mail server).
* `'deferred'`: the message was delayed by in transit
(e.g., because of a transient DNS problem, a full mailbox, or
certain spam-detection strategies).
The ESP will keep trying to deliver the message, and should generate
a separate `'bounced'` event if later it gives up.
* `'delivered'`: the message was accepted by the receiving MTA.
(This does not guarantee the user will see it. For example, it might
still be classified as spam.)
* `'autoresponded'`: a robot sent an automatic reply, such as a vacation
notice, or a request to prove you're a human.
* `'opened'`: the user opened the message (used with your ESP's
:attr:`~anymail.message.AnymailMessage.track_opens` feature).
* `'clicked'`: the user clicked a link in the message (used with your ESP's
:attr:`~anymail.message.AnymailMessage.track_clicks` feature).
* `'complained'`: the recipient reported the message as spam.
* `'unsubscribed'`: the recipient attempted to unsubscribe
(when you are using your ESP's subscription management features).
* `'subscribed'`: the recipient attempted to subscribe to a list,
or undo an earlier unsubscribe (when you are using your ESP's
subscription management features).
* `'unknown'`: anything else. Anymail isn't able to normalize this event,
and you'll need to examine the raw :attr:`esp_event` data.
.. attribute:: message_id
A `str` unique identifier for the message, matching the
:attr:`message.anymail_status.message_id <anymail.message.AnymailStatus.message_id>`
attribute from when the message was sent.
The exact format of the string varies by ESP. (It may or may not be
an actual "Message-ID", and is often some sort of UUID.)
.. attribute:: timestamp
A `~datetime.datetime` indicating when the event was generated.
(The timezone is often UTC, but the exact behavior depends on your ESP and
account settings. Anymail ensures that this value is an *aware* datetime
with an accurate timezone.)
.. attribute:: event_id
A `str` unique identifier for the event, if available; otherwise `None`.
Can be used to avoid processing the same event twice. Exact format varies
by ESP, and not all ESPs provide an event_id for all event types.
.. attribute:: recipient
The `str` email address of the recipient. (Just the "recipient\@example.com"
portion.)
.. attribute:: metadata
A `dict` of unique data attached to the message, or `None`.
(See :attr:`AnymailMessage.metadata <anymail.message.AnymailMessage.metadata>`.)
.. attribute:: tags
A `list` of `str` tags attached to the message, or `None`.
(See :attr:`AnymailMessage.tags <anymail.message.AnymailMessage.tags>`.)
.. attribute:: reject_reason
For `'bounced'` and `'rejected'` events, a normalized `str` giving the reason
for the bounce/rejection. Otherwise `None`. One of:
* `'invalid'`: bad email address format.
* `'bounced'`: bounced recipient. (In a `'rejected'` event, indicates the
recipient is on your ESP's prior-bounces suppression list.)
* `'timed_out'`: your ESP is giving up after repeated transient
delivery failures (which may have shown up as `'deferred'` events).
* `'blocked'`: your ESP's policy prohibits this recipient.
* `'spam'`: the receiving MTA or recipient determined the message is spam.
(In a `'rejected'` event, indicates the recipient is on your ESP's
prior-spam-complaints suppression list.)
* `'unsubscribed'`: the recipient is in your ESP's unsubscribed
suppression list.
* `'other'`: some other reject reason; examine the raw :attr:`esp_event`.
* `None`: Anymail isn't able to normalize a reject/bounce reason for
this ESP.
.. note::
Not all ESPs provide all reject reasons, and this area is often
under-documented by the ESP. Anymail does its best to interpret
the ESP event, but you may find (e.g.,) that it will report
`'timed_out'` for one ESP, and `'bounced'` for another, sending
to the same non-existent mailbox.
We appreciate :ref:`bug reports <reporting-bugs>` with the raw
:attr:`esp_event` data in cases where Anymail is getting it wrong.
.. attribute:: description
If available, a `str` with a (usually) human-readable description of the event.
Otherwise `None`. For example, might explain why an email has bounced. Exact
format varies by ESP (and sometimes event type).
.. attribute:: mta_response
If available, a `str` with a raw (intended for email administrators) response
from the receiving MTA. Otherwise `None`. Often includes SMTP response codes,
but the exact format varies by ESP (and sometimes receiving MTA).
.. attribute:: user_agent
For `'opened'` and `'clicked'` events, a `str` identifying the browser and/or
email client the user is using, if available. Otherwise `None`.
.. attribute:: click_url
For `'clicked'` events, the `str` url the user clicked. Otherwise `None`.
.. attribute:: esp_event
The "raw" event data from the ESP, deserialized into a python data structure.
For most ESPs this is either parsed JSON (as a `dict`), or HTTP POST fields
(as a Django :class:`~django.http.QueryDict`).
This gives you (non-portable) access to additional information provided by
your ESP. For example, some ESPs include geo-IP location information with
open and click events.
.. `Mandrill webhooks`_ are used for notification about outbound messages
.. (bounces, clicks, etc.), and also for delivering inbound email
.. processed through Mandrill.
..
.. Djrill includes optional support for Mandrill's webhook notifications.
.. If enabled, it will send a Django signal for each event in a webhook.
.. Your code can connect to this signal for further processing.
.. _signal-receivers:
.. _Mandrill webhooks: http://help.mandrill.com/entries/21738186-Introduction-to-Webhooks
Signal receiver functions
-------------------------
.. _webhooks-config:
Your Anymail signal receiver must be a function with this signature:
Configuring tracking webhooks
-----------------------------
.. function:: def my_handler(sender, event, esp_name, **kwargs):
.. warning:: Webhook Security
(You can name it anything you want.)
Webhooks are ordinary urls---they're wide open to the internet.
You must take steps to secure webhooks, or anyone could submit
random (or malicious) data to your app simply by invoking your
webhook URL. For security:
:param class sender: The source of the event. (One of the
:mod:`anymail.webhook.*` View classes, but you
generally won't examine this parameter; it's
required by Django's signal mechanism.)
:param AnymailTrackingEvent event: The normalized tracking event.
Almost anything you'd be interested in
will be in here.
:param str esp_name: e.g., "SendMail" or "Postmark". If you are working
with multiple ESPs, you can use this to distinguish
ESP-specific handling in your shared event processing.
:param \**kwargs: Required by Django's signal mechanism
(to support future extensions).
* Your webhook should only be accessible over SSL (https).
(This is beyond the scope of Anymail.)
:returns: nothing
:raises: any exceptions in your signal receiver will result
in a 400 HTTP error to the webhook. See discussion
below.
* Your webhook must include a random, secret key, known only to your
app and your ESP. Anymail will verify calls to your webhook, and will
reject calls without the correct key.
If (any of) your signal receivers raise an exception, Anymail
will discontinue processing the current batch of events and return
an HTTP 400 error to the ESP. Most ESPs respond to this by re-sending
the event(s) later, a limited number of times.
.. * You can, optionally include the two settings :setting:`DJRILL_WEBHOOK_SIGNATURE_KEY`
.. and :setting:`DJRILL_WEBHOOK_URL` to enforce `webhook signature`_ checking
This is the desired behavior for transient problems (e.g., your
Django database being unavailable), but can cause confusion in other
error cases. You may want to catch some (or all) exceptions
in your signal receiver, log the problem for later follow up,
and allow Anymail to return the normal 200 success response
to your ESP.
.. _webhook signature: http://help.mandrill.com/entries/23704122-Authenticating-webhook-requests
Some ESPs impose strict time limits on webhooks, and will consider
them failed if they don't respond within (say) five seconds.
And will retry sending the "failed" events, which could cause duplicate
processing in your code.
If your signal receiver code might be slow, you should instead
queue the event for later, asynchronous processing (e.g., using
something like `Celery`_).
If your signal receiver function is defined within some other
function or instance method, you *must* use the `weak=False`
option when connecting it. Otherwise, it might seem to work at first,
but will unpredictably stop being called at some point---typically
on your production server, in a hard-to-debug way. See Django's
`listening to signals`_ docs for more information.
.. To enable Djrill webhook processing you need to create and set a webhook
.. secret in your project settings, include the Djrill url routing, and
.. then add the webhook in the Mandrill control panel.
..
.. 1. In your project's :file:`settings.py`, add a :setting:`DJRILL_WEBHOOK_SECRET`:
..
.. .. code-block:: python
..
.. DJRILL_WEBHOOK_SECRET = "<create your own random secret>"
..
.. substituting a secret you've generated just for Mandrill webhooks.
.. (Do *not* use your Mandrill API key or Django SECRET_KEY for this!)
..
.. An easy way to generate a random secret is to run the command below in a shell:
..
.. .. code-block:: console
..
.. $ python -c "from django.utils import crypto; print crypto.get_random_string(16)"
..
..
.. 2. In your base :file:`urls.py`, add routing for the Djrill urls:
..
.. .. code-block:: python
..
.. urlpatterns = patterns('',
.. ...
.. url(r'^djrill/', include(djrill.urls)),
.. )
..
..
.. 3. Now you need to tell Mandrill about your webhook:
..
.. * For receiving events on sent messages (e.g., bounces or clickthroughs),
.. you'll do this in Mandrill's `webhooks control panel`_.
.. * For setting up inbound email through Mandrill, you'll add your webhook
.. to Mandrill's `inbound settings`_ under "Routes" for your domain.
.. * And if you want both, you'll need to add the webhook in both places.
..
.. In all cases, the "Post to URL" is
.. :samp:`{https://yoursite.example.com}/djrill/webhook/?secret={your-secret}`
.. substituting your app's own domain, and changing *your-secret* to the secret
.. you created in step 1.
..
.. (For sent-message webhooks, don't forget to tick the "Trigger on Events"
.. checkboxes for the events you want to receive.)
..
..
.. Once you've completed these steps and your Django app is live on your site,
.. you can use the Mandrill "Test" commands to verify your webhook configuration.
.. Then see the next section for setting up Django signal handlers to process
.. the webhooks.
..
.. Incidentally, you have some control over the webhook url.
.. If you'd like to change the "djrill" prefix, that comes from
.. the url config in step 2. And if you'd like to change
.. the *name* of the "secret" query string parameter, you can set
.. :setting:`DJRILL_WEBHOOK_SECRET_NAME` in your :file:`settings.py`.
..
.. For extra security, Mandrill provides a signature in the request header
.. X-Mandrill-Signature. If you want to verify this signature, you need to provide
.. the settings :setting:`DJRILL_WEBHOOK_SIGNATURE_KEY` with the webhook-specific
.. signature key that can be found in the Mandrill admin panel and
.. :setting:`DJRILL_WEBHOOK_URL` where you should enter the exact URL, including
.. that you entered in Mandrill when creating the webhook.
.. _webhooks control panel: https://mandrillapp.com/settings/webhooks
.. _inbound settings: https://mandrillapp.com/inbound
.. _webhook-usage:
Tracking event signals
----------------------
.. Once you've enabled webhooks, Djrill will send a ``djrill.signals.webhook_event``
.. custom `Django signal`_ for each Mandrill event it receives.
.. You can connect your own receiver function to this signal for further processing.
..
.. Be sure to read Django's `listening to signals`_ docs for information on defining
.. and connecting signal receivers.
..
.. Examples:
..
.. .. code-block:: python
..
.. from djrill.signals import webhook_event
.. from django.dispatch import receiver
..
.. @receiver(webhook_event)
.. def handle_bounce(sender, event_type, data, **kwargs):
.. if event_type == 'hard_bounce' or event_type == 'soft_bounce':
.. print "Message to %s bounced: %s" % (
.. data['msg']['email'],
.. data['msg']['bounce_description']
.. )
..
.. @receiver(webhook_event)
.. def handle_inbound(sender, event_type, data, **kwargs):
.. if event_type == 'inbound':
.. print "Inbound message from %s: %s" % (
.. data['msg']['from_email'],
.. data['msg']['subject']
.. )
..
.. @receiver(webhook_event)
.. def handle_whitelist_sync(sender, event_type, data, **kwargs):
.. if event_type == 'whitelist_add' or event_type == 'whitelist_remove':
.. print "Rejection whitelist update: %s email %s (%s)" % (
.. data['action'],
.. data['reject']['email'],
.. data['reject']['reason']
.. )
..
..
.. Note that your webhook_event signal handlers will be called for all Mandrill
.. webhook callbacks, so you should always check the `event_type` param as shown
.. in the examples above to ensure you're processing the expected events.
..
.. Mandrill batches up multiple events into a single webhook call.
.. Djrill will invoke your signal handler once for each event in the batch.
..
.. The available fields in the `data` param are described in Mandrill's documentation:
.. `sent-message webhooks`_, `inbound webhooks`_, and `whitelist/blacklist sync webooks`_.
.. _Django signal: https://docs.djangoproject.com/en/stable/topics/signals/
.. _inbound webhooks:
http://help.mandrill.com/entries/22092308-What-is-the-format-of-inbound-email-webhooks-
.. _Celery: http://www.celeryproject.org/
.. _listening to signals:
https://docs.djangoproject.com/en/stable/topics/signals/#listening-to-signals
.. _sent-message webhooks: http://help.mandrill.com/entries/21738186-Introduction-to-Webhooks
.. _whitelist/blacklist sync webooks:
https://mandrill.zendesk.com/hc/en-us/articles/205583297-Sync-Event-Webhook-format

View File

@@ -9,6 +9,7 @@ done with Anymail:
multiple_backends
django_templates
securing_webhooks
.. TODO:
.. Working with django-mailer(2)

View File

@@ -0,0 +1,109 @@
.. _securing-webhooks:
Securing webhooks
=================
If not used carefully, webhooks can create security vulnerabilities
in your Django application.
At minimum, you should **use SSL** and a **shared authorization secret**
for your Anymail webhooks. (Really, for *any* webhooks.)
Use SSL
-------
Your Django site must use SSL, and the webhook URLs you
give your ESP should start with "https" (not http).
Without https, the data your ESP sends your webhooks is exposed in transit.
This can include your customers' email addresses, the contents of messages
you receive through your ESP, the shared secret used to authorize calls
to your webhooks (described in the next section), and other data you'd
probably like to keep private.
Configuring SSL is beyond the scope of Anymail, but there are many good
tutorials on the web.
If you aren't able to use https on your Django site, then you should
not set up your ESP's webhooks.
.. setting:: ANYMAIL_WEBHOOK_AUTHORIZATION
Use a shared authorization secret
---------------------------------
A webhook is an ordinary URL---anyone can post anything to it.
To avoid receiving random (or malicious) data in your webhook,
you should use a shared random secret that your ESP can present
with webhook data, to prove the post is coming from your ESP.
Most ESPs recommend using HTTP basic authorization as this shared
secret. Anymail includes support for this, via the
:setting:`!ANYMAIL_WEBHOOK_AUTHORIZATION` setting.
Basic usage is covered in the
:ref:`webhooks configuration <webhooks-configuration>` docs.
If something posts to your webhooks without the required shared
secret as basic auth in the HTTP_AUTHORIZATION header, Anymail will
raise an :exc:`AnymailWebhookValidationFailure` error, which is
a subclass of Django's :exc:`~django.core.exceptions.SuspiciousOperation`.
This will result in an HTTP 400 response, without further processing
the data or calling your signal receiver function.
In addition to a single "random:random" string, you can give a list
of authorization strings. Anymail will permit webhook calls that match
any of the authorization strings:
.. code-block:: python
ANYMAIL = {
...
'WEBHOOK_AUTHORIZATION': [
'abcdefghijklmnop:qrstuvwxyz0123456789',
'ZYXWVUTSRQPONMLK:JIHGFEDCBA9876543210',
],
}
This facilitates credential rotation: first, append a new authorization
string to the list, and deploy your Django site. Then, update the webhook
URLs at your ESP to use the new authorization. Finally, remove the old
(now unused) authorization string from the list and re-deploy.
.. warning::
If your webhook URLs don't use https, this shared authorization
secret won't stay secret, defeating its purpose.
Signed webhooks
---------------
Some ESPs implement webhook signing, which is another method of verifying
the webhook data came from your ESP. Anymail will verify these signatures
for ESPs that support them. See the docs for your
:ref:`specific ESP <supported-esps>` for more details and configuration
that may be required.
Even with signed webhooks, it doesn't hurt to also use a shared secret.
Additional steps
----------------
Webhooks aren't unique to Anymail or to ESPs. They're used for many
different types of inter-site communication, and you can find additional
recommendations for improving webhook security on the web.
For example, you might consider:
* Tracking :attr:`~anymail.signals.AnymailTrackingEvent.event_id`,
to avoid accidental double-processing of the same events (or replay attacks)
* Checking the webhook's :attr:`~anymail.signals.AnymailTrackingEvent.timestamp`
is reasonably close the current time
* Configuring your firewall to reject webhook calls that come from
somewhere other than your ESP's documented IP addresses (if your ESP
provides this information)
But you should start with using SSL and a random shared secret via HTTP auth.

View File

@@ -11,8 +11,8 @@ from .test_postmark_backend import *
from .test_postmark_integration import *
from .test_sendgrid_backend import *
from .test_sendgrid_webhooks import *
from .test_sendgrid_integration import *
# Djrill leftovers:
from .test_mandrill_djrill_features import *
from .test_mandrill_webhook import *

View File

@@ -0,0 +1,250 @@
import json
from datetime import datetime
import hashlib
import hmac
from django.core.exceptions import ImproperlyConfigured
from django.core.urlresolvers import reverse
from django.test import override_settings
from django.utils.timezone import utc
from mock import ANY
from anymail.signals import AnymailTrackingEvent
from anymail.webhooks.mailgun import MailgunTrackingWebhookView
from .webhook_cases import WebhookTestCase, WebhookBasicAuthTestsMixin
TEST_API_KEY = 'TEST_API_KEY'
def mailgun_sign(data, api_key=TEST_API_KEY):
"""Add a Mailgun webhook signature to data dict"""
# Modifies the dict in place
data.setdefault('timestamp', '1234567890')
data.setdefault('token', '1234567890abcdef1234567890abcdef')
data['signature'] = hmac.new(key=api_key.encode('ascii'),
msg='{timestamp}{token}'.format(**data).encode('ascii'),
digestmod=hashlib.sha256).hexdigest()
return data
def querydict_to_postdict(qd):
"""Converts a Django QueryDict to a TestClient.post(data)-style dict
Single-value fields appear as normal
Multi-value fields appear as a list (differs from QueryDict.dict)
"""
return {
key: values if len(values) > 1 else values[0]
for key, values in qd.lists()
}
class MailgunWebhookSettingsTestCase(WebhookTestCase):
def test_requires_api_key(self):
webhook = reverse('mailgun_tracking_webhook')
with self.assertRaises(ImproperlyConfigured):
self.client.post(webhook, data=mailgun_sign({'event': 'delivered'}))
@override_settings(ANYMAIL_MAILGUN_API_KEY=TEST_API_KEY)
class MailgunWebhookSecurityTestCase(WebhookTestCase, WebhookBasicAuthTestsMixin):
should_warn_if_no_auth = False # because we check webhook signature
def call_webhook(self):
webhook = reverse('mailgun_tracking_webhook')
return self.client.post(webhook, data=mailgun_sign({'event': 'delivered'}))
# Additional tests are in WebhookBasicAuthTestsMixin
def test_verifies_correct_signature(self):
webhook = reverse('mailgun_tracking_webhook')
response = self.client.post(webhook, data=mailgun_sign({'event': 'delivered'}))
self.assertEqual(response.status_code, 200)
def test_verifies_missing_signature(self):
webhook = reverse('mailgun_tracking_webhook')
response = self.client.post(webhook, data={'event': 'delivered'})
self.assertEqual(response.status_code, 400)
def test_verifies_bad_signature(self):
webhook = reverse('mailgun_tracking_webhook')
data = mailgun_sign({'event': 'delivered'}, api_key="wrong API key")
response = self.client.post(webhook, data=data)
self.assertEqual(response.status_code, 400)
@override_settings(ANYMAIL_MAILGUN_API_KEY=TEST_API_KEY)
class MailgunDeliveryTestCase(WebhookTestCase):
def test_delivered_event(self):
raw_event = mailgun_sign({
'domain': 'example.com',
'message-headers': json.dumps([
["Sender", "from=example.com"],
["Date", "Thu, 21 Apr 2016 17:55:29 +0000"],
["X-Mailgun-Sid", "WyIxZmY4ZSIsICJtZWRtdW5kc0BnbWFpbC5jb20iLCAiZjFjNzgyIl0="],
["Received", "by luna.mailgun.net with HTTP; Thu, 21 Apr 2016 17:55:29 +0000"],
["Message-Id", "<20160421175529.19495.89030.B3AE3728@example.com>"],
["To", "recipient@example.com"],
["From", "from@example.com"],
["Subject", "Webhook testing"],
["Mime-Version", "1.0"],
["Content-Type", ["multipart/alternative", {"boundary": "74fb561763da440d8e6a034054974251"}]]
]),
'X-Mailgun-Sid': 'WyIxZmY4ZSIsICJtZWRtdW5kc0BnbWFpbC5jb20iLCAiZjFjNzgyIl0=',
'token': '06c96bafc3f42a66b9edd546347a2fe18dc23461fe80dc52f0',
'timestamp': '1461261330',
'Message-Id': '<20160421175529.19495.89030.B3AE3728@example.com>',
'recipient': 'recipient@example.com',
'event': 'delivered',
})
webhook = reverse('mailgun_tracking_webhook')
response = self.client.post(webhook, data=raw_event)
self.assertEqual(response.status_code, 200)
kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=MailgunTrackingWebhookView,
event=ANY, esp_name='Mailgun')
event = kwargs['event']
self.assertIsInstance(event, AnymailTrackingEvent)
self.assertEqual(event.event_type, "delivered")
self.assertEqual(event.timestamp, datetime(2016, 4, 21, 17, 55, 30, tzinfo=utc))
self.assertEqual(event.message_id, "<20160421175529.19495.89030.B3AE3728@example.com>")
self.assertEqual(event.event_id, "06c96bafc3f42a66b9edd546347a2fe18dc23461fe80dc52f0")
self.assertEqual(event.recipient, "recipient@example.com")
self.assertEqual(querydict_to_postdict(event.esp_event), raw_event)
def test_dropped_bounce(self):
raw_event = mailgun_sign({
'code': '605',
'domain': 'example.com',
'description': 'Not delivering to previously bounced address',
'attachment-count': '1',
'Message-Id': '<20160421180324.70521.79375.96884DDB@example.com>',
'reason': 'hardfail',
'event': 'dropped',
'message-headers': json.dumps([
["X-Mailgun-Sid", "WyI3Y2VjMyIsICJib3VuY2VAZXhhbXBsZS5jb20iLCAiZjFjNzgyIl0="],
["Received", "by luna.mailgun.net with HTTP; Thu, 21 Apr 2016 18:03:24 +0000"],
["Message-Id", "<20160421180324.70521.79375.96884DDB@example.com>"],
["To", "bounce@example.com"],
["From", "from@example.com"],
["Subject", "Webhook testing"],
["Mime-Version", "1.0"],
["Content-Type", ["multipart/alternative", {"boundary": "a5b51388a4e3455d8feb8510bb8c9fa2"}]]
]),
'recipient': 'bounce@example.com',
'timestamp': '1461261330',
'X-Mailgun-Sid': 'WyI3Y2VjMyIsICJib3VuY2VAZXhhbXBsZS5jb20iLCAiZjFjNzgyIl0=',
'token': 'a3fe1fa1640349ac552b84ddde373014b4c41645830c8dd3fc',
})
webhook = reverse('mailgun_tracking_webhook')
response = self.client.post(webhook, data=raw_event)
self.assertEqual(response.status_code, 200)
kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=MailgunTrackingWebhookView,
event=ANY, esp_name='Mailgun')
event = kwargs['event']
self.assertIsInstance(event, AnymailTrackingEvent)
self.assertEqual(event.event_type, "rejected")
self.assertEqual(event.timestamp, datetime(2016, 4, 21, 17, 55, 30, tzinfo=utc))
self.assertEqual(event.message_id, "<20160421180324.70521.79375.96884DDB@example.com>")
self.assertEqual(event.event_id, "a3fe1fa1640349ac552b84ddde373014b4c41645830c8dd3fc")
self.assertEqual(event.recipient, "bounce@example.com")
self.assertEqual(event.reject_reason, "bounced")
self.assertEqual(event.description, 'Not delivering to previously bounced address')
self.assertEqual(querydict_to_postdict(event.esp_event), raw_event)
def test_dropped_spam(self):
raw_event = mailgun_sign({
'code': '607',
'description': 'Not delivering to a user who marked your messages as spam',
'reason': 'hardfail',
'event': 'dropped',
'recipient': 'complaint@example.com',
# (omitting some fields that aren't relevant to the test)
})
webhook = reverse('mailgun_tracking_webhook')
response = self.client.post(webhook, data=raw_event)
self.assertEqual(response.status_code, 200)
kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=MailgunTrackingWebhookView,
event=ANY, esp_name='Mailgun')
event = kwargs['event']
self.assertEqual(event.event_type, "rejected")
self.assertEqual(event.reject_reason, "spam")
self.assertEqual(event.description, 'Not delivering to a user who marked your messages as spam')
def test_dropped_timed_out(self):
raw_event = mailgun_sign({
'code': '499',
'description': 'Unable to connect to MX servers: [example.com]',
'reason': 'old',
'event': 'dropped',
'recipient': 'complaint@example.com',
# (omitting some fields that aren't relevant to the test)
})
webhook = reverse('mailgun_tracking_webhook')
response = self.client.post(webhook, data=raw_event)
self.assertEqual(response.status_code, 200)
kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=MailgunTrackingWebhookView,
event=ANY, esp_name='Mailgun')
event = kwargs['event']
self.assertEqual(event.event_type, "rejected")
self.assertEqual(event.reject_reason, "timed_out")
self.assertEqual(event.description, 'Unable to connect to MX servers: [example.com]')
def test_invalid_mailbox(self):
raw_event = mailgun_sign({
'code': '550',
'error': "550 5.1.1 The email account that you tried to reach does not exist. Please try "
" 5.1.1 double-checking the recipient's email address for typos or "
" 5.1.1 unnecessary spaces.",
'event': 'bounced',
'recipient': 'noreply@example.com',
# (omitting some fields that aren't relevant to the test)
})
webhook = reverse('mailgun_tracking_webhook')
response = self.client.post(webhook, data=raw_event)
self.assertEqual(response.status_code, 200)
kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=MailgunTrackingWebhookView,
event=ANY, esp_name='Mailgun')
event = kwargs['event']
self.assertEqual(event.event_type, "bounced")
self.assertEqual(event.reject_reason, "bounced")
self.assertIn("The email account that you tried to reach does not exist", event.mta_response)
def test_metadata(self):
# Metadata fields are interspersed with other data, but also in message-headers
raw_event = mailgun_sign({
'event': 'delivered',
'message-headers': json.dumps([
["X-Mailgun-Variables", "{\"custom1\": \"value1\", \"custom2\": \"{\\\"key\\\":\\\"value\\\"}\"}"],
]),
'custom1': 'value',
'custom2': '{"key":"value"}', # you can store JSON, but you'll need to unpack it yourself
})
self.client.post(reverse('mailgun_tracking_webhook'), data=raw_event)
kwargs = self.assert_handler_called_once_with(self.tracking_handler)
event = kwargs['event']
self.assertEqual(event.metadata, {"custom1": "value1", "custom2": '{"key":"value"}'})
def test_tags(self):
# Most events include multiple 'tag' fields for message's tags
raw_event = mailgun_sign({
'tag': ['tag1', 'tag2'], # Django TestClient encodes list as multiple field values
'event': 'opened',
})
self.client.post(reverse('mailgun_tracking_webhook'), data=raw_event)
kwargs = self.assert_handler_called_once_with(self.tracking_handler)
event = kwargs['event']
self.assertEqual(event.tags, ["tag1", "tag2"])
def test_x_tags(self):
# Delivery events don't include 'tag', but do include 'X-Mailgun-Tag' fields
raw_event = mailgun_sign({
'X-Mailgun-Tag': ['tag1', 'tag2'],
'event': 'delivered',
})
self.client.post(reverse('mailgun_tracking_webhook'), data=raw_event)
kwargs = self.assert_handler_called_once_with(self.tracking_handler)
kwargs = self.assert_handler_called_once_with(self.tracking_handler)
event = kwargs['event']
self.assertEqual(event.tags, ["tag1", "tag2"])

View File

@@ -1,128 +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.test import TestCase
from django.test.utils import override_settings
from anymail.compat import b
from anymail.signals import webhook_event
class DjrillWebhookSecretMixinTests(TestCase):
"""
Test mixin used in optional Mandrill webhook support
"""
def test_missing_secret(self):
with self.assertRaises(ImproperlyConfigured):
self.client.get('/webhook/')
@override_settings(DJRILL_WEBHOOK_SECRET='abc123')
def test_incorrect_secret(self):
response = self.client.head('/webhook/?secret=wrong')
self.assertEqual(response.status_code, 403)
@override_settings(DJRILL_WEBHOOK_SECRET='abc123')
def test_default_secret_name(self):
response = self.client.head('/webhook/?secret=abc123')
self.assertEqual(response.status_code, 200)
@override_settings(DJRILL_WEBHOOK_SECRET='abc123', DJRILL_WEBHOOK_SECRET_NAME='verysecret')
def test_custom_secret_name(self):
response = self.client.head('/webhook/?verysecret=abc123')
self.assertEqual(response.status_code, 200)
@override_settings(DJRILL_WEBHOOK_SECRET='abc123',
DJRILL_WEBHOOK_SIGNATURE_KEY="signature")
class DjrillWebhookSignatureMixinTests(TestCase):
"""
Test mixin used in optional Mandrill webhook signature support
"""
def test_incorrect_settings(self):
with self.assertRaises(ImproperlyConfigured):
self.client.post('/webhook/?secret=abc123')
@override_settings(DJRILL_WEBHOOK_URL="/webhook/?secret=abc123",
DJRILL_WEBHOOK_SIGNATURE_KEY = "anothersignature")
def test_unauthorized(self):
response = self.client.post(settings.DJRILL_WEBHOOK_URL)
self.assertEqual(response.status_code, 403)
@override_settings(DJRILL_WEBHOOK_URL="/webhook/?secret=abc123")
def test_signature(self):
signature = hmac.new(key=b(settings.DJRILL_WEBHOOK_SIGNATURE_KEY),
msg=b(settings.DJRILL_WEBHOOK_URL+"mandrill_events[]"),
digestmod=hashlib.sha1)
hash_string = b64encode(signature.digest())
response = self.client.post('/webhook/?secret=abc123', data={"mandrill_events":"[]"},
**{"HTTP_X_MANDRILL_SIGNATURE": hash_string})
self.assertEqual(response.status_code, 200)
@override_settings(DJRILL_WEBHOOK_SECRET='abc123')
class DjrillWebhookViewTests(TestCase):
"""
Test optional Mandrill webhook view
"""
def test_head_request(self):
response = self.client.head('/webhook/?secret=abc123')
self.assertEqual(response.status_code, 200)
def test_post_request_invalid_json(self):
response = self.client.post('/webhook/?secret=abc123')
self.assertEqual(response.status_code, 400)
def test_post_request_valid_json(self):
response = self.client.post('/webhook/?secret=abc123', {
'mandrill_events': json.dumps([{"event": "send", "msg": {}}])
})
self.assertEqual(response.status_code, 200)
def test_webhook_send_signal(self):
self.signal_received_count = 0
test_event = {"event": "send", "msg": {}}
def my_callback(sender, event_type, data, **kwargs):
self.signal_received_count += 1
self.assertEqual(event_type, 'send')
self.assertEqual(data, test_event)
try:
webhook_event.connect(my_callback, weak=False) # local callback func, so don't use weak ref
response = self.client.post('/webhook/?secret=abc123', {
'mandrill_events': json.dumps([test_event])
})
finally:
webhook_event.disconnect(my_callback)
self.assertEqual(response.status_code, 200)
self.assertEqual(self.signal_received_count, 1)
def test_webhook_sync_event(self):
# Mandrill sync events use a different format from other events
# https://mandrill.zendesk.com/hc/en-us/articles/205583297-Sync-Event-Webhook-format
self.signal_received_count = 0
test_event = {"type": "whitelist", "action": "add"}
def my_callback(sender, event_type, data, **kwargs):
self.signal_received_count += 1
self.assertEqual(event_type, 'whitelist_add') # synthesized event_type
self.assertEqual(data, test_event)
try:
webhook_event.connect(my_callback, weak=False) # local callback func, so don't use weak ref
response = self.client.post('/webhook/?secret=abc123', {
'mandrill_events': json.dumps([test_event])
})
finally:
webhook_event.disconnect(my_callback)
self.assertEqual(response.status_code, 200)
self.assertEqual(self.signal_received_count, 1)

View File

@@ -0,0 +1,195 @@
import json
from datetime import datetime
# noinspection PyUnresolvedReferences
from six.moves.urllib.parse import urljoin
import hashlib
import hmac
from base64 import b64encode
from django.core.exceptions import ImproperlyConfigured
from django.core.urlresolvers import reverse
from django.test import override_settings
from django.utils.timezone import utc
from mock import ANY
from anymail.signals import AnymailTrackingEvent
from anymail.webhooks.mandrill import MandrillTrackingWebhookView
from .webhook_cases import WebhookTestCase, WebhookBasicAuthTestsMixin
TEST_WEBHOOK_KEY = 'TEST_WEBHOOK_KEY'
def mandrill_args(events=None, urlname='mandrill_tracking_webhook', key=TEST_WEBHOOK_KEY):
"""Returns TestClient.post kwargs for Mandrill webhook call with events
Computes correct signature.
"""
if events is None:
events = []
url = urljoin('http://testserver/', reverse(urlname))
mandrill_events = json.dumps(events)
signed_data = url + 'mandrill_events' + mandrill_events
signature = b64encode(hmac.new(key=key.encode('ascii'),
msg=signed_data.encode('utf-8'),
digestmod=hashlib.sha1).digest())
return {
'path': url,
'data': {'mandrill_events': mandrill_events},
'HTTP_X_MANDRILL_SIGNATURE': signature,
}
class MandrillWebhookSettingsTestCase(WebhookTestCase):
def test_requires_webhook_key(self):
webhook = reverse('mandrill_tracking_webhook')
with self.assertRaises(ImproperlyConfigured):
self.client.post(webhook, data={'mandrill_events': '[]'})
@override_settings(ANYMAIL_MANDRILL_WEBHOOK_KEY=TEST_WEBHOOK_KEY)
class MandrillWebhookSecurityTestCase(WebhookTestCase, WebhookBasicAuthTestsMixin):
should_warn_if_no_auth = False # because we check webhook signature
def call_webhook(self):
kwargs = mandrill_args([{'event': 'send'}])
return self.client.post(**kwargs)
# Additional tests are in WebhookBasicAuthTestsMixin
def test_verifies_correct_signature(self):
kwargs = mandrill_args([{'event': 'send'}])
response = self.client.post(**kwargs)
self.assertEqual(response.status_code, 200)
def test_verifies_missing_signature(self):
webhook = reverse('mandrill_tracking_webhook')
response = self.client.post(webhook, data={'mandrill_events': '[{"event":"send"}]'})
self.assertEqual(response.status_code, 400)
def test_verifies_bad_signature(self):
kwargs = mandrill_args([{'event': 'send'}], key="wrong API key")
response = self.client.post(**kwargs)
self.assertEqual(response.status_code, 400)
@override_settings(ANYMAIL_MANDRILL_WEBHOOK_KEY=TEST_WEBHOOK_KEY)
class MandrillTrackingTestCase(WebhookTestCase):
def test_head_request(self):
# Mandrill verifies webhooks at config time with a HEAD request
webhook = reverse('mandrill_tracking_webhook')
response = self.client.head(webhook)
self.assertEqual(response.status_code, 200)
def test_post_request_invalid_json(self):
kwargs = mandrill_args()
kwargs['data'] = {'mandrill_events': "GARBAGE DATA"}
response = self.client.post(**kwargs)
self.assertEqual(response.status_code, 400)
def test_send_event(self):
raw_events = [{
"event": "send",
"msg": {
"ts": 1461095211, # time send called
"subject": "Webhook Test",
"email": "recipient@example.com",
"sender": "sender@example.com",
"tags": ["tag1", "tag2"],
"metadata": {"custom1": "value1", "custom2": "value2"},
"_id": "abcdef012345789abcdef012345789"
},
"_id": "abcdef012345789abcdef012345789",
"ts": 1461095246 # time of event
}]
response = self.client.post(**mandrill_args(events=raw_events))
self.assertEqual(response.status_code, 200)
kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=MandrillTrackingWebhookView,
event=ANY, esp_name='Mandrill')
event = kwargs['event']
self.assertIsInstance(event, AnymailTrackingEvent)
self.assertEqual(event.event_type, "sent")
self.assertEqual(event.timestamp, datetime(2016, 4, 19, 19, 47, 26, tzinfo=utc))
self.assertEqual(event.esp_event, raw_events[0])
self.assertEqual(event.message_id, "abcdef012345789abcdef012345789")
self.assertEqual(event.recipient, "recipient@example.com")
self.assertEqual(event.tags, ["tag1", "tag2"])
self.assertEqual(event.metadata, {"custom1": "value1", "custom2": "value2"})
def test_hard_bounce_event(self):
raw_events = [{
"event": "hard_bounce",
"msg": {
"ts": 1461095211, # time send called
"subject": "Webhook Test",
"email": "bounce@example.com",
"sender": "sender@example.com",
"bounce_description": "bad_mailbox",
"bgtools_code": 10,
"diag": "smtp;550 5.1.1 The email account that you tried to reach does not exist.",
"_id": "abcdef012345789abcdef012345789"
},
"_id": "abcdef012345789abcdef012345789",
"ts": 1461095246 # time of event
}]
response = self.client.post(**mandrill_args(events=raw_events))
self.assertEqual(response.status_code, 200)
kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=MandrillTrackingWebhookView,
event=ANY, esp_name='Mandrill')
event = kwargs['event']
self.assertIsInstance(event, AnymailTrackingEvent)
self.assertEqual(event.event_type, "bounced")
self.assertEqual(event.esp_event, raw_events[0])
self.assertEqual(event.message_id, "abcdef012345789abcdef012345789")
self.assertEqual(event.recipient, "bounce@example.com")
self.assertEqual(event.mta_response,
"smtp;550 5.1.1 The email account that you tried to reach does not exist.")
def test_click_event(self):
raw_events = [{
"event": "click",
"msg": {
"ts": 1461095211, # time send called
"subject": "Webhook Test",
"email": "recipient@example.com",
"sender": "sender@example.com",
"opens": [{"ts": 1461095242}],
"clicks": [{"ts": 1461095246, "url": "http://example.com"}],
"_id": "abcdef012345789abcdef012345789"
},
"user_agent": "Mozilla/5.0 (Windows NT 5.1; rv:11.0) Gecko Firefox/11.0",
"url": "http://example.com",
"_id": "abcdef012345789abcdef012345789",
"ts": 1461095246 # time of event
}]
response = self.client.post(**mandrill_args(events=raw_events))
self.assertEqual(response.status_code, 200)
kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=MandrillTrackingWebhookView,
event=ANY, esp_name='Mandrill')
event = kwargs['event']
self.assertIsInstance(event, AnymailTrackingEvent)
self.assertEqual(event.event_type, "clicked")
self.assertEqual(event.esp_event, raw_events[0])
self.assertEqual(event.click_url, "http://example.com")
self.assertEqual(event.user_agent, "Mozilla/5.0 (Windows NT 5.1; rv:11.0) Gecko Firefox/11.0")
def test_sync_event(self):
# Mandrill sync events use a different format from other events
# https://mandrill.zendesk.com/hc/en-us/articles/205583297-Sync-Event-Webhook-format
raw_events = [{
"type": "blacklist",
"action": "add",
"reject": {
"email": "recipient@example.com",
"reason": "manual edit"
}
}]
response = self.client.post(**mandrill_args(events=raw_events))
self.assertEqual(response.status_code, 200)
kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=MandrillTrackingWebhookView,
event=ANY, esp_name='Mandrill')
event = kwargs['event']
self.assertEqual(event.event_type, "unknown")
self.assertEqual(event.recipient, "recipient@example.com")
self.assertEqual(event.description, "manual edit")

View File

@@ -0,0 +1,86 @@
import json
from datetime import datetime
from django.core.urlresolvers import reverse
from django.utils.timezone import get_fixed_timezone
from mock import ANY
from anymail.signals import AnymailTrackingEvent
from anymail.webhooks.postmark import PostmarkTrackingWebhookView
from .webhook_cases import WebhookBasicAuthTestsMixin, WebhookTestCase
class PostmarkWebhookSecurityTestCase(WebhookTestCase, WebhookBasicAuthTestsMixin):
def call_webhook(self):
webhook = reverse('postmark_tracking_webhook')
return self.client.post(webhook, content_type='application/json', data=json.dumps({}))
# Actual tests are in WebhookBasicAuthTestsMixin
class PostmarkDeliveryTestCase(WebhookTestCase):
def test_bounce_event(self):
raw_event = {
"ID": 901542550,
"Type": "HardBounce",
"TypeCode": 1,
"Name": "Hard bounce",
"MessageID": "2706ee8a-737c-4285-b032-ccd317af53ed",
"Description": "The server was unable to deliver your message (ex: unknown user, mailbox not found).",
"Details": "smtp;550 5.1.1 The email account that you tried to reach does not exist.",
"Email": "bounce@example.com",
"BouncedAt": "2016-04-27T16:28:50.3963933-04:00",
"DumpAvailable": True,
"Inactive": True,
"CanActivate": True,
"Subject": "Postmark event test",
"Content": "..."
}
webhook = reverse('postmark_tracking_webhook')
response = self.client.post(webhook, content_type='application/json', data=json.dumps(raw_event))
self.assertEqual(response.status_code, 200)
kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=PostmarkTrackingWebhookView,
event=ANY, esp_name='Postmark')
event = kwargs['event']
self.assertIsInstance(event, AnymailTrackingEvent)
self.assertEqual(event.event_type, "bounced")
self.assertEqual(event.esp_event, raw_event)
self.assertEqual(event.timestamp, datetime(2016, 4, 27, 16, 28, 50, microsecond=396393,
tzinfo=get_fixed_timezone(-4*60)))
self.assertEqual(event.message_id, "2706ee8a-737c-4285-b032-ccd317af53ed")
self.assertEqual(event.event_id, "901542550")
self.assertEqual(event.recipient, "bounce@example.com")
self.assertEqual(event.reject_reason, "bounced")
self.assertEqual(event.description,
"The server was unable to deliver your message (ex: unknown user, mailbox not found).")
self.assertEqual(event.mta_response,
"smtp;550 5.1.1 The email account that you tried to reach does not exist.")
def test_open_event(self):
raw_event = {
"FirstOpen": True,
"Client": {"Name": "Gmail", "Company": "Google", "Family": "Gmail"},
"OS": {"Name": "unknown", "Company": "unknown", "Family": "unknown"},
"Platform": "Unknown",
"UserAgent": "Mozilla/5.0 (Windows NT 5.1; rv:11.0) Gecko Firefox/11.0",
"ReadSeconds": 0,
"Geo": {},
"MessageID": "f4830d10-9c35-4f0c-bca3-3d9b459821f8",
"ReceivedAt": "2016-04-27T16:21:41.2493688-04:00",
"Recipient": "recipient@example.com"
}
webhook = reverse('postmark_tracking_webhook')
response = self.client.post(webhook, content_type='application/json', data=json.dumps(raw_event))
self.assertEqual(response.status_code, 200)
kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=PostmarkTrackingWebhookView,
event=ANY, esp_name='Postmark')
event = kwargs['event']
self.assertIsInstance(event, AnymailTrackingEvent)
self.assertEqual(event.event_type, "opened")
self.assertEqual(event.esp_event, raw_event)
self.assertEqual(event.timestamp, datetime(2016, 4, 27, 16, 21, 41, microsecond=249368,
tzinfo=get_fixed_timezone(-4*60)))
self.assertEqual(event.message_id, "f4830d10-9c35-4f0c-bca3-3d9b459821f8")
self.assertEqual(event.recipient, "recipient@example.com")
self.assertEqual(event.user_agent, "Mozilla/5.0 (Windows NT 5.1; rv:11.0) Gecko Firefox/11.0")

View File

@@ -0,0 +1,235 @@
import json
from datetime import datetime
from django.core.urlresolvers import reverse
from django.utils.timezone import utc
from mock import ANY
from anymail.signals import AnymailTrackingEvent
from anymail.webhooks.sendgrid import SendGridTrackingWebhookView
from .webhook_cases import WebhookBasicAuthTestsMixin, WebhookTestCase
class SendGridWebhookSecurityTestCase(WebhookTestCase, WebhookBasicAuthTestsMixin):
def call_webhook(self):
webhook = reverse('sendgrid_tracking_webhook')
return self.client.post(webhook, content_type='application/json', data=json.dumps([]))
# Actual tests are in WebhookBasicAuthTestsMixin
class SendGridDeliveryTestCase(WebhookTestCase):
def test_processed_event(self):
raw_events = [{
"email": "recipient@example.com",
"timestamp": 1461095246,
"smtp-id": "<wrfRRvF7Q0GgwUo2CvDmEA@example.com>",
"sg_event_id": "ZyjAM5rnQmuI1KFInHQ3Nw",
"sg_message_id": "wrfRRvF7Q0GgwUo2CvDmEA.filter0425p1mdw1.13037.57168B4A1D.0",
"event": "processed",
"category": ["tag1", "tag2"],
"custom1": "value1",
"custom2": "value2",
}]
webhook = reverse('sendgrid_tracking_webhook')
response = self.client.post(webhook, content_type='application/json', data=json.dumps(raw_events))
self.assertEqual(response.status_code, 200)
kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=SendGridTrackingWebhookView,
event=ANY, esp_name='SendGrid')
event = kwargs['event']
self.assertIsInstance(event, AnymailTrackingEvent)
self.assertEqual(event.event_type, "queued")
self.assertEqual(event.timestamp, datetime(2016, 4, 19, 19, 47, 26, tzinfo=utc))
self.assertEqual(event.esp_event, raw_events[0])
self.assertEqual(event.message_id, "<wrfRRvF7Q0GgwUo2CvDmEA@example.com>")
self.assertEqual(event.event_id, "ZyjAM5rnQmuI1KFInHQ3Nw")
self.assertEqual(event.recipient, "recipient@example.com")
self.assertEqual(event.tags, ["tag1", "tag2"])
self.assertEqual(event.metadata, {"custom1": "value1", "custom2": "value2"})
def test_delivered_event(self):
raw_events = [{
"ip": "167.89.17.173",
"response": "250 2.0.0 OK 1461095248 m143si2210036ioe.159 - gsmtp ",
"sg_event_id": "nOSv8m0eTQ-vxvwNwt3fZQ",
"sg_message_id": "wrfRRvF7Q0GgwUo2CvDmEA.filter0425p1mdw1.13037.57168B4A1D.0",
"tls": 1,
"event": "delivered",
"email": "recipient@example.com",
"timestamp": 1461095250,
"smtp-id": "<wrfRRvF7Q0GgwUo2CvDmEA@example.com>"
}]
webhook = reverse('sendgrid_tracking_webhook')
response = self.client.post(webhook, content_type='application/json', data=json.dumps(raw_events))
self.assertEqual(response.status_code, 200)
kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=SendGridTrackingWebhookView,
event=ANY, esp_name='SendGrid')
event = kwargs['event']
self.assertIsInstance(event, AnymailTrackingEvent)
self.assertEqual(event.event_type, "delivered")
self.assertEqual(event.timestamp, datetime(2016, 4, 19, 19, 47, 30, tzinfo=utc))
self.assertEqual(event.esp_event, raw_events[0])
self.assertEqual(event.message_id, "<wrfRRvF7Q0GgwUo2CvDmEA@example.com>")
self.assertEqual(event.event_id, "nOSv8m0eTQ-vxvwNwt3fZQ")
self.assertEqual(event.recipient, "recipient@example.com")
self.assertEqual(event.mta_response, "250 2.0.0 OK 1461095248 m143si2210036ioe.159 - gsmtp ")
self.assertEqual(event.tags, None)
self.assertEqual(event.metadata, None)
def test_dropped_invalid_event(self):
raw_events = [{
"email": "invalid@invalid",
"smtp-id": "<YZkwwo_vQUidhSh7sCzkvQ@example.com>",
"timestamp": 1461095250,
"sg_event_id": "3NPOePGOTkeM_U3fgWApfg",
"sg_message_id": "filter0093p1las1.9128.5717FB8127.0",
"reason": "Invalid",
"event": "dropped"
}]
webhook = reverse('sendgrid_tracking_webhook')
response = self.client.post(webhook, content_type='application/json', data=json.dumps(raw_events))
self.assertEqual(response.status_code, 200)
kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=SendGridTrackingWebhookView,
event=ANY, esp_name='SendGrid')
event = kwargs['event']
self.assertIsInstance(event, AnymailTrackingEvent)
self.assertEqual(event.event_type, "rejected")
self.assertEqual(event.esp_event, raw_events[0])
self.assertEqual(event.message_id, "<YZkwwo_vQUidhSh7sCzkvQ@example.com>")
self.assertEqual(event.event_id, "3NPOePGOTkeM_U3fgWApfg")
self.assertEqual(event.recipient, "invalid@invalid")
self.assertEqual(event.reject_reason, "invalid")
self.assertEqual(event.mta_response, None)
def test_dropped_unsubscribed_event(self):
raw_events = [{
"email": "unsubscribe@example.com",
"smtp-id": "<Kwx3gAIKQOG7Nd5XEO7guQ@example.com>",
"timestamp": 1461095250,
"sg_event_id": "oxy9OLwMTAy5EsuZn1qhIg",
"sg_message_id": "filter0199p1las1.4745.5717FB6F5.0",
"reason": "Unsubscribed Address",
"event": "dropped"
}]
webhook = reverse('sendgrid_tracking_webhook')
response = self.client.post(webhook, content_type='application/json', data=json.dumps(raw_events))
self.assertEqual(response.status_code, 200)
kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=SendGridTrackingWebhookView,
event=ANY, esp_name='SendGrid')
event = kwargs['event']
self.assertIsInstance(event, AnymailTrackingEvent)
self.assertEqual(event.event_type, "rejected")
self.assertEqual(event.esp_event, raw_events[0])
self.assertEqual(event.message_id, "<Kwx3gAIKQOG7Nd5XEO7guQ@example.com>")
self.assertEqual(event.event_id, "oxy9OLwMTAy5EsuZn1qhIg")
self.assertEqual(event.recipient, "unsubscribe@example.com")
self.assertEqual(event.reject_reason, "unsubscribed")
self.assertEqual(event.mta_response, None)
def test_bounce_event(self):
raw_events = [{
"ip": "167.89.17.173",
"status": "5.1.1",
"sg_event_id": "lC0Rc-FuQmKbnxCWxX1jRQ",
"reason": "550 5.1.1 The email account that you tried to reach does not exist.",
"sg_message_id": "Lli-03HcQ5-JLybO9fXsJg.filter0077p1las1.21536.5717FC482.0",
"tls": 1,
"event": "bounce",
"email": "noreply@example.com",
"timestamp": 1461095250,
"smtp-id": "<Lli-03HcQ5-JLybO9fXsJg@example.com>",
"type": "bounce"
}]
webhook = reverse('sendgrid_tracking_webhook')
response = self.client.post(webhook, content_type='application/json', data=json.dumps(raw_events))
self.assertEqual(response.status_code, 200)
kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=SendGridTrackingWebhookView,
event=ANY, esp_name='SendGrid')
event = kwargs['event']
self.assertIsInstance(event, AnymailTrackingEvent)
self.assertEqual(event.event_type, "bounced")
self.assertEqual(event.esp_event, raw_events[0])
self.assertEqual(event.message_id, "<Lli-03HcQ5-JLybO9fXsJg@example.com>")
self.assertEqual(event.event_id, "lC0Rc-FuQmKbnxCWxX1jRQ")
self.assertEqual(event.recipient, "noreply@example.com")
self.assertEqual(event.mta_response, "550 5.1.1 The email account that you tried to reach does not exist.")
def test_deferred_event(self):
raw_events = [{
"response": "Email was deferred due to the following reason(s): [IPs were throttled by recipient server]",
"sg_event_id": "b_syL5UiTvWC_Ky5L6Bs5Q",
"sg_message_id": "u9Gvi3mzT6iC2poAb58_qQ.filter0465p1mdw1.8054.5718271B40.0",
"event": "deferred",
"email": "recipient@example.com",
"attempt": "1",
"timestamp": 1461200990,
"smtp-id": "<20160421010427.2847.6797@example.com>",
}]
webhook = reverse('sendgrid_tracking_webhook')
response = self.client.post(webhook, content_type='application/json', data=json.dumps(raw_events))
self.assertEqual(response.status_code, 200)
kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=SendGridTrackingWebhookView,
event=ANY, esp_name='SendGrid')
event = kwargs['event']
self.assertIsInstance(event, AnymailTrackingEvent)
self.assertEqual(event.event_type, "deferred")
self.assertEqual(event.esp_event, raw_events[0])
self.assertEqual(event.message_id, "<20160421010427.2847.6797@example.com>")
self.assertEqual(event.event_id, "b_syL5UiTvWC_Ky5L6Bs5Q")
self.assertEqual(event.recipient, "recipient@example.com")
self.assertEqual(event.mta_response,
"Email was deferred due to the following reason(s): [IPs were throttled by recipient server]")
def test_open_event(self):
raw_events = [{
"email": "recipient@example.com",
"timestamp": 1461095250,
"ip": "66.102.6.229",
"sg_event_id": "MjIwNDg5NTgtZGE3OC00NDI1LWFiMmMtMDUyZTU2ZmFkOTFm",
"sg_message_id": "wrfRRvF7Q0GgwUo2CvDmEA.filter0425p1mdw1.13037.57168B4A1D.0",
"smtp-id": "<20160421010427.2847.6797@example.com>",
"useragent": "Mozilla/5.0 (Windows NT 5.1; rv:11.0) Gecko Firefox/11.0",
"event": "open"
}]
webhook = reverse('sendgrid_tracking_webhook')
response = self.client.post(webhook, content_type='application/json', data=json.dumps(raw_events))
self.assertEqual(response.status_code, 200)
kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=SendGridTrackingWebhookView,
event=ANY, esp_name='SendGrid')
event = kwargs['event']
self.assertIsInstance(event, AnymailTrackingEvent)
self.assertEqual(event.event_type, "opened")
self.assertEqual(event.esp_event, raw_events[0])
self.assertEqual(event.message_id, "<20160421010427.2847.6797@example.com>")
self.assertEqual(event.event_id, "MjIwNDg5NTgtZGE3OC00NDI1LWFiMmMtMDUyZTU2ZmFkOTFm")
self.assertEqual(event.recipient, "recipient@example.com")
self.assertEqual(event.user_agent, "Mozilla/5.0 (Windows NT 5.1; rv:11.0) Gecko Firefox/11.0")
def test_click_event(self):
raw_events = [{
"ip": "24.130.34.103",
"sg_event_id": "OTdlOGUzYjctYjc5Zi00OWE4LWE4YWUtNjIxNjk2ZTJlNGVi",
"sg_message_id": "_fjPjuJfRW-IPs5SuvYotg.filter0590p1mdw1.2098.57168CFC4B.0",
"useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_4) AppleWebKit/537.36",
"smtp-id": "<20160421010427.2847.6797@example.com>",
"event": "click",
"url_offset": {"index": 0, "type": "html"},
"email": "recipient@example.com",
"timestamp": 1461095250,
"url": "http://www.example.com"
}]
webhook = reverse('sendgrid_tracking_webhook')
response = self.client.post(webhook, content_type='application/json', data=json.dumps(raw_events))
self.assertEqual(response.status_code, 200)
kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=SendGridTrackingWebhookView,
event=ANY, esp_name='SendGrid')
event = kwargs['event']
self.assertIsInstance(event, AnymailTrackingEvent)
self.assertEqual(event.event_type, "clicked")
self.assertEqual(event.esp_event, raw_events[0])
self.assertEqual(event.message_id, "<20160421010427.2847.6797@example.com>")
self.assertEqual(event.event_id, "OTdlOGUzYjctYjc5Zi00OWE4LWE4YWUtNjIxNjk2ZTJlNGVi")
self.assertEqual(event.recipient, "recipient@example.com")
self.assertEqual(event.user_agent, "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_4) AppleWebKit/537.36")
self.assertEqual(event.click_url, "http://www.example.com")

View File

@@ -1,9 +1,13 @@
# Anymail test utils
import os
import sys
import unittest
from base64 import b64decode
import os
import re
import six
import warnings
from base64 import b64decode
from contextlib import contextmanager
def decode_att(att):
@@ -28,10 +32,34 @@ def sample_image_content(filename=SAMPLE_IMAGE_FILENAME):
return f.read()
# noinspection PyUnresolvedReferences
class AnymailTestMixin:
"""Helpful additional methods for Anymail tests"""
pass
def assertWarns(self, expected_warning, msg=None):
# We only support the context-manager version
try:
return super(AnymailTestMixin, self).assertWarns(expected_warning, msg=msg)
except TypeError:
# Python 2.x: use our backported assertWarns
return _AssertWarnsContext(expected_warning, self, msg=msg)
def assertWarnsRegex(self, expected_warning, expected_regex, msg=None):
# We only support the context-manager version
try:
return super(AnymailTestMixin, self).assertWarnsRegex(expected_warning, expected_regex, msg=msg)
except TypeError:
# Python 2.x: use our backported assertWarns
return _AssertWarnsContext(expected_warning, self, expected_regex=expected_regex, msg=msg)
@contextmanager
def assertDoesNotWarn(self):
try:
warnings.simplefilter("error")
yield
finally:
warnings.resetwarnings()
# Plus these methods added below:
# assertCountEqual
# assertRaisesRegex
@@ -45,3 +73,64 @@ for method in ('assertCountEqual', 'assertRaisesRegex', 'assertRegex'):
getattr(unittest.TestCase, method)
except AttributeError:
setattr(AnymailTestMixin, method, getattr(six, method))
# Backported from python 3.5
class _AssertWarnsContext(object):
"""A context manager used to implement TestCase.assertWarns* methods."""
def __init__(self, expected, test_case, expected_regex=None, msg=None):
self.test_case = test_case
self.expected = expected
self.test_case = test_case
if expected_regex is not None:
expected_regex = re.compile(expected_regex)
self.expected_regex = expected_regex
self.msg = msg
def _raiseFailure(self, standardMsg):
# msg = self.test_case._formatMessage(self.msg, standardMsg)
msg = self.msg or standardMsg
raise self.test_case.failureException(msg)
def __enter__(self):
# The __warningregistry__'s need to be in a pristine state for tests
# to work properly.
for v in sys.modules.values():
if getattr(v, '__warningregistry__', None):
v.__warningregistry__ = {}
self.warnings_manager = warnings.catch_warnings(record=True)
self.warnings = self.warnings_manager.__enter__()
warnings.simplefilter("always", self.expected)
return self
def __exit__(self, exc_type, exc_value, tb):
self.warnings_manager.__exit__(exc_type, exc_value, tb)
if exc_type is not None:
# let unexpected exceptions pass through
return
try:
exc_name = self.expected.__name__
except AttributeError:
exc_name = str(self.expected)
first_matching = None
for m in self.warnings:
w = m.message
if not isinstance(w, self.expected):
continue
if first_matching is None:
first_matching = w
if (self.expected_regex is not None and
not self.expected_regex.search(str(w))):
continue
# store warning for later retrieval
self.warning = w
self.filename = m.filename
self.lineno = m.lineno
return
# Now we simply try to choose a helpful failure message
if first_matching is not None:
self._raiseFailure('"{}" does not match "{}"'.format(
self.expected_regex.pattern, str(first_matching)))
self._raiseFailure("{} not triggered".format(exc_name))

116
tests/webhook_cases.py Normal file
View File

@@ -0,0 +1,116 @@
import base64
from django.test import override_settings, SimpleTestCase
from mock import create_autospec, ANY
from anymail.exceptions import AnymailInsecureWebhookWarning
from anymail.signals import tracking, inbound
from .utils import AnymailTestMixin
def event_handler(sender, event, esp_name, **kwargs):
"""Prototypical webhook signal handler"""
pass
@override_settings(ANYMAIL={'WEBHOOK_AUTHORIZATION': 'username:password'})
class WebhookTestCase(AnymailTestMixin, SimpleTestCase):
"""Base for testing webhooks
- connects webhook signal handlers
- sets up basic auth by default (since most ESP webhooks warn if it's not enabled)
"""
def setUp(self):
super(WebhookTestCase, self).setUp()
# Use correct basic auth by default (individual tests can override):
self.set_basic_auth()
# Install mocked signal handlers
self.tracking_handler = create_autospec(event_handler)
tracking.connect(self.tracking_handler)
self.addCleanup(tracking.disconnect, self.tracking_handler)
self.inbound_handler = create_autospec(event_handler)
inbound.connect(self.inbound_handler)
self.addCleanup(inbound.disconnect, self.inbound_handler)
def set_basic_auth(self, username='username', password='password'):
"""Set basic auth for all subsequent test client requests"""
credentials = base64.b64encode("{}:{}".format(username, password).encode('utf-8')).decode('utf-8')
self.client.defaults['HTTP_AUTHORIZATION'] = "Basic {}".format(credentials)
def assert_handler_called_once_with(self, mockfn, *expected_args, **expected_kwargs):
"""Verifies mockfn was called with expected_args and at least expected_kwargs.
Ignores *additional* actual kwargs (which might be added by Django signal dispatch).
(This differs from mock.assert_called_once_with.)
Returns the actual kwargs.
"""
self.assertEqual(mockfn.call_count, 1)
actual_args, actual_kwargs = mockfn.call_args
self.assertEqual(actual_args, expected_args)
for key, expected_value in expected_kwargs.items():
if expected_value is ANY:
self.assertIn(key, actual_kwargs)
else:
self.assertEqual(actual_kwargs[key], expected_value)
return actual_kwargs
# noinspection PyUnresolvedReferences
class WebhookBasicAuthTestsMixin(object):
"""Common test cases for webhook basic authentication.
Instantiate for each ESP's webhooks by:
- mixing into WebhookTestCase
- defining call_webhook to invoke the ESP's webhook
"""
should_warn_if_no_auth = True # subclass set False if other webhook verification used
def call_webhook(self):
# Concrete test cases should call a webhook via self.client.post,
# and return the response
raise NotImplementedError()
@override_settings(ANYMAIL={}) # Clear the WEBHOOK_AUTH settings from superclass
def test_warns_if_no_auth(self):
if self.should_warn_if_no_auth:
with self.assertWarns(AnymailInsecureWebhookWarning):
response = self.call_webhook()
else:
with self.assertDoesNotWarn():
response = self.call_webhook()
self.assertEqual(response.status_code, 200)
def test_verifies_basic_auth(self):
response = self.call_webhook()
self.assertEqual(response.status_code, 200)
def test_verifies_bad_auth(self):
self.set_basic_auth('baduser', 'wrongpassword')
response = self.call_webhook()
self.assertEqual(response.status_code, 400)
def test_verifies_missing_auth(self):
del self.client.defaults['HTTP_AUTHORIZATION']
response = self.call_webhook()
self.assertEqual(response.status_code, 400)
@override_settings(ANYMAIL={'WEBHOOK_AUTHORIZATION': ['cred1:pass1', 'cred2:pass2']})
def test_supports_credential_rotation(self):
"""You can supply a list of basic auth credentials, and any is allowed"""
self.set_basic_auth('cred1', 'pass1')
response = self.call_webhook()
self.assertEqual(response.status_code, 200)
self.set_basic_auth('cred2', 'pass2')
response = self.call_webhook()
self.assertEqual(response.status_code, 200)
self.set_basic_auth('baduser', 'wrongpassword')
response = self.call_webhook()
self.assertEqual(response.status_code, 400)