From 3b414a96198adb63a4228e08a08164fce0682270 Mon Sep 17 00:00:00 2001 From: medmunds Date: Fri, 4 Mar 2016 15:55:19 -0800 Subject: [PATCH] Move all the payload construction into Payload classes --- anymail/backends/base.py | 390 ++++++++++++++++------------ anymail/backends/mandrill.py | 298 +++++++++++---------- anymail/exceptions.py | 19 +- anymail/tests/mock_backend.py | 2 +- anymail/tests/test_mandrill_send.py | 2 +- 5 files changed, 395 insertions(+), 316 deletions(-) diff --git a/anymail/backends/base.py b/anymail/backends/base.py index bf06fa1..3d912f7 100644 --- a/anymail/backends/base.py +++ b/anymail/backends/base.py @@ -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_ 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) diff --git a/anymail/backends/mandrill.py b/anymail/backends/mandrill.py index 10f0454..0685cc6 100644 --- a/anymail/backends/mandrill.py +++ b/anymail/backends/mandrill.py @@ -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,178 +69,201 @@ class MandrillBackend(AnymailRequestsBackend): else: 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 # - def get_base_payload(self, message): - return { - "key": self.api_key, + def init_payload(self): + self.data = { + "key": self.backend.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 + 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: - 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"] - 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}) - def set_payload_subject(self, payload, subject, message): - if not getattr(message, "use_template_subject", False): # Djrill compat! - payload["message"]["subject"] = subject + def set_subject(self, subject): + if not getattr(self.message, "use_template_subject", False): # Djrill compat! + self.data["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 set_reply_to(self, emails): + reply_to = ", ".join([str(email) for email in emails]) + self.data["message"].setdefault("headers", {})["Reply-To"] = reply_to - def add_payload_headers(self, payload, headers, message): - payload["message"].setdefault("headers", {}).update(headers) + def set_extra_headers(self, headers): + self.data["message"].setdefault("headers", {}).update(headers) - def set_payload_text_body(self, payload, body, message): - payload["message"]["text"] = body + def set_text_body(self, body): + self.data["message"]["text"] = body - def set_payload_html_body(self, payload, body, message): - payload["message"]["html"] = body + def set_html_body(self, 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': raise AnymailUnsupportedFeature( "Invalid alternative mimetype '%s'. " "Mandrill only accepts plain text and html emails." % mimetype, - email_message=message) + email_message=self.message) - if "html" in payload["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=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" - payload["message"].setdefault(key, []).append({ + self.data["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_metadata(self, metadata): + self.data["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_send_at(self, send_at): + self.data["send_at"] = encode_date_for_mandrill(send_at) - def set_payload_tags(self, payload, tags, message): - payload["message"]["tags"] = tags + def set_tags(self, tags): + self.data["message"]["tags"] = tags - def set_payload_track_clicks(self, payload, track_clicks, message): - payload["message"]["track_clicks"] = track_clicks + def set_track_clicks(self, track_clicks): + self.data["message"]["track_clicks"] = track_clicks - def set_payload_track_opens(self, payload, track_opens, message): - payload["message"]["track_opens"] = track_opens + def set_track_opens(self, track_opens): + self.data["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) + def set_esp_extra(self, extra): + pass - # unported + # Djrill leftovers - 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) + 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 _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', + 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()) ] - 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) + 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()) + ] - # 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): - """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())] + # Set up simple set_ functions for any missing esp_message_attrs attrs + # (avoids dozens of simple `self.data["message"][] = value` functions) @classmethod - def encode_date_for_mandrill(cls, dt): - """Format a date or datetime for use as a Mandrill API date field + 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) - 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 + @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() diff --git a/anymail/exceptions.py b/anymail/exceptions.py index 7a41cb5..d206eb4 100644 --- a/anymail/exceptions.py +++ b/anymail/exceptions.py @@ -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) diff --git a/anymail/tests/mock_backend.py b/anymail/tests/mock_backend.py index 1af49ee..57fe6dc 100644 --- a/anymail/tests/mock_backend.py +++ b/anymail/tests/mock_backend.py @@ -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() diff --git a/anymail/tests/test_mandrill_send.py b/anymail/tests/test_mandrill_send.py index 0330051..f2d7b2a 100644 --- a/anymail/tests/test_mandrill_send.py +++ b/anymail/tests/test_mandrill_send.py @@ -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):