diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 69034c3..09a2d13 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -88,6 +88,7 @@ jobs: # Install without optional extras (don't need to cover entire matrix) - { tox: django31-py37-none, python: "3.7" } - { tox: django31-py37-amazon_ses, python: "3.7" } + - { tox: django31-py37-postal, python: "3.7" } # Test some specific older package versions - { tox: django22-py37-all-old_urllib3, python: "3.7" } diff --git a/CHANGELOG.rst b/CHANGELOG.rst index cc46a29..24dac66 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -25,6 +25,17 @@ Release history ^^^^^^^^^^^^^^^ .. This extra heading level keeps the ToC from becoming unmanageably long +vNext +----- + +*Unreleased changes on main branch* + +Features +~~~~~~~~ + +* **Postal:** New ESP! See `docs `__. + (Thanks to `@tiltec`_ for researching, implementing, testing and documenting Postal support.) + v8.3 ---- @@ -1252,6 +1263,7 @@ Features .. _@swrobel: https://github.com/swrobel .. _@tcourtqtm: https://github.com/tcourtqtm .. _@Thorbenl: https://github.com/Thorbenl +.. _@tiltec: https://github.com/tiltec .. _@Tobeyforce: https://github.com/Tobeyforce .. _@varche1: https://github.com/varche1 .. _@vgrebenschikov: https://github.com/vgrebenschikov diff --git a/anymail/backends/postal.py b/anymail/backends/postal.py new file mode 100644 index 0000000..ff3bcc1 --- /dev/null +++ b/anymail/backends/postal.py @@ -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) diff --git a/anymail/urls.py b/anymail/urls.py index 32ec564..3f8885a 100644 --- a/anymail/urls.py +++ b/anymail/urls.py @@ -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'), diff --git a/anymail/webhooks/postal.py b/anymail/webhooks/postal.py new file mode 100644 index 0000000..e11d36e --- /dev/null +++ b/anymail/webhooks/postal.py @@ -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] diff --git a/docs/esps/index.rst b/docs/esps/index.rst index 839d797..122b0c5 100644 --- a/docs/esps/index.rst +++ b/docs/esps/index.rst @@ -16,6 +16,7 @@ and notes about any quirks or limitations: mailgun mailjet mandrill + postal postmark sendgrid sendinblue @@ -31,35 +32,35 @@ The table below summarizes the Anymail features supported for each ESP. .. 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 ` ---------------------------------------------------------------------------------------------------------------------------------------------------- -:attr:`~AnymailMessage.envelope_sender` Yes Domain only Yes Domain only No No No Yes -:attr:`~AnymailMessage.metadata` Yes Yes Yes Yes Yes Yes Yes Yes -:attr:`~AnymailMessage.merge_metadata` No Yes Yes Yes Yes Yes No Yes -:attr:`~AnymailMessage.send_at` No Yes No Yes No Yes No Yes -:attr:`~AnymailMessage.tags` Yes Yes Max 1 tag Yes Max 1 tag Yes Yes Max 1 tag -:attr:`~AnymailMessage.track_clicks` No Yes Yes Yes Yes Yes No Yes -:attr:`~AnymailMessage.track_opens` No Yes Yes Yes Yes Yes No Yes -:ref:`amp-email` Yes Yes No No No Yes No Yes +--------------------------------------------------------------------------------------------------------------------------------------------------------------- +:attr:`~AnymailMessage.envelope_sender` Yes Domain only Yes Domain only Yes No No No Yes +:attr:`~AnymailMessage.metadata` Yes Yes Yes Yes No Yes Yes Yes Yes +:attr:`~AnymailMessage.merge_metadata` No Yes Yes Yes No Yes 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 Max 1 tag Yes Yes Max 1 tag +:attr:`~AnymailMessage.track_clicks` No Yes Yes Yes No 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 No Yes No Yes .. rubric:: :ref:`templates-and-merge` ---------------------------------------------------------------------------------------------------------------------------------------------------- -:attr:`~AnymailMessage.template_id` Yes Yes Yes Yes Yes Yes Yes Yes -:attr:`~AnymailMessage.merge_data` Yes Yes Yes Yes Yes Yes No Yes -:attr:`~AnymailMessage.merge_global_data` Yes (emulated) 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 No Yes Yes No Yes +:attr:`~AnymailMessage.merge_global_data` Yes (emulated) Yes Yes No Yes Yes Yes Yes .. rubric:: :ref:`Status ` and :ref:`event tracking ` ---------------------------------------------------------------------------------------------------------------------------------------------------- -:attr:`~AnymailMessage.anymail_status` Yes Yes Yes Yes Yes Yes Yes Yes -|AnymailTrackingEvent| from webhooks 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 Yes .. rubric:: :ref:`Inbound handling ` ---------------------------------------------------------------------------------------------------------------------------------------------------- -|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 @@ -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` .. |Mailjet| replace:: :ref:`mailjet-backend` .. |Mandrill| replace:: :ref:`mandrill-backend` +.. |Postal| replace:: :ref:`postal-backend` .. |Postmark| replace:: :ref:`postmark-backend` .. |SendGrid| replace:: :ref:`sendgrid-backend` .. |Sendinblue| replace:: :ref:`sendinblue-backend` diff --git a/docs/esps/postal.rst b/docs/esps/postal.rst new file mode 100644 index 0000000..dc8c3e9 --- /dev/null +++ b/docs/esps/postal.rst @@ -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": "", + } + +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 ` 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 `, 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 `_ data. + +.. _postal-inbound: + +Inbound webhook +--------------- + +If you want to receive email from Postal through Anymail's normalized :ref:`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. diff --git a/setup.py b/setup.py index e9e8d08..aa5366e 100644 --- a/setup.py +++ b/setup.py @@ -38,7 +38,7 @@ requirements_dev = [ "twine", ] -requirements_test = ["mock", "boto3"] +requirements_test = ["mock", "boto3", "cryptography"] setup( @@ -67,6 +67,7 @@ setup( "sendgrid": [], "sendinblue": [], "sparkpost": [], + "postal": ["cryptography"], # Development/test-only requirements (install with python -m pip -e '.[dev,test]') "dev": requirements_dev, "test": requirements_test, diff --git a/tests/test_postal_backend.py b/tests/test_postal_backend.py new file mode 100644 index 0000000..8964ff0 --- /dev/null +++ b/tests/test_postal_backend.py @@ -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 ', + ['Recipient #1 ', 'to2@example.com'], + cc=['Carbon Copy ', 'cc2@example.com'], + bcc=['Blind Copy ', 'bcc2@example.com']) + msg.send() + data = self.get_api_call_json() + self.assertEqual(data['from'], 'From Name ') + self.assertEqual(data['to'], ['Recipient #1 ', 'to2@example.com']) + self.assertEqual(data['cc'], ['Carbon Copy ', 'cc2@example.com']) + self.assertEqual(data['bcc'], ['Blind Copy ', 'bcc2@example.com']) + + def test_email_message(self): + email = mail.EmailMessage( + 'Subject', 'Body goes here', 'from@example.com', + ['to1@example.com', 'Also To '], + bcc=['bcc1@example.com', 'Also BCC '], + cc=['cc1@example.com', 'Also CC '], + 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 ']) + self.assertEqual(data['bcc'], ['bcc1@example.com', 'Also BCC ']) + self.assertEqual(data['cc'], ['cc1@example.com', 'Also CC ']) + 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 = '

