Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

MailPace: New ESP #354

Closed
wants to merge 11 commits into from
3 changes: 3 additions & 0 deletions .github/workflows/integration-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ jobs:
- { tox: django41-py310-mailersend, python: "3.10" }
- { tox: django41-py310-mailgun, python: "3.10" }
- { tox: django41-py310-mailjet, python: "3.10" }
- { tox: django41-py310-mailpace, python: "3.10" }
- { tox: django41-py310-mandrill, python: "3.10" }
- { tox: django41-py310-postal, python: "3.10" }
- { tox: django41-py310-postmark, python: "3.10" }
Expand Down Expand Up @@ -83,6 +84,8 @@ jobs:
ANYMAIL_TEST_MAILJET_API_KEY: ${{ secrets.ANYMAIL_TEST_MAILJET_API_KEY }}
ANYMAIL_TEST_MAILJET_DOMAIN: ${{ secrets.ANYMAIL_TEST_MAILJET_DOMAIN }}
ANYMAIL_TEST_MAILJET_SECRET_KEY: ${{ secrets.ANYMAIL_TEST_MAILJET_SECRET_KEY }}
ANYMAIL_TEST_MAILPACE_DOMAIN: ${{ secrets.ANYMAIL_TEST_MAILPACE_DOMAIN }}
ANYMAIL_TEST_MAILPACE_SERVER_TOKEN: ${{ secrets.ANYMAIL_TEST_MAILPACE_SERVER_TOKEN }}
ANYMAIL_TEST_MANDRILL_API_KEY: ${{ secrets.ANYMAIL_TEST_MANDRILL_API_KEY }}
ANYMAIL_TEST_MANDRILL_DOMAIN: ${{ secrets.ANYMAIL_TEST_MANDRILL_DOMAIN }}
ANYMAIL_TEST_POSTMARK_DOMAIN: ${{ secrets.ANYMAIL_TEST_POSTMARK_DOMAIN }}
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ Release history
^^^^^^^^^^^^^^^
.. This extra heading level keeps the ToC from becoming unmanageably long

*unreleased changes*

Features
~~~~~~~~

* **MailPace**: Add support for this ESP
(`docs <https://anymail.dev/en/stable/esps/mailpace/>`__).

v10.2
-----

Expand Down
1 change: 1 addition & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ Anymail currently supports these ESPs:
* **MailerSend**
* **Mailgun**
* **Mailjet**
* **MailPace**
* **Mandrill** (MailChimp transactional)
* **Postal** (self-hosted ESP)
* **Postmark**
Expand Down
178 changes: 178 additions & 0 deletions anymail/backends/mailpace.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
from ..exceptions import AnymailRequestsAPIError
from ..message import AnymailRecipientStatus
from ..utils import (
CaseInsensitiveCasePreservingDict,
get_anymail_setting,
)
from .base_requests import AnymailRequestsBackend, RequestsPayload


class EmailBackend(AnymailRequestsBackend):
"""
MailPace API Email Backend
"""

esp_name = "MailPace"

def __init__(self, **kwargs):
"""Init options from Django settings"""
esp_name = self.esp_name
self.server_token = get_anymail_setting(
"server_token", esp_name=esp_name, kwargs=kwargs, allow_bare=True
)
api_url = get_anymail_setting(
"api_url",
esp_name=esp_name,
kwargs=kwargs,
default="https://app.mailpace.com/api/v1/send",
)
if not api_url.endswith("/"):
api_url += "/"
super().__init__(api_url, **kwargs)

def build_message_payload(self, message, defaults):
return MailPacePayload(message, defaults, self)

def raise_for_status(self, response, payload, message):
# We need to handle 400 responses in parse_recipient_status
if response.status_code != 400:
super().raise_for_status(response, payload, message)

def parse_recipient_status(self, response, payload, message):
# Prepare the dict by setting everything to queued without a message id
unknown_status = AnymailRecipientStatus(message_id=None, status="queued")
recipient_status = CaseInsensitiveCasePreservingDict(
{
recip.addr_spec: unknown_status
for recip in payload.to_cc_and_bcc_emails
}
)

