From 48de044c9ba4bc2c0ffb3870ae0aa2198290f34b Mon Sep 17 00:00:00 2001 From: puru02 <51311121+puru02@users.noreply.github.com> Date: Wed, 8 Jun 2022 05:58:39 +0530 Subject: [PATCH] Fix: Postmark tracking webhook handle SubscriptionChange events Handle Postmark SubscriptionChange events as Anymail unsubscribe, subscribe, or bounce Anymail tracking events. --- CHANGELOG.rst | 14 +++++ anymail/webhooks/postmark.py | 14 ++++- tests/test_postmark_webhooks.py | 93 +++++++++++++++++++++++++++++++++ 3 files changed, 120 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 206c615..61d3c6c 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -25,6 +25,19 @@ Release history ^^^^^^^^^^^^^^^ .. This extra heading level keeps the ToC from becoming unmanageably long +vNext +----- + +*Unreleased changes* + +Fixes +~~~~~ + +* **Postmark:** Handle Postmark's SubscriptionChange events as Anymail + unsubscribe, subscribe, or bounce tracking events, rather than "unknown". + (Thanks to `@puru02`_ for the fix.) + + v8.6 LTS -------- @@ -1344,6 +1357,7 @@ Features .. _@mbk-ok: https://github.com/mbk-ok .. _@mwheels: https://github.com/mwheels .. _@nuschk: https://github.com/nuschk +.. _@puru02: https://github.com/puru02 .. _@RignonNoel: https://github.com/RignonNoel .. _@sebashwa: https://github.com/sebashwa .. _@sebbacon: https://github.com/sebbacon diff --git a/anymail/webhooks/postmark.py b/anymail/webhooks/postmark.py index a46782b..ba45841 100644 --- a/anymail/webhooks/postmark.py +++ b/anymail/webhooks/postmark.py @@ -34,6 +34,7 @@ class PostmarkTrackingWebhookView(PostmarkBaseWebhookView): 'Delivery': EventType.DELIVERED, 'Open': EventType.OPENED, 'SpamComplaint': EventType.COMPLAINED, + 'SubscriptionChange': EventType.UNSUBSCRIBED, 'Inbound': EventType.INBOUND, # future, probably } @@ -61,6 +62,7 @@ class PostmarkTrackingWebhookView(PostmarkBaseWebhookView): 'InboundError': (EventType.INBOUND_FAILED, None), 'DMARCPolicy': (EventType.REJECTED, RejectReason.BLOCKED), 'TemplateRenderingFailed': (EventType.FAILED, None), + 'ManualSuppression': (EventType.UNSUBSCRIBED, RejectReason.UNSUBSCRIBED), } def esp_to_anymail_event(self, esp_event): @@ -87,11 +89,21 @@ class PostmarkTrackingWebhookView(PostmarkBaseWebhookView): event_type, reject_reason = self.event_types[esp_event['Type']] except KeyError: pass + if event_type == EventType.UNSUBSCRIBED: + if esp_event['SuppressSending']: + # Postmark doesn't provide a way to distinguish between + # explicit unsubscribes and bounces + try: + event_type, reject_reason = self.event_types[esp_event['SuppressionReason']] + except KeyError: + pass + else: + event_type, reject_reason = self.event_types['Subscribe'] recipient = getfirst(esp_event, ['Email', 'Recipient'], None) # Email for bounce; Recipient for open try: - timestr = getfirst(esp_event, ['DeliveredAt', 'BouncedAt', 'ReceivedAt']) + timestr = getfirst(esp_event, ['DeliveredAt', 'BouncedAt', 'ReceivedAt', 'ChangedAt']) except KeyError: timestamp = None else: diff --git a/tests/test_postmark_webhooks.py b/tests/test_postmark_webhooks.py index bbf12ee..22e935c 100644 --- a/tests/test_postmark_webhooks.py +++ b/tests/test_postmark_webhooks.py @@ -220,3 +220,96 @@ class PostmarkDeliveryTestCase(WebhookTestCase): with self.assertRaisesMessage(AnymailConfigurationError, errmsg): self.client.post('/anymail/postmark/tracking/', content_type='application/json', data=json.dumps({"RecordType": "Inbound"})) + + def test_unsubscribe(self): + raw_event = { + "RecordType": "SubscriptionChange", + "MessageID": "a4909a96-73d7-4c49-b148-a54522d3f7ac", + "ServerID": 23, + "MessageStream": "outbound", + "ChangedAt": "2022-06-05T17:17:32Z", + "Recipient": "john@example.com", + "Origin": "Recipient", + "SuppressSending": True, + "SuppressionReason": "ManualSuppression", + "Tag": "welcome-email", + "Metadata": { + "example": "value", + "example_2": "value" + } + } + response = self.client.post('/anymail/postmark/tracking/', + 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, "unsubscribed") + self.assertEqual(event.esp_event, raw_event) + self.assertEqual(event.timestamp, datetime(2022, 6, 5, 17, 17, 32, tzinfo=utc)) + self.assertEqual(event.message_id, "a4909a96-73d7-4c49-b148-a54522d3f7ac") + self.assertEqual(event.recipient, "john@example.com",) + self.assertEqual(event.reject_reason, "unsubscribed") + + def test_resubscribe(self): + raw_event = { + "RecordType": "SubscriptionChange", + "MessageID": "a4909a96-73d7-4c49-b148-a54522d3f7ac", + "ServerID": 23, + "MessageStream": "outbound", + "ChangedAt": "2022-06-05T17:17:32Z", + "Recipient": "john@example.com", + "Origin": "Recipient", + "SuppressSending": False, + "SuppressionReason": None, + "Tag": "welcome-email", + "Metadata": { + "example": "value", + "example_2": "value" + } + } + response = self.client.post('/anymail/postmark/tracking/', + 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, "subscribed") + self.assertEqual(event.esp_event, raw_event) + self.assertEqual(event.timestamp, datetime(2022, 6, 5, 17, 17, 32, tzinfo=utc)) + self.assertEqual(event.message_id, "a4909a96-73d7-4c49-b148-a54522d3f7ac") + self.assertEqual(event.recipient, "john@example.com",) + self.assertEqual(event.reject_reason, None) + + def test_subscription_change_bounce(self): + raw_event = { + "RecordType": "SubscriptionChange", + "MessageID": "b4cb783d-78ed-43f2-983b-63f55c712dc8", + "ServerID": 23, + "MessageStream": "outbound", + "ChangedAt": "2022-06-05T17:17:32Z", + "Recipient": "john@example.com", + "Origin": "Recipient", + "SuppressSending": True, + "SuppressionReason": "HardBounce", + "Tag": "my-tag", + "Metadata": { + "example": "value", + "example_2": "value" + } + } + response = self.client.post('/anymail/postmark/tracking/', + 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(2022, 6, 5, 17, 17, 32, tzinfo=utc)) + self.assertEqual(event.message_id, "b4cb783d-78ed-43f2-983b-63f55c712dc8") + self.assertEqual(event.recipient, "john@example.com",) + self.assertEqual(event.reject_reason, "bounced")