import binascii import json from base64 import b64decode from datetime import datetime, timezone from ..exceptions import ( AnymailConfigurationError, AnymailImproperlyInstalled, AnymailInvalidAddress, AnymailWebhookValidationFailure, _LazyError, ) from ..inbound import AnymailInboundMessage from ..signals import ( AnymailInboundEvent, AnymailTrackingEvent, EventType, RejectReason, inbound, tracking, ) from ..utils import get_anymail_setting, parse_single_address from .base import AnymailBaseWebhookView try: from cryptography.exceptions import InvalidSignature from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import padding except ImportError: # This module gets imported by anymail.urls, so don't complain about cryptography # missing unless one of the Postal webhook views is actually used and needs it error = _LazyError( AnymailImproperlyInstalled( missing_package="cryptography", install_extra="postal" ) ) serialization = error hashes = error default_backend = error padding = error InvalidSignature = object class PostalBaseWebhookView(AnymailBaseWebhookView): """Base view class for Postal webhooks""" esp_name = "Postal" warn_if_no_basic_auth = False # These can be set from kwargs in View.as_view, or pulled from settings in init: webhook_key = None def __init__(self, **kwargs): self.webhook_key = get_anymail_setting( "webhook_key", esp_name=self.esp_name, kwargs=kwargs, allow_bare=True ) super().__init__(**kwargs) def validate_request(self, request): try: signature = request.META["HTTP_X_POSTAL_SIGNATURE"] except KeyError: raise AnymailWebhookValidationFailure( "X-Postal-Signature header missing from webhook" ) public_key = serialization.load_pem_public_key( ( "-----BEGIN PUBLIC KEY-----\n" + self.webhook_key + "\n-----END PUBLIC KEY-----" ).encode(), backend=default_backend(), ) try: public_key.verify( b64decode(signature), request.body, padding.PKCS1v15(), hashes.SHA1() ) except (InvalidSignature, binascii.Error): raise AnymailWebhookValidationFailure( "Postal webhook called with incorrect signature" ) class PostalTrackingWebhookView(PostalBaseWebhookView): """Handler for Postal message, engagement, and generation event webhooks""" signal = tracking def parse_events(self, request): esp_event = json.loads(request.body.decode("utf-8")) if "rcpt_to" in esp_event: raise AnymailConfigurationError( "You seem to have set Postal's *inbound* webhook " "to Anymail's Postal *tracking* webhook URL." ) raw_timestamp = esp_event.get("timestamp") timestamp = ( datetime.fromtimestamp(int(raw_timestamp), tz=timezone.utc) if raw_timestamp else None ) payload = esp_event.get("payload", {}) status_types = { "Sent": EventType.DELIVERED, "SoftFail": EventType.DEFERRED, "HardFail": EventType.FAILED, "Held": EventType.QUEUED, } if "status" in payload: event_type = status_types.get(payload["status"], EventType.UNKNOWN) elif "bounce" in payload: event_type = EventType.BOUNCED elif "url" in payload: event_type = EventType.CLICKED else: event_type = EventType.UNKNOWN description = payload.get("details") mta_response = payload.get("output") # extract message-related fields message = payload.get("message") or payload.get("original_message", {}) message_id = message.get("id") tag = message.get("tag") recipient = None message_to = message.get("to") if message_to is not None: try: recipient = parse_single_address(message_to).addr_spec except AnymailInvalidAddress: pass if message.get("direction") == "incoming": # Let's ignore tracking events about an inbound emails. # This happens when an inbound email could not be forwarded. # The email didn't originate from Anymail, so the user can't do much about # it. It is part of normal Postal operation, not a configuration error. return [] # only for MessageLinkClicked click_url = payload.get("url") user_agent = payload.get("user_agent") event = AnymailTrackingEvent( event_type=event_type, timestamp=timestamp, event_id=esp_event.get("uuid"), esp_event=esp_event, click_url=click_url, description=description, message_id=message_id, metadata=None, mta_response=mta_response, recipient=recipient, reject_reason=( RejectReason.BOUNCED if event_type == EventType.BOUNCED else None ), tags=[tag], user_agent=user_agent, ) return [event] class PostalInboundWebhookView(PostalBaseWebhookView): """Handler for Postal inbound relay webhook""" signal = inbound def parse_events(self, request): esp_event = json.loads(request.body.decode("utf-8")) if "status" in esp_event: raise AnymailConfigurationError( "You seem to have set Postal's *tracking* webhook " "to Anymail's Postal *inbound* webhook URL." ) raw_mime = esp_event["message"] if esp_event.get("base64") is True: raw_mime = b64decode(esp_event["message"]).decode("utf-8") message = AnymailInboundMessage.parse_raw_mime(raw_mime) message.envelope_sender = esp_event.get("mail_from", None) message.envelope_recipient = esp_event.get("rcpt_to", None) event = AnymailInboundEvent( event_type=EventType.INBOUND, timestamp=None, event_id=esp_event.get("id"), esp_event=esp_event, message=message, ) return [event]