mirror of
https://github.com/pacnpal/django-anymail.git
synced 2025-12-20 03:41:05 -05:00
Unisender Go: Fix status tracking webhook and tests.
- Fix signature checking to avoid false validation errors on webhook payloads including `/` (including all "clicked" and most "opened" events). And in general, avoid depending on specific details of Unisender Go's JSON serialization. (Fixes #398.) - Handle "use single event" webhook option (which has a different payload format). - Verify basic auth when Anymail's WEBHOOK_SECRET is used. (This is optional for Unisender Go, since payloads are signed, but it needs to be checked when enabled.) - Treat "soft_bounced" events as "deferred" rather than "bounced", since they will be retried later. - Update validation error to reference Project ID if the webhook is configured for a specific project. - Expose Unisender Go's delivery_status code and unsubscribe form comment as Anymail's normalized event.description. - Update webhook tests based on actual payloads and add several missing tests. - Update docs to clarify webhook use with Unisender Go projects.
This commit is contained in:
@@ -8,14 +8,14 @@ from hashlib import md5
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.utils.crypto import constant_time_compare
|
||||
|
||||
from anymail.exceptions import AnymailWebhookValidationFailure
|
||||
from anymail.signals import AnymailTrackingEvent, EventType, RejectReason, tracking
|
||||
from anymail.utils import get_anymail_setting
|
||||
from anymail.webhooks.base import AnymailCoreWebhookView
|
||||
from ..exceptions import AnymailWebhookValidationFailure
|
||||
from ..signals import AnymailTrackingEvent, EventType, RejectReason, tracking
|
||||
from ..utils import get_anymail_setting
|
||||
from .base import AnymailBaseWebhookView
|
||||
|
||||
|
||||
class UnisenderGoTrackingWebhookView(AnymailCoreWebhookView):
|
||||
"""Handler for UniSender delivery and engagement tracking webhooks"""
|
||||
class UnisenderGoTrackingWebhookView(AnymailBaseWebhookView):
|
||||
"""Handler for Unisender Go delivery and engagement tracking webhooks"""
|
||||
|
||||
# See https://godocs.unisender.ru/web-api-ref#callback-format for webhook payload
|
||||
|
||||
@@ -23,6 +23,8 @@ class UnisenderGoTrackingWebhookView(AnymailCoreWebhookView):
|
||||
signal = tracking
|
||||
warn_if_no_basic_auth = False # because we validate against signature
|
||||
|
||||
api_key: str | None = None # allows kwargs override
|
||||
|
||||
event_types = {
|
||||
"sent": EventType.SENT,
|
||||
"delivered": EventType.DELIVERED,
|
||||
@@ -31,14 +33,14 @@ class UnisenderGoTrackingWebhookView(AnymailCoreWebhookView):
|
||||
"unsubscribed": EventType.UNSUBSCRIBED,
|
||||
"subscribed": EventType.SUBSCRIBED,
|
||||
"spam": EventType.COMPLAINED,
|
||||
"soft_bounced": EventType.BOUNCED,
|
||||
"soft_bounced": EventType.DEFERRED,
|
||||
"hard_bounced": EventType.BOUNCED,
|
||||
}
|
||||
|
||||
reject_reasons = {
|
||||
"err_user_unknown": RejectReason.BOUNCED,
|
||||
"err_user_inactive": RejectReason.BOUNCED,
|
||||
"err_will_retry": RejectReason.BOUNCED,
|
||||
"err_will_retry": None, # not rejected
|
||||
"err_mailbox_discarded": RejectReason.BOUNCED,
|
||||
"err_mailbox_full": RejectReason.BOUNCED,
|
||||
"err_spam_rejected": RejectReason.SPAM,
|
||||
@@ -56,49 +58,105 @@ class UnisenderGoTrackingWebhookView(AnymailCoreWebhookView):
|
||||
|
||||
http_method_names = ["post", "head", "options", "get"]
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
api_key = get_anymail_setting(
|
||||
"api_key", esp_name=self.esp_name, allow_bare=True, kwargs=kwargs
|
||||
)
|
||||
self.api_key_bytes = api_key.encode("ascii")
|
||||
super().__init__(**kwargs)
|
||||
|
||||
def get(
|
||||
self, request: HttpRequest, *args: typing.Any, **kwargs: typing.Any
|
||||
) -> HttpResponse:
|
||||
# Unisender Go verifies the webhook with a GET request at configuration time
|
||||
return HttpResponse()
|
||||
|
||||
def parse_json_body(self, request: HttpRequest) -> dict | list | None:
|
||||
# Cache parsed JSON request.body on the request.
|
||||
if hasattr(request, "_parsed_json"):
|
||||
parsed = getattr(request, "_parsed_json")
|
||||
else:
|
||||
parsed = json.loads(request.body.decode())
|
||||
setattr(request, "_parsed_json", parsed)
|
||||
return parsed
|
||||
|
||||
def validate_request(self, request: HttpRequest) -> None:
|
||||
"""
|
||||
How Unisender GO authenticate:
|
||||
Hash the whole request body text and replace api key in "auth" field by this hash.
|
||||
|
||||
So it is both auth and encryption. Also, they hash JSON without spaces.
|
||||
Validate Unisender Go webhook signature:
|
||||
"MD5 hash of the string body of the message, with the auth value replaced
|
||||
by the api_key of the user/project whose handler is being called."
|
||||
https://godocs.unisender.ru/web-api-ref#callback-format
|
||||
"""
|
||||
request_json = json.loads(request.body.decode("utf-8"))
|
||||
request_auth = request_json.get("auth", "")
|
||||
request_json["auth"] = get_anymail_setting(
|
||||
"api_key", esp_name=self.esp_name, allow_bare=True
|
||||
)
|
||||
json_with_key = json.dumps(request_json, separators=(",", ":"))
|
||||
# This must avoid any assumptions about how Unisender Go serializes JSON
|
||||
# (key order, spaces, Unicode encoding vs. \u escapes, etc.). But we do
|
||||
# assume the "auth" field MD5 hash is unique within the serialized JSON,
|
||||
# so that we can use string replacement to calculate the expected hash.
|
||||
body = request.body
|
||||
try:
|
||||
parsed = self.parse_json_body(request)
|
||||
actual_auth = parsed["auth"]
|
||||
actual_auth_bytes = actual_auth.encode()
|
||||
except (AttributeError, KeyError, ValueError):
|
||||
raise AnymailWebhookValidationFailure(
|
||||
"Unisender Go webhook called with invalid payload"
|
||||
)
|
||||
|
||||
expected_auth = md5(json_with_key.encode("utf-8")).hexdigest()
|
||||
|
||||
if not constant_time_compare(request_auth, expected_auth):
|
||||
body_to_sign = body.replace(actual_auth_bytes, self.api_key_bytes)
|
||||
expected_auth = md5(body_to_sign).hexdigest()
|
||||
if not constant_time_compare(actual_auth, expected_auth):
|
||||
# If webhook has a selected project, include the project_id in the error.
|
||||
try:
|
||||
project_id = parsed["events_by_user"][0]["project_id"]
|
||||
except (KeyError, IndexError):
|
||||
project_id = parsed.get("project_id") # try "single event" payload
|
||||
is_for_project = f" is for Project ID {project_id}" if project_id else ""
|
||||
raise AnymailWebhookValidationFailure(
|
||||
"Unisender Go webhook called with incorrect signature"
|
||||
f" (check Anymail UNISENDER_GO_API_KEY setting{is_for_project})"
|
||||
)
|
||||
|
||||
def parse_events(self, request: HttpRequest) -> list[AnymailTrackingEvent]:
|
||||
request_json = json.loads(request.body.decode("utf-8"))
|
||||
assert len(request_json["events_by_user"]) == 1 # per API docs
|
||||
esp_events = request_json["events_by_user"][0]["events"]
|
||||
return [
|
||||
self.esp_to_anymail_event(esp_event)
|
||||
for esp_event in esp_events
|
||||
if esp_event["event_name"] == "transactional_email_status"
|
||||
]
|
||||
parsed = self.parse_json_body(request)
|
||||
# Unisender Go has two options for webhook payloads. We support both.
|
||||
try:
|
||||
events_by_user = parsed["events_by_user"]
|
||||
except KeyError:
|
||||
# "Use single event": one flat dict, combining "event_data" fields
|
||||
# with "event_name", "user_id", "project_id", etc.
|
||||
if parsed["event_name"] == "transactional_email_status":
|
||||
esp_events = [parsed]
|
||||
else:
|
||||
esp_events = []
|
||||
else:
|
||||
# Not "use single event": we want the "event_data" from all events
|
||||
# with event_name "transactional_email_status".
|
||||
assert len(events_by_user) == 1 # "A single element array" per API docs
|
||||
esp_events = [
|
||||
event["event_data"]
|
||||
for event in events_by_user[0]["events"]
|
||||
if event["event_name"] == "transactional_email_status"
|
||||
]
|
||||
|
||||
def esp_to_anymail_event(self, esp_event: dict) -> AnymailTrackingEvent:
|
||||
event_data = esp_event["event_data"]
|
||||
return [self.esp_to_anymail_event(esp_event) for esp_event in esp_events]
|
||||
|
||||
def esp_to_anymail_event(self, event_data: dict) -> AnymailTrackingEvent:
|
||||
event_type = self.event_types.get(event_data["status"], EventType.UNKNOWN)
|
||||
timestamp = datetime.fromisoformat(event_data["event_time"])
|
||||
timestamp_utc = timestamp.replace(tzinfo=timezone.utc)
|
||||
metadata = event_data.get("metadata", {})
|
||||
|
||||
# Unisender Go does not provide any way to deduplicate webhook calls.
|
||||
# (There is an "ID" HTTP header, but it has a unique value for every
|
||||
# webhook call--including retransmissions of earlier failed calls.)
|
||||
event_id = None
|
||||
|
||||
# event_time is ISO-like, without a stated time zone. (But it's UTC per docs.)
|
||||
try:
|
||||
timestamp = datetime.fromisoformat(event_data["event_time"]).replace(
|
||||
tzinfo=timezone.utc
|
||||
)
|
||||
except KeyError:
|
||||
timestamp = None
|
||||
|
||||
# Extract our message_id (see backend UNISENDER_GO_GENERATE_MESSAGE_ID).
|
||||
metadata = event_data.get("metadata", {}).copy()
|
||||
message_id = metadata.pop("anymail_id", event_data.get("job_id"))
|
||||
|
||||
delivery_info = event_data.get("delivery_info", {})
|
||||
@@ -108,14 +166,18 @@ class UnisenderGoTrackingWebhookView(AnymailCoreWebhookView):
|
||||
else:
|
||||
reject_reason = None
|
||||
|
||||
description = delivery_info.get("delivery_status") or event_data.get("comment")
|
||||
mta_response = delivery_info.get("destination_response")
|
||||
|
||||
return AnymailTrackingEvent(
|
||||
event_type=event_type,
|
||||
timestamp=timestamp_utc,
|
||||
timestamp=timestamp,
|
||||
message_id=message_id,
|
||||
event_id=None,
|
||||
event_id=event_id,
|
||||
recipient=event_data["email"],
|
||||
reject_reason=reject_reason,
|
||||
mta_response=delivery_info.get("destination_response"),
|
||||
description=description,
|
||||
mta_response=mta_response,
|
||||
metadata=metadata,
|
||||
click_url=event_data.get("url"),
|
||||
user_agent=delivery_info.get("user_agent"),
|
||||
|
||||
Reference in New Issue
Block a user