File: sparkpost.py

package info (click to toggle)
django-anymail 0.8-2%2Bdeb9u1
  • links: PTS, VCS
  • area: contrib
  • in suites: stretch
  • size: 404 kB
  • sloc: python: 2,530; makefile: 4
file content (136 lines) | stat: -rw-r--r-- 6,116 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
import json
from datetime import datetime

from django.utils.timezone import utc

from .base import AnymailBaseWebhookView
from ..exceptions import AnymailConfigurationError
from ..signals import tracking, AnymailTrackingEvent, EventType, RejectReason


class SparkPostBaseWebhookView(AnymailBaseWebhookView):
    """Base view class for SparkPost webhooks"""

    def parse_events(self, request):
        raw_events = json.loads(request.body.decode('utf-8'))
        unwrapped_events = [self.unwrap_event(raw_event) for raw_event in raw_events]
        return [
            self.esp_to_anymail_event(event_class, event, raw_event)
            for (event_class, event, raw_event) in unwrapped_events
            if event is not None  # filter out empty "ping" events
        ]

    def unwrap_event(self, raw_event):
        """Unwraps SparkPost event structure, and returns event_class, event, raw_event

        raw_event is of form {'msys': {event_class: {...event...}}}

        Can return None, None, raw_event for SparkPost "ping" raw_event={'msys': {}}
        """
        event_classes = raw_event['msys'].keys()
        try:
            (event_class,) = event_classes
            event = raw_event['msys'][event_class]
        except ValueError:  # too many/not enough event_classes to unpack
            if len(event_classes) == 0:
                # Empty event (SparkPost sometimes sends as a "ping")
                event_class = event = None
            else:
                raise TypeError("Invalid SparkPost webhook event has multiple event classes: %r" % raw_event)
        return event_class, event, raw_event

    def esp_to_anymail_event(self, event_class, event, raw_event):
        raise NotImplementedError()


class SparkPostTrackingWebhookView(SparkPostBaseWebhookView):
    """Handler for SparkPost message, engagement, and generation event webhooks"""

    signal = tracking

    event_types = {
        # Map SparkPost event.type: Anymail normalized type
        'bounce': EventType.BOUNCED,
        'delivery': EventType.DELIVERED,
        'injection': EventType.QUEUED,
        'spam_complaint': EventType.COMPLAINED,
        'out_of_band': EventType.BOUNCED,
        'policy_rejection': EventType.REJECTED,
        'delay': EventType.DEFERRED,
        'click': EventType.CLICKED,
        'open': EventType.OPENED,
        'generation_failure': EventType.FAILED,
        'generation_rejection': EventType.REJECTED,
        'list_unsubscribe': EventType.UNSUBSCRIBED,
        'link_unsubscribe': EventType.UNSUBSCRIBED,
    }

    reject_reasons = {
        # Map SparkPost event.bounce_class: Anymail normalized reject reason.
        # Can also supply (RejectReason, EventType) for bounce_class that affects our event_type.
        # https://support.sparkpost.com/customer/portal/articles/1929896
        '1': RejectReason.OTHER,     # Undetermined (response text could not be identified)
        '10': RejectReason.INVALID,  # Invalid Recipient
        '20': RejectReason.BOUNCED,  # Soft Bounce
        '21': RejectReason.BOUNCED,  # DNS Failure
        '22': RejectReason.BOUNCED,  # Mailbox Full
        '23': RejectReason.BOUNCED,  # Too Large
        '24': RejectReason.TIMED_OUT,  # Timeout
        '25': RejectReason.BLOCKED,  # Admin Failure (configured policies)
        '30': RejectReason.BOUNCED,  # Generic Bounce: No RCPT
        '40': RejectReason.BOUNCED,  # Generic Bounce: unspecified reasons
        '50': RejectReason.BLOCKED,  # Mail Block (by the receiver)
        '51': RejectReason.SPAM,     # Spam Block (by the receiver)
        '52': RejectReason.SPAM,     # Spam Content (by the receiver)
        '53': RejectReason.OTHER,    # Prohibited Attachment (by the receiver)
        '54': RejectReason.BLOCKED,  # Relaying Denied (by the receiver)
        '60': (RejectReason.OTHER, EventType.AUTORESPONDED),  # Auto-Reply/vacation
        '70': RejectReason.BOUNCED,  # Transient Failure
        '80': (RejectReason.OTHER, EventType.SUBSCRIBED),  # Subscribe
        '90': (RejectReason.UNSUBSCRIBED, EventType.UNSUBSCRIBED),  # Unsubscribe
        '100': (RejectReason.OTHER, EventType.AUTORESPONDED),  # Challenge-Response
    }

    def esp_to_anymail_event(self, event_class, event, raw_event):
        if event_class == 'relay_event':
            # This is an inbound event
            raise AnymailConfigurationError(
                "You seem to have set SparkPost's *inbound* relay webhook URL "
                "to Anymail's SparkPost *tracking* webhook URL.")

        event_type = self.event_types.get(event['type'], EventType.UNKNOWN)
        try:
            timestamp = datetime.fromtimestamp(int(event['timestamp']), tz=utc)
        except (KeyError, TypeError, ValueError):
            timestamp = None

        try:
            tag = event['campaign_id']  # not 'rcpt_tags' -- those don't come from sending a message
            tags = [tag] if tag else None
        except KeyError:
            tags = None

        try:
            reject_reason = self.reject_reasons.get(event['bounce_class'], RejectReason.OTHER)
            try:  # unpack (RejectReason, EventType) for reasons that change our event type
                reject_reason, event_type = reject_reason
            except ValueError:
                pass
        except KeyError:
            reject_reason = None  # no bounce_class

        return AnymailTrackingEvent(
            event_type=event_type,
            timestamp=timestamp,
            message_id=event.get('transmission_id', None),  # not 'message_id' -- see SparkPost backend
            event_id=event.get('event_id', None),
            recipient=event.get('raw_rcpt_to', None),  # preserves email case (vs. 'rcpt_to')
            reject_reason=reject_reason,
            mta_response=event.get('raw_reason', None),
            # description=???,
            tags=tags,
            metadata=event.get('rcpt_meta', None) or None,  # message + recipient metadata
            click_url=event.get('target_link_url', None),
            user_agent=event.get('user_agent', None),
            esp_event=raw_event,
        )