import json from base64 import b64decode from datetime import datetime from django.utils.timezone import utc from .base import AnymailBaseWebhookView from ..exceptions import AnymailConfigurationError from ..inbound import AnymailInboundMessage from ..signals import inbound, tracking, AnymailInboundEvent, AnymailTrackingEvent, EventType, RejectReason class SparkPostBaseWebhookView(AnymailBaseWebhookView): """Base view class for SparkPost webhooks""" esp_name = "SparkPost" 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) from None 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_message': # 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 = [] 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 {}, # message + recipient metadata click_url=event.get('target_link_url', None), user_agent=event.get('user_agent', None), esp_event=raw_event, ) class SparkPostInboundWebhookView(SparkPostBaseWebhookView): """Handler for SparkPost inbound relay webhook""" signal = inbound def esp_to_anymail_event(self, event_class, event, raw_event): if event_class != 'relay_message': # This is not an inbound event raise AnymailConfigurationError( "You seem to have set SparkPost's *tracking* webhook URL " "to Anymail's SparkPost *inbound* relay webhook URL.") if event['protocol'] != 'smtp': raise AnymailConfigurationError( "You cannot use Anymail's webhooks for SparkPost '{protocol}' relay events. " "Anymail only handles the 'smtp' protocol".format(protocol=event['protocol'])) raw_mime = event['content']['email_rfc822'] if event['content']['email_rfc822_is_base64']: raw_mime = b64decode(raw_mime).decode('utf-8') message = AnymailInboundMessage.parse_raw_mime(raw_mime) message.envelope_sender = event.get('msg_from', None) message.envelope_recipient = event.get('rcpt_to', None) return AnymailInboundEvent( event_type=EventType.INBOUND, timestamp=None, # SparkPost does not provide a relay event timestamp event_id=None, # SparkPost does not provide an idempotent id for relay events esp_event=raw_event, message=message, )