mirror of
https://github.com/pacnpal/django-anymail.git
synced 2025-12-20 11:51:05 -05:00
Add Postal support
Thanks to @tiltec for researching, implementing, testing and documenting it.
This commit is contained in:
128
anymail/backends/postal.py
Normal file
128
anymail/backends/postal.py
Normal file
@@ -0,0 +1,128 @@
|
||||
from .base_requests import AnymailRequestsBackend, RequestsPayload
|
||||
from ..exceptions import AnymailRequestsAPIError
|
||||
from ..message import AnymailRecipientStatus
|
||||
from ..utils import get_anymail_setting
|
||||
|
||||
|
||||
class EmailBackend(AnymailRequestsBackend):
|
||||
"""
|
||||
Postal v1 API Email Backend
|
||||
"""
|
||||
|
||||
esp_name = "Postal"
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
"""Init options from Django settings"""
|
||||
esp_name = self.esp_name
|
||||
|
||||
self.api_key = get_anymail_setting(
|
||||
"api_key", esp_name=esp_name, kwargs=kwargs, allow_bare=True
|
||||
)
|
||||
|
||||
# Required, as there is no hosted instance of Postal
|
||||
api_url = get_anymail_setting("api_url", esp_name=esp_name, kwargs=kwargs)
|
||||
if not api_url.endswith("/"):
|
||||
api_url += "/"
|
||||
super().__init__(api_url, **kwargs)
|
||||
|
||||
def build_message_payload(self, message, defaults):
|
||||
return PostalPayload(message, defaults, self)
|
||||
|
||||
def parse_recipient_status(self, response, payload, message):
|
||||
parsed_response = self.deserialize_json_response(response, payload, message)
|
||||
|
||||
if parsed_response["status"] != "success":
|
||||
raise AnymailRequestsAPIError(
|
||||
email_message=message, payload=payload, response=response, backend=self
|
||||
)
|
||||
|
||||
# If we get here, the send call was successful.
|
||||
messages = parsed_response["data"]["messages"]
|
||||
|
||||
return {
|
||||
email: AnymailRecipientStatus(message_id=details["id"], status="queued")
|
||||
for email, details in messages.items()
|
||||
}
|
||||
|
||||
|
||||
class PostalPayload(RequestsPayload):
|
||||
def __init__(self, message, defaults, backend, *args, **kwargs):
|
||||
http_headers = kwargs.pop("headers", {})
|
||||
http_headers["X-Server-API-Key"] = backend.api_key
|
||||
http_headers["Content-Type"] = "application/json"
|
||||
http_headers["Accept"] = "application/json"
|
||||
super().__init__(
|
||||
message, defaults, backend, headers=http_headers, *args, **kwargs
|
||||
)
|
||||
|
||||
def get_api_endpoint(self):
|
||||
return "api/v1/send/message"
|
||||
|
||||
def init_payload(self):
|
||||
self.data = {}
|
||||
|
||||
def serialize_data(self):
|
||||
return self.serialize_json(self.data)
|
||||
|
||||
def set_from_email(self, email):
|
||||
self.data["from"] = str(email)
|
||||
|
||||
def set_subject(self, subject):
|
||||
self.data["subject"] = subject
|
||||
|
||||
def set_to(self, emails):
|
||||
self.data["to"] = [str(email) for email in emails]
|
||||
|
||||
def set_cc(self, emails):
|
||||
self.data["cc"] = [str(email) for email in emails]
|
||||
|
||||
def set_bcc(self, emails):
|
||||
self.data["bcc"] = [str(email) for email in emails]
|
||||
|
||||
def set_reply_to(self, emails):
|
||||
if len(emails) > 1:
|
||||
self.unsupported_feature("multiple reply_to addresses")
|
||||
if len(emails) > 0:
|
||||
self.data["reply_to"] = str(emails[0])
|
||||
|
||||
def set_extra_headers(self, headers):
|
||||
self.data["headers"] = headers
|
||||
|
||||
def set_text_body(self, body):
|
||||
self.data["plain_body"] = body
|
||||
|
||||
def set_html_body(self, body):
|
||||
if "html_body" in self.data:
|
||||
self.unsupported_feature("multiple html parts")
|
||||
self.data["html_body"] = body
|
||||
|
||||
def make_attachment(self, attachment):
|
||||
"""Returns Postal attachment dict for attachment"""
|
||||
att = {
|
||||
"name": attachment.name or "",
|
||||
"data": attachment.b64content,
|
||||
"content_type": attachment.mimetype,
|
||||
}
|
||||
if attachment.inline:
|
||||
# see https://github.com/postalhq/postal/issues/731
|
||||
# but it might be possible with the send/raw endpoint
|
||||
self.unsupported_feature('inline attachments')
|
||||
return att
|
||||
|
||||
def set_attachments(self, attachments):
|
||||
if attachments:
|
||||
self.data["attachments"] = [
|
||||
self.make_attachment(attachment) for attachment in attachments
|
||||
]
|
||||
|
||||
def set_envelope_sender(self, email):
|
||||
self.data["sender"] = str(email)
|
||||
|
||||
def set_tags(self, tags):
|
||||
if len(tags) > 1:
|
||||
self.unsupported_feature("multiple tags")
|
||||
if len(tags) > 0:
|
||||
self.data["tag"] = tags[0]
|
||||
|
||||
def set_esp_extra(self, extra):
|
||||
self.data.update(extra)
|
||||
@@ -4,6 +4,7 @@ from .webhooks.amazon_ses import AmazonSESInboundWebhookView, AmazonSESTrackingW
|
||||
from .webhooks.mailgun import MailgunInboundWebhookView, MailgunTrackingWebhookView
|
||||
from .webhooks.mailjet import MailjetInboundWebhookView, MailjetTrackingWebhookView
|
||||
from .webhooks.mandrill import MandrillCombinedWebhookView
|
||||
from .webhooks.postal import PostalInboundWebhookView, PostalTrackingWebhookView
|
||||
from .webhooks.postmark import PostmarkInboundWebhookView, PostmarkTrackingWebhookView
|
||||
from .webhooks.sendgrid import SendGridInboundWebhookView, SendGridTrackingWebhookView
|
||||
from .webhooks.sendinblue import SendinBlueTrackingWebhookView
|
||||
@@ -15,6 +16,7 @@ urlpatterns = [
|
||||
re_path(r'^amazon_ses/inbound/$', AmazonSESInboundWebhookView.as_view(), name='amazon_ses_inbound_webhook'),
|
||||
re_path(r'^mailgun/inbound(_mime)?/$', MailgunInboundWebhookView.as_view(), name='mailgun_inbound_webhook'),
|
||||
re_path(r'^mailjet/inbound/$', MailjetInboundWebhookView.as_view(), name='mailjet_inbound_webhook'),
|
||||
re_path(r'^postal/inbound/$', PostalInboundWebhookView.as_view(), name='postal_inbound_webhook'),
|
||||
re_path(r'^postmark/inbound/$', PostmarkInboundWebhookView.as_view(), name='postmark_inbound_webhook'),
|
||||
re_path(r'^sendgrid/inbound/$', SendGridInboundWebhookView.as_view(), name='sendgrid_inbound_webhook'),
|
||||
re_path(r'^sparkpost/inbound/$', SparkPostInboundWebhookView.as_view(), name='sparkpost_inbound_webhook'),
|
||||
@@ -22,6 +24,7 @@ urlpatterns = [
|
||||
re_path(r'^amazon_ses/tracking/$', AmazonSESTrackingWebhookView.as_view(), name='amazon_ses_tracking_webhook'),
|
||||
re_path(r'^mailgun/tracking/$', MailgunTrackingWebhookView.as_view(), name='mailgun_tracking_webhook'),
|
||||
re_path(r'^mailjet/tracking/$', MailjetTrackingWebhookView.as_view(), name='mailjet_tracking_webhook'),
|
||||
re_path(r'^postal/tracking/$', PostalTrackingWebhookView.as_view(), name='postal_tracking_webhook'),
|
||||
re_path(r'^postmark/tracking/$', PostmarkTrackingWebhookView.as_view(), name='postmark_tracking_webhook'),
|
||||
re_path(r'^sendgrid/tracking/$', SendGridTrackingWebhookView.as_view(), name='sendgrid_tracking_webhook'),
|
||||
re_path(r'^sendinblue/tracking/$', SendinBlueTrackingWebhookView.as_view(), name='sendinblue_tracking_webhook'),
|
||||
|
||||
194
anymail/webhooks/postal.py
Normal file
194
anymail/webhooks/postal.py
Normal file
@@ -0,0 +1,194 @@
|
||||
import binascii
|
||||
import json
|
||||
from base64 import b64decode
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
from django.utils.timezone import utc
|
||||
|
||||
from .base import AnymailBaseWebhookView
|
||||
from ..exceptions import (
|
||||
AnymailInvalidAddress,
|
||||
AnymailWebhookValidationFailure,
|
||||
AnymailImproperlyInstalled,
|
||||
_LazyError,
|
||||
AnymailConfigurationError,
|
||||
)
|
||||
from ..inbound import AnymailInboundMessage
|
||||
from ..signals import (
|
||||
inbound,
|
||||
tracking,
|
||||
AnymailInboundEvent,
|
||||
AnymailTrackingEvent,
|
||||
EventType,
|
||||
RejectReason,
|
||||
)
|
||||
from ..utils import parse_single_address, get_anymail_setting
|
||||
|
||||
try:
|
||||
from cryptography.hazmat.primitives import serialization, hashes
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives.asymmetric import padding
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
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', backend='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=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]
|
||||
Reference in New Issue
Block a user