parsed_response = self.deserialize_json_response(response, payload, message)

status_code = str(response.status_code)
json_response = response.json()

# Set the status_msg and id based on the status_code
if status_code == "200":
try:
status_msg = parsed_response["status"]
id = parsed_response["id"]
except (KeyError, TypeError) as err:
raise AnymailRequestsAPIError(
"Invalid MailPace API response format",
email_message=None,
payload=payload,
response=response,
backend=self,
) from err
elif status_code.startswith("4"):
status_msg = "error"
id = None

if status_msg == "queued":
# Add the message_id to all of the recipients
for recip in payload.to_cc_and_bcc_emails:
recipient_status[recip.addr_spec] = AnymailRecipientStatus(
message_id=id, status="queued"
)
elif status_msg == "error":
if 'errors' in json_response:
for field in ['to', 'cc', 'bcc']:
if field in json_response['errors']:
error_messages = json_response['errors'][field]
for email in payload.to_cc_and_bcc_emails:
for error_message in error_messages:
if 'undefined field' in error_message or 'is invalid' in error_message:
recipient_status[email.addr_spec] = AnymailRecipientStatus(message_id=None, status='invalid')
elif 'contains a blocked address' in error_message:
recipient_status[email.addr_spec] = AnymailRecipientStatus(message_id=None, status='rejected')
elif 'number of email addresses exceeds maximum volume' in error_message:
recipient_status[email.addr_spec] = AnymailRecipientStatus(message_id=None, status='invalid')
else:
continue # No errors found in this field; continue with the next field
else:
raise AnymailRequestsAPIError(
email_message=message,
payload=payload,
response=response,
backend=self,
)

return dict(recipient_status)


class MailPacePayload(RequestsPayload):
def __init__(self, message, defaults, backend, *args, **kwargs):
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
}
self.server_token = backend.server_token # esp_extra can override
self.to_cc_and_bcc_emails = []
self.merge_data = None
self.merge_metadata = None
super().__init__(message, defaults, backend, headers=headers, *args, **kwargs)

def get_request_params(self, api_url):
params = super().get_request_params(api_url)
params["headers"]["MailPace-Server-Token"] = self.server_token
return params

def serialize_data(self):
return self.serialize_json(self.data)

#
# Payload construction
#

def init_payload(self):
self.data = {} # becomes json

def set_from_email(self, email):
self.data["from"] = email.address

def set_recipients(self, recipient_type, emails):
assert recipient_type in ["to", "cc", "bcc"]
if emails:
# Creates to, cc, and bcc in the payload
self.data[recipient_type] = ", ".join([email.address for email in emails])
self.to_cc_and_bcc_emails += emails

def set_subject(self, subject):
self.data["subject"] = subject

def set_reply_to(self, emails):
if emails:
reply_to = ", ".join([email.address for email in emails])
self.data["replyto"] = reply_to

def set_text_body(self, body):
self.data["textbody"] = body

def set_html_body(self, body):
self.data["htmlbody"] = body

def make_attachment(self, attachment):
"""Returns MailPace attachment dict for attachment"""
att = {
"name": attachment.name or "",
"content": attachment.b64content,
"content_type": attachment.mimetype,
}
if attachment.inline:
att["cid"] = "cid:%s" % attachment.cid
return att

def set_attachments(self, attachments):
if attachments:
self.data["attachments"] = [
self.make_attachment(attachment) for attachment in attachments
]

