Add Postal support

Thanks to @tiltec for researching, implementing, testing and documenting it.
This commit is contained in:
Tilmann Becker
2021-06-08 02:11:35 +02:00
committed by GitHub
parent f831fe814a
commit e90c10b546
14 changed files with 1674 additions and 25 deletions

128
anymail/backends/postal.py Normal file
View 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)

View File

@@ -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
View 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]