Move all the payload construction into Payload classes

This commit is contained in:
medmunds
2016-03-04 15:55:19 -08:00
parent dbf57d8a33
commit 3b414a9619
5 changed files with 395 additions and 316 deletions

View File

@@ -1,6 +1,8 @@
import json import json
import requests import requests
# noinspection PyUnresolvedReferences
from six.moves.urllib.parse import urljoin
from django.conf import settings from django.conf import settings
from django.core.mail.backends.base import BaseEmailBackend from django.core.mail.backends.base import BaseEmailBackend
@@ -110,150 +112,20 @@ class AnymailBaseBackend(BaseEmailBackend):
return True return True
def build_message_payload(self, message): 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 raise NotImplementedError("%s.%s must implement build_message_payload" %
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" %
(self.__class__.__module__, self.__class__.__name__)) (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): def post_to_esp(self, payload, message):
"""Post payload to ESP send API endpoint, and return the raw response. """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)) "or you are incorrectly calling _send directly.)".format(class_name=class_name))
return super(AnymailRequestsBackend, self)._send(message) 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): def post_to_esp(self, payload, message):
"""Post payload to ESP send API endpoint, and return the raw response. """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 Can raise AnymailRequestsAPIError for HTTP errors in the post
""" """
api_url = self.get_api_url(payload, message) params = payload.get_request_params(self.api_url)
post_data = self.serialize_payload(payload, message) response = self.session.request(**params)
response = self.session.post(api_url, data=post_data)
if response.status_code != 200: if response.status_code != 200:
raise AnymailRequestsAPIError(email_message=message, payload=payload, response=response) raise AnymailRequestsAPIError(email_message=message, payload=payload, response=response)
return response return response
@@ -388,3 +240,219 @@ class AnymailRequestsBackend(AnymailBaseBackend):
except ValueError: except ValueError:
raise AnymailRequestsAPIError("Invalid JSON in %s API response" % self.esp_name, raise AnymailRequestsAPIError("Invalid JSON in %s API response" % self.esp_name,
email_message=message, payload=payload, response=response) 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)

View File

