mirror of
https://github.com/pacnpal/django-anymail.git
synced 2025-12-20 03:41:05 -05:00
@@ -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]
|
||||
@@ -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"""
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
try:
|
||||
from django.conf.urls import url
|
||||
except ImportError:
|
||||
from django.conf.urls.defaults import url
|
||||
from django.conf.urls import url
|
||||
|
||||
from .views import DjrillWebhookView
|
||||
from .webhooks.mailgun import MailgunTrackingWebhookView
|
||||
from .webhooks.mandrill import MandrillTrackingWebhookView
|
||||
from .webhooks.postmark import PostmarkTrackingWebhookView
|
||||
from .webhooks.sendgrid import SendGridTrackingWebhookView
|
||||
|
||||
|
||||
app_name = 'anymail'
|
||||
urlpatterns = [
|
||||
url(r'^webhook/$', DjrillWebhookView.as_view(), name='djrill_webhook'),
|
||||
url(r'^mailgun/tracking/$', MailgunTrackingWebhookView.as_view(), name='mailgun_tracking_webhook'),
|
||||
url(r'^mandrill/tracking/$', MandrillTrackingWebhookView.as_view(), name='mandrill_tracking_webhook'),
|
||||
url(r'^postmark/tracking/$', PostmarkTrackingWebhookView.as_view(), name='postmark_tracking_webhook'),
|
||||
url(r'^sendgrid/tracking/$', SendGridTrackingWebhookView.as_view(), name='sendgrid_tracking_webhook'),
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
0
anymail/webhooks/__init__.py
Normal file
0
anymail/webhooks/__init__.py
Normal file
139
anymail/webhooks/base.py
Normal file
139
anymail/webhooks/base.py
Normal 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
133
anymail/webhooks/mailgun.py
Normal 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,
|
||||
)
|
||||
140
anymail/webhooks/mandrill.py
Normal file
140
anymail/webhooks/mandrill.py
Normal 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),
|
||||
)
|
||||
104
anymail/webhooks/postmark.py
Normal file
104
anymail/webhooks/postmark.py
Normal 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),
|
||||
)
|
||||
123
anymail/webhooks/sendgrid.py
Normal file
123
anymail/webhooks/sendgrid.py
Normal 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
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ folks from `brack3t`_ who developed the original version of Djrill.
|
||||
.. _Djrill: https://github.com/brack3t/Djrill
|
||||
|
||||
|
||||
.. _reporting-bugs:
|
||||
|
||||
Bugs
|
||||
----
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,11 +8,5 @@ Receiving inbound email
|
||||
Normalized inbound email handling is coming soon to Anymail.
|
||||
|
||||
|
||||
.. _inbound-webhooks:
|
||||
|
||||
Configuring inbound webhooks
|
||||
----------------------------
|
||||
|
||||
|
||||
Inbound email signals
|
||||
---------------------
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -1,178 +1,272 @@
|
||||
.. module:: anymail.signals
|
||||
|
||||
.. _event-tracking:
|
||||
|
||||
Tracking sent mail status
|
||||
=========================
|
||||
|
||||
.. note::
|
||||
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.
|
||||
|
||||
Normalized event-tracking webhooks and signals are coming
|
||||
to Anymail soon.
|
||||
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.
|
||||
|
||||
|
||||
.. `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.
|
||||
Normalized tracking event
|
||||
-------------------------
|
||||
|
||||
.. _Mandrill webhooks: http://help.mandrill.com/entries/21738186-Introduction-to-Webhooks
|
||||
.. class:: AnymailTrackingEvent
|
||||
|
||||
.. _webhooks-config:
|
||||
The `event` parameter to Anymail's `tracking`
|
||||
:ref:`signal receiver <signal-receivers>`
|
||||
is an object with the following attributes:
|
||||
|
||||
Configuring tracking webhooks
|
||||
-----------------------------
|
||||
.. attribute:: event_type
|
||||
|
||||
.. warning:: Webhook Security
|
||||
A normalized `str` identifying the type of tracking event.
|
||||
|
||||
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:
|
||||
.. note::
|
||||
|
||||
* Your webhook should only be accessible over SSL (https).
|
||||
(This is beyond the scope of Anymail.)
|
||||
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.
|
||||
|
||||
* 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.
|
||||
One of:
|
||||
|
||||
.. * You can, optionally include the two settings :setting:`DJRILL_WEBHOOK_SIGNATURE_KEY`
|
||||
.. and :setting:`DJRILL_WEBHOOK_URL` to enforce `webhook signature`_ checking
|
||||
* `'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.
|
||||
|
||||
.. _webhook signature: http://help.mandrill.com/entries/23704122-Authenticating-webhook-requests
|
||||
.. 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.
|
||||
|
||||
|
||||
.. 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.
|
||||
.. _signal-receivers:
|
||||
|
||||
.. _webhooks control panel: https://mandrillapp.com/settings/webhooks
|
||||
.. _inbound settings: https://mandrillapp.com/inbound
|
||||
Signal receiver functions
|
||||
-------------------------
|
||||
|
||||
Your Anymail signal receiver must be a function with this signature:
|
||||
|
||||
.. _webhook-usage:
|
||||
.. function:: def my_handler(sender, event, esp_name, **kwargs):
|
||||
|
||||
Tracking event signals
|
||||
----------------------
|
||||
(You can name it anything you want.)
|
||||
|
||||
.. 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`_.
|
||||
: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).
|
||||
|
||||
.. _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-
|
||||
:returns: nothing
|
||||
:raises: any exceptions in your signal receiver will result
|
||||
in a 400 HTTP error to the webhook. See discussion
|
||||
below.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
.. _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
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ done with Anymail:
|
||||
|
||||
multiple_backends
|
||||
django_templates
|
||||
securing_webhooks
|
||||
|
||||
.. TODO:
|
||||
.. Working with django-mailer(2)
|
||||
|
||||
109
docs/tips/securing_webhooks.rst
Normal file
109
docs/tips/securing_webhooks.rst
Normal 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.
|
||||
@@ -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 *
|
||||
|
||||
250
tests/test_mailgun_webhooks.py
Normal file
250
tests/test_mailgun_webhooks.py
Normal 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"])
|
||||
@@ -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)
|
||||
195
tests/test_mandrill_webhooks.py
Normal file
195
tests/test_mandrill_webhooks.py
Normal 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")
|
||||
86
tests/test_postmark_webhooks.py
Normal file
86
tests/test_postmark_webhooks.py
Normal 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")
|
||||
|
||||
235
tests/test_sendgrid_webhooks.py
Normal file
235
tests/test_sendgrid_webhooks.py
Normal 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")
|
||||
@@ -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
116
tests/webhook_cases.py
Normal 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)
|
||||
Reference in New Issue
Block a user