Reformat code with automated tools

Apply standardized code style
This commit is contained in:
medmunds
2023-02-06 12:27:43 -08:00
committed by Mike Edmunds
parent 40891fcb4a
commit b4e22c63b3
94 changed files with 12936 additions and 7443 deletions

View File

@@ -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"

View File

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

View File

@@ -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):

View File

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

View File

@@ -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__
)

View File

@@ -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):

View File

@@ -1,4 +1,5 @@
import uuid
from django.core.mail.backends.console import EmailBackend as DjangoConsoleBackend
from ..exceptions import AnymailError

View File

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

View File

@@ -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):

View File

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

View File

@@ -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):

View File

@@ -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)

View File

@@ -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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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):

View File

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

View File

@@ -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"

View File

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

View File

@@ -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:

View File

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

View File

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

View File

@@ -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,
)

View File

@@ -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,
)

View File

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

View File

@@ -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,

View File

@@ -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", ""),
)
)

View File

@@ -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,
)

View File

@@ -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,

View File

@@ -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,
)