Files
django-anymail/anymail/backends/sparkpost.py
medmunds 6b6793016e Mailgun, SparkPost: support multiple from_email addresses
[RFC-5322 allows](https://tools.ietf.org/html/rfc5322#section-3.6.2)
multiple addresses in the From header.

Django's SMTP backend supports this, as a single comma-separated
string (*not* a list of strings like the recipient params):

    from_email='one@example.com, two@example.com'
    to=['one@example.com', 'two@example.com']

Both Mailgun and SparkPost support multiple From addresses
(and Postmark accepts them, though truncates to the first one
on their end). For compatibility with Django -- and because
Anymail attempts to support all ESP features -- Anymail now
allows multiple From addresses, too, for ESPs that support it.

Note: as a practical matter, deliverability with multiple
From addresses is pretty bad. (Google outright rejects them.)

This change also reworks Anymail's internal ParsedEmail object,
and approach to parsing addresses, for better consistency with
Django's SMTP backend and improved error messaging.

In particular, Django (and now Anymail) allows multiple email
addresses in a single recipient string:

    to=['one@example.com', 'two@example.com, three@example.com']
    len(to) == 2  # but there will be three recipients

Fixes #60
2017-04-19 12:43:33 -07:00

221 lines
8.5 KiB
Python

from __future__ import absolute_import # we want the sparkpost package, not our own module
import warnings
from .base import AnymailBaseBackend, BasePayload
from ..exceptions import (AnymailAPIError, AnymailImproperlyInstalled,
AnymailConfigurationError, AnymailDeprecationWarning)
from ..message import AnymailRecipientStatus
from ..utils import get_anymail_setting
try:
from sparkpost import SparkPost, SparkPostException
except ImportError:
raise AnymailImproperlyInstalled(missing_package='sparkpost', backend='sparkpost')
class EmailBackend(AnymailBaseBackend):
"""
SparkPost Email Backend (using python-sparkpost client)
"""
esp_name = "SparkPost"
def __init__(self, **kwargs):
"""Init options from Django settings"""
super(EmailBackend, self).__init__(**kwargs)
# SPARKPOST_API_KEY is optional - library reads from env by default
self.api_key = get_anymail_setting('api_key', esp_name=self.esp_name,
kwargs=kwargs, allow_bare=True, default=None)
try:
self.sp = SparkPost(self.api_key) # SparkPost API instance
except SparkPostException as err:
# This is almost certainly a missing API key
raise AnymailConfigurationError(
"Error initializing SparkPost: %s\n"
"You may need to set ANYMAIL = {'SPARKPOST_API_KEY': ...} "
"or ANYMAIL_SPARKPOST_API_KEY in your Django settings, "
"or SPARKPOST_API_KEY in your environment." % str(err)
)
# Note: SparkPost python API doesn't expose requests session sharing
# (so there's no need to implement open/close connection management here)
def build_message_payload(self, message, defaults):
return SparkPostPayload(message, defaults, self)
def post_to_esp(self, payload, message):
params = payload.get_api_params()
try:
response = self.sp.transmissions.send(**params)
except SparkPostException as err:
raise AnymailAPIError(
str(err), backend=self, email_message=message, payload=payload,
response=getattr(err, 'response', None), # SparkPostAPIException requests.Response
status_code=getattr(err, 'status', None), # SparkPostAPIException HTTP status_code
)
return response
def parse_recipient_status(self, response, payload, message):
try:
accepted = response['total_accepted_recipients']
rejected = response['total_rejected_recipients']
transmission_id = response['id']
except (KeyError, TypeError) as err:
raise AnymailAPIError(
"%s in SparkPost.transmissions.send result %r" % (str(err), response),
backend=self, email_message=message, payload=payload,
)
# SparkPost doesn't (yet*) tell us *which* recipients were accepted or rejected.
# (* looks like undocumented 'rcpt_to_errors' might provide this info.)
# If all are one or the other, we can report a specific status;
# else just report 'unknown' for all recipients.
recipient_count = len(payload.all_recipients)
if accepted == recipient_count and rejected == 0:
status = 'queued'
elif rejected == recipient_count and accepted == 0:
status = 'rejected'
else: # mixed results, or wrong total
status = 'unknown'
recipient_status = AnymailRecipientStatus(message_id=transmission_id, status=status)
return {recipient.email: recipient_status for recipient in payload.all_recipients}
# Pre-v0.8 naming (deprecated)
class SparkPostBackend(EmailBackend):
def __init__(self, **kwargs):
warnings.warn(AnymailDeprecationWarning(
"Please update your EMAIL_BACKEND setting to "
"'anymail.backends.sparkpost.EmailBackend'"))
super(SparkPostBackend, self).__init__(**kwargs)
class SparkPostPayload(BasePayload):
def init_payload(self):
self.params = {}
self.all_recipients = []
self.to_emails = []
self.merge_data = {}
def get_api_params(self):
# Compose recipients param from to_emails and merge_data (if any)
recipients = []
if len(self.merge_data) > 0:
# Build JSON recipient structures
for email in self.to_emails:
rcpt = {'address': {'email': email.email}}
if email.name:
rcpt['address']['name'] = email.name
try:
rcpt['substitution_data'] = self.merge_data[email.email]
except KeyError:
pass # no merge_data or none for this recipient
recipients.append(rcpt)
else:
# Just use simple recipients list
recipients = [email.address for email in self.to_emails]
if recipients:
self.params['recipients'] = recipients
# Must remove empty string "content" params when using stored template
if self.params.get('template', None):
for content_param in ['subject', 'text', 'html']:
try:
if not self.params[content_param]:
del self.params[content_param]
except KeyError:
pass
return self.params
def set_from_email_list(self, emails):
# SparkPost supports multiple From email addresses,
# as a single comma-separated string
self.params['from_email'] = ", ".join([email.address for email in emails])
def set_to(self, emails):
if emails:
self.to_emails = emails # bound to params['recipients'] in get_api_params
self.all_recipients += emails
def set_cc(self, emails):
if emails:
self.params['cc'] = [email.address for email in emails]
self.all_recipients += emails
def set_bcc(self, emails):
if emails:
self.params['bcc'] = [email.address for email in emails]
self.all_recipients += emails
def set_subject(self, subject):
self.params['subject'] = subject
def set_reply_to(self, emails):
if emails:
# reply_to is only documented as a single email, but this seems to work:
self.params['reply_to'] = ', '.join([email.address for email in emails])
def set_extra_headers(self, headers):
if headers:
self.params['custom_headers'] = headers
def set_text_body(self, body):
self.params['text'] = body
def set_html_body(self, body):
if 'html' in self.params:
# second html body could show up through multiple alternatives, or html body + alternative
self.unsupported_feature("multiple html parts")
self.params['html'] = body
def add_attachment(self, attachment):
if attachment.inline:
param = 'inline_images'
name = attachment.cid
else:
param = 'attachments'
name = attachment.name or ''
self.params.setdefault(param, []).append({
'type': attachment.mimetype,
'name': name,
'data': attachment.b64content})
# Anymail-specific payload construction
def set_metadata(self, metadata):
self.params['metadata'] = metadata
def set_send_at(self, send_at):
try:
self.params['start_time'] = send_at.replace(microsecond=0).isoformat()
except (AttributeError, TypeError):
self.params['start_time'] = send_at # assume user already formatted
def set_tags(self, tags):
if len(tags) > 0:
self.params['campaign'] = tags[0]
if len(tags) > 1:
self.unsupported_feature('multiple tags (%r)' % tags)
def set_track_clicks(self, track_clicks):
self.params['track_clicks'] = track_clicks
def set_track_opens(self, track_opens):
self.params['track_opens'] = track_opens
def set_template_id(self, template_id):
# 'template' transmissions.send param becomes 'template_id' in API json 'content'
self.params['template'] = template_id
def set_merge_data(self, merge_data):
self.merge_data = merge_data # merged into params['recipients'] in get_api_params
def set_merge_global_data(self, merge_global_data):
self.params['substitution_data'] = merge_global_data
# ESP-specific payload construction
def set_esp_extra(self, extra):
self.params.update(extra)