Move common message attrs into base backend

This commit is contained in:
medmunds
2016-03-03 16:52:10 -08:00
parent ef971489cd
commit dbf57d8a33
4 changed files with 374 additions and 193 deletions

View File

@@ -1,10 +1,13 @@
import json
import requests
from django.conf import settings
from django.core.mail.backends.base import BaseEmailBackend
from ..exceptions import AnymailError, AnymailRequestsAPIError, AnymailSerializationError, AnymailUnsupportedFeature
from ..utils import Attachment, ParsedEmail, UNSET, combine, last
from .._version import __version__
from ..exceptions import AnymailError, AnymailRequestsAPIError, AnymailSerializationError, AnymailImproperlyConfigured
class AnymailBaseBackend(BaseEmailBackend):
@@ -12,6 +15,10 @@ class AnymailBaseBackend(BaseEmailBackend):
Base Anymail email backend
"""
def __init__(self, *args, **kwargs):
super(AnymailBaseBackend, self).__init__(*args, **kwargs)
self.send_defaults = getattr(settings, "ANYMAIL_SEND_DEFAULTS", {})
def open(self):
"""
Open and persist a connection to the ESP's API, and whether
@@ -109,9 +116,144 @@ class AnymailBaseBackend(BaseEmailBackend):
Can raise AnymailUnsupportedFeature for unsupported options in message.
"""
raise NotImplementedError("%s.%s must implement build_message_payload" %
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" %
(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.
@@ -121,7 +263,7 @@ class AnymailBaseBackend(BaseEmailBackend):
Can raise AnymailAPIError (or derived exception) for problems posting to the ESP
"""
raise NotImplementedError("%s.%s must implement build_message_payload" %
raise NotImplementedError("%s.%s must implement post_to_esp" %
(self.__class__.__module__, self.__class__.__name__))
def deserialize_response(self, response, payload, message):
@@ -129,7 +271,7 @@ class AnymailBaseBackend(BaseEmailBackend):
Can raise AnymailAPIError (or derived exception) if response is unparsable
"""
raise NotImplementedError("%s.%s must implement build_message_payload" %
raise NotImplementedError("%s.%s must implement deserialize_response" %
(self.__class__.__module__, self.__class__.__name__))
def validate_response(self, parsed_response, response, payload, message):

View File

@@ -1,19 +1,13 @@
import mimetypes
from base64 import b64encode
from datetime import date, datetime
from email.mime.base import MIMEBase
from email.utils import parseaddr
try:
from urlparse import urljoin # python 2
except ImportError:
from urllib.parse import urljoin # python 3
from django.conf import settings
from django.core.mail.message import sanitize_address, DEFAULT_ATTACHMENT_MIME_TYPE
from ..exceptions import (AnymailImproperlyConfigured,
AnymailRequestsAPIError, AnymailRecipientsRefused,
AnymailUnsupportedFeature)
from ..exceptions import (AnymailImproperlyConfigured, AnymailRequestsAPIError,
AnymailRecipientsRefused, AnymailUnsupportedFeature)
from .base import AnymailRequestsBackend
@@ -36,47 +30,24 @@ class MandrillBackend(AnymailRequestsBackend):
except AttributeError:
raise AnymailImproperlyConfigured("Set MANDRILL_API_KEY in settings.py to use Anymail Mandrill backend")
self.global_settings = {}
# Djrill compat! MANDRILL_SETTINGS
try:
self.global_settings.update(settings.MANDRILL_SETTINGS)
self.send_defaults.update(settings.MANDRILL_SETTINGS)
except AttributeError:
pass # no MANDRILL_SETTINGS setting
except (TypeError, ValueError): # e.g., not enumerable
raise AnymailImproperlyConfigured("MANDRILL_SETTINGS must be a dict or mapping")
# Djrill compat! MANDRILL_SUBACCOUNT
try:
self.global_settings["subaccount"] = settings.MANDRILL_SUBACCOUNT
self.send_defaults["subaccount"] = settings.MANDRILL_SUBACCOUNT
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 build_message_payload(self, message):
"""Modify payload to add all message-specific options for Mandrill send call.
payload is a dict that will become the Mandrill send data
message is an EmailMessage, possibly with additional Mandrill-specific attrs
Can raise AnymailUnsupportedFeature for unsupported options in message.
"""
msg_dict = self._build_standard_message_dict(message)
self._add_mandrill_options(message, msg_dict)
if getattr(message, 'alternatives', None):
self._add_alternatives(message, msg_dict)
self._add_attachments(message, msg_dict)
payload = {
"key": self.api_key,
"message": msg_dict,
}
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)
return payload
def get_api_url(self, payload, message):
"""Return the correct Mandrill API url for sending payload
@@ -115,48 +86,88 @@ class MandrillBackend(AnymailRequestsBackend):
# Payload construction
#
def _build_standard_message_dict(self, message):
"""Create a Mandrill send message struct from a Django EmailMessage.
Builds the standard dict that Django's send_mail and send_mass_mail
use by default. Standard text email messages sent through Django will
still work through Mandrill.
Raises AnymailUnsupportedFeature for any standard EmailMessage
features that cannot be accurately communicated to Mandrill.
"""
sender = sanitize_address(message.from_email, message.encoding)
from_name, from_email = parseaddr(sender)
to_list = self._make_mandrill_to_list(message, message.to, "to")
to_list += self._make_mandrill_to_list(message, message.cc, "cc")
to_list += self._make_mandrill_to_list(message, message.bcc, "bcc")
content = "html" if message.content_subtype == "html" else "text"
msg_dict = {
content: message.body,
"to": to_list
def get_base_payload(self, message):
return {
"key": self.api_key,
"message": {},
}
if not getattr(message, 'use_template_from', False):
msg_dict["from_email"] = from_email
if from_name:
msg_dict["from_name"] = from_name
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
if not getattr(message, 'use_template_subject', False):
msg_dict["subject"] = message.subject
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})
if hasattr(message, 'reply_to'):
reply_to = [sanitize_address(addr, message.encoding) for addr in message.reply_to]
msg_dict["headers"] = {'Reply-To': ', '.join(reply_to)}
# Note: An explicit Reply-To header will override the reply_to attr below
# (matching Django's own behavior)
def set_payload_subject(self, payload, subject, message):
if not getattr(message, "use_template_subject", False): # Djrill compat!
payload["message"]["subject"] = subject
if message.extra_headers:
msg_dict["headers"] = msg_dict.get("headers", {})
msg_dict["headers"].update(message.extra_headers)
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
return msg_dict
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"""
@@ -170,35 +181,19 @@ class MandrillBackend(AnymailRequestsBackend):
if hasattr(message, attr):
api_params[attr] = getattr(message, attr)
# Mandrill attributes that require conversion:
if hasattr(message, 'send_at'):
api_params['send_at'] = self.encode_date_for_mandrill(message.send_at)
# setting send_at in global_settings wouldn't make much sense
def _make_mandrill_to_list(self, message, recipients, recipient_type="to"):
"""Create a Mandrill 'to' field from a list of emails.
Parses "Real Name <address@example.com>" format emails.
Sanitizes all email addresses.
"""
parsed_rcpts = [parseaddr(sanitize_address(addr, message.encoding))
for addr in recipients]
return [{"email": to_email, "name": to_name, "type": recipient_type}
for (to_name, to_email) in parsed_rcpts]
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',
'track_opens', 'track_clicks', 'auto_text', 'auto_html',
'auto_text', 'auto_html',
'inline_css', 'url_strip_qs',
'tracking_domain', 'signing_domain', 'return_path_domain',
'merge_language',
'tags', 'preserve_recipients', 'view_content_link', 'subaccount',
'preserve_recipients', 'view_content_link', 'subaccount',
'google_analytics_domains', 'google_analytics_campaign',
'metadata']
]
for attr in mandrill_attrs:
if attr in self.global_settings:
@@ -243,98 +238,6 @@ class MandrillBackend(AnymailRequestsBackend):
return [{'name': name, 'content': vardict[name]}
for name in sorted(vardict.keys())]
def _add_alternatives(self, message, msg_dict):
"""
There can be only one! ... alternative attachment, and it must be text/html.
Since mandrill does not accept image attachments or anything other
than HTML, the assumption is the only thing you are attaching is
the HTML output for your email.
"""
if len(message.alternatives) > 1:
raise AnymailUnsupportedFeature(
"Too many alternatives attached to the message. "
"Mandrill only accepts plain text and html emails.",
email_message=message)
(content, mimetype) = message.alternatives[0]
if mimetype != 'text/html':
raise AnymailUnsupportedFeature(
"Invalid alternative mimetype '%s'. "
"Mandrill only accepts plain text and html emails."
% mimetype,
email_message=message)
msg_dict['html'] = content
def _add_attachments(self, message, msg_dict):
"""Extend msg_dict to include any attachments in message"""
if message.attachments:
str_encoding = message.encoding or settings.DEFAULT_CHARSET
mandrill_attachments = []
mandrill_embedded_images = []
for attachment in message.attachments:
att_dict, is_embedded = self._make_mandrill_attachment(attachment, str_encoding)
if is_embedded:
mandrill_embedded_images.append(att_dict)
else:
mandrill_attachments.append(att_dict)
if len(mandrill_attachments) > 0:
msg_dict['attachments'] = mandrill_attachments
if len(mandrill_embedded_images) > 0:
msg_dict['images'] = mandrill_embedded_images
def _make_mandrill_attachment(self, attachment, str_encoding=None):
"""Returns EmailMessage.attachments item formatted for sending with Mandrill.
Returns mandrill_dict, is_embedded_image:
mandrill_dict: {"type":..., "name":..., "content":...}
is_embedded_image: True if the attachment should instead be handled as an inline image.
"""
# Note that an attachment can be either a tuple of (filename, content,
# mimetype) or a MIMEBase object. (Also, both filename and mimetype may
# be missing.)
is_embedded_image = False
if isinstance(attachment, MIMEBase):
name = attachment.get_filename()
content = attachment.get_payload(decode=True)
mimetype = attachment.get_content_type()
# Treat image attachments that have content ids as embedded:
if attachment.get_content_maintype() == "image" and attachment["Content-ID"] is not None:
is_embedded_image = True
name = attachment["Content-ID"]
else:
(name, content, mimetype) = attachment
# Guess missing mimetype from filename, borrowed from
# django.core.mail.EmailMessage._create_attachment()
if mimetype is None and name is not None:
mimetype, _ = mimetypes.guess_type(name)
if mimetype is None:
mimetype = DEFAULT_ATTACHMENT_MIME_TYPE
# b64encode requires bytes, so let's convert our content.
try:
# noinspection PyUnresolvedReferences
if isinstance(content, unicode):
# Python 2.X unicode string
content = content.encode(str_encoding)
except NameError:
# Python 3 doesn't differentiate between strings and unicode
# Convert python3 unicode str to bytes attachment:
if isinstance(content, str):
content = content.encode(str_encoding)
content_b64 = b64encode(content)
mandrill_attachment = {
'type': mimetype,
'name': name or "",
'content': content_b64.decode('ascii'),
}
return mandrill_attachment, is_embedded_image
@classmethod
def encode_date_for_mandrill(cls, dt):
"""Format a date or datetime for use as a Mandrill API date field