This is an important message.

' + 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 = '

This is an important message.

' + 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 ']) + 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 ']) + 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", '

\u2019

', 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('

\u2019

'.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 = '

This has an inline image.

' % 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("

First html is OK

", "text/html") + self.message.attach_alternative("

But not second html

", "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 '],) + 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') diff --git a/tests/test_postal_inbound.py b/tests/test_postal_inbound.py new file mode 100644 index 0000000..416b634 --- /dev/null +++ b/tests/test_postal_inbound.py @@ -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 ... + 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" + Date: Wed, 11 Oct 2017 18:31:04 -0700 + Message-ID: + Subject: Test subject + To: "Test Inbound" , 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 + +
It's a body=E2=80=A6
+ + --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 ', '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, """
It's a body\N{HORIZONTAL ELLIPSIS}
\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'], "") + self.assertEqual(message.get_all('Received'), [ + "from mail.example.org by postal.example.com ...", + "by mail.example.org for ...", + "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" + +
This is the HTML body. It has an inline image: .
+ + --boundary1 + Content-Type: image/png + Content-Disposition: inline; filename="image.png" + Content-ID: + 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"})) diff --git a/tests/test_postal_integration.py b/tests/test_postal_integration.py new file mode 100644 index 0000000..944b757 --- /dev/null +++ b/tests/test_postal_integration.py @@ -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('

HTML content

', "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 ", + envelope_sender="bounces@example.com", + to=["test+to1@anymail.info", "Recipient 2 "], + cc=["test+cc1@anymail.info", "Copy 2 "], + bcc=["test+bcc1@anymail.info", "Blind Copy 2 "], + 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']) diff --git a/tests/test_postal_webhooks.py b/tests/test_postal_webhooks.py new file mode 100644 index 0000000..e60995b --- /dev/null +++ b/tests/test_postal_webhooks.py @@ -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"})) diff --git a/tests/utils_postal.py b/tests/utils_postal.py new file mode 100644 index 0000000..5781258 --- /dev/null +++ b/tests/utils_postal.py @@ -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 diff --git a/tox.ini b/tox.ini index 753f085..03723e1 100644 --- a/tox.ini +++ b/tox.ini @@ -16,7 +16,7 @@ envlist = # ... then prereleases (if available): djangoDev-py{38,39,310,py3}-all # ... then partial installation (limit extras): - django31-py37-{none,amazon_ses} + django31-py37-{none,amazon_ses,postal} # ... then older versions of some dependencies: django22-py37-all-old_urllib3 @@ -34,10 +34,12 @@ deps = mock extras = all,amazon_ses: amazon_ses + all,postal: cryptography setenv = # 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 + postal: ANYMAIL_ONLY_TEST=postal ignore_outcome = # CI that wants to handle errors itself can set TOX_FORCE_IGNORE_OUTCOME=false djangoDev: {env:TOX_FORCE_IGNORE_OUTCOME:true}