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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user