@@ -1,15 +1,12 @@
from datetime import date, datetime 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 django.conf import settings
from ..exceptions import (AnymailImproperlyConfigured, AnymailRequestsAPIError, from ..exceptions import (AnymailImproperlyConfigured, AnymailRequestsAPIError,
AnymailRecipientsRefused, AnymailUnsupportedFeature) AnymailRecipientsRefused, AnymailUnsupportedFeature)
from ..utils import last, combine
from .base import AnymailRequestsBackend from .base import AnymailRequestsBackend, RequestsPayload
class MandrillBackend(AnymailRequestsBackend): class MandrillBackend(AnymailRequestsBackend):
@@ -44,20 +41,10 @@ class MandrillBackend(AnymailRequestsBackend):
except AttributeError: except AttributeError:
pass # no MANDRILL_SUBACCOUNT setting pass # no MANDRILL_SUBACCOUNT setting
self.global_settings = self.send_defaults
self.ignore_recipient_status = getattr(settings, "MANDRILL_IGNORE_RECIPIENT_STATUS", False) self.ignore_recipient_status = getattr(settings, "MANDRILL_IGNORE_RECIPIENT_STATUS", False)
self.session = None
def get_api_url(self, payload, message): def build_message_payload(self, message):
"""Return the correct Mandrill API url for sending payload return MandrillPayload(message, self.send_defaults, self)
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 validate_response(self, parsed_response, response, payload, message): def validate_response(self, parsed_response, response, payload, message):
"""Validate parsed_response, raising exceptions for any problems. """Validate parsed_response, raising exceptions for any problems.
@@ -82,178 +69,201 @@ class MandrillBackend(AnymailRequestsBackend):
else: else:
return "multi" return "multi"
def _expand_merge_vars(vardict):
"""Convert a Python dict to an array of name-content used by Mandrill.
{ name: value, ... } --> [ {'name': name, 'content': value }, ... ]
"""
# For testing reproducibility, we sort the keys
return [{'name': name, 'content': vardict[name]}
for name in sorted(vardict.keys())]
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"
converted to UTC, if timezone-aware
microseconds removed
date becomes "YYYY-MM-DD 00:00:00"
anything else gets returned intact
"""
if isinstance(dt, datetime):
dt = dt.replace(microsecond=0)
if dt.utcoffset() is not None:
dt = (dt - dt.utcoffset()).replace(tzinfo=None)
return dt.isoformat(' ')
elif isinstance(dt, date):
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 # Payload construction
# #
def get_base_payload(self, message): def init_payload(self):
return { self.data = {
"key": self.api_key, "key": self.backend.api_key,
"message": {}, "message": {},
} }
def set_payload_from_email(self, payload, email, message): def set_from_email(self, email):
if not getattr(message, "use_template_from", False): # Djrill compat! if not getattr(self.message, "use_template_from", False): # Djrill compat!
payload["message"]["from_email"] = email.email self.data["message"]["from_email"] = email.email
if email.name: if email.name:
payload["message"]["from_name"] = email.name self.data["message"]["from_name"] = email.name
def add_payload_recipient(self, payload, recipient_type, email, message): def add_recipient(self, recipient_type, email):
assert recipient_type in ["to", "cc", "bcc"] assert recipient_type in ["to", "cc", "bcc"]
to_list = payload["message"].setdefault("to", []) to_list = self.data["message"].setdefault("to", [])
to_list.append({"email": email.email, "name": email.name, "type": recipient_type}) to_list.append({"email": email.email, "name": email.name, "type": recipient_type})
def set_payload_subject(self, payload, subject, message): def set_subject(self, subject):
if not getattr(message, "use_template_subject", False): # Djrill compat! if not getattr(self.message, "use_template_subject", False): # Djrill compat!
payload["message"]["subject"] = subject self.data["message"]["subject"] = subject
def set_payload_reply_to(self, payload, emails, message): def set_reply_to(self, emails):
reply_to = ", ".join([email.address for email in emails]) reply_to = ", ".join([str(email) for email in emails])
payload["message"].setdefault("headers", {})["Reply-To"] = reply_to self.data["message"].setdefault("headers", {})["Reply-To"] = reply_to
def add_payload_headers(self, payload, headers, message): def set_extra_headers(self, headers):
payload["message"].setdefault("headers", {}).update(headers) self.data["message"].setdefault("headers", {}).update(headers)
def set_payload_text_body(self, payload, body, message): def set_text_body(self, body):
payload["message"]["text"] = body self.data["message"]["text"] = body
def set_payload_html_body(self, payload, body, message): def set_html_body(self, body):
payload["message"]["html"] = body self.data["message"]["html"] = body
def add_payload_alternative(self, payload, content, mimetype, message): def add_alternative(self, content, mimetype):
if mimetype != 'text/html': if mimetype != 'text/html':
raise AnymailUnsupportedFeature( raise AnymailUnsupportedFeature(
"Invalid alternative mimetype '%s'. " "Invalid alternative mimetype '%s'. "
"Mandrill only accepts plain text and html emails." "Mandrill only accepts plain text and html emails."
% mimetype, % mimetype,
email_message=message) email_message=self.message)
if "html" in payload["message"]: if "html" in self.data["message"]:
raise AnymailUnsupportedFeature( raise AnymailUnsupportedFeature(
"Too many alternatives attached to the message. " "Too many alternatives attached to the message. "
"Mandrill only accepts plain text and html emails.", "Mandrill only accepts plain text and html emails.",
email_message=message) email_message=self.message)
payload["message"]["html"] = content self.data["message"]["html"] = content
def add_payload_attachment(self, payload, attachment, message): def add_attachment(self, attachment):
key = "images" if attachment.inline else "attachments" key = "images" if attachment.inline else "attachments"
payload["message"].setdefault(key, []).append({ self.data["message"].setdefault(key, []).append({
"type": attachment.mimetype, "type": attachment.mimetype,
"name": attachment.name or "", "name": attachment.name or "",
"content": attachment.b64content "content": attachment.b64content
}) })
def set_payload_metadata(self, payload, metadata, message): def set_metadata(self, metadata):
payload["message"]["metadata"] = metadata self.data["message"]["metadata"] = metadata
def set_payload_send_at(self, payload, send_at, message): def set_send_at(self, send_at):
payload["send_at"] = self.encode_date_for_mandrill(send_at) self.data["send_at"] = encode_date_for_mandrill(send_at)
def set_payload_tags(self, payload, tags, message): def set_tags(self, tags):
payload["message"]["tags"] = tags self.data["message"]["tags"] = tags
def set_payload_track_clicks(self, payload, track_clicks, message): def set_track_clicks(self, track_clicks):
payload["message"]["track_clicks"] = track_clicks self.data["message"]["track_clicks"] = track_clicks
def set_payload_track_opens(self, payload, track_opens, message): def set_track_opens(self, track_opens):
payload["message"]["track_opens"] = track_opens self.data["message"]["track_opens"] = track_opens
def add_payload_esp_options(self, payload, message): def set_esp_extra(self, extra):
self._add_mandrill_options(message, payload["message"]) pass
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 # Djrill leftovers
def _add_mandrill_toplevel_options(self, message, api_params): esp_message_attrs = (
"""Extend api_params to include Mandrill global-send options set on message""" ('async', last, None),
# Mandrill attributes that can be copied directly: ('ip_pool', last, None),
mandrill_attrs = [ ('from_name', last, None), # overrides display name parsed from from_email above
'async', 'ip_pool' ('important', last, None),
] ('auto_text', last, None),
for attr in mandrill_attrs: ('auto_html', last, None),
if attr in self.global_settings: ('inline_css', last, None),
api_params[attr] = self.global_settings[attr] ('url_strip_qs', last, None),
if hasattr(message, attr): ('tracking_domain', last, None),
api_params[attr] = getattr(message, attr) ('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 _add_mandrill_options(self, message, msg_dict): def set_async(self, async):
"""Extend msg_dict to include Mandrill per-message options set on message""" self.data["async"] = async
# Mandrill attributes that can be copied directly:
mandrill_attrs = [ def set_ip_pool(self, ip_pool):
'from_name', # overrides display name parsed from from_email above self.data["ip_pool"] = ip_pool
'important',
'auto_text', 'auto_html', def set_template_name(self, template_name):
'inline_css', 'url_strip_qs', self.data["template_name"] = template_name
'tracking_domain', 'signing_domain', 'return_path_domain', self.data.setdefault("template_content", []) # Mandrill requires something here
'merge_language',
'preserve_recipients', 'view_content_link', 'subaccount', def set_template_content(self, template_content):
'google_analytics_domains', 'google_analytics_campaign', 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())
] ]
for attr in mandrill_attrs: def set_recipient_metadata(self, recipient_metadata):
if attr in self.global_settings: # For testing reproducibility, we sort the recipients
msg_dict[attr] = self.global_settings[attr] self.data['message']['recipient_metadata'] = [
if hasattr(message, attr): {'rcpt': rcpt, 'values': recipient_metadata[rcpt]}
msg_dict[attr] = getattr(message, attr) for rcpt in sorted(recipient_metadata.keys())
]
# Allow simple python dicts in place of Mandrill # Set up simple set_<attr> functions for any missing esp_message_attrs attrs
# [{name:name, value:value},...] arrays... # (avoids dozens of simple `self.data["message"][<attr>] = value` functions)
# 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):
"""Convert a Python dict to an array of name-content used by Mandrill.
{ name: value, ... } --> [ {'name': name, 'content': value }, ... ]
"""
# For testing reproducibility, we sort the keys
return [{'name': name, 'content': vardict[name]}
for name in sorted(vardict.keys())]
@classmethod @classmethod
def encode_date_for_mandrill(cls, dt): def define_message_attr_setters(cls):
"""Format a date or datetime for use as a Mandrill API date field 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)
datetime becomes "YYYY-MM-DD HH:MM:SS" @staticmethod
converted to UTC, if timezone-aware def make_setter(attr, setter_name):
microseconds removed # sure wish we could use functools.partial to create instance methods (descriptors)
date becomes "YYYY-MM-DD 00:00:00" def setter(self, value):
anything else gets returned intact self.data["message"][attr] = value
""" setter.__name__ = setter_name
if isinstance(dt, datetime): return setter
dt = dt.replace(microsecond=0)
if dt.utcoffset() is not None: MandrillPayload.define_message_attr_setters()
dt = (dt - dt.utcoffset()).replace(tzinfo=None)
return dt.isoformat(' ')
elif isinstance(dt, date):
return dt.isoformat() + ' 00:00:00'
else:
return dt