def set_tags(self, tags):
if tags:
if len(tags) == 1:
self.data["tags"] = tags[0]
else:
self.data["tags"] = tags
11 changes: 11 additions & 0 deletions anymail/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
)
from .webhooks.mailgun import MailgunInboundWebhookView, MailgunTrackingWebhookView
from .webhooks.mailjet import MailjetInboundWebhookView, MailjetTrackingWebhookView
from .webhooks.mailpace import MailPaceInboundWebhookView, MailPaceTrackingWebhookView
from .webhooks.mandrill import MandrillCombinedWebhookView
from .webhooks.postal import PostalInboundWebhookView, PostalTrackingWebhookView
from .webhooks.postmark import PostmarkInboundWebhookView, PostmarkTrackingWebhookView
Expand Down Expand Up @@ -50,6 +51,11 @@
MailjetInboundWebhookView.as_view(),
name="mailjet_inbound_webhook",
),
path(
"mailpace/inbound/",
MailPaceInboundWebhookView.as_view(),
name="mailpace_inbound_webhook",
),
path(
"postal/inbound/",
PostalInboundWebhookView.as_view(),
Expand Down Expand Up @@ -95,6 +101,11 @@
MailjetTrackingWebhookView.as_view(),
name="mailjet_tracking_webhook",
),
path(
"mailpace/tracking/",
MailPaceTrackingWebhookView.as_view(),
name="mailpace_tracking_webhook",
),
path(
"postal/tracking/",
PostalTrackingWebhookView.as_view(),
Expand Down
117 changes: 117 additions & 0 deletions anymail/webhooks/mailpace.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import binascii
import json
import base64
from anymail.exceptions import AnymailWebhookValidationFailure
from anymail.utils import get_anymail_setting

from django.utils.dateparse import parse_datetime
from django.utils import timezone
from nacl.signing import VerifyKey
from nacl.exceptions import CryptoError, ValueError

from ..signals import (
AnymailInboundEvent,
AnymailTrackingEvent,
EventType,
RejectReason,
inbound,
tracking,
)
from ..inbound import AnymailInboundMessage

from .base import AnymailBaseWebhookView


class MailPaceBaseWebhookView(AnymailBaseWebhookView):
"""Base view class for MailPace webhooks"""

esp_name = "MailPace"

def parse_events(self, request):
esp_event = json.loads(request.body.decode("utf-8"))
return [self.esp_to_anymail_event(esp_event)]

class MailPaceTrackingWebhookView(MailPaceBaseWebhookView):
"""Handler for MailPace delivery webhooks"""

webhook_key = None

#TODO: make this optional
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)

# Used by base class
signal = tracking

event_record_types = {
# Map MailPace event RecordType --> Anymail normalized event type
"email.queued": EventType.QUEUED,
"email.delivered": EventType.DELIVERED,
"email.deferred": EventType.DEFERRED,
"email.bounced": EventType.BOUNCED,
"email.spam": EventType.REJECTED
}

# MailPace doesn't send a signature for inbound webhooks, yet
# When/if MailPace does this, move this to the parent class
def validate_request(self, request):
try:
signature_base64 = request.headers["X-MailPace-Signature"]
signature = base64.b64decode(signature_base64)
except (KeyError, binascii.Error):
raise AnymailWebhookValidationFailure(
"MailPace webhook called with invalid or missing signature"
)

verify_key_base64 = self.webhook_key

verify_key = VerifyKey(base64.b64decode(verify_key_base64))

message = request.body

try:
verify_key.verify(message, signature)
except (CryptoError, ValueError):
raise AnymailWebhookValidationFailure(
"MailPace webhook called with incorrect signature"
)

def esp_to_anymail_event(self, esp_event):
event_type = self.event_record_types.get(esp_event["event"], EventType.UNKNOWN)
payload = esp_event["payload"]

reject_reason = RejectReason.SPAM if event_type == EventType.REJECTED else RejectReason.BOUNCED if event_type == EventType.BOUNCED else None
tags = payload.get("tags", [])

return AnymailTrackingEvent(
event_type=event_type,
timestamp=parse_datetime(payload["created_at"]),
event_id=payload["id"],
message_id=payload["message_id"],
recipient=payload["to"],
tags=tags,
reject_reason=reject_reason,
)


class MailPaceInboundWebhookView(MailPaceBaseWebhookView):
"""Handler for MailPace inbound webhook"""

signal = inbound

def esp_to_anymail_event(self, esp_event):
# Use Raw MIME based on guidance here:
# https://github.com/anymail/django-anymail/blob/main/ADDING_ESPS.md
message = AnymailInboundMessage.parse_raw_mime(esp_event.get("raw", None))

return AnymailInboundEvent(
event_type=EventType.INBOUND,
timestamp=timezone.now(),
event_id=esp_event.get("id", None),
esp_event=esp_event,
message=message
)
Loading
Loading