mirror of
https://github.com/pacnpal/django-anymail.git
synced 2025-12-20 11:51:05 -05:00
Mailgun: Support new (non-legacy) webhooks
Extend existing Mailgun tracking webhook handler to support both original (legacy) and new (June, 2018) Mailgun webhooks. Closes #117
This commit is contained in:
@@ -43,6 +43,9 @@ Breaking changes
|
|||||||
Features
|
Features
|
||||||
~~~~~~~~
|
~~~~~~~~
|
||||||
|
|
||||||
|
* **Mailgun:** Add support for new Mailgun webhooks. (Mailgun's original "legacy
|
||||||
|
webhook" format is also still supported. See
|
||||||
|
`docs <https://anymail.readthedocs.io/en/stable/esps/mailgun/#mailgun-webhooks>`__.)
|
||||||
* **Mailgun:** Document how to use new European region. (This works in earlier
|
* **Mailgun:** Document how to use new European region. (This works in earlier
|
||||||
Anymail versions, too.)
|
Anymail versions, too.)
|
||||||
* **Postmark:** Add support for Anymail's normalized `metadata` in sending
|
* **Postmark:** Add support for Anymail's normalized `metadata` in sending
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ from django.utils.crypto import constant_time_compare
|
|||||||
from django.utils.timezone import utc
|
from django.utils.timezone import utc
|
||||||
|
|
||||||
from .base import AnymailBaseWebhookView
|
from .base import AnymailBaseWebhookView
|
||||||
from ..exceptions import AnymailWebhookValidationFailure
|
from ..exceptions import AnymailWebhookValidationFailure, AnymailInvalidAddress
|
||||||
from ..inbound import AnymailInboundMessage
|
from ..inbound import AnymailInboundMessage
|
||||||
from ..signals import inbound, tracking, AnymailInboundEvent, AnymailTrackingEvent, EventType, RejectReason
|
from ..signals import inbound, tracking, AnymailInboundEvent, AnymailTrackingEvent, EventType, RejectReason
|
||||||
from ..utils import get_anymail_setting, combine, querydict_getfirst
|
from ..utils import get_anymail_setting, combine, querydict_getfirst, parse_single_address
|
||||||
|
|
||||||
|
|
||||||
class MailgunBaseWebhookView(AnymailBaseWebhookView):
|
class MailgunBaseWebhookView(AnymailBaseWebhookView):
|
||||||
@@ -29,6 +29,20 @@ class MailgunBaseWebhookView(AnymailBaseWebhookView):
|
|||||||
|
|
||||||
def validate_request(self, request):
|
def validate_request(self, request):
|
||||||
super(MailgunBaseWebhookView, self).validate_request(request) # first check basic auth if enabled
|
super(MailgunBaseWebhookView, self).validate_request(request) # first check basic auth if enabled
|
||||||
|
if request.content_type == "application/json":
|
||||||
|
# New-style webhook: json payload with separate signature block
|
||||||
|
try:
|
||||||
|
event = json.loads(request.body.decode('utf-8'))
|
||||||
|
signature_block = event['signature']
|
||||||
|
token = signature_block['token']
|
||||||
|
timestamp = signature_block['timestamp']
|
||||||
|
signature = signature_block['signature']
|
||||||
|
except (KeyError, ValueError, UnicodeDecodeError) as err:
|
||||||
|
raise AnymailWebhookValidationFailure(
|
||||||
|
"Mailgun webhook called with invalid payload format",
|
||||||
|
raised_from=err)
|
||||||
|
else:
|
||||||
|
# Legacy webhook: signature fields are interspersed with other POST data
|
||||||
try:
|
try:
|
||||||
# Must use the *last* value of these fields if there are conflicting merged user-variables.
|
# Must use the *last* value of these fields if there are conflicting merged user-variables.
|
||||||
# (Fortunately, Django QueryDict is specced to return the last value.)
|
# (Fortunately, Django QueryDict is specced to return the last value.)
|
||||||
@@ -37,6 +51,7 @@ class MailgunBaseWebhookView(AnymailBaseWebhookView):
|
|||||||
signature = str(request.POST['signature']) # force to same type as hexdigest() (for python2)
|
signature = str(request.POST['signature']) # force to same type as hexdigest() (for python2)
|
||||||
except KeyError:
|
except KeyError:
|
||||||
raise AnymailWebhookValidationFailure("Mailgun webhook called without required security fields")
|
raise AnymailWebhookValidationFailure("Mailgun webhook called without required security fields")
|
||||||
|
|
||||||
expected_signature = hmac.new(key=self.api_key, msg='{}{}'.format(timestamp, token).encode('ascii'),
|
expected_signature = hmac.new(key=self.api_key, msg='{}{}'.format(timestamp, token).encode('ascii'),
|
||||||
digestmod=hashlib.sha256).hexdigest()
|
digestmod=hashlib.sha256).hexdigest()
|
||||||
if not constant_time_compare(signature, expected_signature):
|
if not constant_time_compare(signature, expected_signature):
|
||||||
@@ -48,7 +63,109 @@ class MailgunTrackingWebhookView(MailgunBaseWebhookView):
|
|||||||
|
|
||||||
signal = tracking
|
signal = tracking
|
||||||
|
|
||||||
|
def parse_events(self, request):
|
||||||
|
if request.content_type == "application/json":
|
||||||
|
esp_event = json.loads(request.body.decode('utf-8'))
|
||||||
|
return [self.esp_to_anymail_event(esp_event)]
|
||||||
|
else:
|
||||||
|
return [self.mailgun_legacy_to_anymail_event(request.POST)]
|
||||||
|
|
||||||
event_types = {
|
event_types = {
|
||||||
|
# Map Mailgun event: Anymail normalized type
|
||||||
|
'accepted': EventType.QUEUED, # not delivered to webhooks (8/2018)
|
||||||
|
'rejected': EventType.REJECTED,
|
||||||
|
'delivered': EventType.DELIVERED,
|
||||||
|
'failed': EventType.BOUNCED,
|
||||||
|
'opened': EventType.OPENED,
|
||||||
|
'clicked': EventType.CLICKED,
|
||||||
|
'unsubscribed': EventType.UNSUBSCRIBED,
|
||||||
|
'complained': EventType.COMPLAINED,
|
||||||
|
}
|
||||||
|
|
||||||
|
reject_reasons = {
|
||||||
|
# Map Mailgun event_data.reason: Anymail normalized RejectReason
|
||||||
|
# (these appear in webhook doc examples, but aren't actually documented anywhere)
|
||||||
|
"bounce": RejectReason.BOUNCED,
|
||||||
|
"suppress-bounce": RejectReason.BOUNCED,
|
||||||
|
"generic": RejectReason.BOUNCED, # ??? appears to be used for any temporary failure?
|
||||||
|
}
|
||||||
|
|
||||||
|
def esp_to_anymail_event(self, esp_event):
|
||||||
|
event_data = esp_event.get('event-data', {})
|
||||||
|
|
||||||
|
try:
|
||||||
|
event_type = self.event_types[event_data['event']]
|
||||||
|
except KeyError:
|
||||||
|
event_type = EventType.UNKNOWN
|
||||||
|
|
||||||
|
# Use signature.token for event_id, rather than event_data.id,
|
||||||
|
# because the latter is only "guaranteed to be unique within a day".
|
||||||
|
event_id = esp_event.get('signature', {}).get('token')
|
||||||
|
|
||||||
|
recipient = event_data.get('recipient')
|
||||||
|
|
||||||
|
try:
|
||||||
|
timestamp = datetime.fromtimestamp(float(event_data['timestamp']), tz=utc)
|
||||||
|
except KeyError:
|
||||||
|
timestamp = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
message_id = event_data['message']['headers']['message-id']
|
||||||
|
except KeyError:
|
||||||
|
message_id = None
|
||||||
|
if message_id and not message_id.startswith('<'):
|
||||||
|
message_id = "<{}>".format(message_id)
|
||||||
|
|
||||||
|
metadata = event_data.get('user-variables', {})
|
||||||
|
tags = event_data.get('tags', [])
|
||||||
|
|
||||||
|
try:
|
||||||
|
delivery_status = event_data['delivery-status']
|
||||||
|
except KeyError:
|
||||||
|
description = None
|
||||||
|
mta_response = None
|
||||||
|
else:
|
||||||
|
description = delivery_status.get('description')
|
||||||
|
mta_response = delivery_status.get('message')
|
||||||
|
|
||||||
|
if 'reason' in event_data:
|
||||||
|
reject_reason = self.reject_reasons.get(event_data['reason'], RejectReason.OTHER)
|
||||||
|
else:
|
||||||
|
reject_reason = None
|
||||||
|
|
||||||
|
if event_type == EventType.REJECTED:
|
||||||
|
# This event has a somewhat different structure than the others...
|
||||||
|
description = description or event_data.get("reject", {}).get("reason")
|
||||||
|
reject_reason = reject_reason or RejectReason.OTHER
|
||||||
|
if not recipient:
|
||||||
|
try:
|
||||||
|
to_email = parse_single_address(
|
||||||
|
event_data["message"]["headers"]["to"])
|
||||||
|
except (AnymailInvalidAddress, KeyError):
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
recipient = to_email.addr_spec
|
||||||
|
|
||||||
|
return AnymailTrackingEvent(
|
||||||
|
event_type=event_type,
|
||||||
|
timestamp=timestamp,
|
||||||
|
message_id=message_id,
|
||||||
|
event_id=event_id,
|
||||||
|
recipient=recipient,
|
||||||
|
reject_reason=reject_reason,
|
||||||
|
description=description,
|
||||||
|
mta_response=mta_response,
|
||||||
|
tags=tags,
|
||||||
|
metadata=metadata,
|
||||||
|
click_url=event_data.get('url'),
|
||||||
|
user_agent=event_data.get('client-info', {}).get('user-agent'),
|
||||||
|
esp_event=esp_event,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Legacy event handling
|
||||||
|
# (Prior to 2018-06-29, these were the only Mailgun events.)
|
||||||
|
|
||||||
|
legacy_event_types = {
|
||||||
# Map Mailgun event: Anymail normalized type
|
# Map Mailgun event: Anymail normalized type
|
||||||
'delivered': EventType.DELIVERED,
|
'delivered': EventType.DELIVERED,
|
||||||
'dropped': EventType.REJECTED,
|
'dropped': EventType.REJECTED,
|
||||||
@@ -60,7 +177,7 @@ class MailgunTrackingWebhookView(MailgunBaseWebhookView):
|
|||||||
# Mailgun does not send events corresponding to QUEUED or DEFERRED
|
# Mailgun does not send events corresponding to QUEUED or DEFERRED
|
||||||
}
|
}
|
||||||
|
|
||||||
reject_reasons = {
|
legacy_reject_reasons = {
|
||||||
# Map Mailgun (SMTP) error codes to Anymail normalized reject_reason.
|
# Map Mailgun (SMTP) error codes to Anymail normalized reject_reason.
|
||||||
# By default, we will treat anything 400-599 as REJECT_BOUNCED
|
# By default, we will treat anything 400-599 as REJECT_BOUNCED
|
||||||
# so only exceptions are listed here.
|
# so only exceptions are listed here.
|
||||||
@@ -71,10 +188,7 @@ class MailgunTrackingWebhookView(MailgunBaseWebhookView):
|
|||||||
607: RejectReason.SPAM, # previous spam complaint
|
607: RejectReason.SPAM, # previous spam complaint
|
||||||
}
|
}
|
||||||
|
|
||||||
def parse_events(self, request):
|
def mailgun_legacy_to_anymail_event(self, esp_event):
|
||||||
return [self.esp_to_anymail_event(request.POST)]
|
|
||||||
|
|
||||||
def esp_to_anymail_event(self, esp_event):
|
|
||||||
# esp_event is a Django QueryDict (from request.POST),
|
# esp_event is a Django QueryDict (from request.POST),
|
||||||
# which has multi-valued fields, but is *not* case-insensitive.
|
# which has multi-valued fields, but is *not* case-insensitive.
|
||||||
# Because of the way Mailgun merges user-variables into the event,
|
# Because of the way Mailgun merges user-variables into the event,
|
||||||
@@ -82,7 +196,7 @@ class MailgunTrackingWebhookView(MailgunBaseWebhookView):
|
|||||||
# to avoid potential conflicting user-data.
|
# to avoid potential conflicting user-data.
|
||||||
esp_event.getfirst = querydict_getfirst.__get__(esp_event)
|
esp_event.getfirst = querydict_getfirst.__get__(esp_event)
|
||||||
|
|
||||||
event_type = self.event_types.get(esp_event.getfirst('event'), EventType.UNKNOWN)
|
event_type = self.legacy_event_types.get(esp_event.getfirst('event'), EventType.UNKNOWN)
|
||||||
timestamp = datetime.fromtimestamp(int(esp_event['timestamp']), tz=utc) # use *last* value of timestamp
|
timestamp = datetime.fromtimestamp(int(esp_event['timestamp']), tz=utc) # use *last* value of timestamp
|
||||||
# Message-Id is not documented for every event, but seems to always be included.
|
# Message-Id is not documented for every event, but seems to always be included.
|
||||||
# (It's sometimes spelled as 'message-id', lowercase, and missing the <angle-brackets>.)
|
# (It's sometimes spelled as 'message-id', lowercase, and missing the <angle-brackets>.)
|
||||||
@@ -107,12 +221,12 @@ class MailgunTrackingWebhookView(MailgunBaseWebhookView):
|
|||||||
else:
|
else:
|
||||||
reject_reason = RejectReason.BOUNCED if status_class in ("4", "5") else RejectReason.OTHER
|
reject_reason = RejectReason.BOUNCED if status_class in ("4", "5") else RejectReason.OTHER
|
||||||
else:
|
else:
|
||||||
reject_reason = self.reject_reasons.get(
|
reject_reason = self.legacy_reject_reasons.get(
|
||||||
mta_status,
|
mta_status,
|
||||||
RejectReason.BOUNCED if 400 <= mta_status < 600
|
RejectReason.BOUNCED if 400 <= mta_status < 600
|
||||||
else RejectReason.OTHER)
|
else RejectReason.OTHER)
|
||||||
|
|
||||||
metadata = self._extract_metadata(esp_event)
|
metadata = self._extract_legacy_metadata(esp_event)
|
||||||
|
|
||||||
# tags are supposed to be in 'tag' fields, but are sometimes in undocumented X-Mailgun-Tag
|
# tags are supposed to be in 'tag' fields, but are sometimes in undocumented X-Mailgun-Tag
|
||||||
tags = esp_event.getlist('tag', None) or esp_event.getlist('X-Mailgun-Tag', [])
|
tags = esp_event.getlist('tag', None) or esp_event.getlist('X-Mailgun-Tag', [])
|
||||||
@@ -133,7 +247,7 @@ class MailgunTrackingWebhookView(MailgunBaseWebhookView):
|
|||||||
esp_event=esp_event,
|
esp_event=esp_event,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _extract_metadata(self, esp_event):
|
def _extract_legacy_metadata(self, esp_event):
|
||||||
# Mailgun merges user-variables into the POST fields. If you know which user variable
|
# Mailgun merges user-variables into the POST fields. If you know which user variable
|
||||||
# you want to retrieve--and it doesn't conflict with a Mailgun event field--that's fine.
|
# you want to retrieve--and it doesn't conflict with a Mailgun event field--that's fine.
|
||||||
# But if you want to extract all user-variables (like we do), it's more complicated...
|
# But if you want to extract all user-variables (like we do), it's more complicated...
|
||||||
@@ -149,10 +263,10 @@ class MailgunTrackingWebhookView(MailgunBaseWebhookView):
|
|||||||
# Each X-Mailgun-Variables value is JSON. Parse and merge them all into single dict:
|
# Each X-Mailgun-Variables value is JSON. Parse and merge them all into single dict:
|
||||||
metadata = combine(*[json.loads(value) for value in variables])
|
metadata = combine(*[json.loads(value) for value in variables])
|
||||||
|
|
||||||
elif event_type in self._known_event_fields:
|
elif event_type in self._known_legacy_event_fields:
|
||||||
# For other events, we must extract from the POST fields, ignoring known Mailgun
|
# For other events, we must extract from the POST fields, ignoring known Mailgun
|
||||||
# event parameters, and treating all other values as user-variables.
|
# event parameters, and treating all other values as user-variables.
|
||||||
known_fields = self._known_event_fields[event_type]
|
known_fields = self._known_legacy_event_fields[event_type]
|
||||||
for field, values in esp_event.lists():
|
for field, values in esp_event.lists():
|
||||||
if field not in known_fields:
|
if field not in known_fields:
|
||||||
# Unknown fields are assumed to be user-variables. (There should really only be
|
# Unknown fields are assumed to be user-variables. (There should really only be
|
||||||
@@ -177,7 +291,7 @@ class MailgunTrackingWebhookView(MailgunBaseWebhookView):
|
|||||||
|
|
||||||
return metadata
|
return metadata
|
||||||
|
|
||||||
_common_event_fields = {
|
_common_legacy_event_fields = {
|
||||||
# These fields are documented to appear in all Mailgun opened, clicked and unsubscribed events:
|
# These fields are documented to appear in all Mailgun opened, clicked and unsubscribed events:
|
||||||
'event', 'recipient', 'domain', 'ip', 'country', 'region', 'city', 'user-agent', 'device-type',
|
'event', 'recipient', 'domain', 'ip', 'country', 'region', 'city', 'user-agent', 'device-type',
|
||||||
'client-type', 'client-name', 'client-os', 'campaign-id', 'campaign-name', 'tag', 'mailing-list',
|
'client-type', 'client-name', 'client-os', 'campaign-id', 'campaign-name', 'tag', 'mailing-list',
|
||||||
@@ -185,13 +299,13 @@ class MailgunTrackingWebhookView(MailgunBaseWebhookView):
|
|||||||
# Undocumented, but observed in actual events:
|
# Undocumented, but observed in actual events:
|
||||||
'body-plain', 'h', 'message-id',
|
'body-plain', 'h', 'message-id',
|
||||||
}
|
}
|
||||||
_known_event_fields = {
|
_known_legacy_event_fields = {
|
||||||
# For all Mailgun event types that *don't* include message-headers,
|
# For all Mailgun event types that *don't* include message-headers,
|
||||||
# map Mailgun (not normalized) event type to set of expected event fields.
|
# map Mailgun (not normalized) event type to set of expected event fields.
|
||||||
# Used for metadata extraction.
|
# Used for metadata extraction.
|
||||||
'clicked': _common_event_fields | {'url'},
|
'clicked': _common_legacy_event_fields | {'url'},
|
||||||
'opened': _common_event_fields,
|
'opened': _common_legacy_event_fields,
|
||||||
'unsubscribed': _common_event_fields,
|
'unsubscribed': _common_legacy_event_fields,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -147,14 +147,6 @@ values directly to Mailgun. You can use any of the (non-file) parameters listed
|
|||||||
Limitations and quirks
|
Limitations and quirks
|
||||||
----------------------
|
----------------------
|
||||||
|
|
||||||
**Metadata keys and tracking webhooks**
|
|
||||||
Because of the way Mailgun supplies custom data (user-variables) to webhooks,
|
|
||||||
there are a few metadata keys that Anymail cannot reliably retrieve in some
|
|
||||||
tracking events. You should avoid using "body-plain", "h", "message-headers",
|
|
||||||
"message-id" or "tag" as :attr:`~anymail.message.AnymailMessage.metadata` keys
|
|
||||||
if you need to access that metadata from an opened, clicked, or unsubscribed
|
|
||||||
:ref:`tracking event <event-tracking>` handler.
|
|
||||||
|
|
||||||
**Envelope sender uses only domain**
|
**Envelope sender uses only domain**
|
||||||
Anymail's :attr:`~anymail.message.AnymailMessage.envelope_sender` is used to
|
Anymail's :attr:`~anymail.message.AnymailMessage.envelope_sender` is used to
|
||||||
select your Mailgun :ref:`sender domain <mailgun-sender-domain>`. For
|
select your Mailgun :ref:`sender domain <mailgun-sender-domain>`. For
|
||||||
@@ -212,31 +204,73 @@ See the `Mailgun batch sending`_ docs for more information.
|
|||||||
Status tracking webhooks
|
Status tracking webhooks
|
||||||
------------------------
|
------------------------
|
||||||
|
|
||||||
|
.. versionchanged:: 4.0
|
||||||
|
|
||||||
|
Added support for Mailgun's June, 2018 (non-"legacy") webhook format.
|
||||||
|
|
||||||
If you are using Anymail's normalized :ref:`status tracking <event-tracking>`, enter
|
If you are using Anymail's normalized :ref:`status tracking <event-tracking>`, enter
|
||||||
the url in your `Mailgun dashboard`_ on the "Webhooks" tab. Mailgun allows you to enter
|
the url in the `Mailgun webhooks dashboard`_. (Be sure to select the correct sending
|
||||||
a different URL for each event type: just enter this same Anymail tracking URL
|
domain---Mailgun's sandbox and production domains have separate webhook settings.)
|
||||||
for all events you want to receive:
|
|
||||||
|
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/`
|
:samp:`https://{random}:{random}@{yoursite.example.com}/anymail/mailgun/tracking/`
|
||||||
|
|
||||||
* *random:random* is an :setting:`ANYMAIL_WEBHOOK_SECRET` shared secret
|
* *random:random* is an :setting:`ANYMAIL_WEBHOOK_SECRET` shared secret
|
||||||
* *yoursite.example.com* is your Django site
|
* *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
|
Mailgun implements a limited form of webhook signing, and Anymail will verify
|
||||||
these signatures (based on your :setting:`MAILGUN_API_KEY <ANYMAIL_MAILGUN_API_KEY>`
|
these signatures (based on your :setting:`MAILGUN_API_KEY <ANYMAIL_MAILGUN_API_KEY>`
|
||||||
Anymail setting).
|
Anymail setting). By default, Mailgun's webhook signature provides similar security
|
||||||
|
to Anymail's shared webhook secret, so it's acceptable to omit the
|
||||||
|
:setting:`ANYMAIL_WEBHOOK_SECRET` setting (and "{random}:{random}@" portion of the
|
||||||
|
webhook url) with Mailgun webhooks.
|
||||||
|
|
||||||
Mailgun will report these Anymail :attr:`~anymail.signals.AnymailTrackingEvent.event_type`\s:
|
Mailgun will report these Anymail :attr:`~anymail.signals.AnymailTrackingEvent.event_type`\s:
|
||||||
delivered, rejected, bounced, complained, unsubscribed, opened, clicked.
|
delivered, rejected, bounced, complained, unsubscribed, opened, clicked.
|
||||||
|
|
||||||
The event's :attr:`~anymail.signals.AnymailTrackingEvent.esp_event` field will be
|
The event's :attr:`~anymail.signals.AnymailTrackingEvent.esp_event` field will be
|
||||||
a Django :class:`~django.http.QueryDict` object of `Mailgun event fields`_.
|
the parsed `Mailgun webhook payload`_ as a Python `dict` with ``"signature"`` and
|
||||||
|
``"event-data"`` keys.
|
||||||
|
|
||||||
.. _Mailgun dashboard: https://mailgun.com/app/dashboard
|
Anymail uses Mailgun's webhook `token` as its normalized
|
||||||
.. _Mailgun event fields: https://documentation.mailgun.com/user_manual.html#webhooks
|
:attr:`~anymail.signals.AnymailTrackingEvent.event_id`, rather than Mailgun's
|
||||||
|
event-data `id` (which is only guaranteed to be unique during a single day).
|
||||||
|
If you need the event-data id, it can be accessed in your webhook handler as
|
||||||
|
``event.esp_event["event-data"]["id"]``. (This can be helpful for working with
|
||||||
|
Mailgun's other event APIs.)
|
||||||
|
|
||||||
|
.. note:: **Mailgun legacy webhooks**
|
||||||
|
|
||||||
|
In late June, 2018, Mailgun introduced a new set of webhooks with an improved
|
||||||
|
payload design, and at the same time renamed their original webhooks to "Legacy
|
||||||
|
Webhooks."
|
||||||
|
|
||||||
|
Anymail v4.0 and later supports both new and legacy Mailgun webhooks, and the same
|
||||||
|
Anymail webhook url works as either. Earlier Anymail versions can only be used
|
||||||
|
as legacy webhook urls.
|
||||||
|
|
||||||
|
The new (non-legacy) webhooks are preferred, particularly with Anymail's
|
||||||
|
:attr:`~anymail.message.AnymailMessage.metadata` and
|
||||||
|
:attr:`~anymail.message.AnymailMessage.tags` features. But if you have already
|
||||||
|
configured the legacy webhooks, there is no need to change.
|
||||||
|
|
||||||
|
If you are using Mailgun's legacy webhooks:
|
||||||
|
|
||||||
|
* The :attr:`event.esp_event <anymail.signals.AnymailTrackingEvent.esp_event>` field
|
||||||
|
will be a Django :class:`~django.http.QueryDict` of Mailgun event fields (the
|
||||||
|
raw POST data provided by legacy webhooks).
|
||||||
|
|
||||||
|
* You should avoid using "body-plain," "h," "message-headers," "message-id" or "tag"
|
||||||
|
as :attr:`~anymail.message.AnymailMessage.metadata` keys. A design limitation in
|
||||||
|
Mailgun's legacy webhooks prevents Anymail from reliably retrieving this metadata
|
||||||
|
from opened, clicked, and unsubscribed events. (This is not an issue with the
|
||||||
|
newer, non-legacy webhooks.)
|
||||||
|
|
||||||
|
|
||||||
|
.. _Mailgun webhooks dashboard: https://mailgun.com/app/webhooks
|
||||||
|
.. _Mailgun webhook payload: https://documentation.mailgun.com/en/latest/user_manual.html#webhooks
|
||||||
|
|
||||||
|
|
||||||
.. _mailgun-inbound:
|
.. _mailgun-inbound:
|
||||||
@@ -247,7 +281,7 @@ Inbound webhook
|
|||||||
If you want to receive email from Mailgun through Anymail's normalized :ref:`inbound <inbound>`
|
If you want to receive email from Mailgun through Anymail's normalized :ref:`inbound <inbound>`
|
||||||
handling, follow Mailgun's `Receiving, Storing and Fowarding Messages`_ guide to set up
|
handling, follow Mailgun's `Receiving, Storing and Fowarding Messages`_ guide to set up
|
||||||
an inbound route that forwards to Anymail's inbound webhook. (You can configure routes
|
an inbound route that forwards to Anymail's inbound webhook. (You can configure routes
|
||||||
using Mailgun's API, or simply using the "Routes" tab in your `Mailgun dashboard`_.)
|
using Mailgun's API, or simply using the `Mailgun routes dashboard`_.)
|
||||||
|
|
||||||
The *action* for your route will be either:
|
The *action* for your route will be either:
|
||||||
|
|
||||||
@@ -266,7 +300,9 @@ received email (including complex forms like multi-message mailing list digests)
|
|||||||
If you want to use Anymail's normalized :attr:`~anymail.inbound.AnymailInboundMessage.spam_detected` and
|
If you want to use Anymail's normalized :attr:`~anymail.inbound.AnymailInboundMessage.spam_detected` and
|
||||||
:attr:`~anymail.inbound.AnymailInboundMessage.spam_score` attributes, you'll need to set your Mailgun
|
:attr:`~anymail.inbound.AnymailInboundMessage.spam_score` attributes, you'll need to set your Mailgun
|
||||||
domain's inbound spam filter to "Deliver spam, but add X-Mailgun-SFlag and X-Mailgun-SScore headers"
|
domain's inbound spam filter to "Deliver spam, but add X-Mailgun-SFlag and X-Mailgun-SScore headers"
|
||||||
(in the `Mailgun dashboard`_ on the "Domains" tab).
|
(in the `Mailgun domains dashboard`_).
|
||||||
|
|
||||||
.. _Receiving, Storing and Fowarding Messages:
|
.. _Receiving, Storing and Fowarding Messages:
|
||||||
https://documentation.mailgun.com/en/latest/user_manual.html#receiving-forwarding-and-storing-messages
|
https://documentation.mailgun.com/en/latest/user_manual.html#receiving-forwarding-and-storing-messages
|
||||||
|
.. _Mailgun routes dashboard: https://app.mailgun.com/app/routes
|
||||||
|
.. _Mailgun domains dashboard: https://app.mailgun.com/app/domains
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from anymail.inbound import AnymailInboundMessage
|
|||||||
from anymail.signals import AnymailInboundEvent
|
from anymail.signals import AnymailInboundEvent
|
||||||
from anymail.webhooks.mailgun import MailgunInboundWebhookView
|
from anymail.webhooks.mailgun import MailgunInboundWebhookView
|
||||||
|
|
||||||
from .test_mailgun_webhooks import TEST_API_KEY, mailgun_sign, querydict_to_postdict
|
from .test_mailgun_webhooks import TEST_API_KEY, mailgun_sign_legacy_payload, querydict_to_postdict
|
||||||
from .utils import sample_image_content, sample_email_content
|
from .utils import sample_image_content, sample_email_content
|
||||||
from .webhook_cases import WebhookTestCase
|
from .webhook_cases import WebhookTestCase
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ from .webhook_cases import WebhookTestCase
|
|||||||
@override_settings(ANYMAIL_MAILGUN_API_KEY=TEST_API_KEY)
|
@override_settings(ANYMAIL_MAILGUN_API_KEY=TEST_API_KEY)
|
||||||
class MailgunInboundTestCase(WebhookTestCase):
|
class MailgunInboundTestCase(WebhookTestCase):
|
||||||
def test_inbound_basics(self):
|
def test_inbound_basics(self):
|
||||||
raw_event = mailgun_sign({
|
raw_event = mailgun_sign_legacy_payload({
|
||||||
'token': '06c96bafc3f42a66b9edd546347a2fe18dc23461fe80dc52f0',
|
'token': '06c96bafc3f42a66b9edd546347a2fe18dc23461fe80dc52f0',
|
||||||
'timestamp': '1461261330',
|
'timestamp': '1461261330',
|
||||||
'recipient': 'test@inbound.example.com',
|
'recipient': 'test@inbound.example.com',
|
||||||
@@ -101,7 +101,7 @@ class MailgunInboundTestCase(WebhookTestCase):
|
|||||||
email_content = sample_email_content()
|
email_content = sample_email_content()
|
||||||
att3 = six.BytesIO(email_content)
|
att3 = six.BytesIO(email_content)
|
||||||
att3.content_type = 'message/rfc822; charset="us-ascii"'
|
att3.content_type = 'message/rfc822; charset="us-ascii"'
|
||||||
raw_event = mailgun_sign({
|
raw_event = mailgun_sign_legacy_payload({
|
||||||
'message-headers': '[]',
|
'message-headers': '[]',
|
||||||
'attachment-count': '3',
|
'attachment-count': '3',
|
||||||
'content-id-map': """{"<abc123>": "attachment-2"}""",
|
'content-id-map': """{"<abc123>": "attachment-2"}""",
|
||||||
@@ -133,7 +133,7 @@ class MailgunInboundTestCase(WebhookTestCase):
|
|||||||
|
|
||||||
def test_inbound_mime(self):
|
def test_inbound_mime(self):
|
||||||
# Mailgun provides the full, raw MIME message if the webhook url ends in 'mime'
|
# Mailgun provides the full, raw MIME message if the webhook url ends in 'mime'
|
||||||
raw_event = mailgun_sign({
|
raw_event = mailgun_sign_legacy_payload({
|
||||||
'token': '06c96bafc3f42a66b9edd546347a2fe18dc23461fe80dc52f0',
|
'token': '06c96bafc3f42a66b9edd546347a2fe18dc23461fe80dc52f0',
|
||||||
'timestamp': '1461261330',
|
'timestamp': '1461261330',
|
||||||
'recipient': 'test@inbound.example.com',
|
'recipient': 'test@inbound.example.com',
|
||||||
|
|||||||
@@ -16,14 +16,33 @@ from .webhook_cases import WebhookTestCase, WebhookBasicAuthTestsMixin
|
|||||||
TEST_API_KEY = 'TEST_API_KEY'
|
TEST_API_KEY = 'TEST_API_KEY'
|
||||||
|
|
||||||
|
|
||||||
def mailgun_sign(data, api_key=TEST_API_KEY):
|
def mailgun_signature(timestamp, token, api_key):
|
||||||
|
"""Generates a Mailgun webhook signature"""
|
||||||
|
# https://documentation.mailgun.com/en/latest/user_manual.html#securing-webhooks
|
||||||
|
return hmac.new(
|
||||||
|
key=api_key.encode('ascii'),
|
||||||
|
msg='{timestamp}{token}'.format(timestamp=timestamp, token=token).encode('ascii'),
|
||||||
|
digestmod=hashlib.sha256).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def mailgun_sign_payload(data, api_key=TEST_API_KEY):
|
||||||
|
"""Add or complete Mailgun webhook signature block in data dict"""
|
||||||
|
# Modifies the dict in place
|
||||||
|
event_data = data.get('event-data', {})
|
||||||
|
signature = data.setdefault('signature', {})
|
||||||
|
token = signature.setdefault('token', '1234567890abcdef1234567890abcdef')
|
||||||
|
timestamp = signature.setdefault('timestamp',
|
||||||
|
str(int(float(event_data.get('timestamp', '1234567890.123')))))
|
||||||
|
signature['signature'] = mailgun_signature(timestamp, token, api_key=api_key)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def mailgun_sign_legacy_payload(data, api_key=TEST_API_KEY):
|
||||||
"""Add a Mailgun webhook signature to data dict"""
|
"""Add a Mailgun webhook signature to data dict"""
|
||||||
# Modifies the dict in place
|
# Modifies the dict in place
|
||||||
data.setdefault('timestamp', '1234567890')
|
data.setdefault('timestamp', '1234567890')
|
||||||
data.setdefault('token', '1234567890abcdef1234567890abcdef')
|
data.setdefault('token', '1234567890abcdef1234567890abcdef')
|
||||||
data['signature'] = hmac.new(key=api_key.encode('ascii'),
|
data['signature'] = mailgun_signature(data['timestamp'], data['token'], api_key=api_key)
|
||||||
msg='{timestamp}{token}'.format(**data).encode('ascii'),
|
|
||||||
digestmod=hashlib.sha256).hexdigest()
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
@@ -42,8 +61,8 @@ def querydict_to_postdict(qd):
|
|||||||
class MailgunWebhookSettingsTestCase(WebhookTestCase):
|
class MailgunWebhookSettingsTestCase(WebhookTestCase):
|
||||||
def test_requires_api_key(self):
|
def test_requires_api_key(self):
|
||||||
with self.assertRaises(ImproperlyConfigured):
|
with self.assertRaises(ImproperlyConfigured):
|
||||||
self.client.post('/anymail/mailgun/tracking/',
|
self.client.post('/anymail/mailgun/tracking/', content_type="application/json",
|
||||||
data=mailgun_sign({'event': 'delivered'}))
|
data=json.dumps(mailgun_sign_payload({'event-data': {'event': 'delivered'}})))
|
||||||
|
|
||||||
|
|
||||||
@override_settings(ANYMAIL_MAILGUN_API_KEY=TEST_API_KEY)
|
@override_settings(ANYMAIL_MAILGUN_API_KEY=TEST_API_KEY)
|
||||||
@@ -51,32 +70,347 @@ class MailgunWebhookSecurityTestCase(WebhookTestCase, WebhookBasicAuthTestsMixin
|
|||||||
should_warn_if_no_auth = False # because we check webhook signature
|
should_warn_if_no_auth = False # because we check webhook signature
|
||||||
|
|
||||||
def call_webhook(self):
|
def call_webhook(self):
|
||||||
return self.client.post('/anymail/mailgun/tracking/',
|
return self.client.post('/anymail/mailgun/tracking/', content_type="application/json",
|
||||||
data=mailgun_sign({'event': 'delivered'}))
|
data=json.dumps(mailgun_sign_payload({'event-data': {'event': 'delivered'}})))
|
||||||
|
|
||||||
# Additional tests are in WebhookBasicAuthTestsMixin
|
# Additional tests are in WebhookBasicAuthTestsMixin
|
||||||
|
|
||||||
def test_verifies_correct_signature(self):
|
def test_verifies_correct_signature(self):
|
||||||
response = self.client.post('/anymail/mailgun/tracking/',
|
response = self.client.post('/anymail/mailgun/tracking/', content_type="application/json",
|
||||||
data=mailgun_sign({'event': 'delivered'}))
|
data=json.dumps(mailgun_sign_payload({'event-data': {'event': 'delivered'}})))
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
def test_verifies_missing_signature(self):
|
def test_verifies_missing_signature(self):
|
||||||
response = self.client.post('/anymail/mailgun/tracking/',
|
response = self.client.post('/anymail/mailgun/tracking/', content_type="application/json",
|
||||||
data={'event': 'delivered'})
|
data=json.dumps({'event-data': {'event': 'delivered'}}))
|
||||||
self.assertEqual(response.status_code, 400)
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
def test_verifies_bad_signature(self):
|
def test_verifies_bad_signature(self):
|
||||||
data = mailgun_sign({'event': 'delivered'}, api_key="wrong API key")
|
data = mailgun_sign_payload({'event-data': {'event': 'delivered'}},
|
||||||
response = self.client.post('/anymail/mailgun/tracking/', data=data)
|
api_key="wrong API key")
|
||||||
|
response = self.client.post('/anymail/mailgun/tracking/', content_type="application/json",
|
||||||
|
data=json.dumps(data))
|
||||||
self.assertEqual(response.status_code, 400)
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
|
||||||
@override_settings(ANYMAIL_MAILGUN_API_KEY=TEST_API_KEY)
|
@override_settings(ANYMAIL_MAILGUN_API_KEY=TEST_API_KEY)
|
||||||
class MailgunDeliveryTestCase(WebhookTestCase):
|
class MailgunTestCase(WebhookTestCase):
|
||||||
|
# Tests for Mailgun's new webhooks (announced 2018-06-29)
|
||||||
|
|
||||||
def test_delivered_event(self):
|
def test_delivered_event(self):
|
||||||
raw_event = mailgun_sign({
|
# This is an actual, complete (sanitized) "delivered" event as received from Mailgun.
|
||||||
|
# (For brevity, later tests omit several payload fields that aren't used by Anymail.)
|
||||||
|
raw_event = mailgun_sign_payload({
|
||||||
|
"signature": {
|
||||||
|
"timestamp": "1534108637",
|
||||||
|
"token": "651869375b9df3c98fc15c4889b102119add1235c38fc92824",
|
||||||
|
"signature": "...",
|
||||||
|
},
|
||||||
|
"event-data": {
|
||||||
|
"tags": [],
|
||||||
|
"timestamp": 1534108637.153125,
|
||||||
|
"storage": {
|
||||||
|
"url": "https://sw.api.mailgun.net/v3/domains/example.org/messages/eyJwI...",
|
||||||
|
"key": "eyJwI...",
|
||||||
|
},
|
||||||
|
"recipient-domain": "example.com",
|
||||||
|
"id": "hTWCTD81RtiDN-...",
|
||||||
|
"campaigns": [],
|
||||||
|
"user-variables": {},
|
||||||
|
"flags": {
|
||||||
|
"is-routed": False,
|
||||||
|
"is-authenticated": True,
|
||||||
|
"is-system-test": False,
|
||||||
|
"is-test-mode": False,
|
||||||
|
},
|
||||||
|
"log-level": "info",
|
||||||
|
"envelope": {
|
||||||
|
"sending-ip": "333.123.123.200",
|
||||||
|
"sender": "test@example.org",
|
||||||
|
"transport": "smtp",
|
||||||
|
"targets": "recipient@example.com",
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"headers": {
|
||||||
|
"to": "recipient@example.com",
|
||||||
|
"message-id": "20180812211713.1.DF5966851B4BAA99@example.org",
|
||||||
|
"from": "test@example.org",
|
||||||
|
"subject": "Testing",
|
||||||
|
},
|
||||||
|
"attachments": [],
|
||||||
|
"size": 809,
|
||||||
|
},
|
||||||
|
"recipient": "recipient@example.com",
|
||||||
|
"event": "delivered",
|
||||||
|
"delivery-status": {
|
||||||
|
"tls": True,
|
||||||
|
"mx-host": "smtp-in.example.com",
|
||||||
|
"attempt-no": 1,
|
||||||
|
"description": "",
|
||||||
|
"session-seconds": 3.5700838565826416,
|
||||||
|
"utf8": True,
|
||||||
|
"code": 250,
|
||||||
|
"message": "OK",
|
||||||
|
"certificate-verified": True,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
response = self.client.post('/anymail/mailgun/tracking/',
|
||||||
|
data=json.dumps(raw_event), content_type='application/json')
|
||||||
|
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(2018, 8, 12, 21, 17, 17, microsecond=153125, tzinfo=utc))
|
||||||
|
self.assertEqual(event.message_id, "<20180812211713.1.DF5966851B4BAA99@example.org>")
|
||||||
|
# Note that Anymail uses the "token" as its normalized event_id:
|
||||||
|
self.assertEqual(event.event_id, "651869375b9df3c98fc15c4889b102119add1235c38fc92824")
|
||||||
|
# ... if you want the Mailgun "event id", that's available through the raw esp_event:
|
||||||
|
self.assertEqual(event.esp_event["event-data"]["id"], "hTWCTD81RtiDN-...")
|
||||||
|
self.assertEqual(event.recipient, "recipient@example.com")
|
||||||
|
self.assertEqual(event.esp_event, raw_event)
|
||||||
|
self.assertEqual(event.tags, [])
|
||||||
|
self.assertEqual(event.metadata, {})
|
||||||
|
|
||||||
|
def test_failed_permanent_event(self):
|
||||||
|
raw_event = mailgun_sign_payload({
|
||||||
|
"event-data": {
|
||||||
|
"event": "failed",
|
||||||
|
"severity": "permanent",
|
||||||
|
"reason": "bounce",
|
||||||
|
"recipient": "invalid@example.com",
|
||||||
|
"timestamp": 1534110422.389832,
|
||||||
|
"log-level": "error",
|
||||||
|
"message": {
|
||||||
|
"headers": {
|
||||||
|
"to": "invalid@example.com",
|
||||||
|
"message-id": "20180812214658.1.0DF563D0B3597700@example.org",
|
||||||
|
"from": "Test Sender ",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"delivery-status": {
|
||||||
|
"tls": True,
|
||||||
|
"mx-host": "aspmx.l.example.org",
|
||||||
|
"attempt-no": 1,
|
||||||
|
"description": "",
|
||||||
|
"session-seconds": 2.952177047729492,
|
||||||
|
"utf8": True,
|
||||||
|
"code": 550,
|
||||||
|
"message": "5.1.1 The email account that you tried to reach does not exist. Please try\n"
|
||||||
|
"5.1.1 double-checking the recipient's email address for typos",
|
||||||
|
"certificate-verified": True
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
response = self.client.post('/anymail/mailgun/tracking/',
|
||||||
|
data=json.dumps(raw_event), content_type='application/json')
|
||||||
|
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.recipient, "invalid@example.com")
|
||||||
|
self.assertEqual(event.reject_reason, "bounced")
|
||||||
|
self.assertEqual(event.description, "")
|
||||||
|
self.assertEqual(event.mta_response,
|
||||||
|
"5.1.1 The email account that you tried to reach does not exist. Please try\n"
|
||||||
|
"5.1.1 double-checking the recipient's email address for typos")
|
||||||
|
|
||||||
|
def test_failed_temporary_event(self):
|
||||||
|
raw_event = mailgun_sign_payload({
|
||||||
|
"event-data": {
|
||||||
|
"event": "failed",
|
||||||
|
"severity": "temporary",
|
||||||
|
"reason": "generic",
|
||||||
|
"timestamp": 1534111899.659519,
|
||||||
|
"log-level": "warn",
|
||||||
|
"message": {
|
||||||
|
"headers": {
|
||||||
|
"to": "undeliverable@nomx.example.com",
|
||||||
|
"message-id": "20180812214638.1.4A7D468E9BC18C5D@example.org",
|
||||||
|
"from": "Test Sender ",
|
||||||
|
"subject": "Testing"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"recipient": "undeliverable@nomx.example.com",
|
||||||
|
"delivery-status": {
|
||||||
|
"attempt-no": 3,
|
||||||
|
"description": "No MX for nomx.example.com",
|
||||||
|
"session-seconds": 0.0,
|
||||||
|
"retry-seconds": 1800,
|
||||||
|
"code": 498,
|
||||||
|
"message": "No MX for nomx.example.com"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
response = self.client.post('/anymail/mailgun/tracking/',
|
||||||
|
data=json.dumps(raw_event), content_type='application/json')
|
||||||
|
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.recipient, "undeliverable@nomx.example.com")
|
||||||
|
self.assertEqual(event.reject_reason, "bounced")
|
||||||
|
self.assertEqual(event.description, "No MX for nomx.example.com")
|
||||||
|
self.assertEqual(event.mta_response, "No MX for nomx.example.com")
|
||||||
|
|
||||||
|
def test_rejected_event(self):
|
||||||
|
# (The "rejected" event is documented and appears in Mailgun dashboard logs,
|
||||||
|
# but it doesn't appear to be delivered through webhooks as of 8/2018.)
|
||||||
|
# Note that this payload lacks the recipient field present in all other events.
|
||||||
|
raw_event = mailgun_sign_payload({
|
||||||
|
"event-data": {
|
||||||
|
"event": "rejected",
|
||||||
|
"timestamp": 1529704976.104692,
|
||||||
|
"log-level": "warn",
|
||||||
|
"reject": {
|
||||||
|
"reason": "Sandbox subdomains are for test purposes only.",
|
||||||
|
"description": "",
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"headers": {
|
||||||
|
"to": "Recipient Name <recipient@example.org>",
|
||||||
|
"message-id": "20180622220256.1.B31A451A2E5422BB@sandbox55887.mailgun.org",
|
||||||
|
"from": "test@sandbox55887.mailgun.org",
|
||||||
|
"subject": "Test Subject"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
response = self.client.post('/anymail/mailgun/tracking/',
|
||||||
|
data=json.dumps(raw_event), content_type='application/json')
|
||||||
|
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, "other")
|
||||||
|
self.assertEqual(event.description, "Sandbox subdomains are for test purposes only.")
|
||||||
|
self.assertEqual(event.recipient, "recipient@example.org")
|
||||||
|
|
||||||
|
def test_complained_event(self):
|
||||||
|
raw_event = mailgun_sign_payload({
|
||||||
|
"event-data": {
|
||||||
|
"event": "complained",
|
||||||
|
"id": "ncV2XwymRUKbPek_MIM-Gw",
|
||||||
|
"timestamp": 1377214260.049634,
|
||||||
|
"log-level": "warn",
|
||||||
|
"recipient": "recipient@example.com",
|
||||||
|
"message": {
|
||||||
|
"headers": {
|
||||||
|
"to": "foo@recipient.com",
|
||||||
|
"message-id": "20130718032413.263EE2E0926@example.org",
|
||||||
|
"from": "Sender Name <sender@example.org>",
|
||||||
|
"subject": "We are not spammer",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
response = self.client.post('/anymail/mailgun/tracking/',
|
||||||
|
data=json.dumps(raw_event), content_type='application/json')
|
||||||
|
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, "complained")
|
||||||
|
self.assertEqual(event.recipient, "recipient@example.com")
|
||||||
|
|
||||||
|
def test_unsubscribed_event(self):
|
||||||
|
raw_event = mailgun_sign_payload({
|
||||||
|
"event-data": {
|
||||||
|
"event": "unsubscribed",
|
||||||
|
"id": "W3X4JOhFT-OZidZGKKr9iA",
|
||||||
|
"timestamp": 1377213791.421473,
|
||||||
|
"log-level": "info",
|
||||||
|
"recipient": "recipient@example.com",
|
||||||
|
"message": {
|
||||||
|
"headers": {
|
||||||
|
"message-id": "20130822232216.13966.79700@samples.mailgun.org"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
response = self.client.post('/anymail/mailgun/tracking/',
|
||||||
|
data=json.dumps(raw_event), content_type='application/json')
|
||||||
|
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, "unsubscribed")
|
||||||
|
self.assertEqual(event.recipient, "recipient@example.com")
|
||||||
|
|
||||||
|
def test_opened_event(self):
|
||||||
|
raw_event = mailgun_sign_payload({
|
||||||
|
"event-data": {
|
||||||
|
"event": "opened",
|
||||||
|
"timestamp": 1534109600.089676,
|
||||||
|
"recipient": "recipient@example.com",
|
||||||
|
"tags": ["welcome", "variation-A"],
|
||||||
|
"user-variables": {
|
||||||
|
"cohort": "2018-08-B",
|
||||||
|
"user_id": "123456"
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
# Mailgun *only* includes the message-id header for opened, clicked events...
|
||||||
|
"headers": {
|
||||||
|
"message-id": "20180812213139.1.BC6694A917BB7E6A@example.org"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"geolocation": {
|
||||||
|
"country": "US",
|
||||||
|
"region": "CA",
|
||||||
|
"city": "San Francisco"
|
||||||
|
},
|
||||||
|
"ip": "888.222.444.111",
|
||||||
|
"client-info": {
|
||||||
|
"client-type": "browser",
|
||||||
|
"client-os": "OS X",
|
||||||
|
"device-type": "desktop",
|
||||||
|
"client-name": "Chrome",
|
||||||
|
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6)..."
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
response = self.client.post('/anymail/mailgun/tracking/',
|
||||||
|
data=json.dumps(raw_event), content_type='application/json')
|
||||||
|
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, "opened")
|
||||||
|
self.assertEqual(event.recipient, "recipient@example.com")
|
||||||
|
self.assertEqual(event.tags, ["welcome", "variation-A"])
|
||||||
|
self.assertEqual(event.metadata, {"cohort": "2018-08-B", "user_id": "123456"})
|
||||||
|
|
||||||
|
def test_clicked_event(self):
|
||||||
|
raw_event = mailgun_sign_payload({
|
||||||
|
"event-data": {
|
||||||
|
"event": "clicked",
|
||||||
|
"timestamp": 1534109600.089676,
|
||||||
|
"recipient": "recipient@example.com",
|
||||||
|
"url": "https://example.com/test"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
response = self.client.post('/anymail/mailgun/tracking/',
|
||||||
|
data=json.dumps(raw_event), content_type='application/json')
|
||||||
|
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, "clicked")
|
||||||
|
self.assertEqual(event.click_url, "https://example.com/test")
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(ANYMAIL_MAILGUN_API_KEY=TEST_API_KEY)
|
||||||
|
class MailgunLegacyTestCase(WebhookTestCase):
|
||||||
|
# Tests for Mailgun's "legacy" webhooks
|
||||||
|
# (which were the only webhooks available prior to Anymail 4.0)
|
||||||
|
|
||||||
|
def test_delivered_event(self):
|
||||||
|
raw_event = mailgun_sign_legacy_payload({
|
||||||
'domain': 'example.com',
|
'domain': 'example.com',
|
||||||
'message-headers': json.dumps([
|
'message-headers': json.dumps([
|
||||||
["Sender", "from=example.com"],
|
["Sender", "from=example.com"],
|
||||||
@@ -113,7 +447,7 @@ class MailgunDeliveryTestCase(WebhookTestCase):
|
|||||||
self.assertEqual(event.metadata, {})
|
self.assertEqual(event.metadata, {})
|
||||||
|
|
||||||
def test_dropped_bounce(self):
|
def test_dropped_bounce(self):
|
||||||
raw_event = mailgun_sign({
|
raw_event = mailgun_sign_legacy_payload({
|
||||||
'code': '605',
|
'code': '605',
|
||||||
'domain': 'example.com',
|
'domain': 'example.com',
|
||||||
'description': 'Not delivering to previously bounced address',
|
'description': 'Not delivering to previously bounced address',
|
||||||
@@ -152,7 +486,7 @@ class MailgunDeliveryTestCase(WebhookTestCase):
|
|||||||
self.assertEqual(querydict_to_postdict(event.esp_event), raw_event)
|
self.assertEqual(querydict_to_postdict(event.esp_event), raw_event)
|
||||||
|
|
||||||
def test_dropped_spam(self):
|
def test_dropped_spam(self):
|
||||||
raw_event = mailgun_sign({
|
raw_event = mailgun_sign_legacy_payload({
|
||||||
'code': '607',
|
'code': '607',
|
||||||
'description': 'Not delivering to a user who marked your messages as spam',
|
'description': 'Not delivering to a user who marked your messages as spam',
|
||||||
'reason': 'hardfail',
|
'reason': 'hardfail',
|
||||||
@@ -170,7 +504,7 @@ class MailgunDeliveryTestCase(WebhookTestCase):
|
|||||||
self.assertEqual(event.description, 'Not delivering to a user who marked your messages as spam')
|
self.assertEqual(event.description, 'Not delivering to a user who marked your messages as spam')
|
||||||
|
|
||||||
def test_dropped_timed_out(self):
|
def test_dropped_timed_out(self):
|
||||||
raw_event = mailgun_sign({
|
raw_event = mailgun_sign_legacy_payload({
|
||||||
'code': '499',
|
'code': '499',
|
||||||
'description': 'Unable to connect to MX servers: [example.com]',
|
'description': 'Unable to connect to MX servers: [example.com]',
|
||||||
'reason': 'old',
|
'reason': 'old',
|
||||||
@@ -188,7 +522,7 @@ class MailgunDeliveryTestCase(WebhookTestCase):
|
|||||||
self.assertEqual(event.description, 'Unable to connect to MX servers: [example.com]')
|
self.assertEqual(event.description, 'Unable to connect to MX servers: [example.com]')
|
||||||
|
|
||||||
def test_invalid_mailbox(self):
|
def test_invalid_mailbox(self):
|
||||||
raw_event = mailgun_sign({
|
raw_event = mailgun_sign_legacy_payload({
|
||||||
'code': '550',
|
'code': '550',
|
||||||
'error': "550 5.1.1 The email account that you tried to reach does not exist. Please try "
|
'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 double-checking the recipient's email address for typos or "
|
||||||
@@ -209,7 +543,7 @@ class MailgunDeliveryTestCase(WebhookTestCase):
|
|||||||
def test_alt_smtp_code(self):
|
def test_alt_smtp_code(self):
|
||||||
# In some cases, Mailgun uses RFC-3463 extended SMTP status codes (x.y.z, rather than nnn).
|
# In some cases, Mailgun uses RFC-3463 extended SMTP status codes (x.y.z, rather than nnn).
|
||||||
# See issue #62.
|
# See issue #62.
|
||||||
raw_event = mailgun_sign({
|
raw_event = mailgun_sign_legacy_payload({
|
||||||
'code': '5.1.1',
|
'code': '5.1.1',
|
||||||
'error': 'smtp;550 5.1.1 RESOLVER.ADR.RecipNotFound; not found',
|
'error': 'smtp;550 5.1.1 RESOLVER.ADR.RecipNotFound; not found',
|
||||||
'event': 'bounced',
|
'event': 'bounced',
|
||||||
@@ -228,7 +562,7 @@ class MailgunDeliveryTestCase(WebhookTestCase):
|
|||||||
def test_metadata_message_headers(self):
|
def test_metadata_message_headers(self):
|
||||||
# Metadata fields are interspersed with other data, but also in message-headers
|
# Metadata fields are interspersed with other data, but also in message-headers
|
||||||
# for delivered, bounced and dropped events
|
# for delivered, bounced and dropped events
|
||||||
raw_event = mailgun_sign({
|
raw_event = mailgun_sign_legacy_payload({
|
||||||
'event': 'delivered',
|
'event': 'delivered',
|
||||||
'message-headers': json.dumps([
|
'message-headers': json.dumps([
|
||||||
["X-Mailgun-Variables", "{\"custom1\": \"value1\", \"custom2\": \"{\\\"key\\\":\\\"value\\\"}\"}"],
|
["X-Mailgun-Variables", "{\"custom1\": \"value1\", \"custom2\": \"{\\\"key\\\":\\\"value\\\"}\"}"],
|
||||||
@@ -244,7 +578,7 @@ class MailgunDeliveryTestCase(WebhookTestCase):
|
|||||||
def test_metadata_post_fields(self):
|
def test_metadata_post_fields(self):
|
||||||
# Metadata fields are only interspersed with other event params
|
# Metadata fields are only interspersed with other event params
|
||||||
# for opened, clicked, unsubscribed events
|
# for opened, clicked, unsubscribed events
|
||||||
raw_event = mailgun_sign({
|
raw_event = mailgun_sign_legacy_payload({
|
||||||
'event': 'clicked',
|
'event': 'clicked',
|
||||||
'custom1': 'value1',
|
'custom1': 'value1',
|
||||||
'custom2': '{"key":"value"}', # you can store JSON, but you'll need to unpack it yourself
|
'custom2': '{"key":"value"}', # you can store JSON, but you'll need to unpack it yourself
|
||||||
@@ -267,7 +601,7 @@ class MailgunDeliveryTestCase(WebhookTestCase):
|
|||||||
"ordinary field": "ordinary metadata value",
|
"ordinary field": "ordinary metadata value",
|
||||||
}
|
}
|
||||||
|
|
||||||
raw_event = mailgun_sign({
|
raw_event = mailgun_sign_legacy_payload({
|
||||||
'event': 'clicked',
|
'event': 'clicked',
|
||||||
'recipient': 'actual-recipient@example.com',
|
'recipient': 'actual-recipient@example.com',
|
||||||
'token': 'actual-event-token',
|
'token': 'actual-event-token',
|
||||||
@@ -305,7 +639,7 @@ class MailgunDeliveryTestCase(WebhookTestCase):
|
|||||||
|
|
||||||
def test_tags(self):
|
def test_tags(self):
|
||||||
# Most events include multiple 'tag' fields for message's tags
|
# Most events include multiple 'tag' fields for message's tags
|
||||||
raw_event = mailgun_sign({
|
raw_event = mailgun_sign_legacy_payload({
|
||||||
'tag': ['tag1', 'tag2'], # Django TestClient encodes list as multiple field values
|
'tag': ['tag1', 'tag2'], # Django TestClient encodes list as multiple field values
|
||||||
'event': 'opened',
|
'event': 'opened',
|
||||||
})
|
})
|
||||||
@@ -316,7 +650,7 @@ class MailgunDeliveryTestCase(WebhookTestCase):
|
|||||||
|
|
||||||
def test_x_tags(self):
|
def test_x_tags(self):
|
||||||
# Delivery events don't include 'tag', but do include 'X-Mailgun-Tag' fields
|
# Delivery events don't include 'tag', but do include 'X-Mailgun-Tag' fields
|
||||||
raw_event = mailgun_sign({
|
raw_event = mailgun_sign_legacy_payload({
|
||||||
'X-Mailgun-Tag': ['tag1', 'tag2'],
|
'X-Mailgun-Tag': ['tag1', 'tag2'],
|
||||||
'event': 'delivered',
|
'event': 'delivered',
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user