mirror of
https://github.com/pacnpal/django-anymail.git
synced 2025-12-20 03:41:05 -05:00
Add Postal support
Thanks to @tiltec for researching, implementing, testing and documenting it.
This commit is contained in:
1
.github/workflows/test.yml
vendored
1
.github/workflows/test.yml
vendored
@@ -88,6 +88,7 @@ jobs:
|
|||||||
# Install without optional extras (don't need to cover entire matrix)
|
# Install without optional extras (don't need to cover entire matrix)
|
||||||
- { tox: django31-py37-none, python: "3.7" }
|
- { tox: django31-py37-none, python: "3.7" }
|
||||||
- { tox: django31-py37-amazon_ses, python: "3.7" }
|
- { tox: django31-py37-amazon_ses, python: "3.7" }
|
||||||
|
- { tox: django31-py37-postal, python: "3.7" }
|
||||||
# Test some specific older package versions
|
# Test some specific older package versions
|
||||||
- { tox: django22-py37-all-old_urllib3, python: "3.7" }
|
- { tox: django22-py37-all-old_urllib3, python: "3.7" }
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,17 @@ Release history
|
|||||||
^^^^^^^^^^^^^^^
|
^^^^^^^^^^^^^^^
|
||||||
.. This extra heading level keeps the ToC from becoming unmanageably long
|
.. This extra heading level keeps the ToC from becoming unmanageably long
|
||||||
|
|
||||||
|
vNext
|
||||||
|
-----
|
||||||
|
|
||||||
|
*Unreleased changes on main branch*
|
||||||
|
|
||||||
|
Features
|
||||||
|
~~~~~~~~
|
||||||
|
|
||||||
|
* **Postal:** New ESP! See `docs <https://anymail.readthedocs.io/en/latest/esps/postal>`__.
|
||||||
|
(Thanks to `@tiltec`_ for researching, implementing, testing and documenting Postal support.)
|
||||||
|
|
||||||
v8.3
|
v8.3
|
||||||
----
|
----
|
||||||
|
|
||||||
@@ -1252,6 +1263,7 @@ Features
|
|||||||
.. _@swrobel: https://github.com/swrobel
|
.. _@swrobel: https://github.com/swrobel
|
||||||
.. _@tcourtqtm: https://github.com/tcourtqtm
|
.. _@tcourtqtm: https://github.com/tcourtqtm
|
||||||
.. _@Thorbenl: https://github.com/Thorbenl
|
.. _@Thorbenl: https://github.com/Thorbenl
|
||||||
|
.. _@tiltec: https://github.com/tiltec
|
||||||
.. _@Tobeyforce: https://github.com/Tobeyforce
|
.. _@Tobeyforce: https://github.com/Tobeyforce
|
||||||
.. _@varche1: https://github.com/varche1
|
.. _@varche1: https://github.com/varche1
|
||||||
.. _@vgrebenschikov: https://github.com/vgrebenschikov
|
.. _@vgrebenschikov: https://github.com/vgrebenschikov
|
||||||
|
|||||||
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.mailgun import MailgunInboundWebhookView, MailgunTrackingWebhookView
|
||||||
from .webhooks.mailjet import MailjetInboundWebhookView, MailjetTrackingWebhookView
|
from .webhooks.mailjet import MailjetInboundWebhookView, MailjetTrackingWebhookView
|
||||||
from .webhooks.mandrill import MandrillCombinedWebhookView
|
from .webhooks.mandrill import MandrillCombinedWebhookView
|
||||||
|
from .webhooks.postal import PostalInboundWebhookView, PostalTrackingWebhookView
|
||||||
from .webhooks.postmark import PostmarkInboundWebhookView, PostmarkTrackingWebhookView
|
from .webhooks.postmark import PostmarkInboundWebhookView, PostmarkTrackingWebhookView
|
||||||
from .webhooks.sendgrid import SendGridInboundWebhookView, SendGridTrackingWebhookView
|
from .webhooks.sendgrid import SendGridInboundWebhookView, SendGridTrackingWebhookView
|
||||||
from .webhooks.sendinblue import SendinBlueTrackingWebhookView
|
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'^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'^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'^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'^postmark/inbound/$', PostmarkInboundWebhookView.as_view(), name='postmark_inbound_webhook'),
|
||||||
re_path(r'^sendgrid/inbound/$', SendGridInboundWebhookView.as_view(), name='sendgrid_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'),
|
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'^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'^mailgun/tracking/$', MailgunTrackingWebhookView.as_view(), name='mailgun_tracking_webhook'),
|
||||||
re_path(r'^mailjet/tracking/$', MailjetTrackingWebhookView.as_view(), name='mailjet_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'^postmark/tracking/$', PostmarkTrackingWebhookView.as_view(), name='postmark_tracking_webhook'),
|
||||||
re_path(r'^sendgrid/tracking/$', SendGridTrackingWebhookView.as_view(), name='sendgrid_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'),
|
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]
|
||||||
@@ -16,6 +16,7 @@ and notes about any quirks or limitations:
|
|||||||
mailgun
|
mailgun
|
||||||
mailjet
|
mailjet
|
||||||
mandrill
|
mandrill
|
||||||
|
postal
|
||||||
postmark
|
postmark
|
||||||
sendgrid
|
sendgrid
|
||||||
sendinblue
|
sendinblue
|
||||||
@@ -31,35 +32,35 @@ The table below summarizes the Anymail features supported for each ESP.
|
|||||||
|
|
||||||
.. rst-class:: sticky-left
|
.. rst-class:: sticky-left
|
||||||
|
|
||||||
============================================ ============ =========== ========== =========== ========== ========== ============ ===========
|
============================================ ============ =========== ========== =========== ========== ========== ========== ============ ===========
|
||||||
Email Service Provider |Amazon SES| |Mailgun| |Mailjet| |Mandrill| |Postmark| |SendGrid| |Sendinblue| |SparkPost|
|
Email Service Provider |Amazon SES| |Mailgun| |Mailjet| |Mandrill| |Postal| |Postmark| |SendGrid| |Sendinblue| |SparkPost|
|
||||||
============================================ ============ =========== ========== =========== ========== ========== ============ ===========
|
============================================ ============ =========== ========== =========== ========== ========== ========== ============ ===========
|
||||||
.. rubric:: :ref:`Anymail send options <anymail-send-options>`
|
.. rubric:: :ref:`Anymail send options <anymail-send-options>`
|
||||||
---------------------------------------------------------------------------------------------------------------------------------------------------
|
---------------------------------------------------------------------------------------------------------------------------------------------------------------
|
||||||
:attr:`~AnymailMessage.envelope_sender` Yes Domain only Yes Domain only No No No Yes
|
:attr:`~AnymailMessage.envelope_sender` Yes Domain only Yes Domain only Yes No No No Yes
|
||||||
:attr:`~AnymailMessage.metadata` Yes Yes Yes Yes Yes Yes Yes Yes
|
:attr:`~AnymailMessage.metadata` Yes Yes Yes Yes No Yes Yes Yes Yes
|
||||||
:attr:`~AnymailMessage.merge_metadata` No Yes Yes Yes Yes Yes No Yes
|
:attr:`~AnymailMessage.merge_metadata` No Yes Yes Yes No Yes Yes No Yes
|
||||||
:attr:`~AnymailMessage.send_at` No Yes No Yes No Yes No Yes
|
:attr:`~AnymailMessage.send_at` No Yes No Yes No No Yes No Yes
|
||||||
:attr:`~AnymailMessage.tags` Yes Yes Max 1 tag Yes Max 1 tag Yes Yes Max 1 tag
|
:attr:`~AnymailMessage.tags` Yes Yes Max 1 tag Yes Max 1 tag Max 1 tag Yes Yes Max 1 tag
|
||||||
:attr:`~AnymailMessage.track_clicks` No Yes Yes Yes Yes Yes No Yes
|
:attr:`~AnymailMessage.track_clicks` No Yes Yes Yes No Yes Yes No Yes
|
||||||
:attr:`~AnymailMessage.track_opens` No Yes Yes Yes Yes Yes No Yes
|
:attr:`~AnymailMessage.track_opens` No Yes Yes Yes No Yes Yes No Yes
|
||||||
:ref:`amp-email` Yes Yes No No No Yes No Yes
|
:ref:`amp-email` Yes Yes No No No No Yes No Yes
|
||||||
|
|
||||||
.. rubric:: :ref:`templates-and-merge`
|
.. rubric:: :ref:`templates-and-merge`
|
||||||
---------------------------------------------------------------------------------------------------------------------------------------------------
|
---------------------------------------------------------------------------------------------------------------------------------------------------------------
|
||||||
:attr:`~AnymailMessage.template_id` Yes Yes Yes Yes Yes Yes Yes Yes
|
:attr:`~AnymailMessage.template_id` Yes Yes Yes Yes No Yes Yes Yes Yes
|
||||||
:attr:`~AnymailMessage.merge_data` Yes Yes Yes Yes Yes Yes No Yes
|
:attr:`~AnymailMessage.merge_data` Yes Yes Yes Yes No Yes Yes No Yes
|
||||||
:attr:`~AnymailMessage.merge_global_data` Yes (emulated) Yes Yes Yes Yes Yes Yes
|
:attr:`~AnymailMessage.merge_global_data` Yes (emulated) Yes Yes No Yes Yes Yes Yes
|
||||||
|
|
||||||
.. rubric:: :ref:`Status <esp-send-status>` and :ref:`event tracking <event-tracking>`
|
.. rubric:: :ref:`Status <esp-send-status>` and :ref:`event tracking <event-tracking>`
|
||||||
---------------------------------------------------------------------------------------------------------------------------------------------------
|
---------------------------------------------------------------------------------------------------------------------------------------------------------------
|
||||||
:attr:`~AnymailMessage.anymail_status` Yes Yes Yes Yes Yes Yes Yes Yes
|
:attr:`~AnymailMessage.anymail_status` Yes Yes Yes Yes Yes Yes Yes Yes Yes
|
||||||
|AnymailTrackingEvent| from webhooks Yes Yes Yes Yes Yes Yes Yes Yes
|
|AnymailTrackingEvent| from webhooks Yes Yes Yes Yes Yes Yes Yes Yes Yes
|
||||||
|
|
||||||
.. rubric:: :ref:`Inbound handling <inbound>`
|
.. rubric:: :ref:`Inbound handling <inbound>`
|
||||||
---------------------------------------------------------------------------------------------------------------------------------------------------
|
---------------------------------------------------------------------------------------------------------------------------------------------------------------
|
||||||
|AnymailInboundEvent| from webhooks Yes Yes Yes Yes Yes Yes No Yes
|
|AnymailInboundEvent| from webhooks Yes Yes Yes Yes Yes Yes Yes No Yes
|
||||||
============================================ ============ =========== ========== =========== ========== ========== ============ ===========
|
============================================ ============ =========== ========== =========== ========== ========== ========== ============ ===========
|
||||||
|
|
||||||
|
|
||||||
Trying to choose an ESP? Please **don't** start with this table. It's far more
|
Trying to choose an ESP? Please **don't** start with this table. It's far more
|
||||||
@@ -71,6 +72,7 @@ meaningless. (And even specific features don't matter if you don't plan to use t
|
|||||||
.. |Mailgun| replace:: :ref:`mailgun-backend`
|
.. |Mailgun| replace:: :ref:`mailgun-backend`
|
||||||
.. |Mailjet| replace:: :ref:`mailjet-backend`
|
.. |Mailjet| replace:: :ref:`mailjet-backend`
|
||||||
.. |Mandrill| replace:: :ref:`mandrill-backend`
|
.. |Mandrill| replace:: :ref:`mandrill-backend`
|
||||||
|
.. |Postal| replace:: :ref:`postal-backend`
|
||||||
.. |Postmark| replace:: :ref:`postmark-backend`
|
.. |Postmark| replace:: :ref:`postmark-backend`
|
||||||
.. |SendGrid| replace:: :ref:`sendgrid-backend`
|
.. |SendGrid| replace:: :ref:`sendgrid-backend`
|
||||||
.. |Sendinblue| replace:: :ref:`sendinblue-backend`
|
.. |Sendinblue| replace:: :ref:`sendinblue-backend`
|
||||||
|
|||||||
173
docs/esps/postal.rst
Normal file
173
docs/esps/postal.rst
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
.. _postal-backend:
|
||||||
|
|
||||||
|
Postal
|
||||||
|
========
|
||||||
|
|
||||||
|
Anymail integrates with the `Postal`_ self-hosted transactional email platform,
|
||||||
|
using their `HTTP email API`_.
|
||||||
|
|
||||||
|
.. _Postal: https://postal.atech.media/
|
||||||
|
.. _HTTP email API: https://github.com/postalhq/postal/wiki/Using-the-API
|
||||||
|
|
||||||
|
|
||||||
|
Settings
|
||||||
|
--------
|
||||||
|
|
||||||
|
.. rubric:: EMAIL_BACKEND
|
||||||
|
|
||||||
|
To use Anymail's Postal backend, set:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
EMAIL_BACKEND = "anymail.backends.postal.EmailBackend"
|
||||||
|
|
||||||
|
in your settings.py.
|
||||||
|
|
||||||
|
|
||||||
|
.. setting:: ANYMAIL_POSTAL_API_KEY
|
||||||
|
|
||||||
|
.. rubric:: POSTAL_API_KEY
|
||||||
|
|
||||||
|
Required. A Postal API key.
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
ANYMAIL = {
|
||||||
|
...
|
||||||
|
"POSTAL_API_KEY": "<your api key>",
|
||||||
|
}
|
||||||
|
|
||||||
|
Anymail will also look for ``POSTAL_API_KEY`` at the
|
||||||
|
root of the settings file if neither ``ANYMAIL["POSTAL_API_KEY"]``
|
||||||
|
nor ``ANYMAIL_POSTAL_API_KEY`` is set.
|
||||||
|
|
||||||
|
|
||||||
|
.. setting:: ANYMAIL_POSTAL_API_URL
|
||||||
|
|
||||||
|
.. rubric:: POSTAL_API_URL
|
||||||
|
|
||||||
|
Required. The base url for calling the Postal API.
|
||||||
|
|
||||||
|
|
||||||
|
.. setting:: ANYMAIL_POSTAL_WEBHOOK_KEY
|
||||||
|
|
||||||
|
.. rubric:: POSTAL_WEBHOOK_KEY
|
||||||
|
|
||||||
|
Required when using status tracking or inbound webhooks.
|
||||||
|
|
||||||
|
This should be set to the public key of the Postal instance.
|
||||||
|
You can find it by running `postal default-dkim-record` on your
|
||||||
|
Postal instance.
|
||||||
|
Use the part that comes after `p=`, until the semicolon at the end.
|
||||||
|
|
||||||
|
|
||||||
|
.. _postal-esp-extra:
|
||||||
|
|
||||||
|
esp_extra support
|
||||||
|
-----------------
|
||||||
|
|
||||||
|
To use Postal features not directly supported by Anymail, you can
|
||||||
|
set a message's :attr:`~anymail.message.AnymailMessage.esp_extra` to
|
||||||
|
a `dict` that will be merged into the json sent to Postal's
|
||||||
|
`email API`_.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
message.esp_extra = {
|
||||||
|
'HypotheticalFuturePostalParam': '2022', # merged into send params
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
(You can also set `"esp_extra"` in Anymail's
|
||||||
|
:ref:`global send defaults <send-defaults>` to apply it to all
|
||||||
|
messages.)
|
||||||
|
|
||||||
|
|
||||||
|
.. _email API: https://krystal.github.io/postal-api/controllers/send/message
|
||||||
|
|
||||||
|
|
||||||
|
Limitations and quirks
|
||||||
|
----------------------
|
||||||
|
|
||||||
|
Postal does not support a few tracking and reporting additions offered by other ESPs.
|
||||||
|
|
||||||
|
Anymail normally raises an :exc:`~anymail.exceptions.AnymailUnsupportedFeature`
|
||||||
|
error when you try to send a message using features that Postal doesn't support
|
||||||
|
You can tell Anymail to suppress these errors and send the messages anyway --
|
||||||
|
see :ref:`unsupported-features`.
|
||||||
|
|
||||||
|
**Single tag**
|
||||||
|
Postal allows a maximum of one tag per message. If your message has two or more
|
||||||
|
:attr:`~anymail.message.AnymailMessage.tags`, you'll get an
|
||||||
|
:exc:`~anymail.exceptions.AnymailUnsupportedFeature` error---or
|
||||||
|
if you've enabled :setting:`ANYMAIL_IGNORE_UNSUPPORTED_FEATURES`,
|
||||||
|
Anymail will use only the first tag.
|
||||||
|
|
||||||
|
**No delayed sending**
|
||||||
|
Postal does not support :attr:`~anymail.message.AnymailMessage.send_at`.
|
||||||
|
|
||||||
|
**Toggle click-tracking and open-tracking**
|
||||||
|
By default, Postal does not enable click-tracking and open-tracking.
|
||||||
|
To enable it, `see their docs on click- & open-tracking`_.
|
||||||
|
Anymail's :attr:`~anymail.message.AnymailMessage.track_clicks` and
|
||||||
|
:attr:`~anymail.message.AnymailMessage.track_opens` settings are unsupported.
|
||||||
|
|
||||||
|
.. _see their docs on click- & open-tracking: https://github.com/postalhq/postal/wiki/Click-&-Open-Tracking
|
||||||
|
|
||||||
|
**Attachments must be named**
|
||||||
|
Postal issues an `AttachmentMissingName` error when trying to send an attachment without name.
|
||||||
|
|
||||||
|
|
||||||
|
.. _postal-templates:
|
||||||
|
|
||||||
|
Batch sending/merge and ESP templates
|
||||||
|
-------------------------------------
|
||||||
|
|
||||||
|
Postal does not support batch sending or ESP templates.
|
||||||
|
|
||||||
|
|
||||||
|
.. _postal-webhooks:
|
||||||
|
|
||||||
|
Status tracking webhooks
|
||||||
|
------------------------
|
||||||
|
|
||||||
|
If you are using Anymail's normalized :ref:`status tracking <event-tracking>`, set up
|
||||||
|
a webhook in your Postal mail server settings, under Webhooks. The webhook URL is:
|
||||||
|
|
||||||
|
:samp:`https://{yoursite.example.com}/anymail/postal/tracking/`
|
||||||
|
|
||||||
|
* *yoursite.example.com* is your Django site
|
||||||
|
|
||||||
|
Choose all the event types you want to receive.
|
||||||
|
|
||||||
|
Postal signs its webhook payloads. You need to set :setting:`ANYMAIL_POSTAL_WEBHOOK_KEY`.
|
||||||
|
|
||||||
|
If you use multiple Postal mail servers, you'll need to repeat entering the webhook
|
||||||
|
settings for each of them.
|
||||||
|
|
||||||
|
Postal will report these Anymail :attr:`~anymail.signals.AnymailTrackingEvent.event_type`\s:
|
||||||
|
failed, bounced, deferred, queued, delivered, clicked.
|
||||||
|
|
||||||
|
The event's :attr:`~anymail.signals.AnymailTrackingEvent.esp_event` field will be
|
||||||
|
a `dict` of Postal's `webhook <https://github.com/postalhq/postal/wiki/Webhook-Events-&-Payloads>`_ data.
|
||||||
|
|
||||||
|
.. _postal-inbound:
|
||||||
|
|
||||||
|
Inbound webhook
|
||||||
|
---------------
|
||||||
|
|
||||||
|
If you want to receive email from Postal through Anymail's normalized :ref:`inbound <inbound>`
|
||||||
|
handling, follow Postal's guide to for receiving emails (Help > Receiving Emails) to create an
|
||||||
|
incoming route. Then set up an `HTTP Endpoint`, pointing to Anymail's inbound webhook.
|
||||||
|
|
||||||
|
The url will be:
|
||||||
|
|
||||||
|
:samp:`https://{yoursite.example.com}/anymail/postal/inbound/`
|
||||||
|
|
||||||
|
* *yoursite.example.com* is your Django site
|
||||||
|
|
||||||
|
Set `Format` to `Delivered as the raw message`.
|
||||||
|
|
||||||
|
You also need to set :setting:`ANYMAIL_POSTAL_WEBHOOK_KEY` to enable signature validation.
|
||||||
3
setup.py
3
setup.py
@@ -38,7 +38,7 @@ requirements_dev = [
|
|||||||
"twine",
|
"twine",
|
||||||
]
|
]
|
||||||
|
|
||||||
requirements_test = ["mock", "boto3"]
|
requirements_test = ["mock", "boto3", "cryptography"]
|
||||||
|
|
||||||
|
|
||||||
setup(
|
setup(
|
||||||
@@ -67,6 +67,7 @@ setup(
|
|||||||
"sendgrid": [],
|
"sendgrid": [],
|
||||||
"sendinblue": [],
|
"sendinblue": [],
|
||||||
"sparkpost": [],
|
"sparkpost": [],
|
||||||
|
"postal": ["cryptography"],
|
||||||
# Development/test-only requirements (install with python -m pip -e '.[dev,test]')
|
# Development/test-only requirements (install with python -m pip -e '.[dev,test]')
|
||||||
"dev": requirements_dev,
|
"dev": requirements_dev,
|
||||||
"test": requirements_test,
|
"test": requirements_test,
|
||||||
|
|||||||
472
tests/test_postal_backend.py
Normal file
472
tests/test_postal_backend.py
Normal file
@@ -0,0 +1,472 @@
|
|||||||
|
from base64 import b64encode
|
||||||
|
from decimal import Decimal
|
||||||
|
from email.mime.base import MIMEBase
|
||||||
|
from email.mime.image import MIMEImage
|
||||||
|
|
||||||
|
from django.core import mail
|
||||||
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
|
from django.test import SimpleTestCase, override_settings, tag
|
||||||
|
|
||||||
|
from anymail.exceptions import (
|
||||||
|
AnymailAPIError, AnymailSerializationError,
|
||||||
|
AnymailUnsupportedFeature)
|
||||||
|
from anymail.message import attach_inline_image_file
|
||||||
|
from .mock_requests_backend import RequestsBackendMockAPITestCase, SessionSharingTestCases
|
||||||
|
from .utils import sample_image_content, sample_image_path, SAMPLE_IMAGE_FILENAME, AnymailTestMixin, decode_att
|
||||||
|
|
||||||
|
|
||||||
|
@tag('postal')
|
||||||
|
@override_settings(EMAIL_BACKEND='anymail.backends.postal.EmailBackend',
|
||||||
|
ANYMAIL={'POSTAL_API_KEY': 'test_server_token', 'POSTAL_API_URL': 'https://postal.example.com'})
|
||||||
|
class PostalBackendMockAPITestCase(RequestsBackendMockAPITestCase):
|
||||||
|
DEFAULT_RAW_RESPONSE = b"""{
|
||||||
|
"status": "success",
|
||||||
|
"time": 2.82,
|
||||||
|
"flags": {},
|
||||||
|
"data": {
|
||||||
|
"message_id": "ad5084a6-cf01-448b-92da-2574ee64c0ba@rp.postal.example.com",
|
||||||
|
"messages": {
|
||||||
|
"to@example.com": { "id": 1503, "token": "ke47R2hZkSlA" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
# Simple message useful for many tests
|
||||||
|
self.message = mail.EmailMultiAlternatives('Subject', 'Text Body', 'from@example.com', ['to@example.com'])
|
||||||
|
|
||||||
|
|
||||||
|
@tag('postal')
|
||||||
|
class PostalBackendStandardEmailTests(PostalBackendMockAPITestCase):
|
||||||
|
"""Test backend support for Django standard email features"""
|
||||||
|
|
||||||
|
def test_send_mail(self):
|
||||||
|
"""Test basic API for simple send"""
|
||||||
|
mail.send_mail('Subject here', 'Here is the message.',
|
||||||
|
'from@sender.example.com', ['to@example.com'], fail_silently=False)
|
||||||
|
self.assert_esp_called('/message')
|
||||||
|
headers = self.get_api_call_headers()
|
||||||
|
self.assertEqual(headers["X-Server-API-Key"], "test_server_token")
|
||||||
|
data = self.get_api_call_json()
|
||||||
|
self.assertEqual(data['subject'], "Subject here")
|
||||||
|
self.assertEqual(data['plain_body'], "Here is the message.")
|
||||||
|
self.assertEqual(data['from'], "from@sender.example.com")
|
||||||
|
self.assertEqual(data['to'], ["to@example.com"])
|
||||||
|
|
||||||
|
def test_name_addr(self):
|
||||||
|
"""Make sure RFC2822 name-addr format (with display-name) is allowed
|
||||||
|
|
||||||
|
(Test both sender and recipient addresses)
|
||||||
|
"""
|
||||||
|
msg = mail.EmailMessage(
|
||||||
|
'Subject', 'Message', 'From Name <from@example.com>',
|
||||||
|
['Recipient #1 <to1@example.com>', 'to2@example.com'],
|
||||||
|
cc=['Carbon Copy <cc1@example.com>', 'cc2@example.com'],
|
||||||
|
bcc=['Blind Copy <bcc1@example.com>', 'bcc2@example.com'])
|
||||||
|
msg.send()
|
||||||
|
data = self.get_api_call_json()
|
||||||
|
self.assertEqual(data['from'], 'From Name <from@example.com>')
|
||||||
|
self.assertEqual(data['to'], ['Recipient #1 <to1@example.com>', 'to2@example.com'])
|
||||||
|
self.assertEqual(data['cc'], ['Carbon Copy <cc1@example.com>', 'cc2@example.com'])
|
||||||
|
self.assertEqual(data['bcc'], ['Blind Copy <bcc1@example.com>', 'bcc2@example.com'])
|
||||||
|
|
||||||
|
def test_email_message(self):
|
||||||
|
email = mail.EmailMessage(
|
||||||
|
'Subject', 'Body goes here', 'from@example.com',
|
||||||
|
['to1@example.com', 'Also To <to2@example.com>'],
|
||||||
|
bcc=['bcc1@example.com', 'Also BCC <bcc2@example.com>'],
|
||||||
|
cc=['cc1@example.com', 'Also CC <cc2@example.com>'],
|
||||||
|
headers={'Reply-To': 'another@example.com',
|
||||||
|
'X-MyHeader': 'my value',
|
||||||
|
'Message-ID': 'mycustommsgid@sales.example.com'}) # should override backend msgid
|
||||||
|
email.send()
|
||||||
|
data = self.get_api_call_json()
|
||||||
|
self.assertEqual(data['subject'], "Subject")
|
||||||
|
self.assertEqual(data['plain_body'], "Body goes here")
|
||||||
|
self.assertEqual(data['from'], "from@example.com")
|
||||||
|
self.assertEqual(data['to'], ['to1@example.com', 'Also To <to2@example.com>'])
|
||||||
|
self.assertEqual(data['bcc'], ['bcc1@example.com', 'Also BCC <bcc2@example.com>'])
|
||||||
|
self.assertEqual(data['cc'], ['cc1@example.com', 'Also CC <cc2@example.com>'])
|
||||||
|
self.assertEqual(data['reply_to'], 'another@example.com')
|
||||||
|
self.assertCountEqual(data['headers'], {
|
||||||
|
'Message-ID': 'mycustommsgid@sales.example.com',
|
||||||
|
'X-MyHeader': 'my value'
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_html_message(self):
|
||||||
|
text_content = 'This is an important message.'
|
||||||
|
html_content = '<p>This is an <strong>important</strong> message.</p>'
|
||||||
|
email = mail.EmailMultiAlternatives('Subject', text_content,
|
||||||
|
'from@example.com', ['to@example.com'])
|
||||||
|
email.attach_alternative(html_content, "text/html")
|
||||||
|
email.send()
|
||||||
|
data = self.get_api_call_json()
|
||||||
|
self.assertEqual(data['plain_body'], text_content)
|
||||||
|
self.assertEqual(data['html_body'], html_content)
|
||||||
|
# Don't accidentally send the html part as an attachment:
|
||||||
|
self.assertNotIn('attachments', data)
|
||||||
|
|
||||||
|
def test_html_only_message(self):
|
||||||
|
html_content = '<p>This is an <strong>important</strong> message.</p>'
|
||||||
|
email = mail.EmailMessage('Subject', html_content, 'from@example.com', ['to@example.com'])
|
||||||
|
email.content_subtype = "html" # Main content is now text/html
|
||||||
|
email.send()
|
||||||
|
data = self.get_api_call_json()
|
||||||
|
self.assertNotIn('plain_body', data)
|
||||||
|
self.assertEqual(data['html_body'], html_content)
|
||||||
|
|
||||||
|
def test_extra_headers(self):
|
||||||
|
self.message.extra_headers = {'X-Custom': 'string', 'X-Num': 123}
|
||||||
|
self.message.send()
|
||||||
|
data = self.get_api_call_json()
|
||||||
|
self.assertCountEqual(data['headers'], {
|
||||||
|
'X-Custom': 'string',
|
||||||
|
'X-Num': 123
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_extra_headers_serialization_error(self):
|
||||||
|
self.message.extra_headers = {'X-Custom': Decimal(12.5)}
|
||||||
|
with self.assertRaisesMessage(AnymailSerializationError, "Decimal"):
|
||||||
|
self.message.send()
|
||||||
|
|
||||||
|
@override_settings(ANYMAIL_IGNORE_UNSUPPORTED_FEATURES=True) # Postal only allows single reply-to
|
||||||
|
def test_reply_to(self):
|
||||||
|
email = mail.EmailMessage('Subject', 'Body goes here', 'from@example.com', ['to1@example.com'],
|
||||||
|
reply_to=['reply@example.com', 'Other <reply2@example.com>'])
|
||||||
|
email.send()
|
||||||
|
data = self.get_api_call_json()
|
||||||
|
self.assertEqual(data['reply_to'], 'reply@example.com') # keeps first email
|
||||||
|
|
||||||
|
def test_multiple_reply_to(self):
|
||||||
|
email = mail.EmailMessage('Subject', 'Body goes here', 'from@example.com', ['to1@example.com'],
|
||||||
|
reply_to=['reply@example.com', 'Other <reply2@example.com>'])
|
||||||
|
with self.assertRaises(AnymailUnsupportedFeature):
|
||||||
|
email.send()
|
||||||
|
|
||||||
|
def test_attachments(self):
|
||||||
|
text_content = "* Item one\n* Item two\n* Item three"
|
||||||
|
self.message.attach(filename="test.txt", content=text_content, mimetype="text/plain")
|
||||||
|
|
||||||
|
# Should guess mimetype if not provided...
|
||||||
|
png_content = b"PNG\xb4 pretend this is the contents of a png file"
|
||||||
|
self.message.attach(filename="test.png", content=png_content)
|
||||||
|
|
||||||
|
# Should work with a MIMEBase object (also tests no filename)...
|
||||||
|
pdf_content = b"PDF\xb4 pretend this is valid pdf data"
|
||||||
|
mimeattachment = MIMEBase('application', 'pdf')
|
||||||
|
mimeattachment.set_payload(pdf_content)
|
||||||
|
self.message.attach(mimeattachment)
|
||||||
|
|
||||||
|
self.message.send()
|
||||||
|
data = self.get_api_call_json()
|
||||||
|
attachments = data['attachments']
|
||||||
|
self.assertEqual(len(attachments), 3)
|
||||||
|
self.assertEqual(attachments[0]["name"], "test.txt")
|
||||||
|
self.assertEqual(attachments[0]["content_type"], "text/plain")
|
||||||
|
self.assertEqual(decode_att(attachments[0]["data"]).decode('ascii'), text_content)
|
||||||
|
|
||||||
|
self.assertEqual(attachments[1]["content_type"], "image/png") # inferred from filename
|
||||||
|
self.assertEqual(attachments[1]["name"], "test.png")
|
||||||
|
self.assertEqual(decode_att(attachments[1]["data"]), png_content)
|
||||||
|
|
||||||
|
self.assertEqual(attachments[2]["content_type"], "application/pdf")
|
||||||
|
self.assertEqual(attachments[2]["name"], "") # none
|
||||||
|
self.assertEqual(decode_att(attachments[2]["data"]), pdf_content)
|
||||||
|
|
||||||
|
def test_unicode_attachment_correctly_decoded(self):
|
||||||
|
self.message.attach("Une pièce jointe.html", '<p>\u2019</p>', mimetype='text/html')
|
||||||
|
self.message.send()
|
||||||
|
data = self.get_api_call_json()
|
||||||
|
self.assertEqual(data['attachments'], [{
|
||||||
|
'name': 'Une pièce jointe.html',
|
||||||
|
'content_type': 'text/html',
|
||||||
|
'data': b64encode('<p>\u2019</p>'.encode('utf-8')).decode('ascii')
|
||||||
|
}])
|
||||||
|
|
||||||
|
def test_embedded_images(self):
|
||||||
|
image_filename = SAMPLE_IMAGE_FILENAME
|
||||||
|
image_path = sample_image_path(image_filename)
|
||||||
|
|
||||||
|
cid = attach_inline_image_file(self.message, image_path) # Read from a png file
|
||||||
|
html_content = '<p>This has an <img src="cid:%s" alt="inline" /> image.</p>' % cid
|
||||||
|
self.message.attach_alternative(html_content, "text/html")
|
||||||
|
|
||||||
|
with self.assertRaisesMessage(AnymailUnsupportedFeature, 'inline attachments'):
|
||||||
|
self.message.send()
|
||||||
|
|
||||||
|
def test_attached_images(self):
|
||||||
|
image_filename = SAMPLE_IMAGE_FILENAME
|
||||||
|
image_path = sample_image_path(image_filename)
|
||||||
|
image_data = sample_image_content(image_filename)
|
||||||
|
|
||||||
|
self.message.attach_file(image_path) # option 1: attach as a file
|
||||||
|
|
||||||
|
image = MIMEImage(image_data) # option 2: construct the MIMEImage and attach it directly
|
||||||
|
self.message.attach(image)
|
||||||
|
|
||||||
|
image_data_b64 = b64encode(image_data).decode('ascii')
|
||||||
|
|
||||||
|
self.message.send()
|
||||||
|
data = self.get_api_call_json()
|
||||||
|
self.assertEqual(data['attachments'], [
|
||||||
|
{
|
||||||
|
'name': image_filename, # the named one
|
||||||
|
'content_type': 'image/png',
|
||||||
|
'data': image_data_b64,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': '', # the unnamed one
|
||||||
|
'content_type': 'image/png',
|
||||||
|
'data': image_data_b64,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
def test_multiple_html_alternatives(self):
|
||||||
|
# Multiple alternatives not allowed
|
||||||
|
self.message.attach_alternative("<p>First html is OK</p>", "text/html")
|
||||||
|
self.message.attach_alternative("<p>But not second html</p>", "text/html")
|
||||||
|
with self.assertRaisesMessage(AnymailUnsupportedFeature, 'multiple html parts'):
|
||||||
|
self.message.send()
|
||||||
|
|
||||||
|
def test_html_alternative(self):
|
||||||
|
# Only html alternatives allowed
|
||||||
|
self.message.attach_alternative("{'not': 'allowed'}", "application/json")
|
||||||
|
with self.assertRaises(AnymailUnsupportedFeature):
|
||||||
|
self.message.send()
|
||||||
|
|
||||||
|
def test_alternatives_fail_silently(self):
|
||||||
|
# Make sure fail_silently is respected
|
||||||
|
self.message.attach_alternative("{'not': 'allowed'}", "application/json")
|
||||||
|
sent = self.message.send(fail_silently=True)
|
||||||
|
self.assert_esp_not_called("API should not be called when send fails silently")
|
||||||
|
self.assertEqual(sent, 0)
|
||||||
|
|
||||||
|
def test_suppress_empty_address_lists(self):
|
||||||
|
"""Empty to, cc, bcc, and reply_to shouldn't generate empty fields"""
|
||||||
|
self.message.send()
|
||||||
|
data = self.get_api_call_json()
|
||||||
|
self.assertNotIn('Cc', data)
|
||||||
|
self.assertNotIn('Bcc', data)
|
||||||
|
self.assertNotIn('ReplyTo', data)
|
||||||
|
|
||||||
|
# Test empty `to` -- but send requires at least one recipient somewhere (like cc)
|
||||||
|
self.message.to = []
|
||||||
|
self.message.cc = ['cc@example.com']
|
||||||
|
self.message.send()
|
||||||
|
data = self.get_api_call_json()
|
||||||
|
self.assertNotIn('To', data)
|
||||||
|
|
||||||
|
def test_api_failure(self):
|
||||||
|
failure_response = b"""{
|
||||||
|
"status": "error",
|
||||||
|
"time": 0.0,
|
||||||
|
"flags": {},
|
||||||
|
"data": {
|
||||||
|
"code": "ValidationError"
|
||||||
|
}
|
||||||
|
}"""
|
||||||
|
self.set_mock_response(status_code=200, raw=failure_response)
|
||||||
|
with self.assertRaisesMessage(AnymailAPIError, "Postal API response 200"):
|
||||||
|
mail.send_mail('Subject', 'Body', 'from@example.com', ['to@example.com'])
|
||||||
|
|
||||||
|
# Make sure fail_silently is respected
|
||||||
|
self.set_mock_response(status_code=200, raw=failure_response)
|
||||||
|
sent = mail.send_mail('Subject', 'Body', 'from@example.com', ['to@example.com'], fail_silently=True)
|
||||||
|
self.assertEqual(sent, 0)
|
||||||
|
|
||||||
|
def test_api_error_includes_details(self):
|
||||||
|
"""AnymailAPIError should include ESP's error message"""
|
||||||
|
# JSON error response:
|
||||||
|
error_response = b"""{
|
||||||
|
"status": "error",
|
||||||
|
"time": 0.0,
|
||||||
|
"flags": {},
|
||||||
|
"data": {
|
||||||
|
"code": "ValidationError"
|
||||||
|
}
|
||||||
|
}"""
|
||||||
|
self.set_mock_response(status_code=200, raw=error_response)
|
||||||
|
with self.assertRaisesMessage(AnymailAPIError, "ValidationError"):
|
||||||
|
self.message.send()
|
||||||
|
|
||||||
|
# Non-JSON error response:
|
||||||
|
self.set_mock_response(status_code=500, raw=b"Ack! Bad proxy!")
|
||||||
|
with self.assertRaisesMessage(AnymailAPIError, "Ack! Bad proxy!"):
|
||||||
|
self.message.send()
|
||||||
|
|
||||||
|
# No content in the error response:
|
||||||
|
self.set_mock_response(status_code=502, raw=None)
|
||||||
|
with self.assertRaises(AnymailAPIError):
|
||||||
|
self.message.send()
|
||||||
|
|
||||||
|
|
||||||
|
@tag('postal')
|
||||||
|
class PostalBackendAnymailFeatureTests(PostalBackendMockAPITestCase):
|
||||||
|
"""Test backend support for Anymail added features"""
|
||||||
|
|
||||||
|
def test_envelope_sender(self):
|
||||||
|
self.message.envelope_sender = "anything@bounces.example.com"
|
||||||
|
self.message.send()
|
||||||
|
data = self.get_api_call_json()
|
||||||
|
self.assertEqual(data["sender"], "anything@bounces.example.com")
|
||||||
|
|
||||||
|
def test_metadata(self):
|
||||||
|
self.message.metadata = {'user_id': "12345", 'items': 6}
|
||||||
|
with self.assertRaisesMessage(AnymailUnsupportedFeature, 'metadata'):
|
||||||
|
self.message.send()
|
||||||
|
|
||||||
|
def test_send_at(self):
|
||||||
|
self.message.send_at = 1651820889 # 2022-05-06 07:08:09 UTC
|
||||||
|
with self.assertRaisesMessage(AnymailUnsupportedFeature, 'send_at'):
|
||||||
|
self.message.send()
|
||||||
|
|
||||||
|
def test_tags(self):
|
||||||
|
self.message.tags = ["receipt"]
|
||||||
|
self.message.send()
|
||||||
|
data = self.get_api_call_json()
|
||||||
|
self.assertEqual(data['tag'], "receipt")
|
||||||
|
|
||||||
|
self.message.tags = ["receipt", "repeat-user"]
|
||||||
|
with self.assertRaisesMessage(AnymailUnsupportedFeature, 'multiple tags'):
|
||||||
|
self.message.send()
|
||||||
|
|
||||||
|
def test_track_opens(self):
|
||||||
|
self.message.track_opens = True
|
||||||
|
with self.assertRaisesMessage(AnymailUnsupportedFeature, 'track_opens'):
|
||||||
|
self.message.send()
|
||||||
|
|
||||||
|
def test_track_clicks(self):
|
||||||
|
self.message.track_clicks = True
|
||||||
|
with self.assertRaisesMessage(AnymailUnsupportedFeature, 'track_clicks'):
|
||||||
|
self.message.send()
|
||||||
|
|
||||||
|
def test_default_omits_options(self):
|
||||||
|
"""Make sure by default we don't send any ESP-specific options.
|
||||||
|
|
||||||
|
Options not specified by the caller should be omitted entirely from
|
||||||
|
the API call (*not* sent as False or empty). This ensures
|
||||||
|
that your ESP account settings apply by default.
|
||||||
|
"""
|
||||||
|
self.message.send()
|
||||||
|
data = self.get_api_call_json()
|
||||||
|
self.assertNotIn('tag', data)
|
||||||
|
|
||||||
|
def test_esp_extra(self):
|
||||||
|
self.message.esp_extra = {
|
||||||
|
'future_postal_option': 'some-value',
|
||||||
|
}
|
||||||
|
self.message.send()
|
||||||
|
data = self.get_api_call_json()
|
||||||
|
self.assertEqual(data['future_postal_option'], 'some-value')
|
||||||
|
|
||||||
|
# noinspection PyUnresolvedReferences
|
||||||
|
def test_send_attaches_anymail_status(self):
|
||||||
|
""" The anymail_status should be attached to the message when it is sent """
|
||||||
|
response_content = b"""{
|
||||||
|
"status": "success",
|
||||||
|
"time": 1.08,
|
||||||
|
"flags": {},
|
||||||
|
"data": {
|
||||||
|
"message_id": "9dfcc4df-09a6-4f1d-b535-0eb0a9f104a4@postal.example.com",
|
||||||
|
"messages": {
|
||||||
|
"to1@example.com": { "id": 1531, "token": "xLcafDRCVUFe" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}"""
|
||||||
|
self.set_mock_response(raw=response_content)
|
||||||
|
msg = mail.EmailMessage('Subject', 'Message', 'from@example.com', ['Recipient <to1@example.com>'],)
|
||||||
|
sent = msg.send()
|
||||||
|
self.assertEqual(sent, 1)
|
||||||
|
self.assertEqual(msg.anymail_status.status, {'queued'})
|
||||||
|
self.assertEqual(msg.anymail_status.message_id, 1531)
|
||||||
|
self.assertEqual(msg.anymail_status.recipients['to1@example.com'].status, 'queued')
|
||||||
|
self.assertEqual(msg.anymail_status.recipients['to1@example.com'].message_id,
|
||||||
|
1531)
|
||||||
|
self.assertEqual(msg.anymail_status.esp_response.content, response_content)
|
||||||
|
|
||||||
|
# noinspection PyUnresolvedReferences
|
||||||
|
def test_send_without_to_attaches_anymail_status(self):
|
||||||
|
"""The anymail_status should be attached even if there are no `to` recipients"""
|
||||||
|
response_content = b"""{
|
||||||
|
"status": "success",
|
||||||
|
"time": 1.08,
|
||||||
|
"flags": {},
|
||||||
|
"data": {
|
||||||
|
"message_id": "9dfcc4df-09a6-4f1d-b535-0eb0a9f104a4@postal.example.com",
|
||||||
|
"messages": {
|
||||||
|
"cc@example.com": { "id": 1531, "token": "xLcafDRCVUFe" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}"""
|
||||||
|
self.set_mock_response(raw=response_content)
|
||||||
|
msg = mail.EmailMessage('Subject', 'Message', 'from@example.com', cc=['cc@example.com'],)
|
||||||
|
sent = msg.send()
|
||||||
|
self.assertEqual(sent, 1)
|
||||||
|
self.assertEqual(msg.anymail_status.status, {'queued'})
|
||||||
|
self.assertEqual(msg.anymail_status.message_id, 1531)
|
||||||
|
self.assertEqual(msg.anymail_status.recipients['cc@example.com'].status, 'queued')
|
||||||
|
self.assertEqual(msg.anymail_status.recipients['cc@example.com'].message_id,
|
||||||
|
1531)
|
||||||
|
self.assertEqual(msg.anymail_status.esp_response.content, response_content)
|
||||||
|
|
||||||
|
# noinspection PyUnresolvedReferences
|
||||||
|
def test_send_failed_anymail_status(self):
|
||||||
|
""" If the send fails, anymail_status should contain initial values"""
|
||||||
|
self.set_mock_response(status_code=500)
|
||||||
|
sent = self.message.send(fail_silently=True)
|
||||||
|
self.assertEqual(sent, 0)
|
||||||
|
self.assertIsNone(self.message.anymail_status.status)
|
||||||
|
self.assertIsNone(self.message.anymail_status.message_id)
|
||||||
|
self.assertEqual(self.message.anymail_status.recipients, {})
|
||||||
|
self.assertIsNone(self.message.anymail_status.esp_response)
|
||||||
|
|
||||||
|
# noinspection PyUnresolvedReferences
|
||||||
|
def test_send_unparsable_response(self):
|
||||||
|
"""If the send succeeds, but a non-JSON API response, should raise an API exception"""
|
||||||
|
mock_response = self.set_mock_response(status_code=200,
|
||||||
|
raw=b"yikes, this isn't a real response")
|
||||||
|
with self.assertRaises(AnymailAPIError):
|
||||||
|
self.message.send()
|
||||||
|
self.assertIsNone(self.message.anymail_status.status)
|
||||||
|
self.assertIsNone(self.message.anymail_status.message_id)
|
||||||
|
self.assertEqual(self.message.anymail_status.recipients, {})
|
||||||
|
self.assertEqual(self.message.anymail_status.esp_response, mock_response)
|
||||||
|
|
||||||
|
def test_json_serialization_errors(self):
|
||||||
|
"""Try to provide more information about non-json-serializable data"""
|
||||||
|
self.message.tags = [Decimal('19.99')] # yeah, don't do this
|
||||||
|
with self.assertRaises(AnymailSerializationError) as cm:
|
||||||
|
self.message.send()
|
||||||
|
print(self.get_api_call_json())
|
||||||
|
err = cm.exception
|
||||||
|
self.assertIsInstance(err, TypeError) # compatibility with json.dumps
|
||||||
|
self.assertIn("Don't know how to send this data to Postal", str(err)) # our added context
|
||||||
|
self.assertRegex(str(err), r"Decimal.*is not JSON serializable") # original message
|
||||||
|
|
||||||
|
|
||||||
|
@tag('postal')
|
||||||
|
class PostalBackendRecipientsRefusedTests(PostalBackendMockAPITestCase):
|
||||||
|
# Postal doesn't check email bounce or complaint lists at time of send --
|
||||||
|
# it always just queues the message. You'll need to listen for the "rejected"
|
||||||
|
# and "failed" events to detect refused recipients.
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@tag('postal')
|
||||||
|
class PostalBackendSessionSharingTestCase(SessionSharingTestCases, PostalBackendMockAPITestCase):
|
||||||
|
"""Requests session sharing tests"""
|
||||||
|
pass # tests are defined in SessionSharingTestCases
|
||||||
|
|
||||||
|
|
||||||
|
@tag('postal')
|
||||||
|
@override_settings(EMAIL_BACKEND="anymail.backends.postal.EmailBackend")
|
||||||
|
class PostalBackendImproperlyConfiguredTests(AnymailTestMixin, SimpleTestCase):
|
||||||
|
"""Test ESP backend without required settings in place"""
|
||||||
|
|
||||||
|
def test_missing_api_key(self):
|
||||||
|
with self.assertRaises(ImproperlyConfigured) as cm:
|
||||||
|
mail.send_mail('Subject', 'Message', 'from@example.com', ['to@example.com'])
|
||||||
|
errmsg = str(cm.exception)
|
||||||
|
self.assertRegex(errmsg, r'\bPOSTAL_API_KEY\b')
|
||||||
|
self.assertRegex(errmsg, r'\bANYMAIL_POSTAL_API_KEY\b')
|
||||||
183
tests/test_postal_inbound.py
Normal file
183
tests/test_postal_inbound.py
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import json
|
||||||
|
import unittest
|
||||||
|
from base64 import b64encode
|
||||||
|
from textwrap import dedent
|
||||||
|
|
||||||
|
from django.test import tag
|
||||||
|
from mock import ANY
|
||||||
|
|
||||||
|
from anymail.exceptions import AnymailConfigurationError
|
||||||
|
from anymail.inbound import AnymailInboundMessage
|
||||||
|
from anymail.signals import AnymailInboundEvent
|
||||||
|
from anymail.webhooks.postal import PostalInboundWebhookView
|
||||||
|
from .utils import sample_image_content, sample_email_content
|
||||||
|
from .utils_postal import ClientWithPostalSignature, make_key
|
||||||
|
from .webhook_cases import WebhookTestCase
|
||||||
|
|
||||||
|
|
||||||
|
@tag('postal')
|
||||||
|
@unittest.skipUnless(ClientWithPostalSignature, "Install 'cryptography' to run postal webhook tests")
|
||||||
|
class PostalInboundTestCase(WebhookTestCase):
|
||||||
|
client_class = ClientWithPostalSignature
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.clear_basic_auth()
|
||||||
|
|
||||||
|
self.client.set_private_key(make_key())
|
||||||
|
|
||||||
|
def test_inbound_basics(self):
|
||||||
|
raw_event = {
|
||||||
|
"id": 233980,
|
||||||
|
"rcpt_to": "test@inbound.example.com",
|
||||||
|
"mail_from": "envelope-from@example.org",
|
||||||
|
"message": b64encode(dedent("""\
|
||||||
|
Received: from mail.example.org by postal.example.com ...
|
||||||
|
Received: by mail.example.org for <test@inbound.example.com> ...
|
||||||
|
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.org; ...
|
||||||
|
MIME-Version: 1.0
|
||||||
|
Received: by 10.10.1.71 with HTTP; Wed, 11 Oct 2017 18:31:04 -0700 (PDT)
|
||||||
|
From: "Displayed From" <from+test@example.org>
|
||||||
|
Date: Wed, 11 Oct 2017 18:31:04 -0700
|
||||||
|
Message-ID: <CAEPk3R+4Zr@mail.example.org>
|
||||||
|
Subject: Test subject
|
||||||
|
To: "Test Inbound" <test@inbound.example.com>, other@example.com
|
||||||
|
Cc: cc@example.com
|
||||||
|
Content-Type: multipart/alternative; boundary="94eb2c05e174adb140055b6339c5"
|
||||||
|
|
||||||
|
--94eb2c05e174adb140055b6339c5
|
||||||
|
Content-Type: text/plain; charset="UTF-8"
|
||||||
|
Content-Transfer-Encoding: quoted-printable
|
||||||
|
|
||||||
|
It's a body=E2=80=A6
|
||||||
|
|
||||||
|
--94eb2c05e174adb140055b6339c5
|
||||||
|
Content-Type: text/html; charset="UTF-8"
|
||||||
|
Content-Transfer-Encoding: quoted-printable
|
||||||
|
|
||||||
|
<div dir=3D"ltr">It's a body=E2=80=A6</div>
|
||||||
|
|
||||||
|
--94eb2c05e174adb140055b6339c5--
|
||||||
|
""").encode('utf-8')).decode('ascii'),
|
||||||
|
"base64": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
response = self.client.post('/anymail/postal/inbound/',
|
||||||
|
content_type='application/json', data=json.dumps(raw_event))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
kwargs = self.assert_handler_called_once_with(self.inbound_handler, sender=PostalInboundWebhookView,
|
||||||
|
event=ANY, esp_name='Postal')
|
||||||
|
# AnymailInboundEvent
|
||||||
|
event = kwargs['event']
|
||||||
|
self.assertIsInstance(event, AnymailInboundEvent)
|
||||||
|
self.assertEqual(event.event_type, 'inbound')
|
||||||
|
self.assertIsNone(event.timestamp) # Postal doesn't provide inbound event timestamp
|
||||||
|
self.assertEqual(event.event_id, 233980)
|
||||||
|
self.assertIsInstance(event.message, AnymailInboundMessage)
|
||||||
|
self.assertEqual(event.esp_event, raw_event)
|
||||||
|
|
||||||
|
# AnymailInboundMessage - convenience properties
|
||||||
|
message = event.message
|
||||||
|
|
||||||
|
self.assertEqual(message.from_email.display_name, 'Displayed From')
|
||||||
|
self.assertEqual(message.from_email.addr_spec, 'from+test@example.org')
|
||||||
|
self.assertEqual([str(e) for e in message.to],
|
||||||
|
['Test Inbound <test@inbound.example.com>', 'other@example.com'])
|
||||||
|
self.assertEqual([str(e) for e in message.cc],
|
||||||
|
['cc@example.com'])
|
||||||
|
self.assertEqual(message.subject, 'Test subject')
|
||||||
|
self.assertEqual(message.date.isoformat(" "), "2017-10-11 18:31:04-07:00")
|
||||||
|
self.assertEqual(message.text, "It's a body\N{HORIZONTAL ELLIPSIS}\n")
|
||||||
|
self.assertEqual(message.html, """<div dir="ltr">It's a body\N{HORIZONTAL ELLIPSIS}</div>\n""")
|
||||||
|
|
||||||
|
self.assertEqual(message.envelope_sender, 'envelope-from@example.org')
|
||||||
|
self.assertEqual(message.envelope_recipient, 'test@inbound.example.com')
|
||||||
|
self.assertIsNone(message.stripped_text)
|
||||||
|
self.assertIsNone(message.stripped_html)
|
||||||
|
self.assertIsNone(message.spam_detected)
|
||||||
|
self.assertIsNone(message.spam_score)
|
||||||
|
|
||||||
|
# AnymailInboundMessage - other headers
|
||||||
|
self.assertEqual(message['Message-ID'], "<CAEPk3R+4Zr@mail.example.org>")
|
||||||
|
self.assertEqual(message.get_all('Received'), [
|
||||||
|
"from mail.example.org by postal.example.com ...",
|
||||||
|
"by mail.example.org for <test@inbound.example.com> ...",
|
||||||
|
"by 10.10.1.71 with HTTP; Wed, 11 Oct 2017 18:31:04 -0700 (PDT)",
|
||||||
|
])
|
||||||
|
|
||||||
|
def test_attachments(self):
|
||||||
|
image_content = sample_image_content()
|
||||||
|
email_content = sample_email_content()
|
||||||
|
raw_mime = dedent("""\
|
||||||
|
MIME-Version: 1.0
|
||||||
|
From: from@example.org
|
||||||
|
Subject: Attachments
|
||||||
|
To: test@inbound.example.com
|
||||||
|
Content-Type: multipart/mixed; boundary="boundary0"
|
||||||
|
|
||||||
|
--boundary0
|
||||||
|
Content-Type: multipart/related; boundary="boundary1"
|
||||||
|
|
||||||
|
--boundary1
|
||||||
|
Content-Type: text/html; charset="UTF-8"
|
||||||
|
|
||||||
|
<div>This is the HTML body. It has an inline image: <img src="cid:abc123">.</div>
|
||||||
|
|
||||||
|
--boundary1
|
||||||
|
Content-Type: image/png
|
||||||
|
Content-Disposition: inline; filename="image.png"
|
||||||
|
Content-ID: <abc123>
|
||||||
|
Content-Transfer-Encoding: base64
|
||||||
|
|
||||||
|
{image_content_base64}
|
||||||
|
--boundary1--
|
||||||
|
--boundary0
|
||||||
|
Content-Type: text/plain; charset="UTF-8"
|
||||||
|
Content-Disposition: attachment; filename="test.txt"
|
||||||
|
|
||||||
|
test attachment
|
||||||
|
--boundary0
|
||||||
|
Content-Type: message/rfc822; charset="US-ASCII"
|
||||||
|
Content-Disposition: attachment
|
||||||
|
X-Comment: (the only valid transfer encodings for message/* are 7bit, 8bit, and binary)
|
||||||
|
|
||||||
|
{email_content}
|
||||||
|
--boundary0--
|
||||||
|
""").format(image_content_base64=b64encode(image_content).decode('ascii'),
|
||||||
|
email_content=email_content.decode('ascii'))
|
||||||
|
|
||||||
|
raw_event = {
|
||||||
|
"id": 233980,
|
||||||
|
"rcpt_to": "test@inbound.example.com",
|
||||||
|
"mail_from": "envelope-from@example.org",
|
||||||
|
"message": b64encode(raw_mime.encode('utf-8')).decode('ascii'),
|
||||||
|
"base64": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
response = self.client.post('/anymail/postal/inbound/',
|
||||||
|
content_type='application/json', data=json.dumps(raw_event))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
kwargs = self.assert_handler_called_once_with(self.inbound_handler, sender=PostalInboundWebhookView,
|
||||||
|
event=ANY, esp_name='Postal')
|
||||||
|
event = kwargs['event']
|
||||||
|
message = event.message
|
||||||
|
attachments = message.attachments # AnymailInboundMessage convenience accessor
|
||||||
|
self.assertEqual(len(attachments), 2)
|
||||||
|
self.assertEqual(attachments[0].get_filename(), 'test.txt')
|
||||||
|
self.assertEqual(attachments[0].get_content_type(), 'text/plain')
|
||||||
|
self.assertEqual(attachments[0].get_content_text(), 'test attachment')
|
||||||
|
self.assertEqual(attachments[1].get_content_type(), 'message/rfc822')
|
||||||
|
self.assertEqualIgnoringHeaderFolding(attachments[1].get_content_bytes(), email_content)
|
||||||
|
|
||||||
|
inlines = message.inline_attachments
|
||||||
|
self.assertEqual(len(inlines), 1)
|
||||||
|
inline = inlines['abc123']
|
||||||
|
self.assertEqual(inline.get_filename(), 'image.png')
|
||||||
|
self.assertEqual(inline.get_content_type(), 'image/png')
|
||||||
|
self.assertEqual(inline.get_content_bytes(), image_content)
|
||||||
|
|
||||||
|
def test_misconfigured_tracking(self):
|
||||||
|
errmsg = "You seem to have set Postal's *tracking* webhook to Anymail's Postal *inbound* webhook URL."
|
||||||
|
with self.assertRaisesMessage(AnymailConfigurationError, errmsg):
|
||||||
|
self.client.post('/anymail/postal/inbound/', content_type='application/json',
|
||||||
|
data=json.dumps({"status": "Held"}))
|
||||||
96
tests/test_postal_integration.py
Normal file
96
tests/test_postal_integration.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from django.test import SimpleTestCase, override_settings, tag
|
||||||
|
|
||||||
|
from anymail.exceptions import AnymailAPIError
|
||||||
|
from anymail.message import AnymailMessage
|
||||||
|
|
||||||
|
from .utils import AnymailTestMixin
|
||||||
|
|
||||||
|
|
||||||
|
ANYMAIL_TEST_POSTAL_API_KEY = os.getenv('ANYMAIL_TEST_POSTAL_API_KEY')
|
||||||
|
ANYMAIL_TEST_POSTAL_API_URL = os.getenv('ANYMAIL_TEST_POSTAL_API_URL')
|
||||||
|
|
||||||
|
|
||||||
|
@tag('postal', 'live')
|
||||||
|
@unittest.skipUnless(ANYMAIL_TEST_POSTAL_API_KEY and ANYMAIL_TEST_POSTAL_API_URL,
|
||||||
|
"Set ANYMAIL_TEST_POSTAL_API_KEY and ANYMAIL_TEST_POSTAL_API_URL "
|
||||||
|
"environment variables to run Postal integration tests")
|
||||||
|
@override_settings(ANYMAIL_POSTAL_API_KEY=ANYMAIL_TEST_POSTAL_API_KEY,
|
||||||
|
ANYMAIL_POSTAL_API_URL=ANYMAIL_TEST_POSTAL_API_URL,
|
||||||
|
EMAIL_BACKEND="anymail.backends.postal.EmailBackend")
|
||||||
|
class PostalBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
|
||||||
|
"""Postal API integration tests
|
||||||
|
|
||||||
|
These tests run against the **live** Postal API, using the
|
||||||
|
environment variable `ANYMAIL_TEST_POSTAL_API_KEY` as the API key and
|
||||||
|
`ANYMAIL_TEST_POSTAL_API_URL` as server url.
|
||||||
|
If these variables are not set, these tests won't run.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.message = AnymailMessage('Anymail Postal integration test', 'Text content',
|
||||||
|
'from@example.com', ['test+to1@anymail.info'])
|
||||||
|
self.message.attach_alternative('<p>HTML content</p>', "text/html")
|
||||||
|
|
||||||
|
def test_simple_send(self):
|
||||||
|
# Example of getting the Postal send status and message id from the message
|
||||||
|
sent_count = self.message.send()
|
||||||
|
self.assertEqual(sent_count, 1)
|
||||||
|
|
||||||
|
anymail_status = self.message.anymail_status
|
||||||
|
sent_status = anymail_status.recipients['test+to1@anymail.info'].status
|
||||||
|
message_id = anymail_status.recipients['test+to1@anymail.info'].message_id
|
||||||
|
|
||||||
|
self.assertEqual(sent_status, 'queued')
|
||||||
|
self.assertGreater(len(message_id), 0) # non-empty string
|
||||||
|
self.assertEqual(anymail_status.status, {sent_status}) # set of all recipient statuses
|
||||||
|
self.assertEqual(anymail_status.message_id, message_id)
|
||||||
|
|
||||||
|
def test_all_options(self):
|
||||||
|
message = AnymailMessage(
|
||||||
|
subject="Anymail Postal all-options integration test",
|
||||||
|
body="This is the text body",
|
||||||
|
from_email="Test From <from@example.com>",
|
||||||
|
envelope_sender="bounces@example.com",
|
||||||
|
to=["test+to1@anymail.info", "Recipient 2 <test+to2@anymail.info>"],
|
||||||
|
cc=["test+cc1@anymail.info", "Copy 2 <test+cc2@anymail.info>"],
|
||||||
|
bcc=["test+bcc1@anymail.info", "Blind Copy 2 <test+bcc2@anymail.info>"],
|
||||||
|
reply_to=["reply1@example.com"],
|
||||||
|
headers={"X-Anymail-Test": "value"},
|
||||||
|
tags=["tag 1"], # max one tag
|
||||||
|
)
|
||||||
|
message.attach("attachment1.txt", "Here is some\ntext for you", "text/plain")
|
||||||
|
message.attach("attachment2.csv", "ID,Name\n1,Amy Lina", "text/csv")
|
||||||
|
|
||||||
|
message.send()
|
||||||
|
self.assertEqual(message.anymail_status.status, {'queued'})
|
||||||
|
self.assertEqual(message.anymail_status.recipients['test+to1@anymail.info'].status, 'queued')
|
||||||
|
self.assertEqual(message.anymail_status.recipients['test+to2@anymail.info'].status, 'queued')
|
||||||
|
# distinct messages should have different message_ids:
|
||||||
|
self.assertNotEqual(message.anymail_status.recipients['test+to1@anymail.info'].message_id,
|
||||||
|
message.anymail_status.recipients['teset+to2@anymail.info'].message_id)
|
||||||
|
|
||||||
|
def test_invalid_from(self):
|
||||||
|
self.message.from_email = 'webmaster@localhost' # Django's default From
|
||||||
|
with self.assertRaises(AnymailAPIError) as cm:
|
||||||
|
self.message.send()
|
||||||
|
err = cm.exception
|
||||||
|
response = err.response.json()
|
||||||
|
self.assertEqual(err.status_code, 200)
|
||||||
|
self.assertEqual(response['status'], 'error')
|
||||||
|
self.assertIn("The From address is not authorised to send mail from this server", response['data']['message'])
|
||||||
|
self.assertIn("UnauthenticatedFromAddress", response['data']['code'])
|
||||||
|
|
||||||
|
@override_settings(ANYMAIL_POSTAL_API_KEY="Hey, that's not an API key!")
|
||||||
|
def test_invalid_server_token(self):
|
||||||
|
with self.assertRaises(AnymailAPIError) as cm:
|
||||||
|
self.message.send()
|
||||||
|
err = cm.exception
|
||||||
|
response = err.response.json()
|
||||||
|
self.assertEqual(err.status_code, 200)
|
||||||
|
self.assertEqual(response['status'], 'error')
|
||||||
|
self.assertIn("The API token provided in X-Server-API-Key was not valid.", response['data']['message'])
|
||||||
|
self.assertIn("InvalidServerAPIKey", response['data']['code'])
|
||||||
324
tests/test_postal_webhooks.py
Normal file
324
tests/test_postal_webhooks.py
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
import json
|
||||||
|
import unittest
|
||||||
|
from base64 import b64encode
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from django.test import tag
|
||||||
|
from django.utils.timezone import utc
|
||||||
|
from mock import ANY
|
||||||
|
|
||||||
|
from anymail.exceptions import AnymailConfigurationError
|
||||||
|
from anymail.signals import AnymailTrackingEvent
|
||||||
|
from anymail.webhooks.postal import PostalTrackingWebhookView
|
||||||
|
from .utils_postal import ClientWithPostalSignature, make_key
|
||||||
|
from .webhook_cases import WebhookTestCase
|
||||||
|
|
||||||
|
|
||||||
|
@tag('postal')
|
||||||
|
@unittest.skipUnless(ClientWithPostalSignature, "Install 'cryptography' to run postal webhook tests")
|
||||||
|
class PostalWebhookSecurityTestCase(WebhookTestCase):
|
||||||
|
client_class = ClientWithPostalSignature
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.clear_basic_auth()
|
||||||
|
|
||||||
|
self.client.set_private_key(make_key())
|
||||||
|
|
||||||
|
def test_failed_signature_check(self):
|
||||||
|
response = self.client.post('/anymail/postal/tracking/',
|
||||||
|
content_type='application/json', data=json.dumps({'some': 'data'}),
|
||||||
|
HTTP_X_POSTAL_SIGNATURE=b64encode('invalid'.encode('utf-8')))
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
response = self.client.post('/anymail/postal/tracking/',
|
||||||
|
content_type='application/json', data=json.dumps({'some': 'data'}),
|
||||||
|
HTTP_X_POSTAL_SIGNATURE='garbage')
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
response = self.client.post('/anymail/postal/tracking/',
|
||||||
|
content_type='application/json', data=json.dumps({'some': 'data'}),
|
||||||
|
HTTP_X_POSTAL_SIGNATURE='')
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
|
||||||
|
@tag('postal')
|
||||||
|
@unittest.skipUnless(ClientWithPostalSignature, "Install 'cryptography' to run postal webhook tests")
|
||||||
|
class PostalDeliveryTestCase(WebhookTestCase):
|
||||||
|
client_class = ClientWithPostalSignature
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.clear_basic_auth()
|
||||||
|
|
||||||
|
self.client.set_private_key(make_key())
|
||||||
|
|
||||||
|
def test_bounce_event(self):
|
||||||
|
raw_event = {
|
||||||
|
"event": "MessageDelayed",
|
||||||
|
"timestamp": 1606753101.961181,
|
||||||
|
"payload": {
|
||||||
|
"original_message": {
|
||||||
|
"id": 233843,
|
||||||
|
"token": "McC2tuqg7mhx",
|
||||||
|
"direction": "outgoing",
|
||||||
|
"message_id": "7b82aac4-5d63-41b8-8e35-9faa31a892dc@rp.postal.example.com",
|
||||||
|
"to": "bounce@example.com",
|
||||||
|
"from": "sender@example.com",
|
||||||
|
"subject": "...",
|
||||||
|
"timestamp": 1606436187.8883688,
|
||||||
|
"spam_status": "NotChecked",
|
||||||
|
"tag": None
|
||||||
|
},
|
||||||
|
"bounce": {
|
||||||
|
"id": 233864,
|
||||||
|
"token": "nII5p0Cp8onV",
|
||||||
|
"direction": "incoming",
|
||||||
|
"message_id": "E1kiRR8-0001ay-Iq@example.com",
|
||||||
|
"to": "bk87jw@psrp.postal.example.com",
|
||||||
|
"from": None,
|
||||||
|
"subject": "Mail delivery failed: returning message to sender",
|
||||||
|
"timestamp": 1606436523.6060522,
|
||||||
|
"spam_status": "NotChecked",
|
||||||
|
"tag": None
|
||||||
|
},
|
||||||
|
"details": "details",
|
||||||
|
"output": "server output",
|
||||||
|
"sent_with_ssl": None,
|
||||||
|
"timestamp": 1606753101.9110143,
|
||||||
|
"time": None
|
||||||
|
},
|
||||||
|
"uuid": "0fcc831f-92b9-4e2b-97f2-d873abc77fab"
|
||||||
|
}
|
||||||
|
|
||||||
|
response = self.client.post('/anymail/postal/tracking/',
|
||||||
|
content_type='application/json', data=json.dumps(raw_event))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=PostalTrackingWebhookView,
|
||||||
|
event=ANY, esp_name='Postal')
|
||||||
|
event = kwargs['event']
|
||||||
|
self.assertIsInstance(event, AnymailTrackingEvent)
|
||||||
|
self.assertEqual(event.event_type, "bounced")
|
||||||
|
self.assertEqual(event.esp_event, raw_event)
|
||||||
|
self.assertEqual(event.timestamp, datetime.fromtimestamp(1606753101, tz=utc))
|
||||||
|
self.assertEqual(event.message_id, 233843)
|
||||||
|
self.assertEqual(event.event_id, "0fcc831f-92b9-4e2b-97f2-d873abc77fab")
|
||||||
|
self.assertEqual(event.recipient, "bounce@example.com")
|
||||||
|
self.assertEqual(event.reject_reason, "bounced")
|
||||||
|
self.assertEqual(event.description,
|
||||||
|
"details")
|
||||||
|
self.assertEqual(event.mta_response,
|
||||||
|
"server output")
|
||||||
|
|
||||||
|
def test_deferred_event(self):
|
||||||
|
raw_event = {
|
||||||
|
"event": "MessageDelayed",
|
||||||
|
"timestamp": 1606753101.961181,
|
||||||
|
"payload": {
|
||||||
|
"message": {
|
||||||
|
"id": 1564,
|
||||||
|
"token": "Kmo8CRdjuM7B",
|
||||||
|
"direction": "outgoing",
|
||||||
|
"message_id": "7b095c0e-2c98-4e68-a41f-7bd217a83925@rp.postal.example.com",
|
||||||
|
"to": "deferred@example.com",
|
||||||
|
"from": "test@postal.example.com",
|
||||||
|
"subject": "Test Message at November 30, 2020 16:03",
|
||||||
|
"timestamp": 1606752235.195664,
|
||||||
|
"spam_status": "NotChecked",
|
||||||
|
"tag": None
|
||||||
|
},
|
||||||
|
"status": "SoftFail",
|
||||||
|
"details": "details",
|
||||||
|
"output": "server output",
|
||||||
|
"sent_with_ssl": None,
|
||||||
|
"timestamp": 1606753101.9110143,
|
||||||
|
"time": None
|
||||||
|
},
|
||||||
|
"uuid": "0fcc831f-92b9-4e2b-97f2-d873abc77fab"
|
||||||
|
}
|
||||||
|
response = self.client.post('/anymail/postal/tracking/',
|
||||||
|
content_type='application/json', data=json.dumps(raw_event))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=PostalTrackingWebhookView,
|
||||||
|
event=ANY, esp_name='Postal')
|
||||||
|
event = kwargs['event']
|
||||||
|
self.assertIsInstance(event, AnymailTrackingEvent)
|
||||||
|
self.assertEqual(event.event_type, "deferred")
|
||||||
|
self.assertEqual(event.esp_event, raw_event)
|
||||||
|
self.assertEqual(event.timestamp, datetime.fromtimestamp(1606753101, tz=utc))
|
||||||
|
self.assertEqual(event.message_id, 1564)
|
||||||
|
self.assertEqual(event.event_id, "0fcc831f-92b9-4e2b-97f2-d873abc77fab")
|
||||||
|
self.assertEqual(event.recipient, "deferred@example.com")
|
||||||
|
self.assertEqual(event.reject_reason, None)
|
||||||
|
self.assertEqual(event.description,
|
||||||
|
"details")
|
||||||
|
self.assertEqual(event.mta_response,
|
||||||
|
"server output")
|
||||||
|
|
||||||
|
def test_queued_event(self):
|
||||||
|
raw_event = {
|
||||||
|
"event": "MessageHeld",
|
||||||
|
"timestamp": 1606753101.330977,
|
||||||
|
"payload": {
|
||||||
|
"message": {
|
||||||
|
"id": 1568,
|
||||||
|
"token": "VRvQMS20Bb4Y",
|
||||||
|
"direction": "outgoing",
|
||||||
|
"message_id": "ec7b6375-4045-451a-9503-2a23a607c1c1@rp.postal.example.com",
|
||||||
|
"to": "suppressed@example.com",
|
||||||
|
"from": "test@example.com",
|
||||||
|
"subject": "Test Message at November 30, 2020 16:12",
|
||||||
|
"timestamp": 1606752750.993815,
|
||||||
|
"spam_status": "NotChecked",
|
||||||
|
"tag": None
|
||||||
|
},
|
||||||
|
"status": "Held",
|
||||||
|
"details": "Recipient (suppressed@example.com) is on the suppression list",
|
||||||
|
"output": "server output",
|
||||||
|
"sent_with_ssl": None,
|
||||||
|
"timestamp": 1606752751.8933666,
|
||||||
|
"time": None
|
||||||
|
},
|
||||||
|
"uuid": "9be13015-2e54-456c-bf66-eacbe33da824"
|
||||||
|
}
|
||||||
|
response = self.client.post('/anymail/postal/tracking/',
|
||||||
|
content_type='application/json', data=json.dumps(raw_event))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=PostalTrackingWebhookView,
|
||||||
|
event=ANY, esp_name='Postal')
|
||||||
|
event = kwargs['event']
|
||||||
|
self.assertIsInstance(event, AnymailTrackingEvent)
|
||||||
|
self.assertEqual(event.event_type, "queued")
|
||||||
|
self.assertEqual(event.esp_event, raw_event)
|
||||||
|
self.assertEqual(event.timestamp, datetime.fromtimestamp(1606753101, tz=utc))
|
||||||
|
self.assertEqual(event.message_id, 1568)
|
||||||
|
self.assertEqual(event.event_id, "9be13015-2e54-456c-bf66-eacbe33da824")
|
||||||
|
self.assertEqual(event.recipient, "suppressed@example.com")
|
||||||
|
self.assertEqual(event.reject_reason, None)
|
||||||
|
self.assertEqual(event.description,
|
||||||
|
"Recipient (suppressed@example.com) is on the suppression list")
|
||||||
|
self.assertEqual(event.mta_response,
|
||||||
|
"server output")
|
||||||
|
|
||||||
|
def test_failed_event(self):
|
||||||
|
raw_event = {
|
||||||
|
"event": "MessageDeliveryFailed",
|
||||||
|
"timestamp": 1606753101.084981,
|
||||||
|
"payload": {
|
||||||
|
"message": {
|
||||||
|
"id": 1571,
|
||||||
|
"token": "MzWWQPubXXWz",
|
||||||
|
"direction": "outgoing",
|
||||||
|
"message_id": "cfb29da8ed1e4ed5a6c8a0f24d7a9ef3@rp.postal.example.com",
|
||||||
|
"to": "failed@example.com",
|
||||||
|
"from": "test@example.com",
|
||||||
|
"subject": "Message delivery failed...",
|
||||||
|
"timestamp": 1606753318.072171,
|
||||||
|
"spam_status": "NotChecked",
|
||||||
|
"tag": None
|
||||||
|
},
|
||||||
|
"status": "HardFail",
|
||||||
|
"details": "Could not deliver",
|
||||||
|
"output": "server output",
|
||||||
|
"sent_with_ssl": None,
|
||||||
|
"timestamp": 1606753318.7010343,
|
||||||
|
"time": None
|
||||||
|
},
|
||||||
|
"uuid": "5fec5077-dae7-4989-94d5-e1963f3e9181"
|
||||||
|
}
|
||||||
|
response = self.client.post('/anymail/postal/tracking/',
|
||||||
|
content_type='application/json', data=json.dumps(raw_event))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=PostalTrackingWebhookView,
|
||||||
|
event=ANY, esp_name='Postal')
|
||||||
|
event = kwargs['event']
|
||||||
|
self.assertIsInstance(event, AnymailTrackingEvent)
|
||||||
|
self.assertEqual(event.event_type, "failed")
|
||||||
|
self.assertEqual(event.esp_event, raw_event)
|
||||||
|
self.assertEqual(event.timestamp, datetime.fromtimestamp(1606753101, tz=utc))
|
||||||
|
self.assertEqual(event.message_id, 1571)
|
||||||
|
self.assertEqual(event.event_id, "5fec5077-dae7-4989-94d5-e1963f3e9181")
|
||||||
|
self.assertEqual(event.recipient, "failed@example.com")
|
||||||
|
self.assertEqual(event.reject_reason, None)
|
||||||
|
self.assertEqual(event.description,
|
||||||
|
"Could not deliver")
|
||||||
|
self.assertEqual(event.mta_response,
|
||||||
|
"server output")
|
||||||
|
|
||||||
|
def test_delivered_event(self):
|
||||||
|
raw_event = {
|
||||||
|
"event": "MessageSent",
|
||||||
|
"timestamp": 1606753101.354368,
|
||||||
|
"payload": {
|
||||||
|
"message": {
|
||||||
|
"id": 1563,
|
||||||
|
"token": "zw6psSlgo6ki",
|
||||||
|
"direction": "outgoing",
|
||||||
|
"message_id": "c462ad36-be49-469c-b7b2-dfd317eb40fa@rp.postal.example.com",
|
||||||
|
"to": "recipient@example.com",
|
||||||
|
"from": "test@example.com",
|
||||||
|
"subject": "Test Message at November 30, 2020 16:01",
|
||||||
|
"timestamp": 1606752104.699201,
|
||||||
|
"spam_status": "NotChecked",
|
||||||
|
"tag": "welcome-email"
|
||||||
|
},
|
||||||
|
"status": "Sent",
|
||||||
|
"details": "Message for recipient@example.com accepted",
|
||||||
|
"output": "250 2.0.0 OK\n",
|
||||||
|
"sent_with_ssl": False,
|
||||||
|
"timestamp": 1606752106.9858062,
|
||||||
|
"time": 0.89
|
||||||
|
},
|
||||||
|
"uuid": "58e8d7ee-2cd5-4db2-9af3-3f436105795a"
|
||||||
|
}
|
||||||
|
response = self.client.post('/anymail/postal/tracking/',
|
||||||
|
content_type='application/json', data=json.dumps(raw_event))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=PostalTrackingWebhookView,
|
||||||
|
event=ANY, esp_name='Postal')
|
||||||
|
event = kwargs['event']
|
||||||
|
self.assertIsInstance(event, AnymailTrackingEvent)
|
||||||
|
self.assertEqual(event.event_type, "delivered")
|
||||||
|
self.assertEqual(event.esp_event, raw_event)
|
||||||
|
self.assertEqual(event.timestamp, datetime.fromtimestamp(1606753101, tz=utc))
|
||||||
|
self.assertEqual(event.message_id, 1563)
|
||||||
|
self.assertEqual(event.recipient, "recipient@example.com")
|
||||||
|
self.assertEqual(event.tags, ["welcome-email"])
|
||||||
|
self.assertEqual(event.metadata, None)
|
||||||
|
|
||||||
|
def test_ignore_incoming_events(self):
|
||||||
|
raw_event = {
|
||||||
|
"event": "MessageDeliveryFailed",
|
||||||
|
"timestamp": 1606756014.694645,
|
||||||
|
"payload": {
|
||||||
|
"message": {
|
||||||
|
"id": 1575,
|
||||||
|
"token": "lPDuNhHfV8aU",
|
||||||
|
"direction": "incoming",
|
||||||
|
"message_id": "asdf@other-mta.example.com",
|
||||||
|
"to": "incoming@example.com",
|
||||||
|
"from": "sender@example.com",
|
||||||
|
"subject": "test",
|
||||||
|
"timestamp": 1606756008.718169,
|
||||||
|
"spam_status": "NotSpam",
|
||||||
|
"tag": None
|
||||||
|
},
|
||||||
|
"status": "HardFail",
|
||||||
|
"details": "Received a 400 from https://anymail.example.com/anymail/postal/tracking/.",
|
||||||
|
"output": "Not found",
|
||||||
|
"sent_with_ssl": False,
|
||||||
|
"timestamp": 1606756014.1078613,
|
||||||
|
"time": 0.15
|
||||||
|
},
|
||||||
|
"uuid": "a01724c0-0d1a-4090-89aa-c3da5a683375"
|
||||||
|
}
|
||||||
|
response = self.client.post('/anymail/postal/tracking/',
|
||||||
|
content_type='application/json', data=json.dumps(raw_event))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(self.tracking_handler.call_count, 0)
|
||||||
|
|
||||||
|
def test_misconfigured_inbound(self):
|
||||||
|
errmsg = "You seem to have set Postal's *inbound* webhook to Anymail's Postal *tracking* webhook URL."
|
||||||
|
with self.assertRaisesMessage(AnymailConfigurationError, errmsg):
|
||||||
|
self.client.post('/anymail/postal/tracking/', content_type='application/json',
|
||||||
|
data=json.dumps({"rcpt_to": "to@example.org"}))
|
||||||
58
tests/utils_postal.py
Normal file
58
tests/utils_postal.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
from base64 import b64encode
|
||||||
|
|
||||||
|
from django.test import override_settings
|
||||||
|
|
||||||
|
from tests.utils import ClientWithCsrfChecks
|
||||||
|
|
||||||
|
HAS_CRYPTOGRAPHY = True
|
||||||
|
try:
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
from cryptography.hazmat.primitives import hashes
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import padding
|
||||||
|
except ImportError:
|
||||||
|
HAS_CRYPTOGRAPHY = False
|
||||||
|
|
||||||
|
|
||||||
|
def make_key():
|
||||||
|
"""Generate RSA public key with short key size, for testing only"""
|
||||||
|
private_key = rsa.generate_private_key(
|
||||||
|
public_exponent=65537,
|
||||||
|
key_size=512,
|
||||||
|
)
|
||||||
|
return private_key
|
||||||
|
|
||||||
|
|
||||||
|
def derive_public_webhook_key(private_key):
|
||||||
|
"""Derive public """
|
||||||
|
public_key = private_key.public_key()
|
||||||
|
public_bytes = public_key.public_bytes(
|
||||||
|
encoding=serialization.Encoding.PEM,
|
||||||
|
format=serialization.PublicFormat.SubjectPublicKeyInfo
|
||||||
|
)
|
||||||
|
public_bytes = b'\n'.join(public_bytes.splitlines()[1:-1])
|
||||||
|
return public_bytes.decode('utf-8')
|
||||||
|
|
||||||
|
|
||||||
|
def sign(private_key, message):
|
||||||
|
"""Sign message with private key"""
|
||||||
|
signature = private_key.sign(message, padding.PKCS1v15(), hashes.SHA1())
|
||||||
|
return signature
|
||||||
|
|
||||||
|
|
||||||
|
class _ClientWithPostalSignature(ClientWithCsrfChecks):
|
||||||
|
private_key = None
|
||||||
|
|
||||||
|
def set_private_key(self, private_key):
|
||||||
|
self.private_key = private_key
|
||||||
|
|
||||||
|
def post(self, *args, **kwargs):
|
||||||
|
signature = b64encode(sign(self.private_key, kwargs['data'].encode('utf-8')))
|
||||||
|
kwargs.setdefault('HTTP_X_POSTAL_SIGNATURE', signature)
|
||||||
|
|
||||||
|
webhook_key = derive_public_webhook_key(self.private_key)
|
||||||
|
with override_settings(ANYMAIL={'POSTAL_WEBHOOK_KEY': webhook_key}):
|
||||||
|
return super().post(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
ClientWithPostalSignature = _ClientWithPostalSignature if HAS_CRYPTOGRAPHY else None
|
||||||
6
tox.ini
6
tox.ini
@@ -16,7 +16,7 @@ envlist =
|
|||||||
# ... then prereleases (if available):
|
# ... then prereleases (if available):
|
||||||
djangoDev-py{38,39,310,py3}-all
|
djangoDev-py{38,39,310,py3}-all
|
||||||
# ... then partial installation (limit extras):
|
# ... then partial installation (limit extras):
|
||||||
django31-py37-{none,amazon_ses}
|
django31-py37-{none,amazon_ses,postal}
|
||||||
# ... then older versions of some dependencies:
|
# ... then older versions of some dependencies:
|
||||||
django22-py37-all-old_urllib3
|
django22-py37-all-old_urllib3
|
||||||
|
|
||||||
@@ -34,10 +34,12 @@ deps =
|
|||||||
mock
|
mock
|
||||||
extras =
|
extras =
|
||||||
all,amazon_ses: amazon_ses
|
all,amazon_ses: amazon_ses
|
||||||
|
all,postal: cryptography
|
||||||
setenv =
|
setenv =
|
||||||
# tell runtests.py to limit some test tags based on extras factor
|
# tell runtests.py to limit some test tags based on extras factor
|
||||||
none: ANYMAIL_SKIP_TESTS=amazon_ses
|
none: ANYMAIL_SKIP_TESTS=amazon_ses,postal
|
||||||
amazon_ses: ANYMAIL_ONLY_TEST=amazon_ses
|
amazon_ses: ANYMAIL_ONLY_TEST=amazon_ses
|
||||||
|
postal: ANYMAIL_ONLY_TEST=postal
|
||||||
ignore_outcome =
|
ignore_outcome =
|
||||||
# CI that wants to handle errors itself can set TOX_FORCE_IGNORE_OUTCOME=false
|
# CI that wants to handle errors itself can set TOX_FORCE_IGNORE_OUTCOME=false
|
||||||
djangoDev: {env:TOX_FORCE_IGNORE_OUTCOME:true}
|
djangoDev: {env:TOX_FORCE_IGNORE_OUTCOME:true}
|
||||||
|
|||||||
Reference in New Issue
Block a user