Files
django-anymail/tests/test_mailgun_webhooks.py
medmunds bb68f3dd6d Mailgun: fix event/metadata param extraction in tracking webhook
Mailgun merges user-variables (metadata) into the webhook post data
interspersed with the actual event params. This can lead to ambiguity
interpreting post data.

To extract metadata from an event, Anymail had been attempting to avoid
that ambiguity by instead using X-Mailgun-Variables fields found in the
event's message-headers param. But message-headers isn't included in
some tracking events (opened, clicked, unsubscribed), resulting in
empty metadata for those events. (#76)

Also, conflicting metadata keys could confuse Anymail's Mailgun event
parsing, leading to unexpected values in the normalized event. (#77)

This commit:
* Cleans up Anymail's tracking webhook to be explicit about which
  multi-value params it uses, avoiding conflicts with metadata keys.
  Fixes #77.
* Extracts metadata from post params for opened, clicked and
  unsubscribed events. All unknown event params are assumed to be
  metadata. Fixes #76.
* Documents a few metadata key names where it's impossible (or likely
  to be unreliable) for Anymail to extract metadata from the post data.

For reference, the order of params in the Mailgun's post data *appears*
to be (from live testing):
* For the timestamp, token and signature params, any user-variable with
  the same name appears *before* the corresponding event data.
* For all other params, any user-variable with the same name as a
  Mailgun event param appears *after* the Mailgun data.
2017-10-27 13:26:37 -07:00

327 lines
16 KiB
Python

import json
from datetime import datetime
import hashlib
import hmac
from django.core.exceptions import ImproperlyConfigured
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):
with self.assertRaises(ImproperlyConfigured):
self.client.post('/anymail/mailgun/tracking/',
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):
return self.client.post('/anymail/mailgun/tracking/',
data=mailgun_sign({'event': 'delivered'}))
# Additional tests are in WebhookBasicAuthTestsMixin
def test_verifies_correct_signature(self):
response = self.client.post('/anymail/mailgun/tracking/',
data=mailgun_sign({'event': 'delivered'}))
self.assertEqual(response.status_code, 200)
def test_verifies_missing_signature(self):
response = self.client.post('/anymail/mailgun/tracking/',
data={'event': 'delivered'})
self.assertEqual(response.status_code, 400)
def test_verifies_bad_signature(self):
data = mailgun_sign({'event': 'delivered'}, api_key="wrong API key")
response = self.client.post('/anymail/mailgun/tracking/', 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',
})
response = self.client.post('/anymail/mailgun/tracking/', 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)
self.assertEqual(event.tags, [])
self.assertEqual(event.metadata, {})
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',
})
response = self.client.post('/anymail/mailgun/tracking/', 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)
})
response = self.client.post('/anymail/mailgun/tracking/', 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)
})
response = self.client.post('/anymail/mailgun/tracking/', 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)
})
response = self.client.post('/anymail/mailgun/tracking/', 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_alt_smtp_code(self):
# In some cases, Mailgun uses RFC-3463 extended SMTP status codes (x.y.z, rather than nnn).
# See issue #62.
raw_event = mailgun_sign({
'code': '5.1.1',
'error': 'smtp;550 5.1.1 RESOLVER.ADR.RecipNotFound; not found',
'event': 'bounced',
'recipient': 'noreply@example.com',
# (omitting some fields that aren't relevant to the test)
})
response = self.client.post('/anymail/mailgun/tracking/', 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("RecipNotFound", event.mta_response)
def test_metadata_message_headers(self):
# Metadata fields are interspersed with other data, but also in message-headers
# for delivered, bounced and dropped events
raw_event = mailgun_sign({
'event': 'delivered',
'message-headers': json.dumps([
["X-Mailgun-Variables", "{\"custom1\": \"value1\", \"custom2\": \"{\\\"key\\\":\\\"value\\\"}\"}"],
]),
'custom1': 'value1',
'custom2': '{"key":"value"}', # you can store JSON, but you'll need to unpack it yourself
})
self.client.post('/anymail/mailgun/tracking/', 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_metadata_post_fields(self):
# Metadata fields are only interspersed with other event params
# for opened, clicked, unsubscribed events
raw_event = mailgun_sign({
'event': 'clicked',
'custom1': 'value1',
'custom2': '{"key":"value"}', # you can store JSON, but you'll need to unpack it yourself
})
self.client.post('/anymail/mailgun/tracking/', 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_metadata_key_conflicts(self):
# If you happen to name metadata (user-variable) keys the same as Mailgun
# event properties, Mailgun will include both in the webhook post.
# Make sure we don't confuse them.
metadata = {
"event": "metadata-event",
"recipient": "metadata-recipient",
"signature": "metadata-signature",
"timestamp": "metadata-timestamp",
"token": "metadata-token",
"ordinary field": "ordinary metadata value",
}
raw_event = mailgun_sign({
'event': 'clicked',
'recipient': 'actual-recipient@example.com',
'token': 'actual-event-token',
'timestamp': '1461261330',
'url': 'http://clicked.example.com/actual/event/param',
'h': "an (undocumented) Mailgun event param",
'tag': ["actual-tag-1", "actual-tag-2"],
})
# Simulate how Mailgun merges user-variables fields into event:
for key in metadata.keys():
if key in raw_event:
if key in {'signature', 'timestamp', 'token'}:
# For these fields, Mailgun's value appears after the metadata value
raw_event[key] = [metadata[key], raw_event[key]]
elif key == 'message-headers':
pass # Mailgun won't merge this field into the event
else:
# For all other fields, the defined event value comes first
raw_event[key] = [raw_event[key], metadata[key]]
else:
raw_event[key] = metadata[key]
response = self.client.post('/anymail/mailgun/tracking/', data=raw_event)
self.assertEqual(response.status_code, 200) # if this fails, signature checking is using metadata values
kwargs = self.assert_handler_called_once_with(self.tracking_handler)
event = kwargs['event']
self.assertEqual(event.event_type, "clicked")
self.assertEqual(event.recipient, "actual-recipient@example.com")
self.assertEqual(event.timestamp.isoformat(), "2016-04-21T17:55:30+00:00")
self.assertEqual(event.event_id, "actual-event-token")
self.assertEqual(event.tags, ["actual-tag-1", "actual-tag-2"])
self.assertEqual(event.metadata, metadata)
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('/anymail/mailgun/tracking/', 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('/anymail/mailgun/tracking/', data=raw_event)
kwargs = self.assert_handler_called_once_with(self.tracking_handler)
event = kwargs['event']
self.assertEqual(event.tags, ["tag1", "tag2"])