mirror of
https://github.com/pacnpal/django-anymail.git
synced 2025-12-21 20:31:06 -05:00
Reformat code with automated tools
Apply standardized code style
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
# Expose package version at root of package
|
||||
from ._version import __version__, VERSION # NOQA: F401
|
||||
|
||||
|
||||
from django import VERSION as DJANGO_VERSION
|
||||
|
||||
from ._version import VERSION, __version__ # NOQA: F401
|
||||
|
||||
if DJANGO_VERSION < (3, 2, 0):
|
||||
default_app_config = 'anymail.apps.AnymailBaseConfig'
|
||||
default_app_config = "anymail.apps.AnymailBaseConfig"
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
VERSION = (9, 0)
|
||||
__version__ = '.'.join([str(x) for x in VERSION]) # major.minor.patch or major.minor.devN
|
||||
__minor_version__ = '.'.join([str(x) for x in VERSION[:2]]) # Sphinx's X.Y "version"
|
||||
|
||||
#: major.minor.patch or major.minor.devN
|
||||
__version__ = ".".join([str(x) for x in VERSION])
|
||||
|
||||
#: Sphinx's X.Y "version"
|
||||
__minor_version__ = ".".join([str(x) for x in VERSION[:2]])
|
||||
|
||||
@@ -5,7 +5,7 @@ from .checks import check_deprecated_settings, check_insecure_settings
|
||||
|
||||
|
||||
class AnymailBaseConfig(AppConfig):
|
||||
name = 'anymail'
|
||||
name = "anymail"
|
||||
verbose_name = "Anymail"
|
||||
|
||||
def ready(self):
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
from email.charset import Charset, QP
|
||||
from email.charset import QP, Charset
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
from .base import AnymailBaseBackend, BasePayload
|
||||
from .._version import __version__
|
||||
from ..exceptions import AnymailAPIError, AnymailImproperlyInstalled
|
||||
from ..message import AnymailRecipientStatus
|
||||
from ..utils import get_anymail_setting, UNSET
|
||||
from ..utils import UNSET, get_anymail_setting
|
||||
from .base import AnymailBaseBackend, BasePayload
|
||||
|
||||
try:
|
||||
import boto3
|
||||
from botocore.client import Config
|
||||
from botocore.exceptions import BotoCoreError, ClientError, ConnectionError
|
||||
except ImportError as err:
|
||||
raise AnymailImproperlyInstalled(missing_package='boto3', backend='amazon_ses') from err
|
||||
raise AnymailImproperlyInstalled(
|
||||
missing_package="boto3", backend="amazon_ses"
|
||||
) from err
|
||||
|
||||
|
||||
# boto3 has several root exception classes; this is meant to cover all of them
|
||||
@@ -29,19 +31,34 @@ class EmailBackend(AnymailBaseBackend):
|
||||
def __init__(self, **kwargs):
|
||||
"""Init options from Django settings"""
|
||||
super().__init__(**kwargs)
|
||||
# AMAZON_SES_CLIENT_PARAMS is optional - boto3 can find credentials several other ways
|
||||
self.session_params, self.client_params = _get_anymail_boto3_params(kwargs=kwargs)
|
||||
self.configuration_set_name = get_anymail_setting("configuration_set_name", esp_name=self.esp_name,
|
||||
kwargs=kwargs, allow_bare=False, default=None)
|
||||
self.message_tag_name = get_anymail_setting("message_tag_name", esp_name=self.esp_name,
|
||||
kwargs=kwargs, allow_bare=False, default=None)
|
||||
# AMAZON_SES_CLIENT_PARAMS is optional
|
||||
# (boto3 can find credentials several other ways)
|
||||
self.session_params, self.client_params = _get_anymail_boto3_params(
|
||||
kwargs=kwargs
|
||||
)
|
||||
self.configuration_set_name = get_anymail_setting(
|
||||
"configuration_set_name",
|
||||
esp_name=self.esp_name,
|
||||
kwargs=kwargs,
|
||||
allow_bare=False,
|
||||
default=None,
|
||||
)
|
||||
self.message_tag_name = get_anymail_setting(
|
||||
"message_tag_name",
|
||||
esp_name=self.esp_name,
|
||||
kwargs=kwargs,
|
||||
allow_bare=False,
|
||||
default=None,
|
||||
)
|
||||
self.client = None
|
||||
|
||||
def open(self):
|
||||
if self.client:
|
||||
return False # already exists
|
||||
try:
|
||||
self.client = boto3.session.Session(**self.session_params).client("ses", **self.client_params)
|
||||
self.client = boto3.session.Session(**self.session_params).client(
|
||||
"ses", **self.client_params
|
||||
)
|
||||
except BOTO_BASE_ERRORS:
|
||||
if not self.fail_silently:
|
||||
raise
|
||||
@@ -51,7 +68,7 @@ class EmailBackend(AnymailBaseBackend):
|
||||
def close(self):
|
||||
if self.client is None:
|
||||
return
|
||||
# self.client.close() # boto3 doesn't currently seem to support (or require) this
|
||||
# self.client.close() # boto3 doesn't support (or require) client shutdown
|
||||
self.client = None
|
||||
|
||||
def build_message_payload(self, message, defaults):
|
||||
@@ -66,9 +83,15 @@ class EmailBackend(AnymailBaseBackend):
|
||||
try:
|
||||
response = payload.call_send_api(self.client)
|
||||
except BOTO_BASE_ERRORS as err:
|
||||
# ClientError has a response attr with parsed json error response (other errors don't)
|
||||
raise AnymailAPIError(str(err), backend=self, email_message=message, payload=payload,
|
||||
response=getattr(err, 'response', None)) from err
|
||||
# ClientError has a response attr with parsed json error response
|
||||
# (other errors don't)
|
||||
raise AnymailAPIError(
|
||||
str(err),
|
||||
backend=self,
|
||||
email_message=message,
|
||||
payload=payload,
|
||||
response=getattr(err, "response", None),
|
||||
) from err
|
||||
return response
|
||||
|
||||
def parse_recipient_status(self, response, payload, message):
|
||||
@@ -105,15 +128,19 @@ class AmazonSESSendRawEmailPayload(AmazonSESBasePayload):
|
||||
# (which is Django email's default for most non-ASCII bodies)
|
||||
# - you are using an SES ConfigurationSet with open or click tracking enabled
|
||||
# then SES replaces the non-ASCII characters with question marks as it rewrites
|
||||
# the message to add tracking. Forcing `CTE: quoted-printable` avoids the problem.
|
||||
# (https://forums.aws.amazon.com/thread.jspa?threadID=287048)
|
||||
# the message to add tracking. Forcing `CTE: quoted-printable` avoids the
|
||||
# problem. (https://forums.aws.amazon.com/thread.jspa?threadID=287048)
|
||||
for part in self.mime_message.walk():
|
||||
if part.get_content_maintype() == "text" and part["Content-Transfer-Encoding"] == "8bit":
|
||||
if (
|
||||
part.get_content_maintype() == "text"
|
||||
and part["Content-Transfer-Encoding"] == "8bit"
|
||||
):
|
||||
content = part.get_payload()
|
||||
del part["Content-Transfer-Encoding"]
|
||||
qp_charset = Charset(part.get_content_charset("us-ascii"))
|
||||
qp_charset.body_encoding = QP
|
||||
# (can't use part.set_payload, because SafeMIMEText can undo this workaround)
|
||||
# (can't use part.set_payload, because SafeMIMEText can undo
|
||||
# this workaround)
|
||||
MIMEText.set_payload(part, content, charset=qp_charset)
|
||||
|
||||
def call_send_api(self, ses_client):
|
||||
@@ -121,9 +148,7 @@ class AmazonSESSendRawEmailPayload(AmazonSESBasePayload):
|
||||
# Any non-ASCII characters in recipient domains must be encoded with Punycode.
|
||||
# (Amazon SES doesn't support non-ASCII recipient usernames.)
|
||||
self.params["Destinations"] = [email.address for email in self.all_recipients]
|
||||
self.params["RawMessage"] = {
|
||||
"Data": self.mime_message.as_bytes()
|
||||
}
|
||||
self.params["RawMessage"] = {"Data": self.mime_message.as_bytes()}
|
||||
return ses_client.send_raw_email(**self.params)
|
||||
|
||||
def parse_recipient_status(self, response):
|
||||
@@ -132,23 +157,34 @@ class AmazonSESSendRawEmailPayload(AmazonSESBasePayload):
|
||||
except (KeyError, TypeError) as err:
|
||||
raise AnymailAPIError(
|
||||
"%s parsing Amazon SES send result %r" % (str(err), response),
|
||||
backend=self.backend, email_message=self.message, payload=self) from None
|
||||
backend=self.backend,
|
||||
email_message=self.message,
|
||||
payload=self,
|
||||
) from None
|
||||
|
||||
recipient_status = AnymailRecipientStatus(message_id=message_id, status="queued")
|
||||
return {recipient.addr_spec: recipient_status for recipient in self.all_recipients}
|
||||
recipient_status = AnymailRecipientStatus(
|
||||
message_id=message_id, status="queued"
|
||||
)
|
||||
return {
|
||||
recipient.addr_spec: recipient_status for recipient in self.all_recipients
|
||||
}
|
||||
|
||||
# Standard EmailMessage attrs...
|
||||
# These all get rolled into the RFC-5322 raw mime directly via EmailMessage.message()
|
||||
# These all get rolled into the RFC-5322 raw mime directly via
|
||||
# EmailMessage.message()
|
||||
|
||||
def _no_send_defaults(self, attr):
|
||||
# Anymail global send defaults don't work for standard attrs, because the
|
||||
# merged/computed value isn't forced back into the EmailMessage.
|
||||
if attr in self.defaults:
|
||||
self.unsupported_feature("Anymail send defaults for '%s' with Amazon SES" % attr)
|
||||
self.unsupported_feature(
|
||||
"Anymail send defaults for '%s' with Amazon SES" % attr
|
||||
)
|
||||
|
||||
def set_from_email_list(self, emails):
|
||||
# Although Amazon SES will send messages with any From header, it can only parse Source
|
||||
# if the From header is a single email. Explicit Source avoids an "Illegal address" error:
|
||||
# Although Amazon SES will send messages with any From header, it can only parse
|
||||
# Source if the From header is a single email. Explicit Source avoids an
|
||||
# "Illegal address" error:
|
||||
if len(emails) > 1:
|
||||
self.params["Source"] = emails[0].addr_spec
|
||||
# (else SES will look at the (single) address in the From header)
|
||||
@@ -212,29 +248,38 @@ class AmazonSESSendRawEmailPayload(AmazonSESBasePayload):
|
||||
# notifications for SES *events* but not SES *notifications*. (Got that?)
|
||||
# Message Tags also allow *very* limited characters in both name and value.
|
||||
# Message Tags can be sent with any SES send call.
|
||||
# (See "How do message tags work?" in https://aws.amazon.com/blogs/ses/introducing-sending-metrics/
|
||||
# (See "How do message tags work?" in
|
||||
# https://aws.amazon.com/blogs/ses/introducing-sending-metrics/
|
||||
# and https://forums.aws.amazon.com/thread.jspa?messageID=782922.)
|
||||
# To support reliable retrieval in webhooks, just use custom headers for metadata.
|
||||
# To support reliable retrieval in webhooks, just use custom headers for
|
||||
# metadata.
|
||||
self.mime_message["X-Metadata"] = self.serialize_json(metadata)
|
||||
|
||||
def set_tags(self, tags):
|
||||
# See note about Amazon SES Message Tags and custom headers in set_metadata above.
|
||||
# To support reliable retrieval in webhooks, use custom headers for tags.
|
||||
# See note about Amazon SES Message Tags and custom headers in set_metadata
|
||||
# above. To support reliable retrieval in webhooks, use custom headers for tags.
|
||||
# (There are no restrictions on number or content for custom header tags.)
|
||||
for tag in tags:
|
||||
self.mime_message.add_header("X-Tag", tag) # creates multiple X-Tag headers, one per tag
|
||||
# creates multiple X-Tag headers, one per tag:
|
||||
self.mime_message.add_header("X-Tag", tag)
|
||||
|
||||
# Also *optionally* pass a single Message Tag if the AMAZON_SES_MESSAGE_TAG_NAME
|
||||
# Anymail setting is set (default no). The AWS API restricts tag content in this case.
|
||||
# (This is useful for dashboard segmentation; use esp_extra["Tags"] for anything more complex.)
|
||||
# Anymail setting is set (default no). The AWS API restricts tag content in this
|
||||
# case. (This is useful for dashboard segmentation; use esp_extra["Tags"] for
|
||||
# anything more complex.)
|
||||
if tags and self.backend.message_tag_name is not None:
|
||||
if len(tags) > 1:
|
||||
self.unsupported_feature("multiple tags with the AMAZON_SES_MESSAGE_TAG_NAME setting")
|
||||
self.unsupported_feature(
|
||||
"multiple tags with the AMAZON_SES_MESSAGE_TAG_NAME setting"
|
||||
)
|
||||
self.params.setdefault("Tags", []).append(
|
||||
{"Name": self.backend.message_tag_name, "Value": tags[0]})
|
||||
{"Name": self.backend.message_tag_name, "Value": tags[0]}
|
||||
)
|
||||
|
||||
def set_template_id(self, template_id):
|
||||
raise NotImplementedError("AmazonSESSendRawEmailPayload should not have been used with template_id")
|
||||
raise NotImplementedError(
|
||||
"AmazonSESSendRawEmailPayload should not have been used with template_id"
|
||||
)
|
||||
|
||||
def set_merge_data(self, merge_data):
|
||||
self.unsupported_feature("merge_data without template_id")
|
||||
@@ -254,15 +299,24 @@ class AmazonSESSendBulkTemplatedEmailPayload(AmazonSESBasePayload):
|
||||
# include any 'cc' or 'bcc' in every destination
|
||||
cc_and_bcc_addresses = {}
|
||||
if self.recipients["cc"]:
|
||||
cc_and_bcc_addresses["CcAddresses"] = [cc.address for cc in self.recipients["cc"]]
|
||||
cc_and_bcc_addresses["CcAddresses"] = [
|
||||
cc.address for cc in self.recipients["cc"]
|
||||
]
|
||||
if self.recipients["bcc"]:
|
||||
cc_and_bcc_addresses["BccAddresses"] = [bcc.address for bcc in self.recipients["bcc"]]
|
||||
cc_and_bcc_addresses["BccAddresses"] = [
|
||||
bcc.address for bcc in self.recipients["bcc"]
|
||||
]
|
||||
|
||||
# set up destination and data for each 'to'
|
||||
self.params["Destinations"] = [{
|
||||
"Destination": dict(ToAddresses=[to.address], **cc_and_bcc_addresses),
|
||||
"ReplacementTemplateData": self.serialize_json(self.merge_data.get(to.addr_spec, {}))
|
||||
} for to in self.recipients["to"]]
|
||||
self.params["Destinations"] = [
|
||||
{
|
||||
"Destination": dict(ToAddresses=[to.address], **cc_and_bcc_addresses),
|
||||
"ReplacementTemplateData": self.serialize_json(
|
||||
self.merge_data.get(to.addr_spec, {})
|
||||
),
|
||||
}
|
||||
for to in self.recipients["to"]
|
||||
]
|
||||
|
||||
return ses_client.send_bulk_templated_email(**self.params)
|
||||
|
||||
@@ -272,25 +326,33 @@ class AmazonSESSendBulkTemplatedEmailPayload(AmazonSESBasePayload):
|
||||
anymail_statuses = [
|
||||
AnymailRecipientStatus(
|
||||
message_id=status.get("MessageId", None),
|
||||
status='queued' if status.get("Status") == "Success" else 'failed')
|
||||
status="queued" if status.get("Status") == "Success" else "failed",
|
||||
)
|
||||
for status in response["Status"]
|
||||
]
|
||||
except (KeyError, TypeError) as err:
|
||||
raise AnymailAPIError(
|
||||
"%s parsing Amazon SES send result %r" % (str(err), response),
|
||||
backend=self.backend, email_message=self.message, payload=self) from None
|
||||
backend=self.backend,
|
||||
email_message=self.message,
|
||||
payload=self,
|
||||
) from None
|
||||
|
||||
to_addrs = [to.addr_spec for to in self.recipients["to"]]
|
||||
if len(anymail_statuses) != len(to_addrs):
|
||||
raise AnymailAPIError(
|
||||
"Sent to %d destinations, but only %d statuses in Amazon SES send result %r"
|
||||
% (len(to_addrs), len(anymail_statuses), response),
|
||||
backend=self.backend, email_message=self.message, payload=self)
|
||||
"Sent to %d destinations, but only %d statuses in Amazon SES"
|
||||
" send result %r" % (len(to_addrs), len(anymail_statuses), response),
|
||||
backend=self.backend,
|
||||
email_message=self.message,
|
||||
payload=self,
|
||||
)
|
||||
|
||||
return dict(zip(to_addrs, anymail_statuses))
|
||||
|
||||
def set_from_email(self, email):
|
||||
self.params["Source"] = email.address # this will RFC2047-encode display_name if needed
|
||||
# this will RFC2047-encode display_name if needed:
|
||||
self.params["Source"] = email.address
|
||||
|
||||
def set_recipients(self, recipient_type, emails):
|
||||
# late-bound in call_send_api
|
||||
@@ -330,16 +392,23 @@ class AmazonSESSendBulkTemplatedEmailPayload(AmazonSESBasePayload):
|
||||
self.unsupported_feature("metadata with template")
|
||||
|
||||
def set_tags(self, tags):
|
||||
# no custom headers with SendBulkTemplatedEmail, but support AMAZON_SES_MESSAGE_TAG_NAME if used
|
||||
# (see tags/metadata in AmazonSESSendRawEmailPayload for more info)
|
||||
# no custom headers with SendBulkTemplatedEmail, but support
|
||||
# AMAZON_SES_MESSAGE_TAG_NAME if used (see tags/metadata in
|
||||
# AmazonSESSendRawEmailPayload for more info)
|
||||
if tags:
|
||||
if self.backend.message_tag_name is not None:
|
||||
if len(tags) > 1:
|
||||
self.unsupported_feature("multiple tags with the AMAZON_SES_MESSAGE_TAG_NAME setting")
|
||||
self.params["DefaultTags"] = [{"Name": self.backend.message_tag_name, "Value": tags[0]}]
|
||||
self.unsupported_feature(
|
||||
"multiple tags with the AMAZON_SES_MESSAGE_TAG_NAME setting"
|
||||
)
|
||||
self.params["DefaultTags"] = [
|
||||
{"Name": self.backend.message_tag_name, "Value": tags[0]}
|
||||
]
|
||||
else:
|
||||
self.unsupported_feature(
|
||||
"tags with template (unless using the AMAZON_SES_MESSAGE_TAG_NAME setting)")
|
||||
"tags with template (unless using the"
|
||||
" AMAZON_SES_MESSAGE_TAG_NAME setting)"
|
||||
)
|
||||
|
||||
def set_template_id(self, template_id):
|
||||
self.params["Template"] = template_id
|
||||
@@ -363,13 +432,20 @@ def _get_anymail_boto3_params(esp_name=EmailBackend.esp_name, kwargs=None):
|
||||
May remove keys from kwargs, but won't modify original settings
|
||||
"""
|
||||
# (shared with ..webhooks.amazon_ses)
|
||||
session_params = get_anymail_setting("session_params", esp_name=esp_name, kwargs=kwargs, default={})
|
||||
client_params = get_anymail_setting("client_params", esp_name=esp_name, kwargs=kwargs, default={})
|
||||
session_params = get_anymail_setting(
|
||||
"session_params", esp_name=esp_name, kwargs=kwargs, default={}
|
||||
)
|
||||
client_params = get_anymail_setting(
|
||||
"client_params", esp_name=esp_name, kwargs=kwargs, default={}
|
||||
)
|
||||
|
||||
# Add Anymail user-agent, and convert config dict to botocore.client.Config
|
||||
client_params = client_params.copy() # don't modify source
|
||||
config = Config(user_agent_extra="django-anymail/{version}-{esp}".format(
|
||||
esp=esp_name.lower().replace(" ", "-"), version=__version__))
|
||||
config = Config(
|
||||
user_agent_extra="django-anymail/{version}-{esp}".format(
|
||||
esp=esp_name.lower().replace(" ", "-"), version=__version__
|
||||
)
|
||||
)
|
||||
if "config" in client_params:
|
||||
# convert config dict to botocore.client.Config if needed
|
||||
client_params_config = client_params["config"]
|
||||
|
||||
@@ -3,17 +3,31 @@ from datetime import date, datetime, timezone
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.mail.backends.base import BaseEmailBackend
|
||||
from django.utils.timezone import is_naive, get_current_timezone, make_aware
|
||||
from django.utils.timezone import get_current_timezone, is_naive, make_aware
|
||||
from requests.structures import CaseInsensitiveDict
|
||||
|
||||
from ..exceptions import (
|
||||
AnymailCancelSend, AnymailError, AnymailUnsupportedFeature, AnymailRecipientsRefused,
|
||||
AnymailSerializationError)
|
||||
AnymailCancelSend,
|
||||
AnymailError,
|
||||
AnymailRecipientsRefused,
|
||||
AnymailSerializationError,
|
||||
AnymailUnsupportedFeature,
|
||||
)
|
||||
from ..message import AnymailStatus
|
||||
from ..signals import pre_send, post_send
|
||||
from ..signals import post_send, pre_send
|
||||
from ..utils import (
|
||||
Attachment, UNSET, combine, last, get_anymail_setting, parse_address_list, parse_single_address,
|
||||
force_non_lazy, force_non_lazy_list, force_non_lazy_dict, is_lazy)
|
||||
UNSET,
|
||||
Attachment,
|
||||
combine,
|
||||
force_non_lazy,
|
||||
force_non_lazy_dict,
|
||||
force_non_lazy_list,
|
||||
get_anymail_setting,
|
||||
is_lazy,
|
||||
last,
|
||||
parse_address_list,
|
||||
parse_single_address,
|
||||
)
|
||||
|
||||
|
||||
class AnymailBaseBackend(BaseEmailBackend):
|
||||
@@ -24,17 +38,23 @@ class AnymailBaseBackend(BaseEmailBackend):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.ignore_unsupported_features = get_anymail_setting('ignore_unsupported_features',
|
||||
kwargs=kwargs, default=False)
|
||||
self.ignore_recipient_status = get_anymail_setting('ignore_recipient_status',
|
||||
kwargs=kwargs, default=False)
|
||||
self.debug_api_requests = get_anymail_setting('debug_api_requests', # generate debug output
|
||||
kwargs=kwargs, default=False)
|
||||
self.ignore_unsupported_features = get_anymail_setting(
|
||||
"ignore_unsupported_features", kwargs=kwargs, default=False
|
||||
)
|
||||
self.ignore_recipient_status = get_anymail_setting(
|
||||
"ignore_recipient_status", kwargs=kwargs, default=False
|
||||
)
|
||||
self.debug_api_requests = get_anymail_setting(
|
||||
"debug_api_requests", kwargs=kwargs, default=False
|
||||
)
|
||||
|
||||
# Merge SEND_DEFAULTS and <esp_name>_SEND_DEFAULTS settings
|
||||
send_defaults = get_anymail_setting('send_defaults', default={}) # but not from kwargs
|
||||
esp_send_defaults = get_anymail_setting('send_defaults', esp_name=self.esp_name,
|
||||
kwargs=kwargs, default=None)
|
||||
send_defaults = get_anymail_setting(
|
||||
"send_defaults", default={} # but not from kwargs
|
||||
)
|
||||
esp_send_defaults = get_anymail_setting(
|
||||
"send_defaults", esp_name=self.esp_name, kwargs=kwargs, default=None
|
||||
)
|
||||
if esp_send_defaults is not None:
|
||||
send_defaults = send_defaults.copy()
|
||||
send_defaults.update(esp_send_defaults)
|
||||
@@ -128,7 +148,9 @@ class AnymailBaseBackend(BaseEmailBackend):
|
||||
message.anymail_status.set_recipient_status(recipient_status)
|
||||
|
||||
self.run_post_send(message) # send signal before raising status errors
|
||||
self.raise_for_recipient_status(message.anymail_status, response, payload, message)
|
||||
self.raise_for_recipient_status(
|
||||
message.anymail_status, response, payload, message
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
@@ -143,8 +165,12 @@ class AnymailBaseBackend(BaseEmailBackend):
|
||||
def run_post_send(self, message):
|
||||
"""Send post_send signal to all receivers"""
|
||||
results = post_send.send_robust(
|
||||
self.__class__, message=message, status=message.anymail_status, esp_name=self.esp_name)
|
||||
for (receiver, response) in results:
|
||||
self.__class__,
|
||||
message=message,
|
||||
status=message.anymail_status,
|
||||
esp_name=self.esp_name,
|
||||
)
|
||||
for receiver, response in results:
|
||||
if isinstance(response, Exception):
|
||||
raise response
|
||||
|
||||
@@ -161,8 +187,10 @@ class AnymailBaseBackend(BaseEmailBackend):
|
||||
:param defaults: dict
|
||||
:return: :class:BasePayload
|
||||
"""
|
||||
raise NotImplementedError("%s.%s must implement build_message_payload" %
|
||||
(self.__class__.__module__, self.__class__.__name__))
|
||||
raise NotImplementedError(
|
||||
"%s.%s must implement build_message_payload"
|
||||
% (self.__class__.__module__, self.__class__.__name__)
|
||||
)
|
||||
|
||||
def post_to_esp(self, payload, message):
|
||||
"""Post payload to ESP send API endpoint, and return the raw response.
|
||||
@@ -173,25 +201,35 @@ class AnymailBaseBackend(BaseEmailBackend):
|
||||
|
||||
Can raise AnymailAPIError (or derived exception) for problems posting to the ESP
|
||||
"""
|
||||
raise NotImplementedError("%s.%s must implement post_to_esp" %
|
||||
(self.__class__.__module__, self.__class__.__name__))
|
||||
raise NotImplementedError(
|
||||
"%s.%s must implement post_to_esp"
|
||||
% (self.__class__.__module__, self.__class__.__name__)
|
||||
)
|
||||
|
||||
def parse_recipient_status(self, response, payload, message):
|
||||
"""Return a dict mapping email to AnymailRecipientStatus for each recipient.
|
||||
|
||||
Can raise AnymailAPIError (or derived exception) if response is unparsable
|
||||
"""
|
||||
raise NotImplementedError("%s.%s must implement parse_recipient_status" %
|
||||
(self.__class__.__module__, self.__class__.__name__))
|
||||
raise NotImplementedError(
|
||||
"%s.%s must implement parse_recipient_status"
|
||||
% (self.__class__.__module__, self.__class__.__name__)
|
||||
)
|
||||
|
||||
def raise_for_recipient_status(self, anymail_status, response, payload, message):
|
||||
"""If *all* recipients are refused or invalid, raises AnymailRecipientsRefused"""
|
||||
"""
|
||||
If *all* recipients are refused or invalid, raises AnymailRecipientsRefused
|
||||
"""
|
||||
if not self.ignore_recipient_status:
|
||||
# Error if *all* recipients are invalid or refused
|
||||
# (This behavior parallels smtplib.SMTPRecipientsRefused from Django's SMTP EmailBackend)
|
||||
# Error if *all* recipients are invalid or refused. (This behavior parallels
|
||||
# smtplib.SMTPRecipientsRefused from Django's SMTP EmailBackend.)
|
||||
if anymail_status.status.issubset({"invalid", "rejected"}):
|
||||
raise AnymailRecipientsRefused(email_message=message, payload=payload, response=response,
|
||||
backend=self)
|
||||
raise AnymailRecipientsRefused(
|
||||
email_message=message,
|
||||
payload=payload,
|
||||
response=response,
|
||||
backend=self,
|
||||
)
|
||||
|
||||
@property
|
||||
def esp_name(self):
|
||||
@@ -202,8 +240,10 @@ class AnymailBaseBackend(BaseEmailBackend):
|
||||
esp_name = "Postmark"
|
||||
esp_name = "SendGrid" # (use ESP's preferred capitalization)
|
||||
"""
|
||||
raise NotImplementedError("%s.%s must declare esp_name class attr" %
|
||||
(self.__class__.__module__, self.__class__.__name__))
|
||||
raise NotImplementedError(
|
||||
"%s.%s must declare esp_name class attr"
|
||||
% (self.__class__.__module__, self.__class__.__name__)
|
||||
)
|
||||
|
||||
|
||||
class BasePayload:
|
||||
@@ -222,36 +262,36 @@ class BasePayload:
|
||||
# the combined/converted results for each attr.
|
||||
base_message_attrs = (
|
||||
# Standard EmailMessage/EmailMultiAlternatives props
|
||||
('from_email', last, parse_address_list), # multiple from_emails are allowed
|
||||
('to', combine, parse_address_list),
|
||||
('cc', combine, parse_address_list),
|
||||
('bcc', combine, parse_address_list),
|
||||
('subject', last, force_non_lazy),
|
||||
('reply_to', combine, parse_address_list),
|
||||
('extra_headers', combine, force_non_lazy_dict),
|
||||
('body', last, force_non_lazy), # special handling below checks message.content_subtype
|
||||
('alternatives', combine, 'prepped_alternatives'),
|
||||
('attachments', combine, 'prepped_attachments'),
|
||||
("from_email", last, parse_address_list), # multiple from_emails are allowed
|
||||
("to", combine, parse_address_list),
|
||||
("cc", combine, parse_address_list),
|
||||
("bcc", combine, parse_address_list),
|
||||
("subject", last, force_non_lazy),
|
||||
("reply_to", combine, parse_address_list),
|
||||
("extra_headers", combine, force_non_lazy_dict),
|
||||
("body", last, force_non_lazy), # set_body handles content_subtype
|
||||
("alternatives", combine, "prepped_alternatives"),
|
||||
("attachments", combine, "prepped_attachments"),
|
||||
)
|
||||
anymail_message_attrs = (
|
||||
# Anymail expando-props
|
||||
('envelope_sender', last, parse_single_address),
|
||||
('metadata', combine, force_non_lazy_dict),
|
||||
('send_at', last, 'aware_datetime'),
|
||||
('tags', combine, force_non_lazy_list),
|
||||
('track_clicks', last, None),
|
||||
('track_opens', last, None),
|
||||
('template_id', last, force_non_lazy),
|
||||
('merge_data', combine, force_non_lazy_dict),
|
||||
('merge_global_data', combine, force_non_lazy_dict),
|
||||
('merge_metadata', combine, force_non_lazy_dict),
|
||||
('esp_extra', combine, force_non_lazy_dict),
|
||||
("envelope_sender", last, parse_single_address),
|
||||
("metadata", combine, force_non_lazy_dict),
|
||||
("send_at", last, "aware_datetime"),
|
||||
("tags", combine, force_non_lazy_list),
|
||||
("track_clicks", last, None),
|
||||
("track_opens", last, None),
|
||||
("template_id", last, force_non_lazy),
|
||||
("merge_data", combine, force_non_lazy_dict),
|
||||
("merge_global_data", combine, force_non_lazy_dict),
|
||||
("merge_metadata", combine, force_non_lazy_dict),
|
||||
("esp_extra", combine, force_non_lazy_dict),
|
||||
)
|
||||
esp_message_attrs = () # subclasses can override
|
||||
|
||||
# If any of these attrs are set on a message, treat the message
|
||||
# as a batch send (separate message for each `to` recipient):
|
||||
batch_attrs = ('merge_data', 'merge_metadata')
|
||||
batch_attrs = ("merge_data", "merge_metadata")
|
||||
|
||||
def __init__(self, message, defaults, backend):
|
||||
self.message = message
|
||||
@@ -262,11 +302,16 @@ class BasePayload:
|
||||
|
||||
self.init_payload()
|
||||
|
||||
# we should consider hoisting the first text/html out of alternatives into set_html_body
|
||||
message_attrs = self.base_message_attrs + self.anymail_message_attrs + self.esp_message_attrs
|
||||
# we should consider hoisting the first text/html
|
||||
# out of alternatives into set_html_body
|
||||
message_attrs = (
|
||||
self.base_message_attrs
|
||||
+ self.anymail_message_attrs
|
||||
+ self.esp_message_attrs
|
||||
)
|
||||
for attr, combiner, converter in message_attrs:
|
||||
value = getattr(message, attr, UNSET)
|
||||
if attr in ('to', 'cc', 'bcc', 'reply_to') and value is not UNSET:
|
||||
if attr in ("to", "cc", "bcc", "reply_to") and value is not UNSET:
|
||||
self.validate_not_bare_string(attr, value)
|
||||
if combiner is not None:
|
||||
default_value = self.defaults.get(attr, UNSET)
|
||||
@@ -281,16 +326,17 @@ class BasePayload:
|
||||
else:
|
||||
value = converter(value)
|
||||
if value is not UNSET:
|
||||
if attr == 'from_email':
|
||||
if attr == "from_email":
|
||||
setter = self.set_from_email_list
|
||||
elif attr == 'extra_headers':
|
||||
elif attr == "extra_headers":
|
||||
setter = self.process_extra_headers
|
||||
else:
|
||||
# AttributeError here? Your Payload subclass is missing a set_<attr> implementation
|
||||
setter = getattr(self, 'set_%s' % attr)
|
||||
# AttributeError here? Your Payload subclass is missing
|
||||
# a set_<attr> implementation
|
||||
setter = getattr(self, "set_%s" % attr)
|
||||
setter(value)
|
||||
if attr in self.batch_attrs:
|
||||
self._batch_attrs_used[attr] = (value is not UNSET)
|
||||
self._batch_attrs_used[attr] = value is not UNSET
|
||||
|
||||
def is_batch(self):
|
||||
"""
|
||||
@@ -301,45 +347,62 @@ class BasePayload:
|
||||
inside a set_<attr> method or during __init__).
|
||||
"""
|
||||
batch_attrs_used = self._batch_attrs_used.values()
|
||||
assert UNSET not in batch_attrs_used, "Cannot call is_batch before all attributes processed"
|
||||
assert (
|
||||
UNSET not in batch_attrs_used
|
||||
), "Cannot call is_batch before all attributes processed"
|
||||
return any(batch_attrs_used)
|
||||
|
||||
def unsupported_feature(self, feature):
|
||||
if not self.backend.ignore_unsupported_features:
|
||||
raise AnymailUnsupportedFeature("%s does not support %s" % (self.esp_name, feature),
|
||||
email_message=self.message, payload=self, backend=self.backend)
|
||||
raise AnymailUnsupportedFeature(
|
||||
"%s does not support %s" % (self.esp_name, feature),
|
||||
email_message=self.message,
|
||||
payload=self,
|
||||
backend=self.backend,
|
||||
)
|
||||
|
||||
def process_extra_headers(self, headers):
|
||||
# Handle some special-case headers, and pass the remainder to set_extra_headers.
|
||||
# (Subclasses shouldn't need to override this.)
|
||||
headers = CaseInsensitiveDict(headers) # email headers are case-insensitive per RFC-822 et seq
|
||||
|
||||
reply_to = headers.pop('Reply-To', None)
|
||||
# email headers are case-insensitive per RFC-822 et seq:
|
||||
headers = CaseInsensitiveDict(headers)
|
||||
|
||||
reply_to = headers.pop("Reply-To", None)
|
||||
if reply_to:
|
||||
# message.extra_headers['Reply-To'] will override message.reply_to
|
||||
# (because the extra_headers attr is processed after reply_to).
|
||||
# This matches the behavior of Django's EmailMessage.message().
|
||||
self.set_reply_to(parse_address_list([reply_to], field="extra_headers['Reply-To']"))
|
||||
self.set_reply_to(
|
||||
parse_address_list([reply_to], field="extra_headers['Reply-To']")
|
||||
)
|
||||
|
||||
if 'From' in headers:
|
||||
# If message.extra_headers['From'] is supplied, it should override message.from_email,
|
||||
# but message.from_email should be used as the envelope_sender. See:
|
||||
# - https://code.djangoproject.com/ticket/9214
|
||||
# - https://github.com/django/django/blob/1.8/django/core/mail/message.py#L269
|
||||
# - https://github.com/django/django/blob/1.8/django/core/mail/backends/smtp.py#L118
|
||||
header_from = parse_address_list(headers.pop('From'), field="extra_headers['From']")
|
||||
envelope_sender = parse_single_address(self.message.from_email, field="from_email") # must be single
|
||||
if "From" in headers:
|
||||
# If message.extra_headers['From'] is supplied, it should override
|
||||
# message.from_email, but message.from_email should be used as the
|
||||
# envelope_sender. See:
|
||||
# https://code.djangoproject.com/ticket/9214
|
||||
# https://github.com/django/django/blob/1.8/django/core/mail/message.py#L269
|
||||
# https://github.com/django/django/blob/1.8/django/core/mail/backends/smtp.py#L118
|
||||
header_from = parse_address_list(
|
||||
headers.pop("From"), field="extra_headers['From']"
|
||||
)
|
||||
# sender must be single:
|
||||
envelope_sender = parse_single_address(
|
||||
self.message.from_email, field="from_email"
|
||||
)
|
||||
self.set_from_email_list(header_from)
|
||||
self.set_envelope_sender(envelope_sender)
|
||||
|
||||
if 'To' in headers:
|
||||
# If message.extra_headers['To'] is supplied, message.to is used only as the envelope
|
||||
# recipients (SMTP.sendmail to_addrs), and the header To is spoofed. See:
|
||||
# - https://github.com/django/django/blob/1.8/django/core/mail/message.py#L270
|
||||
# - https://github.com/django/django/blob/1.8/django/core/mail/backends/smtp.py#L119-L120
|
||||
if "To" in headers:
|
||||
# If message.extra_headers['To'] is supplied, message.to is used only as
|
||||
# the envelope recipients (SMTP.sendmail to_addrs), and the header To is
|
||||
# spoofed. See:
|
||||
# https://github.com/django/django/blob/1.8/django/core/mail/message.py#L270
|
||||
# https://github.com/django/django/blob/1.8/django/core/mail/backends/smtp.py#L119-L120
|
||||
# No current ESP supports this, so this code is mainly here to flag
|
||||
# the SMTP backend's behavior as an unsupported feature in Anymail:
|
||||
header_to = headers.pop('To')
|
||||
header_to = headers.pop("To")
|
||||
self.set_spoofed_to_header(header_to)
|
||||
|
||||
if headers:
|
||||
@@ -363,20 +426,25 @@ class BasePayload:
|
||||
# TypeError: can only concatenate list (not "str") to list
|
||||
# TypeError: Can't convert 'list' object to str implicitly
|
||||
if isinstance(value, str) or is_lazy(value):
|
||||
raise TypeError('"{attr}" attribute must be a list or other iterable'.format(attr=attr))
|
||||
raise TypeError(
|
||||
'"{attr}" attribute must be a list or other iterable'.format(attr=attr)
|
||||
)
|
||||
|
||||
#
|
||||
# Attribute converters
|
||||
#
|
||||
|
||||
def prepped_alternatives(self, alternatives):
|
||||
return [(force_non_lazy(content), mimetype)
|
||||
for (content, mimetype) in alternatives]
|
||||
return [
|
||||
(force_non_lazy(content), mimetype) for (content, mimetype) in alternatives
|
||||
]
|
||||
|
||||
def prepped_attachments(self, attachments):
|
||||
str_encoding = self.message.encoding or settings.DEFAULT_CHARSET
|
||||
return [Attachment(attachment, str_encoding) # (handles lazy content, filename)
|
||||
for attachment in attachments]
|
||||
return [
|
||||
Attachment(attachment, str_encoding) # (handles lazy content, filename)
|
||||
for attachment in attachments
|
||||
]
|
||||
|
||||
def aware_datetime(self, value):
|
||||
"""Converts a date or datetime or timestamp to an aware datetime.
|
||||
@@ -406,12 +474,14 @@ class BasePayload:
|
||||
#
|
||||
|
||||
def init_payload(self):
|
||||
raise NotImplementedError("%s.%s must implement init_payload" %
|
||||
(self.__class__.__module__, self.__class__.__name__))
|
||||
raise NotImplementedError(
|
||||
"%s.%s must implement init_payload"
|
||||
% (self.__class__.__module__, self.__class__.__name__)
|
||||
)
|
||||
|
||||
def set_from_email_list(self, emails):
|
||||
# If your backend supports multiple from emails, override this to handle the whole list;
|
||||
# otherwise just implement set_from_email
|
||||
# If your backend supports multiple from emails, override this to handle
|
||||
# the whole list; otherwise just implement set_from_email
|
||||
if len(emails) > 1:
|
||||
self.unsupported_feature("multiple from emails")
|
||||
# fall through if ignoring unsupported features
|
||||
@@ -419,36 +489,42 @@ class BasePayload:
|
||||
self.set_from_email(emails[0])
|
||||
|
||||
def set_from_email(self, email):
|
||||
raise NotImplementedError("%s.%s must implement set_from_email or set_from_email_list" %
|
||||
(self.__class__.__module__, self.__class__.__name__))
|
||||
raise NotImplementedError(
|
||||
"%s.%s must implement set_from_email or set_from_email_list"
|
||||
% (self.__class__.__module__, self.__class__.__name__)
|
||||
)
|
||||
|
||||
def set_to(self, emails):
|
||||
return self.set_recipients('to', emails)
|
||||
return self.set_recipients("to", emails)
|
||||
|
||||
def set_cc(self, emails):
|
||||
return self.set_recipients('cc', emails)
|
||||
return self.set_recipients("cc", emails)
|
||||
|
||||
def set_bcc(self, emails):
|
||||
return self.set_recipients('bcc', emails)
|
||||
return self.set_recipients("bcc", emails)
|
||||
|
||||
def set_recipients(self, recipient_type, emails):
|
||||
for email in emails:
|
||||
self.add_recipient(recipient_type, email)
|
||||
|
||||
def add_recipient(self, recipient_type, email):
|
||||
raise NotImplementedError("%s.%s must implement add_recipient, set_recipients, or set_{to,cc,bcc}" %
|
||||
(self.__class__.__module__, self.__class__.__name__))
|
||||
raise NotImplementedError(
|
||||
"%s.%s must implement add_recipient, set_recipients, or set_{to,cc,bcc}"
|
||||
% (self.__class__.__module__, self.__class__.__name__)
|
||||
)
|
||||
|
||||
def set_subject(self, subject):
|
||||
raise NotImplementedError("%s.%s must implement set_subject" %
|
||||
(self.__class__.__module__, self.__class__.__name__))
|
||||
raise NotImplementedError(
|
||||
"%s.%s must implement set_subject"
|
||||
% (self.__class__.__module__, self.__class__.__name__)
|
||||
)
|
||||
|
||||
def set_reply_to(self, emails):
|
||||
self.unsupported_feature('reply_to')
|
||||
self.unsupported_feature("reply_to")
|
||||
|
||||
def set_extra_headers(self, headers):
|
||||
# headers is a CaseInsensitiveDict, and is a copy (so is safe to modify)
|
||||
self.unsupported_feature('extra_headers')
|
||||
self.unsupported_feature("extra_headers")
|
||||
|
||||
def set_body(self, body):
|
||||
# Interpret message.body depending on message.content_subtype.
|
||||
@@ -463,12 +539,16 @@ class BasePayload:
|
||||
self.add_alternative(body, "text/%s" % content_subtype)
|
||||
|
||||
def set_text_body(self, body):
|
||||
raise NotImplementedError("%s.%s must implement set_text_body" %
|
||||
(self.__class__.__module__, self.__class__.__name__))
|
||||
raise NotImplementedError(
|
||||
"%s.%s must implement set_text_body"
|
||||
% (self.__class__.__module__, self.__class__.__name__)
|
||||
)
|
||||
|
||||
def set_html_body(self, body):
|
||||
raise NotImplementedError("%s.%s must implement set_html_body" %
|
||||
(self.__class__.__module__, self.__class__.__name__))
|
||||
raise NotImplementedError(
|
||||
"%s.%s must implement set_html_body"
|
||||
% (self.__class__.__module__, self.__class__.__name__)
|
||||
)
|
||||
|
||||
def set_alternatives(self, alternatives):
|
||||
# Handle treating first text/{plain,html} alternatives as bodies.
|
||||
@@ -499,12 +579,15 @@ class BasePayload:
|
||||
self.add_attachment(attachment)
|
||||
|
||||
def add_attachment(self, attachment):
|
||||
raise NotImplementedError("%s.%s must implement add_attachment or set_attachments" %
|
||||
(self.__class__.__module__, self.__class__.__name__))
|
||||
raise NotImplementedError(
|
||||
"%s.%s must implement add_attachment or set_attachments"
|
||||
% (self.__class__.__module__, self.__class__.__name__)
|
||||
)
|
||||
|
||||
def set_spoofed_to_header(self, header_to):
|
||||
# In the unlikely case an ESP supports *completely replacing* the To message header
|
||||
# without altering the actual envelope recipients, the backend can implement this.
|
||||
# In the unlikely case an ESP supports *completely replacing* the To message
|
||||
# header without altering the actual envelope recipients, the backend can
|
||||
# implement this.
|
||||
self.unsupported_feature("spoofing `To` header")
|
||||
|
||||
# Anymail-specific payload construction
|
||||
@@ -557,13 +640,18 @@ class BasePayload:
|
||||
return json.dumps(data, default=self._json_default)
|
||||
except TypeError as err:
|
||||
# Add some context to the "not JSON serializable" message
|
||||
raise AnymailSerializationError(orig_err=err, email_message=self.message,
|
||||
backend=self.backend, payload=self) from None
|
||||
raise AnymailSerializationError(
|
||||
orig_err=err,
|
||||
email_message=self.message,
|
||||
backend=self.backend,
|
||||
payload=self,
|
||||
) from None
|
||||
|
||||
@staticmethod
|
||||
def _json_default(o):
|
||||
"""json.dump default function that handles some common Payload data types"""
|
||||
if isinstance(o, CaseInsensitiveDict): # used for headers
|
||||
return dict(o)
|
||||
raise TypeError("Object of type '%s' is not JSON serializable" %
|
||||
o.__class__.__name__)
|
||||
raise TypeError(
|
||||
"Object of type '%s' is not JSON serializable" % o.__class__.__name__
|
||||
)
|
||||
|
||||
@@ -3,9 +3,10 @@ from urllib.parse import urljoin
|
||||
import requests
|
||||
|
||||
from anymail.utils import get_anymail_setting
|
||||
from .base import AnymailBaseBackend, BasePayload
|
||||
|
||||
from .._version import __version__
|
||||
from ..exceptions import AnymailRequestsAPIError
|
||||
from .base import AnymailBaseBackend, BasePayload
|
||||
|
||||
|
||||
class AnymailRequestsBackend(AnymailBaseBackend):
|
||||
@@ -16,7 +17,9 @@ class AnymailRequestsBackend(AnymailBaseBackend):
|
||||
def __init__(self, api_url, **kwargs):
|
||||
"""Init options from Django settings"""
|
||||
self.api_url = api_url
|
||||
self.timeout = get_anymail_setting('requests_timeout', kwargs=kwargs, default=30)
|
||||
self.timeout = get_anymail_setting(
|
||||
"requests_timeout", kwargs=kwargs, default=30
|
||||
)
|
||||
super().__init__(**kwargs)
|
||||
self.session = None
|
||||
|
||||
@@ -49,7 +52,10 @@ class AnymailRequestsBackend(AnymailBaseBackend):
|
||||
raise RuntimeError(
|
||||
"Session has not been opened in {class_name}._send. "
|
||||
"(This is either an implementation error in {class_name}, "
|
||||
"or you are incorrectly calling _send directly.)".format(class_name=class_name))
|
||||
"or you are incorrectly calling _send directly.)".format(
|
||||
class_name=class_name
|
||||
)
|
||||
)
|
||||
return super()._send(message)
|
||||
|
||||
def create_session(self):
|
||||
@@ -61,11 +67,13 @@ class AnymailRequestsBackend(AnymailBaseBackend):
|
||||
session = requests.Session()
|
||||
|
||||
session.headers["User-Agent"] = "django-anymail/{version}-{esp} {orig}".format(
|
||||
esp=self.esp_name.lower(), version=__version__,
|
||||
orig=session.headers.get("User-Agent", ""))
|
||||
esp=self.esp_name.lower(),
|
||||
version=__version__,
|
||||
orig=session.headers.get("User-Agent", ""),
|
||||
)
|
||||
|
||||
if self.debug_api_requests:
|
||||
session.hooks['response'].append(self._dump_api_request)
|
||||
session.hooks["response"].append(self._dump_api_request)
|
||||
|
||||
return session
|
||||
|
||||
@@ -79,16 +87,20 @@ class AnymailRequestsBackend(AnymailBaseBackend):
|
||||
Can raise AnymailRequestsAPIError for HTTP errors in the post
|
||||
"""
|
||||
params = payload.get_request_params(self.api_url)
|
||||
params.setdefault('timeout', self.timeout)
|
||||
params.setdefault("timeout", self.timeout)
|
||||
try:
|
||||
response = self.session.request(**params)
|
||||
except requests.RequestException as err:
|
||||
# raise an exception that is both AnymailRequestsAPIError
|
||||
# and the original requests exception type
|
||||
exc_class = type('AnymailRequestsAPIError', (AnymailRequestsAPIError, type(err)), {})
|
||||
exc_class = type(
|
||||
"AnymailRequestsAPIError", (AnymailRequestsAPIError, type(err)), {}
|
||||
)
|
||||
raise exc_class(
|
||||
"Error posting to %s:" % params.get('url', '<missing url>'),
|
||||
email_message=message, payload=payload) from err
|
||||
"Error posting to %s:" % params.get("url", "<missing url>"),
|
||||
email_message=message,
|
||||
payload=payload,
|
||||
) from err
|
||||
self.raise_for_status(response, payload, message)
|
||||
return response
|
||||
|
||||
@@ -101,8 +113,8 @@ class AnymailRequestsBackend(AnymailBaseBackend):
|
||||
"""
|
||||
if response.status_code < 200 or response.status_code >= 300:
|
||||
raise AnymailRequestsAPIError(
|
||||
email_message=message, payload=payload,
|
||||
response=response, backend=self)
|
||||
email_message=message, payload=payload, response=response, backend=self
|
||||
)
|
||||
|
||||
def deserialize_json_response(self, response, payload, message):
|
||||
"""Deserialize an ESP API response that's in json.
|
||||
@@ -112,9 +124,13 @@ class AnymailRequestsBackend(AnymailBaseBackend):
|
||||
try:
|
||||
return response.json()
|
||||
except ValueError as err:
|
||||
raise AnymailRequestsAPIError("Invalid JSON in %s API response" % self.esp_name,
|
||||
email_message=message, payload=payload, response=response,
|
||||
backend=self) from err
|
||||
raise AnymailRequestsAPIError(
|
||||
"Invalid JSON in %s API response" % self.esp_name,
|
||||
email_message=message,
|
||||
payload=payload,
|
||||
response=response,
|
||||
backend=self,
|
||||
) from err
|
||||
|
||||
@staticmethod
|
||||
def _dump_api_request(response, **kwargs):
|
||||
@@ -125,31 +141,52 @@ class AnymailRequestsBackend(AnymailBaseBackend):
|
||||
# in http://docs.python-requests.org/en/v3.0.0/api/#api-changes)
|
||||
request = response.request # a PreparedRequest
|
||||
print("\n===== Anymail API request")
|
||||
print("{method} {url}\n{headers}".format(
|
||||
method=request.method, url=request.url,
|
||||
headers="".join("{header}: {value}\n".format(header=header, value=value)
|
||||
for (header, value) in request.headers.items()),
|
||||
))
|
||||
print(
|
||||
"{method} {url}\n{headers}".format(
|
||||
method=request.method,
|
||||
url=request.url,
|
||||
headers="".join(
|
||||
"{header}: {value}\n".format(header=header, value=value)
|
||||
for (header, value) in request.headers.items()
|
||||
),
|
||||
)
|
||||
)
|
||||
if request.body is not None:
|
||||
body_text = (request.body if isinstance(request.body, str)
|
||||
else request.body.decode("utf-8", errors="replace")
|
||||
).replace("\r\n", "\n")
|
||||
body_text = (
|
||||
request.body
|
||||
if isinstance(request.body, str)
|
||||
else request.body.decode("utf-8", errors="replace")
|
||||
).replace("\r\n", "\n")
|
||||
print(body_text)
|
||||
print("\n----- Response")
|
||||
print("HTTP {status} {reason}\n{headers}\n{body}".format(
|
||||
status=response.status_code, reason=response.reason,
|
||||
headers="".join("{header}: {value}\n".format(header=header, value=value)
|
||||
for (header, value) in response.headers.items()),
|
||||
body=response.text, # Let Requests decode body content for us
|
||||
))
|
||||
print(
|
||||
"HTTP {status} {reason}\n{headers}\n{body}".format(
|
||||
status=response.status_code,
|
||||
reason=response.reason,
|
||||
headers="".join(
|
||||
"{header}: {value}\n".format(header=header, value=value)
|
||||
for (header, value) in response.headers.items()
|
||||
),
|
||||
body=response.text, # Let Requests decode body content for us
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class RequestsPayload(BasePayload):
|
||||
"""Abstract Payload for AnymailRequestsBackend"""
|
||||
|
||||
def __init__(self, message, defaults, backend,
|
||||
method="POST", params=None, data=None,
|
||||
headers=None, files=None, auth=None):
|
||||
def __init__(
|
||||
self,
|
||||
message,
|
||||
defaults,
|
||||
backend,
|
||||
method="POST",
|
||||
params=None,
|
||||
data=None,
|
||||
headers=None,
|
||||
files=None,
|
||||
auth=None,
|
||||
):
|
||||
self.method = method
|
||||
self.params = params
|
||||
self.data = data
|
||||
@@ -183,7 +220,10 @@ class RequestsPayload(BasePayload):
|
||||
)
|
||||
|
||||
def get_api_endpoint(self):
|
||||
"""Returns a str that should be joined to the backend's api_url for sending this payload."""
|
||||
"""
|
||||
Returns a str that should be joined to the backend's api_url
|
||||
for sending this payload.
|
||||
"""
|
||||
return None
|
||||
|
||||
def serialize_data(self):
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import uuid
|
||||
|
||||
from django.core.mail.backends.console import EmailBackend as DjangoConsoleBackend
|
||||
|
||||
from ..exceptions import AnymailError
|
||||
|
||||
@@ -4,22 +4,25 @@ from urllib.parse import quote
|
||||
|
||||
from requests import Request
|
||||
|
||||
from .base_requests import AnymailRequestsBackend, RequestsPayload
|
||||
from ..exceptions import AnymailError, AnymailRequestsAPIError
|
||||
from ..message import AnymailRecipientStatus
|
||||
from ..utils import get_anymail_setting, rfc2822date
|
||||
from .base_requests import AnymailRequestsBackend, RequestsPayload
|
||||
|
||||
|
||||
# Feature-detect whether requests (urllib3) correctly uses RFC 7578 encoding for non-
|
||||
# ASCII filenames in Content-Disposition headers. (This was fixed in urllib3 v1.25.)
|
||||
# See MailgunPayload.get_request_params for info (and a workaround on older versions).
|
||||
# (Note: when this workaround is removed, please also remove the "old_urllib3" tox envs.)
|
||||
# (Note: when this workaround is removed, please also remove "old_urllib3" tox envs.)
|
||||
def is_requests_rfc_5758_compliant():
|
||||
request = Request(method='POST', url='https://www.example.com',
|
||||
files=[('attachment', ('\N{NOT SIGN}.txt', 'test', 'text/plain'))])
|
||||
request = Request(
|
||||
method="POST",
|
||||
url="https://www.example.com",
|
||||
files=[("attachment", ("\N{NOT SIGN}.txt", "test", "text/plain"))],
|
||||
)
|
||||
prepared = request.prepare()
|
||||
form_data = prepared.body # bytes
|
||||
return b'filename*=' not in form_data
|
||||
return b"filename*=" not in form_data
|
||||
|
||||
|
||||
REQUESTS_IS_RFC_7578_COMPLIANT = is_requests_rfc_5758_compliant()
|
||||
@@ -35,11 +38,22 @@ class EmailBackend(AnymailRequestsBackend):
|
||||
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)
|
||||
self.sender_domain = get_anymail_setting('sender_domain', esp_name=esp_name, kwargs=kwargs,
|
||||
allow_bare=True, default=None)
|
||||
api_url = get_anymail_setting('api_url', esp_name=esp_name, kwargs=kwargs,
|
||||
default="https://api.mailgun.net/v3")
|
||||
self.api_key = get_anymail_setting(
|
||||
"api_key", esp_name=esp_name, kwargs=kwargs, allow_bare=True
|
||||
)
|
||||
self.sender_domain = get_anymail_setting(
|
||||
"sender_domain",
|
||||
esp_name=esp_name,
|
||||
kwargs=kwargs,
|
||||
allow_bare=True,
|
||||
default=None,
|
||||
)
|
||||
api_url = get_anymail_setting(
|
||||
"api_url",
|
||||
esp_name=esp_name,
|
||||
kwargs=kwargs,
|
||||
default="https://api.mailgun.net/v3",
|
||||
)
|
||||
if not api_url.endswith("/"):
|
||||
api_url += "/"
|
||||
super().__init__(api_url, **kwargs)
|
||||
@@ -55,9 +69,13 @@ class EmailBackend(AnymailRequestsBackend):
|
||||
"Unknown sender domain {sender_domain!r}.\n"
|
||||
"Check the domain is verified with Mailgun, and that the ANYMAIL"
|
||||
" MAILGUN_API_URL setting {api_url!r} is the correct region.".format(
|
||||
sender_domain=payload.sender_domain, api_url=self.api_url),
|
||||
email_message=message, payload=payload,
|
||||
response=response, backend=self)
|
||||
sender_domain=payload.sender_domain, api_url=self.api_url
|
||||
),
|
||||
email_message=message,
|
||||
payload=payload,
|
||||
response=response,
|
||||
backend=self,
|
||||
)
|
||||
|
||||
super().raise_for_status(response, payload, message)
|
||||
|
||||
@@ -68,8 +86,11 @@ class EmailBackend(AnymailRequestsBackend):
|
||||
"Invalid Mailgun API endpoint %r.\n"
|
||||
"Check your ANYMAIL MAILGUN_SENDER_DOMAIN"
|
||||
" and MAILGUN_API_URL settings." % response.url,
|
||||
email_message=message, payload=payload,
|
||||
response=response, backend=self)
|
||||
email_message=message,
|
||||
payload=payload,
|
||||
response=response,
|
||||
backend=self,
|
||||
)
|
||||
|
||||
def parse_recipient_status(self, response, payload, message):
|
||||
# The *only* 200 response from Mailgun seems to be:
|
||||
@@ -86,20 +107,27 @@ class EmailBackend(AnymailRequestsBackend):
|
||||
message_id = parsed_response["id"]
|
||||
mailgun_message = parsed_response["message"]
|
||||
except (KeyError, TypeError) as err:
|
||||
raise AnymailRequestsAPIError("Invalid Mailgun API response format",
|
||||
email_message=message, payload=payload, response=response,
|
||||
backend=self) from err
|
||||
raise AnymailRequestsAPIError(
|
||||
"Invalid Mailgun API response format",
|
||||
email_message=message,
|
||||
payload=payload,
|
||||
response=response,
|
||||
backend=self,
|
||||
) from err
|
||||
if not mailgun_message.startswith("Queued"):
|
||||
raise AnymailRequestsAPIError("Unrecognized Mailgun API message '%s'" % mailgun_message,
|
||||
email_message=message, payload=payload, response=response,
|
||||
backend=self)
|
||||
raise AnymailRequestsAPIError(
|
||||
"Unrecognized Mailgun API message '%s'" % mailgun_message,
|
||||
email_message=message,
|
||||
payload=payload,
|
||||
response=response,
|
||||
backend=self,
|
||||
)
|
||||
# Simulate a per-recipient status of "queued":
|
||||
status = AnymailRecipientStatus(message_id=message_id, status="queued")
|
||||
return {recipient.addr_spec: status for recipient in payload.all_recipients}
|
||||
|
||||
|
||||
class MailgunPayload(RequestsPayload):
|
||||
|
||||
def __init__(self, message, defaults, backend, *args, **kwargs):
|
||||
auth = ("api", backend.api_key)
|
||||
self.sender_domain = backend.sender_domain
|
||||
@@ -116,22 +144,32 @@ class MailgunPayload(RequestsPayload):
|
||||
|
||||
def get_api_endpoint(self):
|
||||
if self.sender_domain is None:
|
||||
raise AnymailError("Cannot call Mailgun unknown sender domain. "
|
||||
"Either provide valid `from_email`, "
|
||||
"or set `message.esp_extra={'sender_domain': 'example.com'}`",
|
||||
backend=self.backend, email_message=self.message, payload=self)
|
||||
if '/' in self.sender_domain or '%2f' in self.sender_domain.lower():
|
||||
raise AnymailError(
|
||||
"Cannot call Mailgun unknown sender domain. "
|
||||
"Either provide valid `from_email`, "
|
||||
"or set `message.esp_extra={'sender_domain': 'example.com'}`",
|
||||
backend=self.backend,
|
||||
email_message=self.message,
|
||||
payload=self,
|
||||
)
|
||||
if "/" in self.sender_domain or "%2f" in self.sender_domain.lower():
|
||||
# Mailgun returns a cryptic 200-OK "Mailgun Magnificent API" response
|
||||
# if '/' (or even %-encoded '/') confuses it about the API endpoint.
|
||||
raise AnymailError("Invalid '/' in sender domain '%s'" % self.sender_domain,
|
||||
backend=self.backend, email_message=self.message, payload=self)
|
||||
return "%s/messages" % quote(self.sender_domain, safe='')
|
||||
raise AnymailError(
|
||||
"Invalid '/' in sender domain '%s'" % self.sender_domain,
|
||||
backend=self.backend,
|
||||
email_message=self.message,
|
||||
payload=self,
|
||||
)
|
||||
return "%s/messages" % quote(self.sender_domain, safe="")
|
||||
|
||||
def get_request_params(self, api_url):
|
||||
params = super().get_request_params(api_url)
|
||||
non_ascii_filenames = [filename
|
||||
for (field, (filename, content, mimetype)) in params["files"]
|
||||
if filename is not None and not isascii(filename)]
|
||||
non_ascii_filenames = [
|
||||
filename
|
||||
for (field, (filename, content, mimetype)) in params["files"]
|
||||
if filename is not None and not isascii(filename)
|
||||
]
|
||||
if non_ascii_filenames and not REQUESTS_IS_RFC_7578_COMPLIANT:
|
||||
# Workaround https://github.com/requests/requests/issues/4652:
|
||||
# Mailgun expects RFC 7578 compliant multipart/form-data, and is confused
|
||||
@@ -139,7 +177,7 @@ class MailgunPayload(RequestsPayload):
|
||||
# ("filename*=utf-8''...") in Content-Disposition headers.
|
||||
# The workaround is to pre-generate the (non-compliant) form-data body, and
|
||||
# replace 'filename*={RFC 2231 encoded}' with 'filename="{UTF-8 bytes}"'.
|
||||
# Replace _only_ the filenames that will be problems (not all "filename*=...")
|
||||
# Replace _only_ filenames that will be problems (not all "filename*=...")
|
||||
# to minimize potential side effects--e.g., in attached messages that might
|
||||
# have their own attachments with (correctly) RFC 2231 encoded filenames.
|
||||
prepared = Request(**params).prepare()
|
||||
@@ -147,10 +185,12 @@ class MailgunPayload(RequestsPayload):
|
||||
for filename in non_ascii_filenames: # text
|
||||
rfc2231_filename = encode_rfc2231(filename, charset="utf-8")
|
||||
form_data = form_data.replace(
|
||||
b'filename*=' + rfc2231_filename.encode("utf-8"),
|
||||
b'filename="' + filename.encode("utf-8") + b'"')
|
||||
b"filename*=" + rfc2231_filename.encode("utf-8"),
|
||||
b'filename="' + filename.encode("utf-8") + b'"',
|
||||
)
|
||||
params["data"] = form_data
|
||||
params["headers"]["Content-Type"] = prepared.headers["Content-Type"] # multipart/form-data; boundary=...
|
||||
# Content-Type: multipart/form-data; boundary=...
|
||||
params["headers"]["Content-Type"] = prepared.headers["Content-Type"]
|
||||
params["files"] = None # these are now in the form_data body
|
||||
return params
|
||||
|
||||
@@ -181,32 +221,39 @@ class MailgunPayload(RequestsPayload):
|
||||
# are available as `%recipient.KEY%` virtually anywhere in the message
|
||||
# (including header fields and other parameters).
|
||||
#
|
||||
# Anymail needs both mechanisms to map its normalized metadata and template merge_data
|
||||
# features to Mailgun:
|
||||
# Anymail needs both mechanisms to map its normalized metadata and template
|
||||
# merge_data features to Mailgun:
|
||||
# (1) Anymail's `metadata` maps directly to Mailgun's custom data, where it can be
|
||||
# accessed from webhooks.
|
||||
# (2) Anymail's `merge_metadata` (per-recipient metadata for batch sends) maps
|
||||
# *indirectly* through recipient-variables to Mailgun's custom data. To avoid
|
||||
# conflicts, the recipient-variables mapping prepends 'v:' to merge_metadata keys.
|
||||
# (E.g., Mailgun's custom-data "user" is set to "%recipient.v:user", which picks
|
||||
# up its per-recipient value from Mailgun's `recipient-variables[to_email]["v:user"]`.)
|
||||
# (3) Anymail's `merge_data` (per-recipient template substitutions) maps directly to
|
||||
# Mailgun's `recipient-variables`, where it can be referenced in on-the-fly templates.
|
||||
# (4) Anymail's `merge_global_data` (global template substitutions) is copied to
|
||||
# Mailgun's `recipient-variables` for every recipient, as the default for missing
|
||||
# `merge_data` keys.
|
||||
# conflicts, the recipient-variables mapping prepends 'v:' to merge_metadata
|
||||
# keys. (E.g., Mailgun's custom-data "user" is set to "%recipient.v:user",
|
||||
# which picks up its per-recipient value from Mailgun's
|
||||
# `recipient-variables[to_email]["v:user"]`.)
|
||||
# (3) Anymail's `merge_data` (per-recipient template substitutions) maps directly
|
||||
# to Mailgun's `recipient-variables`, where it can be referenced in on-the-fly
|
||||
# templates.
|
||||
# (4) Anymail's `merge_global_data` (global template substitutions) is copied
|
||||
# to Mailgun's `recipient-variables` for every recipient, as the default
|
||||
# for missing `merge_data` keys.
|
||||
# (5) Only if a stored template is used, `merge_data` and `merge_global_data` are
|
||||
# *also* mapped *indirectly* through recipient-variables to Mailgun's custom data,
|
||||
# where they can be referenced in handlebars {{substitutions}}.
|
||||
# *also* mapped *indirectly* through recipient-variables to Mailgun's custom
|
||||
# data, where they can be referenced in handlebars {{substitutions}}.
|
||||
# (E.g., Mailgun's custom-data "name" is set to "%recipient.name%", which picks
|
||||
# up its per-recipient value from Mailgun's `recipient-variables[to_email]["name"]`.)
|
||||
# up its per-recipient value from Mailgun's
|
||||
# `recipient-variables[to_email]["name"]`.)
|
||||
#
|
||||
# If Anymail's `merge_data`, `template_id` (stored templates) and `metadata` (or
|
||||
# `merge_metadata`) are used together, there's a possibility of conflicting keys in
|
||||
# Mailgun's custom data. Anymail treats that conflict as an unsupported feature error.
|
||||
# `merge_metadata`) are used together, there's a possibility of conflicting keys
|
||||
# in Mailgun's custom data. Anymail treats that conflict as an unsupported feature
|
||||
# error.
|
||||
|
||||
def populate_recipient_variables(self):
|
||||
"""Populate Mailgun recipient-variables and custom data from merge data and metadata"""
|
||||
"""
|
||||
Populate Mailgun recipient-variables and custom data
|
||||
from merge data and metadata
|
||||
"""
|
||||
# (numbers refer to detailed explanation above)
|
||||
# Mailgun parameters to construct:
|
||||
recipient_variables = {}
|
||||
@@ -217,53 +264,66 @@ class MailgunPayload(RequestsPayload):
|
||||
|
||||
# (2) merge_metadata --> Mailgun custom_data via recipient_variables
|
||||
if self.merge_metadata:
|
||||
def vkey(key): # 'v:key'
|
||||
return 'v:{}'.format(key)
|
||||
|
||||
merge_metadata_keys = flatset( # all keys used in any recipient's merge_metadata
|
||||
recipient_data.keys() for recipient_data in self.merge_metadata.values())
|
||||
custom_data.update({ # custom_data['key'] = '%recipient.v:key%' indirection
|
||||
key: '%recipient.{}%'.format(vkey(key))
|
||||
for key in merge_metadata_keys})
|
||||
base_recipient_data = { # defaults for each recipient must cover all keys
|
||||
vkey(key): self.metadata.get(key, '')
|
||||
for key in merge_metadata_keys}
|
||||
def vkey(key): # 'v:key'
|
||||
return "v:{}".format(key)
|
||||
|
||||
# all keys used in any recipient's merge_metadata:
|
||||
merge_metadata_keys = flatset(
|
||||
recipient_data.keys() for recipient_data in self.merge_metadata.values()
|
||||
)
|
||||
# custom_data['key'] = '%recipient.v:key%' indirection:
|
||||
custom_data.update(
|
||||
{key: "%recipient.{}%".format(vkey(key)) for key in merge_metadata_keys}
|
||||
)
|
||||
# defaults for each recipient must cover all keys:
|
||||
base_recipient_data = {
|
||||
vkey(key): self.metadata.get(key, "") for key in merge_metadata_keys
|
||||
}
|
||||
for email in self.to_emails:
|
||||
this_recipient_data = base_recipient_data.copy()
|
||||
this_recipient_data.update({
|
||||
vkey(key): value
|
||||
for key, value in self.merge_metadata.get(email, {}).items()})
|
||||
this_recipient_data.update(
|
||||
{
|
||||
vkey(key): value
|
||||
for key, value in self.merge_metadata.get(email, {}).items()
|
||||
}
|
||||
)
|
||||
recipient_variables.setdefault(email, {}).update(this_recipient_data)
|
||||
|
||||
# (3) and (4) merge_data, merge_global_data --> Mailgun recipient_variables
|
||||
if self.merge_data or self.merge_global_data:
|
||||
merge_data_keys = flatset( # all keys used in any recipient's merge_data
|
||||
recipient_data.keys() for recipient_data in self.merge_data.values())
|
||||
# all keys used in any recipient's merge_data:
|
||||
merge_data_keys = flatset(
|
||||
recipient_data.keys() for recipient_data in self.merge_data.values()
|
||||
)
|
||||
merge_data_keys = merge_data_keys.union(self.merge_global_data.keys())
|
||||
base_recipient_data = { # defaults for each recipient must cover all keys
|
||||
key: self.merge_global_data.get(key, '')
|
||||
for key in merge_data_keys}
|
||||
# defaults for each recipient must cover all keys:
|
||||
base_recipient_data = {
|
||||
key: self.merge_global_data.get(key, "") for key in merge_data_keys
|
||||
}
|
||||
for email in self.to_emails:
|
||||
this_recipient_data = base_recipient_data.copy()
|
||||
this_recipient_data.update(self.merge_data.get(email, {}))
|
||||
recipient_variables.setdefault(email, {}).update(this_recipient_data)
|
||||
|
||||
# (5) if template, also map Mailgun custom_data to per-recipient_variables
|
||||
if self.data.get('template') is not None:
|
||||
if self.data.get("template") is not None:
|
||||
conflicts = merge_data_keys.intersection(custom_data.keys())
|
||||
if conflicts:
|
||||
self.unsupported_feature(
|
||||
"conflicting merge_data and metadata keys (%s) when using template_id"
|
||||
% ', '.join("'%s'" % key for key in conflicts))
|
||||
custom_data.update({ # custom_data['key'] = '%recipient.key%' indirection
|
||||
key: '%recipient.{}%'.format(key)
|
||||
for key in merge_data_keys})
|
||||
"conflicting merge_data and metadata keys (%s)"
|
||||
" when using template_id"
|
||||
% ", ".join("'%s'" % key for key in conflicts)
|
||||
)
|
||||
# custom_data['key'] = '%recipient.key%' indirection:
|
||||
custom_data.update(
|
||||
{key: "%recipient.{}%".format(key) for key in merge_data_keys}
|
||||
)
|
||||
|
||||
# populate Mailgun params
|
||||
self.data.update({'v:%s' % key: value
|
||||
for key, value in custom_data.items()})
|
||||
self.data.update({"v:%s" % key: value for key, value in custom_data.items()})
|
||||
if recipient_variables or self.is_batch():
|
||||
self.data['recipient-variables'] = self.serialize_json(recipient_variables)
|
||||
self.data["recipient-variables"] = self.serialize_json(recipient_variables)
|
||||
|
||||
#
|
||||
# Payload construction
|
||||
@@ -285,9 +345,11 @@ class MailgunPayload(RequestsPayload):
|
||||
assert recipient_type in ["to", "cc", "bcc"]
|
||||
if emails:
|
||||
self.data[recipient_type] = [email.address for email in emails]
|
||||
self.all_recipients += emails # used for backend.parse_recipient_status
|
||||
if recipient_type == 'to':
|
||||
self.to_emails = [email.addr_spec for email in emails] # used for populate_recipient_variables
|
||||
# used for backend.parse_recipient_status:
|
||||
self.all_recipients += emails
|
||||
if recipient_type == "to":
|
||||
# used for populate_recipient_variables:
|
||||
self.to_emails = [email.addr_spec for email in emails]
|
||||
|
||||
def set_subject(self, subject):
|
||||
self.data["subject"] = subject
|
||||
@@ -306,7 +368,8 @@ class MailgunPayload(RequestsPayload):
|
||||
|
||||
def set_html_body(self, body):
|
||||
if "html" in self.data:
|
||||
# second html body could show up through multiple alternatives, or html body + alternative
|
||||
# second html body could show up through multiple alternatives,
|
||||
# or html body + alternative
|
||||
self.unsupported_feature("multiple html parts")
|
||||
self.data["html"] = body
|
||||
|
||||
@@ -330,9 +393,7 @@ class MailgunPayload(RequestsPayload):
|
||||
name = attachment.name
|
||||
if not name:
|
||||
self.unsupported_feature("attachments without filenames")
|
||||
self.files.append(
|
||||
(field, (name, attachment.content, attachment.mimetype))
|
||||
)
|
||||
self.files.append((field, (name, attachment.content, attachment.mimetype)))
|
||||
|
||||
def set_envelope_sender(self, email):
|
||||
# Only the domain is used
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from .base_requests import AnymailRequestsBackend, RequestsPayload
|
||||
from ..exceptions import AnymailRequestsAPIError
|
||||
from ..message import AnymailRecipientStatus
|
||||
from ..utils import get_anymail_setting, update_deep
|
||||
from .base_requests import AnymailRequestsBackend, RequestsPayload
|
||||
|
||||
|
||||
class EmailBackend(AnymailRequestsBackend):
|
||||
@@ -14,10 +14,18 @@ class EmailBackend(AnymailRequestsBackend):
|
||||
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)
|
||||
self.secret_key = get_anymail_setting('secret_key', esp_name=esp_name, kwargs=kwargs, allow_bare=True)
|
||||
api_url = get_anymail_setting('api_url', esp_name=esp_name, kwargs=kwargs,
|
||||
default="https://api.mailjet.com/v3.1/")
|
||||
self.api_key = get_anymail_setting(
|
||||
"api_key", esp_name=esp_name, kwargs=kwargs, allow_bare=True
|
||||
)
|
||||
self.secret_key = get_anymail_setting(
|
||||
"secret_key", esp_name=esp_name, kwargs=kwargs, allow_bare=True
|
||||
)
|
||||
api_url = get_anymail_setting(
|
||||
"api_url",
|
||||
esp_name=esp_name,
|
||||
kwargs=kwargs,
|
||||
default="https://api.mailjet.com/v3.1/",
|
||||
)
|
||||
if not api_url.endswith("/"):
|
||||
api_url += "/"
|
||||
super().__init__(api_url, **kwargs)
|
||||
@@ -37,42 +45,57 @@ class EmailBackend(AnymailRequestsBackend):
|
||||
|
||||
# Global error? (no messages sent)
|
||||
if "ErrorCode" in parsed_response:
|
||||
raise AnymailRequestsAPIError(email_message=message, payload=payload, response=response, backend=self)
|
||||
raise AnymailRequestsAPIError(
|
||||
email_message=message, payload=payload, response=response, backend=self
|
||||
)
|
||||
|
||||
recipient_status = {}
|
||||
try:
|
||||
for result in parsed_response["Messages"]:
|
||||
status = 'sent' if result["Status"] == 'success' else 'failed' # Status is 'success' or 'error'
|
||||
recipients = result.get("To", []) + result.get("Cc", []) + result.get("Bcc", [])
|
||||
# result["Status"] is "success" or "error"
|
||||
status = "sent" if result["Status"] == "success" else "failed"
|
||||
recipients = (
|
||||
result.get("To", []) + result.get("Cc", []) + result.get("Bcc", [])
|
||||
)
|
||||
for recipient in recipients:
|
||||
email = recipient['Email']
|
||||
message_id = str(recipient['MessageID']) # MessageUUID isn't yet useful for other Mailjet APIs
|
||||
recipient_status[email] = AnymailRecipientStatus(message_id=message_id, status=status)
|
||||
# Note that for errors, Mailjet doesn't identify the problem recipients.
|
||||
# This can occur with a batch send. We patch up the missing recipients below.
|
||||
email = recipient["Email"]
|
||||
# other Mailjet APIs expect MessageID (not MessageUUID)
|
||||
message_id = str(recipient["MessageID"])
|
||||
recipient_status[email] = AnymailRecipientStatus(
|
||||
message_id=message_id, status=status
|
||||
)
|
||||
# For errors, Mailjet doesn't identify the problem recipients. (This
|
||||
# can occur with a batch send.) Patch up the missing recipients below.
|
||||
except (KeyError, TypeError) as err:
|
||||
raise AnymailRequestsAPIError("Invalid Mailjet API response format",
|
||||
email_message=message, payload=payload, response=response,
|
||||
backend=self) from err
|
||||
raise AnymailRequestsAPIError(
|
||||
"Invalid Mailjet API response format",
|
||||
email_message=message,
|
||||
payload=payload,
|
||||
response=response,
|
||||
backend=self,
|
||||
) from err
|
||||
|
||||
# Any recipient who wasn't reported as a 'success' must have been an error:
|
||||
for email in payload.recipients:
|
||||
if email.addr_spec not in recipient_status:
|
||||
recipient_status[email.addr_spec] = AnymailRecipientStatus(message_id=None, status='failed')
|
||||
recipient_status[email.addr_spec] = AnymailRecipientStatus(
|
||||
message_id=None, status="failed"
|
||||
)
|
||||
|
||||
return recipient_status
|
||||
|
||||
|
||||
class MailjetPayload(RequestsPayload):
|
||||
|
||||
def __init__(self, message, defaults, backend, *args, **kwargs):
|
||||
auth = (backend.api_key, backend.secret_key)
|
||||
http_headers = {
|
||||
'Content-Type': 'application/json',
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
self.recipients = [] # for backend parse_recipient_status
|
||||
self.metadata = None
|
||||
super().__init__(message, defaults, backend, auth=auth, headers=http_headers, *args, **kwargs)
|
||||
super().__init__(
|
||||
message, defaults, backend, auth=auth, headers=http_headers, *args, **kwargs
|
||||
)
|
||||
|
||||
def get_api_endpoint(self):
|
||||
return "send"
|
||||
@@ -122,9 +145,9 @@ class MailjetPayload(RequestsPayload):
|
||||
# This case shouldn't happen. Please file a bug report if it does.
|
||||
raise AssertionError("set_to called with non-empty Messages list")
|
||||
if emails:
|
||||
self.data["Messages"].append({
|
||||
"To": [self._mailjet_email(email) for email in emails]
|
||||
})
|
||||
self.data["Messages"].append(
|
||||
{"To": [self._mailjet_email(email) for email in emails]}
|
||||
)
|
||||
self.recipients += emails
|
||||
else:
|
||||
# Mailjet requires a To list; cc-only messages aren't possible
|
||||
@@ -132,12 +155,16 @@ class MailjetPayload(RequestsPayload):
|
||||
|
||||
def set_cc(self, emails):
|
||||
if emails:
|
||||
self.data["Globals"]["Cc"] = [self._mailjet_email(email) for email in emails]
|
||||
self.data["Globals"]["Cc"] = [
|
||||
self._mailjet_email(email) for email in emails
|
||||
]
|
||||
self.recipients += emails
|
||||
|
||||
def set_bcc(self, emails):
|
||||
if emails:
|
||||
self.data["Globals"]["Bcc"] = [self._mailjet_email(email) for email in emails]
|
||||
self.data["Globals"]["Bcc"] = [
|
||||
self._mailjet_email(email) for email in emails
|
||||
]
|
||||
self.recipients += emails
|
||||
|
||||
def set_subject(self, subject):
|
||||
@@ -159,7 +186,8 @@ class MailjetPayload(RequestsPayload):
|
||||
def set_html_body(self, body):
|
||||
if body is not None:
|
||||
if "HTMLPart" in self.data["Globals"]:
|
||||
# second html body could show up through multiple alternatives, or html body + alternative
|
||||
# second html body could show up through multiple alternatives,
|
||||
# or html body + alternative
|
||||
self.unsupported_feature("multiple html parts")
|
||||
|
||||
self.data["Globals"]["HTMLPart"] = body
|
||||
@@ -183,7 +211,7 @@ class MailjetPayload(RequestsPayload):
|
||||
def set_metadata(self, metadata):
|
||||
# Mailjet expects a single string payload
|
||||
self.data["Globals"]["EventPayload"] = self.serialize_json(metadata)
|
||||
self.metadata = metadata # keep original in case we need to merge with merge_metadata
|
||||
self.metadata = metadata # save for set_merge_metadata
|
||||
|
||||
def set_merge_metadata(self, merge_metadata):
|
||||
self._burst_for_batch_send()
|
||||
@@ -204,7 +232,7 @@ class MailjetPayload(RequestsPayload):
|
||||
if len(tags) > 0:
|
||||
self.data["Globals"]["CustomCampaign"] = tags[0]
|
||||
if len(tags) > 1:
|
||||
self.unsupported_feature('multiple tags (%r)' % tags)
|
||||
self.unsupported_feature("multiple tags (%r)" % tags)
|
||||
|
||||
def set_track_clicks(self, track_clicks):
|
||||
self.data["Globals"]["TrackClicks"] = "enabled" if track_clicks else "disabled"
|
||||
@@ -213,7 +241,8 @@ class MailjetPayload(RequestsPayload):
|
||||
self.data["Globals"]["TrackOpens"] = "enabled" if track_opens else "disabled"
|
||||
|
||||
def set_template_id(self, template_id):
|
||||
self.data["Globals"]["TemplateID"] = int(template_id) # Mailjet requires integer (not string)
|
||||
# Mailjet requires integer (not string) TemplateID:
|
||||
self.data["Globals"]["TemplateID"] = int(template_id)
|
||||
self.data["Globals"]["TemplateLanguage"] = True
|
||||
|
||||
def set_merge_data(self, merge_data):
|
||||
|
||||
@@ -2,9 +2,8 @@ import warnings
|
||||
from datetime import datetime
|
||||
|
||||
from ..exceptions import AnymailRequestsAPIError, AnymailWarning
|
||||
from ..message import AnymailRecipientStatus, ANYMAIL_STATUSES
|
||||
from ..utils import last, combine, get_anymail_setting
|
||||
|
||||
from ..message import ANYMAIL_STATUSES, AnymailRecipientStatus
|
||||
from ..utils import combine, get_anymail_setting, last
|
||||
from .base_requests import AnymailRequestsBackend, RequestsPayload
|
||||
|
||||
|
||||
@@ -18,9 +17,15 @@ class EmailBackend(AnymailRequestsBackend):
|
||||
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)
|
||||
api_url = get_anymail_setting('api_url', esp_name=esp_name, kwargs=kwargs,
|
||||
default="https://mandrillapp.com/api/1.0")
|
||||
self.api_key = get_anymail_setting(
|
||||
"api_key", esp_name=esp_name, kwargs=kwargs, allow_bare=True
|
||||
)
|
||||
api_url = get_anymail_setting(
|
||||
"api_url",
|
||||
esp_name=esp_name,
|
||||
kwargs=kwargs,
|
||||
default="https://mandrillapp.com/api/1.0",
|
||||
)
|
||||
if not api_url.endswith("/"):
|
||||
api_url += "/"
|
||||
super().__init__(api_url, **kwargs)
|
||||
@@ -32,18 +37,26 @@ class EmailBackend(AnymailRequestsBackend):
|
||||
parsed_response = self.deserialize_json_response(response, payload, message)
|
||||
recipient_status = {}
|
||||
try:
|
||||
# Mandrill returns a list of { email, status, _id, reject_reason } for each recipient
|
||||
# Mandrill returns a list of { email, status, _id, reject_reason }
|
||||
# for each recipient
|
||||
for item in parsed_response:
|
||||
email = item['email']
|
||||
status = item['status']
|
||||
email = item["email"]
|
||||
status = item["status"]
|
||||
if status not in ANYMAIL_STATUSES:
|
||||
status = 'unknown'
|
||||
message_id = item.get('_id', None) # can be missing for invalid/rejected recipients
|
||||
recipient_status[email] = AnymailRecipientStatus(message_id=message_id, status=status)
|
||||
status = "unknown"
|
||||
# "_id" can be missing for invalid/rejected recipients:
|
||||
message_id = item.get("_id", None)
|
||||
recipient_status[email] = AnymailRecipientStatus(
|
||||
message_id=message_id, status=status
|
||||
)
|
||||
except (KeyError, TypeError) as err:
|
||||
raise AnymailRequestsAPIError("Invalid Mandrill API response format",
|
||||
email_message=message, payload=payload, response=response,
|
||||
backend=self) from err
|
||||
raise AnymailRequestsAPIError(
|
||||
"Invalid Mandrill API response format",
|
||||
email_message=message,
|
||||
payload=payload,
|
||||
response=response,
|
||||
backend=self,
|
||||
) from err
|
||||
return recipient_status
|
||||
|
||||
|
||||
@@ -60,19 +73,18 @@ def encode_date_for_mandrill(dt):
|
||||
dt = dt.replace(microsecond=0)
|
||||
if dt.utcoffset() is not None:
|
||||
dt = (dt - dt.utcoffset()).replace(tzinfo=None)
|
||||
return dt.isoformat(' ')
|
||||
return dt.isoformat(" ")
|
||||
else:
|
||||
return dt
|
||||
|
||||
|
||||
class MandrillPayload(RequestsPayload):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.esp_extra = {} # late-bound in serialize_data
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def get_api_endpoint(self):
|
||||
if 'template_name' in self.data:
|
||||
if "template_name" in self.data:
|
||||
return "messages/send-template.json"
|
||||
else:
|
||||
return "messages/send.json"
|
||||
@@ -81,7 +93,7 @@ class MandrillPayload(RequestsPayload):
|
||||
self.process_esp_extra()
|
||||
if self.is_batch():
|
||||
# hide recipients from each other
|
||||
self.data['message']['preserve_recipients'] = False
|
||||
self.data["message"]["preserve_recipients"] = False
|
||||
return self.serialize_json(self.data)
|
||||
|
||||
#
|
||||
@@ -96,7 +108,9 @@ class MandrillPayload(RequestsPayload):
|
||||
|
||||
def set_from_email(self, email):
|
||||
if getattr(self.message, "use_template_from", False):
|
||||
self.deprecation_warning('message.use_template_from', 'message.from_email = None')
|
||||
self.deprecation_warning(
|
||||
"message.use_template_from", "message.from_email = None"
|
||||
)
|
||||
else:
|
||||
self.data["message"]["from_email"] = email.addr_spec
|
||||
if email.display_name:
|
||||
@@ -112,7 +126,9 @@ class MandrillPayload(RequestsPayload):
|
||||
|
||||
def set_subject(self, subject):
|
||||
if getattr(self.message, "use_template_subject", False):
|
||||
self.deprecation_warning('message.use_template_subject', 'message.subject = None')
|
||||
self.deprecation_warning(
|
||||
"message.use_template_subject", "message.subject = None"
|
||||
)
|
||||
else:
|
||||
self.data["message"]["subject"] = subject
|
||||
|
||||
@@ -129,7 +145,8 @@ class MandrillPayload(RequestsPayload):
|
||||
|
||||
def set_html_body(self, body):
|
||||
if "html" in self.data["message"]:
|
||||
# second html body could show up through multiple alternatives, or html body + alternative
|
||||
# second html body could show up through multiple alternatives,
|
||||
# or html body + alternative
|
||||
self.unsupported_feature("multiple html parts")
|
||||
self.data["message"]["html"] = body
|
||||
|
||||
@@ -140,11 +157,13 @@ class MandrillPayload(RequestsPayload):
|
||||
else:
|
||||
field = "attachments"
|
||||
name = attachment.name or ""
|
||||
self.data["message"].setdefault(field, []).append({
|
||||
"type": attachment.mimetype,
|
||||
"name": name,
|
||||
"content": attachment.b64content
|
||||
})
|
||||
self.data["message"].setdefault(field, []).append(
|
||||
{
|
||||
"type": attachment.mimetype,
|
||||
"name": name,
|
||||
"content": attachment.b64content,
|
||||
}
|
||||
)
|
||||
|
||||
def set_envelope_sender(self, email):
|
||||
# Only the domain is used
|
||||
@@ -170,22 +189,28 @@ class MandrillPayload(RequestsPayload):
|
||||
self.data.setdefault("template_content", []) # Mandrill requires something here
|
||||
|
||||
def set_merge_data(self, merge_data):
|
||||
self.data['message']['merge_vars'] = [
|
||||
{'rcpt': rcpt, 'vars': [{'name': key, 'content': rcpt_data[key]}
|
||||
for key in sorted(rcpt_data.keys())]} # sort for testing reproducibility
|
||||
self.data["message"]["merge_vars"] = [
|
||||
{
|
||||
"rcpt": rcpt,
|
||||
"vars": [
|
||||
# sort for testing reproducibility:
|
||||
{"name": key, "content": rcpt_data[key]}
|
||||
for key in sorted(rcpt_data.keys())
|
||||
],
|
||||
}
|
||||
for rcpt, rcpt_data in merge_data.items()
|
||||
]
|
||||
|
||||
def set_merge_global_data(self, merge_global_data):
|
||||
self.data['message']['global_merge_vars'] = [
|
||||
{'name': var, 'content': value}
|
||||
for var, value in merge_global_data.items()
|
||||
self.data["message"]["global_merge_vars"] = [
|
||||
{"name": var, "content": value} for var, value in merge_global_data.items()
|
||||
]
|
||||
|
||||
def set_merge_metadata(self, merge_metadata):
|
||||
# recipient_metadata format is similar to, but not quite the same as, merge_vars:
|
||||
self.data['message']['recipient_metadata'] = [
|
||||
{'rcpt': rcpt, 'values': rcpt_data}
|
||||
# recipient_metadata format is similar to, but not quite the same as,
|
||||
# merge_vars:
|
||||
self.data["message"]["recipient_metadata"] = [
|
||||
{"rcpt": rcpt, "values": rcpt_data}
|
||||
for rcpt, rcpt_data in merge_metadata.items()
|
||||
]
|
||||
|
||||
@@ -198,33 +223,40 @@ class MandrillPayload(RequestsPayload):
|
||||
esp_extra = self.esp_extra
|
||||
# Convert pythonic template_content dict to Mandrill name/content list
|
||||
try:
|
||||
template_content = esp_extra['template_content']
|
||||
template_content = esp_extra["template_content"]
|
||||
except KeyError:
|
||||
pass
|
||||
else:
|
||||
if hasattr(template_content, 'items'): # if it's dict-like
|
||||
# if it's dict-like:
|
||||
if hasattr(template_content, "items"):
|
||||
if esp_extra is self.esp_extra:
|
||||
esp_extra = self.esp_extra.copy() # don't modify caller's value
|
||||
esp_extra['template_content'] = [
|
||||
{'name': var, 'content': value}
|
||||
for var, value in template_content.items()]
|
||||
# don't modify caller's value
|
||||
esp_extra = self.esp_extra.copy()
|
||||
esp_extra["template_content"] = [
|
||||
{"name": var, "content": value}
|
||||
for var, value in template_content.items()
|
||||
]
|
||||
# Convert pythonic recipient_metadata dict to Mandrill rcpt/values list
|
||||
try:
|
||||
recipient_metadata = esp_extra['message']['recipient_metadata']
|
||||
recipient_metadata = esp_extra["message"]["recipient_metadata"]
|
||||
except KeyError:
|
||||
pass
|
||||
else:
|
||||
if hasattr(recipient_metadata, 'keys'): # if it's dict-like
|
||||
if esp_extra['message'] is self.esp_extra['message']:
|
||||
esp_extra['message'] = self.esp_extra['message'].copy() # don't modify caller's value
|
||||
# For testing reproducibility, we sort the recipients
|
||||
esp_extra['message']['recipient_metadata'] = [
|
||||
{'rcpt': rcpt, 'values': recipient_metadata[rcpt]}
|
||||
for rcpt in sorted(recipient_metadata.keys())]
|
||||
# Merge esp_extra with payload data: shallow merge within ['message'] and top-level keys
|
||||
self.data.update({k: v for k, v in esp_extra.items() if k != 'message'})
|
||||
# if it's dict-like:
|
||||
if hasattr(recipient_metadata, "keys"):
|
||||
if esp_extra["message"] is self.esp_extra["message"]:
|
||||
# don't modify caller's value:
|
||||
esp_extra["message"] = self.esp_extra["message"].copy()
|
||||
# For testing reproducibility, sort the recipients
|
||||
esp_extra["message"]["recipient_metadata"] = [
|
||||
{"rcpt": rcpt, "values": recipient_metadata[rcpt]}
|
||||
for rcpt in sorted(recipient_metadata.keys())
|
||||
]
|
||||
# Merge esp_extra with payload data: shallow merge within ['message']
|
||||
# and top-level keys
|
||||
self.data.update({k: v for k, v in esp_extra.items() if k != "message"})
|
||||
try:
|
||||
self.data['message'].update(esp_extra['message'])
|
||||
self.data["message"].update(esp_extra["message"])
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
@@ -245,69 +277,75 @@ class MandrillPayload(RequestsPayload):
|
||||
self.deprecation_warning(feature, replacement)
|
||||
|
||||
esp_message_attrs = (
|
||||
('async', last, None),
|
||||
('ip_pool', last, None),
|
||||
('from_name', last, None), # overrides display name parsed from from_email above
|
||||
('important', last, None),
|
||||
('auto_text', last, None),
|
||||
('auto_html', last, None),
|
||||
('inline_css', last, None),
|
||||
('url_strip_qs', last, None),
|
||||
('tracking_domain', last, None),
|
||||
('signing_domain', last, None),
|
||||
('return_path_domain', last, None),
|
||||
('merge_language', last, None),
|
||||
('preserve_recipients', last, None),
|
||||
('view_content_link', last, None),
|
||||
('subaccount', last, None),
|
||||
('google_analytics_domains', last, None),
|
||||
('google_analytics_campaign', last, None),
|
||||
('global_merge_vars', combine, None),
|
||||
('merge_vars', combine, None),
|
||||
('recipient_metadata', combine, None),
|
||||
('template_name', last, None),
|
||||
('template_content', combine, None),
|
||||
("async", last, None),
|
||||
("ip_pool", last, None),
|
||||
("from_name", last, None), # overrides display name parsed from from_email
|
||||
("important", last, None),
|
||||
("auto_text", last, None),
|
||||
("auto_html", last, None),
|
||||
("inline_css", last, None),
|
||||
("url_strip_qs", last, None),
|
||||
("tracking_domain", last, None),
|
||||
("signing_domain", last, None),
|
||||
("return_path_domain", last, None),
|
||||
("merge_language", last, None),
|
||||
("preserve_recipients", last, None),
|
||||
("view_content_link", last, None),
|
||||
("subaccount", last, None),
|
||||
("google_analytics_domains", last, None),
|
||||
("google_analytics_campaign", last, None),
|
||||
("global_merge_vars", combine, None),
|
||||
("merge_vars", combine, None),
|
||||
("recipient_metadata", combine, None),
|
||||
("template_name", last, None),
|
||||
("template_content", combine, None),
|
||||
)
|
||||
|
||||
def set_async(self, is_async):
|
||||
self.deprecated_to_esp_extra('async')
|
||||
self.esp_extra['async'] = is_async
|
||||
self.deprecated_to_esp_extra("async")
|
||||
self.esp_extra["async"] = is_async
|
||||
|
||||
def set_ip_pool(self, ip_pool):
|
||||
self.deprecated_to_esp_extra('ip_pool')
|
||||
self.esp_extra['ip_pool'] = ip_pool
|
||||
self.deprecated_to_esp_extra("ip_pool")
|
||||
self.esp_extra["ip_pool"] = ip_pool
|
||||
|
||||
def set_global_merge_vars(self, global_merge_vars):
|
||||
self.deprecation_warning('message.global_merge_vars', 'message.merge_global_data')
|
||||
self.deprecation_warning(
|
||||
"message.global_merge_vars", "message.merge_global_data"
|
||||
)
|
||||
self.set_merge_global_data(global_merge_vars)
|
||||
|
||||
def set_merge_vars(self, merge_vars):
|
||||
self.deprecation_warning('message.merge_vars', 'message.merge_data')
|
||||
self.deprecation_warning("message.merge_vars", "message.merge_data")
|
||||
self.set_merge_data(merge_vars)
|
||||
|
||||
def set_return_path_domain(self, domain):
|
||||
self.deprecation_warning('message.return_path_domain', 'message.envelope_sender')
|
||||
self.esp_extra.setdefault('message', {})['return_path_domain'] = domain
|
||||
self.deprecation_warning(
|
||||
"message.return_path_domain", "message.envelope_sender"
|
||||
)
|
||||
self.esp_extra.setdefault("message", {})["return_path_domain"] = domain
|
||||
|
||||
def set_template_name(self, template_name):
|
||||
self.deprecation_warning('message.template_name', 'message.template_id')
|
||||
self.deprecation_warning("message.template_name", "message.template_id")
|
||||
self.set_template_id(template_name)
|
||||
|
||||
def set_template_content(self, template_content):
|
||||
self.deprecated_to_esp_extra('template_content')
|
||||
self.esp_extra['template_content'] = template_content
|
||||
self.deprecated_to_esp_extra("template_content")
|
||||
self.esp_extra["template_content"] = template_content
|
||||
|
||||
def set_recipient_metadata(self, recipient_metadata):
|
||||
self.deprecated_to_esp_extra('recipient_metadata', in_message_dict=True)
|
||||
self.esp_extra.setdefault('message', {})['recipient_metadata'] = recipient_metadata
|
||||
self.deprecated_to_esp_extra("recipient_metadata", in_message_dict=True)
|
||||
self.esp_extra.setdefault("message", {})[
|
||||
"recipient_metadata"
|
||||
] = recipient_metadata
|
||||
|
||||
# Set up simple set_<attr> functions for any missing esp_message_attrs attrs
|
||||
# (avoids dozens of simple `self.data["message"][<attr>] = value` functions)
|
||||
|
||||
@classmethod
|
||||
def define_message_attr_setters(cls):
|
||||
for (attr, _, _) in cls.esp_message_attrs:
|
||||
setter_name = 'set_%s' % attr
|
||||
for attr, _, _ in cls.esp_message_attrs:
|
||||
setter_name = "set_%s" % attr
|
||||
try:
|
||||
getattr(cls, setter_name)
|
||||
except AttributeError:
|
||||
@@ -316,10 +354,12 @@ class MandrillPayload(RequestsPayload):
|
||||
|
||||
@staticmethod
|
||||
def make_setter(attr, setter_name):
|
||||
# sure wish we could use functools.partial to create instance methods (descriptors)
|
||||
# sure wish we could use functools.partial
|
||||
# to create instance methods (descriptors)
|
||||
def setter(self, value):
|
||||
self.deprecated_to_esp_extra(attr, in_message_dict=True)
|
||||
self.esp_extra.setdefault('message', {})[attr] = value
|
||||
self.esp_extra.setdefault("message", {})[attr] = value
|
||||
|
||||
setter.__name__ = setter_name
|
||||
return setter
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from .base_requests import AnymailRequestsBackend, RequestsPayload
|
||||
from ..exceptions import AnymailRequestsAPIError
|
||||
from ..message import AnymailRecipientStatus
|
||||
from ..utils import get_anymail_setting
|
||||
from .base_requests import AnymailRequestsBackend, RequestsPayload
|
||||
|
||||
|
||||
class EmailBackend(AnymailRequestsBackend):
|
||||
@@ -106,7 +106,7 @@ class PostalPayload(RequestsPayload):
|
||||
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')
|
||||
self.unsupported_feature("inline attachments")
|
||||
return att
|
||||
|
||||
def set_attachments(self, attachments):
|
||||
|
||||
@@ -2,8 +2,11 @@ import re
|
||||
|
||||
from ..exceptions import AnymailRequestsAPIError
|
||||
from ..message import AnymailRecipientStatus
|
||||
from ..utils import get_anymail_setting, parse_address_list, CaseInsensitiveCasePreservingDict
|
||||
|
||||
from ..utils import (
|
||||
CaseInsensitiveCasePreservingDict,
|
||||
get_anymail_setting,
|
||||
parse_address_list,
|
||||
)
|
||||
from .base_requests import AnymailRequestsBackend, RequestsPayload
|
||||
|
||||
|
||||
@@ -17,9 +20,15 @@ class EmailBackend(AnymailRequestsBackend):
|
||||
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://api.postmarkapp.com/")
|
||||
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://api.postmarkapp.com/",
|
||||
)
|
||||
if not api_url.endswith("/"):
|
||||
api_url += "/"
|
||||
super().__init__(api_url, **kwargs)
|
||||
@@ -33,13 +42,17 @@ class EmailBackend(AnymailRequestsBackend):
|
||||
super().raise_for_status(response, payload, message)
|
||||
|
||||
def parse_recipient_status(self, response, payload, message):
|
||||
# Default to "unknown" status for each recipient, unless/until we find otherwise.
|
||||
# (This also forces recipient_status email capitalization to match that as sent,
|
||||
# while correctly handling Postmark's lowercase-only inactive recipient reporting.)
|
||||
unknown_status = AnymailRecipientStatus(message_id=None, status='unknown')
|
||||
recipient_status = CaseInsensitiveCasePreservingDict({
|
||||
recip.addr_spec: unknown_status
|
||||
for recip in payload.to_emails + payload.cc_and_bcc_emails})
|
||||
# Default to "unknown" status for each recipient, unless/until we find
|
||||
# otherwise. (This also forces recipient_status email capitalization to match
|
||||
# that as sent, while correctly handling Postmark's lowercase-only inactive
|
||||
# recipient reporting.)
|
||||
unknown_status = AnymailRecipientStatus(message_id=None, status="unknown")
|
||||
recipient_status = CaseInsensitiveCasePreservingDict(
|
||||
{
|
||||
recip.addr_spec: unknown_status
|
||||
for recip in payload.to_emails + payload.cc_and_bcc_emails
|
||||
}
|
||||
)
|
||||
|
||||
parsed_response = self.deserialize_json_response(response, payload, message)
|
||||
if not isinstance(parsed_response, list):
|
||||
@@ -52,21 +65,30 @@ class EmailBackend(AnymailRequestsBackend):
|
||||
error_code = one_response["ErrorCode"]
|
||||
msg = one_response["Message"]
|
||||
except (KeyError, TypeError) as err:
|
||||
raise AnymailRequestsAPIError("Invalid Postmark API response format",
|
||||
email_message=message, payload=payload, response=response,
|
||||
backend=self) from err
|
||||
raise AnymailRequestsAPIError(
|
||||
"Invalid Postmark API response format",
|
||||
email_message=message,
|
||||
payload=payload,
|
||||
response=response,
|
||||
backend=self,
|
||||
) from err
|
||||
|
||||
if error_code == 0:
|
||||
# At least partial success, and (some) email was sent.
|
||||
try:
|
||||
message_id = one_response["MessageID"]
|
||||
except KeyError as err:
|
||||
raise AnymailRequestsAPIError("Invalid Postmark API success response format",
|
||||
email_message=message, payload=payload,
|
||||
response=response, backend=self) from err
|
||||
raise AnymailRequestsAPIError(
|
||||
"Invalid Postmark API success response format",
|
||||
email_message=message,
|
||||
payload=payload,
|
||||
response=response,
|
||||
backend=self,
|
||||
) from err
|
||||
|
||||
# Assume all To recipients are "sent" unless proven otherwise below.
|
||||
# (Must use "To" from API response to get correct individual MessageIDs in batch send.)
|
||||
# (Must use "To" from API response to get correct individual MessageIDs
|
||||
# in batch send.)
|
||||
try:
|
||||
to_header = one_response["To"] # (missing if cc- or bcc-only send)
|
||||
except KeyError:
|
||||
@@ -74,60 +96,89 @@ class EmailBackend(AnymailRequestsBackend):
|
||||
else:
|
||||
for to in parse_address_list(to_header):
|
||||
recipient_status[to.addr_spec] = AnymailRecipientStatus(
|
||||
message_id=message_id, status='sent')
|
||||
message_id=message_id, status="sent"
|
||||
)
|
||||
|
||||
# Assume all Cc and Bcc recipients are "sent" unless proven otherwise below.
|
||||
# (Postmark doesn't report "Cc" or "Bcc" in API response; use original payload values.)
|
||||
# Assume all Cc and Bcc recipients are "sent" unless proven otherwise
|
||||
# below. (Postmark doesn't report "Cc" or "Bcc" in API response; use
|
||||
# original payload values.)
|
||||
for recip in payload.cc_and_bcc_emails:
|
||||
recipient_status[recip.addr_spec] = AnymailRecipientStatus(
|
||||
message_id=message_id, status='sent')
|
||||
message_id=message_id, status="sent"
|
||||
)
|
||||
|
||||
# Change "sent" to "rejected" if Postmark reported an address as "Inactive".
|
||||
# Sadly, have to parse human-readable message to figure out if everyone got it:
|
||||
# "Message OK, but will not deliver to these inactive addresses: {addr_spec, ...}.
|
||||
# Inactive recipients are ones that have generated a hard bounce or a spam complaint."
|
||||
# Note that error message emails are addr_spec only (no display names) and forced lowercase.
|
||||
# Change "sent" to "rejected" if Postmark reported an address as
|
||||
# "Inactive". Sadly, have to parse human-readable message to figure out
|
||||
# if everyone got it:
|
||||
# "Message OK, but will not deliver to these inactive addresses:
|
||||
# {addr_spec, ...}. Inactive recipients are ones that have generated
|
||||
# a hard bounce or a spam complaint."
|
||||
# Note that error message emails are addr_spec only (no display names)
|
||||
# and forced lowercase.
|
||||
reject_addr_specs = self._addr_specs_from_error_msg(
|
||||
msg, r'inactive addresses:\s*(.*)\.\s*Inactive recipients')
|
||||
msg, r"inactive addresses:\s*(.*)\.\s*Inactive recipients"
|
||||
)
|
||||
for reject_addr_spec in reject_addr_specs:
|
||||
recipient_status[reject_addr_spec] = AnymailRecipientStatus(
|
||||
message_id=None, status='rejected')
|
||||
message_id=None, status="rejected"
|
||||
)
|
||||
|
||||
elif error_code == 300: # Invalid email request
|
||||
# Various parse-time validation errors, which may include invalid recipients. Email not sent.
|
||||
# response["To"] is not populated for this error; must examine response["Message"]:
|
||||
if re.match(r"^(Invalid|Error\s+parsing)\s+'(To|Cc|Bcc)'", msg, re.IGNORECASE):
|
||||
# Various parse-time validation errors, which may include invalid
|
||||
# recipients. Email not sent. response["To"] is not populated for this
|
||||
# error; must examine response["Message"]:
|
||||
if re.match(
|
||||
r"^(Invalid|Error\s+parsing)\s+'(To|Cc|Bcc)'", msg, re.IGNORECASE
|
||||
):
|
||||
# Recipient-related errors: use AnymailRecipientsRefused logic
|
||||
# "Invalid 'To' address: '{addr_spec}'."
|
||||
# "Error parsing 'Cc': Illegal email domain '{domain}' in address '{addr_spec}'."
|
||||
# "Error parsing 'Bcc': Illegal email address '{addr_spec}'. It must contain the '@' symbol."
|
||||
invalid_addr_specs = self._addr_specs_from_error_msg(msg, r"address:?\s*'(.*)'")
|
||||
# - "Invalid 'To' address: '{addr_spec}'."
|
||||
# - "Error parsing 'Cc': Illegal email domain '{domain}'
|
||||
# in address '{addr_spec}'."
|
||||
# - "Error parsing 'Bcc': Illegal email address '{addr_spec}'.
|
||||
# It must contain the '@' symbol."
|
||||
invalid_addr_specs = self._addr_specs_from_error_msg(
|
||||
msg, r"address:?\s*'(.*)'"
|
||||
)
|
||||
for invalid_addr_spec in invalid_addr_specs:
|
||||
recipient_status[invalid_addr_spec] = AnymailRecipientStatus(
|
||||
message_id=None, status='invalid')
|
||||
message_id=None, status="invalid"
|
||||
)
|
||||
else:
|
||||
# Non-recipient errors; handle as normal API error response
|
||||
# "Invalid 'From' address: '{email_address}'."
|
||||
# "Error parsing 'Reply-To': Illegal email domain '{domain}' in address '{addr_spec}'."
|
||||
# "Invalid metadata content. ..."
|
||||
raise AnymailRequestsAPIError(email_message=message, payload=payload,
|
||||
response=response, backend=self)
|
||||
# - "Invalid 'From' address: '{email_address}'."
|
||||
# - "Error parsing 'Reply-To': Illegal email domain '{domain}'
|
||||
# in address '{addr_spec}'."
|
||||
# - "Invalid metadata content. ..."
|
||||
raise AnymailRequestsAPIError(
|
||||
email_message=message,
|
||||
payload=payload,
|
||||
response=response,
|
||||
backend=self,
|
||||
)
|
||||
|
||||
elif error_code == 406: # Inactive recipient
|
||||
# All recipients were rejected as hard-bounce or spam-complaint. Email not sent.
|
||||
# response["To"] is not populated for this error; must examine response["Message"]:
|
||||
# "You tried to send to a recipient that has been marked as inactive.\n
|
||||
# Found inactive addresses: {addr_spec, ...}.\n
|
||||
# Inactive recipients are ones that have generated a hard bounce or a spam complaint. "
|
||||
# All recipients were rejected as hard-bounce or spam-complaint. Email
|
||||
# not sent. response["To"] is not populated for this error; must examine
|
||||
# response["Message"]:
|
||||
# "You tried to send to a recipient that has been marked as
|
||||
# inactive.\n Found inactive addresses: {addr_spec, ...}.\n
|
||||
# Inactive recipients are ones that have generated a hard bounce
|
||||
# or a spam complaint. "
|
||||
reject_addr_specs = self._addr_specs_from_error_msg(
|
||||
msg, r'inactive addresses:\s*(.*)\.\s*Inactive recipients')
|
||||
msg, r"inactive addresses:\s*(.*)\.\s*Inactive recipients"
|
||||
)
|
||||
for reject_addr_spec in reject_addr_specs:
|
||||
recipient_status[reject_addr_spec] = AnymailRecipientStatus(
|
||||
message_id=None, status='rejected')
|
||||
message_id=None, status="rejected"
|
||||
)
|
||||
|
||||
else: # Other error
|
||||
raise AnymailRequestsAPIError(email_message=message, payload=payload, response=response,
|
||||
backend=self)
|
||||
raise AnymailRequestsAPIError(
|
||||
email_message=message,
|
||||
payload=payload,
|
||||
response=response,
|
||||
backend=self,
|
||||
)
|
||||
|
||||
return dict(recipient_status)
|
||||
|
||||
@@ -141,33 +192,37 @@ class EmailBackend(AnymailRequestsBackend):
|
||||
match = re.search(pattern, error_msg, re.MULTILINE)
|
||||
if match:
|
||||
emails = match.group(1) # "one@xample.com, two@example.com"
|
||||
return [email.strip().lower() for email in emails.split(',')]
|
||||
return [email.strip().lower() for email in emails.split(",")]
|
||||
else:
|
||||
return []
|
||||
|
||||
|
||||
class PostmarkPayload(RequestsPayload):
|
||||
|
||||
def __init__(self, message, defaults, backend, *args, **kwargs):
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
# 'X-Postmark-Server-Token': see get_request_params (and set_esp_extra)
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
# "X-Postmark-Server-Token": see get_request_params (and set_esp_extra)
|
||||
}
|
||||
self.server_token = backend.server_token # added to headers later, so esp_extra can override
|
||||
self.server_token = backend.server_token # esp_extra can override
|
||||
self.to_emails = []
|
||||
self.cc_and_bcc_emails = [] # need to track (separately) for parse_recipient_status
|
||||
self.cc_and_bcc_emails = [] # needed for parse_recipient_status
|
||||
self.merge_data = None
|
||||
self.merge_metadata = None
|
||||
super().__init__(message, defaults, backend, headers=headers, *args, **kwargs)
|
||||
|
||||
def get_api_endpoint(self):
|
||||
batch_send = self.is_batch()
|
||||
if 'TemplateAlias' in self.data or 'TemplateId' in self.data or 'TemplateModel' in self.data:
|
||||
if (
|
||||
"TemplateAlias" in self.data
|
||||
or "TemplateId" in self.data
|
||||
or "TemplateModel" in self.data
|
||||
):
|
||||
if batch_send:
|
||||
return "email/batchWithTemplates"
|
||||
else:
|
||||
# This is the one Postmark API documented to have a trailing slash. (Typo?)
|
||||
# This is the one Postmark API documented to have a trailing slash.
|
||||
# (Typo?)
|
||||
return "email/withTemplate/"
|
||||
else:
|
||||
if batch_send:
|
||||
@@ -177,7 +232,7 @@ class PostmarkPayload(RequestsPayload):
|
||||
|
||||
def get_request_params(self, api_url):
|
||||
params = super().get_request_params(api_url)
|
||||
params['headers']['X-Postmark-Server-Token'] = self.server_token
|
||||
params["headers"]["X-Postmark-Server-Token"] = self.server_token
|
||||
return params
|
||||
|
||||
def serialize_data(self):
|
||||
@@ -189,11 +244,15 @@ class PostmarkPayload(RequestsPayload):
|
||||
elif api_endpoint == "email/batch":
|
||||
data = [self.data_for_recipient(to) for to in self.to_emails]
|
||||
elif api_endpoint == "email/withTemplate/":
|
||||
assert self.merge_data is None and self.merge_metadata is None # else it's a batch send
|
||||
assert (
|
||||
self.merge_data is None and self.merge_metadata is None
|
||||
) # else it's a batch send
|
||||
data = self.data
|
||||
else:
|
||||
raise AssertionError("PostmarkPayload.serialize_data missing"
|
||||
" case for api_endpoint %r" % api_endpoint)
|
||||
raise AssertionError(
|
||||
"PostmarkPayload.serialize_data missing"
|
||||
" case for api_endpoint %r" % api_endpoint
|
||||
)
|
||||
return self.serialize_json(data)
|
||||
|
||||
def data_for_recipient(self, to):
|
||||
@@ -222,7 +281,7 @@ class PostmarkPayload(RequestsPayload):
|
||||
#
|
||||
|
||||
def init_payload(self):
|
||||
self.data = {} # becomes json
|
||||
self.data = {} # becomes json
|
||||
|
||||
def set_from_email_list(self, emails):
|
||||
# Postmark accepts multiple From email addresses
|
||||
@@ -233,7 +292,7 @@ class PostmarkPayload(RequestsPayload):
|
||||
assert recipient_type in ["to", "cc", "bcc"]
|
||||
if emails:
|
||||
field = recipient_type.capitalize()
|
||||
self.data[field] = ', '.join([email.address for email in emails])
|
||||
self.data[field] = ", ".join([email.address for email in emails])
|
||||
if recipient_type == "to":
|
||||
self.to_emails = emails
|
||||
else:
|
||||
@@ -249,8 +308,7 @@ class PostmarkPayload(RequestsPayload):
|
||||
|
||||
def set_extra_headers(self, headers):
|
||||
self.data["Headers"] = [
|
||||
{"Name": key, "Value": value}
|
||||
for key, value in headers.items()
|
||||
{"Name": key, "Value": value} for key, value in headers.items()
|
||||
]
|
||||
|
||||
def set_text_body(self, body):
|
||||
@@ -258,7 +316,8 @@ class PostmarkPayload(RequestsPayload):
|
||||
|
||||
def set_html_body(self, body):
|
||||
if "HtmlBody" in self.data:
|
||||
# second html body could show up through multiple alternatives, or html body + alternative
|
||||
# second html body could show up through multiple alternatives,
|
||||
# or html body + alternative
|
||||
self.unsupported_feature("multiple html parts")
|
||||
self.data["HtmlBody"] = body
|
||||
|
||||
@@ -289,10 +348,10 @@ class PostmarkPayload(RequestsPayload):
|
||||
if len(tags) > 0:
|
||||
self.data["Tag"] = tags[0]
|
||||
if len(tags) > 1:
|
||||
self.unsupported_feature('multiple tags (%r)' % tags)
|
||||
self.unsupported_feature("multiple tags (%r)" % tags)
|
||||
|
||||
def set_track_clicks(self, track_clicks):
|
||||
self.data["TrackLinks"] = 'HtmlAndText' if track_clicks else 'None'
|
||||
self.data["TrackLinks"] = "HtmlAndText" if track_clicks else "None"
|
||||
|
||||
def set_track_opens(self, track_opens):
|
||||
self.data["TrackOpens"] = track_opens
|
||||
@@ -327,4 +386,4 @@ class PostmarkPayload(RequestsPayload):
|
||||
def set_esp_extra(self, extra):
|
||||
self.data.update(extra)
|
||||
# Special handling for 'server_token':
|
||||
self.server_token = self.data.pop('server_token', self.server_token)
|
||||
self.server_token = self.data.pop("server_token", self.server_token)
|
||||
|
||||
@@ -4,10 +4,10 @@ from email.utils import quote as rfc822_quote
|
||||
|
||||
from requests.structures import CaseInsensitiveDict
|
||||
|
||||
from .base_requests import AnymailRequestsBackend, RequestsPayload
|
||||
from ..exceptions import AnymailConfigurationError, AnymailWarning
|
||||
from ..message import AnymailRecipientStatus
|
||||
from ..utils import BASIC_NUMERIC_TYPES, Mapping, get_anymail_setting, update_deep
|
||||
from .base_requests import AnymailRequestsBackend, RequestsPayload
|
||||
|
||||
|
||||
class EmailBackend(AnymailRequestsBackend):
|
||||
@@ -22,29 +22,45 @@ class EmailBackend(AnymailRequestsBackend):
|
||||
esp_name = self.esp_name
|
||||
|
||||
# Warn if v2-only username or password settings found
|
||||
username = get_anymail_setting('username', esp_name=esp_name, kwargs=kwargs, default=None, allow_bare=True)
|
||||
password = get_anymail_setting('password', esp_name=esp_name, kwargs=kwargs, default=None, allow_bare=True)
|
||||
username = get_anymail_setting(
|
||||
"username", esp_name=esp_name, kwargs=kwargs, default=None, allow_bare=True
|
||||
)
|
||||
password = get_anymail_setting(
|
||||
"password", esp_name=esp_name, kwargs=kwargs, default=None, allow_bare=True
|
||||
)
|
||||
if username or password:
|
||||
raise AnymailConfigurationError(
|
||||
"SendGrid v3 API doesn't support username/password auth; Please change to API key.")
|
||||
"SendGrid v3 API doesn't support username/password auth;"
|
||||
" Please change to API key."
|
||||
)
|
||||
|
||||
self.api_key = get_anymail_setting('api_key', esp_name=esp_name, kwargs=kwargs, allow_bare=True)
|
||||
self.api_key = get_anymail_setting(
|
||||
"api_key", esp_name=esp_name, kwargs=kwargs, allow_bare=True
|
||||
)
|
||||
|
||||
self.generate_message_id = get_anymail_setting('generate_message_id', esp_name=esp_name,
|
||||
kwargs=kwargs, default=True)
|
||||
self.merge_field_format = get_anymail_setting('merge_field_format', esp_name=esp_name,
|
||||
kwargs=kwargs, default=None)
|
||||
self.generate_message_id = get_anymail_setting(
|
||||
"generate_message_id", esp_name=esp_name, kwargs=kwargs, default=True
|
||||
)
|
||||
self.merge_field_format = get_anymail_setting(
|
||||
"merge_field_format", esp_name=esp_name, kwargs=kwargs, default=None
|
||||
)
|
||||
|
||||
# Undocumented setting to disable workaround for SendGrid display-name quoting bug (see below).
|
||||
# If/when SendGrid fixes their API, recipient names will end up with extra double quotes
|
||||
# until Anymail is updated to remove the workaround. In the meantime, you can disable it
|
||||
# by adding `"SENDGRID_WORKAROUND_NAME_QUOTE_BUG": False` to your `ANYMAIL` settings.
|
||||
self.workaround_name_quote_bug = get_anymail_setting('workaround_name_quote_bug', esp_name=esp_name,
|
||||
kwargs=kwargs, default=True)
|
||||
# Undocumented setting to disable workaround for SendGrid display-name quoting
|
||||
# bug (see below). If/when SendGrid fixes their API, recipient names will end up
|
||||
# with extra double quotes until Anymail is updated to remove the workaround.
|
||||
# In the meantime, you can disable it by adding
|
||||
# `"SENDGRID_WORKAROUND_NAME_QUOTE_BUG": False` to your `ANYMAIL` settings.
|
||||
self.workaround_name_quote_bug = get_anymail_setting(
|
||||
"workaround_name_quote_bug", esp_name=esp_name, kwargs=kwargs, default=True
|
||||
)
|
||||
|
||||
# This is SendGrid's newer Web API v3
|
||||
api_url = get_anymail_setting('api_url', esp_name=esp_name, kwargs=kwargs,
|
||||
default="https://api.sendgrid.com/v3/")
|
||||
api_url = get_anymail_setting(
|
||||
"api_url",
|
||||
esp_name=esp_name,
|
||||
kwargs=kwargs,
|
||||
default="https://api.sendgrid.com/v3/",
|
||||
)
|
||||
if not api_url.endswith("/"):
|
||||
api_url += "/"
|
||||
super().__init__(api_url, **kwargs)
|
||||
@@ -53,17 +69,19 @@ class EmailBackend(AnymailRequestsBackend):
|
||||
return SendGridPayload(message, defaults, self)
|
||||
|
||||
def parse_recipient_status(self, response, payload, message):
|
||||
# If we get here, the send call was successful.
|
||||
# (SendGrid uses a non-2xx response for any failures, caught in raise_for_status.)
|
||||
# SendGrid v3 doesn't provide any information in the response for a successful send,
|
||||
# so simulate a per-recipient status of "queued":
|
||||
return {recip.addr_spec: AnymailRecipientStatus(message_id=payload.message_ids.get(recip.addr_spec),
|
||||
status="queued")
|
||||
for recip in payload.all_recipients}
|
||||
# If we get here, the "send" call was successful. (SendGrid uses a non-2xx
|
||||
# response for any failures, caught in raise_for_status.) SendGrid v3 doesn't
|
||||
# provide any information in the response for a successful send, so simulate a
|
||||
# per-recipient status of "queued":
|
||||
return {
|
||||
recip.addr_spec: AnymailRecipientStatus(
|
||||
message_id=payload.message_ids.get(recip.addr_spec), status="queued"
|
||||
)
|
||||
for recip in payload.all_recipients
|
||||
}
|
||||
|
||||
|
||||
class SendGridPayload(RequestsPayload):
|
||||
|
||||
def __init__(self, message, defaults, backend, *args, **kwargs):
|
||||
self.all_recipients = [] # used for backend.parse_recipient_status
|
||||
self.generate_message_id = backend.generate_message_id
|
||||
@@ -75,11 +93,13 @@ class SendGridPayload(RequestsPayload):
|
||||
self.merge_global_data = {}
|
||||
self.merge_metadata = {}
|
||||
|
||||
http_headers = kwargs.pop('headers', {})
|
||||
http_headers['Authorization'] = 'Bearer %s' % backend.api_key
|
||||
http_headers['Content-Type'] = 'application/json'
|
||||
http_headers['Accept'] = 'application/json'
|
||||
super().__init__(message, defaults, backend, headers=http_headers, *args, **kwargs)
|
||||
http_headers = kwargs.pop("headers", {})
|
||||
http_headers["Authorization"] = "Bearer %s" % 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 "mail/send"
|
||||
@@ -105,11 +125,17 @@ class SendGridPayload(RequestsPayload):
|
||||
return self.serialize_json(self.data)
|
||||
|
||||
def set_anymail_id(self):
|
||||
"""Ensure each personalization has a known anymail_id for later event tracking"""
|
||||
"""
|
||||
Ensure each personalization has a known anymail_id for later event tracking
|
||||
"""
|
||||
for personalization in self.data["personalizations"]:
|
||||
message_id = str(uuid.uuid4())
|
||||
personalization.setdefault("custom_args", {})["anymail_id"] = message_id
|
||||
for recipient in personalization["to"] + personalization.get("cc", []) + personalization.get("bcc", []):
|
||||
for recipient in (
|
||||
personalization["to"]
|
||||
+ personalization.get("cc", [])
|
||||
+ personalization.get("bcc", [])
|
||||
):
|
||||
self.message_ids[recipient["email"]] = message_id
|
||||
|
||||
def expand_personalizations_for_batch(self):
|
||||
@@ -139,8 +165,10 @@ class SendGridPayload(RequestsPayload):
|
||||
self.convert_dynamic_template_data_to_legacy_substitutions()
|
||||
|
||||
def convert_dynamic_template_data_to_legacy_substitutions(self):
|
||||
"""Change personalizations[...]['dynamic_template_data'] to ...['substitutions]"""
|
||||
merge_field_format = self.merge_field_format or '{}'
|
||||
"""
|
||||
Change personalizations[...]['dynamic_template_data'] to ...['substitutions]
|
||||
"""
|
||||
merge_field_format = self.merge_field_format or "{}"
|
||||
|
||||
all_merge_fields = set()
|
||||
for personalization in self.data["personalizations"]:
|
||||
@@ -149,10 +177,12 @@ class SendGridPayload(RequestsPayload):
|
||||
except KeyError:
|
||||
pass # no substitutions for this recipient
|
||||
else:
|
||||
# Convert dynamic_template_data keys for substitutions, using merge_field_format
|
||||
# Convert dynamic_template_data keys for substitutions,
|
||||
# using merge_field_format
|
||||
personalization["substitutions"] = {
|
||||
merge_field_format.format(field): data
|
||||
for field, data in dynamic_template_data.items()}
|
||||
for field, data in dynamic_template_data.items()
|
||||
}
|
||||
all_merge_fields.update(dynamic_template_data.keys())
|
||||
|
||||
if self.merge_field_format is None:
|
||||
@@ -160,15 +190,21 @@ class SendGridPayload(RequestsPayload):
|
||||
warnings.warn(
|
||||
"Your SendGrid merge fields don't seem to have delimiters, "
|
||||
"which can cause unexpected results with Anymail's merge_data. "
|
||||
"Search SENDGRID_MERGE_FIELD_FORMAT in the Anymail docs for more info.",
|
||||
AnymailWarning)
|
||||
"Search SENDGRID_MERGE_FIELD_FORMAT in the Anymail docs "
|
||||
"for more info.",
|
||||
AnymailWarning,
|
||||
)
|
||||
|
||||
if self.merge_global_data and all(field.isalnum() for field in self.merge_global_data.keys()):
|
||||
if self.merge_global_data and all(
|
||||
field.isalnum() for field in self.merge_global_data.keys()
|
||||
):
|
||||
warnings.warn(
|
||||
"Your SendGrid global merge fields don't seem to have delimiters, "
|
||||
"which can cause unexpected results with Anymail's merge_data. "
|
||||
"Search SENDGRID_MERGE_FIELD_FORMAT in the Anymail docs for more info.",
|
||||
AnymailWarning)
|
||||
"Search SENDGRID_MERGE_FIELD_FORMAT in the Anymail docs "
|
||||
"for more info.",
|
||||
AnymailWarning,
|
||||
)
|
||||
|
||||
def build_merge_metadata(self):
|
||||
if self.merge_metadata:
|
||||
@@ -208,8 +244,9 @@ class SendGridPayload(RequestsPayload):
|
||||
workaround_name_quote_bug = self.workaround_name_quote_bug
|
||||
# Normally, exactly one "personalizations" entry for all recipients
|
||||
# (Exception: with merge_data; will be burst apart later.)
|
||||
self.data["personalizations"][0][recipient_type] = \
|
||||
[self.email_object(email, workaround_name_quote_bug) for email in emails]
|
||||
self.data["personalizations"][0][recipient_type] = [
|
||||
self.email_object(email, workaround_name_quote_bug) for email in emails
|
||||
]
|
||||
self.all_recipients += emails # used for backend.parse_recipient_status
|
||||
|
||||
def set_subject(self, subject):
|
||||
@@ -226,10 +263,12 @@ class SendGridPayload(RequestsPayload):
|
||||
def set_extra_headers(self, headers):
|
||||
# SendGrid requires header values to be strings -- not integers.
|
||||
# We'll stringify ints and floats; anything else is the caller's responsibility.
|
||||
self.data["headers"].update({
|
||||
k: str(v) if isinstance(v, BASIC_NUMERIC_TYPES) else v
|
||||
for k, v in headers.items()
|
||||
})
|
||||
self.data["headers"].update(
|
||||
{
|
||||
k: str(v) if isinstance(v, BASIC_NUMERIC_TYPES) else v
|
||||
for k, v in headers.items()
|
||||
}
|
||||
)
|
||||
|
||||
def set_text_body(self, body):
|
||||
# Empty strings (the EmailMessage default) can cause unexpected SendGrid
|
||||
@@ -238,33 +277,41 @@ class SendGridPayload(RequestsPayload):
|
||||
# Treat an empty string as a request to omit the body
|
||||
# (which means use the template content if present.)
|
||||
if body != "":
|
||||
self.data.setdefault("content", []).append({
|
||||
"type": "text/plain",
|
||||
"value": body,
|
||||
})
|
||||
self.data.setdefault("content", []).append(
|
||||
{
|
||||
"type": "text/plain",
|
||||
"value": body,
|
||||
}
|
||||
)
|
||||
|
||||
def set_html_body(self, body):
|
||||
# SendGrid's API permits multiple html bodies
|
||||
# "If you choose to include the text/plain or text/html mime types, they must be
|
||||
# the first indices of the content array in the order text/plain, text/html."
|
||||
if body != "": # see note in set_text_body about template rendering
|
||||
self.data.setdefault("content", []).append({
|
||||
"type": "text/html",
|
||||
"value": body,
|
||||
})
|
||||
# Body must not be empty (see note in set_text_body about template rendering).
|
||||
if body != "":
|
||||
self.data.setdefault("content", []).append(
|
||||
{
|
||||
"type": "text/html",
|
||||
"value": body,
|
||||
}
|
||||
)
|
||||
|
||||
def add_alternative(self, content, mimetype):
|
||||
# SendGrid is one of the few ESPs that supports arbitrary alternative parts in their API
|
||||
self.data.setdefault("content", []).append({
|
||||
"type": mimetype,
|
||||
"value": content,
|
||||
})
|
||||
# SendGrid is one of the few ESPs that supports arbitrary alternative parts
|
||||
self.data.setdefault("content", []).append(
|
||||
{
|
||||
"type": mimetype,
|
||||
"value": content,
|
||||
}
|
||||
)
|
||||
|
||||
def add_attachment(self, attachment):
|
||||
att = {
|
||||
"content": attachment.b64content,
|
||||
"type": attachment.mimetype,
|
||||
"filename": attachment.name or '', # required -- submit empty string if unknown
|
||||
# (filename is required -- submit empty string if unknown)
|
||||
"filename": attachment.name or "",
|
||||
}
|
||||
if attachment.inline:
|
||||
att["disposition"] = "inline"
|
||||
@@ -276,9 +323,10 @@ class SendGridPayload(RequestsPayload):
|
||||
|
||||
def transform_metadata(self, metadata):
|
||||
# SendGrid requires custom_args values to be strings -- not integers.
|
||||
# (And issues the cryptic error {"field": null, "message": "Bad Request", "help": null}
|
||||
# (And issues the cryptic error
|
||||
# {"field": null, "message": "Bad Request", "help": null}
|
||||
# if they're not.)
|
||||
# We'll stringify ints and floats; anything else is the caller's responsibility.
|
||||
# Stringify ints and floats; anything else is the caller's responsibility.
|
||||
return {
|
||||
k: str(v) if isinstance(v, BASIC_NUMERIC_TYPES) else v
|
||||
for k, v in metadata.items()
|
||||
@@ -330,8 +378,12 @@ class SendGridPayload(RequestsPayload):
|
||||
self.merge_metadata = merge_metadata
|
||||
|
||||
def set_esp_extra(self, extra):
|
||||
self.merge_field_format = extra.pop("merge_field_format", self.merge_field_format)
|
||||
self.use_dynamic_template = extra.pop("use_dynamic_template", self.use_dynamic_template)
|
||||
self.merge_field_format = extra.pop(
|
||||
"merge_field_format", self.merge_field_format
|
||||
)
|
||||
self.use_dynamic_template = extra.pop(
|
||||
"use_dynamic_template", self.use_dynamic_template
|
||||
)
|
||||
if isinstance(extra.get("personalizations", None), Mapping):
|
||||
# merge personalizations *dict* into other message personalizations
|
||||
assert len(self.data["personalizations"]) == 1
|
||||
@@ -339,6 +391,7 @@ class SendGridPayload(RequestsPayload):
|
||||
if "x-smtpapi" in extra:
|
||||
raise AnymailConfigurationError(
|
||||
"You are attempting to use SendGrid v2 API-style x-smtpapi params "
|
||||
"with the SendGrid v3 API. Please update your `esp_extra` to the new API."
|
||||
"with the SendGrid v3 API. Please update your `esp_extra` "
|
||||
"to the new API."
|
||||
)
|
||||
update_deep(self.data, extra)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
from requests.structures import CaseInsensitiveDict
|
||||
|
||||
from .base_requests import AnymailRequestsBackend, RequestsPayload
|
||||
from ..exceptions import AnymailRequestsAPIError
|
||||
from ..message import AnymailRecipientStatus
|
||||
from ..utils import get_anymail_setting, BASIC_NUMERIC_TYPES
|
||||
from ..utils import BASIC_NUMERIC_TYPES, get_anymail_setting
|
||||
from .base_requests import AnymailRequestsBackend, RequestsPayload
|
||||
|
||||
|
||||
class EmailBackend(AnymailRequestsBackend):
|
||||
@@ -17,13 +17,13 @@ class EmailBackend(AnymailRequestsBackend):
|
||||
"""Init options from Django settings"""
|
||||
esp_name = self.esp_name
|
||||
self.api_key = get_anymail_setting(
|
||||
'api_key',
|
||||
"api_key",
|
||||
esp_name=esp_name,
|
||||
kwargs=kwargs,
|
||||
allow_bare=True,
|
||||
)
|
||||
api_url = get_anymail_setting(
|
||||
'api_url',
|
||||
"api_url",
|
||||
esp_name=esp_name,
|
||||
kwargs=kwargs,
|
||||
default="https://api.sendinblue.com/v3/",
|
||||
@@ -40,42 +40,45 @@ class EmailBackend(AnymailRequestsBackend):
|
||||
# https://developers.sendinblue.com/docs/responses
|
||||
message_id = None
|
||||
|
||||
if response.content != b'':
|
||||
if response.content != b"":
|
||||
parsed_response = self.deserialize_json_response(response, payload, message)
|
||||
try:
|
||||
message_id = parsed_response['messageId']
|
||||
message_id = parsed_response["messageId"]
|
||||
except (KeyError, TypeError) as err:
|
||||
raise AnymailRequestsAPIError("Invalid SendinBlue API response format",
|
||||
email_message=message, payload=payload, response=response,
|
||||
backend=self) from err
|
||||
raise AnymailRequestsAPIError(
|
||||
"Invalid SendinBlue API response format",
|
||||
email_message=message,
|
||||
payload=payload,
|
||||
response=response,
|
||||
backend=self,
|
||||
) from err
|
||||
|
||||
status = AnymailRecipientStatus(message_id=message_id, status="queued")
|
||||
return {recipient.addr_spec: status for recipient in payload.all_recipients}
|
||||
|
||||
|
||||
class SendinBluePayload(RequestsPayload):
|
||||
|
||||
def __init__(self, message, defaults, backend, *args, **kwargs):
|
||||
self.all_recipients = [] # used for backend.parse_recipient_status
|
||||
|
||||
http_headers = kwargs.pop('headers', {})
|
||||
http_headers['api-key'] = backend.api_key
|
||||
http_headers['Content-Type'] = 'application/json'
|
||||
http_headers = kwargs.pop("headers", {})
|
||||
http_headers["api-key"] = backend.api_key
|
||||
http_headers["Content-Type"] = "application/json"
|
||||
|
||||
super().__init__(message, defaults, backend, headers=http_headers, *args, **kwargs)
|
||||
super().__init__(
|
||||
message, defaults, backend, headers=http_headers, *args, **kwargs
|
||||
)
|
||||
|
||||
def get_api_endpoint(self):
|
||||
return "smtp/email"
|
||||
|
||||
def init_payload(self):
|
||||
self.data = { # becomes json
|
||||
'headers': CaseInsensitiveDict()
|
||||
}
|
||||
self.data = {"headers": CaseInsensitiveDict()} # becomes json
|
||||
|
||||
def serialize_data(self):
|
||||
"""Performs any necessary serialization on self.data, and returns the result."""
|
||||
if not self.data['headers']:
|
||||
del self.data['headers'] # don't send empty headers
|
||||
if not self.data["headers"]:
|
||||
del self.data["headers"] # don't send empty headers
|
||||
return self.serialize_json(self.data)
|
||||
|
||||
#
|
||||
@@ -86,9 +89,9 @@ class SendinBluePayload(RequestsPayload):
|
||||
def email_object(email):
|
||||
"""Converts EmailAddress to SendinBlue API array"""
|
||||
email_object = dict()
|
||||
email_object['email'] = email.addr_spec
|
||||
email_object["email"] = email.addr_spec
|
||||
if email.display_name:
|
||||
email_object['name'] = email.display_name
|
||||
email_object["name"] = email.display_name
|
||||
return email_object
|
||||
|
||||
def set_from_email(self, email):
|
||||
@@ -109,39 +112,41 @@ class SendinBluePayload(RequestsPayload):
|
||||
if len(emails) > 1:
|
||||
self.unsupported_feature("multiple reply_to addresses")
|
||||
if len(emails) > 0:
|
||||
self.data['replyTo'] = self.email_object(emails[0])
|
||||
self.data["replyTo"] = self.email_object(emails[0])
|
||||
|
||||
def set_extra_headers(self, headers):
|
||||
# SendinBlue requires header values to be strings -- not integers -- as of 11/2022.
|
||||
# We'll stringify ints and floats; anything else is the caller's responsibility.
|
||||
self.data["headers"].update({
|
||||
k: str(v) if isinstance(v, BASIC_NUMERIC_TYPES) else v
|
||||
for k, v in headers.items()
|
||||
})
|
||||
# SendinBlue requires header values to be strings (not integers) as of 11/2022.
|
||||
# Stringify ints and floats; anything else is the caller's responsibility.
|
||||
self.data["headers"].update(
|
||||
{
|
||||
k: str(v) if isinstance(v, BASIC_NUMERIC_TYPES) else v
|
||||
for k, v in headers.items()
|
||||
}
|
||||
)
|
||||
|
||||
def set_tags(self, tags):
|
||||
if len(tags) > 0:
|
||||
self.data['tags'] = tags
|
||||
self.data["tags"] = tags
|
||||
|
||||
def set_template_id(self, template_id):
|
||||
self.data['templateId'] = template_id
|
||||
self.data["templateId"] = template_id
|
||||
|
||||
def set_text_body(self, body):
|
||||
if body:
|
||||
self.data['textContent'] = body
|
||||
self.data["textContent"] = body
|
||||
|
||||
def set_html_body(self, body):
|
||||
if body:
|
||||
if "htmlContent" in self.data:
|
||||
self.unsupported_feature("multiple html parts")
|
||||
|
||||
self.data['htmlContent'] = body
|
||||
self.data["htmlContent"] = body
|
||||
|
||||
def add_attachment(self, attachment):
|
||||
"""Converts attachments to SendinBlue API {name, base64} array"""
|
||||
att = {
|
||||
'name': attachment.name or '',
|
||||
'content': attachment.b64content,
|
||||
"name": attachment.name or "",
|
||||
"content": attachment.b64content,
|
||||
}
|
||||
|
||||
if attachment.inline:
|
||||
@@ -157,15 +162,15 @@ class SendinBluePayload(RequestsPayload):
|
||||
self.unsupported_feature("merge_data")
|
||||
|
||||
def set_merge_global_data(self, merge_global_data):
|
||||
self.data['params'] = merge_global_data
|
||||
self.data["params"] = merge_global_data
|
||||
|
||||
def set_metadata(self, metadata):
|
||||
# SendinBlue expects a single string payload
|
||||
self.data['headers']["X-Mailin-custom"] = self.serialize_json(metadata)
|
||||
self.data["headers"]["X-Mailin-custom"] = self.serialize_json(metadata)
|
||||
|
||||
def set_send_at(self, send_at):
|
||||
try:
|
||||
start_time_iso = send_at.isoformat(timespec="milliseconds")
|
||||
except (AttributeError, TypeError):
|
||||
start_time_iso = send_at # assume user already formatted
|
||||
self.data['scheduledAt'] = start_time_iso
|
||||
self.data["scheduledAt"] = start_time_iso
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from .base_requests import AnymailRequestsBackend, RequestsPayload
|
||||
from ..exceptions import AnymailRequestsAPIError
|
||||
from ..message import AnymailRecipientStatus
|
||||
from ..utils import get_anymail_setting, update_deep
|
||||
from .base_requests import AnymailRequestsBackend, RequestsPayload
|
||||
|
||||
|
||||
class EmailBackend(AnymailRequestsBackend):
|
||||
@@ -13,12 +13,18 @@ class EmailBackend(AnymailRequestsBackend):
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
"""Init options from Django settings"""
|
||||
self.api_key = get_anymail_setting('api_key', esp_name=self.esp_name,
|
||||
kwargs=kwargs, allow_bare=True)
|
||||
self.subaccount = get_anymail_setting('subaccount', esp_name=self.esp_name,
|
||||
kwargs=kwargs, default=None)
|
||||
api_url = get_anymail_setting('api_url', esp_name=self.esp_name, kwargs=kwargs,
|
||||
default="https://api.sparkpost.com/api/v1/")
|
||||
self.api_key = get_anymail_setting(
|
||||
"api_key", esp_name=self.esp_name, kwargs=kwargs, allow_bare=True
|
||||
)
|
||||
self.subaccount = get_anymail_setting(
|
||||
"subaccount", esp_name=self.esp_name, kwargs=kwargs, default=None
|
||||
)
|
||||
api_url = get_anymail_setting(
|
||||
"api_url",
|
||||
esp_name=self.esp_name,
|
||||
kwargs=kwargs,
|
||||
default="https://api.sparkpost.com/api/v1/",
|
||||
)
|
||||
if not api_url.endswith("/"):
|
||||
api_url += "/"
|
||||
super().__init__(api_url, **kwargs)
|
||||
@@ -34,9 +40,13 @@ class EmailBackend(AnymailRequestsBackend):
|
||||
rejected = results["total_rejected_recipients"]
|
||||
transmission_id = results["id"]
|
||||
except (KeyError, TypeError) as err:
|
||||
raise AnymailRequestsAPIError("Invalid SparkPost API response format",
|
||||
email_message=message, payload=payload,
|
||||
response=response, backend=self) from err
|
||||
raise AnymailRequestsAPIError(
|
||||
"Invalid SparkPost API response format",
|
||||
email_message=message,
|
||||
payload=payload,
|
||||
response=response,
|
||||
backend=self,
|
||||
) from err
|
||||
|
||||
# SparkPost doesn't (yet*) tell us *which* recipients were accepted or rejected.
|
||||
# (* looks like undocumented 'rcpt_to_errors' might provide this info.)
|
||||
@@ -44,26 +54,32 @@ class EmailBackend(AnymailRequestsBackend):
|
||||
# else just report 'unknown' for all recipients.
|
||||
recipient_count = len(payload.recipients)
|
||||
if accepted == recipient_count and rejected == 0:
|
||||
status = 'queued'
|
||||
status = "queued"
|
||||
elif rejected == recipient_count and accepted == 0:
|
||||
status = 'rejected'
|
||||
status = "rejected"
|
||||
else: # mixed results, or wrong total
|
||||
status = 'unknown'
|
||||
recipient_status = AnymailRecipientStatus(message_id=transmission_id, status=status)
|
||||
return {recipient.addr_spec: recipient_status for recipient in payload.recipients}
|
||||
status = "unknown"
|
||||
recipient_status = AnymailRecipientStatus(
|
||||
message_id=transmission_id, status=status
|
||||
)
|
||||
return {
|
||||
recipient.addr_spec: recipient_status for recipient in payload.recipients
|
||||
}
|
||||
|
||||
|
||||
class SparkPostPayload(RequestsPayload):
|
||||
def __init__(self, message, defaults, backend, *args, **kwargs):
|
||||
http_headers = {
|
||||
'Authorization': backend.api_key,
|
||||
'Content-Type': 'application/json',
|
||||
"Authorization": backend.api_key,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
if backend.subaccount is not None:
|
||||
http_headers['X-MSYS-SUBACCOUNT'] = backend.subaccount
|
||||
http_headers["X-MSYS-SUBACCOUNT"] = backend.subaccount
|
||||
self.recipients = [] # all recipients, for backend parse_recipient_status
|
||||
self.cc_and_bcc = [] # for _finalize_recipients
|
||||
super().__init__(message, defaults, backend, headers=http_headers, *args, **kwargs)
|
||||
super().__init__(
|
||||
message, defaults, backend, headers=http_headers, *args, **kwargs
|
||||
)
|
||||
|
||||
def get_api_endpoint(self):
|
||||
return "transmissions/"
|
||||
@@ -74,15 +90,15 @@ class SparkPostPayload(RequestsPayload):
|
||||
|
||||
def _finalize_recipients(self):
|
||||
# https://www.sparkpost.com/docs/faq/cc-bcc-with-rest-api/
|
||||
# self.data["recipients"] is currently a list of all to-recipients. We need to add
|
||||
# all cc and bcc recipients. Exactly how depends on whether this is a batch send.
|
||||
# self.data["recipients"] is currently a list of all to-recipients. Must add all
|
||||
# cc and bcc recipients. Exactly how depends on whether this is a batch send.
|
||||
if self.is_batch():
|
||||
# For batch sends, must duplicate the cc/bcc for *every* to-recipient
|
||||
# (using each to-recipient's metadata and substitutions).
|
||||
extra_recipients = []
|
||||
for to_recipient in self.data["recipients"]:
|
||||
for email in self.cc_and_bcc:
|
||||
extra = to_recipient.copy() # capture "metadata" and "substitutions", if any
|
||||
extra = to_recipient.copy() # gets "metadata" and "substitutions"
|
||||
extra["address"] = {
|
||||
"email": email.addr_spec,
|
||||
"header_to": to_recipient["address"]["header_to"],
|
||||
@@ -94,17 +110,21 @@ class SparkPostPayload(RequestsPayload):
|
||||
# "To" header to show all the "To" recipients...
|
||||
full_to_header = ", ".join(
|
||||
to_recipient["address"]["header_to"]
|
||||
for to_recipient in self.data["recipients"])
|
||||
for to_recipient in self.data["recipients"]
|
||||
)
|
||||
for recipient in self.data["recipients"]:
|
||||
recipient["address"]["header_to"] = full_to_header
|
||||
# ... and then simply add the cc/bcc to the end of the list.
|
||||
# (There is no per-recipient data, or it would be a batch send.)
|
||||
self.data["recipients"].extend(
|
||||
{"address": {
|
||||
"email": email.addr_spec,
|
||||
"header_to": full_to_header,
|
||||
}}
|
||||
for email in self.cc_and_bcc)
|
||||
{
|
||||
"address": {
|
||||
"email": email.addr_spec,
|
||||
"header_to": full_to_header,
|
||||
}
|
||||
}
|
||||
for email in self.cc_and_bcc
|
||||
)
|
||||
|
||||
#
|
||||
# Payload construction
|
||||
@@ -127,11 +147,14 @@ class SparkPostPayload(RequestsPayload):
|
||||
# (We use "header_to" rather than "name" to simplify some logic
|
||||
# in _finalize_recipients; the results end up the same.)
|
||||
self.data["recipients"].extend(
|
||||
{"address": {
|
||||
"email": email.addr_spec,
|
||||
"header_to": email.address,
|
||||
}}
|
||||
for email in emails)
|
||||
{
|
||||
"address": {
|
||||
"email": email.addr_spec,
|
||||
"header_to": email.address,
|
||||
}
|
||||
}
|
||||
for email in emails
|
||||
)
|
||||
self.recipients += emails
|
||||
|
||||
def set_cc(self, emails):
|
||||
@@ -155,7 +178,9 @@ class SparkPostPayload(RequestsPayload):
|
||||
|
||||
def set_reply_to(self, emails):
|
||||
if emails:
|
||||
self.data["content"]["reply_to"] = ", ".join(email.address for email in emails)
|
||||
self.data["content"]["reply_to"] = ", ".join(
|
||||
email.address for email in emails
|
||||
)
|
||||
|
||||
def set_extra_headers(self, headers):
|
||||
if headers:
|
||||
@@ -166,7 +191,8 @@ class SparkPostPayload(RequestsPayload):
|
||||
|
||||
def set_html_body(self, body):
|
||||
if "html" in self.data["content"]:
|
||||
# second html body could show up through multiple alternatives, or html body + alternative
|
||||
# second html body could show up through multiple alternatives,
|
||||
# or html body + alternative
|
||||
self.unsupported_feature("multiple html parts")
|
||||
self.data["content"]["html"] = body
|
||||
|
||||
@@ -179,19 +205,27 @@ class SparkPostPayload(RequestsPayload):
|
||||
super().add_alternative(content, mimetype)
|
||||
|
||||
def set_attachments(self, atts):
|
||||
attachments = [{
|
||||
"name": att.name or "",
|
||||
"type": att.content_type,
|
||||
"data": att.b64content,
|
||||
} for att in atts if not att.inline]
|
||||
attachments = [
|
||||
{
|
||||
"name": att.name or "",
|
||||
"type": att.content_type,
|
||||
"data": att.b64content,
|
||||
}
|
||||
for att in atts
|
||||
if not att.inline
|
||||
]
|
||||
if attachments:
|
||||
self.data["content"]["attachments"] = attachments
|
||||
|
||||
inline_images = [{
|
||||
"name": att.cid,
|
||||
"type": att.mimetype,
|
||||
"data": att.b64content,
|
||||
} for att in atts if att.inline]
|
||||
inline_images = [
|
||||
{
|
||||
"name": att.cid,
|
||||
"type": att.mimetype,
|
||||
"data": att.b64content,
|
||||
}
|
||||
for att in atts
|
||||
if att.inline
|
||||
]
|
||||
if inline_images:
|
||||
self.data["content"]["inline_images"] = inline_images
|
||||
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
from django.core import mail
|
||||
|
||||
from .base import AnymailBaseBackend, BasePayload
|
||||
from ..exceptions import AnymailAPIError
|
||||
from ..message import AnymailRecipientStatus
|
||||
from .base import AnymailBaseBackend, BasePayload
|
||||
|
||||
|
||||
class EmailBackend(AnymailBaseBackend):
|
||||
"""
|
||||
Anymail backend that simulates sending messages, useful for testing.
|
||||
|
||||
Sent messages are collected in django.core.mail.outbox (as with Django's locmem backend).
|
||||
Sent messages are collected in django.core.mail.outbox
|
||||
(as with Django's locmem backend).
|
||||
|
||||
In addition:
|
||||
* Anymail send params parsed from the message will be attached to the outbox message
|
||||
as a dict in the attr `anymail_test_params`
|
||||
* If the caller supplies an `anymail_test_response` attr on the message, that will be
|
||||
used instead of the default "sent" response. It can be either an AnymailRecipientStatus
|
||||
or an instance of AnymailAPIError (or a subclass) to raise an exception.
|
||||
* Anymail send params parsed from the message will be attached
|
||||
to the outbox message as a dict in the attr `anymail_test_params`
|
||||
* If the caller supplies an `anymail_test_response` attr on the message,
|
||||
that will be used instead of the default "sent" response. It can be either
|
||||
an AnymailRecipientStatus or an instance of AnymailAPIError (or a subclass)
|
||||
to raise an exception.
|
||||
"""
|
||||
|
||||
esp_name = "Test"
|
||||
@@ -24,9 +26,9 @@ class EmailBackend(AnymailBaseBackend):
|
||||
def __init__(self, *args, **kwargs):
|
||||
# Allow replacing the payload, for testing.
|
||||
# (Real backends would generally not implement this option.)
|
||||
self._payload_class = kwargs.pop('payload_class', TestPayload)
|
||||
self._payload_class = kwargs.pop("payload_class", TestPayload)
|
||||
super().__init__(*args, **kwargs)
|
||||
if not hasattr(mail, 'outbox'):
|
||||
if not hasattr(mail, "outbox"):
|
||||
mail.outbox = [] # see django.core.mail.backends.locmem
|
||||
|
||||
def get_esp_message_id(self, message):
|
||||
@@ -49,19 +51,20 @@ class EmailBackend(AnymailBaseBackend):
|
||||
except AttributeError:
|
||||
# Default is to return 'sent' for each recipient
|
||||
status = AnymailRecipientStatus(
|
||||
message_id=self.get_esp_message_id(message),
|
||||
status='sent'
|
||||
message_id=self.get_esp_message_id(message), status="sent"
|
||||
)
|
||||
response = {
|
||||
'recipient_status': {email: status for email in payload.recipient_emails}
|
||||
"recipient_status": {
|
||||
email: status for email in payload.recipient_emails
|
||||
}
|
||||
}
|
||||
return response
|
||||
|
||||
def parse_recipient_status(self, response, payload, message):
|
||||
try:
|
||||
return response['recipient_status']
|
||||
return response["recipient_status"]
|
||||
except KeyError as err:
|
||||
raise AnymailAPIError('Unparsable test response') from err
|
||||
raise AnymailAPIError("Unparsable test response") from err
|
||||
|
||||
|
||||
class TestPayload(BasePayload):
|
||||
@@ -76,79 +79,79 @@ class TestPayload(BasePayload):
|
||||
def get_params(self):
|
||||
# Test backend callers can check message.anymail_test_params['is_batch_send']
|
||||
# to verify whether Anymail thought the message should use batch send logic.
|
||||
self.params['is_batch_send'] = self.is_batch()
|
||||
self.params["is_batch_send"] = self.is_batch()
|
||||
return self.params
|
||||
|
||||
def set_from_email(self, email):
|
||||
self.params['from'] = email
|
||||
self.params["from"] = email
|
||||
|
||||
def set_envelope_sender(self, email):
|
||||
self.params['envelope_sender'] = email.addr_spec
|
||||
self.params["envelope_sender"] = email.addr_spec
|
||||
|
||||
def set_to(self, emails):
|
||||
self.params['to'] = emails
|
||||
self.params["to"] = emails
|
||||
self.recipient_emails += [email.addr_spec for email in emails]
|
||||
|
||||
def set_cc(self, emails):
|
||||
self.params['cc'] = emails
|
||||
self.params["cc"] = emails
|
||||
self.recipient_emails += [email.addr_spec for email in emails]
|
||||
|
||||
def set_bcc(self, emails):
|
||||
self.params['bcc'] = emails
|
||||
self.params["bcc"] = emails
|
||||
self.recipient_emails += [email.addr_spec for email in emails]
|
||||
|
||||
def set_subject(self, subject):
|
||||
self.params['subject'] = subject
|
||||
self.params["subject"] = subject
|
||||
|
||||
def set_reply_to(self, emails):
|
||||
self.params['reply_to'] = emails
|
||||
self.params["reply_to"] = emails
|
||||
|
||||
def set_extra_headers(self, headers):
|
||||
self.params['extra_headers'] = headers
|
||||
self.params["extra_headers"] = headers
|
||||
|
||||
def set_text_body(self, body):
|
||||
self.params['text_body'] = body
|
||||
self.params["text_body"] = body
|
||||
|
||||
def set_html_body(self, body):
|
||||
self.params['html_body'] = body
|
||||
self.params["html_body"] = body
|
||||
|
||||
def add_alternative(self, content, mimetype):
|
||||
# For testing purposes, we allow all "text/*" alternatives,
|
||||
# but not any other mimetypes.
|
||||
if mimetype.startswith('text'):
|
||||
self.params.setdefault('alternatives', []).append((content, mimetype))
|
||||
if mimetype.startswith("text"):
|
||||
self.params.setdefault("alternatives", []).append((content, mimetype))
|
||||
else:
|
||||
self.unsupported_feature("alternative part with type '%s'" % mimetype)
|
||||
|
||||
def add_attachment(self, attachment):
|
||||
self.params.setdefault('attachments', []).append(attachment)
|
||||
self.params.setdefault("attachments", []).append(attachment)
|
||||
|
||||
def set_metadata(self, metadata):
|
||||
self.params['metadata'] = metadata
|
||||
self.params["metadata"] = metadata
|
||||
|
||||
def set_send_at(self, send_at):
|
||||
self.params['send_at'] = send_at
|
||||
self.params["send_at"] = send_at
|
||||
|
||||
def set_tags(self, tags):
|
||||
self.params['tags'] = tags
|
||||
self.params["tags"] = tags
|
||||
|
||||
def set_track_clicks(self, track_clicks):
|
||||
self.params['track_clicks'] = track_clicks
|
||||
self.params["track_clicks"] = track_clicks
|
||||
|
||||
def set_track_opens(self, track_opens):
|
||||
self.params['track_opens'] = track_opens
|
||||
self.params["track_opens"] = track_opens
|
||||
|
||||
def set_template_id(self, template_id):
|
||||
self.params['template_id'] = template_id
|
||||
self.params["template_id"] = template_id
|
||||
|
||||
def set_merge_data(self, merge_data):
|
||||
self.params['merge_data'] = merge_data
|
||||
self.params["merge_data"] = merge_data
|
||||
|
||||
def set_merge_metadata(self, merge_metadata):
|
||||
self.params['merge_metadata'] = merge_metadata
|
||||
self.params["merge_metadata"] = merge_metadata
|
||||
|
||||
def set_merge_global_data(self, merge_global_data):
|
||||
self.params['merge_global_data'] = merge_global_data
|
||||
self.params["merge_global_data"] = merge_global_data
|
||||
|
||||
def set_esp_extra(self, extra):
|
||||
# Merge extra into params
|
||||
|
||||
@@ -13,17 +13,23 @@ def check_deprecated_settings(app_configs, **kwargs):
|
||||
|
||||
# anymail.E001: rename WEBHOOK_AUTHORIZATION to WEBHOOK_SECRET
|
||||
if "WEBHOOK_AUTHORIZATION" in anymail_settings:
|
||||
errors.append(checks.Error(
|
||||
"The ANYMAIL setting 'WEBHOOK_AUTHORIZATION' has been renamed 'WEBHOOK_SECRET' to improve security.",
|
||||
hint="You must update your settings.py.",
|
||||
id="anymail.E001",
|
||||
))
|
||||
errors.append(
|
||||
checks.Error(
|
||||
"The ANYMAIL setting 'WEBHOOK_AUTHORIZATION' has been renamed"
|
||||
" 'WEBHOOK_SECRET' to improve security.",
|
||||
hint="You must update your settings.py.",
|
||||
id="anymail.E001",
|
||||
)
|
||||
)
|
||||
if hasattr(settings, "ANYMAIL_WEBHOOK_AUTHORIZATION"):
|
||||
errors.append(checks.Error(
|
||||
"The ANYMAIL_WEBHOOK_AUTHORIZATION setting has been renamed ANYMAIL_WEBHOOK_SECRET to improve security.",
|
||||
hint="You must update your settings.py.",
|
||||
id="anymail.E001",
|
||||
))
|
||||
errors.append(
|
||||
checks.Error(
|
||||
"The ANYMAIL_WEBHOOK_AUTHORIZATION setting has been renamed"
|
||||
" ANYMAIL_WEBHOOK_SECRET to improve security.",
|
||||
hint="You must update your settings.py.",
|
||||
id="anymail.E001",
|
||||
)
|
||||
)
|
||||
|
||||
return errors
|
||||
|
||||
@@ -33,11 +39,13 @@ def check_insecure_settings(app_configs, **kwargs):
|
||||
|
||||
# anymail.W002: DEBUG_API_REQUESTS can leak private information
|
||||
if get_anymail_setting("debug_api_requests", default=False) and not settings.DEBUG:
|
||||
errors.append(checks.Warning(
|
||||
"You have enabled the ANYMAIL setting DEBUG_API_REQUESTS, which can "
|
||||
"leak API keys and other sensitive data into logs or the console.",
|
||||
hint="You should not use DEBUG_API_REQUESTS in production deployment.",
|
||||
id="anymail.W002",
|
||||
))
|
||||
errors.append(
|
||||
checks.Warning(
|
||||
"You have enabled the ANYMAIL setting DEBUG_API_REQUESTS, which can "
|
||||
"leak API keys and other sensitive data into logs or the console.",
|
||||
hint="You should not use DEBUG_API_REQUESTS in production deployment.",
|
||||
id="anymail.W002",
|
||||
)
|
||||
)
|
||||
|
||||
return errors
|
||||
|
||||
@@ -22,17 +22,18 @@ class AnymailError(Exception):
|
||||
response: requests.Response from the send call
|
||||
esp_name: what to call the ESP (read from backend if provided)
|
||||
"""
|
||||
self.backend = kwargs.pop('backend', None)
|
||||
self.email_message = kwargs.pop('email_message', None)
|
||||
self.payload = kwargs.pop('payload', None)
|
||||
self.status_code = kwargs.pop('status_code', None)
|
||||
self.esp_name = kwargs.pop('esp_name',
|
||||
self.backend.esp_name if self.backend else None)
|
||||
self.backend = kwargs.pop("backend", None)
|
||||
self.email_message = kwargs.pop("email_message", None)
|
||||
self.payload = kwargs.pop("payload", None)
|
||||
self.status_code = kwargs.pop("status_code", None)
|
||||
self.esp_name = kwargs.pop(
|
||||
"esp_name", self.backend.esp_name if self.backend else None
|
||||
)
|
||||
if isinstance(self, HTTPError):
|
||||
# must leave response in kwargs for HTTPError
|
||||
self.response = kwargs.get('response', None)
|
||||
self.response = kwargs.get("response", None)
|
||||
else:
|
||||
self.response = kwargs.pop('response', None)
|
||||
self.response = kwargs.pop("response", None)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
@@ -48,15 +49,20 @@ class AnymailError(Exception):
|
||||
if self.status_code is None:
|
||||
return None
|
||||
|
||||
# Decode response.reason to text -- borrowed from requests.Response.raise_for_status:
|
||||
# Decode response.reason to text
|
||||
# (borrowed from requests.Response.raise_for_status)
|
||||
reason = self.response.reason
|
||||
if isinstance(reason, bytes):
|
||||
try:
|
||||
reason = reason.decode('utf-8')
|
||||
reason = reason.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
reason = reason.decode('iso-8859-1')
|
||||
reason = reason.decode("iso-8859-1")
|
||||
|
||||
description = "%s API response %d (%s)" % (self.esp_name or "ESP", self.status_code, reason)
|
||||
description = "%s API response %d (%s)" % (
|
||||
self.esp_name or "ESP",
|
||||
self.status_code,
|
||||
reason,
|
||||
)
|
||||
try:
|
||||
json_response = self.response.json()
|
||||
description += ":\n" + json.dumps(json_response, indent=2)
|
||||
@@ -71,7 +77,9 @@ class AnymailError(Exception):
|
||||
"""Describe the original exception"""
|
||||
if self.__cause__ is None:
|
||||
return None
|
||||
return ''.join(format_exception_only(type(self.__cause__), self.__cause__)).strip()
|
||||
return "".join(
|
||||
format_exception_only(type(self.__cause__), self.__cause__)
|
||||
).strip()
|
||||
|
||||
|
||||
class AnymailAPIError(AnymailError):
|
||||
@@ -122,15 +130,20 @@ class AnymailSerializationError(AnymailError, TypeError):
|
||||
in your merge_vars.
|
||||
|
||||
"""
|
||||
|
||||
# inherits from TypeError for compatibility with JSON serialization error
|
||||
|
||||
def __init__(self, message=None, orig_err=None, *args, **kwargs):
|
||||
if message is None:
|
||||
# self.esp_name not set until super init, so duplicate logic to get esp_name
|
||||
backend = kwargs.get('backend', None)
|
||||
esp_name = kwargs.get('esp_name', backend.esp_name if backend else "the ESP")
|
||||
message = "Don't know how to send this data to %s. " \
|
||||
"Try converting it to a string or number first." % esp_name
|
||||
backend = kwargs.get("backend", None)
|
||||
esp_name = kwargs.get(
|
||||
"esp_name", backend.esp_name if backend else "the ESP"
|
||||
)
|
||||
message = (
|
||||
"Don't know how to send this data to %s. "
|
||||
"Try converting it to a string or number first." % esp_name
|
||||
)
|
||||
if orig_err is not None:
|
||||
message += "\n%s" % str(orig_err)
|
||||
super().__init__(message, *args, **kwargs)
|
||||
@@ -150,6 +163,7 @@ class AnymailWebhookValidationFailure(AnymailError, SuspiciousOperation):
|
||||
|
||||
class AnymailConfigurationError(ImproperlyConfigured):
|
||||
"""Exception for Anymail configuration or installation issues"""
|
||||
|
||||
# This deliberately doesn't inherit from AnymailError,
|
||||
# because we don't want it to be swallowed by backend fail_silently
|
||||
|
||||
@@ -158,14 +172,17 @@ class AnymailImproperlyInstalled(AnymailConfigurationError, ImportError):
|
||||
"""Exception for Anymail missing package dependencies"""
|
||||
|
||||
def __init__(self, missing_package, backend="<backend>"):
|
||||
message = "The %s package is required to use this ESP, but isn't installed.\n" \
|
||||
"(Be sure to use `pip install django-anymail[%s]` " \
|
||||
"with your desired ESPs.)" % (missing_package, backend)
|
||||
message = (
|
||||
"The %s package is required to use this ESP, but isn't installed.\n"
|
||||
"(Be sure to use `pip install django-anymail[%s]` "
|
||||
"with your desired ESPs.)" % (missing_package, backend)
|
||||
)
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
# Warnings
|
||||
|
||||
|
||||
class AnymailWarning(Warning):
|
||||
"""Base warning for Anymail"""
|
||||
|
||||
@@ -180,8 +197,10 @@ class AnymailDeprecationWarning(AnymailWarning, DeprecationWarning):
|
||||
|
||||
# Helpers
|
||||
|
||||
|
||||
class _LazyError:
|
||||
"""An object that sits inert unless/until used, then raises an error"""
|
||||
|
||||
def __init__(self, error):
|
||||
self._error = error
|
||||
|
||||
|
||||
@@ -23,14 +23,15 @@ class AnymailInboundMessage(Message):
|
||||
# Message to send; Message is better designed for representing arbitrary messages:
|
||||
#
|
||||
# * Message is easily parsed from raw mime (which is an inbound format provided
|
||||
# by many ESPs), and can accurately represent any mime email that might be received
|
||||
# by many ESPs), and can accurately represent any mime email received
|
||||
# * Message can represent repeated header fields (e.g., "Received") which
|
||||
# are common in inbound messages
|
||||
# * Django's EmailMessage defaults a bunch of properties in ways that aren't helpful
|
||||
# (e.g., from_email from settings)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
# Note: this must accept zero arguments, for use with message_from_string (email.parser)
|
||||
# Note: this must accept zero arguments,
|
||||
# for use with message_from_string (email.parser)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Additional attrs provided by some ESPs:
|
||||
@@ -47,48 +48,49 @@ class AnymailInboundMessage(Message):
|
||||
|
||||
@property
|
||||
def from_email(self):
|
||||
"""EmailAddress """
|
||||
"""EmailAddress"""
|
||||
# equivalent to Python 3.2+ message['From'].addresses[0]
|
||||
from_email = self.get_address_header('From')
|
||||
from_email = self.get_address_header("From")
|
||||
if len(from_email) == 1:
|
||||
return from_email[0]
|
||||
elif len(from_email) == 0:
|
||||
return None
|
||||
else:
|
||||
return from_email # unusual, but technically-legal multiple-From; preserve list
|
||||
# unusual, but technically-legal multiple-From; preserve list:
|
||||
return from_email
|
||||
|
||||
@property
|
||||
def to(self):
|
||||
"""list of EmailAddress objects from To header"""
|
||||
# equivalent to Python 3.2+ message['To'].addresses
|
||||
return self.get_address_header('To')
|
||||
return self.get_address_header("To")
|
||||
|
||||
@property
|
||||
def cc(self):
|
||||
"""list of EmailAddress objects from Cc header"""
|
||||
# equivalent to Python 3.2+ message['Cc'].addresses
|
||||
return self.get_address_header('Cc')
|
||||
return self.get_address_header("Cc")
|
||||
|
||||
@property
|
||||
def subject(self):
|
||||
"""str value of Subject header, or None"""
|
||||
return self['Subject']
|
||||
return self["Subject"]
|
||||
|
||||
@property
|
||||
def date(self):
|
||||
"""datetime.datetime from Date header, or None if missing/invalid"""
|
||||
# equivalent to Python 3.2+ message['Date'].datetime
|
||||
return self.get_date_header('Date')
|
||||
return self.get_date_header("Date")
|
||||
|
||||
@property
|
||||
def text(self):
|
||||
"""Contents of the (first) text/plain body part, or None"""
|
||||
return self._get_body_content('text/plain')
|
||||
return self._get_body_content("text/plain")
|
||||
|
||||
@property
|
||||
def html(self):
|
||||
"""Contents of the (first) text/html body part, or None"""
|
||||
return self._get_body_content('text/html')
|
||||
return self._get_body_content("text/html")
|
||||
|
||||
@property
|
||||
def attachments(self):
|
||||
@@ -98,11 +100,17 @@ class AnymailInboundMessage(Message):
|
||||
@property
|
||||
def inline_attachments(self):
|
||||
"""dict of Content-ID: attachment (as MIMEPart objects)"""
|
||||
return {unquote(part['Content-ID']): part for part in self.walk()
|
||||
if part.is_inline_attachment() and part['Content-ID'] is not None}
|
||||
return {
|
||||
unquote(part["Content-ID"]): part
|
||||
for part in self.walk()
|
||||
if part.is_inline_attachment() and part["Content-ID"] is not None
|
||||
}
|
||||
|
||||
def get_address_header(self, header):
|
||||
"""Return the value of header parsed into a (possibly-empty) list of EmailAddress objects"""
|
||||
"""
|
||||
Return the value of header parsed into a (possibly-empty)
|
||||
list of EmailAddress objects
|
||||
"""
|
||||
values = self.get_all(header)
|
||||
if values is not None:
|
||||
values = parse_address_list(values)
|
||||
@@ -116,10 +124,11 @@ class AnymailInboundMessage(Message):
|
||||
return value
|
||||
|
||||
def _get_body_content(self, content_type):
|
||||
# This doesn't handle as many corner cases as Python 3.6 email.message.EmailMessage.get_body,
|
||||
# but should work correctly for nearly all real-world inbound messages.
|
||||
# We're guaranteed to have `is_attachment` available, because all AnymailInboundMessage parts
|
||||
# should themselves be AnymailInboundMessage.
|
||||
# This doesn't handle as many corner cases as Python 3.6
|
||||
# email.message.EmailMessage.get_body, but should work correctly
|
||||
# for nearly all real-world inbound messages.
|
||||
# We're guaranteed to have `is_attachment` available, because all
|
||||
# AnymailInboundMessage parts should themselves be AnymailInboundMessage.
|
||||
for part in self.walk():
|
||||
if part.get_content_type() == content_type and not part.is_attachment():
|
||||
return part.get_content_text()
|
||||
@@ -127,53 +136,59 @@ class AnymailInboundMessage(Message):
|
||||
|
||||
# Hoisted from email.message.MIMEPart
|
||||
def is_attachment(self):
|
||||
return self.get_content_disposition() == 'attachment'
|
||||
return self.get_content_disposition() == "attachment"
|
||||
|
||||
# New for Anymail
|
||||
def is_inline_attachment(self):
|
||||
return self.get_content_disposition() == 'inline'
|
||||
return self.get_content_disposition() == "inline"
|
||||
|
||||
def get_content_bytes(self):
|
||||
"""Return the raw payload bytes"""
|
||||
maintype = self.get_content_maintype()
|
||||
if maintype == 'message':
|
||||
# The attachment's payload is a single (parsed) email Message; flatten it to bytes.
|
||||
if maintype == "message":
|
||||
# The attachment's payload is a single (parsed) email Message;
|
||||
# flatten it to bytes.
|
||||
# (Note that self.is_multipart() misleadingly returns True in this case.)
|
||||
payload = self.get_payload()
|
||||
assert len(payload) == 1 # should be exactly one message
|
||||
return payload[0].as_bytes()
|
||||
elif maintype == 'multipart':
|
||||
elif maintype == "multipart":
|
||||
# The attachment itself is multipart; the payload is a list of parts,
|
||||
# and it's not clear which one is the "content".
|
||||
raise ValueError("get_content_bytes() is not valid on multipart messages "
|
||||
"(perhaps you want as_bytes()?)")
|
||||
raise ValueError(
|
||||
"get_content_bytes() is not valid on multipart messages "
|
||||
"(perhaps you want as_bytes()?)"
|
||||
)
|
||||
return self.get_payload(decode=True)
|
||||
|
||||
def get_content_text(self, charset=None, errors=None):
|
||||
"""Return the payload decoded to text"""
|
||||
maintype = self.get_content_maintype()
|
||||
if maintype == 'message':
|
||||
# The attachment's payload is a single (parsed) email Message; flatten it to text.
|
||||
if maintype == "message":
|
||||
# The attachment's payload is a single (parsed) email Message;
|
||||
# flatten it to text.
|
||||
# (Note that self.is_multipart() misleadingly returns True in this case.)
|
||||
payload = self.get_payload()
|
||||
assert len(payload) == 1 # should be exactly one message
|
||||
return payload[0].as_string()
|
||||
elif maintype == 'multipart':
|
||||
elif maintype == "multipart":
|
||||
# The attachment itself is multipart; the payload is a list of parts,
|
||||
# and it's not clear which one is the "content".
|
||||
raise ValueError("get_content_text() is not valid on multipart messages "
|
||||
"(perhaps you want as_string()?)")
|
||||
raise ValueError(
|
||||
"get_content_text() is not valid on multipart messages "
|
||||
"(perhaps you want as_string()?)"
|
||||
)
|
||||
else:
|
||||
payload = self.get_payload(decode=True)
|
||||
if payload is None:
|
||||
return payload
|
||||
charset = charset or self.get_content_charset('US-ASCII')
|
||||
errors = errors or 'replace'
|
||||
charset = charset or self.get_content_charset("US-ASCII")
|
||||
errors = errors or "replace"
|
||||
return payload.decode(charset, errors=errors)
|
||||
|
||||
def as_uploaded_file(self):
|
||||
"""Return the attachment converted to a Django UploadedFile"""
|
||||
if self['Content-Disposition'] is None:
|
||||
if self["Content-Disposition"] is None:
|
||||
return None # this part is not an attachment
|
||||
name = self.get_filename()
|
||||
content_type = self.get_content_type()
|
||||
@@ -192,7 +207,7 @@ class AnymailInboundMessage(Message):
|
||||
if isinstance(s, str):
|
||||
# Avoid Python 3.x issue https://bugs.python.org/issue18271
|
||||
# (See test_inbound: test_parse_raw_mime_8bit_utf8)
|
||||
return cls.parse_raw_mime_bytes(s.encode('utf-8'))
|
||||
return cls.parse_raw_mime_bytes(s.encode("utf-8"))
|
||||
return Parser(cls, policy=default_policy).parsestr(s)
|
||||
|
||||
@classmethod
|
||||
@@ -209,16 +224,28 @@ class AnymailInboundMessage(Message):
|
||||
return Parser(cls, policy=default_policy).parse(fp)
|
||||
|
||||
@classmethod
|
||||
def construct(cls, raw_headers=None, from_email=None, to=None, cc=None, subject=None, headers=None,
|
||||
text=None, text_charset='utf-8', html=None, html_charset='utf-8',
|
||||
attachments=None):
|
||||
def construct(
|
||||
cls,
|
||||
raw_headers=None,
|
||||
from_email=None,
|
||||
to=None,
|
||||
cc=None,
|
||||
subject=None,
|
||||
headers=None,
|
||||
text=None,
|
||||
text_charset="utf-8",
|
||||
html=None,
|
||||
html_charset="utf-8",
|
||||
attachments=None,
|
||||
):
|
||||
"""
|
||||
Returns a new AnymailInboundMessage constructed from params.
|
||||
|
||||
This is designed to handle the sorts of email fields typically present
|
||||
in ESP parsed inbound messages. (It's not a generalized MIME message constructor.)
|
||||
This is designed to handle the sorts of email fields typically present in ESP
|
||||
parsed inbound messages. (It's not a generalized MIME message constructor.)
|
||||
|
||||
:param raw_headers: {str|None} base (or complete) message headers as a single string
|
||||
:param raw_headers: {str|None}
|
||||
base (or complete) message headers as a single string
|
||||
:param from_email: {str|None} value for From header
|
||||
:param to: {str|None} value for To header
|
||||
:param cc: {str|None} value for Cc header
|
||||
@@ -232,23 +259,26 @@ class AnymailInboundMessage(Message):
|
||||
:return: {AnymailInboundMessage}
|
||||
"""
|
||||
if raw_headers is not None:
|
||||
msg = Parser(cls, policy=default_policy).parsestr(raw_headers, headersonly=True)
|
||||
msg.set_payload(None) # headersonly forces an empty string payload, which breaks things later
|
||||
msg = Parser(cls, policy=default_policy).parsestr(
|
||||
raw_headers, headersonly=True
|
||||
)
|
||||
# headersonly forces an empty string payload, which breaks things later:
|
||||
msg.set_payload(None)
|
||||
else:
|
||||
msg = cls()
|
||||
|
||||
if from_email is not None:
|
||||
del msg['From'] # override raw_headers value, if any
|
||||
msg['From'] = from_email
|
||||
del msg["From"] # override raw_headers value, if any
|
||||
msg["From"] = from_email
|
||||
if to is not None:
|
||||
del msg['To']
|
||||
msg['To'] = to
|
||||
del msg["To"]
|
||||
msg["To"] = to
|
||||
if cc is not None:
|
||||
del msg['Cc']
|
||||
msg['Cc'] = cc
|
||||
del msg["Cc"]
|
||||
msg["Cc"] = cc
|
||||
if subject is not None:
|
||||
del msg['Subject']
|
||||
msg['Subject'] = subject
|
||||
del msg["Subject"]
|
||||
msg["Subject"] = subject
|
||||
if headers is not None:
|
||||
try:
|
||||
header_items = headers.items() # mapping
|
||||
@@ -257,30 +287,31 @@ class AnymailInboundMessage(Message):
|
||||
for name, value in header_items:
|
||||
msg.add_header(name, value)
|
||||
|
||||
# For simplicity, we always build a MIME structure that could support plaintext/html
|
||||
# alternative bodies, inline attachments for the body(ies), and message attachments.
|
||||
# This may be overkill for simpler messages, but the structure is never incorrect.
|
||||
del msg['MIME-Version'] # override raw_headers values, if any
|
||||
del msg['Content-Type']
|
||||
msg['MIME-Version'] = '1.0'
|
||||
msg['Content-Type'] = 'multipart/mixed'
|
||||
# For simplicity, always build a MIME structure that could support
|
||||
# plaintext/html alternative bodies, inline attachments for the body(ies), and
|
||||
# message attachments. This may be overkill for simpler messages, but the
|
||||
# structure is never incorrect.
|
||||
del msg["MIME-Version"] # override raw_headers values, if any
|
||||
del msg["Content-Type"]
|
||||
msg["MIME-Version"] = "1.0"
|
||||
msg["Content-Type"] = "multipart/mixed"
|
||||
|
||||
related = cls() # container for alternative bodies and inline attachments
|
||||
related['Content-Type'] = 'multipart/related'
|
||||
related["Content-Type"] = "multipart/related"
|
||||
msg.attach(related)
|
||||
|
||||
alternatives = cls() # container for text and html bodies
|
||||
alternatives['Content-Type'] = 'multipart/alternative'
|
||||
alternatives["Content-Type"] = "multipart/alternative"
|
||||
related.attach(alternatives)
|
||||
|
||||
if text is not None:
|
||||
part = cls()
|
||||
part['Content-Type'] = 'text/plain'
|
||||
part["Content-Type"] = "text/plain"
|
||||
part.set_payload(text, charset=text_charset)
|
||||
alternatives.attach(part)
|
||||
if html is not None:
|
||||
part = cls()
|
||||
part['Content-Type'] = 'text/html'
|
||||
part["Content-Type"] = "text/html"
|
||||
part.set_payload(html, charset=html_charset)
|
||||
alternatives.attach(part)
|
||||
|
||||
@@ -299,31 +330,41 @@ class AnymailInboundMessage(Message):
|
||||
# some sort of lazy attachment where the content is only pulled in if/when
|
||||
# requested (and then use file.chunks() to minimize memory usage)
|
||||
return cls.construct_attachment(
|
||||
content_type=getattr(file, 'content_type', None),
|
||||
content_type=getattr(file, "content_type", None),
|
||||
content=file.read(),
|
||||
filename=getattr(file, 'name', None),
|
||||
filename=getattr(file, "name", None),
|
||||
content_id=content_id,
|
||||
charset=getattr(file, 'charset', None))
|
||||
charset=getattr(file, "charset", None),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def construct_attachment(cls, content_type, content,
|
||||
charset=None, filename=None, content_id=None, base64=False):
|
||||
def construct_attachment(
|
||||
cls,
|
||||
content_type,
|
||||
content,
|
||||
charset=None,
|
||||
filename=None,
|
||||
content_id=None,
|
||||
base64=False,
|
||||
):
|
||||
part = cls()
|
||||
part['Content-Type'] = content_type
|
||||
part['Content-Disposition'] = 'inline' if content_id is not None else 'attachment'
|
||||
part["Content-Type"] = content_type
|
||||
part["Content-Disposition"] = (
|
||||
"inline" if content_id is not None else "attachment"
|
||||
)
|
||||
|
||||
if filename is not None:
|
||||
part.set_param('name', filename, header='Content-Type')
|
||||
part.set_param('filename', filename, header='Content-Disposition')
|
||||
part.set_param("name", filename, header="Content-Type")
|
||||
part.set_param("filename", filename, header="Content-Disposition")
|
||||
|
||||
if content_id is not None:
|
||||
part['Content-ID'] = angle_wrap(content_id)
|
||||
part["Content-ID"] = angle_wrap(content_id)
|
||||
|
||||
if base64:
|
||||
content = b64decode(content)
|
||||
|
||||
payload = content
|
||||
if part.get_content_maintype() == 'message':
|
||||
if part.get_content_maintype() == "message":
|
||||
# email.Message parses message/rfc822 parts as a "multipart" (list) payload
|
||||
# whose single item is the recursively-parsed message attachment
|
||||
if isinstance(content, bytes):
|
||||
|
||||
@@ -19,27 +19,31 @@ class AnymailMessageMixin(EmailMessage):
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.esp_extra = kwargs.pop('esp_extra', UNSET)
|
||||
self.envelope_sender = kwargs.pop('envelope_sender', UNSET)
|
||||
self.metadata = kwargs.pop('metadata', UNSET)
|
||||
self.send_at = kwargs.pop('send_at', UNSET)
|
||||
self.tags = kwargs.pop('tags', UNSET)
|
||||
self.track_clicks = kwargs.pop('track_clicks', UNSET)
|
||||
self.track_opens = kwargs.pop('track_opens', UNSET)
|
||||
self.template_id = kwargs.pop('template_id', UNSET)
|
||||
self.merge_data = kwargs.pop('merge_data', UNSET)
|
||||
self.merge_global_data = kwargs.pop('merge_global_data', UNSET)
|
||||
self.merge_metadata = kwargs.pop('merge_metadata', UNSET)
|
||||
self.esp_extra = kwargs.pop("esp_extra", UNSET)
|
||||
self.envelope_sender = kwargs.pop("envelope_sender", UNSET)
|
||||
self.metadata = kwargs.pop("metadata", UNSET)
|
||||
self.send_at = kwargs.pop("send_at", UNSET)
|
||||
self.tags = kwargs.pop("tags", UNSET)
|
||||
self.track_clicks = kwargs.pop("track_clicks", UNSET)
|
||||
self.track_opens = kwargs.pop("track_opens", UNSET)
|
||||
self.template_id = kwargs.pop("template_id", UNSET)
|
||||
self.merge_data = kwargs.pop("merge_data", UNSET)
|
||||
self.merge_global_data = kwargs.pop("merge_global_data", UNSET)
|
||||
self.merge_metadata = kwargs.pop("merge_metadata", UNSET)
|
||||
self.anymail_status = AnymailStatus()
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def attach_inline_image_file(self, path, subtype=None, idstring="img", domain=None):
|
||||
"""Add inline image from file path to an EmailMessage, and return its content id"""
|
||||
"""
|
||||
Add inline image from file path to an EmailMessage, and return its content id
|
||||
"""
|
||||
assert isinstance(self, EmailMessage)
|
||||
return attach_inline_image_file(self, path, subtype, idstring, domain)
|
||||
|
||||
def attach_inline_image(self, content, filename=None, subtype=None, idstring="img", domain=None):
|
||||
def attach_inline_image(
|
||||
self, content, filename=None, subtype=None, idstring="img", domain=None
|
||||
):
|
||||
"""Add inline image and return its content id"""
|
||||
assert isinstance(self, EmailMessage)
|
||||
return attach_inline_image(self, content, filename, subtype, idstring, domain)
|
||||
@@ -57,27 +61,32 @@ def attach_inline_image_file(message, path, subtype=None, idstring="img", domain
|
||||
return attach_inline_image(message, content, filename, subtype, idstring, domain)
|
||||
|
||||
|
||||
def attach_inline_image(message, content, filename=None, subtype=None, idstring="img", domain=None):
|
||||
def attach_inline_image(
|
||||
message, content, filename=None, subtype=None, idstring="img", domain=None
|
||||
):
|
||||
"""Add inline image to an EmailMessage, and return its content id"""
|
||||
if domain is None:
|
||||
# Avoid defaulting to hostname that might end in '.com', because some ESPs
|
||||
# use Content-ID as filename, and Gmail blocks filenames ending in '.com'.
|
||||
domain = 'inline' # valid domain for a msgid; will never be a real TLD
|
||||
content_id = make_msgid(idstring, domain) # Content ID per RFC 2045 section 7 (with <...>)
|
||||
domain = "inline" # valid domain for a msgid; will never be a real TLD
|
||||
|
||||
# Content ID per RFC 2045 section 7 (with <...>):
|
||||
content_id = make_msgid(idstring, domain)
|
||||
|
||||
image = MIMEImage(content, subtype)
|
||||
image.add_header('Content-Disposition', 'inline', filename=filename)
|
||||
image.add_header('Content-ID', content_id)
|
||||
image.add_header("Content-Disposition", "inline", filename=filename)
|
||||
image.add_header("Content-ID", content_id)
|
||||
message.attach(image)
|
||||
return unquote(content_id) # Without <...>, for use as the <img> tag src
|
||||
|
||||
|
||||
ANYMAIL_STATUSES = [
|
||||
'sent', # the ESP has sent the message (though it may or may not get delivered)
|
||||
'queued', # the ESP will try to send the message later
|
||||
'invalid', # the recipient email was not valid
|
||||
'rejected', # the recipient is blacklisted
|
||||
'failed', # the attempt to send failed for some other reason
|
||||
'unknown', # anything else
|
||||
"sent", # the ESP has sent the message (though it may or may not get delivered)
|
||||
"queued", # the ESP will try to send the message later
|
||||
"invalid", # the recipient email was not valid
|
||||
"rejected", # the recipient is blacklisted
|
||||
"failed", # the attempt to send failed for some other reason
|
||||
"unknown", # anything else
|
||||
]
|
||||
|
||||
|
||||
@@ -98,16 +107,20 @@ class AnymailRecipientStatus:
|
||||
|
||||
def __repr__(self):
|
||||
return "AnymailRecipientStatus({message_id!r}, {status!r})".format(
|
||||
message_id=self.message_id, status=self.status)
|
||||
message_id=self.message_id, status=self.status
|
||||
)
|
||||
|
||||
|
||||
class AnymailStatus:
|
||||
"""Information about an EmailMessage's send status for all recipients"""
|
||||
|
||||
def __init__(self):
|
||||
self.message_id = None # set of ESP message ids across all recipients, or bare id if only one, or None
|
||||
self.status = None # set of ANYMAIL_STATUSES across all recipients, or None for not yet sent to ESP
|
||||
self.recipients = {} # per-recipient: { email: AnymailRecipientStatus, ... }
|
||||
#: set of ESP message ids across all recipients, or bare id if only one, or None
|
||||
self.message_id = None
|
||||
#: set of ANYMAIL_STATUSES across all recipients, or None if not yet sent to ESP
|
||||
self.status = None
|
||||
#: per-recipient: { email: AnymailRecipientStatus, ... }
|
||||
self.recipients = {}
|
||||
self.esp_response = None
|
||||
|
||||
def __repr__(self):
|
||||
@@ -118,17 +131,26 @@ class AnymailStatus:
|
||||
return "{%s}" % ", ".join(item_reprs)
|
||||
else:
|
||||
return repr(o)
|
||||
|
||||
details = ["status={status}".format(status=_repr(self.status))]
|
||||
if self.message_id:
|
||||
details.append("message_id={message_id}".format(message_id=_repr(self.message_id)))
|
||||
details.append(
|
||||
"message_id={message_id}".format(message_id=_repr(self.message_id))
|
||||
)
|
||||
if self.recipients:
|
||||
details.append("{num_recipients} recipients".format(num_recipients=len(self.recipients)))
|
||||
details.append(
|
||||
"{num_recipients} recipients".format(
|
||||
num_recipients=len(self.recipients)
|
||||
)
|
||||
)
|
||||
return "AnymailStatus<{details}>".format(details=", ".join(details))
|
||||
|
||||
def set_recipient_status(self, recipients):
|
||||
self.recipients.update(recipients)
|
||||
recipient_statuses = self.recipients.values()
|
||||
self.message_id = set([recipient.message_id for recipient in recipient_statuses])
|
||||
self.message_id = set(
|
||||
[recipient.message_id for recipient in recipient_statuses]
|
||||
)
|
||||
if len(self.message_id) == 1:
|
||||
self.message_id = self.message_id.pop() # de-set-ify if single message_id
|
||||
self.status = set([recipient.status for recipient in recipient_statuses])
|
||||
|
||||
@@ -1,31 +1,36 @@
|
||||
from django.dispatch import Signal
|
||||
|
||||
|
||||
# Outbound message, before sending
|
||||
# provides args: message, esp_name
|
||||
#: Outbound message, before sending
|
||||
#: provides args: message, esp_name
|
||||
pre_send = Signal()
|
||||
|
||||
# Outbound message, after sending
|
||||
# provides args: message, status, esp_name
|
||||
#: Outbound message, after sending
|
||||
#: provides args: message, status, esp_name
|
||||
post_send = Signal()
|
||||
|
||||
# Delivery and tracking events for sent messages
|
||||
# provides args: event, esp_name
|
||||
#: Delivery and tracking events for sent messages
|
||||
#: provides args: event, esp_name
|
||||
tracking = Signal()
|
||||
|
||||
# Event for receiving inbound messages
|
||||
# provides args: event, esp_name
|
||||
#: Event for receiving inbound messages
|
||||
#: provides args: event, esp_name
|
||||
inbound = Signal()
|
||||
|
||||
|
||||
class AnymailEvent:
|
||||
"""Base class for normalized Anymail webhook events"""
|
||||
|
||||
def __init__(self, event_type, timestamp=None, event_id=None, esp_event=None, **kwargs):
|
||||
self.event_type = event_type # normalized to an EventType str
|
||||
self.timestamp = timestamp # normalized to an aware datetime
|
||||
self.event_id = event_id # opaque str
|
||||
self.esp_event = esp_event # raw event fields (e.g., parsed JSON dict or POST data QueryDict)
|
||||
def __init__(
|
||||
self, event_type, timestamp=None, event_id=None, esp_event=None, **kwargs
|
||||
):
|
||||
#: normalized to an EventType str
|
||||
self.event_type = event_type
|
||||
#: normalized to an aware datetime
|
||||
self.timestamp = timestamp
|
||||
#: opaque str
|
||||
self.event_id = event_id
|
||||
#: raw event fields (e.g., parsed JSON dict or POST data QueryDict)
|
||||
self.esp_event = esp_event
|
||||
|
||||
|
||||
class AnymailTrackingEvent(AnymailEvent):
|
||||
@@ -33,15 +38,19 @@ class AnymailTrackingEvent(AnymailEvent):
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.click_url = kwargs.pop('click_url', None) # str
|
||||
self.description = kwargs.pop('description', None) # str, usually human-readable, not normalized
|
||||
self.message_id = kwargs.pop('message_id', None) # str, format may vary
|
||||
self.metadata = kwargs.pop('metadata', {}) # dict
|
||||
self.mta_response = kwargs.pop('mta_response', None) # str, may include SMTP codes, not normalized
|
||||
self.recipient = kwargs.pop('recipient', None) # str email address (just the email portion; no name)
|
||||
self.reject_reason = kwargs.pop('reject_reason', None) # normalized to a RejectReason str
|
||||
self.tags = kwargs.pop('tags', []) # list of str
|
||||
self.user_agent = kwargs.pop('user_agent', None) # str
|
||||
self.click_url = kwargs.pop("click_url", None) #: str
|
||||
#: str, usually human-readable, not normalized
|
||||
self.description = kwargs.pop("description", None)
|
||||
self.message_id = kwargs.pop("message_id", None) #: str, format may vary
|
||||
self.metadata = kwargs.pop("metadata", {}) #: dict
|
||||
#: str, may include SMTP codes, not normalized
|
||||
self.mta_response = kwargs.pop("mta_response", None)
|
||||
#: str email address (just the email portion; no name)
|
||||
self.recipient = kwargs.pop("recipient", None)
|
||||
#: normalized to a RejectReason str
|
||||
self.reject_reason = kwargs.pop("reject_reason", None)
|
||||
self.tags = kwargs.pop("tags", []) #: list of str
|
||||
self.user_agent = kwargs.pop("user_agent", None) #: str
|
||||
|
||||
|
||||
class AnymailInboundEvent(AnymailEvent):
|
||||
@@ -49,45 +58,92 @@ class AnymailInboundEvent(AnymailEvent):
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.message = kwargs.pop('message', None) # anymail.inbound.AnymailInboundMessage
|
||||
#: anymail.inbound.AnymailInboundMessage
|
||||
self.message = kwargs.pop("message", None)
|
||||
|
||||
|
||||
class EventType:
|
||||
"""Constants for normalized Anymail event types"""
|
||||
|
||||
# Delivery (and non-delivery) event types:
|
||||
# Delivery (and non-delivery) event types
|
||||
# (these match message.ANYMAIL_STATUSES where appropriate)
|
||||
QUEUED = 'queued' # the ESP has accepted the message and will try to send it (possibly later)
|
||||
SENT = 'sent' # the ESP has sent the message (though it may or may not get delivered)
|
||||
REJECTED = 'rejected' # the ESP refused to send the messsage (e.g., suppression list, policy, invalid email)
|
||||
FAILED = 'failed' # the ESP was unable to send the message (e.g., template rendering error)
|
||||
|
||||
BOUNCED = 'bounced' # rejected or blocked by receiving MTA
|
||||
DEFERRED = 'deferred' # delayed by receiving MTA; should be followed by a later BOUNCED or DELIVERED
|
||||
DELIVERED = 'delivered' # accepted by receiving MTA
|
||||
AUTORESPONDED = 'autoresponded' # a bot replied
|
||||
#: the ESP has accepted the message and will try to send it (possibly later)
|
||||
QUEUED = "queued"
|
||||
|
||||
# Tracking event types:
|
||||
OPENED = 'opened' # open tracking
|
||||
CLICKED = 'clicked' # click tracking
|
||||
COMPLAINED = 'complained' # recipient reported as spam (e.g., through feedback loop)
|
||||
UNSUBSCRIBED = 'unsubscribed' # recipient attempted to unsubscribe
|
||||
SUBSCRIBED = 'subscribed' # signed up for mailing list through ESP-hosted form
|
||||
#: the ESP has sent the message (though it may or may not get delivered)
|
||||
SENT = "sent"
|
||||
|
||||
# Inbound event types:
|
||||
INBOUND = 'inbound' # received message
|
||||
INBOUND_FAILED = 'inbound_failed'
|
||||
#: the ESP refused to send the message
|
||||
#: (e.g., suppression list, policy, invalid email)
|
||||
REJECTED = "rejected"
|
||||
|
||||
# Other:
|
||||
UNKNOWN = 'unknown' # anything else
|
||||
#: the ESP was unable to send the message (e.g., template rendering error)
|
||||
FAILED = "failed"
|
||||
|
||||
#: rejected or blocked by receiving MTA
|
||||
BOUNCED = "bounced"
|
||||
|
||||
#: delayed by receiving MTA; should be followed by a later BOUNCED or DELIVERED
|
||||
DEFERRED = "deferred"
|
||||
|
||||
#: accepted by receiving MTA
|
||||
DELIVERED = "delivered"
|
||||
|
||||
#: a bot replied
|
||||
AUTORESPONDED = "autoresponded"
|
||||
|
||||
# Tracking event types
|
||||
|
||||
#: open tracking
|
||||
OPENED = "opened"
|
||||
|
||||
#: click tracking
|
||||
CLICKED = "clicked"
|
||||
|
||||
#: recipient reported as spam (e.g., through feedback loop)
|
||||
COMPLAINED = "complained"
|
||||
|
||||
#: recipient attempted to unsubscribe
|
||||
UNSUBSCRIBED = "unsubscribed"
|
||||
|
||||
#: signed up for mailing list through ESP-hosted form
|
||||
SUBSCRIBED = "subscribed"
|
||||
|
||||
# Inbound event types
|
||||
|
||||
#: received message
|
||||
INBOUND = "inbound"
|
||||
|
||||
#: (ESP notification of) error receiving message
|
||||
INBOUND_FAILED = "inbound_failed"
|
||||
|
||||
# Other event types
|
||||
|
||||
#: all other ESP events
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
class RejectReason:
|
||||
"""Constants for normalized Anymail reject/drop reasons"""
|
||||
INVALID = 'invalid' # bad address format
|
||||
BOUNCED = 'bounced' # (previous) bounce from recipient
|
||||
TIMED_OUT = 'timed_out' # (previous) repeated failed delivery attempts
|
||||
BLOCKED = 'blocked' # ESP policy suppression
|
||||
SPAM = 'spam' # (previous) spam complaint from recipient
|
||||
UNSUBSCRIBED = 'unsubscribed' # (previous) unsubscribe request from recipient
|
||||
OTHER = 'other'
|
||||
|
||||
#: bad address format
|
||||
INVALID = "invalid"
|
||||
|
||||
#: (previous) bounce from recipient
|
||||
BOUNCED = "bounced"
|
||||
|
||||
#: (previous) repeated failed delivery attempts
|
||||
TIMED_OUT = "timed_out"
|
||||
|
||||
#: ESP policy suppression
|
||||
BLOCKED = "blocked"
|
||||
|
||||
#: (previous) spam complaint from recipient
|
||||
SPAM = "spam"
|
||||
|
||||
#: (previous) unsubscribe request from recipient
|
||||
UNSUBSCRIBED = "unsubscribed"
|
||||
|
||||
#: all other ESP reject reasons
|
||||
OTHER = "other"
|
||||
|
||||
118
anymail/urls.py
118
anymail/urls.py
@@ -1,6 +1,9 @@
|
||||
from django.urls import re_path
|
||||
|
||||
from .webhooks.amazon_ses import AmazonSESInboundWebhookView, AmazonSESTrackingWebhookView
|
||||
from .webhooks.amazon_ses import (
|
||||
AmazonSESInboundWebhookView,
|
||||
AmazonSESTrackingWebhookView,
|
||||
)
|
||||
from .webhooks.mailgun import MailgunInboundWebhookView, MailgunTrackingWebhookView
|
||||
from .webhooks.mailjet import MailjetInboundWebhookView, MailjetTrackingWebhookView
|
||||
from .webhooks.mandrill import MandrillCombinedWebhookView
|
||||
@@ -8,30 +11,97 @@ from .webhooks.postal import PostalInboundWebhookView, PostalTrackingWebhookView
|
||||
from .webhooks.postmark import PostmarkInboundWebhookView, PostmarkTrackingWebhookView
|
||||
from .webhooks.sendgrid import SendGridInboundWebhookView, SendGridTrackingWebhookView
|
||||
from .webhooks.sendinblue import SendinBlueTrackingWebhookView
|
||||
from .webhooks.sparkpost import SparkPostInboundWebhookView, SparkPostTrackingWebhookView
|
||||
from .webhooks.sparkpost import (
|
||||
SparkPostInboundWebhookView,
|
||||
SparkPostTrackingWebhookView,
|
||||
)
|
||||
|
||||
|
||||
app_name = 'anymail'
|
||||
app_name = "anymail"
|
||||
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'),
|
||||
|
||||
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'),
|
||||
re_path(r'^sparkpost/tracking/$', SparkPostTrackingWebhookView.as_view(), name='sparkpost_tracking_webhook'),
|
||||
|
||||
# Anymail uses a combined Mandrill webhook endpoint, to simplify Mandrill's key-validation scheme:
|
||||
re_path(r'^mandrill/$', MandrillCombinedWebhookView.as_view(), name='mandrill_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"^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",
|
||||
),
|
||||
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",
|
||||
),
|
||||
re_path(
|
||||
r"^sparkpost/tracking/$",
|
||||
SparkPostTrackingWebhookView.as_view(),
|
||||
name="sparkpost_tracking_webhook",
|
||||
),
|
||||
# Anymail uses a combined Mandrill webhook endpoint,
|
||||
# to simplify Mandrill's key-validation scheme:
|
||||
re_path(
|
||||
r"^mandrill/$", MandrillCombinedWebhookView.as_view(), name="mandrill_webhook"
|
||||
),
|
||||
# This url is maintained for backwards compatibility with earlier Anymail releases:
|
||||
re_path(r'^mandrill/tracking/$', MandrillCombinedWebhookView.as_view(), name='mandrill_tracking_webhook'),
|
||||
re_path(
|
||||
r"^mandrill/tracking/$",
|
||||
MandrillCombinedWebhookView.as_view(),
|
||||
name="mandrill_tracking_webhook",
|
||||
),
|
||||
]
|
||||
|
||||
119
anymail/utils.py
119
anymail/utils.py
@@ -17,7 +17,7 @@ from .exceptions import AnymailConfigurationError, AnymailInvalidAddress
|
||||
BASIC_NUMERIC_TYPES = (int, float)
|
||||
|
||||
|
||||
UNSET = type('UNSET', (object,), {}) # Used as non-None default value
|
||||
UNSET = type("UNSET", (object,), {}) # Used as non-None default value
|
||||
|
||||
|
||||
def combine(*args):
|
||||
@@ -93,7 +93,7 @@ def getfirst(dct, keys, default=UNSET):
|
||||
except KeyError:
|
||||
pass
|
||||
if default is UNSET:
|
||||
raise KeyError("None of %s found in dict" % ', '.join(keys))
|
||||
raise KeyError("None of %s found in dict" % ", ".join(keys))
|
||||
else:
|
||||
return default
|
||||
|
||||
@@ -105,7 +105,11 @@ def update_deep(dct, other):
|
||||
and other can be any Mapping
|
||||
"""
|
||||
for key, value in other.items():
|
||||
if key in dct and isinstance(dct[key], MutableMapping) and isinstance(value, Mapping):
|
||||
if (
|
||||
key in dct
|
||||
and isinstance(dct[key], MutableMapping)
|
||||
and isinstance(value, Mapping)
|
||||
):
|
||||
update_deep(dct[key], value)
|
||||
else:
|
||||
dct[key] = value
|
||||
@@ -138,18 +142,25 @@ def parse_address_list(address_list, field=None):
|
||||
# from the list -- which may split comma-seperated strings into multiple addresses.
|
||||
# (See django.core.mail.message: EmailMessage.message to/cc/bcc/reply_to handling;
|
||||
# also logic for ADDRESS_HEADERS in forbid_multi_line_headers.)
|
||||
address_list_strings = [force_str(address) for address in address_list] # resolve lazy strings
|
||||
|
||||
# resolve lazy strings:
|
||||
address_list_strings = [force_str(address) for address in address_list]
|
||||
name_email_pairs = getaddresses(address_list_strings)
|
||||
if name_email_pairs == [] and address_list_strings == [""]:
|
||||
name_email_pairs = [('', '')] # getaddresses ignores a single empty string
|
||||
parsed = [EmailAddress(display_name=name, addr_spec=email)
|
||||
for (name, email) in name_email_pairs]
|
||||
name_email_pairs = [("", "")] # getaddresses ignores a single empty string
|
||||
parsed = [
|
||||
EmailAddress(display_name=name, addr_spec=email)
|
||||
for (name, email) in name_email_pairs
|
||||
]
|
||||
|
||||
# Sanity-check, and raise useful errors
|
||||
for address in parsed:
|
||||
if address.username == '' or address.domain == '':
|
||||
# Django SMTP allows username-only emails, but they're not meaningful with an ESP
|
||||
errmsg = "Invalid email address '{problem}' parsed from '{source}'{where}.".format(
|
||||
if address.username == "" or address.domain == "":
|
||||
# Django SMTP allows username-only emails,
|
||||
# but they're not meaningful with an ESP
|
||||
errmsg = (
|
||||
"Invalid email address '{problem}'" " parsed from '{source}'{where}."
|
||||
).format(
|
||||
problem=address.addr_spec,
|
||||
source=", ".join(address_list_strings),
|
||||
where=" in `%s`" % field if field else "",
|
||||
@@ -165,7 +176,8 @@ def parse_single_address(address, field=None):
|
||||
"""Parses a single EmailAddress from str address, or raises AnymailInvalidAddress
|
||||
|
||||
:param str address: the fully-formatted email str to parse
|
||||
:param str|None field: optional description of the source of this address, for error message
|
||||
:param str|None field:
|
||||
optional description of the source of this address, for error message
|
||||
:return :class:`EmailAddress`: if address contains a single email
|
||||
:raises :exc:`AnymailInvalidAddress`: if address contains no or multiple emails
|
||||
"""
|
||||
@@ -173,8 +185,11 @@ def parse_single_address(address, field=None):
|
||||
count = len(parsed)
|
||||
if count > 1:
|
||||
raise AnymailInvalidAddress(
|
||||
"Only one email address is allowed; found {count} in '{address}'{where}.".format(
|
||||
count=count, address=address, where=" in `%s`" % field if field else ""))
|
||||
"Only one email address is allowed;"
|
||||
" found {count} in '{address}'{where}.".format(
|
||||
count=count, address=address, where=" in `%s`" % field if field else ""
|
||||
)
|
||||
)
|
||||
else:
|
||||
return parsed[0]
|
||||
|
||||
@@ -205,7 +220,7 @@ class EmailAddress:
|
||||
(also available as `str(EmailAddress)`)
|
||||
"""
|
||||
|
||||
def __init__(self, display_name='', addr_spec=None):
|
||||
def __init__(self, display_name="", addr_spec=None):
|
||||
self._address = None # lazy formatted address
|
||||
if addr_spec is None:
|
||||
try:
|
||||
@@ -215,10 +230,10 @@ class EmailAddress:
|
||||
|
||||
# ESPs should clean or reject addresses containing newlines, but some
|
||||
# extra protection can't hurt (and it seems to be a common oversight)
|
||||
if '\n' in display_name or '\r' in display_name:
|
||||
raise ValueError('EmailAddress display_name cannot contain newlines')
|
||||
if '\n' in addr_spec or '\r' in addr_spec:
|
||||
raise ValueError('EmailAddress addr_spec cannot contain newlines')
|
||||
if "\n" in display_name or "\r" in display_name:
|
||||
raise ValueError("EmailAddress display_name cannot contain newlines")
|
||||
if "\n" in addr_spec or "\r" in addr_spec:
|
||||
raise ValueError("EmailAddress addr_spec cannot contain newlines")
|
||||
|
||||
self.display_name = display_name
|
||||
self.addr_spec = addr_spec
|
||||
@@ -227,11 +242,12 @@ class EmailAddress:
|
||||
# do we need to unquote username?
|
||||
except ValueError:
|
||||
self.username = addr_spec
|
||||
self.domain = ''
|
||||
self.domain = ""
|
||||
|
||||
def __repr__(self):
|
||||
return "EmailAddress({display_name!r}, {addr_spec!r})".format(
|
||||
display_name=self.display_name, addr_spec=self.addr_spec)
|
||||
display_name=self.display_name, addr_spec=self.addr_spec
|
||||
)
|
||||
|
||||
@property
|
||||
def address(self):
|
||||
@@ -278,7 +294,7 @@ class Attachment:
|
||||
# Note that an attachment can be either a tuple of (filename, content, mimetype)
|
||||
# or a MIMEBase object. (Also, both filename and mimetype may be missing.)
|
||||
self._attachment = attachment
|
||||
self.encoding = encoding # should we be checking attachment["Content-Encoding"] ???
|
||||
self.encoding = encoding # or check attachment["Content-Encoding"] ???
|
||||
self.inline = False
|
||||
self.content_id = None
|
||||
self.cid = ""
|
||||
@@ -289,12 +305,15 @@ class Attachment:
|
||||
if self.content is None:
|
||||
self.content = attachment.as_bytes()
|
||||
self.mimetype = attachment.get_content_type()
|
||||
self.content_type = attachment["Content-Type"] # includes charset if provided
|
||||
# Content-Type includes charset if provided
|
||||
self.content_type = attachment["Content-Type"]
|
||||
|
||||
content_disposition = attachment.get_content_disposition()
|
||||
if content_disposition == 'inline' or (not content_disposition and 'Content-ID' in attachment):
|
||||
if content_disposition == "inline" or (
|
||||
not content_disposition and "Content-ID" in attachment
|
||||
):
|
||||
self.inline = True
|
||||
self.content_id = attachment["Content-ID"] # probably including the <...>
|
||||
self.content_id = attachment["Content-ID"] # probably including <...>
|
||||
if self.content_id is not None:
|
||||
self.cid = unquote(self.content_id) # without the <, >
|
||||
else:
|
||||
@@ -322,7 +341,9 @@ class Attachment:
|
||||
details.append("name={name!r}".format(name=self.name))
|
||||
if self.inline:
|
||||
details.insert(0, "inline")
|
||||
details.append("content_id={content_id!r}".format(content_id=self.content_id))
|
||||
details.append(
|
||||
"content_id={content_id!r}".format(content_id=self.content_id)
|
||||
)
|
||||
return "Attachment<{details}>".format(details=", ".join(details))
|
||||
|
||||
@property
|
||||
@@ -334,7 +355,9 @@ class Attachment:
|
||||
return b64encode(content).decode("ascii")
|
||||
|
||||
|
||||
def get_anymail_setting(name, default=UNSET, esp_name=None, kwargs=None, allow_bare=False):
|
||||
def get_anymail_setting(
|
||||
name, default=UNSET, esp_name=None, kwargs=None, allow_bare=False
|
||||
):
|
||||
"""Returns an Anymail option from kwargs or Django settings.
|
||||
|
||||
Returns first of:
|
||||
@@ -352,7 +375,7 @@ def get_anymail_setting(name, default=UNSET, esp_name=None, kwargs=None, allow_b
|
||||
|
||||
try:
|
||||
value = kwargs.pop(name)
|
||||
if name in ['username', 'password']:
|
||||
if name in ["username", "password"]:
|
||||
# Work around a problem in django.core.mail.send_mail, which calls
|
||||
# get_connection(... username=None, password=None) by default.
|
||||
# We need to ignore those None defaults (else settings like
|
||||
@@ -382,7 +405,10 @@ def get_anymail_setting(name, default=UNSET, esp_name=None, kwargs=None, allow_b
|
||||
except AttributeError:
|
||||
pass
|
||||
if default is UNSET:
|
||||
message = "You must set %s or ANYMAIL = {'%s': ...}" % (anymail_setting, setting)
|
||||
message = "You must set %s or ANYMAIL = {'%s': ...}" % (
|
||||
anymail_setting,
|
||||
setting,
|
||||
)
|
||||
if allow_bare:
|
||||
message += " or %s" % setting
|
||||
message += " in your Django settings"
|
||||
@@ -413,7 +439,9 @@ def collect_all_methods(cls, method_name):
|
||||
|
||||
|
||||
def querydict_getfirst(qdict, field, default=UNSET):
|
||||
"""Like :func:`django.http.QueryDict.get`, but returns *first* value of multi-valued field.
|
||||
"""
|
||||
Like :func:`django.http.QueryDict.get`,
|
||||
but returns *first* value of multi-valued field.
|
||||
|
||||
>>> from django.http import QueryDict
|
||||
>>> q = QueryDict('a=1&a=2&a=3')
|
||||
@@ -429,8 +457,9 @@ def querydict_getfirst(qdict, field, default=UNSET):
|
||||
>>> q.getfirst('a')
|
||||
'1'
|
||||
"""
|
||||
# (Why not instead define a QueryDict subclass with this method? Because there's no simple way
|
||||
# to efficiently initialize a QueryDict subclass with the contents of an existing instance.)
|
||||
# (Why not instead define a QueryDict subclass with this method? Because there's
|
||||
# no simple way to efficiently initialize a QueryDict subclass with the contents
|
||||
# of an existing instance.)
|
||||
values = qdict.getlist(field)
|
||||
if len(values) > 0:
|
||||
return values[0]
|
||||
@@ -453,10 +482,10 @@ def angle_wrap(s):
|
||||
# This is the inverse behavior of email.utils.unquote
|
||||
# (which you might think email.utils.quote would do, but it doesn't)
|
||||
if len(s) > 0:
|
||||
if s[0] != '<':
|
||||
s = '<' + s
|
||||
if s[-1] != '>':
|
||||
s = s + '>'
|
||||
if s[0] != "<":
|
||||
s = "<" + s
|
||||
if s[-1] != ">":
|
||||
s = s + ">"
|
||||
return s
|
||||
|
||||
|
||||
@@ -468,7 +497,9 @@ def is_lazy(obj):
|
||||
|
||||
|
||||
def force_non_lazy(obj):
|
||||
"""If obj is a Django lazy object, return it coerced to text; otherwise return it unchanged.
|
||||
"""
|
||||
If obj is a Django lazy object, return it coerced to text;
|
||||
otherwise return it unchanged.
|
||||
|
||||
(Similar to django.utils.encoding.force_text, but doesn't alter non-text objects.)
|
||||
"""
|
||||
@@ -500,9 +531,9 @@ def get_request_basic_auth(request):
|
||||
If request includes basic auth, result is string 'username:password'.
|
||||
"""
|
||||
try:
|
||||
authtype, authdata = request.META['HTTP_AUTHORIZATION'].split()
|
||||
authtype, authdata = request.META["HTTP_AUTHORIZATION"].split()
|
||||
if authtype.lower() == "basic":
|
||||
return base64.b64decode(authdata).decode('utf-8')
|
||||
return base64.b64decode(authdata).decode("utf-8")
|
||||
except (IndexError, KeyError, TypeError, ValueError):
|
||||
pass
|
||||
return None
|
||||
@@ -519,8 +550,15 @@ def get_request_uri(request):
|
||||
if basic_auth is not None:
|
||||
# must reassemble url with auth
|
||||
parts = urlsplit(url)
|
||||
url = urlunsplit((parts.scheme, basic_auth + '@' + parts.netloc,
|
||||
parts.path, parts.query, parts.fragment))
|
||||
url = urlunsplit(
|
||||
(
|
||||
parts.scheme,
|
||||
basic_auth + "@" + parts.netloc,
|
||||
parts.path,
|
||||
parts.query,
|
||||
parts.fragment,
|
||||
)
|
||||
)
|
||||
return url
|
||||
|
||||
|
||||
@@ -558,6 +596,7 @@ class CaseInsensitiveCasePreservingDict(CaseInsensitiveDict):
|
||||
>>> cid.keys()
|
||||
["accEPT"]
|
||||
"""
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
_k = key.lower()
|
||||
try:
|
||||
|
||||
@@ -5,24 +5,40 @@ from base64 import b64decode
|
||||
from django.http import HttpResponse
|
||||
from django.utils.dateparse import parse_datetime
|
||||
|
||||
from .base import AnymailBaseWebhookView
|
||||
from ..exceptions import (
|
||||
AnymailAPIError, AnymailConfigurationError, AnymailImproperlyInstalled, AnymailWebhookValidationFailure,
|
||||
_LazyError)
|
||||
AnymailAPIError,
|
||||
AnymailConfigurationError,
|
||||
AnymailImproperlyInstalled,
|
||||
AnymailWebhookValidationFailure,
|
||||
_LazyError,
|
||||
)
|
||||
from ..inbound import AnymailInboundMessage
|
||||
from ..signals import AnymailInboundEvent, AnymailTrackingEvent, EventType, RejectReason, inbound, tracking
|
||||
from ..signals import (
|
||||
AnymailInboundEvent,
|
||||
AnymailTrackingEvent,
|
||||
EventType,
|
||||
RejectReason,
|
||||
inbound,
|
||||
tracking,
|
||||
)
|
||||
from ..utils import get_anymail_setting, getfirst
|
||||
from .base import AnymailBaseWebhookView
|
||||
|
||||
try:
|
||||
import boto3
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
from ..backends.amazon_ses import _get_anymail_boto3_params
|
||||
except ImportError:
|
||||
# This module gets imported by anymail.urls, so don't complain about boto3 missing
|
||||
# unless one of the Amazon SES webhook views is actually used and needs it
|
||||
boto3 = _LazyError(AnymailImproperlyInstalled(missing_package='boto3', backend='amazon_ses'))
|
||||
boto3 = _LazyError(
|
||||
AnymailImproperlyInstalled(missing_package="boto3", backend="amazon_ses")
|
||||
)
|
||||
ClientError = object
|
||||
_get_anymail_boto3_params = _LazyError(AnymailImproperlyInstalled(missing_package='boto3', backend='amazon_ses'))
|
||||
_get_anymail_boto3_params = _LazyError(
|
||||
AnymailImproperlyInstalled(missing_package="boto3", backend="amazon_ses")
|
||||
)
|
||||
|
||||
|
||||
class AmazonSESBaseWebhookView(AnymailBaseWebhookView):
|
||||
@@ -31,23 +47,32 @@ class AmazonSESBaseWebhookView(AnymailBaseWebhookView):
|
||||
esp_name = "Amazon SES"
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
# whether to automatically respond to SNS SubscriptionConfirmation requests; default True
|
||||
# (Future: could also take a TopicArn or list to auto-confirm)
|
||||
# whether to automatically respond to SNS SubscriptionConfirmation requests;
|
||||
# default True. (Future: could also take a TopicArn or list to auto-confirm)
|
||||
self.auto_confirm_enabled = get_anymail_setting(
|
||||
"auto_confirm_sns_subscriptions", esp_name=self.esp_name, kwargs=kwargs, default=True)
|
||||
# boto3 params for connecting to S3 (inbound downloads) and SNS (auto-confirm subscriptions):
|
||||
self.session_params, self.client_params = _get_anymail_boto3_params(kwargs=kwargs)
|
||||
"auto_confirm_sns_subscriptions",
|
||||
esp_name=self.esp_name,
|
||||
kwargs=kwargs,
|
||||
default=True,
|
||||
)
|
||||
# boto3 params for connecting to S3 (inbound downloads)
|
||||
# and SNS (auto-confirm subscriptions):
|
||||
self.session_params, self.client_params = _get_anymail_boto3_params(
|
||||
kwargs=kwargs
|
||||
)
|
||||
super().__init__(**kwargs)
|
||||
|
||||
@staticmethod
|
||||
def _parse_sns_message(request):
|
||||
# cache so we don't have to parse the json multiple times
|
||||
if not hasattr(request, '_sns_message'):
|
||||
if not hasattr(request, "_sns_message"):
|
||||
try:
|
||||
body = request.body.decode(request.encoding or 'utf-8')
|
||||
body = request.body.decode(request.encoding or "utf-8")
|
||||
request._sns_message = json.loads(body)
|
||||
except (TypeError, ValueError, UnicodeDecodeError) as err:
|
||||
raise AnymailAPIError("Malformed SNS message body %r" % request.body) from err
|
||||
raise AnymailAPIError(
|
||||
"Malformed SNS message body %r" % request.body
|
||||
) from err
|
||||
return request._sns_message
|
||||
|
||||
def validate_request(self, request):
|
||||
@@ -57,18 +82,24 @@ class AmazonSESBaseWebhookView(AnymailBaseWebhookView):
|
||||
body_type = sns_message.get("Type", "<<missing>>")
|
||||
if header_type != body_type:
|
||||
raise AnymailWebhookValidationFailure(
|
||||
'SNS header "x-amz-sns-message-type: %s" doesn\'t match body "Type": "%s"'
|
||||
% (header_type, body_type))
|
||||
'SNS header "x-amz-sns-message-type: %s"'
|
||||
' doesn\'t match body "Type": "%s"' % (header_type, body_type)
|
||||
)
|
||||
|
||||
if header_type not in ["Notification", "SubscriptionConfirmation", "UnsubscribeConfirmation"]:
|
||||
if header_type not in [
|
||||
"Notification",
|
||||
"SubscriptionConfirmation",
|
||||
"UnsubscribeConfirmation",
|
||||
]:
|
||||
raise AnymailAPIError("Unknown SNS message type '%s'" % header_type)
|
||||
|
||||
header_id = request.META.get("HTTP_X_AMZ_SNS_MESSAGE_ID", "<<missing>>")
|
||||
body_id = sns_message.get("MessageId", "<<missing>>")
|
||||
if header_id != body_id:
|
||||
raise AnymailWebhookValidationFailure(
|
||||
'SNS header "x-amz-sns-message-id: %s" doesn\'t match body "MessageId": "%s"'
|
||||
% (header_id, body_id))
|
||||
'SNS header "x-amz-sns-message-id: %s"'
|
||||
' doesn\'t match body "MessageId": "%s"' % (header_id, body_id)
|
||||
)
|
||||
|
||||
# Future: Verify SNS message signature
|
||||
# https://docs.aws.amazon.com/sns/latest/dg/SendMessageToHttp.verify.signature.html
|
||||
@@ -76,7 +107,8 @@ class AmazonSESBaseWebhookView(AnymailBaseWebhookView):
|
||||
def post(self, request, *args, **kwargs):
|
||||
# request has *not* yet been validated at this point
|
||||
if self.basic_auth and not request.META.get("HTTP_AUTHORIZATION"):
|
||||
# Amazon SNS requires a proper 401 response before it will attempt to send basic auth
|
||||
# Amazon SNS requires a proper 401 response
|
||||
# before it will attempt to send basic auth
|
||||
response = HttpResponse(status=401)
|
||||
response["WWW-Authenticate"] = 'Basic realm="Anymail WEBHOOK_SECRET"'
|
||||
return response
|
||||
@@ -92,10 +124,16 @@ class AmazonSESBaseWebhookView(AnymailBaseWebhookView):
|
||||
try:
|
||||
ses_event = json.loads(message_string)
|
||||
except (TypeError, ValueError) as err:
|
||||
if message_string == "Successfully validated SNS topic for Amazon SES event publishing.":
|
||||
pass # this Notification is generated after SubscriptionConfirmation
|
||||
if (
|
||||
"Successfully validated SNS topic for Amazon SES event publishing."
|
||||
== message_string
|
||||
):
|
||||
# this Notification is generated after SubscriptionConfirmation
|
||||
pass
|
||||
else:
|
||||
raise AnymailAPIError("Unparsable SNS Message %r" % message_string) from err
|
||||
raise AnymailAPIError(
|
||||
"Unparsable SNS Message %r" % message_string
|
||||
) from err
|
||||
else:
|
||||
events = self.esp_to_anymail_events(ses_event, sns_message)
|
||||
elif sns_type == "SubscriptionConfirmation":
|
||||
@@ -107,43 +145,63 @@ class AmazonSESBaseWebhookView(AnymailBaseWebhookView):
|
||||
raise NotImplementedError()
|
||||
|
||||
def auto_confirm_sns_subscription(self, sns_message):
|
||||
"""Automatically accept a subscription to Amazon SNS topics, if the request is expected.
|
||||
"""
|
||||
Automatically accept a subscription to Amazon SNS topics,
|
||||
if the request is expected.
|
||||
|
||||
If an SNS SubscriptionConfirmation arrives with HTTP basic auth proving it is meant for us,
|
||||
automatically load the SubscribeURL to confirm the subscription.
|
||||
If an SNS SubscriptionConfirmation arrives with HTTP basic auth proving it is
|
||||
meant for us, automatically load the SubscribeURL to confirm the subscription.
|
||||
"""
|
||||
if not self.auto_confirm_enabled:
|
||||
return
|
||||
|
||||
if not self.basic_auth:
|
||||
# Note: basic_auth (shared secret) confirms the notification was meant for us.
|
||||
# If WEBHOOK_SECRET isn't set, Anymail logs a warning but allows the request.
|
||||
# (Also, verifying the SNS message signature would be insufficient here:
|
||||
# if someone else tried to point their own SNS topic at our webhook url,
|
||||
# SNS would send a SubscriptionConfirmation with a valid Amazon signature.)
|
||||
# basic_auth (shared secret) confirms the notification was meant for us.
|
||||
# If WEBHOOK_SECRET isn't set, Anymail logs a warning but allows the
|
||||
# request. (Also, verifying the SNS message signature would be insufficient
|
||||
# here: if someone else tried to point their own SNS topic at our webhook
|
||||
# url, SNS would send a SubscriptionConfirmation with a valid Amazon
|
||||
# signature.)
|
||||
raise AnymailWebhookValidationFailure(
|
||||
"Anymail received an unexpected SubscriptionConfirmation request for Amazon SNS topic "
|
||||
"'{topic_arn!s}'. (Anymail can automatically confirm SNS subscriptions if you set a "
|
||||
"WEBHOOK_SECRET and use that in your SNS notification url. Or you can manually confirm "
|
||||
"this subscription in the SNS dashboard with token '{token!s}'.)"
|
||||
"".format(topic_arn=sns_message.get('TopicArn'), token=sns_message.get('Token')))
|
||||
"Anymail received an unexpected SubscriptionConfirmation request for "
|
||||
"Amazon SNS topic '{topic_arn!s}'. (Anymail can automatically confirm "
|
||||
"SNS subscriptions if you set a WEBHOOK_SECRET and use that in your "
|
||||
"SNS notification url. Or you can manually confirm this subscription "
|
||||
"in the SNS dashboard with token '{token!s}'.)".format(
|
||||
topic_arn=sns_message.get("TopicArn"),
|
||||
token=sns_message.get("Token"),
|
||||
)
|
||||
)
|
||||
|
||||
# WEBHOOK_SECRET *is* set, so the request's basic auth has been verified by now (in run_validators).
|
||||
# We're good to confirm...
|
||||
# WEBHOOK_SECRET *is* set, so the request's basic auth has been verified by now
|
||||
# (in run_validators). We're good to confirm...
|
||||
topic_arn = sns_message["TopicArn"]
|
||||
token = sns_message["Token"]
|
||||
|
||||
# Must confirm in TopicArn's own region (which may be different from the default)
|
||||
# Must confirm in TopicArn's own region
|
||||
# (which may be different from the default)
|
||||
try:
|
||||
(_arn_tag, _partition, _service, region, _account, _resource) = topic_arn.split(":", maxsplit=6)
|
||||
(
|
||||
_arn_tag,
|
||||
_partition,
|
||||
_service,
|
||||
region,
|
||||
_account,
|
||||
_resource,
|
||||
) = topic_arn.split(":", maxsplit=6)
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError("Invalid ARN format '{topic_arn!s}'".format(topic_arn=topic_arn))
|
||||
raise ValueError(
|
||||
"Invalid ARN format '{topic_arn!s}'".format(topic_arn=topic_arn)
|
||||
)
|
||||
client_params = self.client_params.copy()
|
||||
client_params["region_name"] = region
|
||||
|
||||
sns_client = boto3.session.Session(**self.session_params).client('sns', **client_params)
|
||||
sns_client = boto3.session.Session(**self.session_params).client(
|
||||
"sns", **client_params
|
||||
)
|
||||
sns_client.confirm_subscription(
|
||||
TopicArn=topic_arn, Token=token, AuthenticateOnUnsubscribe='true')
|
||||
TopicArn=topic_arn, Token=token, AuthenticateOnUnsubscribe="true"
|
||||
)
|
||||
|
||||
|
||||
class AmazonSESTrackingWebhookView(AmazonSESBaseWebhookView):
|
||||
@@ -153,16 +211,19 @@ class AmazonSESTrackingWebhookView(AmazonSESBaseWebhookView):
|
||||
|
||||
def esp_to_anymail_events(self, ses_event, sns_message):
|
||||
# Amazon SES has two notification formats, which are almost exactly the same:
|
||||
# - https://docs.aws.amazon.com/ses/latest/DeveloperGuide/event-publishing-retrieving-sns-contents.html
|
||||
# - https://docs.aws.amazon.com/ses/latest/DeveloperGuide/notification-contents.html
|
||||
# https://docs.aws.amazon.com/ses/latest/DeveloperGuide/event-publishing-retrieving-sns-contents.html
|
||||
# https://docs.aws.amazon.com/ses/latest/DeveloperGuide/notification-contents.html
|
||||
# This code should handle either.
|
||||
ses_event_type = getfirst(ses_event, ["eventType", "notificationType"], "<<type missing>>")
|
||||
ses_event_type = getfirst(
|
||||
ses_event, ["eventType", "notificationType"], "<<type missing>>"
|
||||
)
|
||||
if ses_event_type == "Received":
|
||||
# This is an inbound event
|
||||
raise AnymailConfigurationError(
|
||||
"You seem to have set an Amazon SES *inbound* receipt rule to publish "
|
||||
"to an SNS Topic that posts to Anymail's *tracking* webhook URL. "
|
||||
"(SNS TopicArn %s)" % sns_message.get("TopicArn"))
|
||||
"(SNS TopicArn %s)" % sns_message.get("TopicArn")
|
||||
)
|
||||
|
||||
event_id = sns_message.get("MessageId") # unique to the SNS notification
|
||||
try:
|
||||
@@ -171,7 +232,8 @@ class AmazonSESTrackingWebhookView(AmazonSESBaseWebhookView):
|
||||
timestamp = None
|
||||
|
||||
mail_object = ses_event.get("mail", {})
|
||||
message_id = mail_object.get("messageId") # same as MessageId in SendRawEmail response
|
||||
# same as MessageId in SendRawEmail response:
|
||||
message_id = mail_object.get("messageId")
|
||||
all_recipients = mail_object.get("destination", [])
|
||||
|
||||
# Recover tags and metadata from custom headers
|
||||
@@ -187,7 +249,8 @@ class AmazonSESTrackingWebhookView(AmazonSESBaseWebhookView):
|
||||
except (ValueError, TypeError, KeyError):
|
||||
pass
|
||||
|
||||
common_props = dict( # AnymailTrackingEvent props for all recipients
|
||||
# AnymailTrackingEvent props for all recipients:
|
||||
common_props = dict(
|
||||
esp_event=ses_event,
|
||||
event_id=event_id,
|
||||
message_id=message_id,
|
||||
@@ -195,12 +258,13 @@ class AmazonSESTrackingWebhookView(AmazonSESBaseWebhookView):
|
||||
tags=tags,
|
||||
timestamp=timestamp,
|
||||
)
|
||||
per_recipient_props = [ # generate individual events for each of these
|
||||
dict(recipient=email_address)
|
||||
for email_address in all_recipients
|
||||
# generate individual events for each of these:
|
||||
per_recipient_props = [
|
||||
dict(recipient=email_address) for email_address in all_recipients
|
||||
]
|
||||
|
||||
event_object = ses_event.get(ses_event_type.lower(), {}) # e.g., ses_event["bounce"]
|
||||
# event-type-specific data (e.g., ses_event["bounce"]):
|
||||
event_object = ses_event.get(ses_event_type.lower(), {})
|
||||
|
||||
if ses_event_type == "Bounce":
|
||||
common_props.update(
|
||||
@@ -208,10 +272,13 @@ class AmazonSESTrackingWebhookView(AmazonSESBaseWebhookView):
|
||||
description="{bounceType}: {bounceSubType}".format(**event_object),
|
||||
reject_reason=RejectReason.BOUNCED,
|
||||
)
|
||||
per_recipient_props = [dict(
|
||||
recipient=recipient["emailAddress"],
|
||||
mta_response=recipient.get("diagnosticCode"),
|
||||
) for recipient in event_object["bouncedRecipients"]]
|
||||
per_recipient_props = [
|
||||
dict(
|
||||
recipient=recipient["emailAddress"],
|
||||
mta_response=recipient.get("diagnosticCode"),
|
||||
)
|
||||
for recipient in event_object["bouncedRecipients"]
|
||||
]
|
||||
elif ses_event_type == "Complaint":
|
||||
common_props.update(
|
||||
event_type=EventType.COMPLAINED,
|
||||
@@ -219,17 +286,18 @@ class AmazonSESTrackingWebhookView(AmazonSESBaseWebhookView):
|
||||
reject_reason=RejectReason.SPAM,
|
||||
user_agent=event_object.get("userAgent"),
|
||||
)
|
||||
per_recipient_props = [dict(
|
||||
recipient=recipient["emailAddress"],
|
||||
) for recipient in event_object["complainedRecipients"]]
|
||||
per_recipient_props = [
|
||||
dict(recipient=recipient["emailAddress"])
|
||||
for recipient in event_object["complainedRecipients"]
|
||||
]
|
||||
elif ses_event_type == "Delivery":
|
||||
common_props.update(
|
||||
event_type=EventType.DELIVERED,
|
||||
mta_response=event_object.get("smtpResponse"),
|
||||
)
|
||||
per_recipient_props = [dict(
|
||||
recipient=recipient,
|
||||
) for recipient in event_object["recipients"]]
|
||||
per_recipient_props = [
|
||||
dict(recipient=recipient) for recipient in event_object["recipients"]
|
||||
]
|
||||
elif ses_event_type == "Send":
|
||||
common_props.update(
|
||||
event_type=EventType.SENT,
|
||||
@@ -256,7 +324,8 @@ class AmazonSESTrackingWebhookView(AmazonSESBaseWebhookView):
|
||||
click_url=event_object.get("link"),
|
||||
)
|
||||
elif ses_event_type == "Rendering Failure":
|
||||
event_object = ses_event["failure"] # rather than ses_event["rendering failure"]
|
||||
# (this type doesn't follow usual event_object naming)
|
||||
event_object = ses_event["failure"]
|
||||
common_props.update(
|
||||
event_type=EventType.FAILED,
|
||||
description=event_object["errorMessage"],
|
||||
@@ -285,8 +354,9 @@ class AmazonSESInboundWebhookView(AmazonSESBaseWebhookView):
|
||||
# This is not an inbound event
|
||||
raise AnymailConfigurationError(
|
||||
"You seem to have set an Amazon SES *sending* event or notification "
|
||||
"to publish to an SNS Topic that posts to Anymail's *inbound* webhook URL. "
|
||||
"(SNS TopicArn %s)" % sns_message.get("TopicArn"))
|
||||
"to publish to an SNS Topic that posts to Anymail's *inbound* webhook "
|
||||
"URL. (SNS TopicArn %s)" % sns_message.get("TopicArn")
|
||||
)
|
||||
|
||||
receipt_object = ses_event.get("receipt", {})
|
||||
action_object = receipt_object.get("action", {})
|
||||
@@ -301,11 +371,13 @@ class AmazonSESInboundWebhookView(AmazonSESBaseWebhookView):
|
||||
else:
|
||||
message = AnymailInboundMessage.parse_raw_mime(content)
|
||||
elif action_type == "S3":
|
||||
# download message from s3 into memory, then parse
|
||||
# (SNS has 15s limit for an http response; hope download doesn't take that long)
|
||||
# download message from s3 into memory, then parse. (SNS has 15s limit
|
||||
# for an http response; hope download doesn't take that long)
|
||||
bucket_name = action_object["bucketName"]
|
||||
object_key = action_object["objectKey"]
|
||||
s3 = boto3.session.Session(**self.session_params).client("s3", **self.client_params)
|
||||
s3 = boto3.session.Session(**self.session_params).client(
|
||||
"s3", **self.client_params
|
||||
)
|
||||
content = io.BytesIO()
|
||||
try:
|
||||
s3.download_fileobj(bucket_name, object_key, content)
|
||||
@@ -314,46 +386,62 @@ class AmazonSESInboundWebhookView(AmazonSESBaseWebhookView):
|
||||
except ClientError as err:
|
||||
# improve the botocore error message
|
||||
raise AnymailBotoClientAPIError(
|
||||
"Anymail AmazonSESInboundWebhookView couldn't download S3 object '{bucket_name}:{object_key}'"
|
||||
"Anymail AmazonSESInboundWebhookView couldn't download"
|
||||
" S3 object '{bucket_name}:{object_key}'"
|
||||
"".format(bucket_name=bucket_name, object_key=object_key),
|
||||
client_error=err) from err
|
||||
client_error=err,
|
||||
) from err
|
||||
finally:
|
||||
content.close()
|
||||
else:
|
||||
raise AnymailConfigurationError(
|
||||
"Anymail's Amazon SES inbound webhook works only with 'SNS' or 'S3' receipt rule actions, "
|
||||
"not SNS notifications for {action_type!s} actions. (SNS TopicArn {topic_arn!s})"
|
||||
"".format(action_type=action_type, topic_arn=sns_message.get("TopicArn")))
|
||||
"Anymail's Amazon SES inbound webhook works only with 'SNS' or 'S3'"
|
||||
" receipt rule actions, not SNS notifications for {action_type!s}"
|
||||
" actions. (SNS TopicArn {topic_arn!s})"
|
||||
"".format(
|
||||
action_type=action_type, topic_arn=sns_message.get("TopicArn")
|
||||
)
|
||||
)
|
||||
|
||||
message.envelope_sender = mail_object.get("source") # "the envelope MAIL FROM address"
|
||||
# "the envelope MAIL FROM address":
|
||||
message.envelope_sender = mail_object.get("source")
|
||||
try:
|
||||
# "recipients that were matched by the active receipt rule"
|
||||
message.envelope_recipient = receipt_object["recipients"][0]
|
||||
except (KeyError, TypeError, IndexError):
|
||||
pass
|
||||
spam_status = receipt_object.get("spamVerdict", {}).get("status", "").upper()
|
||||
message.spam_detected = {"PASS": False, "FAIL": True}.get(spam_status) # else None if unsure
|
||||
# spam_detected = False if no spam, True if spam, or None if unsure:
|
||||
message.spam_detected = {"PASS": False, "FAIL": True}.get(spam_status)
|
||||
|
||||
event_id = mail_object.get("messageId") # "unique ID assigned to the email by Amazon SES"
|
||||
# "unique ID assigned to the email by Amazon SES":
|
||||
event_id = mail_object.get("messageId")
|
||||
try:
|
||||
timestamp = parse_datetime(mail_object["timestamp"]) # "time at which the email was received"
|
||||
# "time at which the email was received":
|
||||
timestamp = parse_datetime(mail_object["timestamp"])
|
||||
except (KeyError, ValueError):
|
||||
timestamp = None
|
||||
|
||||
return [AnymailInboundEvent(
|
||||
event_type=EventType.INBOUND,
|
||||
event_id=event_id,
|
||||
message=message,
|
||||
timestamp=timestamp,
|
||||
esp_event=ses_event,
|
||||
)]
|
||||
return [
|
||||
AnymailInboundEvent(
|
||||
event_type=EventType.INBOUND,
|
||||
event_id=event_id,
|
||||
message=message,
|
||||
timestamp=timestamp,
|
||||
esp_event=ses_event,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
class AnymailBotoClientAPIError(AnymailAPIError, ClientError):
|
||||
"""An AnymailAPIError that is also a Boto ClientError"""
|
||||
|
||||
def __init__(self, *args, client_error):
|
||||
assert isinstance(client_error, ClientError)
|
||||
# init self as boto ClientError (which doesn't cooperatively subclass):
|
||||
super().__init__(error_response=client_error.response, operation_name=client_error.operation_name)
|
||||
super().__init__(
|
||||
error_response=client_error.response,
|
||||
operation_name=client_error.operation_name,
|
||||
)
|
||||
# emulate AnymailError init:
|
||||
self.args = args
|
||||
|
||||
@@ -7,7 +7,7 @@ from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.generic import View
|
||||
|
||||
from ..exceptions import AnymailInsecureWebhookWarning, AnymailWebhookValidationFailure
|
||||
from ..utils import get_anymail_setting, collect_all_methods, get_request_basic_auth
|
||||
from ..utils import collect_all_methods, get_anymail_setting, get_request_basic_auth
|
||||
|
||||
|
||||
# Mixin note: Django's View.__init__ doesn't cooperate with chaining,
|
||||
@@ -25,7 +25,7 @@ class AnymailCoreWebhookView(View):
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.validators = collect_all_methods(self.__class__, 'validate_request')
|
||||
self.validators = collect_all_methods(self.__class__, "validate_request")
|
||||
|
||||
# Subclass implementation:
|
||||
|
||||
@@ -99,8 +99,10 @@ class AnymailCoreWebhookView(View):
|
||||
esp_name = "Postmark"
|
||||
esp_name = "SendGrid" # (use ESP's preferred capitalization)
|
||||
"""
|
||||
raise NotImplementedError("%s.%s must declare esp_name class attr" %
|
||||
(self.__class__.__module__, self.__class__.__name__))
|
||||
raise NotImplementedError(
|
||||
"%s.%s must declare esp_name class attr"
|
||||
% (self.__class__.__module__, self.__class__.__name__)
|
||||
)
|
||||
|
||||
|
||||
class AnymailBasicAuthMixin(AnymailCoreWebhookView):
|
||||
@@ -113,11 +115,16 @@ class AnymailBasicAuthMixin(AnymailCoreWebhookView):
|
||||
warn_if_no_basic_auth = True
|
||||
|
||||
# List of allowable HTTP basic-auth 'user:pass' strings.
|
||||
basic_auth = None # (Declaring class attr allows override by kwargs in View.as_view.)
|
||||
# (Declaring class attr allows override by kwargs in View.as_view.):
|
||||
basic_auth = None
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self.basic_auth = get_anymail_setting('webhook_secret', default=[],
|
||||
kwargs=kwargs) # no esp_name -- auth is shared between ESPs
|
||||
self.basic_auth = get_anymail_setting(
|
||||
"webhook_secret",
|
||||
default=[],
|
||||
# no esp_name -- auth is shared between ESPs
|
||||
kwargs=kwargs,
|
||||
)
|
||||
|
||||
# Allow a single string:
|
||||
if isinstance(self.basic_auth, str):
|
||||
@@ -127,25 +134,31 @@ class AnymailBasicAuthMixin(AnymailCoreWebhookView):
|
||||
"Your Anymail webhooks are insecure and open to anyone on the web. "
|
||||
"You should set WEBHOOK_SECRET in your ANYMAIL settings. "
|
||||
"See 'Securing webhooks' in the Anymail docs.",
|
||||
AnymailInsecureWebhookWarning)
|
||||
AnymailInsecureWebhookWarning,
|
||||
)
|
||||
super().__init__(**kwargs)
|
||||
|
||||
def validate_request(self, request):
|
||||
"""If configured for webhook basic auth, validate request has correct auth."""
|
||||
if self.basic_auth:
|
||||
request_auth = get_request_basic_auth(request)
|
||||
# Use constant_time_compare to avoid timing attack on basic auth. (It's OK that any()
|
||||
# can terminate early: we're not trying to protect how many auth strings are allowed,
|
||||
# just the contents of each individual auth string.)
|
||||
auth_ok = any(constant_time_compare(request_auth, allowed_auth)
|
||||
for allowed_auth in self.basic_auth)
|
||||
# Use constant_time_compare to avoid timing attack on basic auth. (It's OK
|
||||
# that any() can terminate early: we're not trying to protect how many auth
|
||||
# strings are allowed, just the contents of each individual auth string.)
|
||||
auth_ok = any(
|
||||
constant_time_compare(request_auth, allowed_auth)
|
||||
for allowed_auth in self.basic_auth
|
||||
)
|
||||
if not auth_ok:
|
||||
raise AnymailWebhookValidationFailure(
|
||||
"Missing or invalid basic auth in Anymail %s webhook" % self.esp_name)
|
||||
"Missing or invalid basic auth in Anymail %s webhook"
|
||||
% self.esp_name
|
||||
)
|
||||
|
||||
|
||||
class AnymailBaseWebhookView(AnymailBasicAuthMixin, AnymailCoreWebhookView):
|
||||
"""
|
||||
Abstract base class for most webhook views, enforcing HTTP basic auth security
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
@@ -1,15 +1,32 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
from django.utils.crypto import constant_time_compare
|
||||
|
||||
from .base import AnymailBaseWebhookView
|
||||
from ..exceptions import AnymailConfigurationError, AnymailWebhookValidationFailure, AnymailInvalidAddress
|
||||
from ..exceptions import (
|
||||
AnymailConfigurationError,
|
||||
AnymailInvalidAddress,
|
||||
AnymailWebhookValidationFailure,
|
||||
)
|
||||
from ..inbound import AnymailInboundMessage
|
||||
from ..signals import inbound, tracking, AnymailInboundEvent, AnymailTrackingEvent, EventType, RejectReason
|
||||
from ..utils import get_anymail_setting, combine, querydict_getfirst, parse_single_address, UNSET
|
||||
from ..signals import (
|
||||
AnymailInboundEvent,
|
||||
AnymailTrackingEvent,
|
||||
EventType,
|
||||
RejectReason,
|
||||
inbound,
|
||||
tracking,
|
||||
)
|
||||
from ..utils import (
|
||||
UNSET,
|
||||
combine,
|
||||
get_anymail_setting,
|
||||
parse_single_address,
|
||||
querydict_getfirst,
|
||||
)
|
||||
from .base import AnymailBaseWebhookView
|
||||
|
||||
|
||||
class MailgunBaseWebhookView(AnymailBaseWebhookView):
|
||||
@@ -18,18 +35,30 @@ class MailgunBaseWebhookView(AnymailBaseWebhookView):
|
||||
esp_name = "Mailgun"
|
||||
warn_if_no_basic_auth = False # because we validate against signature
|
||||
|
||||
webhook_signing_key = None # (Declaring class attr allows override by kwargs in View.as_view.)
|
||||
# (Declaring class attr allows override by kwargs in View.as_view.)
|
||||
webhook_signing_key = None
|
||||
|
||||
# The `api_key` attribute name is still allowed for compatibility with earlier Anymail releases.
|
||||
# The `api_key` attribute name is still allowed for compatibility
|
||||
# with earlier Anymail releases.
|
||||
api_key = None # (Declaring class attr allows override by kwargs in View.as_view.)
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
# webhook_signing_key: falls back to api_key if webhook_signing_key not provided
|
||||
api_key = get_anymail_setting('api_key', esp_name=self.esp_name,
|
||||
kwargs=kwargs, allow_bare=True, default=None)
|
||||
webhook_signing_key = get_anymail_setting('webhook_signing_key', esp_name=self.esp_name,
|
||||
kwargs=kwargs, default=UNSET if api_key is None else api_key)
|
||||
self.webhook_signing_key = webhook_signing_key.encode('ascii') # hmac.new requires bytes key
|
||||
api_key = get_anymail_setting(
|
||||
"api_key",
|
||||
esp_name=self.esp_name,
|
||||
kwargs=kwargs,
|
||||
allow_bare=True,
|
||||
default=None,
|
||||
)
|
||||
webhook_signing_key = get_anymail_setting(
|
||||
"webhook_signing_key",
|
||||
esp_name=self.esp_name,
|
||||
kwargs=kwargs,
|
||||
default=UNSET if api_key is None else api_key,
|
||||
)
|
||||
# hmac.new requires bytes key:
|
||||
self.webhook_signing_key = webhook_signing_key.encode("ascii")
|
||||
super().__init__(**kwargs)
|
||||
|
||||
def validate_request(self, request):
|
||||
@@ -37,30 +66,38 @@ class MailgunBaseWebhookView(AnymailBaseWebhookView):
|
||||
if request.content_type == "application/json":
|
||||
# New-style webhook: json payload with separate signature block
|
||||
try:
|
||||
event = json.loads(request.body.decode('utf-8'))
|
||||
signature_block = event['signature']
|
||||
token = signature_block['token']
|
||||
timestamp = signature_block['timestamp']
|
||||
signature = signature_block['signature']
|
||||
event = json.loads(request.body.decode("utf-8"))
|
||||
signature_block = event["signature"]
|
||||
token = signature_block["token"]
|
||||
timestamp = signature_block["timestamp"]
|
||||
signature = signature_block["signature"]
|
||||
except (KeyError, ValueError, UnicodeDecodeError) as err:
|
||||
raise AnymailWebhookValidationFailure(
|
||||
"Mailgun webhook called with invalid payload format") from err
|
||||
"Mailgun webhook called with invalid payload format"
|
||||
) from err
|
||||
else:
|
||||
# Legacy webhook: signature fields are interspersed with other POST data
|
||||
try:
|
||||
# Must use the *last* value of these fields if there are conflicting merged user-variables.
|
||||
# (Fortunately, Django QueryDict is specced to return the last value.)
|
||||
token = request.POST['token']
|
||||
timestamp = request.POST['timestamp']
|
||||
signature = request.POST['signature']
|
||||
# Must use the *last* value of these fields if there are conflicting
|
||||
# merged user-variables. (Fortunately, Django QueryDict is specced to
|
||||
# return the last value.)
|
||||
token = request.POST["token"]
|
||||
timestamp = request.POST["timestamp"]
|
||||
signature = request.POST["signature"]
|
||||
except KeyError as err:
|
||||
raise AnymailWebhookValidationFailure(
|
||||
"Mailgun webhook called without required security fields") from err
|
||||
"Mailgun webhook called without required security fields"
|
||||
) from err
|
||||
|
||||
expected_signature = hmac.new(key=self.webhook_signing_key, msg='{}{}'.format(timestamp, token).encode('ascii'),
|
||||
digestmod=hashlib.sha256).hexdigest()
|
||||
expected_signature = hmac.new(
|
||||
key=self.webhook_signing_key,
|
||||
msg="{}{}".format(timestamp, token).encode("ascii"),
|
||||
digestmod=hashlib.sha256,
|
||||
).hexdigest()
|
||||
if not constant_time_compare(signature, expected_signature):
|
||||
raise AnymailWebhookValidationFailure("Mailgun webhook called with incorrect signature")
|
||||
raise AnymailWebhookValidationFailure(
|
||||
"Mailgun webhook called with incorrect signature"
|
||||
)
|
||||
|
||||
|
||||
class MailgunTrackingWebhookView(MailgunBaseWebhookView):
|
||||
@@ -70,75 +107,82 @@ class MailgunTrackingWebhookView(MailgunBaseWebhookView):
|
||||
|
||||
def parse_events(self, request):
|
||||
if request.content_type == "application/json":
|
||||
esp_event = json.loads(request.body.decode('utf-8'))
|
||||
esp_event = json.loads(request.body.decode("utf-8"))
|
||||
return [self.esp_to_anymail_event(esp_event)]
|
||||
else:
|
||||
return [self.mailgun_legacy_to_anymail_event(request.POST)]
|
||||
|
||||
event_types = {
|
||||
# Map Mailgun event: Anymail normalized type
|
||||
'accepted': EventType.QUEUED, # not delivered to webhooks (8/2018)
|
||||
'rejected': EventType.REJECTED,
|
||||
'delivered': EventType.DELIVERED,
|
||||
'failed': EventType.BOUNCED,
|
||||
'opened': EventType.OPENED,
|
||||
'clicked': EventType.CLICKED,
|
||||
'unsubscribed': EventType.UNSUBSCRIBED,
|
||||
'complained': EventType.COMPLAINED,
|
||||
"accepted": EventType.QUEUED, # not delivered to webhooks (8/2018)
|
||||
"rejected": EventType.REJECTED,
|
||||
"delivered": EventType.DELIVERED,
|
||||
"failed": EventType.BOUNCED,
|
||||
"opened": EventType.OPENED,
|
||||
"clicked": EventType.CLICKED,
|
||||
"unsubscribed": EventType.UNSUBSCRIBED,
|
||||
"complained": EventType.COMPLAINED,
|
||||
}
|
||||
|
||||
reject_reasons = {
|
||||
# Map Mailgun event_data.reason: Anymail normalized RejectReason
|
||||
# (these appear in webhook doc examples, but aren't actually documented anywhere)
|
||||
# Map Mailgun event_data.reason: Anymail normalized RejectReason (these appear
|
||||
# in webhook doc examples, but aren't actually documented anywhere)
|
||||
"bounce": RejectReason.BOUNCED,
|
||||
"suppress-bounce": RejectReason.BOUNCED,
|
||||
"generic": RejectReason.OTHER, # ??? appears to be used for any temporary failure?
|
||||
# ??? "generic" appears to be used for any temporary failure?
|
||||
"generic": RejectReason.OTHER,
|
||||
}
|
||||
|
||||
severities = {
|
||||
# Remap some event types based on "severity" payload field
|
||||
(EventType.BOUNCED, 'temporary'): EventType.DEFERRED
|
||||
(EventType.BOUNCED, "temporary"): EventType.DEFERRED
|
||||
}
|
||||
|
||||
def esp_to_anymail_event(self, esp_event):
|
||||
event_data = esp_event.get('event-data', {})
|
||||
event_data = esp_event.get("event-data", {})
|
||||
|
||||
event_type = self.event_types.get(event_data['event'], EventType.UNKNOWN)
|
||||
event_type = self.event_types.get(event_data["event"], EventType.UNKNOWN)
|
||||
|
||||
event_type = self.severities.get((EventType.BOUNCED, event_data.get('severity')), event_type)
|
||||
event_type = self.severities.get(
|
||||
(EventType.BOUNCED, event_data.get("severity")), event_type
|
||||
)
|
||||
|
||||
# Use signature.token for event_id, rather than event_data.id,
|
||||
# because the latter is only "guaranteed to be unique within a day".
|
||||
event_id = esp_event.get('signature', {}).get('token')
|
||||
event_id = esp_event.get("signature", {}).get("token")
|
||||
|
||||
recipient = event_data.get('recipient')
|
||||
recipient = event_data.get("recipient")
|
||||
|
||||
try:
|
||||
timestamp = datetime.fromtimestamp(float(event_data['timestamp']), tz=timezone.utc)
|
||||
timestamp = datetime.fromtimestamp(
|
||||
float(event_data["timestamp"]), tz=timezone.utc
|
||||
)
|
||||
except KeyError:
|
||||
timestamp = None
|
||||
|
||||
try:
|
||||
message_id = event_data['message']['headers']['message-id']
|
||||
message_id = event_data["message"]["headers"]["message-id"]
|
||||
except KeyError:
|
||||
message_id = None
|
||||
if message_id and not message_id.startswith('<'):
|
||||
if message_id and not message_id.startswith("<"):
|
||||
message_id = "<{}>".format(message_id)
|
||||
|
||||
metadata = event_data.get('user-variables', {})
|
||||
tags = event_data.get('tags', [])
|
||||
metadata = event_data.get("user-variables", {})
|
||||
tags = event_data.get("tags", [])
|
||||
|
||||
try:
|
||||
delivery_status = event_data['delivery-status']
|
||||
delivery_status = event_data["delivery-status"]
|
||||
except KeyError:
|
||||
description = None
|
||||
mta_response = None
|
||||
else:
|
||||
description = delivery_status.get('description')
|
||||
mta_response = delivery_status.get('message')
|
||||
description = delivery_status.get("description")
|
||||
mta_response = delivery_status.get("message")
|
||||
|
||||
if 'reason' in event_data:
|
||||
reject_reason = self.reject_reasons.get(event_data['reason'], RejectReason.OTHER)
|
||||
if "reason" in event_data:
|
||||
reject_reason = self.reject_reasons.get(
|
||||
event_data["reason"], RejectReason.OTHER
|
||||
)
|
||||
else:
|
||||
reject_reason = None
|
||||
|
||||
@@ -149,7 +193,8 @@ class MailgunTrackingWebhookView(MailgunBaseWebhookView):
|
||||
if not recipient:
|
||||
try:
|
||||
to_email = parse_single_address(
|
||||
event_data["message"]["headers"]["to"])
|
||||
event_data["message"]["headers"]["to"]
|
||||
)
|
||||
except (AnymailInvalidAddress, KeyError):
|
||||
pass
|
||||
else:
|
||||
@@ -166,8 +211,8 @@ class MailgunTrackingWebhookView(MailgunBaseWebhookView):
|
||||
mta_response=mta_response,
|
||||
tags=tags,
|
||||
metadata=metadata,
|
||||
click_url=event_data.get('url'),
|
||||
user_agent=event_data.get('client-info', {}).get('user-agent'),
|
||||
click_url=event_data.get("url"),
|
||||
user_agent=event_data.get("client-info", {}).get("user-agent"),
|
||||
esp_event=esp_event,
|
||||
)
|
||||
|
||||
@@ -176,13 +221,13 @@ class MailgunTrackingWebhookView(MailgunBaseWebhookView):
|
||||
|
||||
legacy_event_types = {
|
||||
# Map Mailgun event: Anymail normalized type
|
||||
'delivered': EventType.DELIVERED,
|
||||
'dropped': EventType.REJECTED,
|
||||
'bounced': EventType.BOUNCED,
|
||||
'complained': EventType.COMPLAINED,
|
||||
'unsubscribed': EventType.UNSUBSCRIBED,
|
||||
'opened': EventType.OPENED,
|
||||
'clicked': EventType.CLICKED,
|
||||
"delivered": EventType.DELIVERED,
|
||||
"dropped": EventType.REJECTED,
|
||||
"bounced": EventType.BOUNCED,
|
||||
"complained": EventType.COMPLAINED,
|
||||
"unsubscribed": EventType.UNSUBSCRIBED,
|
||||
"opened": EventType.OPENED,
|
||||
"clicked": EventType.CLICKED,
|
||||
# Mailgun does not send events corresponding to QUEUED or DEFERRED
|
||||
}
|
||||
|
||||
@@ -190,7 +235,8 @@ class MailgunTrackingWebhookView(MailgunBaseWebhookView):
|
||||
# Map Mailgun (SMTP) error codes to Anymail normalized reject_reason.
|
||||
# By default, we will treat anything 400-599 as REJECT_BOUNCED
|
||||
# so only exceptions are listed here.
|
||||
499: RejectReason.TIMED_OUT, # unable to connect to MX (also covers invalid recipients)
|
||||
499: RejectReason.TIMED_OUT, # unable to connect to MX
|
||||
# (499 also covers invalid recipients)
|
||||
# These 6xx codes appear to be Mailgun extensions to SMTP
|
||||
# (and don't seem to be documented anywhere):
|
||||
605: RejectReason.BOUNCED, # previous bounce
|
||||
@@ -205,123 +251,163 @@ class MailgunTrackingWebhookView(MailgunBaseWebhookView):
|
||||
# to avoid potential conflicting user-data.
|
||||
esp_event.getfirst = querydict_getfirst.__get__(esp_event)
|
||||
|
||||
if 'event' not in esp_event and 'sender' in esp_event:
|
||||
if "event" not in esp_event and "sender" in esp_event:
|
||||
# Inbound events don't (currently) have an event field
|
||||
raise AnymailConfigurationError(
|
||||
"You seem to have set Mailgun's *inbound* route "
|
||||
"to Anymail's Mailgun *tracking* webhook URL.")
|
||||
"to Anymail's Mailgun *tracking* webhook URL."
|
||||
)
|
||||
|
||||
event_type = self.legacy_event_types.get(esp_event.getfirst('event'), EventType.UNKNOWN)
|
||||
timestamp = datetime.fromtimestamp(
|
||||
int(esp_event['timestamp']), tz=timezone.utc) # use *last* value of timestamp
|
||||
event_type = self.legacy_event_types.get(
|
||||
esp_event.getfirst("event"), EventType.UNKNOWN
|
||||
)
|
||||
# use *last* value of timestamp:
|
||||
timestamp = datetime.fromtimestamp(int(esp_event["timestamp"]), tz=timezone.utc)
|
||||
# Message-Id is not documented for every event, but seems to always be included.
|
||||
# (It's sometimes spelled as 'message-id', lowercase, and missing the <angle-brackets>.)
|
||||
message_id = esp_event.getfirst('Message-Id', None) or esp_event.getfirst('message-id', None)
|
||||
if message_id and not message_id.startswith('<'):
|
||||
# (It's sometimes spelled as 'message-id', lowercase, and missing the
|
||||
# <angle-brackets>.)
|
||||
message_id = esp_event.getfirst("Message-Id", None) or esp_event.getfirst(
|
||||
"message-id", None
|
||||
)
|
||||
if message_id and not message_id.startswith("<"):
|
||||
message_id = "<{}>".format(message_id)
|
||||
|
||||
description = esp_event.getfirst('description', None)
|
||||
mta_response = esp_event.getfirst('error', None) or esp_event.getfirst('notification', None)
|
||||
description = esp_event.getfirst("description", None)
|
||||
mta_response = esp_event.getfirst("error", None) or esp_event.getfirst(
|
||||
"notification", None
|
||||
)
|
||||
reject_reason = None
|
||||
try:
|
||||
mta_status = int(esp_event.getfirst('code'))
|
||||
mta_status = int(esp_event.getfirst("code"))
|
||||
except (KeyError, TypeError):
|
||||
pass
|
||||
except ValueError:
|
||||
# RFC-3463 extended SMTP status code (class.subject.detail, where class is "2", "4" or "5")
|
||||
# RFC-3463 extended SMTP status code
|
||||
# (class.subject.detail, where class is "2", "4" or "5")
|
||||
try:
|
||||
status_class = esp_event.getfirst('code').split('.')[0]
|
||||
status_class = esp_event.getfirst("code").split(".")[0]
|
||||
except (TypeError, IndexError):
|
||||
# illegal SMTP status code format
|
||||
pass
|
||||
else:
|
||||
reject_reason = RejectReason.BOUNCED if status_class in ("4", "5") else RejectReason.OTHER
|
||||
reject_reason = (
|
||||
RejectReason.BOUNCED
|
||||
if status_class in ("4", "5")
|
||||
else RejectReason.OTHER
|
||||
)
|
||||
else:
|
||||
reject_reason = self.legacy_reject_reasons.get(
|
||||
mta_status,
|
||||
RejectReason.BOUNCED if 400 <= mta_status < 600
|
||||
else RejectReason.OTHER)
|
||||
RejectReason.BOUNCED if 400 <= mta_status < 600 else RejectReason.OTHER,
|
||||
)
|
||||
|
||||
metadata = self._extract_legacy_metadata(esp_event)
|
||||
|
||||
# tags are supposed to be in 'tag' fields, but are sometimes in undocumented X-Mailgun-Tag
|
||||
tags = esp_event.getlist('tag', None) or esp_event.getlist('X-Mailgun-Tag', [])
|
||||
# tags are supposed to be in 'tag' fields,
|
||||
# but are sometimes in undocumented X-Mailgun-Tag
|
||||
tags = esp_event.getlist("tag", None) or esp_event.getlist("X-Mailgun-Tag", [])
|
||||
|
||||
return AnymailTrackingEvent(
|
||||
event_type=event_type,
|
||||
timestamp=timestamp,
|
||||
message_id=message_id,
|
||||
event_id=esp_event.get('token', None), # use *last* value of token
|
||||
recipient=esp_event.getfirst('recipient', None),
|
||||
event_id=esp_event.get("token", None), # use *last* value of token
|
||||
recipient=esp_event.getfirst("recipient", None),
|
||||
reject_reason=reject_reason,
|
||||
description=description,
|
||||
mta_response=mta_response,
|
||||
tags=tags,
|
||||
metadata=metadata,
|
||||
click_url=esp_event.getfirst('url', None),
|
||||
user_agent=esp_event.getfirst('user-agent', None),
|
||||
click_url=esp_event.getfirst("url", None),
|
||||
user_agent=esp_event.getfirst("user-agent", None),
|
||||
esp_event=esp_event,
|
||||
)
|
||||
|
||||
def _extract_legacy_metadata(self, esp_event):
|
||||
# Mailgun merges user-variables into the POST fields. If you know which user variable
|
||||
# you want to retrieve--and it doesn't conflict with a Mailgun event field--that's fine.
|
||||
# But if you want to extract all user-variables (like we do), it's more complicated...
|
||||
event_type = esp_event.getfirst('event')
|
||||
# Mailgun merges user-variables into the POST fields. If you know which user
|
||||
# variable you want to retrieve--and it doesn't conflict with a Mailgun event
|
||||
# field--that's fine. But if you want to extract all user-variables (like we
|
||||
# do), it's more complicated...
|
||||
event_type = esp_event.getfirst("event")
|
||||
metadata = {}
|
||||
|
||||
if 'message-headers' in esp_event:
|
||||
# For events where original message headers are available, it's most reliable
|
||||
# to recover user-variables from the X-Mailgun-Variables header(s).
|
||||
headers = json.loads(esp_event['message-headers'])
|
||||
variables = [value for [field, value] in headers if field == 'X-Mailgun-Variables']
|
||||
if "message-headers" in esp_event:
|
||||
# For events where original message headers are available, it's most
|
||||
# reliable to recover user-variables from the X-Mailgun-Variables header(s).
|
||||
headers = json.loads(esp_event["message-headers"])
|
||||
variables = [
|
||||
value for [field, value] in headers if field == "X-Mailgun-Variables"
|
||||
]
|
||||
if len(variables) >= 1:
|
||||
# Each X-Mailgun-Variables value is JSON. Parse and merge them all into single dict:
|
||||
# Each X-Mailgun-Variables value is JSON. Parse and merge them all into
|
||||
# single dict:
|
||||
metadata = combine(*[json.loads(value) for value in variables])
|
||||
|
||||
elif event_type in self._known_legacy_event_fields:
|
||||
# For other events, we must extract from the POST fields, ignoring known Mailgun
|
||||
# event parameters, and treating all other values as user-variables.
|
||||
# For other events, we must extract from the POST fields, ignoring known
|
||||
# Mailgun event parameters, and treating all other values as user-variables.
|
||||
known_fields = self._known_legacy_event_fields[event_type]
|
||||
for field, values in esp_event.lists():
|
||||
if field not in known_fields:
|
||||
# Unknown fields are assumed to be user-variables. (There should really only be
|
||||
# a single value, but just in case take the last one to match QueryDict semantics.)
|
||||
# Unknown fields are assumed to be user-variables. (There should
|
||||
# really only be a single value, but just in case take the last one
|
||||
# to match QueryDict semantics.)
|
||||
metadata[field] = values[-1]
|
||||
elif field == 'tag':
|
||||
# There's no way to distinguish a user-variable named 'tag' from an actual tag,
|
||||
# so don't treat this/these value(s) as metadata.
|
||||
elif field == "tag":
|
||||
# There's no way to distinguish a user-variable named 'tag' from
|
||||
# an actual tag, so don't treat this/these value(s) as metadata.
|
||||
pass
|
||||
elif len(values) == 1:
|
||||
# This is an expected event parameter, and since there's only a single value
|
||||
# it must be the event param, not metadata.
|
||||
# This is an expected event parameter, and since there's only a
|
||||
# single value it must be the event param, not metadata.
|
||||
pass
|
||||
else:
|
||||
# This is an expected event parameter, but there are (at least) two values.
|
||||
# One is the event param, and the other is a user-variable metadata value.
|
||||
# Which is which depends on the field:
|
||||
if field in {'signature', 'timestamp', 'token'}:
|
||||
metadata[field] = values[0] # values = [user-variable, event-param]
|
||||
# This is an expected event parameter, but there are (at least) two
|
||||
# values. One is the event param, and the other is a user-variable
|
||||
# metadata value. Which is which depends on the field:
|
||||
if field in {"signature", "timestamp", "token"}:
|
||||
# values = [user-variable, event-param]
|
||||
metadata[field] = values[0]
|
||||
else:
|
||||
metadata[field] = values[-1] # values = [event-param, user-variable]
|
||||
# values = [event-param, user-variable]
|
||||
metadata[field] = values[-1]
|
||||
|
||||
return metadata
|
||||
|
||||
_common_legacy_event_fields = {
|
||||
# These fields are documented to appear in all Mailgun opened, clicked and unsubscribed events:
|
||||
'event', 'recipient', 'domain', 'ip', 'country', 'region', 'city', 'user-agent', 'device-type',
|
||||
'client-type', 'client-name', 'client-os', 'campaign-id', 'campaign-name', 'tag', 'mailing-list',
|
||||
'timestamp', 'token', 'signature',
|
||||
# These fields are documented to appear in all Mailgun
|
||||
# opened, clicked and unsubscribed events:
|
||||
"event",
|
||||
"recipient",
|
||||
"domain",
|
||||
"ip",
|
||||
"country",
|
||||
"region",
|
||||
"city",
|
||||
"user-agent",
|
||||
"device-type",
|
||||
"client-type",
|
||||
"client-name",
|
||||
"client-os",
|
||||
"campaign-id",
|
||||
"campaign-name",
|
||||
"tag",
|
||||
"mailing-list",
|
||||
"timestamp",
|
||||
"token",
|
||||
"signature",
|
||||
# Undocumented, but observed in actual events:
|
||||
'body-plain', 'h', 'message-id',
|
||||
"body-plain",
|
||||
"h",
|
||||
"message-id",
|
||||
}
|
||||
_known_legacy_event_fields = {
|
||||
# For all Mailgun event types that *don't* include message-headers,
|
||||
# map Mailgun (not normalized) event type to set of expected event fields.
|
||||
# Used for metadata extraction.
|
||||
'clicked': _common_legacy_event_fields | {'url'},
|
||||
'opened': _common_legacy_event_fields,
|
||||
'unsubscribed': _common_legacy_event_fields,
|
||||
"clicked": _common_legacy_event_fields | {"url"},
|
||||
"opened": _common_legacy_event_fields,
|
||||
"unsubscribed": _common_legacy_event_fields,
|
||||
}
|
||||
|
||||
|
||||
@@ -332,57 +418,63 @@ class MailgunInboundWebhookView(MailgunBaseWebhookView):
|
||||
|
||||
def parse_events(self, request):
|
||||
if request.content_type == "application/json":
|
||||
esp_event = json.loads(request.body.decode('utf-8'))
|
||||
event_type = esp_event.get('event-data', {}).get('event', '')
|
||||
esp_event = json.loads(request.body.decode("utf-8"))
|
||||
event_type = esp_event.get("event-data", {}).get("event", "")
|
||||
raise AnymailConfigurationError(
|
||||
"You seem to have set Mailgun's *%s tracking* webhook "
|
||||
"to Anymail's Mailgun *inbound* webhook URL. "
|
||||
"(Or Mailgun has changed inbound events to use json.)"
|
||||
% event_type)
|
||||
"(Or Mailgun has changed inbound events to use json.)" % event_type
|
||||
)
|
||||
return [self.esp_to_anymail_event(request)]
|
||||
|
||||
def esp_to_anymail_event(self, request):
|
||||
# Inbound uses the entire Django request as esp_event, because we need POST and FILES.
|
||||
# Note that request.POST is case-sensitive (unlike email.message.Message headers).
|
||||
# Inbound uses the entire Django request as esp_event, because
|
||||
# we need POST and FILES. Note that request.POST is case-sensitive
|
||||
# (unlike email.message.Message headers).
|
||||
esp_event = request
|
||||
|
||||
if request.POST.get('event', 'inbound') != 'inbound':
|
||||
if request.POST.get("event", "inbound") != "inbound":
|
||||
# (Legacy) tracking event
|
||||
raise AnymailConfigurationError(
|
||||
"You seem to have set Mailgun's *%s tracking* webhook "
|
||||
"to Anymail's Mailgun *inbound* webhook URL." % request.POST['event'])
|
||||
"to Anymail's Mailgun *inbound* webhook URL." % request.POST["event"]
|
||||
)
|
||||
|
||||
if 'attachments' in request.POST:
|
||||
if "attachments" in request.POST:
|
||||
# Inbound route used store() rather than forward().
|
||||
# ("attachments" seems to be the only POST param that differs between
|
||||
# store and forward; Anymail could support store by handling the JSON
|
||||
# attachments param in message_from_mailgun_parsed.)
|
||||
raise AnymailConfigurationError(
|
||||
"You seem to have configured Mailgun's receiving route using the store()"
|
||||
" action. Anymail's inbound webhook requires the forward() action.")
|
||||
"You seem to have configured Mailgun's receiving route using"
|
||||
" the store() action. Anymail's inbound webhook requires"
|
||||
" the forward() action."
|
||||
)
|
||||
|
||||
if 'body-mime' in request.POST:
|
||||
if "body-mime" in request.POST:
|
||||
# Raw-MIME
|
||||
message = AnymailInboundMessage.parse_raw_mime(request.POST['body-mime'])
|
||||
message = AnymailInboundMessage.parse_raw_mime(request.POST["body-mime"])
|
||||
else:
|
||||
# Fully-parsed
|
||||
message = self.message_from_mailgun_parsed(request)
|
||||
|
||||
message.envelope_sender = request.POST.get('sender', None)
|
||||
message.envelope_recipient = request.POST.get('recipient', None)
|
||||
message.stripped_text = request.POST.get('stripped-text', None)
|
||||
message.stripped_html = request.POST.get('stripped-html', None)
|
||||
message.envelope_sender = request.POST.get("sender", None)
|
||||
message.envelope_recipient = request.POST.get("recipient", None)
|
||||
message.stripped_text = request.POST.get("stripped-text", None)
|
||||
message.stripped_html = request.POST.get("stripped-html", None)
|
||||
|
||||
message.spam_detected = message.get('X-Mailgun-Sflag', 'No').lower() == 'yes'
|
||||
message.spam_detected = message.get("X-Mailgun-Sflag", "No").lower() == "yes"
|
||||
try:
|
||||
message.spam_score = float(message['X-Mailgun-Sscore'])
|
||||
message.spam_score = float(message["X-Mailgun-Sscore"])
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
return AnymailInboundEvent(
|
||||
event_type=EventType.INBOUND,
|
||||
timestamp=datetime.fromtimestamp(int(request.POST['timestamp']), tz=timezone.utc),
|
||||
event_id=request.POST.get('token', None),
|
||||
timestamp=datetime.fromtimestamp(
|
||||
int(request.POST["timestamp"]), tz=timezone.utc
|
||||
),
|
||||
event_id=request.POST.get("token", None),
|
||||
esp_event=esp_event,
|
||||
message=message,
|
||||
)
|
||||
@@ -391,35 +483,44 @@ class MailgunInboundWebhookView(MailgunBaseWebhookView):
|
||||
"""Construct a Message from Mailgun's "fully-parsed" fields"""
|
||||
# Mailgun transcodes all fields to UTF-8 for "fully parsed" messages
|
||||
try:
|
||||
attachment_count = int(request.POST['attachment-count'])
|
||||
attachment_count = int(request.POST["attachment-count"])
|
||||
except (KeyError, TypeError):
|
||||
attachments = None
|
||||
else:
|
||||
# Load attachments from posted files: attachment-1, attachment-2, etc.
|
||||
# content-id-map is {content-id: attachment-id}, identifying which files are inline attachments.
|
||||
# Invert it to {attachment-id: content-id}, while handling potentially duplicate content-ids.
|
||||
# content-id-map is {content-id: attachment-id}, identifying which files
|
||||
# are inline attachments. Invert it to {attachment-id: content-id}, while
|
||||
# handling potentially duplicate content-ids.
|
||||
field_to_content_id = json.loads(
|
||||
request.POST.get('content-id-map', '{}'),
|
||||
object_pairs_hook=lambda pairs: {att_id: cid for (cid, att_id) in pairs})
|
||||
request.POST.get("content-id-map", "{}"),
|
||||
object_pairs_hook=lambda pairs: {
|
||||
att_id: cid for (cid, att_id) in pairs
|
||||
},
|
||||
)
|
||||
attachments = []
|
||||
for n in range(1, attachment_count+1):
|
||||
for n in range(1, attachment_count + 1):
|
||||
attachment_id = "attachment-%d" % n
|
||||
try:
|
||||
file = request.FILES[attachment_id]
|
||||
except KeyError:
|
||||
# Django's multipart/form-data handling drops FILES with certain
|
||||
# filenames (for security) or with empty filenames (Django ticket 15879).
|
||||
# filenames (for security) or with empty filenames (Django ticket
|
||||
# 15879).
|
||||
# (To avoid this problem, use Mailgun's "raw MIME" inbound option.)
|
||||
pass
|
||||
else:
|
||||
content_id = field_to_content_id.get(attachment_id)
|
||||
attachment = AnymailInboundMessage.construct_attachment_from_uploaded_file(
|
||||
file, content_id=content_id)
|
||||
attachment = (
|
||||
AnymailInboundMessage.construct_attachment_from_uploaded_file(
|
||||
file, content_id=content_id
|
||||
)
|
||||
)
|
||||
attachments.append(attachment)
|
||||
|
||||
return AnymailInboundMessage.construct(
|
||||
headers=json.loads(request.POST['message-headers']), # includes From, To, Cc, Subject, etc.
|
||||
text=request.POST.get('body-plain', None),
|
||||
html=request.POST.get('body-html', None),
|
||||
# message-headers includes From, To, Cc, Subject, etc.
|
||||
headers=json.loads(request.POST["message-headers"]),
|
||||
text=request.POST.get("body-plain", None),
|
||||
html=request.POST.get("body-html", None),
|
||||
attachments=attachments,
|
||||
)
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
from .base import AnymailBaseWebhookView
|
||||
from ..inbound import AnymailInboundMessage
|
||||
from ..signals import inbound, tracking, AnymailInboundEvent, AnymailTrackingEvent, EventType, RejectReason
|
||||
from ..signals import (
|
||||
AnymailInboundEvent,
|
||||
AnymailTrackingEvent,
|
||||
EventType,
|
||||
RejectReason,
|
||||
inbound,
|
||||
tracking,
|
||||
)
|
||||
from .base import AnymailBaseWebhookView
|
||||
|
||||
|
||||
class MailjetTrackingWebhookView(AnymailBaseWebhookView):
|
||||
@@ -14,7 +20,7 @@ class MailjetTrackingWebhookView(AnymailBaseWebhookView):
|
||||
signal = tracking
|
||||
|
||||
def parse_events(self, request):
|
||||
esp_events = json.loads(request.body.decode('utf-8'))
|
||||
esp_events = json.loads(request.body.decode("utf-8"))
|
||||
# Mailjet webhook docs say the payload is "a JSON array of event objects,"
|
||||
# but that's not true if "group events" isn't enabled in webhook config...
|
||||
try:
|
||||
@@ -28,65 +34,71 @@ class MailjetTrackingWebhookView(AnymailBaseWebhookView):
|
||||
# https://dev.mailjet.com/guides/#events
|
||||
event_types = {
|
||||
# Map Mailjet event: Anymail normalized type
|
||||
'sent': EventType.DELIVERED, # accepted by receiving MTA
|
||||
'open': EventType.OPENED,
|
||||
'click': EventType.CLICKED,
|
||||
'bounce': EventType.BOUNCED,
|
||||
'blocked': EventType.REJECTED,
|
||||
'spam': EventType.COMPLAINED,
|
||||
'unsub': EventType.UNSUBSCRIBED,
|
||||
"sent": EventType.DELIVERED, # accepted by receiving MTA
|
||||
"open": EventType.OPENED,
|
||||
"click": EventType.CLICKED,
|
||||
"bounce": EventType.BOUNCED,
|
||||
"blocked": EventType.REJECTED,
|
||||
"spam": EventType.COMPLAINED,
|
||||
"unsub": EventType.UNSUBSCRIBED,
|
||||
}
|
||||
|
||||
reject_reasons = {
|
||||
# Map Mailjet error strings to Anymail normalized reject_reason
|
||||
# error_related_to: recipient
|
||||
'user unknown': RejectReason.BOUNCED,
|
||||
'mailbox inactive': RejectReason.BOUNCED,
|
||||
'quota exceeded': RejectReason.BOUNCED,
|
||||
'blacklisted': RejectReason.BLOCKED, # might also be previous unsubscribe
|
||||
'spam reporter': RejectReason.SPAM,
|
||||
"user unknown": RejectReason.BOUNCED,
|
||||
"mailbox inactive": RejectReason.BOUNCED,
|
||||
"quota exceeded": RejectReason.BOUNCED,
|
||||
"blacklisted": RejectReason.BLOCKED, # might also be previous unsubscribe
|
||||
"spam reporter": RejectReason.SPAM,
|
||||
# error_related_to: domain
|
||||
'invalid domain': RejectReason.BOUNCED,
|
||||
'no mail host': RejectReason.BOUNCED,
|
||||
'relay/access denied': RejectReason.BOUNCED,
|
||||
'greylisted': RejectReason.OTHER, # see special handling below
|
||||
'typofix': RejectReason.INVALID,
|
||||
# error_related_to: spam (all Mailjet policy/filtering; see above for spam complaints)
|
||||
'sender blocked': RejectReason.BLOCKED,
|
||||
'content blocked': RejectReason.BLOCKED,
|
||||
'policy issue': RejectReason.BLOCKED,
|
||||
"invalid domain": RejectReason.BOUNCED,
|
||||
"no mail host": RejectReason.BOUNCED,
|
||||
"relay/access denied": RejectReason.BOUNCED,
|
||||
"greylisted": RejectReason.OTHER, # see special handling below
|
||||
"typofix": RejectReason.INVALID,
|
||||
# error_related_to: spam
|
||||
# (all Mailjet policy/filtering; see above for spam complaints)
|
||||
"sender blocked": RejectReason.BLOCKED,
|
||||
"content blocked": RejectReason.BLOCKED,
|
||||
"policy issue": RejectReason.BLOCKED,
|
||||
# error_related_to: mailjet
|
||||
'preblocked': RejectReason.BLOCKED,
|
||||
'duplicate in campaign': RejectReason.OTHER,
|
||||
"preblocked": RejectReason.BLOCKED,
|
||||
"duplicate in campaign": RejectReason.OTHER,
|
||||
}
|
||||
|
||||
def esp_to_anymail_event(self, esp_event):
|
||||
event_type = self.event_types.get(esp_event['event'], EventType.UNKNOWN)
|
||||
if esp_event.get('error', None) == 'greylisted' and not esp_event.get('hard_bounce', False):
|
||||
# "This is a temporary error due to possible unrecognised senders. Delivery will be re-attempted."
|
||||
event_type = self.event_types.get(esp_event["event"], EventType.UNKNOWN)
|
||||
if esp_event.get("error", None) == "greylisted" and not esp_event.get(
|
||||
"hard_bounce", False
|
||||
):
|
||||
# "This is a temporary error due to possible unrecognised senders.
|
||||
# Delivery will be re-attempted."
|
||||
event_type = EventType.DEFERRED
|
||||
|
||||
try:
|
||||
timestamp = datetime.fromtimestamp(esp_event['time'], tz=timezone.utc)
|
||||
timestamp = datetime.fromtimestamp(esp_event["time"], tz=timezone.utc)
|
||||
except (KeyError, ValueError):
|
||||
timestamp = None
|
||||
|
||||
try:
|
||||
# convert bigint MessageID to str to match backend AnymailRecipientStatus
|
||||
message_id = str(esp_event['MessageID'])
|
||||
message_id = str(esp_event["MessageID"])
|
||||
except (KeyError, TypeError):
|
||||
message_id = None
|
||||
|
||||
if 'error' in esp_event:
|
||||
reject_reason = self.reject_reasons.get(esp_event['error'], RejectReason.OTHER)
|
||||
if "error" in esp_event:
|
||||
reject_reason = self.reject_reasons.get(
|
||||
esp_event["error"], RejectReason.OTHER
|
||||
)
|
||||
else:
|
||||
reject_reason = None
|
||||
|
||||
tag = esp_event.get('customcampaign', None)
|
||||
tag = esp_event.get("customcampaign", None)
|
||||
tags = [tag] if tag else []
|
||||
|
||||
try:
|
||||
metadata = json.loads(esp_event['Payload'])
|
||||
metadata = json.loads(esp_event["Payload"])
|
||||
except (KeyError, ValueError):
|
||||
metadata = {}
|
||||
|
||||
@@ -95,13 +107,13 @@ class MailjetTrackingWebhookView(AnymailBaseWebhookView):
|
||||
timestamp=timestamp,
|
||||
message_id=message_id,
|
||||
event_id=None,
|
||||
recipient=esp_event.get('email', None),
|
||||
recipient=esp_event.get("email", None),
|
||||
reject_reason=reject_reason,
|
||||
mta_response=esp_event.get('smtp_reply', None),
|
||||
mta_response=esp_event.get("smtp_reply", None),
|
||||
tags=tags,
|
||||
metadata=metadata,
|
||||
click_url=esp_event.get('url', None),
|
||||
user_agent=esp_event.get('agent', None),
|
||||
click_url=esp_event.get("url", None),
|
||||
user_agent=esp_event.get("agent", None),
|
||||
esp_event=esp_event,
|
||||
)
|
||||
|
||||
@@ -113,21 +125,23 @@ class MailjetInboundWebhookView(AnymailBaseWebhookView):
|
||||
signal = inbound
|
||||
|
||||
def parse_events(self, request):
|
||||
esp_event = json.loads(request.body.decode('utf-8'))
|
||||
esp_event = json.loads(request.body.decode("utf-8"))
|
||||
return [self.esp_to_anymail_event(esp_event)]
|
||||
|
||||
def esp_to_anymail_event(self, esp_event):
|
||||
# You could _almost_ reconstruct the raw mime message from Mailjet's Headers and Parts fields,
|
||||
# but it's not clear which multipart boundary to use on each individual Part. Although each Part's
|
||||
# Content-Type header still has the multipart boundary, not knowing the parent part means typical
|
||||
# nested multipart structures can't be reliably recovered from the data Mailjet provides.
|
||||
# We'll just use our standarized multipart inbound constructor.
|
||||
# You could _almost_ reconstruct the raw mime message from Mailjet's Headers
|
||||
# and Parts fields, but it's not clear which multipart boundary to use on each
|
||||
# individual Part. Although each Part's Content-Type header still has the
|
||||
# multipart boundary, not knowing the parent part means typical nested multipart
|
||||
# structures can't be reliably recovered from the data Mailjet provides.
|
||||
# Just use our standardized multipart inbound constructor.
|
||||
|
||||
headers = self._flatten_mailjet_headers(esp_event.get("Headers", {}))
|
||||
attachments = [
|
||||
self._construct_mailjet_attachment(part, esp_event)
|
||||
for part in esp_event.get("Parts", [])
|
||||
if "Attachment" in part.get("ContentRef", "") # Attachment<N> or InlineAttachment<N>
|
||||
# if ContentRef is Attachment<N> or InlineAttachment<N>:
|
||||
if "Attachment" in part.get("ContentRef", "")
|
||||
]
|
||||
message = AnymailInboundMessage.construct(
|
||||
headers=headers,
|
||||
@@ -139,49 +153,62 @@ class MailjetInboundWebhookView(AnymailBaseWebhookView):
|
||||
message.envelope_sender = esp_event.get("Sender", None)
|
||||
message.envelope_recipient = esp_event.get("Recipient", None)
|
||||
|
||||
message.spam_detected = None # Mailjet doesn't provide a boolean; you'll have to interpret spam_score
|
||||
# Mailjet doesn't provide a spam boolean; you'll have to interpret spam_score
|
||||
message.spam_detected = None
|
||||
try:
|
||||
message.spam_score = float(esp_event['SpamAssassinScore'])
|
||||
message.spam_score = float(esp_event["SpamAssassinScore"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
pass
|
||||
|
||||
return AnymailInboundEvent(
|
||||
event_type=EventType.INBOUND,
|
||||
timestamp=None, # Mailjet doesn't provide inbound event timestamp (esp_event['Date'] is time sent)
|
||||
event_id=None, # Mailjet doesn't provide an idempotent inbound event id
|
||||
# Mailjet doesn't provide inbound event timestamp
|
||||
# (esp_event["Date"] is time sent):
|
||||
timestamp=None,
|
||||
# Mailjet doesn't provide an idempotent inbound event id:
|
||||
event_id=None,
|
||||
esp_event=esp_event,
|
||||
message=message,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _flatten_mailjet_headers(headers):
|
||||
"""Convert Mailjet's dict-of-strings-and/or-lists header format to our list-of-name-value-pairs
|
||||
"""
|
||||
Convert Mailjet's dict-of-strings-and/or-lists header format
|
||||
to our list-of-name-value-pairs
|
||||
|
||||
{'name1': 'value', 'name2': ['value1', 'value2']}
|
||||
--> [('name1', 'value'), ('name2', 'value1'), ('name2', 'value2')]
|
||||
"""
|
||||
result = []
|
||||
for name, values in headers.items():
|
||||
if isinstance(values, list): # Mailjet groups repeated headers together as a list of values
|
||||
if isinstance(values, list):
|
||||
# Mailjet groups repeated headers together as a list of values
|
||||
for value in values:
|
||||
result.append((name, value))
|
||||
else:
|
||||
result.append((name, values)) # single-valued (non-list) header
|
||||
# single-valued (non-list) header
|
||||
result.append((name, values))
|
||||
return result
|
||||
|
||||
def _construct_mailjet_attachment(self, part, esp_event):
|
||||
# Mailjet includes unparsed attachment headers in each part; it's easiest to temporarily
|
||||
# attach them to a MIMEPart for parsing. (We could just turn this into the attachment,
|
||||
# but we want to use the payload handling from AnymailInboundMessage.construct_attachment later.)
|
||||
part_headers = AnymailInboundMessage() # temporary container for parsed attachment headers
|
||||
# Mailjet includes unparsed attachment headers in each part; it's easiest to
|
||||
# temporarily attach them to a MIMEPart for parsing. (We could just turn this
|
||||
# into the attachment, but we want to use the payload handling from
|
||||
# AnymailInboundMessage.construct_attachment later.)
|
||||
|
||||
# temporary container for parsed attachment headers:
|
||||
part_headers = AnymailInboundMessage()
|
||||
for name, value in self._flatten_mailjet_headers(part.get("Headers", {})):
|
||||
part_headers.add_header(name, value)
|
||||
|
||||
content_base64 = esp_event[part["ContentRef"]] # Mailjet *always* base64-encodes attachments
|
||||
# Mailjet *always* base64-encodes attachments
|
||||
content_base64 = esp_event[part["ContentRef"]]
|
||||
|
||||
return AnymailInboundMessage.construct_attachment(
|
||||
content_type=part_headers.get_content_type(),
|
||||
content=content_base64, base64=True,
|
||||
content=content_base64,
|
||||
base64=True,
|
||||
filename=part_headers.get_filename(None),
|
||||
content_id=part_headers.get("Content-ID", "") or None,
|
||||
)
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from base64 import b64encode
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from django.utils.crypto import constant_time_compare
|
||||
|
||||
from .base import AnymailBaseWebhookView, AnymailCoreWebhookView
|
||||
from ..exceptions import AnymailWebhookValidationFailure
|
||||
from ..inbound import AnymailInboundMessage
|
||||
from ..signals import inbound, tracking, AnymailInboundEvent, AnymailTrackingEvent, EventType
|
||||
from ..utils import get_anymail_setting, getfirst, get_request_uri
|
||||
from ..signals import (
|
||||
AnymailInboundEvent,
|
||||
AnymailTrackingEvent,
|
||||
EventType,
|
||||
inbound,
|
||||
tracking,
|
||||
)
|
||||
from ..utils import get_anymail_setting, get_request_uri, getfirst
|
||||
from .base import AnymailBaseWebhookView, AnymailCoreWebhookView
|
||||
|
||||
|
||||
class MandrillSignatureMixin(AnymailCoreWebhookView):
|
||||
@@ -22,38 +28,60 @@ class MandrillSignatureMixin(AnymailCoreWebhookView):
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
esp_name = self.esp_name
|
||||
# webhook_key is required for POST, but not for HEAD when Mandrill validates webhook url.
|
||||
# Defer "missing setting" error until we actually try to use it in the POST...
|
||||
webhook_key = get_anymail_setting('webhook_key', esp_name=esp_name, default=None,
|
||||
kwargs=kwargs, allow_bare=True)
|
||||
# webhook_key is required for POST, but not for HEAD when Mandrill validates
|
||||
# webhook url. Defer "missing setting" error until we actually try to use it in
|
||||
# the POST...
|
||||
webhook_key = get_anymail_setting(
|
||||
"webhook_key",
|
||||
esp_name=esp_name,
|
||||
default=None,
|
||||
kwargs=kwargs,
|
||||
allow_bare=True,
|
||||
)
|
||||
if webhook_key is not None:
|
||||
self.webhook_key = webhook_key.encode('ascii') # hmac.new requires bytes key
|
||||
self.webhook_url = get_anymail_setting('webhook_url', esp_name=esp_name, default=None,
|
||||
kwargs=kwargs, allow_bare=True)
|
||||
# hmac.new requires bytes key
|
||||
self.webhook_key = webhook_key.encode("ascii")
|
||||
self.webhook_url = get_anymail_setting(
|
||||
"webhook_url",
|
||||
esp_name=esp_name,
|
||||
default=None,
|
||||
kwargs=kwargs,
|
||||
allow_bare=True,
|
||||
)
|
||||
super().__init__(**kwargs)
|
||||
|
||||
def validate_request(self, request):
|
||||
if self.webhook_key is None:
|
||||
# issue deferred "missing setting" error (re-call get-setting without a default)
|
||||
get_anymail_setting('webhook_key', esp_name=self.esp_name, allow_bare=True)
|
||||
# issue deferred "missing setting" error
|
||||
# (re-call get-setting without a default)
|
||||
get_anymail_setting("webhook_key", esp_name=self.esp_name, allow_bare=True)
|
||||
|
||||
try:
|
||||
signature = request.META["HTTP_X_MANDRILL_SIGNATURE"]
|
||||
except KeyError:
|
||||
raise AnymailWebhookValidationFailure("X-Mandrill-Signature header missing from webhook POST") from None
|
||||
raise AnymailWebhookValidationFailure(
|
||||
"X-Mandrill-Signature header missing from webhook POST"
|
||||
) from None
|
||||
|
||||
# Mandrill signs the exact URL (including basic auth, if used) plus the sorted POST params:
|
||||
# Mandrill signs the exact URL (including basic auth, if used)
|
||||
# plus the sorted POST params:
|
||||
url = self.webhook_url or get_request_uri(request)
|
||||
params = request.POST.dict()
|
||||
signed_data = url
|
||||
for key in sorted(params.keys()):
|
||||
signed_data += key + params[key]
|
||||
|
||||
expected_signature = b64encode(hmac.new(key=self.webhook_key, msg=signed_data.encode('utf-8'),
|
||||
digestmod=hashlib.sha1).digest())
|
||||
expected_signature = b64encode(
|
||||
hmac.new(
|
||||
key=self.webhook_key,
|
||||
msg=signed_data.encode("utf-8"),
|
||||
digestmod=hashlib.sha1,
|
||||
).digest()
|
||||
)
|
||||
if not constant_time_compare(signature, expected_signature):
|
||||
raise AnymailWebhookValidationFailure(
|
||||
"Mandrill webhook called with incorrect signature (for url %r)" % url)
|
||||
"Mandrill webhook called with incorrect signature (for url %r)" % url
|
||||
)
|
||||
|
||||
|
||||
class MandrillCombinedWebhookView(MandrillSignatureMixin, AnymailBaseWebhookView):
|
||||
@@ -65,19 +93,19 @@ class MandrillCombinedWebhookView(MandrillSignatureMixin, AnymailBaseWebhookView
|
||||
signal = None # set in esp_to_anymail_event
|
||||
|
||||
def parse_events(self, request):
|
||||
esp_events = json.loads(request.POST['mandrill_events'])
|
||||
esp_events = json.loads(request.POST["mandrill_events"])
|
||||
return [self.esp_to_anymail_event(esp_event) for esp_event in esp_events]
|
||||
|
||||
def esp_to_anymail_event(self, esp_event):
|
||||
"""Route events to the inbound or tracking handler"""
|
||||
esp_type = getfirst(esp_event, ['event', 'type'], 'unknown')
|
||||
esp_type = getfirst(esp_event, ["event", "type"], "unknown")
|
||||
|
||||
if esp_type == 'inbound':
|
||||
assert self.signal is not tracking # Mandrill should never mix event types in the same batch
|
||||
if esp_type == "inbound":
|
||||
assert self.signal is not tracking # batch must not mix event types
|
||||
self.signal = inbound
|
||||
return self.mandrill_inbound_to_anymail_event(esp_event)
|
||||
else:
|
||||
assert self.signal is not inbound # Mandrill should never mix event types in the same batch
|
||||
assert self.signal is not inbound # batch must not mix event types
|
||||
self.signal = tracking
|
||||
return self.mandrill_tracking_to_anymail_event(esp_event)
|
||||
|
||||
@@ -87,72 +115,74 @@ class MandrillCombinedWebhookView(MandrillSignatureMixin, AnymailBaseWebhookView
|
||||
|
||||
event_types = {
|
||||
# Message events:
|
||||
'send': EventType.SENT,
|
||||
'deferral': EventType.DEFERRED,
|
||||
'hard_bounce': EventType.BOUNCED,
|
||||
'soft_bounce': EventType.BOUNCED,
|
||||
'open': EventType.OPENED,
|
||||
'click': EventType.CLICKED,
|
||||
'spam': EventType.COMPLAINED,
|
||||
'unsub': EventType.UNSUBSCRIBED,
|
||||
'reject': EventType.REJECTED,
|
||||
"send": EventType.SENT,
|
||||
"deferral": EventType.DEFERRED,
|
||||
"hard_bounce": EventType.BOUNCED,
|
||||
"soft_bounce": EventType.BOUNCED,
|
||||
"open": EventType.OPENED,
|
||||
"click": EventType.CLICKED,
|
||||
"spam": EventType.COMPLAINED,
|
||||
"unsub": EventType.UNSUBSCRIBED,
|
||||
"reject": EventType.REJECTED,
|
||||
# Sync events (we don't really normalize these well):
|
||||
'whitelist': EventType.UNKNOWN,
|
||||
'blacklist': EventType.UNKNOWN,
|
||||
"whitelist": EventType.UNKNOWN,
|
||||
"blacklist": EventType.UNKNOWN,
|
||||
# Inbound events:
|
||||
'inbound': EventType.INBOUND,
|
||||
"inbound": EventType.INBOUND,
|
||||
}
|
||||
|
||||
def mandrill_tracking_to_anymail_event(self, esp_event):
|
||||
esp_type = getfirst(esp_event, ['event', 'type'], None)
|
||||
esp_type = getfirst(esp_event, ["event", "type"], None)
|
||||
event_type = self.event_types.get(esp_type, EventType.UNKNOWN)
|
||||
|
||||
try:
|
||||
timestamp = datetime.fromtimestamp(esp_event['ts'], tz=timezone.utc)
|
||||
timestamp = datetime.fromtimestamp(esp_event["ts"], tz=timezone.utc)
|
||||
except (KeyError, ValueError):
|
||||
timestamp = None
|
||||
|
||||
try:
|
||||
recipient = esp_event['msg']['email']
|
||||
recipient = esp_event["msg"]["email"]
|
||||
except KeyError:
|
||||
try:
|
||||
recipient = esp_event['reject']['email'] # sync events
|
||||
recipient = esp_event["reject"]["email"] # sync events
|
||||
except KeyError:
|
||||
recipient = None
|
||||
|
||||
try:
|
||||
mta_response = esp_event['msg']['diag']
|
||||
mta_response = esp_event["msg"]["diag"]
|
||||
except KeyError:
|
||||
mta_response = None
|
||||
|
||||
try:
|
||||
description = getfirst(esp_event['reject'], ['detail', 'reason'])
|
||||
description = getfirst(esp_event["reject"], ["detail", "reason"])
|
||||
except KeyError:
|
||||
description = None
|
||||
|
||||
try:
|
||||
metadata = esp_event['msg']['metadata']
|
||||
metadata = esp_event["msg"]["metadata"]
|
||||
except KeyError:
|
||||
metadata = {}
|
||||
|
||||
try:
|
||||
tags = esp_event['msg']['tags']
|
||||
tags = esp_event["msg"]["tags"]
|
||||
except KeyError:
|
||||
tags = []
|
||||
|
||||
return AnymailTrackingEvent(
|
||||
click_url=esp_event.get('url', None),
|
||||
click_url=esp_event.get("url", None),
|
||||
description=description,
|
||||
esp_event=esp_event,
|
||||
event_type=event_type,
|
||||
message_id=esp_event.get('_id', None),
|
||||
message_id=esp_event.get("_id", None),
|
||||
metadata=metadata,
|
||||
mta_response=mta_response,
|
||||
recipient=recipient,
|
||||
reject_reason=None, # probably map esp_event['msg']['bounce_description'], but insufficient docs
|
||||
# reject_reason should probably map esp_event['msg']['bounce_description'],
|
||||
# but Mandrill docs are insufficient to determine how
|
||||
reject_reason=None,
|
||||
tags=tags,
|
||||
timestamp=timestamp,
|
||||
user_agent=esp_event.get('user_agent', None),
|
||||
user_agent=esp_event.get("user_agent", None),
|
||||
)
|
||||
|
||||
#
|
||||
@@ -160,27 +190,33 @@ class MandrillCombinedWebhookView(MandrillSignatureMixin, AnymailBaseWebhookView
|
||||
#
|
||||
|
||||
def mandrill_inbound_to_anymail_event(self, esp_event):
|
||||
# It's easier (and more accurate) to just work from the original raw mime message
|
||||
message = AnymailInboundMessage.parse_raw_mime(esp_event['msg']['raw_msg'])
|
||||
message.envelope_sender = None # (Mandrill's 'sender' field only applies to outbound messages)
|
||||
message.envelope_recipient = esp_event['msg'].get('email', None)
|
||||
# It's easier (and more accurate) to just work
|
||||
# from the original raw mime message
|
||||
message = AnymailInboundMessage.parse_raw_mime(esp_event["msg"]["raw_msg"])
|
||||
|
||||
message.spam_detected = None # no simple boolean field; would need to parse the spam_report
|
||||
message.spam_score = esp_event['msg'].get('spam_report', {}).get('score', None)
|
||||
# (Mandrill's "sender" field only applies to outbound messages)
|
||||
message.envelope_sender = None
|
||||
message.envelope_recipient = esp_event["msg"].get("email", None)
|
||||
|
||||
# no simple boolean spam; would need to parse the spam_report
|
||||
message.spam_detected = None
|
||||
message.spam_score = esp_event["msg"].get("spam_report", {}).get("score", None)
|
||||
|
||||
try:
|
||||
timestamp = datetime.fromtimestamp(esp_event['ts'], tz=timezone.utc)
|
||||
timestamp = datetime.fromtimestamp(esp_event["ts"], tz=timezone.utc)
|
||||
except (KeyError, ValueError):
|
||||
timestamp = None
|
||||
|
||||
return AnymailInboundEvent(
|
||||
event_type=EventType.INBOUND,
|
||||
timestamp=timestamp,
|
||||
event_id=None, # Mandrill doesn't provide an idempotent inbound message event id
|
||||
# Mandrill doesn't provide an idempotent inbound message event id
|
||||
event_id=None,
|
||||
esp_event=esp_event,
|
||||
message=message,
|
||||
)
|
||||
|
||||
|
||||
# Backwards-compatibility: earlier Anymail versions had only MandrillTrackingWebhookView:
|
||||
# Backwards-compatibility:
|
||||
# earlier Anymail versions had only MandrillTrackingWebhookView:
|
||||
MandrillTrackingWebhookView = MandrillCombinedWebhookView
|
||||
|
||||
@@ -3,35 +3,36 @@ import json
|
||||
from base64 import b64decode
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
from .base import AnymailBaseWebhookView
|
||||
from ..exceptions import (
|
||||
AnymailConfigurationError,
|
||||
AnymailImproperlyInstalled,
|
||||
AnymailInvalidAddress,
|
||||
AnymailWebhookValidationFailure,
|
||||
AnymailImproperlyInstalled,
|
||||
_LazyError,
|
||||
AnymailConfigurationError,
|
||||
)
|
||||
from ..inbound import AnymailInboundMessage
|
||||
from ..signals import (
|
||||
inbound,
|
||||
tracking,
|
||||
AnymailInboundEvent,
|
||||
AnymailTrackingEvent,
|
||||
EventType,
|
||||
RejectReason,
|
||||
inbound,
|
||||
tracking,
|
||||
)
|
||||
from ..utils import parse_single_address, get_anymail_setting
|
||||
from ..utils import get_anymail_setting, parse_single_address
|
||||
from .base import AnymailBaseWebhookView
|
||||
|
||||
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
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import padding
|
||||
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'))
|
||||
# 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
|
||||
@@ -50,7 +51,9 @@ class PostalBaseWebhookView(AnymailBaseWebhookView):
|
||||
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)
|
||||
self.webhook_key = get_anymail_setting(
|
||||
"webhook_key", esp_name=self.esp_name, kwargs=kwargs, allow_bare=True
|
||||
)
|
||||
|
||||
super().__init__(**kwargs)
|
||||
|
||||
@@ -58,23 +61,27 @@ class PostalBaseWebhookView(AnymailBaseWebhookView):
|
||||
try:
|
||||
signature = request.META["HTTP_X_POSTAL_SIGNATURE"]
|
||||
except KeyError:
|
||||
raise AnymailWebhookValidationFailure("X-Postal-Signature header missing from webhook")
|
||||
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()
|
||||
(
|
||||
"-----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()
|
||||
b64decode(signature), request.body, padding.PKCS1v15(), hashes.SHA1()
|
||||
)
|
||||
except (InvalidSignature, binascii.Error):
|
||||
raise AnymailWebhookValidationFailure(
|
||||
"Postal webhook called with incorrect signature")
|
||||
"Postal webhook called with incorrect signature"
|
||||
)
|
||||
|
||||
|
||||
class PostalTrackingWebhookView(PostalBaseWebhookView):
|
||||
@@ -85,10 +92,11 @@ class PostalTrackingWebhookView(PostalBaseWebhookView):
|
||||
def parse_events(self, request):
|
||||
esp_event = json.loads(request.body.decode("utf-8"))
|
||||
|
||||
if 'rcpt_to' in esp_event:
|
||||
if "rcpt_to" in esp_event:
|
||||
raise AnymailConfigurationError(
|
||||
"You seem to have set Postal's *inbound* webhook "
|
||||
"to Anymail's Postal *tracking* webhook URL.")
|
||||
"to Anymail's Postal *tracking* webhook URL."
|
||||
)
|
||||
|
||||
raw_timestamp = esp_event.get("timestamp")
|
||||
timestamp = (
|
||||
@@ -133,8 +141,8 @@ class PostalTrackingWebhookView(PostalBaseWebhookView):
|
||||
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.
|
||||
# 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
|
||||
@@ -144,7 +152,7 @@ class PostalTrackingWebhookView(PostalBaseWebhookView):
|
||||
event = AnymailTrackingEvent(
|
||||
event_type=event_type,
|
||||
timestamp=timestamp,
|
||||
event_id=esp_event.get('uuid'),
|
||||
event_id=esp_event.get("uuid"),
|
||||
esp_event=esp_event,
|
||||
click_url=click_url,
|
||||
description=description,
|
||||
@@ -152,7 +160,9 @@ class PostalTrackingWebhookView(PostalBaseWebhookView):
|
||||
metadata=None,
|
||||
mta_response=mta_response,
|
||||
recipient=recipient,
|
||||
reject_reason=RejectReason.BOUNCED if event_type == EventType.BOUNCED else None,
|
||||
reject_reason=(
|
||||
RejectReason.BOUNCED if event_type == EventType.BOUNCED else None
|
||||
),
|
||||
tags=[tag],
|
||||
user_agent=user_agent,
|
||||
)
|
||||
@@ -168,18 +178,19 @@ class PostalInboundWebhookView(PostalBaseWebhookView):
|
||||
def parse_events(self, request):
|
||||
esp_event = json.loads(request.body.decode("utf-8"))
|
||||
|
||||
if 'status' in esp_event:
|
||||
if "status" in esp_event:
|
||||
raise AnymailConfigurationError(
|
||||
"You seem to have set Postal's *tracking* webhook "
|
||||
"to Anymail's Postal *inbound* webhook URL.")
|
||||
"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)
|
||||
message.envelope_sender = esp_event.get("mail_from", None)
|
||||
message.envelope_recipient = esp_event.get("rcpt_to", None)
|
||||
|
||||
event = AnymailInboundEvent(
|
||||
event_type=EventType.INBOUND,
|
||||
|
||||
@@ -2,11 +2,18 @@ import json
|
||||
|
||||
from django.utils.dateparse import parse_datetime
|
||||
|
||||
from .base import AnymailBaseWebhookView
|
||||
from ..exceptions import AnymailConfigurationError
|
||||
from ..inbound import AnymailInboundMessage
|
||||
from ..signals import inbound, tracking, AnymailInboundEvent, AnymailTrackingEvent, EventType, RejectReason
|
||||
from ..utils import getfirst, EmailAddress
|
||||
from ..signals import (
|
||||
AnymailInboundEvent,
|
||||
AnymailTrackingEvent,
|
||||
EventType,
|
||||
RejectReason,
|
||||
inbound,
|
||||
tracking,
|
||||
)
|
||||
from ..utils import EmailAddress, getfirst
|
||||
from .base import AnymailBaseWebhookView
|
||||
|
||||
|
||||
class PostmarkBaseWebhookView(AnymailBaseWebhookView):
|
||||
@@ -15,7 +22,7 @@ class PostmarkBaseWebhookView(AnymailBaseWebhookView):
|
||||
esp_name = "Postmark"
|
||||
|
||||
def parse_events(self, request):
|
||||
esp_event = json.loads(request.body.decode('utf-8'))
|
||||
esp_event = json.loads(request.body.decode("utf-8"))
|
||||
return [self.esp_to_anymail_event(esp_event)]
|
||||
|
||||
def esp_to_anymail_event(self, esp_event):
|
||||
@@ -29,40 +36,44 @@ class PostmarkTrackingWebhookView(PostmarkBaseWebhookView):
|
||||
|
||||
event_record_types = {
|
||||
# Map Postmark event RecordType --> Anymail normalized event type
|
||||
'Bounce': EventType.BOUNCED, # but check Type field for further info (below)
|
||||
'Click': EventType.CLICKED,
|
||||
'Delivery': EventType.DELIVERED,
|
||||
'Open': EventType.OPENED,
|
||||
'SpamComplaint': EventType.COMPLAINED,
|
||||
'SubscriptionChange': EventType.UNSUBSCRIBED,
|
||||
'Inbound': EventType.INBOUND, # future, probably
|
||||
"Bounce": EventType.BOUNCED, # but check Type field for further info (below)
|
||||
"Click": EventType.CLICKED,
|
||||
"Delivery": EventType.DELIVERED,
|
||||
"Open": EventType.OPENED,
|
||||
"SpamComplaint": EventType.COMPLAINED,
|
||||
"SubscriptionChange": EventType.UNSUBSCRIBED,
|
||||
"Inbound": EventType.INBOUND, # future, probably
|
||||
}
|
||||
|
||||
event_types = {
|
||||
# Map Postmark bounce/spam event Type --> Anymail normalized (event type, reject reason)
|
||||
'HardBounce': (EventType.BOUNCED, RejectReason.BOUNCED),
|
||||
'Transient': (EventType.DEFERRED, None),
|
||||
'Unsubscribe': (EventType.UNSUBSCRIBED, RejectReason.UNSUBSCRIBED),
|
||||
'Subscribe': (EventType.SUBSCRIBED, None),
|
||||
'AutoResponder': (EventType.AUTORESPONDED, None),
|
||||
'AddressChange': (EventType.AUTORESPONDED, None),
|
||||
'DnsError': (EventType.DEFERRED, None), # "temporary DNS error"
|
||||
'SpamNotification': (EventType.COMPLAINED, RejectReason.SPAM),
|
||||
'OpenRelayTest': (EventType.DEFERRED, None), # Receiving MTA is testing Postmark
|
||||
'Unknown': (EventType.UNKNOWN, None),
|
||||
'SoftBounce': (EventType.BOUNCED, RejectReason.BOUNCED), # might also receive HardBounce later
|
||||
'VirusNotification': (EventType.BOUNCED, RejectReason.OTHER),
|
||||
'ChallengeVerification': (EventType.AUTORESPONDED, None),
|
||||
'BadEmailAddress': (EventType.REJECTED, RejectReason.INVALID),
|
||||
'SpamComplaint': (EventType.COMPLAINED, RejectReason.SPAM),
|
||||
'ManuallyDeactivated': (EventType.REJECTED, RejectReason.BLOCKED),
|
||||
'Unconfirmed': (EventType.REJECTED, None),
|
||||
'Blocked': (EventType.REJECTED, RejectReason.BLOCKED),
|
||||
'SMTPApiError': (EventType.FAILED, None), # could occur if user also using Postmark SMTP directly
|
||||
'InboundError': (EventType.INBOUND_FAILED, None),
|
||||
'DMARCPolicy': (EventType.REJECTED, RejectReason.BLOCKED),
|
||||
'TemplateRenderingFailed': (EventType.FAILED, None),
|
||||
'ManualSuppression': (EventType.UNSUBSCRIBED, RejectReason.UNSUBSCRIBED),
|
||||
# Map Postmark bounce/spam event Type
|
||||
# --> Anymail normalized (event type, reject reason)
|
||||
"HardBounce": (EventType.BOUNCED, RejectReason.BOUNCED),
|
||||
"Transient": (EventType.DEFERRED, None),
|
||||
"Unsubscribe": (EventType.UNSUBSCRIBED, RejectReason.UNSUBSCRIBED),
|
||||
"Subscribe": (EventType.SUBSCRIBED, None),
|
||||
"AutoResponder": (EventType.AUTORESPONDED, None),
|
||||
"AddressChange": (EventType.AUTORESPONDED, None),
|
||||
"DnsError": (EventType.DEFERRED, None), # "temporary DNS error"
|
||||
"SpamNotification": (EventType.COMPLAINED, RejectReason.SPAM),
|
||||
# Receiving MTA is testing Postmark:
|
||||
"OpenRelayTest": (EventType.DEFERRED, None),
|
||||
"Unknown": (EventType.UNKNOWN, None),
|
||||
# might also receive HardBounce later:
|
||||
"SoftBounce": (EventType.BOUNCED, RejectReason.BOUNCED),
|
||||
"VirusNotification": (EventType.BOUNCED, RejectReason.OTHER),
|
||||
"ChallengeVerification": (EventType.AUTORESPONDED, None),
|
||||
"BadEmailAddress": (EventType.REJECTED, RejectReason.INVALID),
|
||||
"SpamComplaint": (EventType.COMPLAINED, RejectReason.SPAM),
|
||||
"ManuallyDeactivated": (EventType.REJECTED, RejectReason.BLOCKED),
|
||||
"Unconfirmed": (EventType.REJECTED, None),
|
||||
"Blocked": (EventType.REJECTED, RejectReason.BLOCKED),
|
||||
# could occur if user also using Postmark SMTP directly:
|
||||
"SMTPApiError": (EventType.FAILED, None),
|
||||
"InboundError": (EventType.INBOUND_FAILED, None),
|
||||
"DMARCPolicy": (EventType.REJECTED, RejectReason.BLOCKED),
|
||||
"TemplateRenderingFailed": (EventType.FAILED, None),
|
||||
"ManualSuppression": (EventType.UNSUBSCRIBED, RejectReason.UNSUBSCRIBED),
|
||||
}
|
||||
|
||||
def esp_to_anymail_event(self, esp_event):
|
||||
@@ -70,7 +81,7 @@ class PostmarkTrackingWebhookView(PostmarkBaseWebhookView):
|
||||
try:
|
||||
esp_record_type = esp_event["RecordType"]
|
||||
except KeyError:
|
||||
if 'FromFull' in esp_event:
|
||||
if "FromFull" in esp_event:
|
||||
# This is an inbound event
|
||||
event_type = EventType.INBOUND
|
||||
else:
|
||||
@@ -81,59 +92,65 @@ class PostmarkTrackingWebhookView(PostmarkBaseWebhookView):
|
||||
if event_type == EventType.INBOUND:
|
||||
raise AnymailConfigurationError(
|
||||
"You seem to have set Postmark's *inbound* webhook "
|
||||
"to Anymail's Postmark *tracking* webhook URL.")
|
||||
"to Anymail's Postmark *tracking* webhook URL."
|
||||
)
|
||||
|
||||
if event_type in (EventType.BOUNCED, EventType.COMPLAINED):
|
||||
# additional info is in the Type field
|
||||
try:
|
||||
event_type, reject_reason = self.event_types[esp_event['Type']]
|
||||
event_type, reject_reason = self.event_types[esp_event["Type"]]
|
||||
except KeyError:
|
||||
pass
|
||||
if event_type == EventType.UNSUBSCRIBED:
|
||||
if esp_event['SuppressSending']:
|
||||
if esp_event["SuppressSending"]:
|
||||
# Postmark doesn't provide a way to distinguish between
|
||||
# explicit unsubscribes and bounces
|
||||
try:
|
||||
event_type, reject_reason = self.event_types[esp_event['SuppressionReason']]
|
||||
event_type, reject_reason = self.event_types[
|
||||
esp_event["SuppressionReason"]
|
||||
]
|
||||
except KeyError:
|
||||
pass
|
||||
else:
|
||||
event_type, reject_reason = self.event_types['Subscribe']
|
||||
event_type, reject_reason = self.event_types["Subscribe"]
|
||||
|
||||
recipient = getfirst(esp_event, ['Email', 'Recipient'], None) # Email for bounce; Recipient for open
|
||||
# Email for bounce; Recipient for open:
|
||||
recipient = getfirst(esp_event, ["Email", "Recipient"], None)
|
||||
|
||||
try:
|
||||
timestr = getfirst(esp_event, ['DeliveredAt', 'BouncedAt', 'ReceivedAt', 'ChangedAt'])
|
||||
timestr = getfirst(
|
||||
esp_event, ["DeliveredAt", "BouncedAt", "ReceivedAt", "ChangedAt"]
|
||||
)
|
||||
except KeyError:
|
||||
timestamp = None
|
||||
else:
|
||||
timestamp = parse_datetime(timestr)
|
||||
|
||||
try:
|
||||
event_id = str(esp_event['ID']) # only in bounce events
|
||||
event_id = str(esp_event["ID"]) # only in bounce events
|
||||
except KeyError:
|
||||
event_id = None
|
||||
|
||||
metadata = esp_event.get('Metadata', {})
|
||||
metadata = esp_event.get("Metadata", {})
|
||||
try:
|
||||
tags = [esp_event['Tag']]
|
||||
tags = [esp_event["Tag"]]
|
||||
except KeyError:
|
||||
tags = []
|
||||
|
||||
return AnymailTrackingEvent(
|
||||
description=esp_event.get('Description', None),
|
||||
description=esp_event.get("Description", None),
|
||||
esp_event=esp_event,
|
||||
event_id=event_id,
|
||||
event_type=event_type,
|
||||
message_id=esp_event.get('MessageID', None),
|
||||
message_id=esp_event.get("MessageID", None),
|
||||
metadata=metadata,
|
||||
mta_response=esp_event.get('Details', None),
|
||||
mta_response=esp_event.get("Details", None),
|
||||
recipient=recipient,
|
||||
reject_reason=reject_reason,
|
||||
tags=tags,
|
||||
timestamp=timestamp,
|
||||
user_agent=esp_event.get('UserAgent', None),
|
||||
click_url=esp_event.get('OriginalLink', None),
|
||||
user_agent=esp_event.get("UserAgent", None),
|
||||
click_url=esp_event.get("OriginalLink", None),
|
||||
)
|
||||
|
||||
|
||||
@@ -146,12 +163,14 @@ class PostmarkInboundWebhookView(PostmarkBaseWebhookView):
|
||||
if esp_event.get("RecordType", "Inbound") != "Inbound":
|
||||
raise AnymailConfigurationError(
|
||||
"You seem to have set Postmark's *%s* webhook "
|
||||
"to Anymail's Postmark *inbound* webhook URL." % esp_event["RecordType"])
|
||||
"to Anymail's Postmark *inbound* webhook URL." % esp_event["RecordType"]
|
||||
)
|
||||
|
||||
attachments = [
|
||||
AnymailInboundMessage.construct_attachment(
|
||||
content_type=attachment["ContentType"],
|
||||
content=attachment["Content"], base64=True,
|
||||
content=attachment["Content"],
|
||||
base64=True,
|
||||
filename=attachment.get("Name", "") or None,
|
||||
content_id=attachment.get("ContentID", "") or None,
|
||||
)
|
||||
@@ -160,11 +179,15 @@ class PostmarkInboundWebhookView(PostmarkBaseWebhookView):
|
||||
|
||||
message = AnymailInboundMessage.construct(
|
||||
from_email=self._address(esp_event.get("FromFull")),
|
||||
to=', '.join([self._address(to) for to in esp_event.get("ToFull", [])]),
|
||||
cc=', '.join([self._address(cc) for cc in esp_event.get("CcFull", [])]),
|
||||
# bcc? Postmark specs this for inbound events, but it's unclear how it could occur
|
||||
to=", ".join([self._address(to) for to in esp_event.get("ToFull", [])]),
|
||||
cc=", ".join([self._address(cc) for cc in esp_event.get("CcFull", [])]),
|
||||
# bcc? Postmark specs this for inbound events,
|
||||
# but it's unclear how it could occur
|
||||
subject=esp_event.get("Subject", ""),
|
||||
headers=[(header["Name"], header["Value"]) for header in esp_event.get("Headers", [])],
|
||||
headers=[
|
||||
(header["Name"], header["Value"])
|
||||
for header in esp_event.get("Headers", [])
|
||||
],
|
||||
text=esp_event.get("TextBody", ""),
|
||||
html=esp_event.get("HtmlBody", ""),
|
||||
attachments=attachments,
|
||||
@@ -176,36 +199,48 @@ class PostmarkInboundWebhookView(PostmarkBaseWebhookView):
|
||||
if "ReplyTo" in esp_event and "Reply-To" not in message:
|
||||
message["Reply-To"] = esp_event["ReplyTo"]
|
||||
|
||||
# Postmark doesn't have a separate envelope-sender field, but it can be extracted
|
||||
# from the Received-SPF header that Postmark will have added:
|
||||
if len(message.get_all("Received-SPF", [])) == 1: # (more than one? someone's up to something weird)
|
||||
# Postmark doesn't have a separate envelope-sender field, but it can
|
||||
# be extracted from the Received-SPF header that Postmark will have added.
|
||||
# (More than one Received-SPF? someone's up to something weird?)
|
||||
if len(message.get_all("Received-SPF", [])) == 1:
|
||||
received_spf = message["Received-SPF"].lower()
|
||||
if received_spf.startswith("pass") or received_spf.startswith("neutral"): # not fail/softfail
|
||||
message.envelope_sender = message.get_param("envelope-from", None, header="Received-SPF")
|
||||
if received_spf.startswith( # not fail/softfail
|
||||
"pass"
|
||||
) or received_spf.startswith("neutral"):
|
||||
message.envelope_sender = message.get_param(
|
||||
"envelope-from", None, header="Received-SPF"
|
||||
)
|
||||
|
||||
message.envelope_recipient = esp_event.get("OriginalRecipient", None)
|
||||
message.stripped_text = esp_event.get("StrippedTextReply", None)
|
||||
|
||||
message.spam_detected = message.get('X-Spam-Status', 'No').lower() == 'yes'
|
||||
message.spam_detected = message.get("X-Spam-Status", "No").lower() == "yes"
|
||||
try:
|
||||
message.spam_score = float(message['X-Spam-Score'])
|
||||
message.spam_score = float(message["X-Spam-Score"])
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
return AnymailInboundEvent(
|
||||
event_type=EventType.INBOUND,
|
||||
timestamp=None, # Postmark doesn't provide inbound event timestamp
|
||||
event_id=esp_event.get("MessageID", None), # Postmark uuid, different from Message-ID mime header
|
||||
# Postmark doesn't provide inbound event timestamp:
|
||||
timestamp=None,
|
||||
# Postmark uuid, different from Message-ID mime header:
|
||||
event_id=esp_event.get("MessageID", None),
|
||||
esp_event=esp_event,
|
||||
message=message,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _address(full):
|
||||
"""Return an formatted email address from a Postmark inbound {From,To,Cc}Full dict"""
|
||||
"""
|
||||
Return a formatted email address
|
||||
from a Postmark inbound {From,To,Cc}Full dict
|
||||
"""
|
||||
if full is None:
|
||||
return ""
|
||||
return str(EmailAddress(
|
||||
display_name=full.get('Name', ""),
|
||||
addr_spec=full.get("Email", ""),
|
||||
))
|
||||
return str(
|
||||
EmailAddress(
|
||||
display_name=full.get("Name", ""),
|
||||
addr_spec=full.get("Email", ""),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -3,10 +3,16 @@ from datetime import datetime, timezone
|
||||
from email.parser import BytesParser
|
||||
from email.policy import default as default_policy
|
||||
|
||||
|
||||
from .base import AnymailBaseWebhookView
|
||||
from ..inbound import AnymailInboundMessage
|
||||
from ..signals import AnymailInboundEvent, AnymailTrackingEvent, EventType, RejectReason, inbound, tracking
|
||||
from ..signals import (
|
||||
AnymailInboundEvent,
|
||||
AnymailTrackingEvent,
|
||||
EventType,
|
||||
RejectReason,
|
||||
inbound,
|
||||
tracking,
|
||||
)
|
||||
from .base import AnymailBaseWebhookView
|
||||
|
||||
|
||||
class SendGridTrackingWebhookView(AnymailBaseWebhookView):
|
||||
@@ -16,47 +22,50 @@ class SendGridTrackingWebhookView(AnymailBaseWebhookView):
|
||||
signal = tracking
|
||||
|
||||
def parse_events(self, request):
|
||||
esp_events = json.loads(request.body.decode('utf-8'))
|
||||
esp_events = json.loads(request.body.decode("utf-8"))
|
||||
return [self.esp_to_anymail_event(esp_event) for esp_event in esp_events]
|
||||
|
||||
event_types = {
|
||||
# Map SendGrid event: Anymail normalized type
|
||||
'bounce': EventType.BOUNCED,
|
||||
'deferred': EventType.DEFERRED,
|
||||
'delivered': EventType.DELIVERED,
|
||||
'dropped': EventType.REJECTED,
|
||||
'processed': EventType.QUEUED,
|
||||
'click': EventType.CLICKED,
|
||||
'open': EventType.OPENED,
|
||||
'spamreport': EventType.COMPLAINED,
|
||||
'unsubscribe': EventType.UNSUBSCRIBED,
|
||||
'group_unsubscribe': EventType.UNSUBSCRIBED,
|
||||
'group_resubscribe': EventType.SUBSCRIBED,
|
||||
"bounce": EventType.BOUNCED,
|
||||
"deferred": EventType.DEFERRED,
|
||||
"delivered": EventType.DELIVERED,
|
||||
"dropped": EventType.REJECTED,
|
||||
"processed": EventType.QUEUED,
|
||||
"click": EventType.CLICKED,
|
||||
"open": EventType.OPENED,
|
||||
"spamreport": EventType.COMPLAINED,
|
||||
"unsubscribe": EventType.UNSUBSCRIBED,
|
||||
"group_unsubscribe": EventType.UNSUBSCRIBED,
|
||||
"group_resubscribe": EventType.SUBSCRIBED,
|
||||
}
|
||||
|
||||
reject_reasons = {
|
||||
# Map SendGrid reason/type strings (lowercased) to Anymail normalized reject_reason
|
||||
'invalid': RejectReason.INVALID,
|
||||
'unsubscribed address': RejectReason.UNSUBSCRIBED,
|
||||
'bounce': RejectReason.BOUNCED,
|
||||
'blocked': RejectReason.BLOCKED,
|
||||
'expired': RejectReason.TIMED_OUT,
|
||||
# Map SendGrid reason/type strings (lowercased)
|
||||
# to Anymail normalized reject_reason
|
||||
"invalid": RejectReason.INVALID,
|
||||
"unsubscribed address": RejectReason.UNSUBSCRIBED,
|
||||
"bounce": RejectReason.BOUNCED,
|
||||
"blocked": RejectReason.BLOCKED,
|
||||
"expired": RejectReason.TIMED_OUT,
|
||||
}
|
||||
|
||||
def esp_to_anymail_event(self, esp_event):
|
||||
event_type = self.event_types.get(esp_event['event'], EventType.UNKNOWN)
|
||||
event_type = self.event_types.get(esp_event["event"], EventType.UNKNOWN)
|
||||
try:
|
||||
timestamp = datetime.fromtimestamp(esp_event['timestamp'], tz=timezone.utc)
|
||||
timestamp = datetime.fromtimestamp(esp_event["timestamp"], tz=timezone.utc)
|
||||
except (KeyError, ValueError):
|
||||
timestamp = None
|
||||
|
||||
if esp_event['event'] == 'dropped':
|
||||
mta_response = None # dropped at ESP before even getting to MTA
|
||||
reason = esp_event.get('type', esp_event.get('reason', '')) # cause could be in 'type' or 'reason'
|
||||
if esp_event["event"] == "dropped":
|
||||
# message dropped at ESP before even getting to MTA:
|
||||
mta_response = None
|
||||
# cause could be in "type" or "reason":
|
||||
reason = esp_event.get("type", esp_event.get("reason", ""))
|
||||
reject_reason = self.reject_reasons.get(reason.lower(), RejectReason.OTHER)
|
||||
else:
|
||||
# MTA response is in 'response' for delivered; 'reason' for bounce
|
||||
mta_response = esp_event.get('response', esp_event.get('reason', None))
|
||||
# MTA response is in "response" for delivered; "reason" for bounce
|
||||
mta_response = esp_event.get("response", esp_event.get("reason", None))
|
||||
reject_reason = None
|
||||
|
||||
# SendGrid merges metadata ('unique_args') with the event.
|
||||
@@ -73,49 +82,50 @@ class SendGridTrackingWebhookView(AnymailBaseWebhookView):
|
||||
return AnymailTrackingEvent(
|
||||
event_type=event_type,
|
||||
timestamp=timestamp,
|
||||
message_id=esp_event.get('anymail_id', esp_event.get('smtp-id')), # backwards compatibility
|
||||
event_id=esp_event.get('sg_event_id', None),
|
||||
recipient=esp_event.get('email', None),
|
||||
# (smtp-id for backwards compatibility)
|
||||
message_id=esp_event.get("anymail_id", esp_event.get("smtp-id")),
|
||||
event_id=esp_event.get("sg_event_id", None),
|
||||
recipient=esp_event.get("email", None),
|
||||
reject_reason=reject_reason,
|
||||
mta_response=mta_response,
|
||||
tags=esp_event.get('category', []),
|
||||
tags=esp_event.get("category", []),
|
||||
metadata=metadata,
|
||||
click_url=esp_event.get('url', None),
|
||||
user_agent=esp_event.get('useragent', None),
|
||||
click_url=esp_event.get("url", None),
|
||||
user_agent=esp_event.get("useragent", None),
|
||||
esp_event=esp_event,
|
||||
)
|
||||
|
||||
# Known keys in SendGrid events (used to recover metadata above)
|
||||
sendgrid_event_keys = {
|
||||
'anymail_id',
|
||||
'asm_group_id',
|
||||
'attempt', # MTA deferred count
|
||||
'category',
|
||||
'cert_err',
|
||||
'email',
|
||||
'event',
|
||||
'ip',
|
||||
'marketing_campaign_id',
|
||||
'marketing_campaign_name',
|
||||
'newsletter', # ???
|
||||
'nlvx_campaign_id',
|
||||
'nlvx_campaign_split_id',
|
||||
'nlvx_user_id',
|
||||
'pool',
|
||||
'post_type',
|
||||
'reason', # MTA bounce/drop reason; SendGrid suppression reason
|
||||
'response', # MTA deferred/delivered message
|
||||
'send_at',
|
||||
'sg_event_id',
|
||||
'sg_message_id',
|
||||
'smtp-id',
|
||||
'status', # SMTP status code
|
||||
'timestamp',
|
||||
'tls',
|
||||
'type', # suppression reject reason ("bounce", "blocked", "expired")
|
||||
'url', # click tracking
|
||||
'url_offset', # click tracking
|
||||
'useragent', # click/open tracking
|
||||
"anymail_id",
|
||||
"asm_group_id",
|
||||
"attempt", # MTA deferred count
|
||||
"category",
|
||||
"cert_err",
|
||||
"email",
|
||||
"event",
|
||||
"ip",
|
||||
"marketing_campaign_id",
|
||||
"marketing_campaign_name",
|
||||
"newsletter", # ???
|
||||
"nlvx_campaign_id",
|
||||
"nlvx_campaign_split_id",
|
||||
"nlvx_user_id",
|
||||
"pool",
|
||||
"post_type",
|
||||
"reason", # MTA bounce/drop reason; SendGrid suppression reason
|
||||
"response", # MTA deferred/delivered message
|
||||
"send_at",
|
||||
"sg_event_id",
|
||||
"sg_message_id",
|
||||
"smtp-id",
|
||||
"status", # SMTP status code
|
||||
"timestamp",
|
||||
"tls",
|
||||
"type", # suppression reject reason ("bounce", "blocked", "expired")
|
||||
"url", # click tracking
|
||||
"url_offset", # click tracking
|
||||
"useragent", # click/open tracking
|
||||
}
|
||||
|
||||
|
||||
@@ -129,39 +139,46 @@ class SendGridInboundWebhookView(AnymailBaseWebhookView):
|
||||
return [self.esp_to_anymail_event(request)]
|
||||
|
||||
def esp_to_anymail_event(self, request):
|
||||
# Inbound uses the entire Django request as esp_event, because we need POST and FILES.
|
||||
# Note that request.POST is case-sensitive (unlike email.message.Message headers).
|
||||
# Inbound uses the entire Django request as esp_event, because we need
|
||||
# POST and FILES. Note that request.POST is case-sensitive (unlike
|
||||
# email.message.Message headers).
|
||||
esp_event = request
|
||||
# Must access body before any POST fields, or it won't be available if we need
|
||||
# it later (see text_charset and html_charset handling below).
|
||||
_ensure_body_is_available_later = request.body # noqa: F841
|
||||
if 'headers' in request.POST:
|
||||
if "headers" in request.POST:
|
||||
# Default (not "Send Raw") inbound fields
|
||||
message = self.message_from_sendgrid_parsed(esp_event)
|
||||
elif 'email' in request.POST:
|
||||
elif "email" in request.POST:
|
||||
# "Send Raw" full MIME
|
||||
message = AnymailInboundMessage.parse_raw_mime(request.POST['email'])
|
||||
message = AnymailInboundMessage.parse_raw_mime(request.POST["email"])
|
||||
else:
|
||||
raise KeyError("Invalid SendGrid inbound event data (missing both 'headers' and 'email' fields)")
|
||||
raise KeyError(
|
||||
"Invalid SendGrid inbound event data"
|
||||
" (missing both 'headers' and 'email' fields)"
|
||||
)
|
||||
|
||||
try:
|
||||
envelope = json.loads(request.POST['envelope'])
|
||||
envelope = json.loads(request.POST["envelope"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
pass
|
||||
else:
|
||||
message.envelope_sender = envelope['from']
|
||||
message.envelope_recipient = envelope['to'][0]
|
||||
message.envelope_sender = envelope["from"]
|
||||
message.envelope_recipient = envelope["to"][0]
|
||||
|
||||
message.spam_detected = None # no simple boolean field; would need to parse the spam_report
|
||||
# no simple boolean spam; would need to parse the spam_report
|
||||
message.spam_detected = None
|
||||
try:
|
||||
message.spam_score = float(request.POST['spam_score'])
|
||||
message.spam_score = float(request.POST["spam_score"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
pass
|
||||
|
||||
return AnymailInboundEvent(
|
||||
event_type=EventType.INBOUND,
|
||||
timestamp=None, # SendGrid doesn't provide an inbound event timestamp
|
||||
event_id=None, # SendGrid doesn't provide an idempotent inbound message event id
|
||||
# SendGrid doesn't provide an inbound event timestamp:
|
||||
timestamp=None,
|
||||
# SendGrid doesn't provide an idempotent inbound message event id:
|
||||
event_id=None,
|
||||
esp_event=esp_event,
|
||||
message=message,
|
||||
)
|
||||
@@ -170,12 +187,12 @@ class SendGridInboundWebhookView(AnymailBaseWebhookView):
|
||||
"""Construct a Message from SendGrid's "default" (non-raw) fields"""
|
||||
|
||||
try:
|
||||
charsets = json.loads(request.POST['charsets'])
|
||||
charsets = json.loads(request.POST["charsets"])
|
||||
except (KeyError, ValueError):
|
||||
charsets = {}
|
||||
|
||||
try:
|
||||
attachment_info = json.loads(request.POST['attachment-info'])
|
||||
attachment_info = json.loads(request.POST["attachment-info"])
|
||||
except (KeyError, ValueError):
|
||||
attachments = None
|
||||
else:
|
||||
@@ -186,44 +203,60 @@ class SendGridInboundWebhookView(AnymailBaseWebhookView):
|
||||
file = request.FILES[attachment_id]
|
||||
except KeyError:
|
||||
# Django's multipart/form-data handling drops FILES with certain
|
||||
# filenames (for security) or with empty filenames (Django ticket 15879).
|
||||
# (To avoid this problem, enable SendGrid's "raw, full MIME" inbound option.)
|
||||
# filenames (for security) or with empty filenames (Django ticket
|
||||
# 15879). (To avoid this problem, enable SendGrid's "raw, full MIME"
|
||||
# inbound option.)
|
||||
pass
|
||||
else:
|
||||
# (This deliberately ignores attachment_info[attachment_id]["filename"],
|
||||
# (This deliberately ignores
|
||||
# attachment_info[attachment_id]["filename"],
|
||||
# which has not passed through Django's filename sanitization.)
|
||||
content_id = attachment_info[attachment_id].get("content-id")
|
||||
attachment = AnymailInboundMessage.construct_attachment_from_uploaded_file(
|
||||
file, content_id=content_id)
|
||||
attachment = (
|
||||
AnymailInboundMessage.construct_attachment_from_uploaded_file(
|
||||
file, content_id=content_id
|
||||
)
|
||||
)
|
||||
attachments.append(attachment)
|
||||
|
||||
default_charset = request.POST.encoding.lower() # (probably utf-8)
|
||||
text = request.POST.get('text')
|
||||
text_charset = charsets.get('text', default_charset).lower()
|
||||
html = request.POST.get('html')
|
||||
html_charset = charsets.get('html', default_charset).lower()
|
||||
if (text and text_charset != default_charset) or (html and html_charset != default_charset):
|
||||
text = request.POST.get("text")
|
||||
text_charset = charsets.get("text", default_charset).lower()
|
||||
html = request.POST.get("html")
|
||||
html_charset = charsets.get("html", default_charset).lower()
|
||||
if (text and text_charset != default_charset) or (
|
||||
html and html_charset != default_charset
|
||||
):
|
||||
# Django has parsed text and/or html fields using the wrong charset.
|
||||
# We need to re-parse the raw form data and decode each field separately,
|
||||
# using the indicated charsets. The email package parses multipart/form-data
|
||||
# retaining bytes content. (In theory, we could instead just change
|
||||
# request.encoding and access the POST fields again, per Django docs,
|
||||
# but that seems to be have bugs around the cached request._files.)
|
||||
raw_data = b"".join([
|
||||
b"Content-Type: ", request.META['CONTENT_TYPE'].encode('ascii'),
|
||||
b"\r\n\r\n",
|
||||
request.body
|
||||
])
|
||||
parsed_parts = BytesParser(policy=default_policy).parsebytes(raw_data).get_payload()
|
||||
raw_data = b"".join(
|
||||
[
|
||||
b"Content-Type: ",
|
||||
request.META["CONTENT_TYPE"].encode("ascii"),
|
||||
b"\r\n\r\n",
|
||||
request.body,
|
||||
]
|
||||
)
|
||||
parsed_parts = (
|
||||
BytesParser(policy=default_policy).parsebytes(raw_data).get_payload()
|
||||
)
|
||||
for part in parsed_parts:
|
||||
name = part.get_param('name', header='content-disposition')
|
||||
if name == 'text':
|
||||
name = part.get_param("name", header="content-disposition")
|
||||
if name == "text":
|
||||
text = part.get_payload(decode=True).decode(text_charset)
|
||||
elif name == 'html':
|
||||
elif name == "html":
|
||||
html = part.get_payload(decode=True).decode(html_charset)
|
||||
# (subject, from, to, etc. are parsed from raw headers field,
|
||||
# so no need to worry about their separate POST field charsets)
|
||||
|
||||
return AnymailInboundMessage.construct(
|
||||
raw_headers=request.POST.get('headers', ""), # includes From, To, Cc, Subject, etc.
|
||||
text=text, html=html, attachments=attachments)
|
||||
# POST["headers"] includes From, To, Cc, Subject, etc.
|
||||
raw_headers=request.POST.get("headers", ""),
|
||||
text=text,
|
||||
html=html,
|
||||
attachments=attachments,
|
||||
)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from .base import AnymailBaseWebhookView
|
||||
from ..signals import AnymailTrackingEvent, EventType, RejectReason, tracking
|
||||
from .base import AnymailBaseWebhookView
|
||||
|
||||
|
||||
class SendinBlueTrackingWebhookView(AnymailBaseWebhookView):
|
||||
@@ -12,14 +12,15 @@ class SendinBlueTrackingWebhookView(AnymailBaseWebhookView):
|
||||
signal = tracking
|
||||
|
||||
def parse_events(self, request):
|
||||
esp_event = json.loads(request.body.decode('utf-8'))
|
||||
esp_event = json.loads(request.body.decode("utf-8"))
|
||||
return [self.esp_to_anymail_event(esp_event)]
|
||||
|
||||
# SendinBlue's webhook payload data doesn't seem to be documented anywhere.
|
||||
# There's a list of webhook events at https://apidocs.sendinblue.com/webhooks/#3.
|
||||
event_types = {
|
||||
# Map SendinBlue event type: Anymail normalized (event type, reject reason)
|
||||
"request": (EventType.QUEUED, None), # received even if message won't be sent (e.g., before "blocked")
|
||||
# received even if message won't be sent (e.g., before "blocked"):
|
||||
"request": (EventType.QUEUED, None),
|
||||
"delivered": (EventType.DELIVERED, None),
|
||||
"hard_bounce": (EventType.BOUNCED, RejectReason.BOUNCED),
|
||||
"soft_bounce": (EventType.BOUNCED, RejectReason.BOUNCED),
|
||||
@@ -30,32 +31,39 @@ class SendinBlueTrackingWebhookView(AnymailBaseWebhookView):
|
||||
"opened": (EventType.OPENED, None), # see also unique_opened below
|
||||
"click": (EventType.CLICKED, None),
|
||||
"unsubscribe": (EventType.UNSUBSCRIBED, None),
|
||||
"list_addition": (EventType.SUBSCRIBED, None), # shouldn't occur for transactional messages
|
||||
# shouldn't occur for transactional messages:
|
||||
"list_addition": (EventType.SUBSCRIBED, None),
|
||||
"unique_opened": (EventType.OPENED, None), # you'll *also* receive an "opened"
|
||||
}
|
||||
|
||||
def esp_to_anymail_event(self, esp_event):
|
||||
esp_type = esp_event.get("event")
|
||||
event_type, reject_reason = self.event_types.get(esp_type, (EventType.UNKNOWN, None))
|
||||
event_type, reject_reason = self.event_types.get(
|
||||
esp_type, (EventType.UNKNOWN, None)
|
||||
)
|
||||
recipient = esp_event.get("email")
|
||||
|
||||
try:
|
||||
# SendinBlue supplies "ts", "ts_event" and "date" fields, which seem to be based on the
|
||||
# timezone set in the account preferences (and possibly with inconsistent DST adjustment).
|
||||
# "ts_epoch" is the only field that seems to be consistently UTC; it's in milliseconds
|
||||
timestamp = datetime.fromtimestamp(esp_event["ts_epoch"] / 1000.0, tz=timezone.utc)
|
||||
# SendinBlue supplies "ts", "ts_event" and "date" fields, which seem to be
|
||||
# based on the timezone set in the account preferences (and possibly with
|
||||
# inconsistent DST adjustment). "ts_epoch" is the only field that seems to
|
||||
# be consistently UTC; it's in milliseconds
|
||||
timestamp = datetime.fromtimestamp(
|
||||
esp_event["ts_epoch"] / 1000.0, tz=timezone.utc
|
||||
)
|
||||
except (KeyError, ValueError):
|
||||
timestamp = None
|
||||
|
||||
tags = []
|
||||
try:
|
||||
# If `tags` param set on send, webhook payload includes 'tags' array field.
|
||||
tags = esp_event['tags']
|
||||
tags = esp_event["tags"]
|
||||
except KeyError:
|
||||
try:
|
||||
# If `X-Mailin-Tag` header set on send, webhook payload includes single 'tag' string.
|
||||
# (If header not set, webhook 'tag' will be the template name for template sends.)
|
||||
tags = [esp_event['tag']]
|
||||
# If `X-Mailin-Tag` header set on send, webhook payload includes single
|
||||
# 'tag' string. (If header not set, webhook 'tag' will be the template
|
||||
# name for template sends.)
|
||||
tags = [esp_event["tag"]]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
@@ -67,7 +75,8 @@ class SendinBlueTrackingWebhookView(AnymailBaseWebhookView):
|
||||
return AnymailTrackingEvent(
|
||||
description=None,
|
||||
esp_event=esp_event,
|
||||
event_id=None, # SendinBlue doesn't provide a unique event id
|
||||
# SendinBlue doesn't provide a unique event id:
|
||||
event_id=None,
|
||||
event_type=event_type,
|
||||
message_id=esp_event.get("message-id"),
|
||||
metadata=metadata,
|
||||
|
||||
@@ -2,11 +2,18 @@ import json
|
||||
from base64 import b64decode
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from .base import AnymailBaseWebhookView
|
||||
from ..exceptions import AnymailConfigurationError
|
||||
from ..inbound import AnymailInboundMessage
|
||||
from ..signals import inbound, tracking, AnymailInboundEvent, AnymailTrackingEvent, EventType, RejectReason
|
||||
from ..signals import (
|
||||
AnymailInboundEvent,
|
||||
AnymailTrackingEvent,
|
||||
EventType,
|
||||
RejectReason,
|
||||
inbound,
|
||||
tracking,
|
||||
)
|
||||
from ..utils import get_anymail_setting
|
||||
from .base import AnymailBaseWebhookView
|
||||
|
||||
|
||||
class SparkPostBaseWebhookView(AnymailBaseWebhookView):
|
||||
@@ -15,7 +22,7 @@ class SparkPostBaseWebhookView(AnymailBaseWebhookView):
|
||||
esp_name = "SparkPost"
|
||||
|
||||
def parse_events(self, request):
|
||||
raw_events = json.loads(request.body.decode('utf-8'))
|
||||
raw_events = json.loads(request.body.decode("utf-8"))
|
||||
unwrapped_events = [self.unwrap_event(raw_event) for raw_event in raw_events]
|
||||
return [
|
||||
self.esp_to_anymail_event(event_class, event, raw_event)
|
||||
@@ -30,17 +37,19 @@ class SparkPostBaseWebhookView(AnymailBaseWebhookView):
|
||||
|
||||
Can return None, None, raw_event for SparkPost "ping" raw_event={'msys': {}}
|
||||
"""
|
||||
event_classes = raw_event['msys'].keys()
|
||||
event_classes = raw_event["msys"].keys()
|
||||
try:
|
||||
(event_class,) = event_classes
|
||||
event = raw_event['msys'][event_class]
|
||||
event = raw_event["msys"][event_class]
|
||||
except ValueError: # too many/not enough event_classes to unpack
|
||||
if len(event_classes) == 0:
|
||||
# Empty event (SparkPost sometimes sends as a "ping")
|
||||
event_class = event = None
|
||||
else:
|
||||
raise TypeError(
|
||||
"Invalid SparkPost webhook event has multiple event classes: %r" % raw_event) from None
|
||||
"Invalid SparkPost webhook event has multiple event classes: %r"
|
||||
% raw_event
|
||||
) from None
|
||||
return event_class, event, raw_event
|
||||
|
||||
def esp_to_anymail_event(self, event_class, event, raw_event):
|
||||
@@ -54,54 +63,54 @@ class SparkPostTrackingWebhookView(SparkPostBaseWebhookView):
|
||||
|
||||
event_types = {
|
||||
# Map SparkPost event.type: Anymail normalized type
|
||||
'bounce': EventType.BOUNCED,
|
||||
'delivery': EventType.DELIVERED,
|
||||
'injection': EventType.QUEUED,
|
||||
'spam_complaint': EventType.COMPLAINED,
|
||||
'out_of_band': EventType.BOUNCED,
|
||||
'policy_rejection': EventType.REJECTED,
|
||||
'delay': EventType.DEFERRED,
|
||||
'click': EventType.CLICKED,
|
||||
'open': EventType.OPENED,
|
||||
'amp_click': EventType.CLICKED,
|
||||
'amp_open': EventType.OPENED,
|
||||
'generation_failure': EventType.FAILED,
|
||||
'generation_rejection': EventType.REJECTED,
|
||||
'list_unsubscribe': EventType.UNSUBSCRIBED,
|
||||
'link_unsubscribe': EventType.UNSUBSCRIBED,
|
||||
"bounce": EventType.BOUNCED,
|
||||
"delivery": EventType.DELIVERED,
|
||||
"injection": EventType.QUEUED,
|
||||
"spam_complaint": EventType.COMPLAINED,
|
||||
"out_of_band": EventType.BOUNCED,
|
||||
"policy_rejection": EventType.REJECTED,
|
||||
"delay": EventType.DEFERRED,
|
||||
"click": EventType.CLICKED,
|
||||
"open": EventType.OPENED,
|
||||
"amp_click": EventType.CLICKED,
|
||||
"amp_open": EventType.OPENED,
|
||||
"generation_failure": EventType.FAILED,
|
||||
"generation_rejection": EventType.REJECTED,
|
||||
"list_unsubscribe": EventType.UNSUBSCRIBED,
|
||||
"link_unsubscribe": EventType.UNSUBSCRIBED,
|
||||
}
|
||||
|
||||
# Additional event_types mapping when Anymail setting
|
||||
# SPARKPOST_TRACK_INITIAL_OPEN_AS_OPENED is enabled.
|
||||
initial_open_event_types = {
|
||||
'initial_open': EventType.OPENED,
|
||||
'amp_initial_open': EventType.OPENED,
|
||||
"initial_open": EventType.OPENED,
|
||||
"amp_initial_open": EventType.OPENED,
|
||||
}
|
||||
|
||||
reject_reasons = {
|
||||
# Map SparkPost event.bounce_class: Anymail normalized reject reason.
|
||||
# Can also supply (RejectReason, EventType) for bounce_class that affects our event_type.
|
||||
# https://support.sparkpost.com/customer/portal/articles/1929896
|
||||
'1': RejectReason.OTHER, # Undetermined (response text could not be identified)
|
||||
'10': RejectReason.INVALID, # Invalid Recipient
|
||||
'20': RejectReason.BOUNCED, # Soft Bounce
|
||||
'21': RejectReason.BOUNCED, # DNS Failure
|
||||
'22': RejectReason.BOUNCED, # Mailbox Full
|
||||
'23': RejectReason.BOUNCED, # Too Large
|
||||
'24': RejectReason.TIMED_OUT, # Timeout
|
||||
'25': RejectReason.BLOCKED, # Admin Failure (configured policies)
|
||||
'30': RejectReason.BOUNCED, # Generic Bounce: No RCPT
|
||||
'40': RejectReason.BOUNCED, # Generic Bounce: unspecified reasons
|
||||
'50': RejectReason.BLOCKED, # Mail Block (by the receiver)
|
||||
'51': RejectReason.SPAM, # Spam Block (by the receiver)
|
||||
'52': RejectReason.SPAM, # Spam Content (by the receiver)
|
||||
'53': RejectReason.OTHER, # Prohibited Attachment (by the receiver)
|
||||
'54': RejectReason.BLOCKED, # Relaying Denied (by the receiver)
|
||||
'60': (RejectReason.OTHER, EventType.AUTORESPONDED), # Auto-Reply/vacation
|
||||
'70': RejectReason.BOUNCED, # Transient Failure
|
||||
'80': (RejectReason.OTHER, EventType.SUBSCRIBED), # Subscribe
|
||||
'90': (RejectReason.UNSUBSCRIBED, EventType.UNSUBSCRIBED), # Unsubscribe
|
||||
'100': (RejectReason.OTHER, EventType.AUTORESPONDED), # Challenge-Response
|
||||
# Can also supply (RejectReason, EventType) for bounce_class that affects our
|
||||
# event_type. https://support.sparkpost.com/customer/portal/articles/1929896
|
||||
"1": RejectReason.OTHER, # Undetermined (response text could not be identified)
|
||||
"10": RejectReason.INVALID, # Invalid Recipient
|
||||
"20": RejectReason.BOUNCED, # Soft Bounce
|
||||
"21": RejectReason.BOUNCED, # DNS Failure
|
||||
"22": RejectReason.BOUNCED, # Mailbox Full
|
||||
"23": RejectReason.BOUNCED, # Too Large
|
||||
"24": RejectReason.TIMED_OUT, # Timeout
|
||||
"25": RejectReason.BLOCKED, # Admin Failure (configured policies)
|
||||
"30": RejectReason.BOUNCED, # Generic Bounce: No RCPT
|
||||
"40": RejectReason.BOUNCED, # Generic Bounce: unspecified reasons
|
||||
"50": RejectReason.BLOCKED, # Mail Block (by the receiver)
|
||||
"51": RejectReason.SPAM, # Spam Block (by the receiver)
|
||||
"52": RejectReason.SPAM, # Spam Content (by the receiver)
|
||||
"53": RejectReason.OTHER, # Prohibited Attachment (by the receiver)
|
||||
"54": RejectReason.BLOCKED, # Relaying Denied (by the receiver)
|
||||
"60": (RejectReason.OTHER, EventType.AUTORESPONDED), # Auto-Reply/vacation
|
||||
"70": RejectReason.BOUNCED, # Transient Failure
|
||||
"80": (RejectReason.OTHER, EventType.SUBSCRIBED), # Subscribe
|
||||
"90": (RejectReason.UNSUBSCRIBED, EventType.UNSUBSCRIBED), # Unsubscribe
|
||||
"100": (RejectReason.OTHER, EventType.AUTORESPONDED), # Challenge-Response
|
||||
}
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
@@ -111,34 +120,43 @@ class SparkPostTrackingWebhookView(SparkPostBaseWebhookView):
|
||||
# other ESPs.) Handling "initial_open" is opt-in, to help avoid duplicate
|
||||
# "opened" events on the same first open.
|
||||
track_initial_open_as_opened = get_anymail_setting(
|
||||
'track_initial_open_as_opened', default=False,
|
||||
esp_name=self.esp_name, kwargs=kwargs)
|
||||
"track_initial_open_as_opened",
|
||||
default=False,
|
||||
esp_name=self.esp_name,
|
||||
kwargs=kwargs,
|
||||
)
|
||||
if track_initial_open_as_opened:
|
||||
self.event_types = {**self.event_types, **self.initial_open_event_types}
|
||||
super().__init__(**kwargs)
|
||||
|
||||
def esp_to_anymail_event(self, event_class, event, raw_event):
|
||||
if event_class == 'relay_message':
|
||||
if event_class == "relay_message":
|
||||
# This is an inbound event
|
||||
raise AnymailConfigurationError(
|
||||
"You seem to have set SparkPost's *inbound* relay webhook URL "
|
||||
"to Anymail's SparkPost *tracking* webhook URL.")
|
||||
"to Anymail's SparkPost *tracking* webhook URL."
|
||||
)
|
||||
|
||||
event_type = self.event_types.get(event['type'], EventType.UNKNOWN)
|
||||
event_type = self.event_types.get(event["type"], EventType.UNKNOWN)
|
||||
try:
|
||||
timestamp = datetime.fromtimestamp(int(event['timestamp']), tz=timezone.utc)
|
||||
timestamp = datetime.fromtimestamp(int(event["timestamp"]), tz=timezone.utc)
|
||||
except (KeyError, TypeError, ValueError):
|
||||
timestamp = None
|
||||
|
||||
try:
|
||||
tag = event['campaign_id'] # not 'rcpt_tags' -- those don't come from sending a message
|
||||
tag = event["campaign_id"]
|
||||
# not "rcpt_tags" -- those don't come from sending a message
|
||||
tags = [tag] if tag else None
|
||||
except KeyError:
|
||||
tags = []
|
||||
|
||||
try:
|
||||
reject_reason = self.reject_reasons.get(event['bounce_class'], RejectReason.OTHER)
|
||||
try: # unpack (RejectReason, EventType) for reasons that change our event type
|
||||
reject_reason = self.reject_reasons.get(
|
||||
event["bounce_class"], RejectReason.OTHER
|
||||
)
|
||||
try:
|
||||
# unpack (RejectReason, EventType)
|
||||
# for reasons that change our event type
|
||||
reject_reason, event_type = reject_reason
|
||||
except ValueError:
|
||||
pass
|
||||
@@ -148,16 +166,19 @@ class SparkPostTrackingWebhookView(SparkPostBaseWebhookView):
|
||||
return AnymailTrackingEvent(
|
||||
event_type=event_type,
|
||||
timestamp=timestamp,
|
||||
message_id=event.get('transmission_id', None), # not 'message_id' -- see SparkPost backend
|
||||
event_id=event.get('event_id', None),
|
||||
recipient=event.get('raw_rcpt_to', None), # preserves email case (vs. 'rcpt_to')
|
||||
# use transmission_id, not message_id -- see SparkPost backend
|
||||
message_id=event.get("transmission_id", None),
|
||||
event_id=event.get("event_id", None),
|
||||
# raw_rcpt_to preserves email case (vs. rcpt_to)
|
||||
recipient=event.get("raw_rcpt_to", None),
|
||||
reject_reason=reject_reason,
|
||||
mta_response=event.get('raw_reason', None),
|
||||
mta_response=event.get("raw_reason", None),
|
||||
# description=???,
|
||||
tags=tags,
|
||||
metadata=event.get('rcpt_meta', None) or {}, # message + recipient metadata
|
||||
click_url=event.get('target_link_url', None),
|
||||
user_agent=event.get('user_agent', None),
|
||||
# metadata includes message + recipient metadata
|
||||
metadata=event.get("rcpt_meta", None) or {},
|
||||
click_url=event.get("target_link_url", None),
|
||||
user_agent=event.get("user_agent", None),
|
||||
esp_event=raw_event,
|
||||
)
|
||||
|
||||
@@ -168,29 +189,35 @@ class SparkPostInboundWebhookView(SparkPostBaseWebhookView):
|
||||
signal = inbound
|
||||
|
||||
def esp_to_anymail_event(self, event_class, event, raw_event):
|
||||
if event_class != 'relay_message':
|
||||
if event_class != "relay_message":
|
||||
# This is not an inbound event
|
||||
raise AnymailConfigurationError(
|
||||
"You seem to have set SparkPost's *tracking* webhook URL "
|
||||
"to Anymail's SparkPost *inbound* relay webhook URL.")
|
||||
"to Anymail's SparkPost *inbound* relay webhook URL."
|
||||
)
|
||||
|
||||
if event['protocol'] != 'smtp':
|
||||
if event["protocol"] != "smtp":
|
||||
raise AnymailConfigurationError(
|
||||
"You cannot use Anymail's webhooks for SparkPost '{protocol}' relay events. "
|
||||
"Anymail only handles the 'smtp' protocol".format(protocol=event['protocol']))
|
||||
"You cannot use Anymail's webhooks for SparkPost '{protocol}' relay"
|
||||
" events. Anymail only handles the 'smtp' protocol".format(
|
||||
protocol=event["protocol"]
|
||||
)
|
||||
)
|
||||
|
||||
raw_mime = event['content']['email_rfc822']
|
||||
if event['content']['email_rfc822_is_base64']:
|
||||
raw_mime = b64decode(raw_mime).decode('utf-8')
|
||||
raw_mime = event["content"]["email_rfc822"]
|
||||
if event["content"]["email_rfc822_is_base64"]:
|
||||
raw_mime = b64decode(raw_mime).decode("utf-8")
|
||||
message = AnymailInboundMessage.parse_raw_mime(raw_mime)
|
||||
|
||||
message.envelope_sender = event.get('msg_from', None)
|
||||
message.envelope_recipient = event.get('rcpt_to', None)
|
||||
message.envelope_sender = event.get("msg_from", None)
|
||||
message.envelope_recipient = event.get("rcpt_to", None)
|
||||
|
||||
return AnymailInboundEvent(
|
||||
event_type=EventType.INBOUND,
|
||||
timestamp=None, # SparkPost does not provide a relay event timestamp
|
||||
event_id=None, # SparkPost does not provide an idempotent id for relay events
|
||||
# SparkPost does not provide a relay event timestamp
|
||||
timestamp=None,
|
||||
# SparkPost does not provide an idempotent id for relay events
|
||||
event_id=None,
|
||||
esp_event=raw_event,
|
||||
message=message,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user