mirror of
https://github.com/pacnpal/django-anymail.git
synced 2025-12-20 03:41:05 -05:00
Move all the payload construction into Payload classes
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
import json
|
||||
|
||||
import requests
|
||||
# noinspection PyUnresolvedReferences
|
||||
from six.moves.urllib.parse import urljoin
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.mail.backends.base import BaseEmailBackend
|
||||
@@ -110,150 +112,20 @@ class AnymailBaseBackend(BaseEmailBackend):
|
||||
return True
|
||||
|
||||
def build_message_payload(self, message):
|
||||
"""Return a payload with all message-specific options for ESP send call.
|
||||
"""Returns a payload that will allow message to be sent via the ESP.
|
||||
|
||||
message is an EmailMessage, possibly with additional Anymail-specific attrs
|
||||
Derived classes must implement, and should subclass :class:BasePayload
|
||||
to get standard Anymail options.
|
||||
|
||||
Can raise AnymailUnsupportedFeature for unsupported options in message.
|
||||
Raises :exc:AnymailUnsupportedFeature for message options that
|
||||
cannot be communicated to the ESP.
|
||||
|
||||
:param message: :class:EmailMessage
|
||||
:return: :class:BasePayload
|
||||
"""
|
||||
encoding = message.encoding
|
||||
payload = self.get_base_payload(message)
|
||||
|
||||
# Standard EmailMessage features:
|
||||
self.set_payload_from_email(payload, ParsedEmail(message.from_email, encoding), message)
|
||||
for recipient_type in ["to", "cc", "bcc"]:
|
||||
recipients = getattr(message, recipient_type, [])
|
||||
if recipients:
|
||||
emails = [ParsedEmail(address, encoding) for address in recipients]
|
||||
self.add_payload_recipients(payload, recipient_type, emails, message)
|
||||
self.set_payload_subject(payload, message.subject, message)
|
||||
|
||||
if hasattr(message, "reply_to"):
|
||||
emails = [ParsedEmail(address, encoding) for address in message.reply_to]
|
||||
self.set_payload_reply_to(payload, emails, message)
|
||||
if hasattr(message, "extra_headers"):
|
||||
self.add_payload_headers(payload, message.extra_headers, message)
|
||||
|
||||
if message.content_subtype == "html":
|
||||
self.set_payload_html_body(payload, message.body, message)
|
||||
else:
|
||||
self.set_payload_text_body(payload, message.body, message)
|
||||
|
||||
if hasattr(message, "alternatives"):
|
||||
for (content, mimetype) in message.alternatives:
|
||||
self.add_payload_alternative(payload, content, mimetype, message)
|
||||
|
||||
str_encoding = encoding or settings.DEFAULT_CHARSET
|
||||
for attachment in message.attachments:
|
||||
self.add_payload_attachment(payload, Attachment(attachment, str_encoding), message)
|
||||
|
||||
# Anymail additions:
|
||||
metadata = self.get_anymail_merged_attr(message, "metadata") # merged: changes semantics from Djrill!
|
||||
if metadata is not UNSET:
|
||||
self.set_payload_metadata(payload, metadata, message)
|
||||
send_at = self.get_anymail_attr(message, "send_at")
|
||||
if send_at is not UNSET:
|
||||
self.set_payload_send_at(payload, send_at, message)
|
||||
tags = self.get_anymail_merged_attr(message, "tags") # merged: changes semantics from Djrill!
|
||||
if tags is not UNSET:
|
||||
self.set_payload_tags(payload, tags, message)
|
||||
track_clicks = self.get_anymail_attr(message, "track_clicks")
|
||||
if track_clicks is not UNSET:
|
||||
self.set_payload_track_clicks(payload, track_clicks, message)
|
||||
track_opens = self.get_anymail_attr(message, "track_opens")
|
||||
if track_opens is not UNSET:
|
||||
self.set_payload_track_opens(payload, track_opens, message)
|
||||
|
||||
# ESP-specific fallback:
|
||||
self.add_payload_esp_options(payload, message)
|
||||
|
||||
return payload
|
||||
|
||||
def get_anymail_attr(self, message, attr):
|
||||
default_value = self.send_defaults.get(attr, UNSET)
|
||||
message_value = getattr(message, attr, UNSET)
|
||||
return last(default_value, message_value)
|
||||
|
||||
def get_anymail_merged_attr(self, message, attr):
|
||||
default_value = self.send_defaults.get(attr, UNSET)
|
||||
message_value = getattr(message, attr, UNSET)
|
||||
return combine(default_value, message_value)
|
||||
|
||||
def unsupported_feature(self, feature):
|
||||
# future: check settings.ANYMAIL_UNSUPPORTED_FEATURE_ERRORS
|
||||
raise AnymailUnsupportedFeature("%s does not support %s" % (self.esp_name, feature))
|
||||
|
||||
#
|
||||
# Payload construction
|
||||
#
|
||||
|
||||
def get_base_payload(self, message):
|
||||
raise NotImplementedError("%s.%s must implement init_base_payload" %
|
||||
raise NotImplementedError("%s.%s must implement build_message_payload" %
|
||||
(self.__class__.__module__, self.__class__.__name__))
|
||||
|
||||
def set_payload_from_email(self, payload, email, message):
|
||||
raise NotImplementedError("%s.%s must implement set_payload_from" %
|
||||
(self.__class__.__module__, self.__class__.__name__))
|
||||
|
||||
def add_payload_recipients(self, payload, recipient_type, emails, message):
|
||||
for email in emails:
|
||||
self.add_payload_recipient(payload, recipient_type, email, message)
|
||||
|
||||
def add_payload_recipient(self, payload, recipient_type, email, message):
|
||||
raise NotImplementedError("%s.%s must implement add_payload_recipient" %
|
||||
(self.__class__.__module__, self.__class__.__name__))
|
||||
|
||||
def set_payload_subject(self, payload, subject, message):
|
||||
raise NotImplementedError("%s.%s must implement set_payload_subject" %
|
||||
(self.__class__.__module__, self.__class__.__name__))
|
||||
|
||||
def set_payload_reply_to(self, payload, emails, message):
|
||||
raise NotImplementedError("%s.%s must implement set_payload_reply_to" %
|
||||
(self.__class__.__module__, self.__class__.__name__))
|
||||
|
||||
def add_payload_headers(self, payload, headers, message):
|
||||
raise NotImplementedError("%s.%s must implement add_payload_heeaders" %
|
||||
(self.__class__.__module__, self.__class__.__name__))
|
||||
|
||||
def set_payload_text_body(self, payload, body, message):
|
||||
raise NotImplementedError("%s.%s must implement set_payload_text_body" %
|
||||
(self.__class__.__module__, self.__class__.__name__))
|
||||
|
||||
def set_payload_html_body(self, payload, body, message):
|
||||
raise NotImplementedError("%s.%s must implement set_payload_html_body" %
|
||||
(self.__class__.__module__, self.__class__.__name__))
|
||||
|
||||
def add_payload_alternative(self, payload, content, mimetype, message):
|
||||
raise NotImplementedError("%s.%s must implement add_payload_alternative" %
|
||||
(self.__class__.__module__, self.__class__.__name__))
|
||||
|
||||
def add_payload_attachment(self, payload, attachment, message):
|
||||
raise NotImplementedError("%s.%s must implement add_payload_attachment" %
|
||||
(self.__class__.__module__, self.__class__.__name__))
|
||||
|
||||
# Anymail-specific payload construction
|
||||
def set_payload_metadata(self, payload, metadata, message):
|
||||
self.unsupported_feature("metadata")
|
||||
|
||||
def set_payload_send_at(self, payload, send_at, message):
|
||||
self.unsupported_feature("send_at")
|
||||
|
||||
def set_payload_tags(self, payload, tags, message):
|
||||
self.unsupported_feature("tags")
|
||||
|
||||
def set_payload_track_clicks(self, payload, track_clicks, message):
|
||||
self.unsupported_feature("track_clicks")
|
||||
|
||||
def set_payload_track_opens(self, payload, track_opens, message):
|
||||
self.unsupported_feature("track_opens")
|
||||
|
||||
# ESP-specific payload construction
|
||||
def add_payload_esp_options(self, payload, message):
|
||||
raise NotImplementedError("%s.%s must implement add_payload_esp_options" %
|
||||
(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.
|
||||
|
||||
@@ -343,24 +215,6 @@ class AnymailRequestsBackend(AnymailBaseBackend):
|
||||
"or you are incorrectly calling _send directly.)".format(class_name=class_name))
|
||||
return super(AnymailRequestsBackend, self)._send(message)
|
||||
|
||||
def get_api_url(self, payload, message):
|
||||
"""Return the correct ESP url for sending payload
|
||||
|
||||
Override this to substitute your own logic for determining API endpoint.
|
||||
"""
|
||||
return self.api_url
|
||||
|
||||
def serialize_payload(self, payload, message):
|
||||
"""Return payload serialized to post data.
|
||||
|
||||
Should raise AnymailSerializationError if payload is not serializable
|
||||
"""
|
||||
try:
|
||||
return json.dumps(payload)
|
||||
except TypeError as err:
|
||||
# Add some context to the "not JSON serializable" message
|
||||
raise AnymailSerializationError(orig_err=err, email_message=message, payload=payload)
|
||||
|
||||
def post_to_esp(self, payload, message):
|
||||
"""Post payload to ESP send API endpoint, and return the raw response.
|
||||
|
||||
@@ -370,10 +224,8 @@ class AnymailRequestsBackend(AnymailBaseBackend):
|
||||
|
||||
Can raise AnymailRequestsAPIError for HTTP errors in the post
|
||||
"""
|
||||
api_url = self.get_api_url(payload, message)
|
||||
post_data = self.serialize_payload(payload, message)
|
||||
|
||||
response = self.session.post(api_url, data=post_data)
|
||||
params = payload.get_request_params(self.api_url)
|
||||
response = self.session.request(**params)
|
||||
if response.status_code != 200:
|
||||
raise AnymailRequestsAPIError(email_message=message, payload=payload, response=response)
|
||||
return response
|
||||
@@ -388,3 +240,219 @@ class AnymailRequestsBackend(AnymailBaseBackend):
|
||||
except ValueError:
|
||||
raise AnymailRequestsAPIError("Invalid JSON in %s API response" % self.esp_name,
|
||||
email_message=message, payload=payload, response=response)
|
||||
|
||||
|
||||
class BasePayload(object):
|
||||
# attr, combiner, converter
|
||||
base_message_attrs = (
|
||||
# Standard EmailMessage/EmailMultiAlternatives props
|
||||
('from_email', last, 'parsed_email'),
|
||||
('to', combine, 'parsed_emails'),
|
||||
('cc', combine, 'parsed_emails'),
|
||||
('bcc', combine, 'parsed_emails'),
|
||||
('subject', last, None),
|
||||
('reply_to', combine, 'parsed_emails'),
|
||||
('extra_headers', combine, None),
|
||||
('body', last, None), # special handling below checks message.content_subtype
|
||||
('alternatives', combine, None),
|
||||
('attachments', combine, 'prepped_attachments'),
|
||||
)
|
||||
anymail_message_attrs = (
|
||||
# Anymail expando-props
|
||||
('metadata', combine, None),
|
||||
('send_at', last, None), # normalize to datetime?
|
||||
('tags', combine, None),
|
||||
('track_clicks', last, None),
|
||||
('track_opens', last, None),
|
||||
('esp_extra', combine, None),
|
||||
)
|
||||
esp_message_attrs = () # subclasses can override
|
||||
|
||||
def __init__(self, message, defaults, backend):
|
||||
self.message = message
|
||||
self.defaults = defaults
|
||||
self.backend = backend
|
||||
self.esp_name = backend.esp_name
|
||||
|
||||
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
|
||||
for attr, combiner, converter in message_attrs:
|
||||
value = getattr(message, attr, UNSET)
|
||||
if combiner is not None:
|
||||
default_value = self.defaults.get(attr, UNSET)
|
||||
value = combiner(default_value, value)
|
||||
if value is not UNSET:
|
||||
if converter is not None:
|
||||
if not callable(converter):
|
||||
converter = getattr(self, converter)
|
||||
value = converter(value)
|
||||
if attr == 'body':
|
||||
setter = self.set_html_body if message.content_subtype == 'html' else self.set_text_body
|
||||
else:
|
||||
# AttributeError here? Your Payload subclass is missing a set_<attr> implementation
|
||||
setter = getattr(self, 'set_%s' % attr)
|
||||
setter(value)
|
||||
|
||||
def unsupported_feature(self, feature):
|
||||
# future: check settings.ANYMAIL_UNSUPPORTED_FEATURE_ERRORS
|
||||
raise AnymailUnsupportedFeature("%s does not support %s" % (self.esp_name, feature),
|
||||
email_message=self.message)
|
||||
|
||||
#
|
||||
# Attribute converters
|
||||
#
|
||||
|
||||
def parsed_email(self, address):
|
||||
return ParsedEmail(address, self.message.encoding)
|
||||
|
||||
def parsed_emails(self, addresses):
|
||||
encoding = self.message.encoding
|
||||
return [ParsedEmail(address, encoding) for address in addresses]
|
||||
|
||||
def prepped_attachments(self, attachments):
|
||||
str_encoding = self.message.encoding or settings.DEFAULT_CHARSET
|
||||
return [Attachment(attachment, str_encoding) for attachment in attachments]
|
||||
|
||||
#
|
||||
# Abstract implementation
|
||||
#
|
||||
|
||||
def init_payload(self):
|
||||
raise NotImplementedError("%s.%s must implement init_payload" %
|
||||
(self.__class__.__module__, self.__class__.__name__))
|
||||
|
||||
def set_from_email(self, email):
|
||||
raise NotImplementedError("%s.%s must implement set_from_email" %
|
||||
(self.__class__.__module__, self.__class__.__name__))
|
||||
|
||||
def set_to(self, emails):
|
||||
return self.set_recipients('to', emails)
|
||||
|
||||
def set_cc(self, emails):
|
||||
return self.set_recipients('cc', emails)
|
||||
|
||||
def set_bcc(self, 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__))
|
||||
|
||||
def set_subject(self, subject):
|
||||
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')
|
||||
|
||||
def set_extra_headers(self, headers):
|
||||
self.unsupported_feature('extra_headers')
|
||||
|
||||
def set_text_body(self, body):
|
||||
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__))
|
||||
|
||||
def set_alternatives(self, alternatives):
|
||||
for content, mimetype in alternatives:
|
||||
self.add_alternative(content, mimetype)
|
||||
|
||||
def add_alternative(self, content, mimetype):
|
||||
raise NotImplementedError("%s.%s must implement add_alternative or set_alternatives" %
|
||||
(self.__class__.__module__, self.__class__.__name__))
|
||||
|
||||
def set_attachments(self, attachments):
|
||||
for attachment in attachments:
|
||||
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__))
|
||||
|
||||
# Anymail-specific payload construction
|
||||
def set_metadata(self, metadata):
|
||||
self.unsupported_feature("metadata")
|
||||
|
||||
def set_send_at(self, send_at):
|
||||
self.unsupported_feature("send_at")
|
||||
|
||||
def set_tags(self, tags):
|
||||
self.unsupported_feature("tags")
|
||||
|
||||
def set_track_clicks(self, track_clicks):
|
||||
self.unsupported_feature("track_clicks")
|
||||
|
||||
def set_track_opens(self, track_opens):
|
||||
self.unsupported_feature("track_opens")
|
||||
|
||||
# ESP-specific payload construction
|
||||
def set_esp_extra(self, extra):
|
||||
self.unsupported_feature("esp_extra")
|
||||
|
||||
|
||||
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):
|
||||
self.method = method
|
||||
self.params = params
|
||||
self.data = data
|
||||
self.headers = headers
|
||||
self.files = files
|
||||
self.auth = auth
|
||||
super(RequestsPayload, self).__init__(message, defaults, backend)
|
||||
|
||||
def get_request_params(self, api_url):
|
||||
"""Returns a dict of requests.request params that will send payload to the ESP.
|
||||
|
||||
:param api_url: the base api_url for the backend
|
||||
:return: dict
|
||||
"""
|
||||
api_endpoint = self.get_api_endpoint()
|
||||
if api_endpoint is not None:
|
||||
url = urljoin(api_url, api_endpoint)
|
||||
else:
|
||||
url = api_url
|
||||
|
||||
return dict(
|
||||
method=self.method,
|
||||
url=url,
|
||||
params=self.params,
|
||||
data=self.serialize_data(),
|
||||
headers=self.headers,
|
||||
files=self.files,
|
||||
auth=self.auth,
|
||||
# json= is not here, because we prefer to do our own serialization
|
||||
# to provide extra context in error messages
|
||||
)
|
||||
|
||||
def get_api_endpoint(self):
|
||||
"""Returns a str that should be joined to the backend's api_url for sending this payload."""
|
||||
return None
|
||||
|
||||
def serialize_data(self):
|
||||
"""Performs any necessary serialization on self.data, and returns the result."""
|
||||
return self.data
|
||||
|
||||
def serialize_json(self, data):
|
||||
"""Returns data serialized to json, raising appropriate errors.
|
||||
|
||||
Useful for implementing serialize_data in a subclass,
|
||||
"""
|
||||
try:
|
||||
return json.dumps(data)
|
||||
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)
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
from datetime import date, datetime
|
||||
try:
|
||||
from urlparse import urljoin # python 2
|
||||
except ImportError:
|
||||
from urllib.parse import urljoin # python 3
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from ..exceptions import (AnymailImproperlyConfigured, AnymailRequestsAPIError,
|
||||
AnymailRecipientsRefused, AnymailUnsupportedFeature)
|
||||
from ..utils import last, combine
|
||||
|
||||
from .base import AnymailRequestsBackend
|
||||
from .base import AnymailRequestsBackend, RequestsPayload
|
||||
|
||||
|
||||
class MandrillBackend(AnymailRequestsBackend):
|
||||
@@ -44,20 +41,10 @@ class MandrillBackend(AnymailRequestsBackend):
|
||||
except AttributeError:
|
||||
pass # no MANDRILL_SUBACCOUNT setting
|
||||
|
||||
self.global_settings = self.send_defaults
|
||||
self.ignore_recipient_status = getattr(settings, "MANDRILL_IGNORE_RECIPIENT_STATUS", False)
|
||||
self.session = None
|
||||
|
||||
def get_api_url(self, payload, message):
|
||||
"""Return the correct Mandrill API url for sending payload
|
||||
|
||||
Override this to substitute your own logic for determining API endpoint.
|
||||
"""
|
||||
if 'template_name' in payload:
|
||||
api_method = "messages/send-template.json"
|
||||
else:
|
||||
api_method = "messages/send.json"
|
||||
return urljoin(self.api_url, api_method)
|
||||
def build_message_payload(self, message):
|
||||
return MandrillPayload(message, self.send_defaults, self)
|
||||
|
||||
def validate_response(self, parsed_response, response, payload, message):
|
||||
"""Validate parsed_response, raising exceptions for any problems.
|
||||
@@ -82,154 +69,8 @@ class MandrillBackend(AnymailRequestsBackend):
|
||||
else:
|
||||
return "multi"
|
||||
|
||||
#
|
||||
# Payload construction
|
||||
#
|
||||
|
||||
def get_base_payload(self, message):
|
||||
return {
|
||||
"key": self.api_key,
|
||||
"message": {},
|
||||
}
|
||||
|
||||
def set_payload_from_email(self, payload, email, message):
|
||||
if not getattr(message, "use_template_from", False): # Djrill compat!
|
||||
payload["message"]["from_email"] = email.email
|
||||
if email.name:
|
||||
payload["message"]["from_name"] = email.name
|
||||
|
||||
def add_payload_recipient(self, payload, recipient_type, email, message):
|
||||
assert recipient_type in ["to", "cc", "bcc"]
|
||||
to_list = payload["message"].setdefault("to", [])
|
||||
to_list.append({"email": email.email, "name": email.name, "type": recipient_type})
|
||||
|
||||
def set_payload_subject(self, payload, subject, message):
|
||||
if not getattr(message, "use_template_subject", False): # Djrill compat!
|
||||
payload["message"]["subject"] = subject
|
||||
|
||||
def set_payload_reply_to(self, payload, emails, message):
|
||||
reply_to = ", ".join([email.address for email in emails])
|
||||
payload["message"].setdefault("headers", {})["Reply-To"] = reply_to
|
||||
|
||||
def add_payload_headers(self, payload, headers, message):
|
||||
payload["message"].setdefault("headers", {}).update(headers)
|
||||
|
||||
def set_payload_text_body(self, payload, body, message):
|
||||
payload["message"]["text"] = body
|
||||
|
||||
def set_payload_html_body(self, payload, body, message):
|
||||
payload["message"]["html"] = body
|
||||
|
||||
def add_payload_alternative(self, payload, content, mimetype, message):
|
||||
if mimetype != 'text/html':
|
||||
raise AnymailUnsupportedFeature(
|
||||
"Invalid alternative mimetype '%s'. "
|
||||
"Mandrill only accepts plain text and html emails."
|
||||
% mimetype,
|
||||
email_message=message)
|
||||
|
||||
if "html" in payload["message"]:
|
||||
raise AnymailUnsupportedFeature(
|
||||
"Too many alternatives attached to the message. "
|
||||
"Mandrill only accepts plain text and html emails.",
|
||||
email_message=message)
|
||||
|
||||
payload["message"]["html"] = content
|
||||
|
||||
def add_payload_attachment(self, payload, attachment, message):
|
||||
key = "images" if attachment.inline else "attachments"
|
||||
payload["message"].setdefault(key, []).append({
|
||||
"type": attachment.mimetype,
|
||||
"name": attachment.name or "",
|
||||
"content": attachment.b64content
|
||||
})
|
||||
|
||||
def set_payload_metadata(self, payload, metadata, message):
|
||||
payload["message"]["metadata"] = metadata
|
||||
|
||||
def set_payload_send_at(self, payload, send_at, message):
|
||||
payload["send_at"] = self.encode_date_for_mandrill(send_at)
|
||||
|
||||
def set_payload_tags(self, payload, tags, message):
|
||||
payload["message"]["tags"] = tags
|
||||
|
||||
def set_payload_track_clicks(self, payload, track_clicks, message):
|
||||
payload["message"]["track_clicks"] = track_clicks
|
||||
|
||||
def set_payload_track_opens(self, payload, track_opens, message):
|
||||
payload["message"]["track_opens"] = track_opens
|
||||
|
||||
def add_payload_esp_options(self, payload, message):
|
||||
self._add_mandrill_options(message, payload["message"])
|
||||
if hasattr(message, 'template_name'):
|
||||
payload['template_name'] = message.template_name
|
||||
payload['template_content'] = \
|
||||
self._expand_merge_vars(getattr(message, 'template_content', {}))
|
||||
self._add_mandrill_toplevel_options(message, payload)
|
||||
|
||||
# unported
|
||||
|
||||
def _add_mandrill_toplevel_options(self, message, api_params):
|
||||
"""Extend api_params to include Mandrill global-send options set on message"""
|
||||
# Mandrill attributes that can be copied directly:
|
||||
mandrill_attrs = [
|
||||
'async', 'ip_pool'
|
||||
]
|
||||
for attr in mandrill_attrs:
|
||||
if attr in self.global_settings:
|
||||
api_params[attr] = self.global_settings[attr]
|
||||
if hasattr(message, attr):
|
||||
api_params[attr] = getattr(message, attr)
|
||||
|
||||
def _add_mandrill_options(self, message, msg_dict):
|
||||
"""Extend msg_dict to include Mandrill per-message options set on message"""
|
||||
# Mandrill attributes that can be copied directly:
|
||||
mandrill_attrs = [
|
||||
'from_name', # overrides display name parsed from from_email above
|
||||
'important',
|
||||
'auto_text', 'auto_html',
|
||||
'inline_css', 'url_strip_qs',
|
||||
'tracking_domain', 'signing_domain', 'return_path_domain',
|
||||
'merge_language',
|
||||
'preserve_recipients', 'view_content_link', 'subaccount',
|
||||
'google_analytics_domains', 'google_analytics_campaign',
|
||||
]
|
||||
|
||||
for attr in mandrill_attrs:
|
||||
if attr in self.global_settings:
|
||||
msg_dict[attr] = self.global_settings[attr]
|
||||
if hasattr(message, attr):
|
||||
msg_dict[attr] = getattr(message, attr)
|
||||
|
||||
# Allow simple python dicts in place of Mandrill
|
||||
# [{name:name, value:value},...] arrays...
|
||||
|
||||
# Merge global and per message global_merge_vars
|
||||
# (in conflicts, per-message vars win)
|
||||
global_merge_vars = {}
|
||||
if 'global_merge_vars' in self.global_settings:
|
||||
global_merge_vars.update(self.global_settings['global_merge_vars'])
|
||||
if hasattr(message, 'global_merge_vars'):
|
||||
global_merge_vars.update(message.global_merge_vars)
|
||||
if global_merge_vars:
|
||||
msg_dict['global_merge_vars'] = \
|
||||
self._expand_merge_vars(global_merge_vars)
|
||||
|
||||
if hasattr(message, 'merge_vars'):
|
||||
# For testing reproducibility, we sort the recipients
|
||||
msg_dict['merge_vars'] = [
|
||||
{ 'rcpt': rcpt,
|
||||
'vars': self._expand_merge_vars(message.merge_vars[rcpt]) }
|
||||
for rcpt in sorted(message.merge_vars.keys())
|
||||
]
|
||||
if hasattr(message, 'recipient_metadata'):
|
||||
# For testing reproducibility, we sort the recipients
|
||||
msg_dict['recipient_metadata'] = [
|
||||
{ 'rcpt': rcpt, 'values': message.recipient_metadata[rcpt] }
|
||||
for rcpt in sorted(message.recipient_metadata.keys())
|
||||
]
|
||||
|
||||
def _expand_merge_vars(self, vardict):
|
||||
def _expand_merge_vars(vardict):
|
||||
"""Convert a Python dict to an array of name-content used by Mandrill.
|
||||
|
||||
{ name: value, ... } --> [ {'name': name, 'content': value }, ... ]
|
||||
@@ -238,8 +79,8 @@ class MandrillBackend(AnymailRequestsBackend):
|
||||
return [{'name': name, 'content': vardict[name]}
|
||||
for name in sorted(vardict.keys())]
|
||||
|
||||
@classmethod
|
||||
def encode_date_for_mandrill(cls, dt):
|
||||
|
||||
def encode_date_for_mandrill(dt):
|
||||
"""Format a date or datetime for use as a Mandrill API date field
|
||||
|
||||
datetime becomes "YYYY-MM-DD HH:MM:SS"
|
||||
@@ -257,3 +98,172 @@ class MandrillBackend(AnymailRequestsBackend):
|
||||
return dt.isoformat() + ' 00:00:00'
|
||||
else:
|
||||
return dt
|
||||
|
||||
|
||||
class MandrillPayload(RequestsPayload):
|
||||
|
||||
def get_api_endpoint(self):
|
||||
if 'template_name' in self.data:
|
||||
return "messages/send-template.json"
|
||||
else:
|
||||
return "messages/send.json"
|
||||
|
||||
def serialize_data(self):
|
||||
return self.serialize_json(self.data)
|
||||
|
||||
#
|
||||
# Payload construction
|
||||
#
|
||||
|
||||
def init_payload(self):
|
||||
self.data = {
|
||||
"key": self.backend.api_key,
|
||||
"message": {},
|
||||
}
|
||||
|
||||
def set_from_email(self, email):
|
||||
if not getattr(self.message, "use_template_from", False): # Djrill compat!
|
||||
self.data["message"]["from_email"] = email.email
|
||||
if email.name:
|
||||
self.data["message"]["from_name"] = email.name
|
||||
|
||||
def add_recipient(self, recipient_type, email):
|
||||
assert recipient_type in ["to", "cc", "bcc"]
|
||||
to_list = self.data["message"].setdefault("to", [])
|
||||
to_list.append({"email": email.email, "name": email.name, "type": recipient_type})
|
||||
|
||||
def set_subject(self, subject):
|
||||
if not getattr(self.message, "use_template_subject", False): # Djrill compat!
|
||||
self.data["message"]["subject"] = subject
|
||||
|
||||
def set_reply_to(self, emails):
|
||||
reply_to = ", ".join([str(email) for email in emails])
|
||||
self.data["message"].setdefault("headers", {})["Reply-To"] = reply_to
|
||||
|
||||
def set_extra_headers(self, headers):
|
||||
self.data["message"].setdefault("headers", {}).update(headers)
|
||||
|
||||
def set_text_body(self, body):
|
||||
self.data["message"]["text"] = body
|
||||
|
||||
def set_html_body(self, body):
|
||||
self.data["message"]["html"] = body
|
||||
|
||||
def add_alternative(self, content, mimetype):
|
||||
if mimetype != 'text/html':
|
||||
raise AnymailUnsupportedFeature(
|
||||
"Invalid alternative mimetype '%s'. "
|
||||
"Mandrill only accepts plain text and html emails."
|
||||
% mimetype,
|
||||
email_message=self.message)
|
||||
|
||||
if "html" in self.data["message"]:
|
||||
raise AnymailUnsupportedFeature(
|
||||
"Too many alternatives attached to the message. "
|
||||
"Mandrill only accepts plain text and html emails.",
|
||||
email_message=self.message)
|
||||
|
||||
self.data["message"]["html"] = content
|
||||
|
||||
def add_attachment(self, attachment):
|
||||
key = "images" if attachment.inline else "attachments"
|
||||
self.data["message"].setdefault(key, []).append({
|
||||
"type": attachment.mimetype,
|
||||
"name": attachment.name or "",
|
||||
"content": attachment.b64content
|
||||
})
|
||||
|
||||
def set_metadata(self, metadata):
|
||||
self.data["message"]["metadata"] = metadata
|
||||
|
||||
def set_send_at(self, send_at):
|
||||
self.data["send_at"] = encode_date_for_mandrill(send_at)
|
||||
|
||||
def set_tags(self, tags):
|
||||
self.data["message"]["tags"] = tags
|
||||
|
||||
def set_track_clicks(self, track_clicks):
|
||||
self.data["message"]["track_clicks"] = track_clicks
|
||||
|
||||
def set_track_opens(self, track_opens):
|
||||
self.data["message"]["track_opens"] = track_opens
|
||||
|
||||
def set_esp_extra(self, extra):
|
||||
pass
|
||||
|
||||
# Djrill leftovers
|
||||
|
||||
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, _expand_merge_vars),
|
||||
('merge_vars', combine, None),
|
||||
('recipient_metadata', combine, None),
|
||||
('template_name', last, None),
|
||||
('template_content', combine, _expand_merge_vars),
|
||||
)
|
||||
|
||||
def set_async(self, async):
|
||||
self.data["async"] = async
|
||||
|
||||
def set_ip_pool(self, ip_pool):
|
||||
self.data["ip_pool"] = ip_pool
|
||||
|
||||
def set_template_name(self, template_name):
|
||||
self.data["template_name"] = template_name
|
||||
self.data.setdefault("template_content", []) # Mandrill requires something here
|
||||
|
||||
def set_template_content(self, template_content):
|
||||
self.data["template_content"] = template_content
|
||||
|
||||
def set_merge_vars(self, merge_vars):
|
||||
# For testing reproducibility, we sort the recipients
|
||||
self.data['message']['merge_vars'] = [
|
||||
{'rcpt': rcpt, 'vars': _expand_merge_vars(merge_vars[rcpt])}
|
||||
for rcpt in sorted(merge_vars.keys())
|
||||
]
|
||||
|
||||
def set_recipient_metadata(self, recipient_metadata):
|
||||
# For testing reproducibility, we sort the recipients
|
||||
self.data['message']['recipient_metadata'] = [
|
||||
{'rcpt': rcpt, 'values': recipient_metadata[rcpt]}
|
||||
for rcpt in sorted(recipient_metadata.keys())
|
||||
]
|
||||
|
||||
# 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
|
||||
try:
|
||||
getattr(cls, setter_name)
|
||||
except AttributeError:
|
||||
setter = cls.make_setter(attr, setter_name)
|
||||
setattr(cls, setter_name, setter)
|
||||
|
||||
@staticmethod
|
||||
def make_setter(attr, setter_name):
|
||||
# sure wish we could use functools.partial to create instance methods (descriptors)
|
||||
def setter(self, value):
|
||||
self.data["message"][attr] = value
|
||||
setter.__name__ = setter_name
|
||||
return setter
|
||||
|
||||
MandrillPayload.define_message_attr_setters()
|
||||
|
||||
@@ -18,6 +18,7 @@ class AnymailError(Exception):
|
||||
payload: data arg (*not* json-stringified) for the ESP send call
|
||||
response: requests.Response from the send call
|
||||
"""
|
||||
self.backend = kwargs.pop('backend', None)
|
||||
self.email_message = kwargs.pop('email_message', None)
|
||||
self.payload = kwargs.pop('payload', None)
|
||||
self.status_code = kwargs.pop('status_code', None)
|
||||
@@ -37,18 +38,17 @@ class AnymailError(Exception):
|
||||
return "\n".join(filter(None, parts))
|
||||
|
||||
def describe_send(self):
|
||||
"""Return a string describing the ESP send in self.payload, or None"""
|
||||
if self.payload is None:
|
||||
"""Return a string describing the ESP send in self.email_message, or None"""
|
||||
if self.email_message is None:
|
||||
return None
|
||||
description = "Sending a message"
|
||||
try:
|
||||
to_emails = [to['email'] for to in self.payload['message']['to']]
|
||||
description += " to %s" % ','.join(to_emails)
|
||||
except KeyError:
|
||||
description += " to %s" % ','.join(self.email_message.to)
|
||||
except AttributeError:
|
||||
pass
|
||||
try:
|
||||
description += " from %s" % self.payload['message']['from_email']
|
||||
except KeyError:
|
||||
description += " from %s" % self.email_message.from_email
|
||||
except AttributeError:
|
||||
pass
|
||||
return description
|
||||
|
||||
@@ -120,8 +120,9 @@ class AnymailSerializationError(AnymailError, TypeError):
|
||||
|
||||
def __init__(self, message=None, orig_err=None, *args, **kwargs):
|
||||
if message is None:
|
||||
message = "Don't know how to send this data to your ESP. " \
|
||||
"Try converting it to a string or number first."
|
||||
esp_name = kwargs["backend"].esp_name if "backend" in kwargs else "the ESP"
|
||||
message = "Don't know how to send this data to %s. " \
|
||||
"Try converting it to a string or number first." % esp_name
|
||||
if orig_err is not None:
|
||||
message += "\n%s" % str(orig_err)
|
||||
super(AnymailSerializationError, self).__init__(message, *args, **kwargs)
|
||||
|
||||
@@ -29,7 +29,7 @@ class DjrillBackendMockAPITestCase(TestCase):
|
||||
self.raw = six.BytesIO(raw)
|
||||
|
||||
def setUp(self):
|
||||
self.patch = patch('requests.Session.post', autospec=True)
|
||||
self.patch = patch('requests.Session.request', autospec=True)
|
||||
self.mock_post = self.patch.start()
|
||||
self.mock_post.return_value = self.MockResponse()
|
||||
|
||||
|
||||
@@ -559,7 +559,7 @@ class DjrillMandrillFeatureTests(DjrillBackendMockAPITestCase):
|
||||
self.message.send()
|
||||
err = cm.exception
|
||||
self.assertTrue(isinstance(err, TypeError)) # Djrill 1.x re-raised TypeError from json.dumps
|
||||
self.assertStrContains(str(err), "Don't know how to send this data to your ESP") # our added context
|
||||
self.assertStrContains(str(err), "Don't know how to send this data to Mandrill") # our added context
|
||||
self.assertStrContains(str(err), "Decimal('19.99') is not JSON serializable") # original message
|
||||
|
||||
def test_dates_not_serialized(self):
|
||||
|
||||
Reference in New Issue
Block a user