mirror of
https://github.com/pacnpal/django-anymail.git
synced 2025-12-20 11:51:05 -05:00
Simplify install to just `pip install django-anymail`. (Rather than `... django-anymail[mailgun]` All of the ESPs so far require requests, so just move that into the base requirements. (Chances are your Django app already needs requests for some other reason, anyway.) Truly unique ESP dependencies (e.g., boto for AWS-SES) could still use the setup extra features mechanism.
209 lines
9.0 KiB
Python
209 lines
9.0 KiB
Python
from django.core.exceptions import ImproperlyConfigured
|
|
from django.core.mail import make_msgid
|
|
from requests.structures import CaseInsensitiveDict
|
|
|
|
from ..exceptions import AnymailRequestsAPIError
|
|
from ..message import AnymailRecipientStatus
|
|
from ..utils import get_anymail_setting, timestamp
|
|
|
|
from .base_requests import AnymailRequestsBackend, RequestsPayload
|
|
|
|
|
|
|
|
class SendGridBackend(AnymailRequestsBackend):
|
|
"""
|
|
SendGrid API Email Backend
|
|
"""
|
|
|
|
def __init__(self, **kwargs):
|
|
"""Init options from Django settings"""
|
|
# Auth requires *either* SENDGRID_API_KEY or SENDGRID_USERNAME+SENDGRID_PASSWORD
|
|
self.api_key = get_anymail_setting('SENDGRID_API_KEY', default=None, allow_bare=True)
|
|
self.username = get_anymail_setting('SENDGRID_USERNAME', default=None, allow_bare=True)
|
|
self.password = get_anymail_setting('SENDGRID_PASSWORD', default=None, allow_bare=True)
|
|
if self.api_key is None and self.username is None and self.password is None:
|
|
raise ImproperlyConfigured(
|
|
"You must set either SENDGRID_API_KEY or both SENDGRID_USERNAME and "
|
|
"SENDGRID_PASSWORD in your Django ANYMAIL settings."
|
|
)
|
|
|
|
# This is SendGrid's Web API v2 (because the Web API v3 doesn't support sending)
|
|
api_url = get_anymail_setting("SENDGRID_API_URL", "https://api.sendgrid.com/api/")
|
|
if not api_url.endswith("/"):
|
|
api_url += "/"
|
|
super(SendGridBackend, self).__init__(api_url, **kwargs)
|
|
|
|
def build_message_payload(self, message, defaults):
|
|
return SendGridPayload(message, defaults, self)
|
|
|
|
def parse_recipient_status(self, response, payload, message):
|
|
parsed_response = self.deserialize_json_response(response, payload, message)
|
|
try:
|
|
sendgrid_message = parsed_response["message"]
|
|
except (KeyError, TypeError):
|
|
raise AnymailRequestsAPIError("Invalid SendGrid API response format",
|
|
email_message=message, payload=payload, response=response)
|
|
if sendgrid_message != "success":
|
|
errors = parsed_response.get("errors", [])
|
|
raise AnymailRequestsAPIError("SendGrid send failed: '%s'" % "; ".join(errors),
|
|
email_message=message, payload=payload, response=response)
|
|
# Simulate a per-recipient status of "queued":
|
|
status = AnymailRecipientStatus(message_id=payload.message_id, status="queued")
|
|
return {recipient.email: status for recipient in payload.all_recipients}
|
|
|
|
|
|
class SendGridPayload(RequestsPayload):
|
|
|
|
def __init__(self, message, defaults, backend, *args, **kwargs):
|
|
self.all_recipients = [] # used for backend.parse_recipient_status
|
|
self.message_id = None # Message-ID -- assigned in serialize_data unless provided in headers
|
|
self.smtpapi = {} # SendGrid x-smtpapi field
|
|
|
|
http_headers = kwargs.pop('headers', {})
|
|
query_params = kwargs.pop('params', {})
|
|
if backend.api_key is not None:
|
|
http_headers['Authorization'] = 'Bearer %s' % backend.api_key
|
|
else:
|
|
query_params['api_user'] = backend.username
|
|
query_params['api_key'] = backend.password
|
|
super(SendGridPayload, self).__init__(message, defaults, backend,
|
|
params=query_params, headers=http_headers,
|
|
*args, **kwargs)
|
|
|
|
def get_api_endpoint(self):
|
|
return "mail.send.json"
|
|
|
|
def serialize_data(self):
|
|
"""Performs any necessary serialization on self.data, and returns the result."""
|
|
|
|
# Serialize x-smtpapi to json:
|
|
if len(self.smtpapi) > 0:
|
|
# If esp_extra was also used to set x-smtpapi, need to merge it
|
|
if "x-smtpapi" in self.data:
|
|
esp_extra_smtpapi = self.data["x-smtpapi"]
|
|
self.smtpapi.update(esp_extra_smtpapi) # need to make this deep merge (for filters)!
|
|
self.data["x-smtpapi"] = self.serialize_json(self.smtpapi)
|
|
elif "x-smtpapi" in self.data:
|
|
self.data["x-smtpapi"] = self.serialize_json(self.data["x-smtpapi"])
|
|
|
|
# Add our own message_id, and serialize extra headers to json:
|
|
headers = self.data["headers"]
|
|
try:
|
|
self.message_id = headers["Message-ID"]
|
|
except KeyError:
|
|
self.message_id = headers["Message-ID"] = self.make_message_id()
|
|
self.data["headers"] = self.serialize_json(dict(headers.items()))
|
|
|
|
return self.data
|
|
|
|
def make_message_id(self):
|
|
"""Returns a Message-ID that could be used for this payload
|
|
|
|
Tries to use the from_email's domain as the Message-ID's domain
|
|
"""
|
|
try:
|
|
_, domain = self.data["from"].split("@")
|
|
except (AttributeError, KeyError, TypeError, ValueError):
|
|
domain = None
|
|
return make_msgid(domain=domain)
|
|
|
|
#
|
|
# Payload construction
|
|
#
|
|
|
|
def init_payload(self):
|
|
self.data = {} # {field: [multiple, values]}
|
|
self.files = {}
|
|
self.data['headers'] = CaseInsensitiveDict() # headers keys are case-insensitive
|
|
|
|
def set_from_email(self, email):
|
|
self.data["from"] = email.email
|
|
if email.name:
|
|
self.data["fromname"] = email.name
|
|
|
|
def set_recipients(self, recipient_type, emails):
|
|
assert recipient_type in ["to", "cc", "bcc"]
|
|
if emails:
|
|
self.data[recipient_type] = [email.email for email in emails]
|
|
empty_name = " " # SendGrid API balks on complete empty name fields
|
|
self.data[recipient_type + "name"] = [email.name or empty_name for email in emails]
|
|
self.all_recipients += emails # used for backend.parse_recipient_status
|
|
|
|
def set_subject(self, subject):
|
|
self.data["subject"] = subject
|
|
|
|
def set_reply_to(self, emails):
|
|
# Note: SendGrid mangles the 'replyto' API param: it drops
|
|
# all but the last email in a multi-address replyto, and
|
|
# drops all the display names. [tested 2016-03-10]
|
|
#
|
|
# To avoid those quirks, we provide a fully-formed Reply-To
|
|
# in the custom headers, which makes it through intact.
|
|
if emails:
|
|
reply_to = ", ".join([email.address for email in emails])
|
|
self.data["headers"]["Reply-To"] = reply_to
|
|
|
|
def set_extra_headers(self, headers):
|
|
# SendGrid requires header values to be strings -- not integers.
|
|
# We'll stringify ints and floats; anything else is the caller's responsibility.
|
|
# (This field gets converted to json in self.serialize_data)
|
|
self.data["headers"].update({
|
|
k: str(v) if isinstance(v, (int, float)) else v
|
|
for k, v in headers.items()
|
|
})
|
|
|
|
def set_text_body(self, body):
|
|
self.data["text"] = body
|
|
|
|
def set_html_body(self, body):
|
|
if "html" in self.data:
|
|
# second html body could show up through multiple alternatives, or html body + alternative
|
|
self.unsupported_feature("multiple html parts")
|
|
self.data["html"] = body
|
|
|
|
def add_attachment(self, attachment):
|
|
filename = attachment.name or ""
|
|
if attachment.inline:
|
|
filename = filename or attachment.cid # must have non-empty name for the cid matching
|
|
content_field = "content[%s]" % filename
|
|
self.data[content_field] = attachment.cid
|
|
|
|
files_field = "files[%s]" % filename
|
|
if files_field in self.files:
|
|
# It's possible SendGrid could actually handle this case (needs testing),
|
|
# but requests doesn't seem to accept a list of tuples for a files field.
|
|
# (See the MailgunBackend version for a different approach that might work.)
|
|
self.unsupported_feature(
|
|
"multiple attachments with the same filename ('%s')" % filename if filename
|
|
else "multiple unnamed attachments")
|
|
|
|
self.files[files_field] = (filename, attachment.content, attachment.mimetype)
|
|
|
|
def set_metadata(self, metadata):
|
|
self.smtpapi['unique_args'] = metadata
|
|
|
|
def set_send_at(self, send_at):
|
|
# Backend has converted pretty much everything to
|
|
# a datetime by here; SendGrid expects unix timestamp
|
|
self.smtpapi["send_at"] = int(timestamp(send_at)) # strip microseconds
|
|
|
|
def set_tags(self, tags):
|
|
self.smtpapi["category"] = tags
|
|
|
|
def add_filter(self, filter_name, setting, val):
|
|
self.smtpapi.setdefault('filters', {})\
|
|
.setdefault(filter_name, {})\
|
|
.setdefault('settings', {})[setting] = val
|
|
|
|
def set_track_clicks(self, track_clicks):
|
|
self.add_filter('clicktrack', 'enable', int(track_clicks))
|
|
|
|
def set_track_opens(self, track_opens):
|
|
# SendGrid's opentrack filter also supports a "replace"
|
|
# parameter, which Anymail doesn't offer directly.
|
|
# (You could add it through esp_extra.)
|
|
self.add_filter('opentrack', 'enable', int(track_opens))
|
|
|
|
def set_esp_extra(self, extra):
|
|
self.data.update(extra)
|