mirror of
https://github.com/pacnpal/django-anymail.git
synced 2025-12-21 12:21:06 -05:00
Reformat code with automated tools
Apply standardized code style
This commit is contained in:
@@ -1,18 +1,20 @@
|
||||
from email.charset import Charset, QP
|
||||
from email.charset import QP, Charset
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
from .base import AnymailBaseBackend, BasePayload
|
||||
from .._version import __version__
|
||||
from ..exceptions import AnymailAPIError, AnymailImproperlyInstalled
|
||||
from ..message import AnymailRecipientStatus
|
||||
from ..utils import get_anymail_setting, UNSET
|
||||
from ..utils import UNSET, get_anymail_setting
|
||||
from .base import AnymailBaseBackend, BasePayload
|
||||
|
||||
try:
|
||||
import boto3
|
||||
from botocore.client import Config
|
||||
from botocore.exceptions import BotoCoreError, ClientError, ConnectionError
|
||||
except ImportError as err:
|
||||
raise AnymailImproperlyInstalled(missing_package='boto3', backend='amazon_ses') from err
|
||||
raise AnymailImproperlyInstalled(
|
||||
missing_package="boto3", backend="amazon_ses"
|
||||
) from err
|
||||
|
||||
|
||||
# boto3 has several root exception classes; this is meant to cover all of them
|
||||
@@ -29,19 +31,34 @@ class EmailBackend(AnymailBaseBackend):
|
||||
def __init__(self, **kwargs):
|
||||
"""Init options from Django settings"""
|
||||
super().__init__(**kwargs)
|
||||
# AMAZON_SES_CLIENT_PARAMS is optional - boto3 can find credentials several other ways
|
||||
self.session_params, self.client_params = _get_anymail_boto3_params(kwargs=kwargs)
|
||||
self.configuration_set_name = get_anymail_setting("configuration_set_name", esp_name=self.esp_name,
|
||||
kwargs=kwargs, allow_bare=False, default=None)
|
||||
self.message_tag_name = get_anymail_setting("message_tag_name", esp_name=self.esp_name,
|
||||
kwargs=kwargs, allow_bare=False, default=None)
|
||||
# AMAZON_SES_CLIENT_PARAMS is optional
|
||||
# (boto3 can find credentials several other ways)
|
||||
self.session_params, self.client_params = _get_anymail_boto3_params(
|
||||
kwargs=kwargs
|
||||
)
|
||||
self.configuration_set_name = get_anymail_setting(
|
||||
"configuration_set_name",
|
||||
esp_name=self.esp_name,
|
||||
kwargs=kwargs,
|
||||
allow_bare=False,
|
||||
default=None,
|
||||
)
|
||||
self.message_tag_name = get_anymail_setting(
|
||||
"message_tag_name",
|
||||
esp_name=self.esp_name,
|
||||
kwargs=kwargs,
|
||||
allow_bare=False,
|
||||
default=None,
|
||||
)
|
||||
self.client = None
|
||||
|
||||
def open(self):
|
||||
if self.client:
|
||||
return False # already exists
|
||||
try:
|
||||
self.client = boto3.session.Session(**self.session_params).client("ses", **self.client_params)
|
||||
self.client = boto3.session.Session(**self.session_params).client(
|
||||
"ses", **self.client_params
|
||||
)
|
||||
except BOTO_BASE_ERRORS:
|
||||
if not self.fail_silently:
|
||||
raise
|
||||
@@ -51,7 +68,7 @@ class EmailBackend(AnymailBaseBackend):
|
||||
def close(self):
|
||||
if self.client is None:
|
||||
return
|
||||
# self.client.close() # boto3 doesn't currently seem to support (or require) this
|
||||
# self.client.close() # boto3 doesn't support (or require) client shutdown
|
||||
self.client = None
|
||||
|
||||
def build_message_payload(self, message, defaults):
|
||||
@@ -66,9 +83,15 @@ class EmailBackend(AnymailBaseBackend):
|
||||
try:
|
||||
response = payload.call_send_api(self.client)
|
||||
except BOTO_BASE_ERRORS as err:
|
||||
# ClientError has a response attr with parsed json error response (other errors don't)
|
||||
raise AnymailAPIError(str(err), backend=self, email_message=message, payload=payload,
|
||||
response=getattr(err, 'response', None)) from err
|
||||
# ClientError has a response attr with parsed json error response
|
||||
# (other errors don't)
|
||||
raise AnymailAPIError(
|
||||
str(err),
|
||||
backend=self,
|
||||
email_message=message,
|
||||
payload=payload,
|
||||
response=getattr(err, "response", None),
|
||||
) from err
|
||||
return response
|
||||
|
||||
def parse_recipient_status(self, response, payload, message):
|
||||
@@ -105,15 +128,19 @@ class AmazonSESSendRawEmailPayload(AmazonSESBasePayload):
|
||||
# (which is Django email's default for most non-ASCII bodies)
|
||||
# - you are using an SES ConfigurationSet with open or click tracking enabled
|
||||
# then SES replaces the non-ASCII characters with question marks as it rewrites
|
||||
# the message to add tracking. Forcing `CTE: quoted-printable` avoids the problem.
|
||||
# (https://forums.aws.amazon.com/thread.jspa?threadID=287048)
|
||||
# the message to add tracking. Forcing `CTE: quoted-printable` avoids the
|
||||
# problem. (https://forums.aws.amazon.com/thread.jspa?threadID=287048)
|
||||
for part in self.mime_message.walk():
|
||||
if part.get_content_maintype() == "text" and part["Content-Transfer-Encoding"] == "8bit":
|
||||
if (
|
||||
part.get_content_maintype() == "text"
|
||||
and part["Content-Transfer-Encoding"] == "8bit"
|
||||
):
|
||||
content = part.get_payload()
|
||||
del part["Content-Transfer-Encoding"]
|
||||
qp_charset = Charset(part.get_content_charset("us-ascii"))
|
||||
qp_charset.body_encoding = QP
|
||||
# (can't use part.set_payload, because SafeMIMEText can undo this workaround)
|
||||
# (can't use part.set_payload, because SafeMIMEText can undo
|
||||
# this workaround)
|
||||
MIMEText.set_payload(part, content, charset=qp_charset)
|
||||
|
||||
def call_send_api(self, ses_client):
|
||||
@@ -121,9 +148,7 @@ class AmazonSESSendRawEmailPayload(AmazonSESBasePayload):
|
||||
# Any non-ASCII characters in recipient domains must be encoded with Punycode.
|
||||
# (Amazon SES doesn't support non-ASCII recipient usernames.)
|
||||
self.params["Destinations"] = [email.address for email in self.all_recipients]
|
||||
self.params["RawMessage"] = {
|
||||
"Data": self.mime_message.as_bytes()
|
||||
}
|
||||
self.params["RawMessage"] = {"Data": self.mime_message.as_bytes()}
|
||||
return ses_client.send_raw_email(**self.params)
|
||||
|
||||
def parse_recipient_status(self, response):
|
||||
@@ -132,23 +157,34 @@ class AmazonSESSendRawEmailPayload(AmazonSESBasePayload):
|
||||
except (KeyError, TypeError) as err:
|
||||
raise AnymailAPIError(
|
||||
"%s parsing Amazon SES send result %r" % (str(err), response),
|
||||
backend=self.backend, email_message=self.message, payload=self) from None
|
||||
backend=self.backend,
|
||||
email_message=self.message,
|
||||
payload=self,
|
||||
) from None
|
||||
|
||||
recipient_status = AnymailRecipientStatus(message_id=message_id, status="queued")
|
||||
return {recipient.addr_spec: recipient_status for recipient in self.all_recipients}
|
||||
recipient_status = AnymailRecipientStatus(
|
||||
message_id=message_id, status="queued"
|
||||
)
|
||||
return {
|
||||
recipient.addr_spec: recipient_status for recipient in self.all_recipients
|
||||
}
|
||||
|
||||
# Standard EmailMessage attrs...
|
||||
# These all get rolled into the RFC-5322 raw mime directly via EmailMessage.message()
|
||||
# These all get rolled into the RFC-5322 raw mime directly via
|
||||
# EmailMessage.message()
|
||||
|
||||
def _no_send_defaults(self, attr):
|
||||
# Anymail global send defaults don't work for standard attrs, because the
|
||||
# merged/computed value isn't forced back into the EmailMessage.
|
||||
if attr in self.defaults:
|
||||
self.unsupported_feature("Anymail send defaults for '%s' with Amazon SES" % attr)
|
||||
self.unsupported_feature(
|
||||
"Anymail send defaults for '%s' with Amazon SES" % attr
|
||||
)
|
||||
|
||||
def set_from_email_list(self, emails):
|
||||
# Although Amazon SES will send messages with any From header, it can only parse Source
|
||||
# if the From header is a single email. Explicit Source avoids an "Illegal address" error:
|
||||
# Although Amazon SES will send messages with any From header, it can only parse
|
||||
# Source if the From header is a single email. Explicit Source avoids an
|
||||
# "Illegal address" error:
|
||||
if len(emails) > 1:
|
||||
self.params["Source"] = emails[0].addr_spec
|
||||
# (else SES will look at the (single) address in the From header)
|
||||
@@ -212,29 +248,38 @@ class AmazonSESSendRawEmailPayload(AmazonSESBasePayload):
|
||||
# notifications for SES *events* but not SES *notifications*. (Got that?)
|
||||
# Message Tags also allow *very* limited characters in both name and value.
|
||||
# Message Tags can be sent with any SES send call.
|
||||
# (See "How do message tags work?" in https://aws.amazon.com/blogs/ses/introducing-sending-metrics/
|
||||
# (See "How do message tags work?" in
|
||||
# https://aws.amazon.com/blogs/ses/introducing-sending-metrics/
|
||||
# and https://forums.aws.amazon.com/thread.jspa?messageID=782922.)
|
||||
# To support reliable retrieval in webhooks, just use custom headers for metadata.
|
||||
# To support reliable retrieval in webhooks, just use custom headers for
|
||||
# metadata.
|
||||
self.mime_message["X-Metadata"] = self.serialize_json(metadata)
|
||||
|
||||
def set_tags(self, tags):
|
||||
# See note about Amazon SES Message Tags and custom headers in set_metadata above.
|
||||
# To support reliable retrieval in webhooks, use custom headers for tags.
|
||||
# See note about Amazon SES Message Tags and custom headers in set_metadata
|
||||
# above. To support reliable retrieval in webhooks, use custom headers for tags.
|
||||
# (There are no restrictions on number or content for custom header tags.)
|
||||
for tag in tags:
|
||||
self.mime_message.add_header("X-Tag", tag) # creates multiple X-Tag headers, one per tag
|
||||
# creates multiple X-Tag headers, one per tag:
|
||||
self.mime_message.add_header("X-Tag", tag)
|
||||
|
||||
# Also *optionally* pass a single Message Tag if the AMAZON_SES_MESSAGE_TAG_NAME
|
||||
# Anymail setting is set (default no). The AWS API restricts tag content in this case.
|
||||
# (This is useful for dashboard segmentation; use esp_extra["Tags"] for anything more complex.)
|
||||
# Anymail setting is set (default no). The AWS API restricts tag content in this
|
||||
# case. (This is useful for dashboard segmentation; use esp_extra["Tags"] for
|
||||
# anything more complex.)
|
||||
if tags and self.backend.message_tag_name is not None:
|
||||
if len(tags) > 1:
|
||||
self.unsupported_feature("multiple tags with the AMAZON_SES_MESSAGE_TAG_NAME setting")
|
||||
self.unsupported_feature(
|
||||
"multiple tags with the AMAZON_SES_MESSAGE_TAG_NAME setting"
|
||||
)
|
||||
self.params.setdefault("Tags", []).append(
|
||||
{"Name": self.backend.message_tag_name, "Value": tags[0]})
|
||||
{"Name": self.backend.message_tag_name, "Value": tags[0]}
|
||||
)
|
||||
|
||||
def set_template_id(self, template_id):
|
||||
raise NotImplementedError("AmazonSESSendRawEmailPayload should not have been used with template_id")
|
||||
raise NotImplementedError(
|
||||
"AmazonSESSendRawEmailPayload should not have been used with template_id"
|
||||
)
|
||||
|
||||
def set_merge_data(self, merge_data):
|
||||
self.unsupported_feature("merge_data without template_id")
|
||||
@@ -254,15 +299,24 @@ class AmazonSESSendBulkTemplatedEmailPayload(AmazonSESBasePayload):
|
||||
# include any 'cc' or 'bcc' in every destination
|
||||
cc_and_bcc_addresses = {}
|
||||
if self.recipients["cc"]:
|
||||
cc_and_bcc_addresses["CcAddresses"] = [cc.address for cc in self.recipients["cc"]]
|
||||
cc_and_bcc_addresses["CcAddresses"] = [
|
||||
cc.address for cc in self.recipients["cc"]
|
||||
]
|
||||
if self.recipients["bcc"]:
|
||||
cc_and_bcc_addresses["BccAddresses"] = [bcc.address for bcc in self.recipients["bcc"]]
|
||||
cc_and_bcc_addresses["BccAddresses"] = [
|
||||
bcc.address for bcc in self.recipients["bcc"]
|
||||
]
|
||||
|
||||
# set up destination and data for each 'to'
|
||||
self.params["Destinations"] = [{
|
||||
"Destination": dict(ToAddresses=[to.address], **cc_and_bcc_addresses),
|
||||
"ReplacementTemplateData": self.serialize_json(self.merge_data.get(to.addr_spec, {}))
|
||||
} for to in self.recipients["to"]]
|
||||
self.params["Destinations"] = [
|
||||
{
|
||||
"Destination": dict(ToAddresses=[to.address], **cc_and_bcc_addresses),
|
||||
"ReplacementTemplateData": self.serialize_json(
|
||||
self.merge_data.get(to.addr_spec, {})
|
||||
),
|
||||
}
|
||||
for to in self.recipients["to"]
|
||||
]
|
||||
|
||||
return ses_client.send_bulk_templated_email(**self.params)
|
||||
|
||||
@@ -272,25 +326,33 @@ class AmazonSESSendBulkTemplatedEmailPayload(AmazonSESBasePayload):
|
||||
anymail_statuses = [
|
||||
AnymailRecipientStatus(
|
||||
message_id=status.get("MessageId", None),
|
||||
status='queued' if status.get("Status") == "Success" else 'failed')
|
||||
status="queued" if status.get("Status") == "Success" else "failed",
|
||||
)
|
||||
for status in response["Status"]
|
||||
]
|
||||
except (KeyError, TypeError) as err:
|
||||
raise AnymailAPIError(
|
||||
"%s parsing Amazon SES send result %r" % (str(err), response),
|
||||
backend=self.backend, email_message=self.message, payload=self) from None
|
||||
backend=self.backend,
|
||||
email_message=self.message,
|
||||
payload=self,
|
||||
) from None
|
||||
|
||||
to_addrs = [to.addr_spec for to in self.recipients["to"]]
|
||||
if len(anymail_statuses) != len(to_addrs):
|
||||
raise AnymailAPIError(
|
||||
"Sent to %d destinations, but only %d statuses in Amazon SES send result %r"
|
||||
% (len(to_addrs), len(anymail_statuses), response),
|
||||
backend=self.backend, email_message=self.message, payload=self)
|
||||
"Sent to %d destinations, but only %d statuses in Amazon SES"
|
||||
" send result %r" % (len(to_addrs), len(anymail_statuses), response),
|
||||
backend=self.backend,
|
||||
email_message=self.message,
|
||||
payload=self,
|
||||
)
|
||||
|
||||
return dict(zip(to_addrs, anymail_statuses))
|
||||
|
||||
def set_from_email(self, email):
|
||||
self.params["Source"] = email.address # this will RFC2047-encode display_name if needed
|
||||
# this will RFC2047-encode display_name if needed:
|
||||
self.params["Source"] = email.address
|
||||
|
||||
def set_recipients(self, recipient_type, emails):
|
||||
# late-bound in call_send_api
|
||||
@@ -330,16 +392,23 @@ class AmazonSESSendBulkTemplatedEmailPayload(AmazonSESBasePayload):
|
||||
self.unsupported_feature("metadata with template")
|
||||
|
||||
def set_tags(self, tags):
|
||||
# no custom headers with SendBulkTemplatedEmail, but support AMAZON_SES_MESSAGE_TAG_NAME if used
|
||||
# (see tags/metadata in AmazonSESSendRawEmailPayload for more info)
|
||||
# no custom headers with SendBulkTemplatedEmail, but support
|
||||
# AMAZON_SES_MESSAGE_TAG_NAME if used (see tags/metadata in
|
||||
# AmazonSESSendRawEmailPayload for more info)
|
||||
if tags:
|
||||
if self.backend.message_tag_name is not None:
|
||||
if len(tags) > 1:
|
||||
self.unsupported_feature("multiple tags with the AMAZON_SES_MESSAGE_TAG_NAME setting")
|
||||
self.params["DefaultTags"] = [{"Name": self.backend.message_tag_name, "Value": tags[0]}]
|
||||
self.unsupported_feature(
|
||||
"multiple tags with the AMAZON_SES_MESSAGE_TAG_NAME setting"
|
||||
)
|
||||
self.params["DefaultTags"] = [
|
||||
{"Name": self.backend.message_tag_name, "Value": tags[0]}
|
||||
]
|
||||
else:
|
||||
self.unsupported_feature(
|
||||
"tags with template (unless using the AMAZON_SES_MESSAGE_TAG_NAME setting)")
|
||||
"tags with template (unless using the"
|
||||
" AMAZON_SES_MESSAGE_TAG_NAME setting)"
|
||||
)
|
||||
|
||||
def set_template_id(self, template_id):
|
||||
self.params["Template"] = template_id
|
||||
@@ -363,13 +432,20 @@ def _get_anymail_boto3_params(esp_name=EmailBackend.esp_name, kwargs=None):
|
||||
May remove keys from kwargs, but won't modify original settings
|
||||
"""
|
||||
# (shared with ..webhooks.amazon_ses)
|
||||
session_params = get_anymail_setting("session_params", esp_name=esp_name, kwargs=kwargs, default={})
|
||||
client_params = get_anymail_setting("client_params", esp_name=esp_name, kwargs=kwargs, default={})
|
||||
session_params = get_anymail_setting(
|
||||
"session_params", esp_name=esp_name, kwargs=kwargs, default={}
|
||||
)
|
||||
client_params = get_anymail_setting(
|
||||
"client_params", esp_name=esp_name, kwargs=kwargs, default={}
|
||||
)
|
||||
|
||||
# Add Anymail user-agent, and convert config dict to botocore.client.Config
|
||||
client_params = client_params.copy() # don't modify source
|
||||
config = Config(user_agent_extra="django-anymail/{version}-{esp}".format(
|
||||
esp=esp_name.lower().replace(" ", "-"), version=__version__))
|
||||
config = Config(
|
||||
user_agent_extra="django-anymail/{version}-{esp}".format(
|
||||
esp=esp_name.lower().replace(" ", "-"), version=__version__
|
||||
)
|
||||
)
|
||||
if "config" in client_params:
|
||||
# convert config dict to botocore.client.Config if needed
|
||||
client_params_config = client_params["config"]
|
||||
|
||||
@@ -3,17 +3,31 @@ from datetime import date, datetime, timezone
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.mail.backends.base import BaseEmailBackend
|
||||
from django.utils.timezone import is_naive, get_current_timezone, make_aware
|
||||
from django.utils.timezone import get_current_timezone, is_naive, make_aware
|
||||
from requests.structures import CaseInsensitiveDict
|
||||
|
||||
from ..exceptions import (
|
||||
AnymailCancelSend, AnymailError, AnymailUnsupportedFeature, AnymailRecipientsRefused,
|
||||
AnymailSerializationError)
|
||||
AnymailCancelSend,
|
||||
AnymailError,
|
||||
AnymailRecipientsRefused,
|
||||
AnymailSerializationError,
|
||||
AnymailUnsupportedFeature,
|
||||
)
|
||||
from ..message import AnymailStatus
|
||||
from ..signals import pre_send, post_send
|
||||
from ..signals import post_send, pre_send
|
||||
from ..utils import (
|
||||
Attachment, UNSET, combine, last, get_anymail_setting, parse_address_list, parse_single_address,
|
||||
force_non_lazy, force_non_lazy_list, force_non_lazy_dict, is_lazy)
|
||||
UNSET,
|
||||
Attachment,
|
||||
combine,
|
||||
force_non_lazy,
|
||||
force_non_lazy_dict,
|
||||
force_non_lazy_list,
|
||||
get_anymail_setting,
|
||||
is_lazy,
|
||||
last,
|
||||
parse_address_list,
|
||||
parse_single_address,
|
||||
)
|
||||
|
||||
|
||||
class AnymailBaseBackend(BaseEmailBackend):
|
||||
@@ -24,17 +38,23 @@ class AnymailBaseBackend(BaseEmailBackend):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.ignore_unsupported_features = get_anymail_setting('ignore_unsupported_features',
|
||||
kwargs=kwargs, default=False)
|
||||
self.ignore_recipient_status = get_anymail_setting('ignore_recipient_status',
|
||||
kwargs=kwargs, default=False)
|
||||
self.debug_api_requests = get_anymail_setting('debug_api_requests', # generate debug output
|
||||
kwargs=kwargs, default=False)
|
||||
self.ignore_unsupported_features = get_anymail_setting(
|
||||
"ignore_unsupported_features", kwargs=kwargs, default=False
|
||||
)
|
||||
self.ignore_recipient_status = get_anymail_setting(
|
||||
"ignore_recipient_status", kwargs=kwargs, default=False
|
||||
)
|
||||
self.debug_api_requests = get_anymail_setting(
|
||||
"debug_api_requests", kwargs=kwargs, default=False
|
||||
)
|
||||
|
||||
# Merge SEND_DEFAULTS and <esp_name>_SEND_DEFAULTS settings
|
||||
send_defaults = get_anymail_setting('send_defaults', default={}) # but not from kwargs
|
||||
esp_send_defaults = get_anymail_setting('send_defaults', esp_name=self.esp_name,
|
||||
kwargs=kwargs, default=None)
|
||||
send_defaults = get_anymail_setting(
|
||||
"send_defaults", default={} # but not from kwargs
|
||||
)
|
||||
esp_send_defaults = get_anymail_setting(
|
||||
"send_defaults", esp_name=self.esp_name, kwargs=kwargs, default=None
|
||||
)
|
||||
if esp_send_defaults is not None:
|
||||
send_defaults = send_defaults.copy()
|
||||
send_defaults.update(esp_send_defaults)
|
||||
@@ -128,7 +148,9 @@ class AnymailBaseBackend(BaseEmailBackend):
|
||||
message.anymail_status.set_recipient_status(recipient_status)
|
||||
|
||||
self.run_post_send(message) # send signal before raising status errors
|
||||
self.raise_for_recipient_status(message.anymail_status, response, payload, message)
|
||||
self.raise_for_recipient_status(
|
||||
message.anymail_status, response, payload, message
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
@@ -143,8 +165,12 @@ class AnymailBaseBackend(BaseEmailBackend):
|
||||
def run_post_send(self, message):
|
||||
"""Send post_send signal to all receivers"""
|
||||
results = post_send.send_robust(
|
||||
self.__class__, message=message, status=message.anymail_status, esp_name=self.esp_name)
|
||||
for (receiver, response) in results:
|
||||
self.__class__,
|
||||
message=message,
|
||||
status=message.anymail_status,
|
||||
esp_name=self.esp_name,
|
||||
)
|
||||
for receiver, response in results:
|
||||
if isinstance(response, Exception):
|
||||
raise response
|
||||
|
||||
@@ -161,8 +187,10 @@ class AnymailBaseBackend(BaseEmailBackend):
|
||||
:param defaults: dict
|
||||
:return: :class:BasePayload
|
||||
"""
|
||||
raise NotImplementedError("%s.%s must implement build_message_payload" %
|
||||
(self.__class__.__module__, self.__class__.__name__))
|
||||
raise NotImplementedError(
|
||||
"%s.%s must implement build_message_payload"
|
||||
% (self.__class__.__module__, self.__class__.__name__)
|
||||
)
|
||||
|
||||
def post_to_esp(self, payload, message):
|
||||
"""Post payload to ESP send API endpoint, and return the raw response.
|
||||
@@ -173,25 +201,35 @@ class AnymailBaseBackend(BaseEmailBackend):
|
||||
|
||||
Can raise AnymailAPIError (or derived exception) for problems posting to the ESP
|
||||
"""
|
||||
raise NotImplementedError("%s.%s must implement post_to_esp" %
|
||||
(self.__class__.__module__, self.__class__.__name__))
|
||||
raise NotImplementedError(
|
||||
"%s.%s must implement post_to_esp"
|
||||
% (self.__class__.__module__, self.__class__.__name__)
|
||||
)
|
||||
|
||||
def parse_recipient_status(self, response, payload, message):
|
||||
"""Return a dict mapping email to AnymailRecipientStatus for each recipient.
|
||||
|
||||
Can raise AnymailAPIError (or derived exception) if response is unparsable
|
||||
"""
|
||||
raise NotImplementedError("%s.%s must implement parse_recipient_status" %
|
||||
(self.__class__.__module__, self.__class__.__name__))
|
||||
raise NotImplementedError(
|
||||
"%s.%s must implement parse_recipient_status"
|
||||
% (self.__class__.__module__, self.__class__.__name__)
|
||||
)
|
||||
|
||||
def raise_for_recipient_status(self, anymail_status, response, payload, message):
|
||||
"""If *all* recipients are refused or invalid, raises AnymailRecipientsRefused"""
|
||||
"""
|
||||
If *all* recipients are refused or invalid, raises AnymailRecipientsRefused
|
||||
"""
|
||||
if not self.ignore_recipient_status:
|
||||
# Error if *all* recipients are invalid or refused
|
||||
# (This behavior parallels smtplib.SMTPRecipientsRefused from Django's SMTP EmailBackend)
|
||||
# Error if *all* recipients are invalid or refused. (This behavior parallels
|
||||
# smtplib.SMTPRecipientsRefused from Django's SMTP EmailBackend.)
|
||||
if anymail_status.status.issubset({"invalid", "rejected"}):
|
||||
raise AnymailRecipientsRefused(email_message=message, payload=payload, response=response,
|
||||
backend=self)
|
||||
raise AnymailRecipientsRefused(
|
||||
email_message=message,
|
||||
payload=payload,
|
||||
response=response,
|
||||
backend=self,
|
||||
)
|
||||
|
||||
@property
|
||||
def esp_name(self):
|
||||
@@ -202,8 +240,10 @@ class AnymailBaseBackend(BaseEmailBackend):
|
||||
esp_name = "Postmark"
|
||||
esp_name = "SendGrid" # (use ESP's preferred capitalization)
|
||||
"""
|
||||
raise NotImplementedError("%s.%s must declare esp_name class attr" %
|
||||
(self.__class__.__module__, self.__class__.__name__))
|
||||
raise NotImplementedError(
|
||||
"%s.%s must declare esp_name class attr"
|
||||
% (self.__class__.__module__, self.__class__.__name__)
|
||||
)
|
||||
|
||||
|
||||
class BasePayload:
|
||||
@@ -222,36 +262,36 @@ class BasePayload:
|
||||
# the combined/converted results for each attr.
|
||||
base_message_attrs = (
|
||||
# Standard EmailMessage/EmailMultiAlternatives props
|
||||
('from_email', last, parse_address_list), # multiple from_emails are allowed
|
||||
('to', combine, parse_address_list),
|
||||
('cc', combine, parse_address_list),
|
||||
('bcc', combine, parse_address_list),
|
||||
('subject', last, force_non_lazy),
|
||||
('reply_to', combine, parse_address_list),
|
||||
('extra_headers', combine, force_non_lazy_dict),
|
||||
('body', last, force_non_lazy), # special handling below checks message.content_subtype
|
||||
('alternatives', combine, 'prepped_alternatives'),
|
||||
('attachments', combine, 'prepped_attachments'),
|
||||
("from_email", last, parse_address_list), # multiple from_emails are allowed
|
||||
("to", combine, parse_address_list),
|
||||
("cc", combine, parse_address_list),
|
||||
("bcc", combine, parse_address_list),
|
||||
("subject", last, force_non_lazy),
|
||||
("reply_to", combine, parse_address_list),
|
||||
("extra_headers", combine, force_non_lazy_dict),
|
||||
("body", last, force_non_lazy), # set_body handles content_subtype
|
||||
("alternatives", combine, "prepped_alternatives"),
|
||||
("attachments", combine, "prepped_attachments"),
|
||||
)
|
||||
anymail_message_attrs = (
|
||||
# Anymail expando-props
|
||||
('envelope_sender', last, parse_single_address),
|
||||
('metadata', combine, force_non_lazy_dict),
|
||||
('send_at', last, 'aware_datetime'),
|
||||
('tags', combine, force_non_lazy_list),
|
||||
('track_clicks', last, None),
|
||||
('track_opens', last, None),
|
||||
('template_id', last, force_non_lazy),
|
||||
('merge_data', combine, force_non_lazy_dict),
|
||||
('merge_global_data', combine, force_non_lazy_dict),
|
||||
('merge_metadata', combine, force_non_lazy_dict),
|
||||
('esp_extra', combine, force_non_lazy_dict),
|
||||
("envelope_sender", last, parse_single_address),
|
||||
("metadata", combine, force_non_lazy_dict),
|
||||
("send_at", last, "aware_datetime"),
|
||||
("tags", combine, force_non_lazy_list),
|
||||
("track_clicks", last, None),
|
||||
("track_opens", last, None),
|
||||
("template_id", last, force_non_lazy),
|
||||
("merge_data", combine, force_non_lazy_dict),
|
||||
("merge_global_data", combine, force_non_lazy_dict),
|
||||
("merge_metadata", combine, force_non_lazy_dict),
|
||||
("esp_extra", combine, force_non_lazy_dict),
|
||||
)
|
||||
esp_message_attrs = () # subclasses can override
|
||||
|
||||
# If any of these attrs are set on a message, treat the message
|
||||
# as a batch send (separate message for each `to` recipient):
|
||||
batch_attrs = ('merge_data', 'merge_metadata')
|
||||
batch_attrs = ("merge_data", "merge_metadata")
|
||||
|
||||
def __init__(self, message, defaults, backend):
|
||||
self.message = message
|
||||
@@ -262,11 +302,16 @@ class BasePayload:
|
||||
|
||||
self.init_payload()
|
||||
|
||||
# we should consider hoisting the first text/html out of alternatives into set_html_body
|
||||
message_attrs = self.base_message_attrs + self.anymail_message_attrs + self.esp_message_attrs
|
||||
# we should consider hoisting the first text/html
|
||||
# out of alternatives into set_html_body
|
||||
message_attrs = (
|
||||
self.base_message_attrs
|
||||
+ self.anymail_message_attrs
|
||||
+ self.esp_message_attrs
|
||||
)
|
||||
for attr, combiner, converter in message_attrs:
|
||||
value = getattr(message, attr, UNSET)
|
||||
if attr in ('to', 'cc', 'bcc', 'reply_to') and value is not UNSET:
|
||||
if attr in ("to", "cc", "bcc", "reply_to") and value is not UNSET:
|
||||
self.validate_not_bare_string(attr, value)
|
||||
if combiner is not None:
|
||||
default_value = self.defaults.get(attr, UNSET)
|
||||
@@ -281,16 +326,17 @@ class BasePayload:
|
||||
else:
|
||||
value = converter(value)
|
||||
if value is not UNSET:
|
||||
if attr == 'from_email':
|
||||
if attr == "from_email":
|
||||
setter = self.set_from_email_list
|
||||
elif attr == 'extra_headers':
|
||||
elif attr == "extra_headers":
|
||||
setter = self.process_extra_headers
|
||||
else:
|
||||
# AttributeError here? Your Payload subclass is missing a set_<attr> implementation
|
||||
setter = getattr(self, 'set_%s' % attr)
|
||||
# AttributeError here? Your Payload subclass is missing
|
||||
# a set_<attr> implementation
|
||||
setter = getattr(self, "set_%s" % attr)
|
||||
setter(value)
|
||||
if attr in self.batch_attrs:
|
||||
self._batch_attrs_used[attr] = (value is not UNSET)
|
||||
self._batch_attrs_used[attr] = value is not UNSET
|
||||
|
||||
def is_batch(self):
|
||||
"""
|
||||
@@ -301,45 +347,62 @@ class BasePayload:
|
||||
inside a set_<attr> method or during __init__).
|
||||
"""
|
||||
batch_attrs_used = self._batch_attrs_used.values()
|
||||
assert UNSET not in batch_attrs_used, "Cannot call is_batch before all attributes processed"
|
||||
assert (
|
||||
UNSET not in batch_attrs_used
|
||||
), "Cannot call is_batch before all attributes processed"
|
||||
return any(batch_attrs_used)
|
||||
|
||||
def unsupported_feature(self, feature):
|
||||
if not self.backend.ignore_unsupported_features:
|
||||
raise AnymailUnsupportedFeature("%s does not support %s" % (self.esp_name, feature),
|
||||
email_message=self.message, payload=self, backend=self.backend)
|
||||
raise AnymailUnsupportedFeature(
|
||||
"%s does not support %s" % (self.esp_name, feature),
|
||||
email_message=self.message,
|
||||
payload=self,
|
||||
backend=self.backend,
|
||||
)
|
||||
|
||||
def process_extra_headers(self, headers):
|
||||
# Handle some special-case headers, and pass the remainder to set_extra_headers.
|
||||
# (Subclasses shouldn't need to override this.)
|
||||
headers = CaseInsensitiveDict(headers) # email headers are case-insensitive per RFC-822 et seq
|
||||
|
||||
reply_to = headers.pop('Reply-To', None)
|
||||
# email headers are case-insensitive per RFC-822 et seq:
|
||||
headers = CaseInsensitiveDict(headers)
|
||||
|
||||
reply_to = headers.pop("Reply-To", None)
|
||||
if reply_to:
|
||||
# message.extra_headers['Reply-To'] will override message.reply_to
|
||||
# (because the extra_headers attr is processed after reply_to).
|
||||
# This matches the behavior of Django's EmailMessage.message().
|
||||
self.set_reply_to(parse_address_list([reply_to], field="extra_headers['Reply-To']"))
|
||||
self.set_reply_to(
|
||||
parse_address_list([reply_to], field="extra_headers['Reply-To']")
|
||||
)
|
||||
|
||||
if 'From' in headers:
|
||||
# If message.extra_headers['From'] is supplied, it should override message.from_email,
|
||||
# but message.from_email should be used as the envelope_sender. See:
|
||||
# - https://code.djangoproject.com/ticket/9214
|
||||
# - https://github.com/django/django/blob/1.8/django/core/mail/message.py#L269
|
||||
# - https://github.com/django/django/blob/1.8/django/core/mail/backends/smtp.py#L118
|
||||
header_from = parse_address_list(headers.pop('From'), field="extra_headers['From']")
|
||||
envelope_sender = parse_single_address(self.message.from_email, field="from_email") # must be single
|
||||
if "From" in headers:
|
||||
# If message.extra_headers['From'] is supplied, it should override
|
||||
# message.from_email, but message.from_email should be used as the
|
||||
# envelope_sender. See:
|
||||
# https://code.djangoproject.com/ticket/9214
|
||||
# https://github.com/django/django/blob/1.8/django/core/mail/message.py#L269
|
||||
# https://github.com/django/django/blob/1.8/django/core/mail/backends/smtp.py#L118
|
||||
header_from = parse_address_list(
|
||||
headers.pop("From"), field="extra_headers['From']"
|
||||
)
|
||||
# sender must be single:
|
||||
envelope_sender = parse_single_address(
|
||||
self.message.from_email, field="from_email"
|
||||
)
|
||||
self.set_from_email_list(header_from)
|
||||
self.set_envelope_sender(envelope_sender)
|
||||
|
||||
if 'To' in headers:
|
||||
# If message.extra_headers['To'] is supplied, message.to is used only as the envelope
|
||||
# recipients (SMTP.sendmail to_addrs), and the header To is spoofed. See:
|
||||
# - https://github.com/django/django/blob/1.8/django/core/mail/message.py#L270
|
||||
# - https://github.com/django/django/blob/1.8/django/core/mail/backends/smtp.py#L119-L120
|
||||
if "To" in headers:
|
||||
# If message.extra_headers['To'] is supplied, message.to is used only as
|
||||
# the envelope recipients (SMTP.sendmail to_addrs), and the header To is
|
||||
# spoofed. See:
|
||||
# https://github.com/django/django/blob/1.8/django/core/mail/message.py#L270
|
||||
# https://github.com/django/django/blob/1.8/django/core/mail/backends/smtp.py#L119-L120
|
||||
# No current ESP supports this, so this code is mainly here to flag
|
||||
# the SMTP backend's behavior as an unsupported feature in Anymail:
|
||||
header_to = headers.pop('To')
|
||||
header_to = headers.pop("To")
|
||||
self.set_spoofed_to_header(header_to)
|
||||
|
||||
if headers:
|
||||
@@ -363,20 +426,25 @@ class BasePayload:
|
||||
# TypeError: can only concatenate list (not "str") to list
|
||||
# TypeError: Can't convert 'list' object to str implicitly
|
||||
if isinstance(value, str) or is_lazy(value):
|
||||
raise TypeError('"{attr}" attribute must be a list or other iterable'.format(attr=attr))
|
||||
raise TypeError(
|
||||
'"{attr}" attribute must be a list or other iterable'.format(attr=attr)
|
||||
)
|
||||
|
||||
#
|
||||
# Attribute converters
|
||||
#
|
||||
|
||||
def prepped_alternatives(self, alternatives):
|
||||
return [(force_non_lazy(content), mimetype)
|
||||
for (content, mimetype) in alternatives]
|
||||
return [
|
||||
(force_non_lazy(content), mimetype) for (content, mimetype) in alternatives
|
||||
]
|
||||
|
||||
def prepped_attachments(self, attachments):
|
||||
str_encoding = self.message.encoding or settings.DEFAULT_CHARSET
|
||||
return [Attachment(attachment, str_encoding) # (handles lazy content, filename)
|
||||
for attachment in attachments]
|
||||
return [
|
||||
Attachment(attachment, str_encoding) # (handles lazy content, filename)
|
||||
for attachment in attachments
|
||||
]
|
||||
|
||||
def aware_datetime(self, value):
|
||||
"""Converts a date or datetime or timestamp to an aware datetime.
|
||||
@@ -406,12 +474,14 @@ class BasePayload:
|
||||
#
|
||||
|
||||
def init_payload(self):
|
||||
raise NotImplementedError("%s.%s must implement init_payload" %
|
||||
(self.__class__.__module__, self.__class__.__name__))
|
||||
raise NotImplementedError(
|
||||
"%s.%s must implement init_payload"
|
||||
% (self.__class__.__module__, self.__class__.__name__)
|
||||
)
|
||||
|
||||
def set_from_email_list(self, emails):
|
||||
# If your backend supports multiple from emails, override this to handle the whole list;
|
||||
# otherwise just implement set_from_email
|
||||
# If your backend supports multiple from emails, override this to handle
|
||||
# the whole list; otherwise just implement set_from_email
|
||||
if len(emails) > 1:
|
||||
self.unsupported_feature("multiple from emails")
|
||||
# fall through if ignoring unsupported features
|
||||
@@ -419,36 +489,42 @@ class BasePayload:
|
||||
self.set_from_email(emails[0])
|
||||
|
||||
def set_from_email(self, email):
|
||||
raise NotImplementedError("%s.%s must implement set_from_email or set_from_email_list" %
|
||||
(self.__class__.__module__, self.__class__.__name__))
|
||||
raise NotImplementedError(
|
||||
"%s.%s must implement set_from_email or set_from_email_list"
|
||||
% (self.__class__.__module__, self.__class__.__name__)
|
||||
)
|
||||
|
||||
def set_to(self, emails):
|
||||
return self.set_recipients('to', emails)
|
||||
return self.set_recipients("to", emails)
|
||||
|
||||
def set_cc(self, emails):
|
||||
return self.set_recipients('cc', emails)
|
||||
return self.set_recipients("cc", emails)
|
||||
|
||||
def set_bcc(self, emails):
|
||||
return self.set_recipients('bcc', emails)
|
||||
return self.set_recipients("bcc", emails)
|
||||
|
||||
def set_recipients(self, recipient_type, emails):
|
||||
for email in emails:
|
||||
self.add_recipient(recipient_type, email)
|
||||
|
||||
def add_recipient(self, recipient_type, email):
|
||||
raise NotImplementedError("%s.%s must implement add_recipient, set_recipients, or set_{to,cc,bcc}" %
|
||||
(self.__class__.__module__, self.__class__.__name__))
|
||||
raise NotImplementedError(
|
||||
"%s.%s must implement add_recipient, set_recipients, or set_{to,cc,bcc}"
|
||||
% (self.__class__.__module__, self.__class__.__name__)
|
||||
)
|
||||
|
||||
def set_subject(self, subject):
|
||||
raise NotImplementedError("%s.%s must implement set_subject" %
|
||||
(self.__class__.__module__, self.__class__.__name__))
|
||||
raise NotImplementedError(
|
||||
"%s.%s must implement set_subject"
|
||||
% (self.__class__.__module__, self.__class__.__name__)
|
||||
)
|
||||
|
||||
def set_reply_to(self, emails):
|
||||
self.unsupported_feature('reply_to')
|
||||
self.unsupported_feature("reply_to")
|
||||
|
||||
def set_extra_headers(self, headers):
|
||||
# headers is a CaseInsensitiveDict, and is a copy (so is safe to modify)
|
||||
self.unsupported_feature('extra_headers')
|
||||
self.unsupported_feature("extra_headers")
|
||||
|
||||
def set_body(self, body):
|
||||
# Interpret message.body depending on message.content_subtype.
|
||||
@@ -463,12 +539,16 @@ class BasePayload:
|
||||
self.add_alternative(body, "text/%s" % content_subtype)
|
||||
|
||||
def set_text_body(self, body):
|
||||
raise NotImplementedError("%s.%s must implement set_text_body" %
|
||||
(self.__class__.__module__, self.__class__.__name__))
|
||||
raise NotImplementedError(
|
||||
"%s.%s must implement set_text_body"
|
||||
% (self.__class__.__module__, self.__class__.__name__)
|
||||
)
|
||||
|
||||
def set_html_body(self, body):
|
||||
raise NotImplementedError("%s.%s must implement set_html_body" %
|
||||
(self.__class__.__module__, self.__class__.__name__))
|
||||
raise NotImplementedError(
|
||||
"%s.%s must implement set_html_body"
|
||||
% (self.__class__.__module__, self.__class__.__name__)
|
||||
)
|
||||
|
||||
def set_alternatives(self, alternatives):
|
||||
# Handle treating first text/{plain,html} alternatives as bodies.
|
||||
@@ -499,12 +579,15 @@ class BasePayload:
|
||||
self.add_attachment(attachment)
|
||||
|
||||
def add_attachment(self, attachment):
|
||||
raise NotImplementedError("%s.%s must implement add_attachment or set_attachments" %
|
||||
(self.__class__.__module__, self.__class__.__name__))
|
||||
raise NotImplementedError(
|
||||
"%s.%s must implement add_attachment or set_attachments"
|
||||
% (self.__class__.__module__, self.__class__.__name__)
|
||||
)
|
||||
|
||||
def set_spoofed_to_header(self, header_to):
|
||||
# In the unlikely case an ESP supports *completely replacing* the To message header
|
||||
# without altering the actual envelope recipients, the backend can implement this.
|
||||
# In the unlikely case an ESP supports *completely replacing* the To message
|
||||
# header without altering the actual envelope recipients, the backend can
|
||||
# implement this.
|
||||
self.unsupported_feature("spoofing `To` header")
|
||||
|
||||
# Anymail-specific payload construction
|
||||
@@ -557,13 +640,18 @@ class BasePayload:
|
||||
return json.dumps(data, default=self._json_default)
|
||||
except TypeError as err:
|
||||
# Add some context to the "not JSON serializable" message
|
||||
raise AnymailSerializationError(orig_err=err, email_message=self.message,
|
||||
backend=self.backend, payload=self) from None
|
||||
raise AnymailSerializationError(
|
||||
orig_err=err,
|
||||
email_message=self.message,
|
||||
backend=self.backend,
|
||||
payload=self,
|
||||
) from None
|
||||
|
||||
@staticmethod
|
||||
def _json_default(o):
|
||||
"""json.dump default function that handles some common Payload data types"""
|
||||
if isinstance(o, CaseInsensitiveDict): # used for headers
|
||||
return dict(o)
|
||||
raise TypeError("Object of type '%s' is not JSON serializable" %
|
||||
o.__class__.__name__)
|
||||
raise TypeError(
|
||||
"Object of type '%s' is not JSON serializable" % o.__class__.__name__
|
||||
)
|
||||
|
||||
@@ -3,9 +3,10 @@ from urllib.parse import urljoin
|
||||
import requests
|
||||
|
||||
from anymail.utils import get_anymail_setting
|
||||
from .base import AnymailBaseBackend, BasePayload
|
||||
|
||||
from .._version import __version__
|
||||
from ..exceptions import AnymailRequestsAPIError
|
||||
from .base import AnymailBaseBackend, BasePayload
|
||||
|
||||
|
||||
class AnymailRequestsBackend(AnymailBaseBackend):
|
||||
@@ -16,7 +17,9 @@ class AnymailRequestsBackend(AnymailBaseBackend):
|
||||
def __init__(self, api_url, **kwargs):
|
||||
"""Init options from Django settings"""
|
||||
self.api_url = api_url
|
||||
self.timeout = get_anymail_setting('requests_timeout', kwargs=kwargs, default=30)
|
||||
self.timeout = get_anymail_setting(
|
||||
"requests_timeout", kwargs=kwargs, default=30
|
||||
)
|
||||
super().__init__(**kwargs)
|
||||
self.session = None
|
||||
|
||||
@@ -49,7 +52,10 @@ class AnymailRequestsBackend(AnymailBaseBackend):
|
||||
raise RuntimeError(
|
||||
"Session has not been opened in {class_name}._send. "
|
||||
"(This is either an implementation error in {class_name}, "
|
||||
"or you are incorrectly calling _send directly.)".format(class_name=class_name))
|
||||
"or you are incorrectly calling _send directly.)".format(
|
||||
class_name=class_name
|
||||
)
|
||||
)
|
||||
return super()._send(message)
|
||||
|
||||
def create_session(self):
|
||||
@@ -61,11 +67,13 @@ class AnymailRequestsBackend(AnymailBaseBackend):
|
||||
session = requests.Session()
|
||||
|
||||
session.headers["User-Agent"] = "django-anymail/{version}-{esp} {orig}".format(
|
||||
esp=self.esp_name.lower(), version=__version__,
|
||||
orig=session.headers.get("User-Agent", ""))
|
||||
esp=self.esp_name.lower(),
|
||||
version=__version__,
|
||||
orig=session.headers.get("User-Agent", ""),
|
||||
)
|
||||
|
||||
if self.debug_api_requests:
|
||||
session.hooks['response'].append(self._dump_api_request)
|
||||
session.hooks["response"].append(self._dump_api_request)
|
||||
|
||||
return session
|
||||
|
||||
@@ -79,16 +87,20 @@ class AnymailRequestsBackend(AnymailBaseBackend):
|
||||
Can raise AnymailRequestsAPIError for HTTP errors in the post
|
||||
"""
|
||||
params = payload.get_request_params(self.api_url)
|
||||
params.setdefault('timeout', self.timeout)
|
||||
params.setdefault("timeout", self.timeout)
|
||||
try:
|
||||
response = self.session.request(**params)
|
||||
except requests.RequestException as err:
|
||||
# raise an exception that is both AnymailRequestsAPIError
|
||||
# and the original requests exception type
|
||||
exc_class = type('AnymailRequestsAPIError', (AnymailRequestsAPIError, type(err)), {})
|
||||
exc_class = type(
|
||||
"AnymailRequestsAPIError", (AnymailRequestsAPIError, type(err)), {}
|
||||
)
|
||||
raise exc_class(
|
||||
"Error posting to %s:" % params.get('url', '<missing url>'),
|
||||
email_message=message, payload=payload) from err
|
||||
"Error posting to %s:" % params.get("url", "<missing url>"),
|
||||
email_message=message,
|
||||
payload=payload,
|
||||
) from err
|
||||
self.raise_for_status(response, payload, message)
|
||||
return response
|
||||
|
||||
@@ -101,8 +113,8 @@ class AnymailRequestsBackend(AnymailBaseBackend):
|
||||
"""
|
||||
if response.status_code < 200 or response.status_code >= 300:
|
||||
raise AnymailRequestsAPIError(
|
||||
email_message=message, payload=payload,
|
||||
response=response, backend=self)
|
||||
email_message=message, payload=payload, response=response, backend=self
|
||||
)
|
||||
|
||||
def deserialize_json_response(self, response, payload, message):
|
||||
"""Deserialize an ESP API response that's in json.
|
||||
@@ -112,9 +124,13 @@ class AnymailRequestsBackend(AnymailBaseBackend):
|
||||
try:
|
||||
return response.json()
|
||||
except ValueError as err:
|
||||
raise AnymailRequestsAPIError("Invalid JSON in %s API response" % self.esp_name,
|
||||
email_message=message, payload=payload, response=response,
|
||||
backend=self) from err
|
||||
raise AnymailRequestsAPIError(
|
||||
"Invalid JSON in %s API response" % self.esp_name,
|
||||
email_message=message,
|
||||
payload=payload,
|
||||
response=response,
|
||||
backend=self,
|
||||
) from err
|
||||
|
||||
@staticmethod
|
||||
def _dump_api_request(response, **kwargs):
|
||||
@@ -125,31 +141,52 @@ class AnymailRequestsBackend(AnymailBaseBackend):
|
||||
# in http://docs.python-requests.org/en/v3.0.0/api/#api-changes)
|
||||
request = response.request # a PreparedRequest
|
||||
print("\n===== Anymail API request")
|
||||
print("{method} {url}\n{headers}".format(
|
||||
method=request.method, url=request.url,
|
||||
headers="".join("{header}: {value}\n".format(header=header, value=value)
|
||||
for (header, value) in request.headers.items()),
|
||||
))
|
||||
print(
|
||||
"{method} {url}\n{headers}".format(
|
||||
method=request.method,
|
||||
url=request.url,
|
||||
headers="".join(
|
||||
"{header}: {value}\n".format(header=header, value=value)
|
||||
for (header, value) in request.headers.items()
|
||||
),
|
||||
)
|
||||
)
|
||||
if request.body is not None:
|
||||
body_text = (request.body if isinstance(request.body, str)
|
||||
else request.body.decode("utf-8", errors="replace")
|
||||
).replace("\r\n", "\n")
|
||||
body_text = (
|
||||
request.body
|
||||
if isinstance(request.body, str)
|
||||
else request.body.decode("utf-8", errors="replace")
|
||||
).replace("\r\n", "\n")
|
||||
print(body_text)
|
||||
print("\n----- Response")
|
||||
print("HTTP {status} {reason}\n{headers}\n{body}".format(
|
||||
status=response.status_code, reason=response.reason,
|
||||
headers="".join("{header}: {value}\n".format(header=header, value=value)
|
||||
for (header, value) in response.headers.items()),
|
||||
body=response.text, # Let Requests decode body content for us
|
||||
))
|
||||
print(
|
||||
"HTTP {status} {reason}\n{headers}\n{body}".format(
|
||||
status=response.status_code,
|
||||
reason=response.reason,
|
||||
headers="".join(
|
||||
"{header}: {value}\n".format(header=header, value=value)
|
||||
for (header, value) in response.headers.items()
|
||||
),
|
||||
body=response.text, # Let Requests decode body content for us
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class RequestsPayload(BasePayload):
|
||||
"""Abstract Payload for AnymailRequestsBackend"""
|
||||
|
||||
def __init__(self, message, defaults, backend,
|
||||
method="POST", params=None, data=None,
|
||||
headers=None, files=None, auth=None):
|
||||
def __init__(
|
||||
self,
|
||||
message,
|
||||
defaults,
|
||||
backend,
|
||||
method="POST",
|
||||
params=None,
|
||||
data=None,
|
||||
headers=None,
|
||||
files=None,
|
||||
auth=None,
|
||||
):
|
||||
self.method = method
|
||||
self.params = params
|
||||
self.data = data
|
||||
@@ -183,7 +220,10 @@ class RequestsPayload(BasePayload):
|
||||
)
|
||||
|
||||
def get_api_endpoint(self):
|
||||
"""Returns a str that should be joined to the backend's api_url for sending this payload."""
|
||||
"""
|
||||
Returns a str that should be joined to the backend's api_url
|
||||
for sending this payload.
|
||||
"""
|
||||
return None
|
||||
|
||||
def serialize_data(self):
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import uuid
|
||||
|
||||
from django.core.mail.backends.console import EmailBackend as DjangoConsoleBackend
|
||||
|
||||
from ..exceptions import AnymailError
|
||||
|
||||
@@ -4,22 +4,25 @@ from urllib.parse import quote
|
||||
|
||||
from requests import Request
|
||||
|
||||
from .base_requests import AnymailRequestsBackend, RequestsPayload
|
||||
from ..exceptions import AnymailError, AnymailRequestsAPIError
|
||||
from ..message import AnymailRecipientStatus
|
||||
from ..utils import get_anymail_setting, rfc2822date
|
||||
from .base_requests import AnymailRequestsBackend, RequestsPayload
|
||||
|
||||
|
||||
# Feature-detect whether requests (urllib3) correctly uses RFC 7578 encoding for non-
|
||||
# ASCII filenames in Content-Disposition headers. (This was fixed in urllib3 v1.25.)
|
||||
# See MailgunPayload.get_request_params for info (and a workaround on older versions).
|
||||
# (Note: when this workaround is removed, please also remove the "old_urllib3" tox envs.)
|
||||
# (Note: when this workaround is removed, please also remove "old_urllib3" tox envs.)
|
||||
def is_requests_rfc_5758_compliant():
|
||||
request = Request(method='POST', url='https://www.example.com',
|
||||
files=[('attachment', ('\N{NOT SIGN}.txt', 'test', 'text/plain'))])
|
||||
request = Request(
|
||||
method="POST",
|
||||
url="https://www.example.com",
|
||||
files=[("attachment", ("\N{NOT SIGN}.txt", "test", "text/plain"))],
|
||||
)
|
||||
prepared = request.prepare()
|
||||
form_data = prepared.body # bytes
|
||||
return b'filename*=' not in form_data
|
||||
return b"filename*=" not in form_data
|
||||
|
||||
|
||||
REQUESTS_IS_RFC_7578_COMPLIANT = is_requests_rfc_5758_compliant()
|
||||
@@ -35,11 +38,22 @@ class EmailBackend(AnymailRequestsBackend):
|
||||
def __init__(self, **kwargs):
|
||||
"""Init options from Django settings"""
|
||||
esp_name = self.esp_name
|
||||
self.api_key = get_anymail_setting('api_key', esp_name=esp_name, kwargs=kwargs, allow_bare=True)
|
||||
self.sender_domain = get_anymail_setting('sender_domain', esp_name=esp_name, kwargs=kwargs,
|
||||
allow_bare=True, default=None)
|
||||
api_url = get_anymail_setting('api_url', esp_name=esp_name, kwargs=kwargs,
|
||||
default="https://api.mailgun.net/v3")
|
||||
self.api_key = get_anymail_setting(
|
||||
"api_key", esp_name=esp_name, kwargs=kwargs, allow_bare=True
|
||||
)
|
||||
self.sender_domain = get_anymail_setting(
|
||||
"sender_domain",
|
||||
esp_name=esp_name,
|
||||
kwargs=kwargs,
|
||||
allow_bare=True,
|
||||
default=None,
|
||||
)
|
||||
api_url = get_anymail_setting(
|
||||
"api_url",
|
||||
esp_name=esp_name,
|
||||
kwargs=kwargs,
|
||||
default="https://api.mailgun.net/v3",
|
||||
)
|
||||
if not api_url.endswith("/"):
|
||||
api_url += "/"
|
||||
super().__init__(api_url, **kwargs)
|
||||
@@ -55,9 +69,13 @@ class EmailBackend(AnymailRequestsBackend):
|
||||
"Unknown sender domain {sender_domain!r}.\n"
|
||||
"Check the domain is verified with Mailgun, and that the ANYMAIL"
|
||||
" MAILGUN_API_URL setting {api_url!r} is the correct region.".format(
|
||||
sender_domain=payload.sender_domain, api_url=self.api_url),
|
||||
email_message=message, payload=payload,
|
||||
response=response, backend=self)
|
||||
sender_domain=payload.sender_domain, api_url=self.api_url
|
||||
),
|
||||
email_message=message,
|
||||
payload=payload,
|
||||
response=response,
|
||||
backend=self,
|
||||
)
|
||||
|
||||
super().raise_for_status(response, payload, message)
|
||||
|
||||
@@ -68,8 +86,11 @@ class EmailBackend(AnymailRequestsBackend):
|
||||
"Invalid Mailgun API endpoint %r.\n"
|
||||
"Check your ANYMAIL MAILGUN_SENDER_DOMAIN"
|
||||
" and MAILGUN_API_URL settings." % response.url,
|
||||
email_message=message, payload=payload,
|
||||
response=response, backend=self)
|
||||
email_message=message,
|
||||
payload=payload,
|
||||
response=response,
|
||||
backend=self,
|
||||
)
|
||||
|
||||
def parse_recipient_status(self, response, payload, message):
|
||||
# The *only* 200 response from Mailgun seems to be:
|
||||
@@ -86,20 +107,27 @@ class EmailBackend(AnymailRequestsBackend):
|
||||
message_id = parsed_response["id"]
|
||||
mailgun_message = parsed_response["message"]
|
||||
except (KeyError, TypeError) as err:
|
||||
raise AnymailRequestsAPIError("Invalid Mailgun API response format",
|
||||
email_message=message, payload=payload, response=response,
|
||||
backend=self) from err
|
||||
raise AnymailRequestsAPIError(
|
||||
"Invalid Mailgun API response format",
|
||||
email_message=message,
|
||||
payload=payload,
|
||||
response=response,
|
||||
backend=self,
|
||||
) from err
|
||||
if not mailgun_message.startswith("Queued"):
|
||||
raise AnymailRequestsAPIError("Unrecognized Mailgun API message '%s'" % mailgun_message,
|
||||
email_message=message, payload=payload, response=response,
|
||||
backend=self)
|
||||
raise AnymailRequestsAPIError(
|
||||
"Unrecognized Mailgun API message '%s'" % mailgun_message,
|
||||
email_message=message,
|
||||
payload=payload,
|
||||
response=response,
|
||||
backend=self,
|
||||
)
|
||||
# Simulate a per-recipient status of "queued":
|
||||
status = AnymailRecipientStatus(message_id=message_id, status="queued")
|
||||
return {recipient.addr_spec: status for recipient in payload.all_recipients}
|
||||
|
||||
|
||||
class MailgunPayload(RequestsPayload):
|
||||
|
||||
def __init__(self, message, defaults, backend, *args, **kwargs):
|
||||
auth = ("api", backend.api_key)
|
||||
self.sender_domain = backend.sender_domain
|
||||
@@ -116,22 +144,32 @@ class MailgunPayload(RequestsPayload):
|
||||
|
||||
def get_api_endpoint(self):
|
||||
if self.sender_domain is None:
|
||||
raise AnymailError("Cannot call Mailgun unknown sender domain. "
|
||||
"Either provide valid `from_email`, "
|
||||
"or set `message.esp_extra={'sender_domain': 'example.com'}`",
|
||||
backend=self.backend, email_message=self.message, payload=self)
|
||||
if '/' in self.sender_domain or '%2f' in self.sender_domain.lower():
|
||||
raise AnymailError(
|
||||
"Cannot call Mailgun unknown sender domain. "
|
||||
"Either provide valid `from_email`, "
|
||||
"or set `message.esp_extra={'sender_domain': 'example.com'}`",
|
||||
backend=self.backend,
|
||||
email_message=self.message,
|
||||
payload=self,
|
||||
)
|
||||
if "/" in self.sender_domain or "%2f" in self.sender_domain.lower():
|
||||
# Mailgun returns a cryptic 200-OK "Mailgun Magnificent API" response
|
||||
# if '/' (or even %-encoded '/') confuses it about the API endpoint.
|
||||
raise AnymailError("Invalid '/' in sender domain '%s'" % self.sender_domain,
|
||||
backend=self.backend, email_message=self.message, payload=self)
|
||||
return "%s/messages" % quote(self.sender_domain, safe='')
|
||||
raise AnymailError(
|
||||
"Invalid '/' in sender domain '%s'" % self.sender_domain,
|
||||
backend=self.backend,
|
||||
email_message=self.message,
|
||||
payload=self,
|
||||
)
|
||||
return "%s/messages" % quote(self.sender_domain, safe="")
|
||||
|
||||
def get_request_params(self, api_url):
|
||||
params = super().get_request_params(api_url)
|
||||
non_ascii_filenames = [filename
|
||||
for (field, (filename, content, mimetype)) in params["files"]
|
||||
if filename is not None and not isascii(filename)]
|
||||
non_ascii_filenames = [
|
||||
filename
|
||||
for (field, (filename, content, mimetype)) in params["files"]
|
||||
if filename is not None and not isascii(filename)
|
||||
]
|
||||
if non_ascii_filenames and not REQUESTS_IS_RFC_7578_COMPLIANT:
|
||||
# Workaround https://github.com/requests/requests/issues/4652:
|
||||
# Mailgun expects RFC 7578 compliant multipart/form-data, and is confused
|
||||
@@ -139,7 +177,7 @@ class MailgunPayload(RequestsPayload):
|
||||
# ("filename*=utf-8''...") in Content-Disposition headers.
|
||||
# The workaround is to pre-generate the (non-compliant) form-data body, and
|
||||
# replace 'filename*={RFC 2231 encoded}' with 'filename="{UTF-8 bytes}"'.
|
||||
# Replace _only_ the filenames that will be problems (not all "filename*=...")
|
||||
# Replace _only_ filenames that will be problems (not all "filename*=...")
|
||||
# to minimize potential side effects--e.g., in attached messages that might
|
||||
# have their own attachments with (correctly) RFC 2231 encoded filenames.
|
||||
prepared = Request(**params).prepare()
|
||||
@@ -147,10 +185,12 @@ class MailgunPayload(RequestsPayload):
|
||||
for filename in non_ascii_filenames: # text
|
||||
rfc2231_filename = encode_rfc2231(filename, charset="utf-8")
|
||||
form_data = form_data.replace(
|
||||
b'filename*=' + rfc2231_filename.encode("utf-8"),
|
||||
b'filename="' + filename.encode("utf-8") + b'"')
|
||||
b"filename*=" + rfc2231_filename.encode("utf-8"),
|
||||
b'filename="' + filename.encode("utf-8") + b'"',
|
||||
)
|
||||
params["data"] = form_data
|
||||
params["headers"]["Content-Type"] = prepared.headers["Content-Type"] # multipart/form-data; boundary=...
|
||||
# Content-Type: multipart/form-data; boundary=...
|
||||
params["headers"]["Content-Type"] = prepared.headers["Content-Type"]
|
||||
params["files"] = None # these are now in the form_data body
|
||||
return params
|
||||
|
||||
@@ -181,32 +221,39 @@ class MailgunPayload(RequestsPayload):
|
||||
# are available as `%recipient.KEY%` virtually anywhere in the message
|
||||
# (including header fields and other parameters).
|
||||
#
|
||||
# Anymail needs both mechanisms to map its normalized metadata and template merge_data
|
||||
# features to Mailgun:
|
||||
# Anymail needs both mechanisms to map its normalized metadata and template
|
||||
# merge_data features to Mailgun:
|
||||
# (1) Anymail's `metadata` maps directly to Mailgun's custom data, where it can be
|
||||
# accessed from webhooks.
|
||||
# (2) Anymail's `merge_metadata` (per-recipient metadata for batch sends) maps
|
||||
# *indirectly* through recipient-variables to Mailgun's custom data. To avoid
|
||||
# conflicts, the recipient-variables mapping prepends 'v:' to merge_metadata keys.
|
||||
# (E.g., Mailgun's custom-data "user" is set to "%recipient.v:user", which picks
|
||||
# up its per-recipient value from Mailgun's `recipient-variables[to_email]["v:user"]`.)
|
||||
# (3) Anymail's `merge_data` (per-recipient template substitutions) maps directly to
|
||||
# Mailgun's `recipient-variables`, where it can be referenced in on-the-fly templates.
|
||||
# (4) Anymail's `merge_global_data` (global template substitutions) is copied to
|
||||
# Mailgun's `recipient-variables` for every recipient, as the default for missing
|
||||
# `merge_data` keys.
|
||||
# conflicts, the recipient-variables mapping prepends 'v:' to merge_metadata
|
||||
# keys. (E.g., Mailgun's custom-data "user" is set to "%recipient.v:user",
|
||||
# which picks up its per-recipient value from Mailgun's
|
||||
# `recipient-variables[to_email]["v:user"]`.)
|
||||
# (3) Anymail's `merge_data` (per-recipient template substitutions) maps directly
|
||||
# to Mailgun's `recipient-variables`, where it can be referenced in on-the-fly
|
||||
# templates.
|
||||
# (4) Anymail's `merge_global_data` (global template substitutions) is copied
|
||||
# to Mailgun's `recipient-variables` for every recipient, as the default
|
||||
# for missing `merge_data` keys.
|
||||
# (5) Only if a stored template is used, `merge_data` and `merge_global_data` are
|
||||
# *also* mapped *indirectly* through recipient-variables to Mailgun's custom data,
|
||||
# where they can be referenced in handlebars {{substitutions}}.
|
||||
# *also* mapped *indirectly* through recipient-variables to Mailgun's custom
|
||||
# data, where they can be referenced in handlebars {{substitutions}}.
|
||||
# (E.g., Mailgun's custom-data "name" is set to "%recipient.name%", which picks
|
||||
# up its per-recipient value from Mailgun's `recipient-variables[to_email]["name"]`.)
|
||||
# up its per-recipient value from Mailgun's
|
||||
# `recipient-variables[to_email]["name"]`.)
|
||||
#
|
||||
# If Anymail's `merge_data`, `template_id` (stored templates) and `metadata` (or
|
||||
# `merge_metadata`) are used together, there's a possibility of conflicting keys in
|
||||
# Mailgun's custom data. Anymail treats that conflict as an unsupported feature error.
|
||||
# `merge_metadata`) are used together, there's a possibility of conflicting keys
|
||||
# in Mailgun's custom data. Anymail treats that conflict as an unsupported feature
|
||||
# error.
|
||||
|
||||
def populate_recipient_variables(self):
|
||||
"""Populate Mailgun recipient-variables and custom data from merge data and metadata"""
|
||||
"""
|
||||
Populate Mailgun recipient-variables and custom data
|
||||
from merge data and metadata
|
||||
"""
|
||||
# (numbers refer to detailed explanation above)
|
||||
# Mailgun parameters to construct:
|
||||
recipient_variables = {}
|
||||
@@ -217,53 +264,66 @@ class MailgunPayload(RequestsPayload):
|
||||
|
||||
# (2) merge_metadata --> Mailgun custom_data via recipient_variables
|
||||
if self.merge_metadata:
|
||||
def vkey(key): # 'v:key'
|
||||
return 'v:{}'.format(key)
|
||||
|
||||
merge_metadata_keys = flatset( # all keys used in any recipient's merge_metadata
|
||||
recipient_data.keys() for recipient_data in self.merge_metadata.values())
|
||||
custom_data.update({ # custom_data['key'] = '%recipient.v:key%' indirection
|
||||
key: '%recipient.{}%'.format(vkey(key))
|
||||
for key in merge_metadata_keys})
|
||||
base_recipient_data = { # defaults for each recipient must cover all keys
|
||||
vkey(key): self.metadata.get(key, '')
|
||||
for key in merge_metadata_keys}
|
||||
def vkey(key): # 'v:key'
|
||||
return "v:{}".format(key)
|
||||
|
||||
# all keys used in any recipient's merge_metadata:
|
||||
merge_metadata_keys = flatset(
|
||||
recipient_data.keys() for recipient_data in self.merge_metadata.values()
|
||||
)
|
||||
# custom_data['key'] = '%recipient.v:key%' indirection:
|
||||
custom_data.update(
|
||||
{key: "%recipient.{}%".format(vkey(key)) for key in merge_metadata_keys}
|
||||
)
|
||||
# defaults for each recipient must cover all keys:
|
||||
base_recipient_data = {
|
||||
vkey(key): self.metadata.get(key, "") for key in merge_metadata_keys
|
||||
}
|
||||
for email in self.to_emails:
|
||||
this_recipient_data = base_recipient_data.copy()
|
||||
this_recipient_data.update({
|
||||
vkey(key): value
|
||||
for key, value in self.merge_metadata.get(email, {}).items()})
|
||||
this_recipient_data.update(
|
||||
{
|
||||
vkey(key): value
|
||||
for key, value in self.merge_metadata.get(email, {}).items()
|
||||
}
|
||||
)
|
||||
recipient_variables.setdefault(email, {}).update(this_recipient_data)
|
||||
|
||||
# (3) and (4) merge_data, merge_global_data --> Mailgun recipient_variables
|
||||
if self.merge_data or self.merge_global_data:
|
||||
merge_data_keys = flatset( # all keys used in any recipient's merge_data
|
||||
recipient_data.keys() for recipient_data in self.merge_data.values())
|
||||
# all keys used in any recipient's merge_data:
|
||||
merge_data_keys = flatset(
|
||||
recipient_data.keys() for recipient_data in self.merge_data.values()
|
||||
)
|
||||
merge_data_keys = merge_data_keys.union(self.merge_global_data.keys())
|
||||
base_recipient_data = { # defaults for each recipient must cover all keys
|
||||
key: self.merge_global_data.get(key, '')
|
||||
for key in merge_data_keys}
|
||||
# defaults for each recipient must cover all keys:
|
||||
base_recipient_data = {
|
||||
key: self.merge_global_data.get(key, "") for key in merge_data_keys
|
||||
}
|
||||
for email in self.to_emails:
|
||||
this_recipient_data = base_recipient_data.copy()
|
||||
this_recipient_data.update(self.merge_data.get(email, {}))
|
||||
recipient_variables.setdefault(email, {}).update(this_recipient_data)
|
||||
|
||||
# (5) if template, also map Mailgun custom_data to per-recipient_variables
|
||||
if self.data.get('template') is not None:
|
||||
if self.data.get("template") is not None:
|
||||
conflicts = merge_data_keys.intersection(custom_data.keys())
|
||||
if conflicts:
|
||||
self.unsupported_feature(
|
||||
"conflicting merge_data and metadata keys (%s) when using template_id"
|
||||
% ', '.join("'%s'" % key for key in conflicts))
|
||||
custom_data.update({ # custom_data['key'] = '%recipient.key%' indirection
|
||||
key: '%recipient.{}%'.format(key)
|
||||
for key in merge_data_keys})
|
||||
"conflicting merge_data and metadata keys (%s)"
|
||||
" when using template_id"
|
||||
% ", ".join("'%s'" % key for key in conflicts)
|
||||
)
|
||||
# custom_data['key'] = '%recipient.key%' indirection:
|
||||
custom_data.update(
|
||||
{key: "%recipient.{}%".format(key) for key in merge_data_keys}
|
||||
)
|
||||
|
||||
# populate Mailgun params
|
||||
self.data.update({'v:%s' % key: value
|
||||
for key, value in custom_data.items()})
|
||||
self.data.update({"v:%s" % key: value for key, value in custom_data.items()})
|
||||
if recipient_variables or self.is_batch():
|
||||
self.data['recipient-variables'] = self.serialize_json(recipient_variables)
|
||||
self.data["recipient-variables"] = self.serialize_json(recipient_variables)
|
||||
|
||||
#
|
||||
# Payload construction
|
||||
@@ -285,9 +345,11 @@ class MailgunPayload(RequestsPayload):
|
||||
assert recipient_type in ["to", "cc", "bcc"]
|
||||
if emails:
|
||||
self.data[recipient_type] = [email.address for email in emails]
|
||||
self.all_recipients += emails # used for backend.parse_recipient_status
|
||||
if recipient_type == 'to':
|
||||
self.to_emails = [email.addr_spec for email in emails] # used for populate_recipient_variables
|
||||
# used for backend.parse_recipient_status:
|
||||
self.all_recipients += emails
|
||||
if recipient_type == "to":
|
||||
# used for populate_recipient_variables:
|
||||
self.to_emails = [email.addr_spec for email in emails]
|
||||
|
||||
def set_subject(self, subject):
|
||||
self.data["subject"] = subject
|
||||
@@ -306,7 +368,8 @@ class MailgunPayload(RequestsPayload):
|
||||
|
||||
def set_html_body(self, body):
|
||||
if "html" in self.data:
|
||||
# second html body could show up through multiple alternatives, or html body + alternative
|
||||
# second html body could show up through multiple alternatives,
|
||||
# or html body + alternative
|
||||
self.unsupported_feature("multiple html parts")
|
||||
self.data["html"] = body
|
||||
|
||||
@@ -330,9 +393,7 @@ class MailgunPayload(RequestsPayload):
|
||||
name = attachment.name
|
||||
if not name:
|
||||
self.unsupported_feature("attachments without filenames")
|
||||
self.files.append(
|
||||
(field, (name, attachment.content, attachment.mimetype))
|
||||
)
|
||||
self.files.append((field, (name, attachment.content, attachment.mimetype)))
|
||||
|
||||
def set_envelope_sender(self, email):
|
||||
# Only the domain is used
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from .base_requests import AnymailRequestsBackend, RequestsPayload
|
||||
from ..exceptions import AnymailRequestsAPIError
|
||||
from ..message import AnymailRecipientStatus
|
||||
from ..utils import get_anymail_setting, update_deep
|
||||
from .base_requests import AnymailRequestsBackend, RequestsPayload
|
||||
|
||||
|
||||
class EmailBackend(AnymailRequestsBackend):
|
||||
@@ -14,10 +14,18 @@ class EmailBackend(AnymailRequestsBackend):
|
||||
def __init__(self, **kwargs):
|
||||
"""Init options from Django settings"""
|
||||
esp_name = self.esp_name
|
||||
self.api_key = get_anymail_setting('api_key', esp_name=esp_name, kwargs=kwargs, allow_bare=True)
|
||||
self.secret_key = get_anymail_setting('secret_key', esp_name=esp_name, kwargs=kwargs, allow_bare=True)
|
||||
api_url = get_anymail_setting('api_url', esp_name=esp_name, kwargs=kwargs,
|
||||
default="https://api.mailjet.com/v3.1/")
|
||||
self.api_key = get_anymail_setting(
|
||||
"api_key", esp_name=esp_name, kwargs=kwargs, allow_bare=True
|
||||
)
|
||||
self.secret_key = get_anymail_setting(
|
||||
"secret_key", esp_name=esp_name, kwargs=kwargs, allow_bare=True
|
||||
)
|
||||
api_url = get_anymail_setting(
|
||||
"api_url",
|
||||
esp_name=esp_name,
|
||||
kwargs=kwargs,
|
||||
default="https://api.mailjet.com/v3.1/",
|
||||
)
|
||||
if not api_url.endswith("/"):
|
||||
api_url += "/"
|
||||
super().__init__(api_url, **kwargs)
|
||||
@@ -37,42 +45,57 @@ class EmailBackend(AnymailRequestsBackend):
|
||||
|
||||
# Global error? (no messages sent)
|
||||
if "ErrorCode" in parsed_response:
|
||||
raise AnymailRequestsAPIError(email_message=message, payload=payload, response=response, backend=self)
|
||||
raise AnymailRequestsAPIError(
|
||||
email_message=message, payload=payload, response=response, backend=self
|
||||
)
|
||||
|
||||
recipient_status = {}
|
||||
try:
|
||||
for result in parsed_response["Messages"]:
|
||||
status = 'sent' if result["Status"] == 'success' else 'failed' # Status is 'success' or 'error'
|
||||
recipients = result.get("To", []) + result.get("Cc", []) + result.get("Bcc", [])
|
||||
# result["Status"] is "success" or "error"
|
||||
status = "sent" if result["Status"] == "success" else "failed"
|
||||
recipients = (
|
||||
result.get("To", []) + result.get("Cc", []) + result.get("Bcc", [])
|
||||
)
|
||||
for recipient in recipients:
|
||||
email = recipient['Email']
|
||||
message_id = str(recipient['MessageID']) # MessageUUID isn't yet useful for other Mailjet APIs
|
||||
recipient_status[email] = AnymailRecipientStatus(message_id=message_id, status=status)
|
||||
# Note that for errors, Mailjet doesn't identify the problem recipients.
|
||||
# This can occur with a batch send. We patch up the missing recipients below.
|
||||
email = recipient["Email"]
|
||||
# other Mailjet APIs expect MessageID (not MessageUUID)
|
||||
message_id = str(recipient["MessageID"])
|
||||
recipient_status[email] = AnymailRecipientStatus(
|
||||
message_id=message_id, status=status
|
||||
)
|
||||
# For errors, Mailjet doesn't identify the problem recipients. (This
|
||||
# can occur with a batch send.) Patch up the missing recipients below.
|
||||
except (KeyError, TypeError) as err:
|
||||
raise AnymailRequestsAPIError("Invalid Mailjet API response format",
|
||||
email_message=message, payload=payload, response=response,
|
||||
backend=self) from err
|
||||
raise AnymailRequestsAPIError(
|
||||
"Invalid Mailjet API response format",
|
||||
email_message=message,
|
||||
payload=payload,
|
||||
response=response,
|
||||
backend=self,
|
||||
) from err
|
||||
|
||||
# Any recipient who wasn't reported as a 'success' must have been an error:
|
||||
for email in payload.recipients:
|
||||
if email.addr_spec not in recipient_status:
|
||||
recipient_status[email.addr_spec] = AnymailRecipientStatus(message_id=None, status='failed')
|
||||
recipient_status[email.addr_spec] = AnymailRecipientStatus(
|
||||
message_id=None, status="failed"
|
||||
)
|
||||
|
||||
return recipient_status
|
||||
|
||||
|
||||
class MailjetPayload(RequestsPayload):
|
||||
|
||||
def __init__(self, message, defaults, backend, *args, **kwargs):
|
||||
auth = (backend.api_key, backend.secret_key)
|
||||
http_headers = {
|
||||
'Content-Type': 'application/json',
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
self.recipients = [] # for backend parse_recipient_status
|
||||
self.metadata = None
|
||||
super().__init__(message, defaults, backend, auth=auth, headers=http_headers, *args, **kwargs)
|
||||
super().__init__(
|
||||
message, defaults, backend, auth=auth, headers=http_headers, *args, **kwargs
|
||||
)
|
||||
|
||||
def get_api_endpoint(self):
|
||||
return "send"
|
||||
@@ -122,9 +145,9 @@ class MailjetPayload(RequestsPayload):
|
||||
# This case shouldn't happen. Please file a bug report if it does.
|
||||
raise AssertionError("set_to called with non-empty Messages list")
|
||||
if emails:
|
||||
self.data["Messages"].append({
|
||||
"To": [self._mailjet_email(email) for email in emails]
|
||||
})
|
||||
self.data["Messages"].append(
|
||||
{"To": [self._mailjet_email(email) for email in emails]}
|
||||
)
|
||||
self.recipients += emails
|
||||
else:
|
||||
# Mailjet requires a To list; cc-only messages aren't possible
|
||||
@@ -132,12 +155,16 @@ class MailjetPayload(RequestsPayload):
|
||||
|
||||
def set_cc(self, emails):
|
||||
if emails:
|
||||
self.data["Globals"]["Cc"] = [self._mailjet_email(email) for email in emails]
|
||||
self.data["Globals"]["Cc"] = [
|
||||
self._mailjet_email(email) for email in emails
|
||||
]
|
||||
self.recipients += emails
|
||||
|
||||
def set_bcc(self, emails):
|
||||
if emails:
|
||||
self.data["Globals"]["Bcc"] = [self._mailjet_email(email) for email in emails]
|
||||
self.data["Globals"]["Bcc"] = [
|
||||
self._mailjet_email(email) for email in emails
|
||||
]
|
||||
self.recipients += emails
|
||||
|
||||
def set_subject(self, subject):
|
||||
@@ -159,7 +186,8 @@ class MailjetPayload(RequestsPayload):
|
||||
def set_html_body(self, body):
|
||||
if body is not None:
|
||||
if "HTMLPart" in self.data["Globals"]:
|
||||
# second html body could show up through multiple alternatives, or html body + alternative
|
||||
# second html body could show up through multiple alternatives,
|
||||
# or html body + alternative
|
||||
self.unsupported_feature("multiple html parts")
|
||||
|
||||
self.data["Globals"]["HTMLPart"] = body
|
||||
@@ -183,7 +211,7 @@ class MailjetPayload(RequestsPayload):
|
||||
def set_metadata(self, metadata):
|
||||
# Mailjet expects a single string payload
|
||||
self.data["Globals"]["EventPayload"] = self.serialize_json(metadata)
|
||||
self.metadata = metadata # keep original in case we need to merge with merge_metadata
|
||||
self.metadata = metadata # save for set_merge_metadata
|
||||
|
||||
def set_merge_metadata(self, merge_metadata):
|
||||
self._burst_for_batch_send()
|
||||
@@ -204,7 +232,7 @@ class MailjetPayload(RequestsPayload):
|
||||
if len(tags) > 0:
|
||||
self.data["Globals"]["CustomCampaign"] = tags[0]
|
||||
if len(tags) > 1:
|
||||
self.unsupported_feature('multiple tags (%r)' % tags)
|
||||
self.unsupported_feature("multiple tags (%r)" % tags)
|
||||
|
||||
def set_track_clicks(self, track_clicks):
|
||||
self.data["Globals"]["TrackClicks"] = "enabled" if track_clicks else "disabled"
|
||||
@@ -213,7 +241,8 @@ class MailjetPayload(RequestsPayload):
|
||||
self.data["Globals"]["TrackOpens"] = "enabled" if track_opens else "disabled"
|
||||
|
||||
def set_template_id(self, template_id):
|
||||
self.data["Globals"]["TemplateID"] = int(template_id) # Mailjet requires integer (not string)
|
||||
# Mailjet requires integer (not string) TemplateID:
|
||||
self.data["Globals"]["TemplateID"] = int(template_id)
|
||||
self.data["Globals"]["TemplateLanguage"] = True
|
||||
|
||||
def set_merge_data(self, merge_data):
|
||||
|
||||
@@ -2,9 +2,8 @@ import warnings
|
||||
from datetime import datetime
|
||||
|
||||
from ..exceptions import AnymailRequestsAPIError, AnymailWarning
|
||||
from ..message import AnymailRecipientStatus, ANYMAIL_STATUSES
|
||||
from ..utils import last, combine, get_anymail_setting
|
||||
|
||||
from ..message import ANYMAIL_STATUSES, AnymailRecipientStatus
|
||||
from ..utils import combine, get_anymail_setting, last
|
||||
from .base_requests import AnymailRequestsBackend, RequestsPayload
|
||||
|
||||
|
||||
@@ -18,9 +17,15 @@ class EmailBackend(AnymailRequestsBackend):
|
||||
def __init__(self, **kwargs):
|
||||
"""Init options from Django settings"""
|
||||
esp_name = self.esp_name
|
||||
self.api_key = get_anymail_setting('api_key', esp_name=esp_name, kwargs=kwargs, allow_bare=True)
|
||||
api_url = get_anymail_setting('api_url', esp_name=esp_name, kwargs=kwargs,
|
||||
default="https://mandrillapp.com/api/1.0")
|
||||
self.api_key = get_anymail_setting(
|
||||
"api_key", esp_name=esp_name, kwargs=kwargs, allow_bare=True
|
||||
)
|
||||
api_url = get_anymail_setting(
|
||||
"api_url",
|
||||
esp_name=esp_name,
|
||||
kwargs=kwargs,
|
||||
default="https://mandrillapp.com/api/1.0",
|
||||
)
|
||||
if not api_url.endswith("/"):
|
||||
api_url += "/"
|
||||
super().__init__(api_url, **kwargs)
|
||||
@@ -32,18 +37,26 @@ class EmailBackend(AnymailRequestsBackend):
|
||||
parsed_response = self.deserialize_json_response(response, payload, message)
|
||||
recipient_status = {}
|
||||
try:
|
||||
# Mandrill returns a list of { email, status, _id, reject_reason } for each recipient
|
||||
# Mandrill returns a list of { email, status, _id, reject_reason }
|
||||
# for each recipient
|
||||
for item in parsed_response:
|
||||
email = item['email']
|
||||
status = item['status']
|
||||
email = item["email"]
|
||||
status = item["status"]
|
||||
if status not in ANYMAIL_STATUSES:
|
||||
status = 'unknown'
|
||||
message_id = item.get('_id', None) # can be missing for invalid/rejected recipients
|
||||
recipient_status[email] = AnymailRecipientStatus(message_id=message_id, status=status)
|
||||
status = "unknown"
|
||||
# "_id" can be missing for invalid/rejected recipients:
|
||||
message_id = item.get("_id", None)
|
||||
recipient_status[email] = AnymailRecipientStatus(
|
||||
message_id=message_id, status=status
|
||||
)
|
||||
except (KeyError, TypeError) as err:
|
||||
raise AnymailRequestsAPIError("Invalid Mandrill API response format",
|
||||
email_message=message, payload=payload, response=response,
|
||||
backend=self) from err
|
||||
raise AnymailRequestsAPIError(
|
||||
"Invalid Mandrill API response format",
|
||||
email_message=message,
|
||||
payload=payload,
|
||||
response=response,
|
||||
backend=self,
|
||||
) from err
|
||||
return recipient_status
|
||||
|
||||
|
||||
@@ -60,19 +73,18 @@ def encode_date_for_mandrill(dt):
|
||||
dt = dt.replace(microsecond=0)
|
||||
if dt.utcoffset() is not None:
|
||||
dt = (dt - dt.utcoffset()).replace(tzinfo=None)
|
||||
return dt.isoformat(' ')
|
||||
return dt.isoformat(" ")
|
||||
else:
|
||||
return dt
|
||||
|
||||
|
||||
class MandrillPayload(RequestsPayload):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.esp_extra = {} # late-bound in serialize_data
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def get_api_endpoint(self):
|
||||
if 'template_name' in self.data:
|
||||
if "template_name" in self.data:
|
||||
return "messages/send-template.json"
|
||||
else:
|
||||
return "messages/send.json"
|
||||
@@ -81,7 +93,7 @@ class MandrillPayload(RequestsPayload):
|
||||
self.process_esp_extra()
|
||||
if self.is_batch():
|
||||
# hide recipients from each other
|
||||
self.data['message']['preserve_recipients'] = False
|
||||
self.data["message"]["preserve_recipients"] = False
|
||||
return self.serialize_json(self.data)
|
||||
|
||||
#
|
||||
@@ -96,7 +108,9 @@ class MandrillPayload(RequestsPayload):
|
||||
|
||||
def set_from_email(self, email):
|
||||
if getattr(self.message, "use_template_from", False):
|
||||
self.deprecation_warning('message.use_template_from', 'message.from_email = None')
|
||||
self.deprecation_warning(
|
||||
"message.use_template_from", "message.from_email = None"
|
||||
)
|
||||
else:
|
||||
self.data["message"]["from_email"] = email.addr_spec
|
||||
if email.display_name:
|
||||
@@ -112,7 +126,9 @@ class MandrillPayload(RequestsPayload):
|
||||
|
||||
def set_subject(self, subject):
|
||||
if getattr(self.message, "use_template_subject", False):
|
||||
self.deprecation_warning('message.use_template_subject', 'message.subject = None')
|
||||
self.deprecation_warning(
|
||||
"message.use_template_subject", "message.subject = None"
|
||||
)
|
||||
else:
|
||||
self.data["message"]["subject"] = subject
|
||||
|
||||
@@ -129,7 +145,8 @@ class MandrillPayload(RequestsPayload):
|
||||
|
||||
def set_html_body(self, body):
|
||||
if "html" in self.data["message"]:
|
||||
# second html body could show up through multiple alternatives, or html body + alternative
|
||||
# second html body could show up through multiple alternatives,
|
||||
# or html body + alternative
|
||||
self.unsupported_feature("multiple html parts")
|
||||
self.data["message"]["html"] = body
|
||||
|
||||
@@ -140,11 +157,13 @@ class MandrillPayload(RequestsPayload):
|
||||
else:
|
||||
field = "attachments"
|
||||
name = attachment.name or ""
|
||||
self.data["message"].setdefault(field, []).append({
|
||||
"type": attachment.mimetype,
|
||||
"name": name,
|
||||
"content": attachment.b64content
|
||||
})
|
||||
self.data["message"].setdefault(field, []).append(
|
||||
{
|
||||
"type": attachment.mimetype,
|
||||
"name": name,
|
||||
"content": attachment.b64content,
|
||||
}
|
||||
)
|
||||
|
||||
def set_envelope_sender(self, email):
|
||||
# Only the domain is used
|
||||
@@ -170,22 +189,28 @@ class MandrillPayload(RequestsPayload):
|
||||
self.data.setdefault("template_content", []) # Mandrill requires something here
|
||||
|
||||
def set_merge_data(self, merge_data):
|
||||
self.data['message']['merge_vars'] = [
|
||||
{'rcpt': rcpt, 'vars': [{'name': key, 'content': rcpt_data[key]}
|
||||
for key in sorted(rcpt_data.keys())]} # sort for testing reproducibility
|
||||
self.data["message"]["merge_vars"] = [
|
||||
{
|
||||
"rcpt": rcpt,
|
||||
"vars": [
|
||||
# sort for testing reproducibility:
|
||||
{"name": key, "content": rcpt_data[key]}
|
||||
for key in sorted(rcpt_data.keys())
|
||||
],
|
||||
}
|
||||
for rcpt, rcpt_data in merge_data.items()
|
||||
]
|
||||
|
||||
def set_merge_global_data(self, merge_global_data):
|
||||
self.data['message']['global_merge_vars'] = [
|
||||
{'name': var, 'content': value}
|
||||
for var, value in merge_global_data.items()
|
||||
self.data["message"]["global_merge_vars"] = [
|
||||
{"name": var, "content": value} for var, value in merge_global_data.items()
|
||||
]
|
||||
|
||||
def set_merge_metadata(self, merge_metadata):
|
||||
# recipient_metadata format is similar to, but not quite the same as, merge_vars:
|
||||
self.data['message']['recipient_metadata'] = [
|
||||
{'rcpt': rcpt, 'values': rcpt_data}
|
||||
# recipient_metadata format is similar to, but not quite the same as,
|
||||
# merge_vars:
|
||||
self.data["message"]["recipient_metadata"] = [
|
||||
{"rcpt": rcpt, "values": rcpt_data}
|
||||
for rcpt, rcpt_data in merge_metadata.items()
|
||||
]
|
||||
|
||||
@@ -198,33 +223,40 @@ class MandrillPayload(RequestsPayload):
|
||||
esp_extra = self.esp_extra
|
||||
# Convert pythonic template_content dict to Mandrill name/content list
|
||||
try:
|
||||
template_content = esp_extra['template_content']
|
||||
template_content = esp_extra["template_content"]
|
||||
except KeyError:
|
||||
pass
|
||||
else:
|
||||
if hasattr(template_content, 'items'): # if it's dict-like
|
||||
# if it's dict-like:
|
||||
if hasattr(template_content, "items"):
|
||||
if esp_extra is self.esp_extra:
|
||||
esp_extra = self.esp_extra.copy() # don't modify caller's value
|
||||
esp_extra['template_content'] = [
|
||||
{'name': var, 'content': value}
|
||||
for var, value in template_content.items()]
|
||||
# don't modify caller's value
|
||||
esp_extra = self.esp_extra.copy()
|
||||
esp_extra["template_content"] = [
|
||||
{"name": var, "content": value}
|
||||
for var, value in template_content.items()
|
||||
]
|
||||
# Convert pythonic recipient_metadata dict to Mandrill rcpt/values list
|
||||
try:
|
||||
recipient_metadata = esp_extra['message']['recipient_metadata']
|
||||
recipient_metadata = esp_extra["message"]["recipient_metadata"]
|
||||
except KeyError:
|
||||
pass
|
||||
else:
|
||||
if hasattr(recipient_metadata, 'keys'): # if it's dict-like
|
||||
if esp_extra['message'] is self.esp_extra['message']:
|
||||
esp_extra['message'] = self.esp_extra['message'].copy() # don't modify caller's value
|
||||
# For testing reproducibility, we sort the recipients
|
||||
esp_extra['message']['recipient_metadata'] = [
|
||||
{'rcpt': rcpt, 'values': recipient_metadata[rcpt]}
|
||||
for rcpt in sorted(recipient_metadata.keys())]
|
||||
# Merge esp_extra with payload data: shallow merge within ['message'] and top-level keys
|
||||
self.data.update({k: v for k, v in esp_extra.items() if k != 'message'})
|
||||
# if it's dict-like:
|
||||
if hasattr(recipient_metadata, "keys"):
|
||||
if esp_extra["message"] is self.esp_extra["message"]:
|
||||
# don't modify caller's value:
|
||||
esp_extra["message"] = self.esp_extra["message"].copy()
|
||||
# For testing reproducibility, sort the recipients
|
||||
esp_extra["message"]["recipient_metadata"] = [
|
||||
{"rcpt": rcpt, "values": recipient_metadata[rcpt]}
|
||||
for rcpt in sorted(recipient_metadata.keys())
|
||||
]
|
||||
# Merge esp_extra with payload data: shallow merge within ['message']
|
||||
# and top-level keys
|
||||
self.data.update({k: v for k, v in esp_extra.items() if k != "message"})
|
||||
try:
|
||||
self.data['message'].update(esp_extra['message'])
|
||||
self.data["message"].update(esp_extra["message"])
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
@@ -245,69 +277,75 @@ class MandrillPayload(RequestsPayload):
|
||||
self.deprecation_warning(feature, replacement)
|
||||
|
||||
esp_message_attrs = (
|
||||
('async', last, None),
|
||||
('ip_pool', last, None),
|
||||
('from_name', last, None), # overrides display name parsed from from_email above
|
||||
('important', last, None),
|
||||
('auto_text', last, None),
|
||||
('auto_html', last, None),
|
||||
('inline_css', last, None),
|
||||
('url_strip_qs', last, None),
|
||||
('tracking_domain', last, None),
|
||||
('signing_domain', last, None),
|
||||
('return_path_domain', last, None),
|
||||
('merge_language', last, None),
|
||||
('preserve_recipients', last, None),
|
||||
('view_content_link', last, None),
|
||||
('subaccount', last, None),
|
||||
('google_analytics_domains', last, None),
|
||||
('google_analytics_campaign', last, None),
|
||||
('global_merge_vars', combine, None),
|
||||
('merge_vars', combine, None),
|
||||
('recipient_metadata', combine, None),
|
||||
('template_name', last, None),
|
||||
('template_content', combine, None),
|
||||
("async", last, None),
|
||||
("ip_pool", last, None),
|
||||
("from_name", last, None), # overrides display name parsed from from_email
|
||||
("important", last, None),
|
||||
("auto_text", last, None),
|
||||
("auto_html", last, None),
|
||||
("inline_css", last, None),
|
||||
("url_strip_qs", last, None),
|
||||
("tracking_domain", last, None),
|
||||
("signing_domain", last, None),
|
||||
("return_path_domain", last, None),
|
||||
("merge_language", last, None),
|
||||
("preserve_recipients", last, None),
|
||||
("view_content_link", last, None),
|
||||
("subaccount", last, None),
|
||||
("google_analytics_domains", last, None),
|
||||
("google_analytics_campaign", last, None),
|
||||
("global_merge_vars", combine, None),
|
||||
("merge_vars", combine, None),
|
||||
("recipient_metadata", combine, None),
|
||||
("template_name", last, None),
|
||||
("template_content", combine, None),
|
||||
)
|
||||
|
||||
def set_async(self, is_async):
|
||||
self.deprecated_to_esp_extra('async')
|
||||
self.esp_extra['async'] = is_async
|
||||
self.deprecated_to_esp_extra("async")
|
||||
self.esp_extra["async"] = is_async
|
||||
|
||||
def set_ip_pool(self, ip_pool):
|
||||
self.deprecated_to_esp_extra('ip_pool')
|
||||
self.esp_extra['ip_pool'] = ip_pool
|
||||
self.deprecated_to_esp_extra("ip_pool")
|
||||
self.esp_extra["ip_pool"] = ip_pool
|
||||
|
||||
def set_global_merge_vars(self, global_merge_vars):
|
||||
self.deprecation_warning('message.global_merge_vars', 'message.merge_global_data')
|
||||
self.deprecation_warning(
|
||||
"message.global_merge_vars", "message.merge_global_data"
|
||||
)
|
||||
self.set_merge_global_data(global_merge_vars)
|
||||
|
||||
def set_merge_vars(self, merge_vars):
|
||||
self.deprecation_warning('message.merge_vars', 'message.merge_data')
|
||||
self.deprecation_warning("message.merge_vars", "message.merge_data")
|
||||
self.set_merge_data(merge_vars)
|
||||
|
||||
def set_return_path_domain(self, domain):
|
||||
self.deprecation_warning('message.return_path_domain', 'message.envelope_sender')
|
||||
self.esp_extra.setdefault('message', {})['return_path_domain'] = domain
|
||||
self.deprecation_warning(
|
||||
"message.return_path_domain", "message.envelope_sender"
|
||||
)
|
||||
self.esp_extra.setdefault("message", {})["return_path_domain"] = domain
|
||||
|
||||
def set_template_name(self, template_name):
|
||||
self.deprecation_warning('message.template_name', 'message.template_id')
|
||||
self.deprecation_warning("message.template_name", "message.template_id")
|
||||
self.set_template_id(template_name)
|
||||
|
||||
def set_template_content(self, template_content):
|
||||
self.deprecated_to_esp_extra('template_content')
|
||||
self.esp_extra['template_content'] = template_content
|
||||
self.deprecated_to_esp_extra("template_content")
|
||||
self.esp_extra["template_content"] = template_content
|
||||
|
||||
def set_recipient_metadata(self, recipient_metadata):
|
||||
self.deprecated_to_esp_extra('recipient_metadata', in_message_dict=True)
|
||||
self.esp_extra.setdefault('message', {})['recipient_metadata'] = recipient_metadata
|
||||
self.deprecated_to_esp_extra("recipient_metadata", in_message_dict=True)
|
||||
self.esp_extra.setdefault("message", {})[
|
||||
"recipient_metadata"
|
||||
] = recipient_metadata
|
||||
|
||||
# Set up simple set_<attr> functions for any missing esp_message_attrs attrs
|
||||
# (avoids dozens of simple `self.data["message"][<attr>] = value` functions)
|
||||
|
||||
@classmethod
|
||||
def define_message_attr_setters(cls):
|
||||
for (attr, _, _) in cls.esp_message_attrs:
|
||||
setter_name = 'set_%s' % attr
|
||||
for attr, _, _ in cls.esp_message_attrs:
|
||||
setter_name = "set_%s" % attr
|
||||
try:
|
||||
getattr(cls, setter_name)
|
||||
except AttributeError:
|
||||
@@ -316,10 +354,12 @@ class MandrillPayload(RequestsPayload):
|
||||
|
||||
@staticmethod
|
||||
def make_setter(attr, setter_name):
|
||||
# sure wish we could use functools.partial to create instance methods (descriptors)
|
||||
# sure wish we could use functools.partial
|
||||
# to create instance methods (descriptors)
|
||||
def setter(self, value):
|
||||
self.deprecated_to_esp_extra(attr, in_message_dict=True)
|
||||
self.esp_extra.setdefault('message', {})[attr] = value
|
||||
self.esp_extra.setdefault("message", {})[attr] = value
|
||||
|
||||
setter.__name__ = setter_name
|
||||
return setter
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from .base_requests import AnymailRequestsBackend, RequestsPayload
|
||||
from ..exceptions import AnymailRequestsAPIError
|
||||
from ..message import AnymailRecipientStatus
|
||||
from ..utils import get_anymail_setting
|
||||
from .base_requests import AnymailRequestsBackend, RequestsPayload
|
||||
|
||||
|
||||
class EmailBackend(AnymailRequestsBackend):
|
||||
@@ -106,7 +106,7 @@ class PostalPayload(RequestsPayload):
|
||||
if attachment.inline:
|
||||
# see https://github.com/postalhq/postal/issues/731
|
||||
# but it might be possible with the send/raw endpoint
|
||||
self.unsupported_feature('inline attachments')
|
||||
self.unsupported_feature("inline attachments")
|
||||
return att
|
||||
|
||||
def set_attachments(self, attachments):
|
||||
|
||||
@@ -2,8 +2,11 @@ import re
|
||||
|
||||
from ..exceptions import AnymailRequestsAPIError
|
||||
from ..message import AnymailRecipientStatus
|
||||
from ..utils import get_anymail_setting, parse_address_list, CaseInsensitiveCasePreservingDict
|
||||
|
||||
from ..utils import (
|
||||
CaseInsensitiveCasePreservingDict,
|
||||
get_anymail_setting,
|
||||
parse_address_list,
|
||||
)
|
||||
from .base_requests import AnymailRequestsBackend, RequestsPayload
|
||||
|
||||
|
||||
@@ -17,9 +20,15 @@ class EmailBackend(AnymailRequestsBackend):
|
||||
def __init__(self, **kwargs):
|
||||
"""Init options from Django settings"""
|
||||
esp_name = self.esp_name
|
||||
self.server_token = get_anymail_setting('server_token', esp_name=esp_name, kwargs=kwargs, allow_bare=True)
|
||||
api_url = get_anymail_setting('api_url', esp_name=esp_name, kwargs=kwargs,
|
||||
default="https://api.postmarkapp.com/")
|
||||
self.server_token = get_anymail_setting(
|
||||
"server_token", esp_name=esp_name, kwargs=kwargs, allow_bare=True
|
||||
)
|
||||
api_url = get_anymail_setting(
|
||||
"api_url",
|
||||
esp_name=esp_name,
|
||||
kwargs=kwargs,
|
||||
default="https://api.postmarkapp.com/",
|
||||
)
|
||||
if not api_url.endswith("/"):
|
||||
api_url += "/"
|
||||
super().__init__(api_url, **kwargs)
|
||||
@@ -33,13 +42,17 @@ class EmailBackend(AnymailRequestsBackend):
|
||||
super().raise_for_status(response, payload, message)
|
||||
|
||||
def parse_recipient_status(self, response, payload, message):
|
||||
# Default to "unknown" status for each recipient, unless/until we find otherwise.
|
||||
# (This also forces recipient_status email capitalization to match that as sent,
|
||||
# while correctly handling Postmark's lowercase-only inactive recipient reporting.)
|
||||
unknown_status = AnymailRecipientStatus(message_id=None, status='unknown')
|
||||
recipient_status = CaseInsensitiveCasePreservingDict({
|
||||
recip.addr_spec: unknown_status
|
||||
for recip in payload.to_emails + payload.cc_and_bcc_emails})
|
||||
# Default to "unknown" status for each recipient, unless/until we find
|
||||
# otherwise. (This also forces recipient_status email capitalization to match
|
||||
# that as sent, while correctly handling Postmark's lowercase-only inactive
|
||||
# recipient reporting.)
|
||||
unknown_status = AnymailRecipientStatus(message_id=None, status="unknown")
|
||||
recipient_status = CaseInsensitiveCasePreservingDict(
|
||||
{
|
||||
recip.addr_spec: unknown_status
|
||||
for recip in payload.to_emails + payload.cc_and_bcc_emails
|
||||
}
|
||||
)
|
||||
|
||||
parsed_response = self.deserialize_json_response(response, payload, message)
|
||||
if not isinstance(parsed_response, list):
|
||||
@@ -52,21 +65,30 @@ class EmailBackend(AnymailRequestsBackend):
|
||||
error_code = one_response["ErrorCode"]
|
||||
msg = one_response["Message"]
|
||||
except (KeyError, TypeError) as err:
|
||||
raise AnymailRequestsAPIError("Invalid Postmark API response format",
|
||||
email_message=message, payload=payload, response=response,
|
||||
backend=self) from err
|
||||
raise AnymailRequestsAPIError(
|
||||
"Invalid Postmark API response format",
|
||||
email_message=message,
|
||||
payload=payload,
|
||||
response=response,
|
||||
backend=self,
|
||||
) from err
|
||||
|
||||
if error_code == 0:
|
||||
# At least partial success, and (some) email was sent.
|
||||
try:
|
||||
message_id = one_response["MessageID"]
|
||||
except KeyError as err:
|
||||
raise AnymailRequestsAPIError("Invalid Postmark API success response format",
|
||||
email_message=message, payload=payload,
|
||||
response=response, backend=self) from err
|
||||
raise AnymailRequestsAPIError(
|
||||
"Invalid Postmark API success response format",
|
||||
email_message=message,
|
||||
payload=payload,
|
||||
response=response,
|
||||
backend=self,
|
||||
) from err
|
||||
|
||||
# Assume all To recipients are "sent" unless proven otherwise below.
|
||||
# (Must use "To" from API response to get correct individual MessageIDs in batch send.)
|
||||
# (Must use "To" from API response to get correct individual MessageIDs
|
||||
# in batch send.)
|
||||
try:
|
||||
to_header = one_response["To"] # (missing if cc- or bcc-only send)
|
||||
except KeyError:
|
||||
@@ -74,60 +96,89 @@ class EmailBackend(AnymailRequestsBackend):
|
||||
else:
|
||||
for to in parse_address_list(to_header):
|
||||
recipient_status[to.addr_spec] = AnymailRecipientStatus(
|
||||
message_id=message_id, status='sent')
|
||||
message_id=message_id, status="sent"
|
||||
)
|
||||
|
||||
# Assume all Cc and Bcc recipients are "sent" unless proven otherwise below.
|
||||
# (Postmark doesn't report "Cc" or "Bcc" in API response; use original payload values.)
|
||||
# Assume all Cc and Bcc recipients are "sent" unless proven otherwise
|
||||
# below. (Postmark doesn't report "Cc" or "Bcc" in API response; use
|
||||
# original payload values.)
|
||||
for recip in payload.cc_and_bcc_emails:
|
||||
recipient_status[recip.addr_spec] = AnymailRecipientStatus(
|
||||
message_id=message_id, status='sent')
|
||||
message_id=message_id, status="sent"
|
||||
)
|
||||
|
||||
# Change "sent" to "rejected" if Postmark reported an address as "Inactive".
|
||||
# Sadly, have to parse human-readable message to figure out if everyone got it:
|
||||
# "Message OK, but will not deliver to these inactive addresses: {addr_spec, ...}.
|
||||
# Inactive recipients are ones that have generated a hard bounce or a spam complaint."
|
||||
# Note that error message emails are addr_spec only (no display names) and forced lowercase.
|
||||
# Change "sent" to "rejected" if Postmark reported an address as
|
||||
# "Inactive". Sadly, have to parse human-readable message to figure out
|
||||
# if everyone got it:
|
||||
# "Message OK, but will not deliver to these inactive addresses:
|
||||
# {addr_spec, ...}. Inactive recipients are ones that have generated
|
||||
# a hard bounce or a spam complaint."
|
||||
# Note that error message emails are addr_spec only (no display names)
|
||||
# and forced lowercase.
|
||||
reject_addr_specs = self._addr_specs_from_error_msg(
|
||||
msg, r'inactive addresses:\s*(.*)\.\s*Inactive recipients')
|
||||
msg, r"inactive addresses:\s*(.*)\.\s*Inactive recipients"
|
||||
)
|
||||
for reject_addr_spec in reject_addr_specs:
|
||||
recipient_status[reject_addr_spec] = AnymailRecipientStatus(
|
||||
message_id=None, status='rejected')
|
||||
message_id=None, status="rejected"
|
||||
)
|
||||
|
||||
elif error_code == 300: # Invalid email request
|
||||
# Various parse-time validation errors, which may include invalid recipients. Email not sent.
|
||||
# response["To"] is not populated for this error; must examine response["Message"]:
|
||||
if re.match(r"^(Invalid|Error\s+parsing)\s+'(To|Cc|Bcc)'", msg, re.IGNORECASE):
|
||||
# Various parse-time validation errors, which may include invalid
|
||||
# recipients. Email not sent. response["To"] is not populated for this
|
||||
# error; must examine response["Message"]:
|
||||
if re.match(
|
||||
r"^(Invalid|Error\s+parsing)\s+'(To|Cc|Bcc)'", msg, re.IGNORECASE
|
||||
):
|
||||
# Recipient-related errors: use AnymailRecipientsRefused logic
|
||||
# "Invalid 'To' address: '{addr_spec}'."
|
||||
# "Error parsing 'Cc': Illegal email domain '{domain}' in address '{addr_spec}'."
|
||||
# "Error parsing 'Bcc': Illegal email address '{addr_spec}'. It must contain the '@' symbol."
|
||||
invalid_addr_specs = self._addr_specs_from_error_msg(msg, r"address:?\s*'(.*)'")
|
||||
# - "Invalid 'To' address: '{addr_spec}'."
|
||||
# - "Error parsing 'Cc': Illegal email domain '{domain}'
|
||||
# in address '{addr_spec}'."
|
||||
# - "Error parsing 'Bcc': Illegal email address '{addr_spec}'.
|
||||
# It must contain the '@' symbol."
|
||||
invalid_addr_specs = self._addr_specs_from_error_msg(
|
||||
msg, r"address:?\s*'(.*)'"
|
||||
)
|
||||
for invalid_addr_spec in invalid_addr_specs:
|
||||
recipient_status[invalid_addr_spec] = AnymailRecipientStatus(
|
||||
message_id=None, status='invalid')
|
||||
message_id=None, status="invalid"
|
||||
)
|
||||
else:
|
||||
# Non-recipient errors; handle as normal API error response
|
||||
# "Invalid 'From' address: '{email_address}'."
|
||||
# "Error parsing 'Reply-To': Illegal email domain '{domain}' in address '{addr_spec}'."
|
||||
# "Invalid metadata content. ..."
|
||||
raise AnymailRequestsAPIError(email_message=message, payload=payload,
|
||||
response=response, backend=self)
|
||||
# - "Invalid 'From' address: '{email_address}'."
|
||||
# - "Error parsing 'Reply-To': Illegal email domain '{domain}'
|
||||
# in address '{addr_spec}'."
|
||||
# - "Invalid metadata content. ..."
|
||||
raise AnymailRequestsAPIError(
|
||||
email_message=message,
|
||||
payload=payload,
|
||||
response=response,
|
||||
backend=self,
|
||||
)
|
||||
|
||||
elif error_code == 406: # Inactive recipient
|
||||
# All recipients were rejected as hard-bounce or spam-complaint. Email not sent.
|
||||
# response["To"] is not populated for this error; must examine response["Message"]:
|
||||
# "You tried to send to a recipient that has been marked as inactive.\n
|
||||
# Found inactive addresses: {addr_spec, ...}.\n
|
||||
# Inactive recipients are ones that have generated a hard bounce or a spam complaint. "
|
||||
# All recipients were rejected as hard-bounce or spam-complaint. Email
|
||||
# not sent. response["To"] is not populated for this error; must examine
|
||||
# response["Message"]:
|
||||
# "You tried to send to a recipient that has been marked as
|
||||
# inactive.\n Found inactive addresses: {addr_spec, ...}.\n
|
||||
# Inactive recipients are ones that have generated a hard bounce
|
||||
# or a spam complaint. "
|
||||
reject_addr_specs = self._addr_specs_from_error_msg(
|
||||
msg, r'inactive addresses:\s*(.*)\.\s*Inactive recipients')
|
||||
msg, r"inactive addresses:\s*(.*)\.\s*Inactive recipients"
|
||||
)
|
||||
for reject_addr_spec in reject_addr_specs:
|
||||
recipient_status[reject_addr_spec] = AnymailRecipientStatus(
|
||||
message_id=None, status='rejected')
|
||||
message_id=None, status="rejected"
|
||||
)
|
||||
|
||||
else: # Other error
|
||||
raise AnymailRequestsAPIError(email_message=message, payload=payload, response=response,
|
||||
backend=self)
|
||||
raise AnymailRequestsAPIError(
|
||||
email_message=message,
|
||||
payload=payload,
|
||||
response=response,
|
||||
backend=self,
|
||||
)
|
||||
|
||||
return dict(recipient_status)
|
||||
|
||||
@@ -141,33 +192,37 @@ class EmailBackend(AnymailRequestsBackend):
|
||||
match = re.search(pattern, error_msg, re.MULTILINE)
|
||||
if match:
|
||||
emails = match.group(1) # "one@xample.com, two@example.com"
|
||||
return [email.strip().lower() for email in emails.split(',')]
|
||||
return [email.strip().lower() for email in emails.split(",")]
|
||||
else:
|
||||
return []
|
||||
|
||||
|
||||
class PostmarkPayload(RequestsPayload):
|
||||
|
||||
def __init__(self, message, defaults, backend, *args, **kwargs):
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
# 'X-Postmark-Server-Token': see get_request_params (and set_esp_extra)
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
# "X-Postmark-Server-Token": see get_request_params (and set_esp_extra)
|
||||
}
|
||||
self.server_token = backend.server_token # added to headers later, so esp_extra can override
|
||||
self.server_token = backend.server_token # esp_extra can override
|
||||
self.to_emails = []
|
||||
self.cc_and_bcc_emails = [] # need to track (separately) for parse_recipient_status
|
||||
self.cc_and_bcc_emails = [] # needed for parse_recipient_status
|
||||
self.merge_data = None
|
||||
self.merge_metadata = None
|
||||
super().__init__(message, defaults, backend, headers=headers, *args, **kwargs)
|
||||
|
||||
def get_api_endpoint(self):
|
||||
batch_send = self.is_batch()
|
||||
if 'TemplateAlias' in self.data or 'TemplateId' in self.data or 'TemplateModel' in self.data:
|
||||
if (
|
||||
"TemplateAlias" in self.data
|
||||
or "TemplateId" in self.data
|
||||
or "TemplateModel" in self.data
|
||||
):
|
||||
if batch_send:
|
||||
return "email/batchWithTemplates"
|
||||
else:
|
||||
# This is the one Postmark API documented to have a trailing slash. (Typo?)
|
||||
# This is the one Postmark API documented to have a trailing slash.
|
||||
# (Typo?)
|
||||
return "email/withTemplate/"
|
||||
else:
|
||||
if batch_send:
|
||||
@@ -177,7 +232,7 @@ class PostmarkPayload(RequestsPayload):
|
||||
|
||||
def get_request_params(self, api_url):
|
||||
params = super().get_request_params(api_url)
|
||||
params['headers']['X-Postmark-Server-Token'] = self.server_token
|
||||
params["headers"]["X-Postmark-Server-Token"] = self.server_token
|
||||
return params
|
||||
|
||||
def serialize_data(self):
|
||||
@@ -189,11 +244,15 @@ class PostmarkPayload(RequestsPayload):
|
||||
elif api_endpoint == "email/batch":
|
||||
data = [self.data_for_recipient(to) for to in self.to_emails]
|
||||
elif api_endpoint == "email/withTemplate/":
|
||||
assert self.merge_data is None and self.merge_metadata is None # else it's a batch send
|
||||
assert (
|
||||
self.merge_data is None and self.merge_metadata is None
|
||||
) # else it's a batch send
|
||||
data = self.data
|
||||
else:
|
||||
raise AssertionError("PostmarkPayload.serialize_data missing"
|
||||
" case for api_endpoint %r" % api_endpoint)
|
||||
raise AssertionError(
|
||||
"PostmarkPayload.serialize_data missing"
|
||||
" case for api_endpoint %r" % api_endpoint
|
||||
)
|
||||
return self.serialize_json(data)
|
||||
|
||||
def data_for_recipient(self, to):
|
||||
@@ -222,7 +281,7 @@ class PostmarkPayload(RequestsPayload):
|
||||
#
|
||||
|
||||
def init_payload(self):
|
||||
self.data = {} # becomes json
|
||||
self.data = {} # becomes json
|
||||
|
||||
def set_from_email_list(self, emails):
|
||||
# Postmark accepts multiple From email addresses
|
||||
@@ -233,7 +292,7 @@ class PostmarkPayload(RequestsPayload):
|
||||
assert recipient_type in ["to", "cc", "bcc"]
|
||||
if emails:
|
||||
field = recipient_type.capitalize()
|
||||
self.data[field] = ', '.join([email.address for email in emails])
|
||||
self.data[field] = ", ".join([email.address for email in emails])
|
||||
if recipient_type == "to":
|
||||
self.to_emails = emails
|
||||
else:
|
||||
@@ -249,8 +308,7 @@ class PostmarkPayload(RequestsPayload):
|
||||
|
||||
def set_extra_headers(self, headers):
|
||||
self.data["Headers"] = [
|
||||
{"Name": key, "Value": value}
|
||||
for key, value in headers.items()
|
||||
{"Name": key, "Value": value} for key, value in headers.items()
|
||||
]
|
||||
|
||||
def set_text_body(self, body):
|
||||
@@ -258,7 +316,8 @@ class PostmarkPayload(RequestsPayload):
|
||||
|
||||
def set_html_body(self, body):
|
||||
if "HtmlBody" in self.data:
|
||||
# second html body could show up through multiple alternatives, or html body + alternative
|
||||
# second html body could show up through multiple alternatives,
|
||||
# or html body + alternative
|
||||
self.unsupported_feature("multiple html parts")
|
||||
self.data["HtmlBody"] = body
|
||||
|
||||
@@ -289,10 +348,10 @@ class PostmarkPayload(RequestsPayload):
|
||||
if len(tags) > 0:
|
||||
self.data["Tag"] = tags[0]
|
||||
if len(tags) > 1:
|
||||
self.unsupported_feature('multiple tags (%r)' % tags)
|
||||
self.unsupported_feature("multiple tags (%r)" % tags)
|
||||
|
||||
def set_track_clicks(self, track_clicks):
|
||||
self.data["TrackLinks"] = 'HtmlAndText' if track_clicks else 'None'
|
||||
self.data["TrackLinks"] = "HtmlAndText" if track_clicks else "None"
|
||||
|
||||
def set_track_opens(self, track_opens):
|
||||
self.data["TrackOpens"] = track_opens
|
||||
@@ -327,4 +386,4 @@ class PostmarkPayload(RequestsPayload):
|
||||
def set_esp_extra(self, extra):
|
||||
self.data.update(extra)
|
||||
# Special handling for 'server_token':
|
||||
self.server_token = self.data.pop('server_token', self.server_token)
|
||||
self.server_token = self.data.pop("server_token", self.server_token)
|
||||
|
||||
@@ -4,10 +4,10 @@ from email.utils import quote as rfc822_quote
|
||||
|
||||
from requests.structures import CaseInsensitiveDict
|
||||
|
||||
from .base_requests import AnymailRequestsBackend, RequestsPayload
|
||||
from ..exceptions import AnymailConfigurationError, AnymailWarning
|
||||
from ..message import AnymailRecipientStatus
|
||||
from ..utils import BASIC_NUMERIC_TYPES, Mapping, get_anymail_setting, update_deep
|
||||
from .base_requests import AnymailRequestsBackend, RequestsPayload
|
||||
|
||||
|
||||
class EmailBackend(AnymailRequestsBackend):
|
||||
@@ -22,29 +22,45 @@ class EmailBackend(AnymailRequestsBackend):
|
||||
esp_name = self.esp_name
|
||||
|
||||
# Warn if v2-only username or password settings found
|
||||
username = get_anymail_setting('username', esp_name=esp_name, kwargs=kwargs, default=None, allow_bare=True)
|
||||
password = get_anymail_setting('password', esp_name=esp_name, kwargs=kwargs, default=None, allow_bare=True)
|
||||
username = get_anymail_setting(
|
||||
"username", esp_name=esp_name, kwargs=kwargs, default=None, allow_bare=True
|
||||
)
|
||||
password = get_anymail_setting(
|
||||
"password", esp_name=esp_name, kwargs=kwargs, default=None, allow_bare=True
|
||||
)
|
||||
if username or password:
|
||||
raise AnymailConfigurationError(
|
||||
"SendGrid v3 API doesn't support username/password auth; Please change to API key.")
|
||||
"SendGrid v3 API doesn't support username/password auth;"
|
||||
" Please change to API key."
|
||||
)
|
||||
|
||||
self.api_key = get_anymail_setting('api_key', esp_name=esp_name, kwargs=kwargs, allow_bare=True)
|
||||
self.api_key = get_anymail_setting(
|
||||
"api_key", esp_name=esp_name, kwargs=kwargs, allow_bare=True
|
||||
)
|
||||
|
||||
self.generate_message_id = get_anymail_setting('generate_message_id', esp_name=esp_name,
|
||||
kwargs=kwargs, default=True)
|
||||
self.merge_field_format = get_anymail_setting('merge_field_format', esp_name=esp_name,
|
||||
kwargs=kwargs, default=None)
|
||||
self.generate_message_id = get_anymail_setting(
|
||||
"generate_message_id", esp_name=esp_name, kwargs=kwargs, default=True
|
||||
)
|
||||
self.merge_field_format = get_anymail_setting(
|
||||
"merge_field_format", esp_name=esp_name, kwargs=kwargs, default=None
|
||||
)
|
||||
|
||||
# Undocumented setting to disable workaround for SendGrid display-name quoting bug (see below).
|
||||
# If/when SendGrid fixes their API, recipient names will end up with extra double quotes
|
||||
# until Anymail is updated to remove the workaround. In the meantime, you can disable it
|
||||
# by adding `"SENDGRID_WORKAROUND_NAME_QUOTE_BUG": False` to your `ANYMAIL` settings.
|
||||
self.workaround_name_quote_bug = get_anymail_setting('workaround_name_quote_bug', esp_name=esp_name,
|
||||
kwargs=kwargs, default=True)
|
||||
# Undocumented setting to disable workaround for SendGrid display-name quoting
|
||||
# bug (see below). If/when SendGrid fixes their API, recipient names will end up
|
||||
# with extra double quotes until Anymail is updated to remove the workaround.
|
||||
# In the meantime, you can disable it by adding
|
||||
# `"SENDGRID_WORKAROUND_NAME_QUOTE_BUG": False` to your `ANYMAIL` settings.
|
||||
self.workaround_name_quote_bug = get_anymail_setting(
|
||||
"workaround_name_quote_bug", esp_name=esp_name, kwargs=kwargs, default=True
|
||||
)
|
||||
|
||||
# This is SendGrid's newer Web API v3
|
||||
api_url = get_anymail_setting('api_url', esp_name=esp_name, kwargs=kwargs,
|
||||
default="https://api.sendgrid.com/v3/")
|
||||
api_url = get_anymail_setting(
|
||||
"api_url",
|
||||
esp_name=esp_name,
|
||||
kwargs=kwargs,
|
||||
default="https://api.sendgrid.com/v3/",
|
||||
)
|
||||
if not api_url.endswith("/"):
|
||||
api_url += "/"
|
||||
super().__init__(api_url, **kwargs)
|
||||
@@ -53,17 +69,19 @@ class EmailBackend(AnymailRequestsBackend):
|
||||
return SendGridPayload(message, defaults, self)
|
||||
|
||||
def parse_recipient_status(self, response, payload, message):
|
||||
# If we get here, the send call was successful.
|
||||
# (SendGrid uses a non-2xx response for any failures, caught in raise_for_status.)
|
||||
# SendGrid v3 doesn't provide any information in the response for a successful send,
|
||||
# so simulate a per-recipient status of "queued":
|
||||
return {recip.addr_spec: AnymailRecipientStatus(message_id=payload.message_ids.get(recip.addr_spec),
|
||||
status="queued")
|
||||
for recip in payload.all_recipients}
|
||||
# If we get here, the "send" call was successful. (SendGrid uses a non-2xx
|
||||
# response for any failures, caught in raise_for_status.) SendGrid v3 doesn't
|
||||
# provide any information in the response for a successful send, so simulate a
|
||||
# per-recipient status of "queued":
|
||||
return {
|
||||
recip.addr_spec: AnymailRecipientStatus(
|
||||
message_id=payload.message_ids.get(recip.addr_spec), status="queued"
|
||||
)
|
||||
for recip in payload.all_recipients
|
||||
}
|
||||
|
||||
|
||||
class SendGridPayload(RequestsPayload):
|
||||
|
||||
def __init__(self, message, defaults, backend, *args, **kwargs):
|
||||
self.all_recipients = [] # used for backend.parse_recipient_status
|
||||
self.generate_message_id = backend.generate_message_id
|
||||
@@ -75,11 +93,13 @@ class SendGridPayload(RequestsPayload):
|
||||
self.merge_global_data = {}
|
||||
self.merge_metadata = {}
|
||||
|
||||
http_headers = kwargs.pop('headers', {})
|
||||
http_headers['Authorization'] = 'Bearer %s' % backend.api_key
|
||||
http_headers['Content-Type'] = 'application/json'
|
||||
http_headers['Accept'] = 'application/json'
|
||||
super().__init__(message, defaults, backend, headers=http_headers, *args, **kwargs)
|
||||
http_headers = kwargs.pop("headers", {})
|
||||
http_headers["Authorization"] = "Bearer %s" % backend.api_key
|
||||
http_headers["Content-Type"] = "application/json"
|
||||
http_headers["Accept"] = "application/json"
|
||||
super().__init__(
|
||||
message, defaults, backend, headers=http_headers, *args, **kwargs
|
||||
)
|
||||
|
||||
def get_api_endpoint(self):
|
||||
return "mail/send"
|
||||
@@ -105,11 +125,17 @@ class SendGridPayload(RequestsPayload):
|
||||
return self.serialize_json(self.data)
|
||||
|
||||
def set_anymail_id(self):
|
||||
"""Ensure each personalization has a known anymail_id for later event tracking"""
|
||||
"""
|
||||
Ensure each personalization has a known anymail_id for later event tracking
|
||||
"""
|
||||
for personalization in self.data["personalizations"]:
|
||||
message_id = str(uuid.uuid4())
|
||||
personalization.setdefault("custom_args", {})["anymail_id"] = message_id
|
||||
for recipient in personalization["to"] + personalization.get("cc", []) + personalization.get("bcc", []):
|
||||
for recipient in (
|
||||
personalization["to"]
|
||||
+ personalization.get("cc", [])
|
||||
+ personalization.get("bcc", [])
|
||||
):
|
||||
self.message_ids[recipient["email"]] = message_id
|
||||
|
||||
def expand_personalizations_for_batch(self):
|
||||
@@ -139,8 +165,10 @@ class SendGridPayload(RequestsPayload):
|
||||
self.convert_dynamic_template_data_to_legacy_substitutions()
|
||||
|
||||
def convert_dynamic_template_data_to_legacy_substitutions(self):
|
||||
"""Change personalizations[...]['dynamic_template_data'] to ...['substitutions]"""
|
||||
merge_field_format = self.merge_field_format or '{}'
|
||||
"""
|
||||
Change personalizations[...]['dynamic_template_data'] to ...['substitutions]
|
||||
"""
|
||||
merge_field_format = self.merge_field_format or "{}"
|
||||
|
||||
all_merge_fields = set()
|
||||
for personalization in self.data["personalizations"]:
|
||||
@@ -149,10 +177,12 @@ class SendGridPayload(RequestsPayload):
|
||||
except KeyError:
|
||||
pass # no substitutions for this recipient
|
||||
else:
|
||||
# Convert dynamic_template_data keys for substitutions, using merge_field_format
|
||||
# Convert dynamic_template_data keys for substitutions,
|
||||
# using merge_field_format
|
||||
personalization["substitutions"] = {
|
||||
merge_field_format.format(field): data
|
||||
for field, data in dynamic_template_data.items()}
|
||||
for field, data in dynamic_template_data.items()
|
||||
}
|
||||
all_merge_fields.update(dynamic_template_data.keys())
|
||||
|
||||
if self.merge_field_format is None:
|
||||
@@ -160,15 +190,21 @@ class SendGridPayload(RequestsPayload):
|
||||
warnings.warn(
|
||||
"Your SendGrid merge fields don't seem to have delimiters, "
|
||||
"which can cause unexpected results with Anymail's merge_data. "
|
||||
"Search SENDGRID_MERGE_FIELD_FORMAT in the Anymail docs for more info.",
|
||||
AnymailWarning)
|
||||
"Search SENDGRID_MERGE_FIELD_FORMAT in the Anymail docs "
|
||||
"for more info.",
|
||||
AnymailWarning,
|
||||
)
|
||||
|
||||
if self.merge_global_data and all(field.isalnum() for field in self.merge_global_data.keys()):
|
||||
if self.merge_global_data and all(
|
||||
field.isalnum() for field in self.merge_global_data.keys()
|
||||
):
|
||||
warnings.warn(
|
||||
"Your SendGrid global merge fields don't seem to have delimiters, "
|
||||
"which can cause unexpected results with Anymail's merge_data. "
|
||||
"Search SENDGRID_MERGE_FIELD_FORMAT in the Anymail docs for more info.",
|
||||
AnymailWarning)
|
||||
"Search SENDGRID_MERGE_FIELD_FORMAT in the Anymail docs "
|
||||
"for more info.",
|
||||
AnymailWarning,
|
||||
)
|
||||
|
||||
def build_merge_metadata(self):
|
||||
if self.merge_metadata:
|
||||
@@ -208,8 +244,9 @@ class SendGridPayload(RequestsPayload):
|
||||
workaround_name_quote_bug = self.workaround_name_quote_bug
|
||||
# Normally, exactly one "personalizations" entry for all recipients
|
||||
# (Exception: with merge_data; will be burst apart later.)
|
||||
self.data["personalizations"][0][recipient_type] = \
|
||||
[self.email_object(email, workaround_name_quote_bug) for email in emails]
|
||||
self.data["personalizations"][0][recipient_type] = [
|
||||
self.email_object(email, workaround_name_quote_bug) for email in emails
|
||||
]
|
||||
self.all_recipients += emails # used for backend.parse_recipient_status
|
||||
|
||||
def set_subject(self, subject):
|
||||
@@ -226,10 +263,12 @@ class SendGridPayload(RequestsPayload):
|
||||
def set_extra_headers(self, headers):
|
||||
# SendGrid requires header values to be strings -- not integers.
|
||||
# We'll stringify ints and floats; anything else is the caller's responsibility.
|
||||
self.data["headers"].update({
|
||||
k: str(v) if isinstance(v, BASIC_NUMERIC_TYPES) else v
|
||||
for k, v in headers.items()
|
||||
})
|
||||
self.data["headers"].update(
|
||||
{
|
||||
k: str(v) if isinstance(v, BASIC_NUMERIC_TYPES) else v
|
||||
for k, v in headers.items()
|
||||
}
|
||||
)
|
||||
|
||||
def set_text_body(self, body):
|
||||
# Empty strings (the EmailMessage default) can cause unexpected SendGrid
|
||||
@@ -238,33 +277,41 @@ class SendGridPayload(RequestsPayload):
|
||||
# Treat an empty string as a request to omit the body
|
||||
# (which means use the template content if present.)
|
||||
if body != "":
|
||||
self.data.setdefault("content", []).append({
|
||||
"type": "text/plain",
|
||||
"value": body,
|
||||
})
|
||||
self.data.setdefault("content", []).append(
|
||||
{
|
||||
"type": "text/plain",
|
||||
"value": body,
|
||||
}
|
||||
)
|
||||
|
||||
def set_html_body(self, body):
|
||||
# SendGrid's API permits multiple html bodies
|
||||
# "If you choose to include the text/plain or text/html mime types, they must be
|
||||
# the first indices of the content array in the order text/plain, text/html."
|
||||
if body != "": # see note in set_text_body about template rendering
|
||||
self.data.setdefault("content", []).append({
|
||||
"type": "text/html",
|
||||
"value": body,
|
||||
})
|
||||
# Body must not be empty (see note in set_text_body about template rendering).
|
||||
if body != "":
|
||||
self.data.setdefault("content", []).append(
|
||||
{
|
||||
"type": "text/html",
|
||||
"value": body,
|
||||
}
|
||||
)
|
||||
|
||||
def add_alternative(self, content, mimetype):
|
||||
# SendGrid is one of the few ESPs that supports arbitrary alternative parts in their API
|
||||
self.data.setdefault("content", []).append({
|
||||
"type": mimetype,
|
||||
"value": content,
|
||||
})
|
||||
# SendGrid is one of the few ESPs that supports arbitrary alternative parts
|
||||
self.data.setdefault("content", []).append(
|
||||
{
|
||||
"type": mimetype,
|
||||
"value": content,
|
||||
}
|
||||
)
|
||||
|
||||
def add_attachment(self, attachment):
|
||||
att = {
|
||||
"content": attachment.b64content,
|
||||
"type": attachment.mimetype,
|
||||
"filename": attachment.name or '', # required -- submit empty string if unknown
|
||||
# (filename is required -- submit empty string if unknown)
|
||||
"filename": attachment.name or "",
|
||||
}
|
||||
if attachment.inline:
|
||||
att["disposition"] = "inline"
|
||||
@@ -276,9 +323,10 @@ class SendGridPayload(RequestsPayload):
|
||||
|
||||
def transform_metadata(self, metadata):
|
||||
# SendGrid requires custom_args values to be strings -- not integers.
|
||||
# (And issues the cryptic error {"field": null, "message": "Bad Request", "help": null}
|
||||
# (And issues the cryptic error
|
||||
# {"field": null, "message": "Bad Request", "help": null}
|
||||
# if they're not.)
|
||||
# We'll stringify ints and floats; anything else is the caller's responsibility.
|
||||
# Stringify ints and floats; anything else is the caller's responsibility.
|
||||
return {
|
||||
k: str(v) if isinstance(v, BASIC_NUMERIC_TYPES) else v
|
||||
for k, v in metadata.items()
|
||||
@@ -330,8 +378,12 @@ class SendGridPayload(RequestsPayload):
|
||||
self.merge_metadata = merge_metadata
|
||||
|
||||
def set_esp_extra(self, extra):
|
||||
self.merge_field_format = extra.pop("merge_field_format", self.merge_field_format)
|
||||
self.use_dynamic_template = extra.pop("use_dynamic_template", self.use_dynamic_template)
|
||||
self.merge_field_format = extra.pop(
|
||||
"merge_field_format", self.merge_field_format
|
||||
)
|
||||
self.use_dynamic_template = extra.pop(
|
||||
"use_dynamic_template", self.use_dynamic_template
|
||||
)
|
||||
if isinstance(extra.get("personalizations", None), Mapping):
|
||||
# merge personalizations *dict* into other message personalizations
|
||||
assert len(self.data["personalizations"]) == 1
|
||||
@@ -339,6 +391,7 @@ class SendGridPayload(RequestsPayload):
|
||||
if "x-smtpapi" in extra:
|
||||
raise AnymailConfigurationError(
|
||||
"You are attempting to use SendGrid v2 API-style x-smtpapi params "
|
||||
"with the SendGrid v3 API. Please update your `esp_extra` to the new API."
|
||||
"with the SendGrid v3 API. Please update your `esp_extra` "
|
||||
"to the new API."
|
||||
)
|
||||
update_deep(self.data, extra)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
from requests.structures import CaseInsensitiveDict
|
||||
|
||||
from .base_requests import AnymailRequestsBackend, RequestsPayload
|
||||
from ..exceptions import AnymailRequestsAPIError
|
||||
from ..message import AnymailRecipientStatus
|
||||
from ..utils import get_anymail_setting, BASIC_NUMERIC_TYPES
|
||||
from ..utils import BASIC_NUMERIC_TYPES, get_anymail_setting
|
||||
from .base_requests import AnymailRequestsBackend, RequestsPayload
|
||||
|
||||
|
||||
class EmailBackend(AnymailRequestsBackend):
|
||||
@@ -17,13 +17,13 @@ class EmailBackend(AnymailRequestsBackend):
|
||||
"""Init options from Django settings"""
|
||||
esp_name = self.esp_name
|
||||
self.api_key = get_anymail_setting(
|
||||
'api_key',
|
||||
"api_key",
|
||||
esp_name=esp_name,
|
||||
kwargs=kwargs,
|
||||
allow_bare=True,
|
||||
)
|
||||
api_url = get_anymail_setting(
|
||||
'api_url',
|
||||
"api_url",
|
||||
esp_name=esp_name,
|
||||
kwargs=kwargs,
|
||||
default="https://api.sendinblue.com/v3/",
|
||||
@@ -40,42 +40,45 @@ class EmailBackend(AnymailRequestsBackend):
|
||||
# https://developers.sendinblue.com/docs/responses
|
||||
message_id = None
|
||||
|
||||
if response.content != b'':
|
||||
if response.content != b"":
|
||||
parsed_response = self.deserialize_json_response(response, payload, message)
|
||||
try:
|
||||
message_id = parsed_response['messageId']
|
||||
message_id = parsed_response["messageId"]
|
||||
except (KeyError, TypeError) as err:
|
||||
raise AnymailRequestsAPIError("Invalid SendinBlue API response format",
|
||||
email_message=message, payload=payload, response=response,
|
||||
backend=self) from err
|
||||
raise AnymailRequestsAPIError(
|
||||
"Invalid SendinBlue API response format",
|
||||
email_message=message,
|
||||
payload=payload,
|
||||
response=response,
|
||||
backend=self,
|
||||
) from err
|
||||
|
||||
status = AnymailRecipientStatus(message_id=message_id, status="queued")
|
||||
return {recipient.addr_spec: status for recipient in payload.all_recipients}
|
||||
|
||||
|
||||
class SendinBluePayload(RequestsPayload):
|
||||
|
||||
def __init__(self, message, defaults, backend, *args, **kwargs):
|
||||
self.all_recipients = [] # used for backend.parse_recipient_status
|
||||
|
||||
http_headers = kwargs.pop('headers', {})
|
||||
http_headers['api-key'] = backend.api_key
|
||||
http_headers['Content-Type'] = 'application/json'
|
||||
http_headers = kwargs.pop("headers", {})
|
||||
http_headers["api-key"] = backend.api_key
|
||||
http_headers["Content-Type"] = "application/json"
|
||||
|
||||
super().__init__(message, defaults, backend, headers=http_headers, *args, **kwargs)
|
||||
super().__init__(
|
||||
message, defaults, backend, headers=http_headers, *args, **kwargs
|
||||
)
|
||||
|
||||
def get_api_endpoint(self):
|
||||
return "smtp/email"
|
||||
|
||||
def init_payload(self):
|
||||
self.data = { # becomes json
|
||||
'headers': CaseInsensitiveDict()
|
||||
}
|
||||
self.data = {"headers": CaseInsensitiveDict()} # becomes json
|
||||
|
||||
def serialize_data(self):
|
||||
"""Performs any necessary serialization on self.data, and returns the result."""
|
||||
if not self.data['headers']:
|
||||
del self.data['headers'] # don't send empty headers
|
||||
if not self.data["headers"]:
|
||||
del self.data["headers"] # don't send empty headers
|
||||
return self.serialize_json(self.data)
|
||||
|
||||
#
|
||||
@@ -86,9 +89,9 @@ class SendinBluePayload(RequestsPayload):
|
||||
def email_object(email):
|
||||
"""Converts EmailAddress to SendinBlue API array"""
|
||||
email_object = dict()
|
||||
email_object['email'] = email.addr_spec
|
||||
email_object["email"] = email.addr_spec
|
||||
if email.display_name:
|
||||
email_object['name'] = email.display_name
|
||||
email_object["name"] = email.display_name
|
||||
return email_object
|
||||
|
||||
def set_from_email(self, email):
|
||||
@@ -109,39 +112,41 @@ class SendinBluePayload(RequestsPayload):
|
||||
if len(emails) > 1:
|
||||
self.unsupported_feature("multiple reply_to addresses")
|
||||
if len(emails) > 0:
|
||||
self.data['replyTo'] = self.email_object(emails[0])
|
||||
self.data["replyTo"] = self.email_object(emails[0])
|
||||
|
||||
def set_extra_headers(self, headers):
|
||||
# SendinBlue requires header values to be strings -- not integers -- as of 11/2022.
|
||||
# We'll stringify ints and floats; anything else is the caller's responsibility.
|
||||
self.data["headers"].update({
|
||||
k: str(v) if isinstance(v, BASIC_NUMERIC_TYPES) else v
|
||||
for k, v in headers.items()
|
||||
})
|
||||
# SendinBlue requires header values to be strings (not integers) as of 11/2022.
|
||||
# Stringify ints and floats; anything else is the caller's responsibility.
|
||||
self.data["headers"].update(
|
||||
{
|
||||
k: str(v) if isinstance(v, BASIC_NUMERIC_TYPES) else v
|
||||
for k, v in headers.items()
|
||||
}
|
||||
)
|
||||
|
||||
def set_tags(self, tags):
|
||||
if len(tags) > 0:
|
||||
self.data['tags'] = tags
|
||||
self.data["tags"] = tags
|
||||
|
||||
def set_template_id(self, template_id):
|
||||
self.data['templateId'] = template_id
|
||||
self.data["templateId"] = template_id
|
||||
|
||||
def set_text_body(self, body):
|
||||
if body:
|
||||
self.data['textContent'] = body
|
||||
self.data["textContent"] = body
|
||||
|
||||
def set_html_body(self, body):
|
||||
if body:
|
||||
if "htmlContent" in self.data:
|
||||
self.unsupported_feature("multiple html parts")
|
||||
|
||||
self.data['htmlContent'] = body
|
||||
self.data["htmlContent"] = body
|
||||
|
||||
def add_attachment(self, attachment):
|
||||
"""Converts attachments to SendinBlue API {name, base64} array"""
|
||||
att = {
|
||||
'name': attachment.name or '',
|
||||
'content': attachment.b64content,
|
||||
"name": attachment.name or "",
|
||||
"content": attachment.b64content,
|
||||
}
|
||||
|
||||
if attachment.inline:
|
||||
@@ -157,15 +162,15 @@ class SendinBluePayload(RequestsPayload):
|
||||
self.unsupported_feature("merge_data")
|
||||
|
||||
def set_merge_global_data(self, merge_global_data):
|
||||
self.data['params'] = merge_global_data
|
||||
self.data["params"] = merge_global_data
|
||||
|
||||
def set_metadata(self, metadata):
|
||||
# SendinBlue expects a single string payload
|
||||
self.data['headers']["X-Mailin-custom"] = self.serialize_json(metadata)
|
||||
self.data["headers"]["X-Mailin-custom"] = self.serialize_json(metadata)
|
||||
|
||||
def set_send_at(self, send_at):
|
||||
try:
|
||||
start_time_iso = send_at.isoformat(timespec="milliseconds")
|
||||
except (AttributeError, TypeError):
|
||||
start_time_iso = send_at # assume user already formatted
|
||||
self.data['scheduledAt'] = start_time_iso
|
||||
self.data["scheduledAt"] = start_time_iso
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from .base_requests import AnymailRequestsBackend, RequestsPayload
|
||||
from ..exceptions import AnymailRequestsAPIError
|
||||
from ..message import AnymailRecipientStatus
|
||||
from ..utils import get_anymail_setting, update_deep
|
||||
from .base_requests import AnymailRequestsBackend, RequestsPayload
|
||||
|
||||
|
||||
class EmailBackend(AnymailRequestsBackend):
|
||||
@@ -13,12 +13,18 @@ class EmailBackend(AnymailRequestsBackend):
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
"""Init options from Django settings"""
|
||||
self.api_key = get_anymail_setting('api_key', esp_name=self.esp_name,
|
||||
kwargs=kwargs, allow_bare=True)
|
||||
self.subaccount = get_anymail_setting('subaccount', esp_name=self.esp_name,
|
||||
kwargs=kwargs, default=None)
|
||||
api_url = get_anymail_setting('api_url', esp_name=self.esp_name, kwargs=kwargs,
|
||||
default="https://api.sparkpost.com/api/v1/")
|
||||
self.api_key = get_anymail_setting(
|
||||
"api_key", esp_name=self.esp_name, kwargs=kwargs, allow_bare=True
|
||||
)
|
||||
self.subaccount = get_anymail_setting(
|
||||
"subaccount", esp_name=self.esp_name, kwargs=kwargs, default=None
|
||||
)
|
||||
api_url = get_anymail_setting(
|
||||
"api_url",
|
||||
esp_name=self.esp_name,
|
||||
kwargs=kwargs,
|
||||
default="https://api.sparkpost.com/api/v1/",
|
||||
)
|
||||
if not api_url.endswith("/"):
|
||||
api_url += "/"
|
||||
super().__init__(api_url, **kwargs)
|
||||
@@ -34,9 +40,13 @@ class EmailBackend(AnymailRequestsBackend):
|
||||
rejected = results["total_rejected_recipients"]
|
||||
transmission_id = results["id"]
|
||||
except (KeyError, TypeError) as err:
|
||||
raise AnymailRequestsAPIError("Invalid SparkPost API response format",
|
||||
email_message=message, payload=payload,
|
||||
response=response, backend=self) from err
|
||||
raise AnymailRequestsAPIError(
|
||||
"Invalid SparkPost API response format",
|
||||
email_message=message,
|
||||
payload=payload,
|
||||
response=response,
|
||||
backend=self,
|
||||
) from err
|
||||
|
||||
# SparkPost doesn't (yet*) tell us *which* recipients were accepted or rejected.
|
||||
# (* looks like undocumented 'rcpt_to_errors' might provide this info.)
|
||||
@@ -44,26 +54,32 @@ class EmailBackend(AnymailRequestsBackend):
|
||||
# else just report 'unknown' for all recipients.
|
||||
recipient_count = len(payload.recipients)
|
||||
if accepted == recipient_count and rejected == 0:
|
||||
status = 'queued'
|
||||
status = "queued"
|
||||
elif rejected == recipient_count and accepted == 0:
|
||||
status = 'rejected'
|
||||
status = "rejected"
|
||||
else: # mixed results, or wrong total
|
||||
status = 'unknown'
|
||||
recipient_status = AnymailRecipientStatus(message_id=transmission_id, status=status)
|
||||
return {recipient.addr_spec: recipient_status for recipient in payload.recipients}
|
||||
status = "unknown"
|
||||
recipient_status = AnymailRecipientStatus(
|
||||
message_id=transmission_id, status=status
|
||||
)
|
||||
return {
|
||||
recipient.addr_spec: recipient_status for recipient in payload.recipients
|
||||
}
|
||||
|
||||
|
||||
class SparkPostPayload(RequestsPayload):
|
||||
def __init__(self, message, defaults, backend, *args, **kwargs):
|
||||
http_headers = {
|
||||
'Authorization': backend.api_key,
|
||||
'Content-Type': 'application/json',
|
||||
"Authorization": backend.api_key,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
if backend.subaccount is not None:
|
||||
http_headers['X-MSYS-SUBACCOUNT'] = backend.subaccount
|
||||
http_headers["X-MSYS-SUBACCOUNT"] = backend.subaccount
|
||||
self.recipients = [] # all recipients, for backend parse_recipient_status
|
||||
self.cc_and_bcc = [] # for _finalize_recipients
|
||||
super().__init__(message, defaults, backend, headers=http_headers, *args, **kwargs)
|
||||
super().__init__(
|
||||
message, defaults, backend, headers=http_headers, *args, **kwargs
|
||||
)
|
||||
|
||||
def get_api_endpoint(self):
|
||||
return "transmissions/"
|
||||
@@ -74,15 +90,15 @@ class SparkPostPayload(RequestsPayload):
|
||||
|
||||
def _finalize_recipients(self):
|
||||
# https://www.sparkpost.com/docs/faq/cc-bcc-with-rest-api/
|
||||
# self.data["recipients"] is currently a list of all to-recipients. We need to add
|
||||
# all cc and bcc recipients. Exactly how depends on whether this is a batch send.
|
||||
# self.data["recipients"] is currently a list of all to-recipients. Must add all
|
||||
# cc and bcc recipients. Exactly how depends on whether this is a batch send.
|
||||
if self.is_batch():
|
||||
# For batch sends, must duplicate the cc/bcc for *every* to-recipient
|
||||
# (using each to-recipient's metadata and substitutions).
|
||||
extra_recipients = []
|
||||
for to_recipient in self.data["recipients"]:
|
||||
for email in self.cc_and_bcc:
|
||||
extra = to_recipient.copy() # capture "metadata" and "substitutions", if any
|
||||
extra = to_recipient.copy() # gets "metadata" and "substitutions"
|
||||
extra["address"] = {
|
||||
"email": email.addr_spec,
|
||||
"header_to": to_recipient["address"]["header_to"],
|
||||
@@ -94,17 +110,21 @@ class SparkPostPayload(RequestsPayload):
|
||||
# "To" header to show all the "To" recipients...
|
||||
full_to_header = ", ".join(
|
||||
to_recipient["address"]["header_to"]
|
||||
for to_recipient in self.data["recipients"])
|
||||
for to_recipient in self.data["recipients"]
|
||||
)
|
||||
for recipient in self.data["recipients"]:
|
||||
recipient["address"]["header_to"] = full_to_header
|
||||
# ... and then simply add the cc/bcc to the end of the list.
|
||||
# (There is no per-recipient data, or it would be a batch send.)
|
||||
self.data["recipients"].extend(
|
||||
{"address": {
|
||||
"email": email.addr_spec,
|
||||
"header_to": full_to_header,
|
||||
}}
|
||||
for email in self.cc_and_bcc)
|
||||
{
|
||||
"address": {
|
||||
"email": email.addr_spec,
|
||||
"header_to": full_to_header,
|
||||
}
|
||||
}
|
||||
for email in self.cc_and_bcc
|
||||
)
|
||||
|
||||
#
|
||||
# Payload construction
|
||||
@@ -127,11 +147,14 @@ class SparkPostPayload(RequestsPayload):
|
||||
# (We use "header_to" rather than "name" to simplify some logic
|
||||
# in _finalize_recipients; the results end up the same.)
|
||||
self.data["recipients"].extend(
|
||||
{"address": {
|
||||
"email": email.addr_spec,
|
||||
"header_to": email.address,
|
||||
}}
|
||||
for email in emails)
|
||||
{
|
||||
"address": {
|
||||
"email": email.addr_spec,
|
||||
"header_to": email.address,
|
||||
}
|
||||
}
|
||||
for email in emails
|
||||
)
|
||||
self.recipients += emails
|
||||
|
||||
def set_cc(self, emails):
|
||||
@@ -155,7 +178,9 @@ class SparkPostPayload(RequestsPayload):
|
||||
|
||||
def set_reply_to(self, emails):
|
||||
if emails:
|
||||
self.data["content"]["reply_to"] = ", ".join(email.address for email in emails)
|
||||
self.data["content"]["reply_to"] = ", ".join(
|
||||
email.address for email in emails
|
||||
)
|
||||
|
||||
def set_extra_headers(self, headers):
|
||||
if headers:
|
||||
@@ -166,7 +191,8 @@ class SparkPostPayload(RequestsPayload):
|
||||
|
||||
def set_html_body(self, body):
|
||||
if "html" in self.data["content"]:
|
||||
# second html body could show up through multiple alternatives, or html body + alternative
|
||||
# second html body could show up through multiple alternatives,
|
||||
# or html body + alternative
|
||||
self.unsupported_feature("multiple html parts")
|
||||
self.data["content"]["html"] = body
|
||||
|
||||
@@ -179,19 +205,27 @@ class SparkPostPayload(RequestsPayload):
|
||||
super().add_alternative(content, mimetype)
|
||||
|
||||
def set_attachments(self, atts):
|
||||
attachments = [{
|
||||
"name": att.name or "",
|
||||
"type": att.content_type,
|
||||
"data": att.b64content,
|
||||
} for att in atts if not att.inline]
|
||||
attachments = [
|
||||
{
|
||||
"name": att.name or "",
|
||||
"type": att.content_type,
|
||||
"data": att.b64content,
|
||||
}
|
||||
for att in atts
|
||||
if not att.inline
|
||||
]
|
||||
if attachments:
|
||||
self.data["content"]["attachments"] = attachments
|
||||
|
||||
inline_images = [{
|
||||
"name": att.cid,
|
||||
"type": att.mimetype,
|
||||
"data": att.b64content,
|
||||
} for att in atts if att.inline]
|
||||
inline_images = [
|
||||
{
|
||||
"name": att.cid,
|
||||
"type": att.mimetype,
|
||||
"data": att.b64content,
|
||||
}
|
||||
for att in atts
|
||||
if att.inline
|
||||
]
|
||||
if inline_images:
|
||||
self.data["content"]["inline_images"] = inline_images
|
||||
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
from django.core import mail
|
||||
|
||||
from .base import AnymailBaseBackend, BasePayload
|
||||
from ..exceptions import AnymailAPIError
|
||||
from ..message import AnymailRecipientStatus
|
||||
from .base import AnymailBaseBackend, BasePayload
|
||||
|
||||
|
||||
class EmailBackend(AnymailBaseBackend):
|
||||
"""
|
||||
Anymail backend that simulates sending messages, useful for testing.
|
||||
|
||||
Sent messages are collected in django.core.mail.outbox (as with Django's locmem backend).
|
||||
Sent messages are collected in django.core.mail.outbox
|
||||
(as with Django's locmem backend).
|
||||
|
||||
In addition:
|
||||
* Anymail send params parsed from the message will be attached to the outbox message
|
||||
as a dict in the attr `anymail_test_params`
|
||||
* If the caller supplies an `anymail_test_response` attr on the message, that will be
|
||||
used instead of the default "sent" response. It can be either an AnymailRecipientStatus
|
||||
or an instance of AnymailAPIError (or a subclass) to raise an exception.
|
||||
* Anymail send params parsed from the message will be attached
|
||||
to the outbox message as a dict in the attr `anymail_test_params`
|
||||
* If the caller supplies an `anymail_test_response` attr on the message,
|
||||
that will be used instead of the default "sent" response. It can be either
|
||||
an AnymailRecipientStatus or an instance of AnymailAPIError (or a subclass)
|
||||
to raise an exception.
|
||||
"""
|
||||
|
||||
esp_name = "Test"
|
||||
@@ -24,9 +26,9 @@ class EmailBackend(AnymailBaseBackend):
|
||||
def __init__(self, *args, **kwargs):
|
||||
# Allow replacing the payload, for testing.
|
||||
# (Real backends would generally not implement this option.)
|
||||
self._payload_class = kwargs.pop('payload_class', TestPayload)
|
||||
self._payload_class = kwargs.pop("payload_class", TestPayload)
|
||||
super().__init__(*args, **kwargs)
|
||||
if not hasattr(mail, 'outbox'):
|
||||
if not hasattr(mail, "outbox"):
|
||||
mail.outbox = [] # see django.core.mail.backends.locmem
|
||||
|
||||
def get_esp_message_id(self, message):
|
||||
@@ -49,19 +51,20 @@ class EmailBackend(AnymailBaseBackend):
|
||||
except AttributeError:
|
||||
# Default is to return 'sent' for each recipient
|
||||
status = AnymailRecipientStatus(
|
||||
message_id=self.get_esp_message_id(message),
|
||||
status='sent'
|
||||
message_id=self.get_esp_message_id(message), status="sent"
|
||||
)
|
||||
response = {
|
||||
'recipient_status': {email: status for email in payload.recipient_emails}
|
||||
"recipient_status": {
|
||||
email: status for email in payload.recipient_emails
|
||||
}
|
||||
}
|
||||
return response
|
||||
|
||||
def parse_recipient_status(self, response, payload, message):
|
||||
try:
|
||||
return response['recipient_status']
|
||||
return response["recipient_status"]
|
||||
except KeyError as err:
|
||||
raise AnymailAPIError('Unparsable test response') from err
|
||||
raise AnymailAPIError("Unparsable test response") from err
|
||||
|
||||
|
||||
class TestPayload(BasePayload):
|
||||
@@ -76,79 +79,79 @@ class TestPayload(BasePayload):
|
||||
def get_params(self):
|
||||
# Test backend callers can check message.anymail_test_params['is_batch_send']
|
||||
# to verify whether Anymail thought the message should use batch send logic.
|
||||
self.params['is_batch_send'] = self.is_batch()
|
||||
self.params["is_batch_send"] = self.is_batch()
|
||||
return self.params
|
||||
|
||||
def set_from_email(self, email):
|
||||
self.params['from'] = email
|
||||
self.params["from"] = email
|
||||
|
||||
def set_envelope_sender(self, email):
|
||||
self.params['envelope_sender'] = email.addr_spec
|
||||
self.params["envelope_sender"] = email.addr_spec
|
||||
|
||||
def set_to(self, emails):
|
||||
self.params['to'] = emails
|
||||
self.params["to"] = emails
|
||||
self.recipient_emails += [email.addr_spec for email in emails]
|
||||
|
||||
def set_cc(self, emails):
|
||||
self.params['cc'] = emails
|
||||
self.params["cc"] = emails
|
||||
self.recipient_emails += [email.addr_spec for email in emails]
|
||||
|
||||
def set_bcc(self, emails):
|
||||
self.params['bcc'] = emails
|
||||
self.params["bcc"] = emails
|
||||
self.recipient_emails += [email.addr_spec for email in emails]
|
||||
|
||||
def set_subject(self, subject):
|
||||
self.params['subject'] = subject
|
||||
self.params["subject"] = subject
|
||||
|
||||
def set_reply_to(self, emails):
|
||||
self.params['reply_to'] = emails
|
||||
self.params["reply_to"] = emails
|
||||
|
||||
def set_extra_headers(self, headers):
|
||||
self.params['extra_headers'] = headers
|
||||
self.params["extra_headers"] = headers
|
||||
|
||||
def set_text_body(self, body):
|
||||
self.params['text_body'] = body
|
||||
self.params["text_body"] = body
|
||||
|
||||
def set_html_body(self, body):
|
||||
self.params['html_body'] = body
|
||||
self.params["html_body"] = body
|
||||
|
||||
def add_alternative(self, content, mimetype):
|
||||
# For testing purposes, we allow all "text/*" alternatives,
|
||||
# but not any other mimetypes.
|
||||
if mimetype.startswith('text'):
|
||||
self.params.setdefault('alternatives', []).append((content, mimetype))
|
||||
if mimetype.startswith("text"):
|
||||
self.params.setdefault("alternatives", []).append((content, mimetype))
|
||||
else:
|
||||
self.unsupported_feature("alternative part with type '%s'" % mimetype)
|
||||
|
||||
def add_attachment(self, attachment):
|
||||
self.params.setdefault('attachments', []).append(attachment)
|
||||
self.params.setdefault("attachments", []).append(attachment)
|
||||
|
||||
def set_metadata(self, metadata):
|
||||
self.params['metadata'] = metadata
|
||||
self.params["metadata"] = metadata
|
||||
|
||||
def set_send_at(self, send_at):
|
||||
self.params['send_at'] = send_at
|
||||
self.params["send_at"] = send_at
|
||||
|
||||
def set_tags(self, tags):
|
||||
self.params['tags'] = tags
|
||||
self.params["tags"] = tags
|
||||
|
||||
def set_track_clicks(self, track_clicks):
|
||||
self.params['track_clicks'] = track_clicks
|
||||
self.params["track_clicks"] = track_clicks
|
||||
|
||||
def set_track_opens(self, track_opens):
|
||||
self.params['track_opens'] = track_opens
|
||||
self.params["track_opens"] = track_opens
|
||||
|
||||
def set_template_id(self, template_id):
|
||||
self.params['template_id'] = template_id
|
||||
self.params["template_id"] = template_id
|
||||
|
||||
def set_merge_data(self, merge_data):
|
||||
self.params['merge_data'] = merge_data
|
||||
self.params["merge_data"] = merge_data
|
||||
|
||||
def set_merge_metadata(self, merge_metadata):
|
||||
self.params['merge_metadata'] = merge_metadata
|
||||
self.params["merge_metadata"] = merge_metadata
|
||||
|
||||
def set_merge_global_data(self, merge_global_data):
|
||||
self.params['merge_global_data'] = merge_global_data
|
||||
self.params["merge_global_data"] = merge_global_data
|
||||
|
||||
def set_esp_extra(self, extra):
|
||||
# Merge extra into params
|
||||
|
||||
Reference in New Issue
Block a user