View File

@@ -18,6 +18,7 @@ class AnymailError(Exception):
payload: data arg (*not* json-stringified) for the ESP send call payload: data arg (*not* json-stringified) for the ESP send call
response: requests.Response from the send call response: requests.Response from the send call
""" """
self.backend = kwargs.pop('backend', None)
self.email_message = kwargs.pop('email_message', None) self.email_message = kwargs.pop('email_message', None)
self.payload = kwargs.pop('payload', None) self.payload = kwargs.pop('payload', None)
self.status_code = kwargs.pop('status_code', None) self.status_code = kwargs.pop('status_code', None)
@@ -37,18 +38,17 @@ class AnymailError(Exception):
return "\n".join(filter(None, parts)) return "\n".join(filter(None, parts))
def describe_send(self): def describe_send(self):
"""Return a string describing the ESP send in self.payload, or None""" """Return a string describing the ESP send in self.email_message, or None"""
if self.payload is None: if self.email_message is None:
return None return None
description = "Sending a message" description = "Sending a message"
try: try:
to_emails = [to['email'] for to in self.payload['message']['to']] description += " to %s" % ','.join(self.email_message.to)
description += " to %s" % ','.join(to_emails) except AttributeError:
except KeyError:
pass pass
try: try:
description += " from %s" % self.payload['message']['from_email'] description += " from %s" % self.email_message.from_email
except KeyError: except AttributeError:
pass pass
return description return description
@@ -120,8 +120,9 @@ class AnymailSerializationError(AnymailError, TypeError):
def __init__(self, message=None, orig_err=None, *args, **kwargs): def __init__(self, message=None, orig_err=None, *args, **kwargs):
if message is None: if message is None:
message = "Don't know how to send this data to your ESP. " \ esp_name = kwargs["backend"].esp_name if "backend" in kwargs else "the ESP"
"Try converting it to a string or number first." 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: if orig_err is not None:
message += "\n%s" % str(orig_err) message += "\n%s" % str(orig_err)
super(AnymailSerializationError, self).__init__(message, *args, **kwargs) super(AnymailSerializationError, self).__init__(message, *args, **kwargs)

View File

@@ -29,7 +29,7 @@ class DjrillBackendMockAPITestCase(TestCase):
self.raw = six.BytesIO(raw) self.raw = six.BytesIO(raw)
def setUp(self): 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 = self.patch.start()
self.mock_post.return_value = self.MockResponse() self.mock_post.return_value = self.MockResponse()

View File

@@ -559,7 +559,7 @@ class DjrillMandrillFeatureTests(DjrillBackendMockAPITestCase):
self.message.send() self.message.send()
err = cm.exception err = cm.exception
self.assertTrue(isinstance(err, TypeError)) # Djrill 1.x re-raised TypeError from json.dumps 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 self.assertStrContains(str(err), "Decimal('19.99') is not JSON serializable") # original message
def test_dates_not_serialized(self): def test_dates_not_serialized(self):