diff --git a/anymail/compat.py b/anymail/compat.py deleted file mode 100644 index 9c22d16..0000000 --- a/anymail/compat.py +++ /dev/null @@ -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] \ No newline at end of file diff --git a/anymail/exceptions.py b/anymail/exceptions.py index b378295..ff4fe10 100644 --- a/anymail/exceptions.py +++ b/anymail/exceptions.py @@ -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""" diff --git a/anymail/signals.py b/anymail/signals.py index f8f7ba1..4b6b92c 100644 --- a/anymail/signals.py +++ b/anymail/signals.py @@ -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' diff --git a/anymail/urls.py b/anymail/urls.py index a013c67..3af0c89 100644 --- a/anymail/urls.py +++ b/anymail/urls.py @@ -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'), ] diff --git a/anymail/utils.py b/anymail/utils.py index 9a2b4d6..5e84ba2 100644 --- a/anymail/utils.py +++ b/anymail/utils.py @@ -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) diff --git a/anymail/views.py b/anymail/views.py deleted file mode 100644 index 7cb14ab..0000000 --- a/anymail/views.py +++ /dev/null @@ -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 diff --git a/anymail/webhooks/__init__.py b/anymail/webhooks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/anymail/webhooks/base.py b/anymail/webhooks/base.py new file mode 100644 index 0000000..deda15a --- /dev/null +++ b/anymail/webhooks/base.py @@ -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__) diff --git a/anymail/webhooks/mailgun.py b/anymail/webhooks/mailgun.py new file mode 100644 index 0000000..ad110f8 --- /dev/null +++ b/anymail/webhooks/mailgun.py @@ -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 .) + 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, + ) diff --git a/anymail/webhooks/mandrill.py b/anymail/webhooks/mandrill.py new file mode 100644 index 0000000..3e6a229 --- /dev/null +++ b/anymail/webhooks/mandrill.py @@ -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), + ) diff --git a/anymail/webhooks/postmark.py b/anymail/webhooks/postmark.py new file mode 100644 index 0000000..be34b4a --- /dev/null +++ b/anymail/webhooks/postmark.py @@ -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), + ) diff --git a/anymail/webhooks/sendgrid.py b/anymail/webhooks/sendgrid.py new file mode 100644 index 0000000..24d5ad9 --- /dev/null +++ b/anymail/webhooks/sendgrid.py @@ -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 + } + diff --git a/docs/contributing.rst b/docs/contributing.rst index 1371071..617b34d 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -24,6 +24,8 @@ folks from `brack3t`_ who developed the original version of Djrill. .. _Djrill: https://github.com/brack3t/Djrill +.. _reporting-bugs: + Bugs ---- diff --git a/docs/esps/index.rst b/docs/esps/index.rst index 2cfb6e7..18789a7 100644 --- a/docs/esps/index.rst +++ b/docs/esps/index.rst @@ -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 ` and tracking +.. rubric:: :ref:`Status ` and :ref:`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 diff --git a/docs/esps/mailgun.rst b/docs/esps/mailgun.rst index 4f9d9a7..eeb2ee1 100644 --- a/docs/esps/mailgun.rst +++ b/docs/esps/mailgun.rst @@ -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 `, 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 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 diff --git a/docs/esps/mandrill.rst b/docs/esps/mandrill.rst index f8a2ec7..c213efd 100644 --- a/docs/esps/mandrill.rst +++ b/docs/esps/mandrill.rst @@ -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 `_ +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 `, +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 `, +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. diff --git a/docs/esps/postmark.rst b/docs/esps/postmark.rst index 90c1e9a..9c60176 100644 --- a/docs/esps/postmark.rst +++ b/docs/esps/postmark.rst @@ -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 `, 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 `_ +or `open `_ webhook data. + +.. _Postmark account settings: https://account.postmarkapp.com/servers diff --git a/docs/esps/sendgrid.rst b/docs/esps/sendgrid.rst index 85350cc..bedf991 100644 --- a/docs/esps/sendgrid.rst +++ b/docs/esps/sendgrid.rst @@ -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 `, 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 diff --git a/docs/inbound.rst b/docs/inbound.rst index 7eda9ce..0fab760 100644 --- a/docs/inbound.rst +++ b/docs/inbound.rst @@ -8,11 +8,5 @@ Receiving inbound email Normalized inbound email handling is coming soon to Anymail. -.. _inbound-webhooks: - -Configuring inbound webhooks ----------------------------- - - Inbound email signals --------------------- diff --git a/docs/installation.rst b/docs/installation.rst index 7bde6b2..a4a6ddf 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -41,6 +41,9 @@ To use Anymail for sending email, edit your Django project's :file:`settings.py` "MAILGUN_API_KEY" = "", } + The exact settings vary by ESP. + See the :ref:`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 ` to send particular messages through different ESPs.) - The exact backend name and required settings vary by ESP. - See the :ref:`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 `, and then -follow the instructions in the :ref:`event-tracking` section to set up -the delivery webhooks. +to :ref:`configure an Anymail backend `, and then: + +1. In your :file:`settings.py`, add + :setting:`WEBHOOK_AUTHORIZATION ` + to the ``ANYMAIL`` block: + + .. code-block:: python + + ANYMAIL = { + ... + 'WEBHOOK_AUTHORIZATION': ':', + } + + 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 ` 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 `, +.. 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. + diff --git a/docs/sending/tracking.rst b/docs/sending/tracking.rst index d1f25f6..6eb842a 100644 --- a/docs/sending/tracking.rst +++ b/docs/sending/tracking.rst @@ -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 ` 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 ` 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 ` 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 ` + 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 ` 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 ` + 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 `.) + + .. attribute:: tags + + A `list` of `str` tags attached to the message, or `None`. + (See :attr:`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 ` 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 = "" -.. -.. 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 + diff --git a/docs/tips/index.rst b/docs/tips/index.rst index 05e00d6..1b13028 100644 --- a/docs/tips/index.rst +++ b/docs/tips/index.rst @@ -9,6 +9,7 @@ done with Anymail: multiple_backends django_templates + securing_webhooks .. TODO: .. Working with django-mailer(2) diff --git a/docs/tips/securing_webhooks.rst b/docs/tips/securing_webhooks.rst new file mode 100644 index 0000000..ac8399f --- /dev/null +++ b/docs/tips/securing_webhooks.rst @@ -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 ` 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 ` 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. diff --git a/tests/__init__.py b/tests/__init__.py index d1d80a3..d766f99 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -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 * diff --git a/tests/test_mailgun_webhooks.py b/tests/test_mailgun_webhooks.py new file mode 100644 index 0000000..04f6271 --- /dev/null +++ b/tests/test_mailgun_webhooks.py @@ -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"]) diff --git a/tests/test_mandrill_webhook.py b/tests/test_mandrill_webhook.py deleted file mode 100644 index be7fdde..0000000 --- a/tests/test_mandrill_webhook.py +++ /dev/null @@ -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) diff --git a/tests/test_mandrill_webhooks.py b/tests/test_mandrill_webhooks.py new file mode 100644 index 0000000..62b52d8 --- /dev/null +++ b/tests/test_mandrill_webhooks.py @@ -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") diff --git a/tests/test_postmark_webhooks.py b/tests/test_postmark_webhooks.py new file mode 100644 index 0000000..fa48200 --- /dev/null +++ b/tests/test_postmark_webhooks.py @@ -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") + diff --git a/tests/test_sendgrid_webhooks.py b/tests/test_sendgrid_webhooks.py new file mode 100644 index 0000000..fffeb78 --- /dev/null +++ b/tests/test_sendgrid_webhooks.py @@ -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": "", + "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, "") + 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": "" + }] + 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, "") + 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": "", + "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, "") + 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": "", + "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, "") + 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": "", + "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, "") + 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") diff --git a/tests/utils.py b/tests/utils.py index 57984bc..94f8e6e 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -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)) + diff --git a/tests/webhook_cases.py b/tests/webhook_cases.py new file mode 100644 index 0000000..f8a2020 --- /dev/null +++ b/tests/webhook_cases.py @@ -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)