mirror of
https://github.com/pacnpal/django-anymail.git
synced 2025-12-20 11:51:05 -05:00
224 lines
8.9 KiB
Python
224 lines
8.9 KiB
Python
import json
|
|
from base64 import b64decode
|
|
from datetime import datetime, timezone
|
|
|
|
from ..exceptions import AnymailConfigurationError
|
|
from ..inbound import AnymailInboundMessage
|
|
from ..signals import (
|
|
AnymailInboundEvent,
|
|
AnymailTrackingEvent,
|
|
EventType,
|
|
RejectReason,
|
|
inbound,
|
|
tracking,
|
|
)
|
|
from ..utils import get_anymail_setting
|
|
from .base import AnymailBaseWebhookView
|
|
|
|
|
|
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,
|
|
"amp_click": EventType.CLICKED,
|
|
"amp_open": EventType.OPENED,
|
|
"generation_failure": EventType.FAILED,
|
|
"generation_rejection": EventType.REJECTED,
|
|
"list_unsubscribe": EventType.UNSUBSCRIBED,
|
|
"link_unsubscribe": EventType.UNSUBSCRIBED,
|
|
}
|
|
|
|
# Additional event_types mapping when Anymail setting
|
|
# SPARKPOST_TRACK_INITIAL_OPEN_AS_OPENED is enabled.
|
|
initial_open_event_types = {
|
|
"initial_open": EventType.OPENED,
|
|
"amp_initial_open": EventType.OPENED,
|
|
}
|
|
|
|
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 __init__(self, **kwargs):
|
|
# Set Anymail setting SPARKPOST_TRACK_INITIAL_OPEN_AS_OPENED True
|
|
# to report *both* "open" and "initial_open" as Anymail "opened" events.
|
|
# (Otherwise only "open" maps to "opened", matching the behavior of most
|
|
# other ESPs.) Handling "initial_open" is opt-in, to help avoid duplicate
|
|
# "opened" events on the same first open.
|
|
track_initial_open_as_opened = get_anymail_setting(
|
|
"track_initial_open_as_opened",
|
|
default=False,
|
|
esp_name=self.esp_name,
|
|
kwargs=kwargs,
|
|
)
|
|
if track_initial_open_as_opened:
|
|
self.event_types = {**self.event_types, **self.initial_open_event_types}
|
|
super().__init__(**kwargs)
|
|
|
|
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=timezone.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,
|
|
# use transmission_id, not message_id -- see SparkPost backend
|
|
message_id=event.get("transmission_id", None),
|
|
event_id=event.get("event_id", None),
|
|
# raw_rcpt_to preserves email case (vs. rcpt_to)
|
|
recipient=event.get("raw_rcpt_to", None),
|
|
reject_reason=reject_reason,
|
|
mta_response=event.get("raw_reason", None),
|
|
# description=???,
|
|
tags=tags,
|
|
# metadata includes message + recipient metadata
|
|
metadata=event.get("rcpt_meta", None) or {},
|
|
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,
|
|
# SparkPost does not provide a relay event timestamp
|
|
timestamp=None,
|
|
# SparkPost does not provide an idempotent id for relay events
|
|
event_id=None,
|
|
esp_event=raw_event,
|
|
message=message,
|
|
)
|