Files
django-anymail/anymail/backends/mandrill.py

270 lines
9.6 KiB
Python

from datetime import date, datetime
from django.conf import settings
from ..exceptions import (AnymailImproperlyConfigured, AnymailRequestsAPIError,
AnymailRecipientsRefused, AnymailUnsupportedFeature)
from ..utils import last, combine
from .base import AnymailRequestsBackend, RequestsPayload
class MandrillBackend(AnymailRequestsBackend):
"""
Mandrill API Email Backend
"""
def __init__(self, **kwargs):
"""Init options from Django settings"""
api_url = getattr(settings, "MANDRILL_API_URL", "https://mandrillapp.com/api/1.0")
if not api_url.endswith("/"):
api_url += "/"
super(MandrillBackend, self).__init__(api_url, **kwargs)
try:
self.api_key = settings.MANDRILL_API_KEY
except AttributeError:
raise AnymailImproperlyConfigured("Set MANDRILL_API_KEY in settings.py to use Anymail Mandrill backend")
# Djrill compat! MANDRILL_SETTINGS
try:
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.send_defaults["subaccount"] = settings.MANDRILL_SUBACCOUNT
except AttributeError:
pass # no MANDRILL_SUBACCOUNT setting
self.ignore_recipient_status = getattr(settings, "MANDRILL_IGNORE_RECIPIENT_STATUS", False)
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.
"""
try:
unique_statuses = set([item["status"] for item in parsed_response])
except (KeyError, TypeError):
raise AnymailRequestsAPIError("Invalid Mandrill API response format",
email_message=message, payload=payload, response=response)
if unique_statuses == {"sent"}:
return "sent"
elif unique_statuses == {"queued"}:
return "queued"
elif unique_statuses.issubset({"invalid", "rejected"}):
if self.ignore_recipient_status:
return "refused"
else:
# Error if *all* recipients are invalid or refused
# (This behavior parallels smtplib.SMTPRecipientsRefused from Django's SMTP EmailBackend)
raise AnymailRecipientsRefused(email_message=message, payload=payload, response=response)
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 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()