"])
# Include an inline image in the html:
logo_cid = attach_inline_image_file(msg, "/path/to/logo.jpg")
html = """
Please activate
your account
""".format(logo_cid=logo_cid)
msg.attach_alternative(html, "text/html")
# Optional Anymail extensions:
msg.metadata = {"user_id": "8675309", "experiment_variation": 1}
msg.tags = ["activation", "onboarding"]
msg.track_clicks = True
# Send it:
msg.send()
.. END quickstart
See the `full documentation `_
for more features and options, including receiving messages and tracking
sent message status.
Keywords: Django,email,email backend,ESP,transactional mail,Amazon SES,Mailgun,Mailjet,Mandrill,Postmark,SendinBlue,SendGrid,SparkPost
Platform: UNKNOWN
Classifier: Development Status :: 5 - Production/Stable
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: Implementation :: PyPy
Classifier: Programming Language :: Python :: Implementation :: CPython
Classifier: Programming Language :: Python :: 2
Classifier: Programming Language :: Python :: 2.7
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.4
Classifier: Programming Language :: Python :: 3.5
Classifier: Programming Language :: Python :: 3.6
Classifier: Programming Language :: Python :: 3.7
Classifier: License :: OSI Approved :: BSD License
Classifier: Topic :: Communications :: Email
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Intended Audience :: Developers
Classifier: Framework :: Django
Classifier: Framework :: Django :: 1.11
Classifier: Framework :: Django :: 2.0
Classifier: Framework :: Django :: 2.1
Classifier: Environment :: Web Environment
Provides-Extra: sendinblue
Provides-Extra: sendgrid
Provides-Extra: mailgun
Provides-Extra: mandrill
Provides-Extra: mailjet
Provides-Extra: postmark
Provides-Extra: amazon_ses
Provides-Extra: sparkpost
django-anymail-7.0.0/LICENSE 0000644 0000765 0000024 00000003030 12664365436 016367 0 ustar medmunds staff 0000000 0000000 [The BSD 3-Clause License]
Copyright (c) Anymail Contributors.
All rights reserved.
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its contributors
may be used to endorse or promote products derived from this software
without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
django-anymail-7.0.0/MANIFEST.in 0000644 0000765 0000024 00000000106 13245420402 017076 0 ustar medmunds staff 0000000 0000000 include README.rst AUTHORS.txt LICENSE
recursive-include anymail *.py
django-anymail-7.0.0/anymail/ 0000755 0000765 0000024 00000000000 13535011557 017006 5 ustar medmunds staff 0000000 0000000 django-anymail-7.0.0/anymail/signals.py 0000644 0000765 0000024 00000007646 13263445200 021027 0 ustar medmunds staff 0000000 0000000 from django.dispatch import Signal
# Outbound message, before sending
pre_send = Signal(providing_args=['message', 'esp_name'])
# Outbound message, after sending
post_send = Signal(providing_args=['message', 'status', 'esp_name'])
# Delivery and tracking events for sent messages
tracking = Signal(providing_args=['event', 'esp_name'])
# Event for receiving inbound messages
inbound = Signal(providing_args=['event', 'esp_name'])
class AnymailEvent(object):
"""Base class for normalized Anymail webhook events"""
def __init__(self, event_type, timestamp=None, event_id=None, esp_event=None, **kwargs):
self.event_type = event_type # normalized to an EventType str
self.timestamp = timestamp # normalized to an aware datetime
self.event_id = event_id # opaque str
self.esp_event = esp_event # raw event fields (e.g., parsed JSON dict or POST data QueryDict)
class AnymailTrackingEvent(AnymailEvent):
"""Normalized delivery and tracking event for sent messages"""
def __init__(self, **kwargs):
super(AnymailTrackingEvent, self).__init__(**kwargs)
self.click_url = kwargs.pop('click_url', None) # str
self.description = kwargs.pop('description', None) # str, usually human-readable, not normalized
self.message_id = kwargs.pop('message_id', None) # str, format may vary
self.metadata = kwargs.pop('metadata', {}) # dict
self.mta_response = kwargs.pop('mta_response', None) # str, may include SMTP codes, not normalized
self.recipient = kwargs.pop('recipient', None) # str email address (just the email portion; no name)
self.reject_reason = kwargs.pop('reject_reason', None) # normalized to a RejectReason str
self.tags = kwargs.pop('tags', []) # list of str
self.user_agent = kwargs.pop('user_agent', None) # str
class AnymailInboundEvent(AnymailEvent):
"""Normalized inbound message event"""
def __init__(self, **kwargs):
super(AnymailInboundEvent, self).__init__(**kwargs)
self.message = kwargs.pop('message', None) # anymail.inbound.AnymailInboundMessage
class EventType:
"""Constants for normalized Anymail event types"""
# Delivery (and non-delivery) event types:
# (these match message.ANYMAIL_STATUSES where appropriate)
QUEUED = 'queued' # the ESP has accepted the message and will try to send it (possibly later)
SENT = 'sent' # the ESP has sent the message (though it may or may not get delivered)
REJECTED = 'rejected' # the ESP refused to send the messsage (e.g., suppression list, policy, invalid email)
FAILED = 'failed' # the ESP was unable to send the message (e.g., template rendering error)
BOUNCED = 'bounced' # rejected or blocked by receiving MTA
DEFERRED = 'deferred' # delayed by receiving MTA; should be followed by a later BOUNCED or DELIVERED
DELIVERED = 'delivered' # accepted by receiving MTA
AUTORESPONDED = 'autoresponded' # a bot replied
# Tracking event types:
OPENED = 'opened' # open tracking
CLICKED = 'clicked' # click tracking
COMPLAINED = 'complained' # recipient reported as spam (e.g., through feedback loop)
UNSUBSCRIBED = 'unsubscribed' # recipient attempted to unsubscribe
SUBSCRIBED = 'subscribed' # signed up for mailing list through ESP-hosted form
# Inbound event types:
INBOUND = 'inbound' # received message
INBOUND_FAILED = 'inbound_failed'
# Other:
UNKNOWN = 'unknown' # anything else
class RejectReason:
"""Constants for normalized Anymail reject/drop reasons"""
INVALID = 'invalid' # bad address format
BOUNCED = 'bounced' # (previous) bounce from recipient
TIMED_OUT = 'timed_out' # (previous) repeated failed delivery attempts
BLOCKED = 'blocked' # ESP policy suppression
SPAM = 'spam' # (previous) spam complaint from recipient
UNSUBSCRIBED = 'unsubscribed' # (previous) unsubscribe request from recipient
OTHER = 'other'
django-anymail-7.0.0/anymail/_version.py 0000644 0000765 0000024 00000000305 13535010721 021172 0 ustar medmunds staff 0000000 0000000 VERSION = (7, 0, 0)
__version__ = '.'.join([str(x) for x in VERSION]) # major.minor.patch or major.minor.devN
__minor_version__ = '.'.join([str(x) for x in VERSION[:2]]) # Sphinx's X.Y "version"
django-anymail-7.0.0/anymail/_email_compat.py 0000644 0000765 0000024 00000014021 13260274264 022150 0 ustar medmunds staff 0000000 0000000 # Work around bugs in older versions of email.parser.Parser
#
# This module implements two classes:
# EmailParser
# EmailBytesParser
# which can be used like the Python 3.3+ email.parser.Parser
# and email.parser.BytesParser (with email.policy.default).
#
# On Python 2.7, they attempt to work around some bugs/limitations
# in email.parser.Parser, without trying to back-port the whole
# Python 3 email package.
__all__ = ['EmailParser', 'EmailBytesParser']
from email.parser import Parser
try:
# With Python 3.3+ (email6) package, using `policy=email.policy.default`
# avoids earlier bugs. (Note that Parser defaults to policy=compat32,
# which *preserves* earlier bugs.)
from email.policy import default
from email.parser import BytesParser
class EmailParser(Parser):
def __init__(self, _class=None, policy=default): # don't default to compat32 policy
super(EmailParser, self).__init__(_class, policy=policy)
class EmailBytesParser(BytesParser):
def __init__(self, _class=None, policy=default): # don't default to compat32 policy
super(EmailBytesParser, self).__init__(_class, policy=policy)
except ImportError:
# Pre-Python 3.3 email package: try to work around some bugs
from email.header import decode_header
from collections import deque
class EmailParser(Parser):
def parse(self, fp, headersonly=False):
# Older Parser doesn't correctly unfold headers (RFC5322 section 2.2.3).
# Help it out by pre-unfolding the headers for it.
fp = HeaderUnfoldingWrapper(fp)
message = Parser.parse(self, fp, headersonly=headersonly)
# Older Parser doesn't decode RFC2047 headers, so fix them up here.
# (Since messsage is fully parsed, can decode headers in all MIME subparts.)
for part in message.walk():
part._headers = [ # doesn't seem to be a public API to easily replace all headers
(name, _decode_rfc2047(value))
for name, value in part._headers]
return message
class EmailBytesParser(EmailParser):
def parsebytes(self, text, headersonly=False):
# In Python 2, bytes is str, and Parser.parsestr uses bytes-friendly cStringIO.StringIO.
return self.parsestr(text, headersonly)
class HeaderUnfoldingWrapper:
"""
A wrapper for file-like objects passed to email.parser.Parser.parse which works
around older Parser bugs with folded email headers by pre-unfolding them.
This only works for headers at the message root, not ones within a MIME subpart.
(Accurately recognizing subpart headers would require parsing mixed-content boundaries.)
"""
def __init__(self, fp):
self.fp = fp
self._in_headers = True
self._pushback = deque()
def _readline(self, limit=-1):
try:
line = self._pushback.popleft()
except IndexError:
line = self.fp.readline(limit)
# cStringIO.readline doesn't recognize universal newlines; splitlines does
lines = line.splitlines(True)
if len(lines) > 1:
line = lines[0]
self._pushback.extend(lines[1:])
return line
def _peekline(self, limit=-1):
try:
line = self._pushback[0]
except IndexError:
line = self._readline(limit)
self._pushback.appendleft(line)
return line
def readline(self, limit=-1):
line = self._readline(limit)
if self._in_headers:
line_without_end = line.rstrip("\r\n") # CRLF, CR, or LF -- "universal newlines"
if len(line_without_end) == 0:
# RFC5322 section 2.1: "The body ... is separated from the header section
# by an empty line (i.e., a line with nothing preceding the CRLF)."
self._in_headers = False
else:
# Is this header line folded? Need to check next line...
# RFC5322 section 2.2.3: "Unfolding is accomplished by simply removing any CRLF
# that is immediately followed by WSP." (WSP is space or tab)
next_line = self._peekline(limit)
if next_line.startswith((' ', '\t')):
line = line_without_end
return line
def read(self, size):
if self._in_headers:
# For simplicity, just read a line at a time while in the header section.
# (This works because we know email.parser.Parser doesn't really care if it reads
# more or less data than it asked for -- it just pushes it into FeedParser either way.)
return self.readline(size)
elif len(self._pushback):
buf = ''.join(self._pushback)
self._pushback.clear()
return buf
else:
return self.fp.read(size)
def _decode_rfc2047(value):
result = value
decoded_segments = decode_header(value)
if any(charset is not None for raw, charset in decoded_segments):
# At least one segment is an RFC2047 encoded-word.
# Reassemble the segments into a single decoded string.
unicode_segments = []
prev_charset = None
for raw, charset in decoded_segments:
if (charset is None or prev_charset is None) and unicode_segments:
# Transitioning to, from, or between *non*-encoded segments:
# add back inter-segment whitespace that decode_header consumed
unicode_segments.append(u" ")
decoded = raw.decode(charset, 'replace') if charset is not None else raw
unicode_segments.append(decoded)
prev_charset = charset
result = u"".join(unicode_segments)
return result
django-anymail-7.0.0/anymail/checks.py 0000644 0000765 0000024 00000001710 13246067101 020612 0 ustar medmunds staff 0000000 0000000 from django.conf import settings
from django.core import checks
def check_deprecated_settings(app_configs, **kwargs):
errors = []
anymail_settings = getattr(settings, "ANYMAIL", {})
# anymail.W001: reserved [was deprecation warning that became anymail.E001]
# anymail.E001: rename WEBHOOK_AUTHORIZATION to WEBHOOK_SECRET
if "WEBHOOK_AUTHORIZATION" in anymail_settings:
errors.append(checks.Error(
"The ANYMAIL setting 'WEBHOOK_AUTHORIZATION' has been renamed 'WEBHOOK_SECRET' to improve security.",
hint="You must update your settings.py.",
id="anymail.E001",
))
if hasattr(settings, "ANYMAIL_WEBHOOK_AUTHORIZATION"):
errors.append(checks.Error(
"The ANYMAIL_WEBHOOK_AUTHORIZATION setting has been renamed ANYMAIL_WEBHOOK_SECRET to improve security.",
hint="You must update your settings.py.",
id="anymail.E001",
))
return errors
django-anymail-7.0.0/anymail/backends/ 0000755 0000765 0000024 00000000000 13535011557 020560 5 ustar medmunds staff 0000000 0000000 django-anymail-7.0.0/anymail/backends/console.py 0000644 0000765 0000024 00000002523 13236203646 022576 0 ustar medmunds staff 0000000 0000000 import uuid
from django.core.mail.backends.console import EmailBackend as DjangoConsoleBackend
from ..exceptions import AnymailError
from .test import EmailBackend as AnymailTestBackend
class EmailBackend(AnymailTestBackend, DjangoConsoleBackend):
"""
Anymail backend that prints messages to the console, while retaining
anymail statuses and signals.
"""
esp_name = "Console"
def get_esp_message_id(self, message):
# Generate a guaranteed-unique ID for the message
return str(uuid.uuid4())
def send_messages(self, email_messages):
if not email_messages:
return
msg_count = 0
with self._lock:
try:
stream_created = self.open()
for message in email_messages:
try:
sent = self._send(message)
except AnymailError:
if self.fail_silently:
sent = False
else:
raise
if sent:
self.write_message(message)
self.stream.flush() # flush after each message
msg_count += 1
finally:
if stream_created:
self.close()
return msg_count
django-anymail-7.0.0/anymail/backends/postmark.py 0000644 0000765 0000024 00000034172 13434336005 022775 0 ustar medmunds staff 0000000 0000000 import re
from ..exceptions import AnymailRequestsAPIError
from ..message import AnymailRecipientStatus
from ..utils import get_anymail_setting, parse_address_list, CaseInsensitiveCasePreservingDict
from .base_requests import AnymailRequestsBackend, RequestsPayload
class EmailBackend(AnymailRequestsBackend):
"""
Postmark API Email Backend
"""
esp_name = "Postmark"
def __init__(self, **kwargs):
"""Init options from Django settings"""
esp_name = self.esp_name
self.server_token = get_anymail_setting('server_token', esp_name=esp_name, kwargs=kwargs, allow_bare=True)
api_url = get_anymail_setting('api_url', esp_name=esp_name, kwargs=kwargs,
default="https://api.postmarkapp.com/")
if not api_url.endswith("/"):
api_url += "/"
super(EmailBackend, self).__init__(api_url, **kwargs)
def build_message_payload(self, message, defaults):
return PostmarkPayload(message, defaults, self)
def raise_for_status(self, response, payload, message):
# We need to handle 422 responses in parse_recipient_status
if response.status_code != 422:
super(EmailBackend, self).raise_for_status(response, payload, message)
def parse_recipient_status(self, response, payload, message):
# Default to "unknown" status for each recipient, unless/until we find otherwise.
# (This also forces recipient_status email capitalization to match that as sent,
# while correctly handling Postmark's lowercase-only inactive recipient reporting.)
unknown_status = AnymailRecipientStatus(message_id=None, status='unknown')
recipient_status = CaseInsensitiveCasePreservingDict({
recip.addr_spec: unknown_status
for recip in payload.to_emails + payload.cc_and_bcc_emails})
parsed_response = self.deserialize_json_response(response, payload, message)
if not isinstance(parsed_response, list):
# non-batch calls return a single response object
parsed_response = [parsed_response]
for one_response in parsed_response:
try:
# these fields should always be present
error_code = one_response["ErrorCode"]
msg = one_response["Message"]
except (KeyError, TypeError):
raise AnymailRequestsAPIError("Invalid Postmark API response format",
email_message=message, payload=payload, response=response,
backend=self)
if error_code == 0:
# At least partial success, and (some) email was sent.
try:
message_id = one_response["MessageID"]
except KeyError:
raise AnymailRequestsAPIError("Invalid Postmark API success response format",
email_message=message, payload=payload,
response=response, backend=self)
# Assume all To recipients are "sent" unless proven otherwise below.
# (Must use "To" from API response to get correct individual MessageIDs in batch send.)
try:
to_header = one_response["To"] # (missing if cc- or bcc-only send)
except KeyError:
pass # cc- or bcc-only send; per-recipient status not available
else:
for to in parse_address_list(to_header):
recipient_status[to.addr_spec] = AnymailRecipientStatus(
message_id=message_id, status='sent')
# Assume all Cc and Bcc recipients are "sent" unless proven otherwise below.
# (Postmark doesn't report "Cc" or "Bcc" in API response; use original payload values.)
for recip in payload.cc_and_bcc_emails:
recipient_status[recip.addr_spec] = AnymailRecipientStatus(
message_id=message_id, status='sent')
# Change "sent" to "rejected" if Postmark reported an address as "Inactive".
# Sadly, have to parse human-readable message to figure out if everyone got it:
# "Message OK, but will not deliver to these inactive addresses: {addr_spec, ...}.
# Inactive recipients are ones that have generated a hard bounce or a spam complaint."
# Note that error message emails are addr_spec only (no display names) and forced lowercase.
reject_addr_specs = self._addr_specs_from_error_msg(
msg, r'inactive addresses:\s*(.*)\.\s*Inactive recipients')
for reject_addr_spec in reject_addr_specs:
recipient_status[reject_addr_spec] = AnymailRecipientStatus(
message_id=None, status='rejected')
elif error_code == 300: # Invalid email request
# Either the From address or at least one recipient was invalid. Email not sent.
# response["To"] is not populated for this error; must examine response["Message"]:
# "Invalid 'To' address: '{addr_spec}'."
# "Error parsing 'To': Illegal email domain '{domain}' in address '{addr_spec}'."
# "Error parsing 'To': Illegal email address '{addr_spec}'. It must contain the '@' symbol."
# "Invalid 'From' address: '{email_address}'."
if "'From' address" in msg:
# Normal error
raise AnymailRequestsAPIError(email_message=message, payload=payload, response=response,
backend=self)
else:
# Use AnymailRecipientsRefused logic
invalid_addr_specs = self._addr_specs_from_error_msg(msg, r"address:?\s*'(.*)'")
for invalid_addr_spec in invalid_addr_specs:
recipient_status[invalid_addr_spec] = AnymailRecipientStatus(
message_id=None, status='invalid')
elif error_code == 406: # Inactive recipient
# All recipients were rejected as hard-bounce or spam-complaint. Email not sent.
# response["To"] is not populated for this error; must examine response["Message"]:
# "You tried to send to a recipient that has been marked as inactive.\n
# Found inactive addresses: {addr_spec, ...}.\n
# Inactive recipients are ones that have generated a hard bounce or a spam complaint. "
reject_addr_specs = self._addr_specs_from_error_msg(
msg, r'inactive addresses:\s*(.*)\.\s*Inactive recipients')
for reject_addr_spec in reject_addr_specs:
recipient_status[reject_addr_spec] = AnymailRecipientStatus(
message_id=None, status='rejected')
else: # Other error
raise AnymailRequestsAPIError(email_message=message, payload=payload, response=response,
backend=self)
return dict(recipient_status)
@staticmethod
def _addr_specs_from_error_msg(error_msg, pattern):
"""Extract a list of email addr_specs from Postmark error_msg.
pattern must be a re whose first group matches a comma-separated
list of addr_specs in the message
"""
match = re.search(pattern, error_msg, re.MULTILINE)
if match:
emails = match.group(1) # "one@xample.com, two@example.com"
return [email.strip().lower() for email in emails.split(',')]
else:
return []
class PostmarkPayload(RequestsPayload):
def __init__(self, message, defaults, backend, *args, **kwargs):
headers = {
'Content-Type': 'application/json',
'Accept': 'application/json',
# 'X-Postmark-Server-Token': see get_request_params (and set_esp_extra)
}
self.server_token = backend.server_token # added to headers later, so esp_extra can override
self.to_emails = []
self.cc_and_bcc_emails = [] # need to track (separately) for parse_recipient_status
self.merge_data = None
self.merge_metadata = None
super(PostmarkPayload, self).__init__(message, defaults, backend, headers=headers, *args, **kwargs)
def get_api_endpoint(self):
batch_send = self.is_batch() and len(self.to_emails) > 1
if 'TemplateAlias' in self.data or 'TemplateId' in self.data or 'TemplateModel' in self.data:
if batch_send:
return "email/batchWithTemplates"
else:
# This is the one Postmark API documented to have a trailing slash. (Typo?)
return "email/withTemplate/"
else:
if batch_send:
return "email/batch"
else:
return "email"
def get_request_params(self, api_url):
params = super(PostmarkPayload, self).get_request_params(api_url)
params['headers']['X-Postmark-Server-Token'] = self.server_token
return params
def serialize_data(self):
data = self.data
api_endpoint = self.get_api_endpoint()
if api_endpoint == "email/batchWithTemplates":
data = {"Messages": [self.data_for_recipient(to) for to in self.to_emails]}
elif api_endpoint == "email/batch":
data = [self.data_for_recipient(to) for to in self.to_emails]
return self.serialize_json(data)
def data_for_recipient(self, to):
data = self.data.copy()
data["To"] = to.address
if self.merge_data and to.addr_spec in self.merge_data:
recipient_data = self.merge_data[to.addr_spec]
if "TemplateModel" in data:
# merge recipient_data into merge_global_data
data["TemplateModel"] = data["TemplateModel"].copy()
data["TemplateModel"].update(recipient_data)
else:
data["TemplateModel"] = recipient_data
if self.merge_metadata and to.addr_spec in self.merge_metadata:
recipient_metadata = self.merge_metadata[to.addr_spec]
if "Metadata" in data:
# merge recipient_metadata into toplevel metadata
data["Metadata"] = data["Metadata"].copy()
data["Metadata"].update(recipient_metadata)
else:
data["Metadata"] = recipient_metadata
return data
#
# Payload construction
#
def init_payload(self):
self.data = {} # becomes json
def set_from_email_list(self, emails):
# Postmark accepts multiple From email addresses
# (though truncates to just the first, on their end, as of 4/2017)
self.data["From"] = ", ".join([email.address for email in emails])
def set_recipients(self, recipient_type, emails):
assert recipient_type in ["to", "cc", "bcc"]
if emails:
field = recipient_type.capitalize()
self.data[field] = ', '.join([email.address for email in emails])
if recipient_type == "to":
self.to_emails = emails
else:
self.cc_and_bcc_emails += emails
def set_subject(self, subject):
self.data["Subject"] = subject
def set_reply_to(self, emails):
if emails:
reply_to = ", ".join([email.address for email in emails])
self.data["ReplyTo"] = reply_to
def set_extra_headers(self, headers):
self.data["Headers"] = [
{"Name": key, "Value": value}
for key, value in headers.items()
]
def set_text_body(self, body):
self.data["TextBody"] = body
def set_html_body(self, body):
if "HtmlBody" in self.data:
# second html body could show up through multiple alternatives, or html body + alternative
self.unsupported_feature("multiple html parts")
self.data["HtmlBody"] = body
def make_attachment(self, attachment):
"""Returns Postmark attachment dict for attachment"""
att = {
"Name": attachment.name or "",
"Content": attachment.b64content,
"ContentType": attachment.mimetype,
}
if attachment.inline:
att["ContentID"] = "cid:%s" % attachment.cid
return att
def set_attachments(self, attachments):
if attachments:
self.data["Attachments"] = [
self.make_attachment(attachment) for attachment in attachments
]
def set_metadata(self, metadata):
self.data["Metadata"] = metadata
# Postmark doesn't support delayed sending
# def set_send_at(self, send_at):
def set_tags(self, tags):
if len(tags) > 0:
self.data["Tag"] = tags[0]
if len(tags) > 1:
self.unsupported_feature('multiple tags (%r)' % tags)
def set_track_clicks(self, track_clicks):
self.data["TrackLinks"] = 'HtmlAndText' if track_clicks else 'None'
def set_track_opens(self, track_opens):
self.data["TrackOpens"] = track_opens
def set_template_id(self, template_id):
try:
self.data["TemplateId"] = int(template_id)
except ValueError:
self.data["TemplateAlias"] = template_id
# Subject, TextBody, and HtmlBody aren't allowed with TemplateId;
# delete Django default subject and body empty strings:
for field in ("Subject", "TextBody", "HtmlBody"):
if field in self.data and not self.data[field]:
del self.data[field]
def set_merge_data(self, merge_data):
# late-bind
self.merge_data = merge_data
def set_merge_global_data(self, merge_global_data):
self.data["TemplateModel"] = merge_global_data
def set_merge_metadata(self, merge_metadata):
# late-bind
self.merge_metadata = merge_metadata
def set_esp_extra(self, extra):
self.data.update(extra)
# Special handling for 'server_token':
self.server_token = self.data.pop('server_token', self.server_token)
django-anymail-7.0.0/anymail/backends/sendinblue.py 0000644 0000765 0000024 00000013352 13535006032 023256 0 ustar medmunds staff 0000000 0000000 from requests.structures import CaseInsensitiveDict
from .base_requests import AnymailRequestsBackend, RequestsPayload
from ..exceptions import AnymailRequestsAPIError
from ..message import AnymailRecipientStatus
from ..utils import get_anymail_setting
class EmailBackend(AnymailRequestsBackend):
"""
SendinBlue v3 API Email Backend
"""
esp_name = "SendinBlue"
def __init__(self, **kwargs):
"""Init options from Django settings"""
esp_name = self.esp_name
self.api_key = get_anymail_setting(
'api_key',
esp_name=esp_name,
kwargs=kwargs,
allow_bare=True,
)
api_url = get_anymail_setting(
'api_url',
esp_name=esp_name,
kwargs=kwargs,
default="https://api.sendinblue.com/v3/",
)
if not api_url.endswith("/"):
api_url += "/"
super(EmailBackend, self).__init__(api_url, **kwargs)
def build_message_payload(self, message, defaults):
return SendinBluePayload(message, defaults, self)
def raise_for_status(self, response, payload, message):
if response.status_code < 200 or response.status_code >= 300:
raise AnymailRequestsAPIError(
email_message=message,
payload=payload,
response=response,
backend=self,
)
def parse_recipient_status(self, response, payload, message):
# SendinBlue doesn't give any detail on a success
# https://developers.sendinblue.com/docs/responses
message_id = None
if response.content != b'':
parsed_response = self.deserialize_json_response(response, payload, message)
try:
message_id = parsed_response['messageId']
except (KeyError, TypeError):
raise AnymailRequestsAPIError("Invalid SendinBlue API response format",
email_message=message, payload=payload, response=response,
backend=self)
status = AnymailRecipientStatus(message_id=message_id, status="queued")
return {recipient.addr_spec: status for recipient in payload.all_recipients}
class SendinBluePayload(RequestsPayload):
def __init__(self, message, defaults, backend, *args, **kwargs):
self.all_recipients = [] # used for backend.parse_recipient_status
http_headers = kwargs.pop('headers', {})
http_headers['api-key'] = backend.api_key
http_headers['Content-Type'] = 'application/json'
super(SendinBluePayload, self).__init__(message, defaults, backend, headers=http_headers, *args, **kwargs)
def get_api_endpoint(self):
return "smtp/email"
def init_payload(self):
self.data = { # becomes json
'headers': CaseInsensitiveDict()
}
def serialize_data(self):
"""Performs any necessary serialization on self.data, and returns the result."""
if not self.data['headers']:
del self.data['headers'] # don't send empty headers
return self.serialize_json(self.data)
#
# Payload construction
#
@staticmethod
def email_object(email):
"""Converts EmailAddress to SendinBlue API array"""
email_object = dict()
email_object['email'] = email.addr_spec
if email.display_name:
email_object['name'] = email.display_name
return email_object
def set_from_email(self, email):
self.data["sender"] = self.email_object(email)
def set_recipients(self, recipient_type, emails):
assert recipient_type in ["to", "cc", "bcc"]
if emails:
self.data[recipient_type] = [self.email_object(email) for email in emails]
self.all_recipients += emails # used for backend.parse_recipient_status
def set_subject(self, subject):
if subject != "": # see note in set_text_body about template rendering
self.data["subject"] = subject
def set_reply_to(self, emails):
# SendinBlue only supports a single address in the reply_to API param.
if len(emails) > 1:
self.unsupported_feature("multiple reply_to addresses")
if len(emails) > 0:
self.data['replyTo'] = self.email_object(emails[0])
def set_extra_headers(self, headers):
self.data['headers'].update(headers)
def set_tags(self, tags):
if len(tags) > 0:
self.data['tags'] = tags
def set_template_id(self, template_id):
self.data['templateId'] = template_id
def set_text_body(self, body):
if body:
self.data['textContent'] = body
def set_html_body(self, body):
if body:
if "htmlContent" in self.data:
self.unsupported_feature("multiple html parts")
self.data['htmlContent'] = body
def add_attachment(self, attachment):
"""Converts attachments to SendinBlue API {name, base64} array"""
att = {
'name': attachment.name or '',
'content': attachment.b64content,
}
if attachment.inline:
self.unsupported_feature("inline attachments")
self.data.setdefault("attachment", []).append(att)
def set_esp_extra(self, extra):
self.data.update(extra)
def set_merge_data(self, merge_data):
"""SendinBlue doesn't support special attributes for each recipient"""
self.unsupported_feature("merge_data")
def set_merge_global_data(self, merge_global_data):
self.data['params'] = merge_global_data
def set_metadata(self, metadata):
# SendinBlue expects a single string payload
self.data['headers']["X-Mailin-custom"] = self.serialize_json(metadata)
django-anymail-7.0.0/anymail/backends/__init__.py 0000644 0000765 0000024 00000000000 12664362570 022665 0 ustar medmunds staff 0000000 0000000 django-anymail-7.0.0/anymail/backends/mailjet.py 0000644 0000765 0000024 00000026146 13434352406 022567 0 ustar medmunds staff 0000000 0000000 from six.moves.urllib.parse import quote
from ..exceptions import AnymailRequestsAPIError
from ..message import AnymailRecipientStatus, ANYMAIL_STATUSES
from ..utils import get_anymail_setting, EmailAddress, parse_address_list
from .base_requests import AnymailRequestsBackend, RequestsPayload
class EmailBackend(AnymailRequestsBackend):
"""
Mailjet API Email Backend
"""
esp_name = "Mailjet"
def __init__(self, **kwargs):
"""Init options from Django settings"""
esp_name = self.esp_name
self.api_key = get_anymail_setting('api_key', esp_name=esp_name, kwargs=kwargs, allow_bare=True)
self.secret_key = get_anymail_setting('secret_key', esp_name=esp_name, kwargs=kwargs, allow_bare=True)
api_url = get_anymail_setting('api_url', esp_name=esp_name, kwargs=kwargs,
default="https://api.mailjet.com/v3")
if not api_url.endswith("/"):
api_url += "/"
super(EmailBackend, self).__init__(api_url, **kwargs)
def build_message_payload(self, message, defaults):
return MailjetPayload(message, defaults, self)
def raise_for_status(self, response, payload, message):
# Improve Mailjet's (lack of) error message for bad API key
if response.status_code == 401 and not response.content:
raise AnymailRequestsAPIError(
"Invalid Mailjet API key or secret",
email_message=message, payload=payload, response=response, backend=self)
super(EmailBackend, self).raise_for_status(response, payload, message)
def parse_recipient_status(self, response, payload, message):
# Mailjet's (v3.0) transactional send API is not covered in their reference docs.
# The response appears to be either:
# {"Sent": [{"Email": ..., "MessageID": ...}, ...]}
# where only successful recipients are included
# or if the entire call has failed:
# {"ErrorCode": nnn, "Message": ...}
parsed_response = self.deserialize_json_response(response, payload, message)
if "ErrorCode" in parsed_response:
raise AnymailRequestsAPIError(email_message=message, payload=payload, response=response,
backend=self)
recipient_status = {}
try:
for key in parsed_response:
status = key.lower()
if status not in ANYMAIL_STATUSES:
status = 'unknown'
for item in parsed_response[key]:
message_id = str(item['MessageID'])
email = item['Email']
recipient_status[email] = AnymailRecipientStatus(message_id=message_id, status=status)
except (KeyError, TypeError):
raise AnymailRequestsAPIError("Invalid Mailjet API response format",
email_message=message, payload=payload, response=response,
backend=self)
# Make sure we ended up with a status for every original recipient
# (Mailjet only communicates "Sent")
for recipients in payload.recipients.values():
for email in recipients:
if email.addr_spec not in recipient_status:
recipient_status[email.addr_spec] = AnymailRecipientStatus(message_id=None, status='unknown')
return recipient_status
class MailjetPayload(RequestsPayload):
def __init__(self, message, defaults, backend, *args, **kwargs):
self.esp_extra = {} # late-bound in serialize_data
auth = (backend.api_key, backend.secret_key)
http_headers = {
'Content-Type': 'application/json',
}
# Late binding of recipients and their variables
self.recipients = {'to': []}
self.metadata = None
self.merge_data = {}
self.merge_metadata = {}
super(MailjetPayload, self).__init__(message, defaults, backend,
auth=auth, headers=http_headers, *args, **kwargs)
def get_api_endpoint(self):
return "send"
def serialize_data(self):
self._populate_sender_from_template()
if self.is_batch():
self.data = {'Messages': [
self._data_for_recipient(to_addr)
for to_addr in self.recipients['to']
]}
return self.serialize_json(self.data)
def _data_for_recipient(self, email):
# Return send data for single recipient, without modifying self.data
data = self.data.copy()
data['To'] = self._format_email_for_mailjet(email)
if email.addr_spec in self.merge_data:
recipient_merge_data = self.merge_data[email.addr_spec]
if 'Vars' in data:
data['Vars'] = data['Vars'].copy() # clone merge_global_data
data['Vars'].update(recipient_merge_data)
else:
data['Vars'] = recipient_merge_data
if email.addr_spec in self.merge_metadata:
recipient_metadata = self.merge_metadata[email.addr_spec]
if self.metadata:
metadata = self.metadata.copy() # clone toplevel metadata
metadata.update(recipient_metadata)
else:
metadata = recipient_metadata
data["Mj-EventPayLoad"] = self.serialize_json(metadata)
return data
def _populate_sender_from_template(self):
# If no From address was given, use the address from the template.
# Unfortunately, API 3.0 requires the From address to be given, so let's
# query it when needed. This will supposedly be fixed in 3.1 with a
# public beta in May 2017.
template_id = self.data.get("Mj-TemplateID")
if template_id and not self.data.get("FromEmail"):
response = self.backend.session.get(
"%sREST/template/%s/detailcontent" % (self.backend.api_url, quote(str(template_id), safe='')),
auth=self.auth, timeout=self.backend.timeout
)
self.backend.raise_for_status(response, None, self.message)
json_response = self.backend.deserialize_json_response(response, None, self.message)
# Populate email address header from template.
try:
headers = json_response["Data"][0]["Headers"]
if "From" in headers:
# Workaround Mailjet returning malformed From header
# if there's a comma in the template's From display-name:
from_email = headers["From"].replace(",", "||COMMA||")
parsed = parse_address_list([from_email])[0]
if parsed.display_name:
parsed = EmailAddress(parsed.display_name.replace("||COMMA||", ","),
parsed.addr_spec)
else:
parsed = EmailAddress(headers["SenderName"], headers["SenderEmail"])
except KeyError:
raise AnymailRequestsAPIError("Invalid Mailjet template API response",
email_message=self.message, response=response, backend=self.backend)
self.set_from_email(parsed)
def _format_email_for_mailjet(self, email):
"""Return EmailAddress email converted to a string that Mailjet can parse properly"""
# Workaround Mailjet 3.0 bug parsing display-name with commas
# (see test_comma_in_display_name in test_mailjet_backend for details)
if "," in email.display_name:
return EmailAddress(email.display_name.encode('utf-8'), email.addr_spec).formataddr('utf-8')
else:
return email.address
#
# Payload construction
#
def init_payload(self):
self.data = {}
def set_from_email(self, email):
self.data["FromEmail"] = email.addr_spec
if email.display_name:
self.data["FromName"] = email.display_name
def set_recipients(self, recipient_type, emails):
assert recipient_type in ["to", "cc", "bcc"]
if emails:
self.recipients[recipient_type] = emails # save for recipient_status processing
self.data[recipient_type.capitalize()] = ", ".join(
[self._format_email_for_mailjet(email) for email in emails])
def set_subject(self, subject):
self.data["Subject"] = subject
def set_reply_to(self, emails):
headers = self.data.setdefault("Headers", {})
if emails:
headers["Reply-To"] = ", ".join([str(email) for email in emails])
elif "Reply-To" in headers:
del headers["Reply-To"]
def set_extra_headers(self, headers):
self.data.setdefault("Headers", {}).update(headers)
def set_text_body(self, body):
self.data["Text-part"] = body
def set_html_body(self, body):
if "Html-part" 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-part"] = body
def add_attachment(self, attachment):
if attachment.inline:
field = "Inline_attachments"
name = attachment.cid
else:
field = "Attachments"
name = attachment.name or ""
self.data.setdefault(field, []).append({
"Content-type": attachment.mimetype,
"Filename": name,
"content": attachment.b64content
})
def set_envelope_sender(self, email):
self.data["Sender"] = email.addr_spec # ??? v3 docs unclear
def set_metadata(self, metadata):
self.data["Mj-EventPayLoad"] = self.serialize_json(metadata)
self.metadata = metadata # keep original in case we need to merge with merge_metadata
def set_tags(self, tags):
# The choices here are CustomID or Campaign, and Campaign seems closer
# to how "tags" are handled by other ESPs -- e.g., you can view dashboard
# statistics across all messages with the same Campaign.
if len(tags) > 0:
self.data["Tag"] = tags[0]
self.data["Mj-campaign"] = tags[0]
if len(tags) > 1:
self.unsupported_feature('multiple tags (%r)' % tags)
def set_track_clicks(self, track_clicks):
# 1 disables tracking, 2 enables tracking
self.data["Mj-trackclick"] = 2 if track_clicks else 1
def set_track_opens(self, track_opens):
# 1 disables tracking, 2 enables tracking
self.data["Mj-trackopen"] = 2 if track_opens else 1
def set_template_id(self, template_id):
self.data["Mj-TemplateID"] = template_id
self.data["Mj-TemplateLanguage"] = True
def set_merge_data(self, merge_data):
# Will be handled later in serialize_data
self.merge_data = merge_data
def set_merge_global_data(self, merge_global_data):
self.data["Vars"] = merge_global_data
def set_merge_metadata(self, merge_metadata):
# Will be handled later in serialize_data
self.merge_metadata = merge_metadata
def set_esp_extra(self, extra):
self.data.update(extra)
django-anymail-7.0.0/anymail/backends/test.py 0000644 0000765 0000024 00000011753 13434336005 022114 0 ustar medmunds staff 0000000 0000000 from django.core import mail
from .base import AnymailBaseBackend, BasePayload
from ..exceptions import AnymailAPIError
from ..message import AnymailRecipientStatus
class EmailBackend(AnymailBaseBackend):
"""
Anymail backend that simulates sending messages, useful for testing.
Sent messages are collected in django.core.mail.outbox (as with Django's locmem backend).
In addition:
* Anymail send params parsed from the message will be attached to the outbox message
as a dict in the attr `anymail_test_params`
* If the caller supplies an `anymail_test_response` attr on the message, that will be
used instead of the default "sent" response. It can be either an AnymailRecipientStatus
or an instance of AnymailAPIError (or a subclass) to raise an exception.
"""
esp_name = "Test"
def __init__(self, *args, **kwargs):
# Allow replacing the payload, for testing.
# (Real backends would generally not implement this option.)
self._payload_class = kwargs.pop('payload_class', TestPayload)
super(EmailBackend, self).__init__(*args, **kwargs)
if not hasattr(mail, 'outbox'):
mail.outbox = [] # see django.core.mail.backends.locmem
def get_esp_message_id(self, message):
# Get a unique ID for the message. The message must have been added to
# the outbox first.
return mail.outbox.index(message)
def build_message_payload(self, message, defaults):
return self._payload_class(backend=self, message=message, defaults=defaults)
def post_to_esp(self, payload, message):
# Keep track of the sent messages and params (for test cases)
message.anymail_test_params = payload.params
mail.outbox.append(message)
try:
# Tests can supply their own message.test_response:
response = message.anymail_test_response
if isinstance(response, AnymailAPIError):
raise response
except AttributeError:
# Default is to return 'sent' for each recipient
status = AnymailRecipientStatus(
message_id=self.get_esp_message_id(message),
status='sent'
)
response = {
'recipient_status': {email: status for email in payload.recipient_emails}
}
return response
def parse_recipient_status(self, response, payload, message):
try:
return response['recipient_status']
except KeyError:
raise AnymailAPIError('Unparsable test response')
class TestPayload(BasePayload):
# For test purposes, just keep a dict of the params we've received.
# (This approach is also useful for native API backends -- think of
# payload.params as collecting kwargs for esp_native_api.send().)
def init_payload(self):
self.params = {}
self.recipient_emails = []
def set_from_email(self, email):
self.params['from'] = email
def set_envelope_sender(self, email):
self.params['envelope_sender'] = email.addr_spec
def set_to(self, emails):
self.params['to'] = emails
self.recipient_emails += [email.addr_spec for email in emails]
def set_cc(self, emails):
self.params['cc'] = emails
self.recipient_emails += [email.addr_spec for email in emails]
def set_bcc(self, emails):
self.params['bcc'] = emails
self.recipient_emails += [email.addr_spec for email in emails]
def set_subject(self, subject):
self.params['subject'] = subject
def set_reply_to(self, emails):
self.params['reply_to'] = emails
def set_extra_headers(self, headers):
self.params['extra_headers'] = headers
def set_text_body(self, body):
self.params['text_body'] = body
def set_html_body(self, body):
self.params['html_body'] = body
def add_alternative(self, content, mimetype):
self.unsupported_feature("alternative part with type '%s'" % mimetype)
def add_attachment(self, attachment):
self.params.setdefault('attachments', []).append(attachment)
def set_metadata(self, metadata):
self.params['metadata'] = metadata
def set_send_at(self, send_at):
self.params['send_at'] = send_at
def set_tags(self, tags):
self.params['tags'] = 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):
self.params['template_id'] = template_id
def set_merge_data(self, merge_data):
self.params['merge_data'] = merge_data
def set_merge_metadata(self, merge_metadata):
self.params['merge_metadata'] = merge_metadata
def set_merge_global_data(self, merge_global_data):
self.params['merge_global_data'] = merge_global_data
def set_esp_extra(self, extra):
# Merge extra into params
self.params.update(extra)
django-anymail-7.0.0/anymail/backends/mailgun.py 0000644 0000765 0000024 00000043755 13534023154 022577 0 ustar medmunds staff 0000000 0000000 from datetime import datetime
from email.utils import encode_rfc2231
from six.moves.urllib.parse import quote
from requests import Request
from ..exceptions import AnymailRequestsAPIError, AnymailError
from ..message import AnymailRecipientStatus
from ..utils import get_anymail_setting, rfc2822date
from .base_requests import AnymailRequestsBackend, RequestsPayload
# Feature-detect whether requests (urllib3) correctly uses RFC 7578 encoding for non-
# ASCII filenames in Content-Disposition headers. (This was fixed in urllib3 v1.25.)
# See MailgunPayload.get_request_params for info (and a workaround on older versions).
# (Note: when this workaround is removed, please also remove the "old_urllib3" tox envs.)
def is_requests_rfc_5758_compliant():
request = Request(method='POST', url='https://www.example.com',
files=[('attachment', (u'\N{NOT SIGN}.txt', 'test', 'text/plain'))])
prepared = request.prepare()
form_data = prepared.body # bytes
return b'filename*=' not in form_data
REQUESTS_IS_RFC_7578_COMPLIANT = is_requests_rfc_5758_compliant()
class EmailBackend(AnymailRequestsBackend):
"""
Mailgun API Email Backend
"""
esp_name = "Mailgun"
def __init__(self, **kwargs):
"""Init options from Django settings"""
esp_name = self.esp_name
self.api_key = get_anymail_setting('api_key', esp_name=esp_name, kwargs=kwargs, allow_bare=True)
self.sender_domain = get_anymail_setting('sender_domain', esp_name=esp_name, kwargs=kwargs,
allow_bare=True, default=None)
api_url = get_anymail_setting('api_url', esp_name=esp_name, kwargs=kwargs,
default="https://api.mailgun.net/v3")
if not api_url.endswith("/"):
api_url += "/"
super(EmailBackend, self).__init__(api_url, **kwargs)
def build_message_payload(self, message, defaults):
return MailgunPayload(message, defaults, self)
def parse_recipient_status(self, response, payload, message):
# The *only* 200 response from Mailgun seems to be:
# {
# "id": "<20160306015544.116301.25145@example.org>",
# "message": "Queued. Thank you."
# }
#
# That single message id applies to all recipients.
# The only way to detect rejected, etc. is via webhooks.
# (*Any* invalid recipient addresses will generate a 400 API error)
parsed_response = self.deserialize_json_response(response, payload, message)
try:
message_id = parsed_response["id"]
mailgun_message = parsed_response["message"]
except (KeyError, TypeError):
raise AnymailRequestsAPIError("Invalid Mailgun API response format",
email_message=message, payload=payload, response=response,
backend=self)
if not mailgun_message.startswith("Queued"):
raise AnymailRequestsAPIError("Unrecognized Mailgun API message '%s'" % mailgun_message,
email_message=message, payload=payload, response=response,
backend=self)
# Simulate a per-recipient status of "queued":
status = AnymailRecipientStatus(message_id=message_id, status="queued")
return {recipient.addr_spec: status for recipient in payload.all_recipients}
class MailgunPayload(RequestsPayload):
def __init__(self, message, defaults, backend, *args, **kwargs):
auth = ("api", backend.api_key)
self.sender_domain = backend.sender_domain
self.all_recipients = [] # used for backend.parse_recipient_status
# late-binding of recipient-variables:
self.merge_data = {}
self.merge_global_data = {}
self.metadata = {}
self.merge_metadata = {}
self.to_emails = []
super(MailgunPayload, self).__init__(message, defaults, backend, auth=auth, *args, **kwargs)
def get_api_endpoint(self):
if self.sender_domain is None:
raise AnymailError("Cannot call Mailgun unknown sender domain. "
"Either provide valid `from_email`, "
"or set `message.esp_extra={'sender_domain': 'example.com'}`",
backend=self.backend, email_message=self.message, payload=self)
if '/' in self.sender_domain or '%2f' in self.sender_domain.lower():
# Mailgun returns a cryptic 200-OK "Mailgun Magnificent API" response
# if '/' (or even %-encoded '/') confuses it about the API endpoint.
raise AnymailError("Invalid '/' in sender domain '%s'" % self.sender_domain,
backend=self.backend, email_message=self.message, payload=self)
return "%s/messages" % quote(self.sender_domain, safe='')
def get_request_params(self, api_url):
params = super(MailgunPayload, self).get_request_params(api_url)
non_ascii_filenames = [filename
for (field, (filename, content, mimetype)) in params["files"]
if filename is not None and not isascii(filename)]
if non_ascii_filenames and not REQUESTS_IS_RFC_7578_COMPLIANT:
# Workaround https://github.com/requests/requests/issues/4652:
# Mailgun expects RFC 7578 compliant multipart/form-data, and is confused
# by Requests/urllib3's improper use of RFC 2231 encoded filename parameters
# ("filename*=utf-8''...") in Content-Disposition headers.
# The workaround is to pre-generate the (non-compliant) form-data body, and
# replace 'filename*={RFC 2231 encoded}' with 'filename="{UTF-8 bytes}"'.
# Replace _only_ the filenames that will be problems (not all "filename*=...")
# to minimize potential side effects--e.g., in attached messages that might
# have their own attachments with (correctly) RFC 2231 encoded filenames.
prepared = Request(**params).prepare()
form_data = prepared.body # bytes
for filename in non_ascii_filenames: # text
rfc2231_filename = encode_rfc2231( # wants a str (text in PY3, bytes in PY2)
filename if isinstance(filename, str) else filename.encode("utf-8"),
charset="utf-8")
form_data = form_data.replace(
b'filename*=' + rfc2231_filename.encode("utf-8"),
b'filename="' + filename.encode("utf-8") + b'"')
params["data"] = form_data
params["headers"]["Content-Type"] = prepared.headers["Content-Type"] # multipart/form-data; boundary=...
params["files"] = None # these are now in the form_data body
return params
def serialize_data(self):
self.populate_recipient_variables()
return self.data
# A not-so-brief digression about Mailgun's batch sending, template personalization,
# and metadata tracking capabilities...
#
# Mailgun has two kinds of templates:
# * ESP-stored templates (handlebars syntax), referenced by template name in the
# send API, with substitution data supplied as "custom data" variables.
# Anymail's `template_id` maps to this feature.
# * On-the-fly templating (`%recipient.KEY%` syntax), with template variables
# appearing directly in the message headers and/or body, and data supplied
# as "recipient variables" per-recipient personalizations. Mailgun docs also
# sometimes refer to this data as "template variables," but it's distinct from
# the substitution data used for stored handelbars templates.
#
# Mailgun has two mechanisms for supplying additional data with a message:
# * "Custom data" is supplied via `v:KEY` and/or `h:X-Mailgun-Variables` fields.
# Custom data is passed to tracking webhooks (as 'user-variables') and is
# available for `{{substitutions}}` in ESP-stored handlebars templates.
# Normally, the same custom data is applied to every recipient of a message.
# * "Recipient variables" are supplied via the `recipient-variables` field, and
# provide per-recipient data for batch sending. The recipient specific values
# are available as `%recipient.KEY%` virtually anywhere in the message
# (including header fields and other parameters).
#
# Anymail needs both mechanisms to map its normalized metadata and template merge_data
# features to Mailgun:
# (1) Anymail's `metadata` maps directly to Mailgun's custom data, where it can be
# accessed from webhooks.
# (2) Anymail's `merge_metadata` (per-recipient metadata for batch sends) maps
# *indirectly* through recipient-variables to Mailgun's custom data. To avoid
# conflicts, the recipient-variables mapping prepends 'v:' to merge_metadata keys.
# (E.g., Mailgun's custom-data "user" is set to "%recipient.v:user", which picks
# up its per-recipient value from Mailgun's `recipient-variables[to_email]["v:user"]`.)
# (3) Anymail's `merge_data` (per-recipient template substitutions) maps directly to
# Mailgun's `recipient-variables`, where it can be referenced in on-the-fly templates.
# (4) Anymail's `merge_global_data` (global template substitutions) is copied to
# Mailgun's `recipient-variables` for every recipient, as the default for missing
# `merge_data` keys.
# (5) Only if a stored template is used, `merge_data` and `merge_global_data` are
# *also* mapped *indirectly* through recipient-variables to Mailgun's custom data,
# where they can be referenced in handlebars {{substitutions}}.
# (E.g., Mailgun's custom-data "name" is set to "%recipient.name%", which picks
# up its per-recipient value from Mailgun's `recipient-variables[to_email]["name"]`.)
#
# If Anymail's `merge_data`, `template_id` (stored templates) and `metadata` (or
# `merge_metadata`) are used together, there's a possibility of conflicting keys in
# Mailgun's custom data. Anymail treats that conflict as an unsupported feature error.
def populate_recipient_variables(self):
"""Populate Mailgun recipient-variables and custom data from merge data and metadata"""
# (numbers refer to detailed explanation above)
# Mailgun parameters to construct:
recipient_variables = {}
custom_data = {}
# (1) metadata --> Mailgun custom_data
custom_data.update(self.metadata)
# (2) merge_metadata --> Mailgun custom_data via recipient_variables
if self.merge_metadata:
def vkey(key): # 'v:key'
return 'v:{}'.format(key)
merge_metadata_keys = flatset( # all keys used in any recipient's merge_metadata
recipient_data.keys() for recipient_data in self.merge_metadata.values())
custom_data.update({ # custom_data['key'] = '%recipient.v:key%' indirection
key: '%recipient.{}%'.format(vkey(key))
for key in merge_metadata_keys})
base_recipient_data = { # defaults for each recipient must cover all keys
vkey(key): self.metadata.get(key, '')
for key in merge_metadata_keys}
for email in self.to_emails:
this_recipient_data = base_recipient_data.copy()
this_recipient_data.update({
vkey(key): value
for key, value in self.merge_metadata.get(email, {}).items()})
recipient_variables.setdefault(email, {}).update(this_recipient_data)
# (3) and (4) merge_data, merge_global_data --> Mailgun recipient_variables
if self.merge_data or self.merge_global_data:
merge_data_keys = flatset( # all keys used in any recipient's merge_data
recipient_data.keys() for recipient_data in self.merge_data.values())
merge_data_keys = merge_data_keys.union(self.merge_global_data.keys())
base_recipient_data = { # defaults for each recipient must cover all keys
key: self.merge_global_data.get(key, '')
for key in merge_data_keys}
for email in self.to_emails:
this_recipient_data = base_recipient_data.copy()
this_recipient_data.update(self.merge_data.get(email, {}))
recipient_variables.setdefault(email, {}).update(this_recipient_data)
# (5) if template, also map Mailgun custom_data to per-recipient_variables
if self.data.get('template') is not None:
conflicts = merge_data_keys.intersection(custom_data.keys())
if conflicts:
self.unsupported_feature(
"conflicting merge_data and metadata keys (%s) when using template_id"
% ', '.join("'%s'" % key for key in conflicts))
custom_data.update({ # custom_data['key'] = '%recipient.key%' indirection
key: '%recipient.{}%'.format(key)
for key in merge_data_keys})
# populate Mailgun params
self.data.update({'v:%s' % key: value
for key, value in custom_data.items()})
if recipient_variables or self.is_batch():
self.data['recipient-variables'] = self.serialize_json(recipient_variables)
#
# Payload construction
#
def init_payload(self):
self.data = {} # {field: [multiple, values]}
self.files = [] # [(field, multiple), (field, values)]
self.headers = {}
def set_from_email_list(self, emails):
# Mailgun supports multiple From email addresses
self.data["from"] = [email.address for email in emails]
if self.sender_domain is None and len(emails) > 0:
# try to intuit sender_domain from first from_email
self.sender_domain = emails[0].domain or None
def set_recipients(self, recipient_type, emails):
assert recipient_type in ["to", "cc", "bcc"]
if emails:
self.data[recipient_type] = [email.address for email in emails]
self.all_recipients += emails # used for backend.parse_recipient_status
if recipient_type == 'to':
self.to_emails = [email.addr_spec for email in emails] # used for populate_recipient_variables
def set_subject(self, subject):
self.data["subject"] = subject
def set_reply_to(self, emails):
if emails:
reply_to = ", ".join([str(email) for email in emails])
self.data["h:Reply-To"] = reply_to
def set_extra_headers(self, headers):
for key, value in headers.items():
self.data["h:%s" % key] = value
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):
# http://docs.python-requests.org/en/v2.4.3/user/advanced/#post-multiple-multipart-encoded-files
if attachment.inline:
field = "inline"
name = attachment.cid
if not name:
self.unsupported_feature("inline attachments without Content-ID")
else:
field = "attachment"
name = attachment.name
if not name:
self.unsupported_feature("attachments without filenames")
self.files.append(
(field, (name, attachment.content, attachment.mimetype))
)
def set_envelope_sender(self, email):
# Only the domain is used
self.sender_domain = email.domain
def set_metadata(self, metadata):
self.metadata = metadata # save for handling merge_metadata later
for key, value in metadata.items():
self.data["v:%s" % key] = value
def set_send_at(self, send_at):
# Mailgun expects RFC-2822 format dates
# (BasePayload has converted most date-like values to datetime by now;
# if the caller passes a string, they'll need to format it themselves.)
if isinstance(send_at, datetime):
send_at = rfc2822date(send_at)
self.data["o:deliverytime"] = send_at
def set_tags(self, tags):
self.data["o:tag"] = tags
def set_track_clicks(self, track_clicks):
# Mailgun also supports an "htmlonly" option, which Anymail doesn't offer
self.data["o:tracking-clicks"] = "yes" if track_clicks else "no"
def set_track_opens(self, track_opens):
self.data["o:tracking-opens"] = "yes" if track_opens else "no"
def set_template_id(self, template_id):
self.data["template"] = template_id
def set_merge_data(self, merge_data):
# Processed at serialization time (to allow merging global data)
self.merge_data = merge_data
def set_merge_global_data(self, merge_global_data):
# Processed at serialization time (to allow merging global data)
self.merge_global_data = merge_global_data
def set_merge_metadata(self, merge_metadata):
# Processed at serialization time (to allow combining with merge_data)
self.merge_metadata = merge_metadata
def set_esp_extra(self, extra):
self.data.update(extra)
# Allow override of sender_domain via esp_extra
# (but pop it out of params to send to Mailgun)
self.sender_domain = self.data.pop("sender_domain", self.sender_domain)
def isascii(s):
"""Returns True if str s is entirely ASCII characters.
(Compare to Python 3.7 `str.isascii()`.)
"""
try:
s.encode("ascii")
except UnicodeEncodeError:
return False
return True
def flatset(iterables):
"""Return a set of the items in a single-level flattening of iterables
>>> flatset([1, 2], [2, 3])
set(1, 2, 3)
"""
return set(item for iterable in iterables for item in iterable)
django-anymail-7.0.0/anymail/backends/sendgrid.py 0000644 0000765 0000024 00000040660 13434344345 022741 0 ustar medmunds staff 0000000 0000000 import uuid
import warnings
from email.utils import quote as rfc822_quote
from requests.structures import CaseInsensitiveDict
from .base_requests import AnymailRequestsBackend, RequestsPayload
from ..exceptions import AnymailConfigurationError, AnymailRequestsAPIError, AnymailWarning
from ..message import AnymailRecipientStatus
from ..utils import BASIC_NUMERIC_TYPES, Mapping, get_anymail_setting, timestamp, update_deep
class EmailBackend(AnymailRequestsBackend):
"""
SendGrid v3 API Email Backend
"""
esp_name = "SendGrid"
def __init__(self, **kwargs):
"""Init options from Django settings"""
esp_name = self.esp_name
# Warn if v2-only username or password settings found
username = get_anymail_setting('username', esp_name=esp_name, kwargs=kwargs, default=None, allow_bare=True)
password = get_anymail_setting('password', esp_name=esp_name, kwargs=kwargs, default=None, allow_bare=True)
if username or password:
raise AnymailConfigurationError(
"SendGrid v3 API doesn't support username/password auth; Please change to API key.")
self.api_key = get_anymail_setting('api_key', esp_name=esp_name, kwargs=kwargs, allow_bare=True)
self.generate_message_id = get_anymail_setting('generate_message_id', esp_name=esp_name,
kwargs=kwargs, default=True)
self.merge_field_format = get_anymail_setting('merge_field_format', esp_name=esp_name,
kwargs=kwargs, default=None)
# Undocumented setting to disable workaround for SendGrid display-name quoting bug (see below).
# If/when SendGrid fixes their API, recipient names will end up with extra double quotes
# until Anymail is updated to remove the workaround. In the meantime, you can disable it
# by adding `"SENDGRID_WORKAROUND_NAME_QUOTE_BUG": False` to your `ANYMAIL` settings.
self.workaround_name_quote_bug = get_anymail_setting('workaround_name_quote_bug', esp_name=esp_name,
kwargs=kwargs, default=True)
# This is SendGrid's newer Web API v3
api_url = get_anymail_setting('api_url', esp_name=esp_name, kwargs=kwargs,
default="https://api.sendgrid.com/v3/")
if not api_url.endswith("/"):
api_url += "/"
super(EmailBackend, self).__init__(api_url, **kwargs)
def build_message_payload(self, message, defaults):
return SendGridPayload(message, defaults, self)
def raise_for_status(self, response, payload, message):
if response.status_code < 200 or response.status_code >= 300:
raise AnymailRequestsAPIError(email_message=message, payload=payload, response=response,
backend=self)
def parse_recipient_status(self, response, payload, message):
# If we get here, the send call was successful.
# (SendGrid uses a non-2xx response for any failures, caught in raise_for_status.)
# SendGrid v3 doesn't provide any information in the response for a successful send,
# so simulate a per-recipient status of "queued":
return {recip.addr_spec: AnymailRecipientStatus(message_id=payload.message_ids.get(recip.addr_spec),
status="queued")
for recip 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.generate_message_id = backend.generate_message_id
self.workaround_name_quote_bug = backend.workaround_name_quote_bug
self.use_dynamic_template = False # how to represent merge_data
self.message_ids = {} # recipient -> generated message_id mapping
self.merge_field_format = backend.merge_field_format
self.merge_data = {} # late-bound per-recipient data
self.merge_global_data = {}
self.merge_metadata = {}
http_headers = kwargs.pop('headers', {})
http_headers['Authorization'] = 'Bearer %s' % backend.api_key
http_headers['Content-Type'] = 'application/json'
http_headers['Accept'] = 'application/json'
super(SendGridPayload, self).__init__(message, defaults, backend,
headers=http_headers,
*args, **kwargs)
def get_api_endpoint(self):
return "mail/send"
def init_payload(self):
self.data = { # becomes json
"personalizations": [{}],
"headers": CaseInsensitiveDict(),
}
def serialize_data(self):
"""Performs any necessary serialization on self.data, and returns the result."""
if self.is_batch():
self.expand_personalizations_for_batch()
self.build_merge_data()
self.build_merge_metadata()
if self.generate_message_id:
self.set_anymail_id()
if not self.data["headers"]:
del self.data["headers"] # don't send empty headers
return self.serialize_json(self.data)
def set_anymail_id(self):
"""Ensure each personalization has a known anymail_id for later event tracking"""
for personalization in self.data["personalizations"]:
message_id = str(uuid.uuid4())
personalization.setdefault("custom_args", {})["anymail_id"] = message_id
for recipient in personalization["to"] + personalization.get("cc", []) + personalization.get("bcc", []):
self.message_ids[recipient["email"]] = message_id
def expand_personalizations_for_batch(self):
"""Split data["personalizations"] into individual message for each recipient"""
assert len(self.data["personalizations"]) == 1
base_personalization = self.data["personalizations"].pop()
to_list = base_personalization.pop("to") # {email, name?} for each message.to
for recipient in to_list:
personalization = base_personalization.copy()
personalization["to"] = [recipient]
self.data["personalizations"].append(personalization)
def build_merge_data(self):
if self.merge_data or self.merge_global_data:
# Always build dynamic_template_data first,
# then convert it to legacy template format if needed
for personalization in self.data["personalizations"]:
assert len(personalization["to"]) == 1
recipient_email = personalization["to"][0]["email"]
dynamic_template_data = self.merge_global_data.copy()
dynamic_template_data.update(self.merge_data.get(recipient_email, {}))
if dynamic_template_data:
personalization["dynamic_template_data"] = dynamic_template_data
if not self.use_dynamic_template:
self.convert_dynamic_template_data_to_legacy_substitutions()
def convert_dynamic_template_data_to_legacy_substitutions(self):
"""Change personalizations[...]['dynamic_template_data'] to ...['substitutions]"""
merge_field_format = self.merge_field_format or '{}'
all_merge_fields = set()
for personalization in self.data["personalizations"]:
try:
dynamic_template_data = personalization.pop("dynamic_template_data")
except KeyError:
pass # no substitutions for this recipient
else:
# Convert dynamic_template_data keys for substitutions, using merge_field_format
personalization["substitutions"] = {
merge_field_format.format(field): data
for field, data in dynamic_template_data.items()}
all_merge_fields.update(dynamic_template_data.keys())
if self.merge_field_format is None:
if all_merge_fields and all(field.isalnum() for field in all_merge_fields):
warnings.warn(
"Your SendGrid merge fields don't seem to have delimiters, "
"which can cause unexpected results with Anymail's merge_data. "
"Search SENDGRID_MERGE_FIELD_FORMAT in the Anymail docs for more info.",
AnymailWarning)
if self.merge_global_data and all(field.isalnum() for field in self.merge_global_data.keys()):
warnings.warn(
"Your SendGrid global merge fields don't seem to have delimiters, "
"which can cause unexpected results with Anymail's merge_data. "
"Search SENDGRID_MERGE_FIELD_FORMAT in the Anymail docs for more info.",
AnymailWarning)
def build_merge_metadata(self):
if self.merge_metadata:
for personalization in self.data["personalizations"]:
assert len(personalization["to"]) == 1
recipient_email = personalization["to"][0]["email"]
recipient_metadata = self.merge_metadata.get(recipient_email)
if recipient_metadata:
recipient_custom_args = self.transform_metadata(recipient_metadata)
personalization["custom_args"] = recipient_custom_args
#
# Payload construction
#
@staticmethod
def email_object(email, workaround_name_quote_bug=False):
"""Converts EmailAddress to SendGrid API {email, name} dict"""
obj = {"email": email.addr_spec}
if email.display_name:
# Work around SendGrid API bug: v3 fails to properly quote display-names
# containing commas or semicolons in personalizations (but not in from_email
# or reply_to). See https://github.com/sendgrid/sendgrid-python/issues/291.
# We can work around the problem by quoting the name for SendGrid.
if workaround_name_quote_bug:
obj["name"] = '"%s"' % rfc822_quote(email.display_name)
else:
obj["name"] = email.display_name
return obj
def set_from_email(self, email):
self.data["from"] = self.email_object(email)
def set_recipients(self, recipient_type, emails):
assert recipient_type in ["to", "cc", "bcc"]
if emails:
workaround_name_quote_bug = self.workaround_name_quote_bug
# Normally, exactly one "personalizations" entry for all recipients
# (Exception: with merge_data; will be burst apart later.)
self.data["personalizations"][0][recipient_type] = \
[self.email_object(email, workaround_name_quote_bug) for email in emails]
self.all_recipients += emails # used for backend.parse_recipient_status
def set_subject(self, subject):
if subject != "": # see note in set_text_body about template rendering
self.data["subject"] = subject
def set_reply_to(self, emails):
# SendGrid only supports a single address in the reply_to API param.
if len(emails) > 1:
self.unsupported_feature("multiple reply_to addresses")
if len(emails) > 0:
self.data["reply_to"] = self.email_object(emails[0])
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.
self.data["headers"].update({
k: str(v) if isinstance(v, BASIC_NUMERIC_TYPES) else v
for k, v in headers.items()
})
def set_text_body(self, body):
# Empty strings (the EmailMessage default) can cause unexpected SendGrid
# template rendering behavior, such as ignoring the HTML template and
# rendering HTML from the plaintext template instead.
# Treat an empty string as a request to omit the body
# (which means use the template content if present.)
if body != "":
self.data.setdefault("content", []).append({
"type": "text/plain",
"value": body,
})
def set_html_body(self, body):
# SendGrid's API permits multiple html bodies
# "If you choose to include the text/plain or text/html mime types, they must be
# the first indices of the content array in the order text/plain, text/html."
if body != "": # see note in set_text_body about template rendering
self.data.setdefault("content", []).append({
"type": "text/html",
"value": body,
})
def add_alternative(self, content, mimetype):
# SendGrid is one of the few ESPs that supports arbitrary alternative parts in their API
self.data.setdefault("content", []).append({
"type": mimetype,
"value": content,
})
def add_attachment(self, attachment):
att = {
"content": attachment.b64content,
"type": attachment.mimetype,
"filename": attachment.name or '', # required -- submit empty string if unknown
}
if attachment.inline:
att["disposition"] = "inline"
att["content_id"] = attachment.cid
self.data.setdefault("attachments", []).append(att)
def set_metadata(self, metadata):
self.data["custom_args"] = self.transform_metadata(metadata)
def transform_metadata(self, metadata):
# SendGrid requires custom_args values to be strings -- not integers.
# (And issues the cryptic error {"field": null, "message": "Bad Request", "help": null}
# if they're not.)
# We'll stringify ints and floats; anything else is the caller's responsibility.
return {
k: str(v) if isinstance(v, BASIC_NUMERIC_TYPES) else v
for k, v in metadata.items()
}
def set_send_at(self, send_at):
# Backend has converted pretty much everything to
# a datetime by here; SendGrid expects unix timestamp
self.data["send_at"] = int(timestamp(send_at)) # strip microseconds
def set_tags(self, tags):
self.data["categories"] = tags
def set_track_clicks(self, track_clicks):
self.data.setdefault("tracking_settings", {})["click_tracking"] = {
"enable": track_clicks,
}
def set_track_opens(self, track_opens):
# SendGrid's open_tracking setting also supports a "substitution_tag" parameter,
# which Anymail doesn't offer directly. (You could add it through esp_extra.)
self.data.setdefault("tracking_settings", {})["open_tracking"] = {
"enable": track_opens,
}
def set_template_id(self, template_id):
self.data["template_id"] = template_id
try:
self.use_dynamic_template = template_id.startswith("d-")
except AttributeError:
pass
def set_merge_data(self, merge_data):
# Becomes personalizations[...]['dynamic_template_data']
# or personalizations[...]['substitutions'] in build_merge_data,
# after we know recipients, template type, and merge_field_format.
self.merge_data = merge_data
def set_merge_global_data(self, merge_global_data):
# Becomes personalizations[...]['dynamic_template_data']
# or data['section'] in build_merge_data, after we know
# template type and merge_field_format.
self.merge_global_data = merge_global_data
def set_merge_metadata(self, merge_metadata):
# Becomes personalizations[...]['custom_args'] in
# build_merge_data, after we know recipients, template type,
# and merge_field_format.
self.merge_metadata = merge_metadata
def set_esp_extra(self, extra):
self.merge_field_format = extra.pop("merge_field_format", self.merge_field_format)
self.use_dynamic_template = extra.pop("use_dynamic_template", self.use_dynamic_template)
if isinstance(extra.get("personalizations", None), Mapping):
# merge personalizations *dict* into other message personalizations
assert len(self.data["personalizations"]) == 1
self.data["personalizations"][0].update(extra.pop("personalizations"))
if "x-smtpapi" in extra:
raise AnymailConfigurationError(
"You are attempting to use SendGrid v2 API-style x-smtpapi params "
"with the SendGrid v3 API. Please update your `esp_extra` to the new API."
)
update_deep(self.data, extra)
django-anymail-7.0.0/anymail/backends/mandrill.py 0000644 0000765 0000024 00000031464 13434336005 022740 0 ustar medmunds staff 0000000 0000000 import warnings
from datetime import datetime
from ..exceptions import AnymailRequestsAPIError, AnymailWarning
from ..message import AnymailRecipientStatus, ANYMAIL_STATUSES
from ..utils import last, combine, get_anymail_setting
from .base_requests import AnymailRequestsBackend, RequestsPayload
class EmailBackend(AnymailRequestsBackend):
"""
Mandrill API Email Backend
"""
esp_name = "Mandrill"
def __init__(self, **kwargs):
"""Init options from Django settings"""
esp_name = self.esp_name
self.api_key = get_anymail_setting('api_key', esp_name=esp_name, kwargs=kwargs, allow_bare=True)
api_url = get_anymail_setting('api_url', esp_name=esp_name, kwargs=kwargs,
default="https://mandrillapp.com/api/1.0")
if not api_url.endswith("/"):
api_url += "/"
super(EmailBackend, self).__init__(api_url, **kwargs)
def build_message_payload(self, message, defaults):
return MandrillPayload(message, defaults, self)
def parse_recipient_status(self, response, payload, message):
parsed_response = self.deserialize_json_response(response, payload, message)
recipient_status = {}
try:
# Mandrill returns a list of { email, status, _id, reject_reason } for each recipient
for item in parsed_response:
email = item['email']
status = item['status']
if status not in ANYMAIL_STATUSES:
status = 'unknown'
message_id = item.get('_id', None) # can be missing for invalid/rejected recipients
recipient_status[email] = AnymailRecipientStatus(message_id=message_id, status=status)
except (KeyError, TypeError):
raise AnymailRequestsAPIError("Invalid Mandrill API response format",
email_message=message, payload=payload, response=response,
backend=self)
return recipient_status
class DjrillDeprecationWarning(AnymailWarning, DeprecationWarning):
"""Warning for features carried over from Djrill that will be removed soon"""
def encode_date_for_mandrill(dt):
"""Format a datetime for use as a Mandrill API date field
Mandrill expects "YYYY-MM-DD HH:MM:SS" in UTC
"""
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(' ')
else:
return dt
class MandrillPayload(RequestsPayload):
def __init__(self, *args, **kwargs):
self.esp_extra = {} # late-bound in serialize_data
super(MandrillPayload, self).__init__(*args, **kwargs)
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):
self.process_esp_extra()
if self.is_batch():
# hide recipients from each other
self.data['message']['preserve_recipients'] = False
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 getattr(self.message, "use_template_from", False):
self.deprecation_warning('message.use_template_from', 'message.from_email = None')
else:
self.data["message"]["from_email"] = email.addr_spec
if email.display_name:
self.data["message"]["from_name"] = email.display_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.addr_spec, "name": email.display_name, "type": recipient_type})
def set_subject(self, subject):
if getattr(self.message, "use_template_subject", False):
self.deprecation_warning('message.use_template_subject', 'message.subject = None')
else:
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):
if "html" in self.data["message"]:
# second html body could show up through multiple alternatives, or html body + alternative
self.unsupported_feature("multiple html parts")
self.data["message"]["html"] = body
def add_attachment(self, attachment):
if attachment.inline:
field = "images"
name = attachment.cid
else:
field = "attachments"
name = attachment.name or ""
self.data["message"].setdefault(field, []).append({
"type": attachment.mimetype,
"name": name,
"content": attachment.b64content
})
def set_envelope_sender(self, email):
# Only the domain is used
self.data["message"]["return_path_domain"] = email.domain
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_template_id(self, template_id):
self.data["template_name"] = template_id
self.data.setdefault("template_content", []) # Mandrill requires something here
def set_merge_data(self, merge_data):
self.data['message']['merge_vars'] = [
{'rcpt': rcpt, 'vars': [{'name': key, 'content': rcpt_data[key]}
for key in sorted(rcpt_data.keys())]} # sort for testing reproducibility
for rcpt, rcpt_data in merge_data.items()
]
def set_merge_global_data(self, merge_global_data):
self.data['message']['global_merge_vars'] = [
{'name': var, 'content': value}
for var, value in merge_global_data.items()
]
def set_merge_metadata(self, merge_metadata):
# recipient_metadata format is similar to, but not quite the same as, merge_vars:
self.data['message']['recipient_metadata'] = [
{'rcpt': rcpt, 'values': rcpt_data}
for rcpt, rcpt_data in merge_metadata.items()
]
def set_esp_extra(self, extra):
# late bind in serialize_data, so that obsolete Djrill attrs can contribute
self.esp_extra = extra
def process_esp_extra(self):
if self.esp_extra is not None and len(self.esp_extra) > 0:
esp_extra = self.esp_extra
# Convert pythonic template_content dict to Mandrill name/content list
try:
template_content = esp_extra['template_content']
except KeyError:
pass
else:
if hasattr(template_content, 'items'): # if it's dict-like
if esp_extra is self.esp_extra:
esp_extra = self.esp_extra.copy() # don't modify caller's value
esp_extra['template_content'] = [
{'name': var, 'content': value}
for var, value in template_content.items()]
# Convert pythonic recipient_metadata dict to Mandrill rcpt/values list
try:
recipient_metadata = esp_extra['message']['recipient_metadata']
except KeyError:
pass
else:
if hasattr(recipient_metadata, 'keys'): # if it's dict-like
if esp_extra['message'] is self.esp_extra['message']:
esp_extra['message'] = self.esp_extra['message'].copy() # don't modify caller's value
# For testing reproducibility, we sort the recipients
esp_extra['message']['recipient_metadata'] = [
{'rcpt': rcpt, 'values': recipient_metadata[rcpt]}
for rcpt in sorted(recipient_metadata.keys())]
# Merge esp_extra with payload data: shallow merge within ['message'] and top-level keys
self.data.update({k: v for k, v in esp_extra.items() if k != 'message'})
try:
self.data['message'].update(esp_extra['message'])
except KeyError:
pass
# Djrill deprecated message attrs
def deprecation_warning(self, feature, replacement=None):
msg = "Djrill's `%s` will be removed in an upcoming Anymail release." % feature
if replacement:
msg += " Use `%s` instead." % replacement
warnings.warn(msg, DjrillDeprecationWarning)
def deprecated_to_esp_extra(self, attr, in_message_dict=False):
feature = "message.%s" % attr
if in_message_dict:
replacement = "message.esp_extra = {'message': {'%s': }}" % attr
else:
replacement = "message.esp_extra = {'%s': }" % attr
self.deprecation_warning(feature, replacement)
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, None),
('merge_vars', combine, None),
('recipient_metadata', combine, None),
('template_name', last, None),
('template_content', combine, None),
)
def set_async(self, is_async):
self.deprecated_to_esp_extra('async')
self.esp_extra['async'] = is_async
def set_ip_pool(self, ip_pool):
self.deprecated_to_esp_extra('ip_pool')
self.esp_extra['ip_pool'] = ip_pool
def set_global_merge_vars(self, global_merge_vars):
self.deprecation_warning('message.global_merge_vars', 'message.merge_global_data')
self.set_merge_global_data(global_merge_vars)
def set_merge_vars(self, merge_vars):
self.deprecation_warning('message.merge_vars', 'message.merge_data')
self.set_merge_data(merge_vars)
def set_return_path_domain(self, domain):
self.deprecation_warning('message.return_path_domain', 'message.envelope_sender')
self.esp_extra.setdefault('message', {})['return_path_domain'] = domain
def set_template_name(self, template_name):
self.deprecation_warning('message.template_name', 'message.template_id')
self.set_template_id(template_name)
def set_template_content(self, template_content):
self.deprecated_to_esp_extra('template_content')
self.esp_extra['template_content'] = template_content
def set_recipient_metadata(self, recipient_metadata):
self.deprecated_to_esp_extra('recipient_metadata', in_message_dict=True)
self.esp_extra.setdefault('message', {})['recipient_metadata'] = recipient_metadata
# Set up simple set_ functions for any missing esp_message_attrs attrs
# (avoids dozens of simple `self.data["message"][] = 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.deprecated_to_esp_extra(attr, in_message_dict=True)
self.esp_extra.setdefault('message', {})[attr] = value
setter.__name__ = setter_name
return setter
MandrillPayload.define_message_attr_setters()
django-anymail-7.0.0/anymail/backends/sparkpost.py 0000644 0000765 0000024 00000025103 13434336005 023155 0 ustar medmunds staff 0000000 0000000 from __future__ import absolute_import # we want the sparkpost package, not our own module
from .base import AnymailBaseBackend, BasePayload
from ..exceptions import AnymailAPIError, AnymailImproperlyInstalled, AnymailConfigurationError
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)
# SPARKPOST_API_URL is optional - default is set by library;
# if provided, must be a full SparkPost API endpoint, including "/v1" if appropriate
api_url = get_anymail_setting('api_url', esp_name=self.esp_name, kwargs=kwargs, default=None)
extra_sparkpost_params = {}
if api_url is not None:
if api_url.endswith("/"):
api_url = api_url[:-1]
extra_sparkpost_params['base_uri'] = _FullSparkPostEndpoint(api_url)
try:
self.sp = SparkPost(self.api_key, **extra_sparkpost_params) # 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.addr_spec: recipient_status for recipient in payload.all_recipients}
class SparkPostPayload(BasePayload):
def init_payload(self):
self.params = {}
self.all_recipients = []
self.to_emails = []
self.merge_data = {}
self.merge_metadata = {}
def get_api_params(self):
# Compose recipients param from to_emails and merge_data (if any)
recipients = []
if self.is_batch():
# Build JSON recipient structures
for email in self.to_emails:
rcpt = {'address': {'email': email.addr_spec}}
if email.display_name:
rcpt['address']['name'] = email.display_name
try:
rcpt['substitution_data'] = self.merge_data[email.addr_spec]
except KeyError:
pass # no merge_data or none for this recipient
try:
rcpt['metadata'] = self.merge_metadata[email.addr_spec]
except KeyError:
pass # no merge_metadata 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'] = dict(headers) # convert CaseInsensitiveDict to plain dict for SP lib
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_envelope_sender(self, email):
self.params['return_path'] = email.addr_spec
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_metadata(self, merge_metadata):
self.merge_metadata = merge_metadata # 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)
class _FullSparkPostEndpoint(str):
"""A string-like object that allows using a complete SparkPost API endpoint url as base_uri:
sp = SparkPost(api_key, base_uri=_FullSparkPostEndpoint('https://api.sparkpost.com/api/labs'))
Works around SparkPost.__init__ code `self.base_uri = base_uri + '/api/v' + version`,
which makes it difficult to simply copy and paste full API endpoints from SparkPost's docs
(https://developers.sparkpost.com/api/index.html#header-api-endpoints) -- and completely
prevents using the labs API endpoint (which has no "v" in it).
Should work with all python-sparkpost releases through at least v1.3.6.
"""
_expect = ['/api/v', '1'] # ignore attempts to concatenate these with me (in order)
def __add__(self, other):
expected = self._expect[0]
self._expect = self._expect[1:] # (makes a copy for this instance)
if other == expected:
# ignore this operation
if self._expect:
return self
else:
return str(self) # my work is done; just be a normal str now
else:
# something changed in python-sparkpost; please open an Anymail issue to fix
raise ValueError(
"This version of Anymail is not compatible with this version of python-sparkpost.\n"
"(_FullSparkPostEndpoint(%r) expected %r but got %r)" % (self, expected, other))
django-anymail-7.0.0/anymail/backends/amazon_ses.py 0000644 0000765 0000024 00000044255 13334670507 023306 0 ustar medmunds staff 0000000 0000000 from email.charset import Charset, QP
from email.header import Header
from email.mime.base import MIMEBase
from email.mime.text import MIMEText
from django.core.mail import BadHeaderError
from .base import AnymailBaseBackend, BasePayload
from .._version import __version__
from ..exceptions import AnymailAPIError, AnymailImproperlyInstalled
from ..message import AnymailRecipientStatus
from ..utils import get_anymail_setting, UNSET
try:
import boto3
from botocore.client import Config
from botocore.exceptions import BotoCoreError, ClientError, ConnectionError
except ImportError:
raise AnymailImproperlyInstalled(missing_package='boto3', backend='amazon_ses')
# boto3 has several root exception classes; this is meant to cover all of them
BOTO_BASE_ERRORS = (BotoCoreError, ClientError, ConnectionError)
# Work around Python 2 bug in email.message.Message.to_string, where long headers
# containing commas or semicolons get an extra space inserted after every ',' or ';'
# not already followed by a space. https://bugs.python.org/issue25257
if Header("test,Python2,header,comma,bug", maxlinelen=20).encode() == "test,Python2,header,comma,bug":
# no workaround needed
HeaderBugWorkaround = None
def add_header(message, name, val):
message[name] = val
else:
# workaround: custom Header subclass that won't consider ',' and ';' as folding candidates
class HeaderBugWorkaround(Header):
def encode(self, splitchars=' ', **kwargs): # only split on spaces, rather than splitchars=';, '
return Header.encode(self, splitchars, **kwargs)
def add_header(message, name, val):
# Must bypass Django's SafeMIMEMessage.__set_item__, because its call to
# forbid_multi_line_headers converts the val back to a str, undoing this
# workaround. That makes this code responsible for sanitizing val:
if '\n' in val or '\r' in val:
raise BadHeaderError("Header values can't contain newlines (got %r for header %r)" % (val, name))
val = HeaderBugWorkaround(val, header_name=name)
assert isinstance(message, MIMEBase)
MIMEBase.__setitem__(message, name, val)
class EmailBackend(AnymailBaseBackend):
"""
Amazon SES Email Backend (using boto3)
"""
esp_name = "Amazon SES"
def __init__(self, **kwargs):
"""Init options from Django settings"""
super(EmailBackend, self).__init__(**kwargs)
# AMAZON_SES_CLIENT_PARAMS is optional - boto3 can find credentials several other ways
self.session_params, self.client_params = _get_anymail_boto3_params(kwargs=kwargs)
self.configuration_set_name = get_anymail_setting("configuration_set_name", esp_name=self.esp_name,
kwargs=kwargs, allow_bare=False, default=None)
self.message_tag_name = get_anymail_setting("message_tag_name", esp_name=self.esp_name,
kwargs=kwargs, allow_bare=False, default=None)
self.client = None
def open(self):
if self.client:
return False # already exists
try:
self.client = boto3.session.Session(**self.session_params).client("ses", **self.client_params)
except BOTO_BASE_ERRORS:
if not self.fail_silently:
raise
def close(self):
if self.client is None:
return
# self.client.close() # boto3 doesn't currently seem to support (or require) this
self.client = None
def build_message_payload(self, message, defaults):
# The SES SendRawEmail and SendBulkTemplatedEmail calls have
# very different signatures, so use a custom payload for each
if getattr(message, "template_id", UNSET) is not UNSET:
return AmazonSESSendBulkTemplatedEmailPayload(message, defaults, self)
else:
return AmazonSESSendRawEmailPayload(message, defaults, self)
def post_to_esp(self, payload, message):
try:
response = payload.call_send_api(self.client)
except BOTO_BASE_ERRORS as err:
# ClientError has a response attr with parsed json error response (other errors don't)
raise AnymailAPIError(str(err), backend=self, email_message=message, payload=payload,
response=getattr(err, 'response', None), raised_from=err)
return response
def parse_recipient_status(self, response, payload, message):
return payload.parse_recipient_status(response)
class AmazonSESBasePayload(BasePayload):
def init_payload(self):
self.params = {}
if self.backend.configuration_set_name is not None:
self.params["ConfigurationSetName"] = self.backend.configuration_set_name
def call_send_api(self, ses_client):
raise NotImplementedError()
def parse_recipient_status(self, response):
# response is the parsed (dict) JSON returned from the API call
raise NotImplementedError()
def set_esp_extra(self, extra):
# e.g., ConfigurationSetName, FromArn, SourceArn, ReturnPathArn
self.params.update(extra)
class AmazonSESSendRawEmailPayload(AmazonSESBasePayload):
def init_payload(self):
super(AmazonSESSendRawEmailPayload, self).init_payload()
self.all_recipients = []
self.mime_message = self.message.message()
if HeaderBugWorkaround and "Subject" in self.mime_message:
# (message.message() will have already checked subject for BadHeaderError)
self.mime_message.replace_header("Subject", HeaderBugWorkaround(self.message.subject))
# Work around an Amazon SES bug where, if all of:
# - the message body (text or html) contains non-ASCII characters
# - the body is sent with `Content-Transfer-Encoding: 8bit`
# (which is Django email's default for most non-ASCII bodies)
# - you are using an SES ConfigurationSet with open or click tracking enabled
# then SES replaces the non-ASCII characters with question marks as it rewrites
# the message to add tracking. Forcing `CTE: quoted-printable` avoids the problem.
# (https://forums.aws.amazon.com/thread.jspa?threadID=287048)
for part in self.mime_message.walk():
if part.get_content_maintype() == "text" and part["Content-Transfer-Encoding"] == "8bit":
content = part.get_payload()
del part["Content-Transfer-Encoding"]
qp_charset = Charset(part.get_content_charset("us-ascii"))
qp_charset.body_encoding = QP
# (can't use part.set_payload, because SafeMIMEText can undo this workaround)
MIMEText.set_payload(part, content, charset=qp_charset)
def call_send_api(self, ses_client):
self.params["RawMessage"] = {
# Note: "Destinations" is determined from message headers if not provided
# "Destinations": [email.addr_spec for email in self.all_recipients],
"Data": self.mime_message.as_bytes()
}
return ses_client.send_raw_email(**self.params)
def parse_recipient_status(self, response):
try:
message_id = response["MessageId"]
except (KeyError, TypeError) as err:
raise AnymailAPIError(
"%s parsing Amazon SES send result %r" % (str(err), response),
backend=self.backend, email_message=self.message, payload=self)
recipient_status = AnymailRecipientStatus(message_id=message_id, status="queued")
return {recipient.addr_spec: recipient_status for recipient in self.all_recipients}
# Standard EmailMessage attrs...
# These all get rolled into the RFC-5322 raw mime directly via EmailMessage.message()
def _no_send_defaults(self, attr):
# Anymail global send defaults don't work for standard attrs, because the
# merged/computed value isn't forced back into the EmailMessage.
if attr in self.defaults:
self.unsupported_feature("Anymail send defaults for '%s' with Amazon SES" % attr)
def set_from_email_list(self, emails):
# Although Amazon SES will send messages with any From header, it can only parse Source
# if the From header is a single email. Explicit Source avoids an "Illegal address" error:
if len(emails) > 1:
self.params["Source"] = emails[0].addr_spec
# (else SES will look at the (single) address in the From header)
def set_recipients(self, recipient_type, emails):
self.all_recipients += emails
# included in mime_message
assert recipient_type in ("to", "cc", "bcc")
self._no_send_defaults(recipient_type)
def set_subject(self, subject):
# included in mime_message
self._no_send_defaults("subject")
def set_reply_to(self, emails):
# included in mime_message
self._no_send_defaults("reply_to")
def set_extra_headers(self, headers):
# included in mime_message
self._no_send_defaults("extra_headers")
def set_text_body(self, body):
# included in mime_message
self._no_send_defaults("body")
def set_html_body(self, body):
# included in mime_message
self._no_send_defaults("body")
def set_alternatives(self, alternatives):
# included in mime_message
self._no_send_defaults("alternatives")
def set_attachments(self, attachments):
# included in mime_message
self._no_send_defaults("attachments")
# Anymail-specific payload construction
def set_envelope_sender(self, email):
self.params["Source"] = email.addr_spec
def set_spoofed_to_header(self, header_to):
# django.core.mail.EmailMessage.message() has already set
# self.mime_message["To"] = header_to
# and performed any necessary header sanitization
self.params["Destinations"] = [email.addr_spec for email in self.all_recipients]
def set_metadata(self, metadata):
# Amazon SES has two mechanisms for adding custom data to a message:
# * Custom message headers are available to webhooks (SNS notifications),
# but not in CloudWatch metrics/dashboards or Kinesis Firehose streams.
# Custom headers can be sent only with SendRawEmail.
# * "Message Tags" are available to CloudWatch and Firehose, and to SNS
# notifications for SES *events* but not SES *notifications*. (Got that?)
# Message Tags also allow *very* limited characters in both name and value.
# Message Tags can be sent with any SES send call.
# (See "How do message tags work?" in https://aws.amazon.com/blogs/ses/introducing-sending-metrics/
# and https://forums.aws.amazon.com/thread.jspa?messageID=782922.)
# To support reliable retrieval in webhooks, just use custom headers for metadata.
add_header(self.mime_message, "X-Metadata", self.serialize_json(metadata))
def set_tags(self, tags):
# See note about Amazon SES Message Tags and custom headers in set_metadata above.
# To support reliable retrieval in webhooks, use custom headers for tags.
# (There are no restrictions on number or content for custom header tags.)
for tag in tags:
add_header(self.mime_message, "X-Tag", tag) # creates multiple X-Tag headers, one per tag
# Also *optionally* pass a single Message Tag if the AMAZON_SES_MESSAGE_TAG_NAME
# Anymail setting is set (default no). The AWS API restricts tag content in this case.
# (This is useful for dashboard segmentation; use esp_extra["Tags"] for anything more complex.)
if tags and self.backend.message_tag_name is not None:
if len(tags) > 1:
self.unsupported_feature("multiple tags with the AMAZON_SES_MESSAGE_TAG_NAME setting")
self.params.setdefault("Tags", []).append(
{"Name": self.backend.message_tag_name, "Value": tags[0]})
def set_template_id(self, template_id):
raise NotImplementedError("AmazonSESSendRawEmailPayload should not have been used with template_id")
def set_merge_data(self, merge_data):
self.unsupported_feature("merge_data without template_id")
def set_merge_global_data(self, merge_global_data):
self.unsupported_feature("global_merge_data without template_id")
class AmazonSESSendBulkTemplatedEmailPayload(AmazonSESBasePayload):
def init_payload(self):
super(AmazonSESSendBulkTemplatedEmailPayload, self).init_payload()
# late-bind recipients and merge_data in call_send_api
self.recipients = {"to": [], "cc": [], "bcc": []}
self.merge_data = {}
def call_send_api(self, ses_client):
# include any 'cc' or 'bcc' in every destination
cc_and_bcc_addresses = {}
if self.recipients["cc"]:
cc_and_bcc_addresses["CcAddresses"] = [cc.address for cc in self.recipients["cc"]]
if self.recipients["bcc"]:
cc_and_bcc_addresses["BccAddresses"] = [bcc.address for bcc in self.recipients["bcc"]]
# set up destination and data for each 'to'
self.params["Destinations"] = [{
"Destination": dict(ToAddresses=[to.address], **cc_and_bcc_addresses),
"ReplacementTemplateData": self.serialize_json(self.merge_data.get(to.addr_spec, {}))
} for to in self.recipients["to"]]
return ses_client.send_bulk_templated_email(**self.params)
def parse_recipient_status(self, response):
try:
# response["Status"] should be a list in Destinations (to) order
anymail_statuses = [
AnymailRecipientStatus(
message_id=status.get("MessageId", None),
status='queued' if status.get("Status") == "Success" else 'failed')
for status in response["Status"]
]
except (KeyError, TypeError) as err:
raise AnymailAPIError(
"%s parsing Amazon SES send result %r" % (str(err), response),
backend=self.backend, email_message=self.message, payload=self)
to_addrs = [to.addr_spec for to in self.recipients["to"]]
if len(anymail_statuses) != len(to_addrs):
raise AnymailAPIError(
"Sent to %d destinations, but only %d statuses in Amazon SES send result %r"
% (len(to_addrs), len(anymail_statuses), response),
backend=self.backend, email_message=self.message, payload=self)
return dict(zip(to_addrs, anymail_statuses))
def set_from_email(self, email):
self.params["Source"] = email.address # this will RFC2047-encode display_name if needed
def set_recipients(self, recipient_type, emails):
# late-bound in call_send_api
assert recipient_type in ("to", "cc", "bcc")
self.recipients[recipient_type] = emails
def set_subject(self, subject):
# (subject can only come from template; you can use substitution vars in that)
if subject:
self.unsupported_feature("overriding template subject")
def set_reply_to(self, emails):
if emails:
self.params["ReplyToAddresses"] = [email.address for email in emails]
def set_extra_headers(self, headers):
self.unsupported_feature("extra_headers with template")
def set_text_body(self, body):
if body:
self.unsupported_feature("overriding template body content")
def set_html_body(self, body):
if body:
self.unsupported_feature("overriding template body content")
def set_attachments(self, attachments):
if attachments:
self.unsupported_feature("attachments with template")
# Anymail-specific payload construction
def set_envelope_sender(self, email):
self.params["ReturnPath"] = email.addr_spec
def set_metadata(self, metadata):
# no custom headers with SendBulkTemplatedEmail
self.unsupported_feature("metadata with template")
def set_tags(self, tags):
# no custom headers with SendBulkTemplatedEmail, but support AMAZON_SES_MESSAGE_TAG_NAME if used
# (see tags/metadata in AmazonSESSendRawEmailPayload for more info)
if tags:
if self.backend.message_tag_name is not None:
if len(tags) > 1:
self.unsupported_feature("multiple tags with the AMAZON_SES_MESSAGE_TAG_NAME setting")
self.params["DefaultTags"] = [{"Name": self.backend.message_tag_name, "Value": tags[0]}]
else:
self.unsupported_feature(
"tags with template (unless using the AMAZON_SES_MESSAGE_TAG_NAME setting)")
def set_template_id(self, template_id):
self.params["Template"] = template_id
def set_merge_data(self, merge_data):
# late-bound in call_send_api
self.merge_data = merge_data
def set_merge_global_data(self, merge_global_data):
self.params["DefaultTemplateData"] = self.serialize_json(merge_global_data)
def _get_anymail_boto3_params(esp_name=EmailBackend.esp_name, kwargs=None):
"""Returns 2 dicts of params for boto3.session.Session() and .client()
Incorporates ANYMAIL["AMAZON_SES_SESSION_PARAMS"] and
ANYMAIL["AMAZON_SES_CLIENT_PARAMS"] settings.
Converts config dict to botocore.client.Config if needed
May remove keys from kwargs, but won't modify original settings
"""
# (shared with ..webhooks.amazon_ses)
session_params = get_anymail_setting("session_params", esp_name=esp_name, kwargs=kwargs, default={})
client_params = get_anymail_setting("client_params", esp_name=esp_name, kwargs=kwargs, default={})
# Add Anymail user-agent, and convert config dict to botocore.client.Config
client_params = client_params.copy() # don't modify source
config = Config(user_agent_extra="django-anymail/{version}-{esp}".format(
esp=esp_name.lower().replace(" ", "-"), version=__version__))
if "config" in client_params:
# convert config dict to botocore.client.Config if needed
client_params_config = client_params["config"]
if not isinstance(client_params_config, Config):
client_params_config = Config(**client_params_config)
config = config.merge(client_params_config)
client_params["config"] = config
return session_params, client_params
django-anymail-7.0.0/anymail/backends/base.py 0000644 0000765 0000024 00000057233 13434336005 022052 0 ustar medmunds staff 0000000 0000000 import json
from datetime import date, datetime
import six
from django.conf import settings
from django.core.mail.backends.base import BaseEmailBackend
from django.utils.timezone import is_naive, get_current_timezone, make_aware, utc
from requests.structures import CaseInsensitiveDict
from ..exceptions import (
AnymailCancelSend, AnymailError, AnymailUnsupportedFeature, AnymailRecipientsRefused,
AnymailSerializationError)
from ..message import AnymailStatus
from ..signals import pre_send, post_send
from ..utils import (
Attachment, UNSET, combine, last, get_anymail_setting, parse_address_list, parse_single_address,
force_non_lazy, force_non_lazy_list, force_non_lazy_dict, is_lazy)
class AnymailBaseBackend(BaseEmailBackend):
"""
Base Anymail email backend
"""
def __init__(self, *args, **kwargs):
super(AnymailBaseBackend, self).__init__(*args, **kwargs)
self.ignore_unsupported_features = get_anymail_setting('ignore_unsupported_features',
kwargs=kwargs, default=False)
self.ignore_recipient_status = get_anymail_setting('ignore_recipient_status',
kwargs=kwargs, default=False)
self.debug_api_requests = get_anymail_setting('debug_api_requests', # generate debug output
kwargs=kwargs, default=False)
# Merge SEND_DEFAULTS and _SEND_DEFAULTS settings
send_defaults = get_anymail_setting('send_defaults', default={}) # but not from kwargs
esp_send_defaults = get_anymail_setting('send_defaults', esp_name=self.esp_name,
kwargs=kwargs, default=None)
if esp_send_defaults is not None:
send_defaults = send_defaults.copy()
send_defaults.update(esp_send_defaults)
self.send_defaults = send_defaults
def open(self):
"""
Open and persist a connection to the ESP's API, and whether
a new connection was created.
Callers must ensure they later call close, if (and only if) open
returns True.
"""
# Subclasses should use an instance property to maintain a cached
# connection, and return True iff they initialize that instance
# property in _this_ open call. (If the cached connection already
# exists, just do nothing and return False.)
#
# Subclasses should swallow operational errors if self.fail_silently
# (e.g., network errors), but otherwise can raise any errors.
#
# (Returning a bool to indicate whether connection was created is
# borrowed from django.core.email.backends.SMTPBackend)
return False
def close(self):
"""
Close the cached connection created by open.
You must only call close if your code called open and it returned True.
"""
# Subclasses should tear down the cached connection and clear
# the instance property.
#
# Subclasses should swallow operational errors if self.fail_silently
# (e.g., network errors), but otherwise can raise any errors.
pass
def send_messages(self, email_messages):
"""
Sends one or more EmailMessage objects and returns the number of email
messages sent.
"""
# This API is specified by Django's core BaseEmailBackend
# (so you can't change it to, e.g., return detailed status).
# Subclasses shouldn't need to override.
num_sent = 0
if not email_messages:
return num_sent
created_session = self.open()
try:
for message in email_messages:
try:
sent = self._send(message)
except AnymailError:
if self.fail_silently:
sent = False
else:
raise
if sent:
num_sent += 1
finally:
if created_session:
self.close()
return num_sent
def _send(self, message):
"""Sends the EmailMessage message, and returns True if the message was sent.
This should only be called by the base send_messages loop.
Implementations must raise exceptions derived from AnymailError for
anticipated failures that should be suppressed in fail_silently mode.
"""
message.anymail_status = AnymailStatus()
if not self.run_pre_send(message): # (might modify message)
return False # cancel send without error
if not message.recipients():
return False
payload = self.build_message_payload(message, self.send_defaults)
response = self.post_to_esp(payload, message)
message.anymail_status.esp_response = response
recipient_status = self.parse_recipient_status(response, payload, message)
message.anymail_status.set_recipient_status(recipient_status)
self.run_post_send(message) # send signal before raising status errors
self.raise_for_recipient_status(message.anymail_status, response, payload, message)
return True
def run_pre_send(self, message):
"""Send pre_send signal, and return True if message should still be sent"""
try:
pre_send.send(self.__class__, message=message, esp_name=self.esp_name)
return True
except AnymailCancelSend:
return False # abort without causing error
def run_post_send(self, message):
"""Send post_send signal to all receivers"""
results = post_send.send_robust(
self.__class__, message=message, status=message.anymail_status, esp_name=self.esp_name)
for (receiver, response) in results:
if isinstance(response, Exception):
raise response
def build_message_payload(self, message, defaults):
"""Returns a payload that will allow message to be sent via the ESP.
Derived classes must implement, and should subclass :class:BasePayload
to get standard Anymail options.
Raises :exc:AnymailUnsupportedFeature for message options that
cannot be communicated to the ESP.
:param message: :class:EmailMessage
:param defaults: dict
:return: :class:BasePayload
"""
raise NotImplementedError("%s.%s must implement build_message_payload" %
(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.
payload is the result of build_message_payload
message is the original EmailMessage
return should be a raw response
Can raise AnymailAPIError (or derived exception) for problems posting to the ESP
"""
raise NotImplementedError("%s.%s must implement post_to_esp" %
(self.__class__.__module__, self.__class__.__name__))
def parse_recipient_status(self, response, payload, message):
"""Return a dict mapping email to AnymailRecipientStatus for each recipient.
Can raise AnymailAPIError (or derived exception) if response is unparsable
"""
raise NotImplementedError("%s.%s must implement parse_recipient_status" %
(self.__class__.__module__, self.__class__.__name__))
def raise_for_recipient_status(self, anymail_status, response, payload, message):
"""If *all* recipients are refused or invalid, raises AnymailRecipientsRefused"""
if not self.ignore_recipient_status:
# Error if *all* recipients are invalid or refused
# (This behavior parallels smtplib.SMTPRecipientsRefused from Django's SMTP EmailBackend)
if anymail_status.status.issubset({"invalid", "rejected"}):
raise AnymailRecipientsRefused(email_message=message, payload=payload, response=response,
backend=self)
@property
def esp_name(self):
"""
Read-only name of the ESP for this backend.
Concrete backends must override with class attr. E.g.:
esp_name = "Postmark"
esp_name = "SendGrid" # (use ESP's preferred capitalization)
"""
raise NotImplementedError("%s.%s must declare esp_name class attr" %
(self.__class__.__module__, self.__class__.__name__))
class BasePayload(object):
# Listing of EmailMessage/EmailMultiAlternatives attributes
# to process into Payload. Each item is in the form:
# (attr, combiner, converter)
# attr: the property name
# combiner: optional function(default_value, value) -> value
# to combine settings defaults with the EmailMessage property value
# (usually `combine` to merge, or `last` for message value to override default;
# use `None` if settings defaults aren't supported)
# converter: optional function(value) -> value transformation
# (can be a callable or the string name of a Payload method, or `None`)
# The converter must force any Django lazy translation strings to text.
# The Payload's `set_` method will be called with
# the combined/converted results for each attr.
base_message_attrs = (
# Standard EmailMessage/EmailMultiAlternatives props
('from_email', last, parse_address_list), # multiple from_emails are allowed
('to', combine, parse_address_list),
('cc', combine, parse_address_list),
('bcc', combine, parse_address_list),
('subject', last, force_non_lazy),
('reply_to', combine, parse_address_list),
('extra_headers', combine, force_non_lazy_dict),
('body', last, force_non_lazy), # special handling below checks message.content_subtype
('alternatives', combine, 'prepped_alternatives'),
('attachments', combine, 'prepped_attachments'),
)
anymail_message_attrs = (
# Anymail expando-props
('envelope_sender', last, parse_single_address),
('metadata', combine, force_non_lazy_dict),
('send_at', last, 'aware_datetime'),
('tags', combine, force_non_lazy_list),
('track_clicks', last, None),
('track_opens', last, None),
('template_id', last, force_non_lazy),
('merge_data', combine, force_non_lazy_dict),
('merge_global_data', combine, force_non_lazy_dict),
('merge_metadata', combine, force_non_lazy_dict),
('esp_extra', combine, force_non_lazy_dict),
)
esp_message_attrs = () # subclasses can override
# If any of these attrs are set on a message, treat the message
# as a batch send (separate message for each `to` recipient):
batch_attrs = ('merge_data', 'merge_metadata')
def __init__(self, message, defaults, backend):
self.message = message
self.defaults = defaults
self.backend = backend
self.esp_name = backend.esp_name
self._batch_attrs_used = {attr: UNSET for attr in self.batch_attrs}
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 attr in ('to', 'cc', 'bcc', 'reply_to') and value is not UNSET:
self.validate_not_bare_string(attr, value)
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)
if converter in (parse_address_list, parse_single_address):
# hack to include field name in error message
value = converter(value, field=attr)
else:
value = converter(value)
if value is not UNSET:
if attr == 'body':
setter = self.set_html_body if message.content_subtype == 'html' else self.set_text_body
elif attr == 'from_email':
setter = self.set_from_email_list
elif attr == 'extra_headers':
setter = self.process_extra_headers
else:
# AttributeError here? Your Payload subclass is missing a set_ implementation
setter = getattr(self, 'set_%s' % attr)
setter(value)
if attr in self.batch_attrs:
self._batch_attrs_used[attr] = (value is not UNSET)
def is_batch(self):
"""
Return True if the message should be treated as a batch send.
Intended to be used inside serialize_data or similar, after all relevant
attributes have been processed. Will error if called before that (e.g.,
inside a set_ method or during __init__).
"""
batch_attrs_used = self._batch_attrs_used.values()
assert UNSET not in batch_attrs_used, "Cannot call is_batch before all attributes processed"
return any(batch_attrs_used)
def unsupported_feature(self, feature):
if not self.backend.ignore_unsupported_features:
raise AnymailUnsupportedFeature("%s does not support %s" % (self.esp_name, feature),
email_message=self.message, payload=self, backend=self.backend)
def process_extra_headers(self, headers):
# Handle some special-case headers, and pass the remainder to set_extra_headers.
# (Subclasses shouldn't need to override this.)
headers = CaseInsensitiveDict(headers) # email headers are case-insensitive per RFC-822 et seq
reply_to = headers.pop('Reply-To', None)
if reply_to:
# message.extra_headers['Reply-To'] will override message.reply_to
# (because the extra_headers attr is processed after reply_to).
# This matches the behavior of Django's EmailMessage.message().
self.set_reply_to(parse_address_list([reply_to], field="extra_headers['Reply-To']"))
if 'From' in headers:
# If message.extra_headers['From'] is supplied, it should override message.from_email,
# but message.from_email should be used as the envelope_sender. See:
# - https://code.djangoproject.com/ticket/9214
# - https://github.com/django/django/blob/1.8/django/core/mail/message.py#L269
# - https://github.com/django/django/blob/1.8/django/core/mail/backends/smtp.py#L118
header_from = parse_address_list(headers.pop('From'), field="extra_headers['From']")
envelope_sender = parse_single_address(self.message.from_email, field="from_email") # must be single
self.set_from_email_list(header_from)
self.set_envelope_sender(envelope_sender)
if 'To' in headers:
# If message.extra_headers['To'] is supplied, message.to is used only as the envelope
# recipients (SMTP.sendmail to_addrs), and the header To is spoofed. See:
# - https://github.com/django/django/blob/1.8/django/core/mail/message.py#L270
# - https://github.com/django/django/blob/1.8/django/core/mail/backends/smtp.py#L119-L120
# No current ESP supports this, so this code is mainly here to flag
# the SMTP backend's behavior as an unsupported feature in Anymail:
header_to = headers.pop('To')
self.set_spoofed_to_header(header_to)
if headers:
self.set_extra_headers(headers)
#
# Attribute validators
#
def validate_not_bare_string(self, attr, value):
"""EmailMessage to, cc, bcc, and reply_to are specced to be lists of strings.
This catches the common error where a single string is used instead.
(See also checks in EmailMessage.__init__.)
"""
# Note: this actually only runs for reply_to. If to, cc, or bcc are
# set to single strings, you'll end up with an earlier cryptic TypeError
# from EmailMesssage.recipients (called from EmailMessage.send) before
# the Anymail backend even gets involved:
# TypeError: must be str, not list
# TypeError: can only concatenate list (not "str") to list
# TypeError: Can't convert 'list' object to str implicitly
if isinstance(value, six.string_types) or is_lazy(value):
raise TypeError('"{attr}" attribute must be a list or other iterable'.format(attr=attr))
#
# Attribute converters
#
def prepped_alternatives(self, alternatives):
return [(force_non_lazy(content), mimetype)
for (content, mimetype) in alternatives]
def prepped_attachments(self, attachments):
str_encoding = self.message.encoding or settings.DEFAULT_CHARSET
return [Attachment(attachment, str_encoding) # (handles lazy content, filename)
for attachment in attachments]
def aware_datetime(self, value):
"""Converts a date or datetime or timestamp to an aware datetime.
Naive datetimes are assumed to be in Django's current_timezone.
Dates are interpreted as midnight that date, in Django's current_timezone.
Integers are interpreted as POSIX timestamps (which are inherently UTC).
Anything else (e.g., str) is returned unchanged, which won't be portable.
"""
if isinstance(value, datetime):
dt = value
else:
if isinstance(value, date):
dt = datetime(value.year, value.month, value.day) # naive, midnight
else:
try:
dt = datetime.utcfromtimestamp(value).replace(tzinfo=utc)
except (TypeError, ValueError):
return value
if is_naive(dt):
dt = make_aware(dt, get_current_timezone())
return dt
#
# Abstract implementation
#
def init_payload(self):
raise NotImplementedError("%s.%s must implement init_payload" %
(self.__class__.__module__, self.__class__.__name__))
def set_from_email_list(self, emails):
# If your backend supports multiple from emails, override this to handle the whole list;
# otherwise just implement set_from_email
if len(emails) > 1:
self.unsupported_feature("multiple from emails")
# fall through if ignoring unsupported features
if len(emails) > 0:
self.set_from_email(emails[0])
def set_from_email(self, email):
raise NotImplementedError("%s.%s must implement set_from_email or set_from_email_list" %
(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):
# headers is a CaseInsensitiveDict, and is a copy (so is safe to modify)
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:
if mimetype == "text/html":
# This assumes that there's at most one html alternative,
# and so it should be the html body. (Most ESPs don't
# support multiple html alternative parts anyway.)
self.set_html_body(content)
else:
self.add_alternative(content, mimetype)
def add_alternative(self, content, mimetype):
self.unsupported_feature("alternative part with type '%s'" % mimetype)
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__))
def set_spoofed_to_header(self, header_to):
# In the unlikely case an ESP supports *completely replacing* the To message header
# without altering the actual envelope recipients, the backend can implement this.
self.unsupported_feature("spoofing `To` header")
# Anymail-specific payload construction
def set_envelope_sender(self, email):
self.unsupported_feature("envelope_sender")
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")
def set_template_id(self, template_id):
self.unsupported_feature("template_id")
def set_merge_data(self, merge_data):
self.unsupported_feature("merge_data")
def set_merge_global_data(self, merge_global_data):
self.unsupported_feature("merge_global_data")
def set_merge_metadata(self, merge_metadata):
self.unsupported_feature("merge_metadata")
# ESP-specific payload construction
def set_esp_extra(self, extra):
self.unsupported_feature("esp_extra")
#
# Helpers for concrete implementations
#
def serialize_json(self, data):
"""Returns data serialized to json, raising appropriate errors.
Essentially json.dumps with added context in any errors.
Useful for implementing, e.g., serialize_data in a subclass,
"""
try:
return json.dumps(data, default=self._json_default)
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)
@staticmethod
def _json_default(o):
"""json.dump default function that handles some common Payload data types"""
if isinstance(o, CaseInsensitiveDict): # used for headers
return dict(o)
raise TypeError("Object of type '%s' is not JSON serializable" %
o.__class__.__name__)
django-anymail-7.0.0/anymail/backends/base_requests.py 0000644 0000765 0000024 00000016155 13357721707 024016 0 ustar medmunds staff 0000000 0000000 from __future__ import print_function
import requests
import six
from six.moves.urllib.parse import urljoin
from anymail.utils import get_anymail_setting
from .base import AnymailBaseBackend, BasePayload
from ..exceptions import AnymailRequestsAPIError
from .._version import __version__
class AnymailRequestsBackend(AnymailBaseBackend):
"""
Base Anymail email backend for ESPs that use an HTTP API via requests
"""
def __init__(self, api_url, **kwargs):
"""Init options from Django settings"""
self.api_url = api_url
self.timeout = get_anymail_setting('requests_timeout', kwargs=kwargs, default=30)
super(AnymailRequestsBackend, self).__init__(**kwargs)
self.session = None
def open(self):
if self.session:
return False # already exists
try:
self.session = requests.Session()
except requests.RequestException:
if not self.fail_silently:
raise
else:
self.session.headers["User-Agent"] = "django-anymail/{version}-{esp} {orig}".format(
esp=self.esp_name.lower(), version=__version__,
orig=self.session.headers.get("User-Agent", ""))
if self.debug_api_requests:
self.session.hooks['response'].append(self._dump_api_request)
return True
def close(self):
if self.session is None:
return
try:
self.session.close()
except requests.RequestException:
if not self.fail_silently:
raise
finally:
self.session = None
def _send(self, message):
if self.session is None:
class_name = self.__class__.__name__
raise RuntimeError(
"Session has not been opened in {class_name}._send. "
"(This is either an implementation error in {class_name}, "
"or you are incorrectly calling _send directly.)".format(class_name=class_name))
return super(AnymailRequestsBackend, self)._send(message)
def post_to_esp(self, payload, message):
"""Post payload to ESP send API endpoint, and return the raw response.
payload is the result of build_message_payload
message is the original EmailMessage
return should be a requests.Response
Can raise AnymailRequestsAPIError for HTTP errors in the post
"""
params = payload.get_request_params(self.api_url)
params.setdefault('timeout', self.timeout)
try:
response = self.session.request(**params)
except requests.RequestException as err:
# raise an exception that is both AnymailRequestsAPIError
# and the original requests exception type
exc_class = type('AnymailRequestsAPIError', (AnymailRequestsAPIError, type(err)), {})
raise exc_class(
"Error posting to %s:" % params.get('url', ''),
raised_from=err, email_message=message, payload=payload)
self.raise_for_status(response, payload, message)
return response
def raise_for_status(self, response, payload, message):
"""Raise AnymailRequestsAPIError if response is an HTTP error
Subclasses can override for custom error checking
(though should defer parsing/deserialization of the body to
parse_recipient_status)
"""
if response.status_code != 200:
raise AnymailRequestsAPIError(email_message=message, payload=payload, response=response,
backend=self)
def deserialize_json_response(self, response, payload, message):
"""Deserialize an ESP API response that's in json.
Useful for implementing deserialize_response
"""
try:
return response.json()
except ValueError:
raise AnymailRequestsAPIError("Invalid JSON in %s API response" % self.esp_name,
email_message=message, payload=payload, response=response,
backend=self)
@staticmethod
def _dump_api_request(response, **kwargs):
"""Print the request and response for debugging"""
# (This is not byte-for-byte, but a readable text representation that assumes
# UTF-8 encoding if encoded, and that omits the CR in CRLF line endings.
# If you need the raw bytes, configure HTTPConnection logging as shown
# in http://docs.python-requests.org/en/v3.0.0/api/#api-changes)
request = response.request # a PreparedRequest
print(u"\n===== Anymail API request")
print(u"{method} {url}\n{headers}".format(
method=request.method, url=request.url,
headers=u"".join(u"{header}: {value}\n".format(header=header, value=value)
for (header, value) in request.headers.items()),
))
if request.body is not None:
body_text = (request.body if isinstance(request.body, six.text_type)
else request.body.decode("utf-8", errors="replace")
).replace("\r\n", "\n")
print(body_text)
print(u"\n----- Response")
print(u"HTTP {status} {reason}\n{headers}\n{body}".format(
status=response.status_code, reason=response.reason,
headers=u"".join(u"{header}: {value}\n".format(header=header, value=value)
for (header, value) in response.headers.items()),
body=response.text, # Let Requests decode body content for us
))
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
django-anymail-7.0.0/anymail/__init__.py 0000644 0000765 0000024 00000000235 13245143003 021105 0 ustar medmunds staff 0000000 0000000 # Expose package version at root of package
from ._version import __version__, VERSION # NOQA: F401
default_app_config = 'anymail.apps.AnymailBaseConfig'
django-anymail-7.0.0/anymail/message.py 0000644 0000765 0000024 00000011365 13434336005 021006 0 ustar medmunds staff 0000000 0000000 from email.mime.image import MIMEImage
from email.utils import unquote
import os
from django.core.mail import EmailMessage, EmailMultiAlternatives, make_msgid
from .utils import UNSET
class AnymailMessageMixin(object):
"""Mixin for EmailMessage that exposes Anymail features.
Use of this mixin is optional. You can always just set Anymail
attributes on any EmailMessage.
(The mixin can be helpful with type checkers and other development
tools that complain about accessing Anymail's added attributes
on a regular EmailMessage.)
"""
def __init__(self, *args, **kwargs):
self.esp_extra = kwargs.pop('esp_extra', UNSET)
self.envelope_sender = kwargs.pop('envelope_sender', UNSET)
self.metadata = kwargs.pop('metadata', UNSET)
self.send_at = kwargs.pop('send_at', UNSET)
self.tags = kwargs.pop('tags', UNSET)
self.track_clicks = kwargs.pop('track_clicks', UNSET)
self.track_opens = kwargs.pop('track_opens', UNSET)
self.template_id = kwargs.pop('template_id', UNSET)
self.merge_data = kwargs.pop('merge_data', UNSET)
self.merge_global_data = kwargs.pop('merge_global_data', UNSET)
self.merge_metadata = kwargs.pop('merge_metadata', UNSET)
self.anymail_status = AnymailStatus()
# noinspection PyArgumentList
super(AnymailMessageMixin, self).__init__(*args, **kwargs)
def attach_inline_image_file(self, path, subtype=None, idstring="img", domain=None):
"""Add inline image from file path to an EmailMessage, and return its content id"""
assert isinstance(self, EmailMessage)
return attach_inline_image_file(self, path, subtype, idstring, domain)
def attach_inline_image(self, content, filename=None, subtype=None, idstring="img", domain=None):
"""Add inline image and return its content id"""
assert isinstance(self, EmailMessage)
return attach_inline_image(self, content, filename, subtype, idstring, domain)
class AnymailMessage(AnymailMessageMixin, EmailMultiAlternatives):
pass
def attach_inline_image_file(message, path, subtype=None, idstring="img", domain=None):
"""Add inline image from file path to an EmailMessage, and return its content id"""
filename = os.path.basename(path)
with open(path, 'rb') as f:
content = f.read()
return attach_inline_image(message, content, filename, subtype, idstring, domain)
def attach_inline_image(message, content, filename=None, subtype=None, idstring="img", domain=None):
"""Add inline image to an EmailMessage, and return its content id"""
if domain is None:
# Avoid defaulting to hostname that might end in '.com', because some ESPs
# use Content-ID as filename, and Gmail blocks filenames ending in '.com'.
domain = 'inline' # valid domain for a msgid; will never be a real TLD
content_id = make_msgid(idstring, domain) # Content ID per RFC 2045 section 7 (with <...>)
image = MIMEImage(content, subtype)
image.add_header('Content-Disposition', 'inline', filename=filename)
image.add_header('Content-ID', content_id)
message.attach(image)
return unquote(content_id) # Without <...>, for use as the
tag src
ANYMAIL_STATUSES = [
'sent', # the ESP has sent the message (though it may or may not get delivered)
'queued', # the ESP will try to send the message later
'invalid', # the recipient email was not valid
'rejected', # the recipient is blacklisted
'failed', # the attempt to send failed for some other reason
'unknown', # anything else
]
class AnymailRecipientStatus(object):
"""Information about an EmailMessage's send status for a single recipient"""
def __init__(self, message_id, status):
self.message_id = message_id # ESP message id
self.status = status # one of ANYMAIL_STATUSES, or None for not yet sent to ESP
class AnymailStatus(object):
"""Information about an EmailMessage's send status for all recipients"""
def __init__(self):
self.message_id = None # set of ESP message ids across all recipients, or bare id if only one, or None
self.status = None # set of ANYMAIL_STATUSES across all recipients, or None for not yet sent to ESP
self.recipients = {} # per-recipient: { email: AnymailRecipientStatus, ... }
self.esp_response = None
def set_recipient_status(self, recipients):
self.recipients.update(recipients)
recipient_statuses = self.recipients.values()
self.message_id = set([recipient.message_id for recipient in recipient_statuses])
if len(self.message_id) == 1:
self.message_id = self.message_id.pop() # de-set-ify if single message_id
self.status = set([recipient.status for recipient in recipient_statuses])
django-anymail-7.0.0/anymail/apps.py 0000644 0000765 0000024 00000000421 13245143003 020306 0 ustar medmunds staff 0000000 0000000 from django.apps import AppConfig
from django.core import checks
from .checks import check_deprecated_settings
class AnymailBaseConfig(AppConfig):
name = 'anymail'
verbose_name = "Anymail"
def ready(self):
checks.register(check_deprecated_settings)
django-anymail-7.0.0/anymail/inbound.py 0000644 0000765 0000024 00000033777 13260533572 021040 0 ustar medmunds staff 0000000 0000000 from base64 import b64decode
from email.message import Message
from email.utils import unquote
import six
from django.core.files.uploadedfile import SimpleUploadedFile
from ._email_compat import EmailParser, EmailBytesParser
from .utils import angle_wrap, get_content_disposition, parse_address_list, parse_rfc2822date
class AnymailInboundMessage(Message, object): # `object` ensures new-style class in Python 2)
"""
A normalized, parsed inbound email message.
A subclass of email.message.Message, with some additional
convenience properties, plus helpful methods backported
from Python 3.6+ email.message.EmailMessage (or really, MIMEPart)
"""
# Why Python email.message.Message rather than django.core.mail.EmailMessage?
# Django's EmailMessage is really intended for constructing a (limited subset of)
# Message to send; Message is better designed for representing arbitrary messages:
#
# * Message is easily parsed from raw mime (which is an inbound format provided
# by many ESPs), and can accurately represent any mime email that might be received
# * Message can represent repeated header fields (e.g., "Received") which
# are common in inbound messages
# * Django's EmailMessage defaults a bunch of properties in ways that aren't helpful
# (e.g., from_email from settings)
def __init__(self, *args, **kwargs):
# Note: this must accept zero arguments, for use with message_from_string (email.parser)
super(AnymailInboundMessage, self).__init__(*args, **kwargs)
# Additional attrs provided by some ESPs:
self.envelope_sender = None
self.envelope_recipient = None
self.stripped_text = None
self.stripped_html = None
self.spam_detected = None
self.spam_score = None
#
# Convenience accessors
#
@property
def from_email(self):
"""EmailAddress """
# equivalent to Python 3.2+ message['From'].addresses[0]
from_email = self.get_address_header('From')
if len(from_email) == 1:
return from_email[0]
elif len(from_email) == 0:
return None
else:
return from_email # unusual, but technically-legal multiple-From; preserve list
@property
def to(self):
"""list of EmailAddress objects from To header"""
# equivalent to Python 3.2+ message['To'].addresses
return self.get_address_header('To')
@property
def cc(self):
"""list of EmailAddress objects from Cc header"""
# equivalent to Python 3.2+ message['Cc'].addresses
return self.get_address_header('Cc')
@property
def subject(self):
"""str value of Subject header, or None"""
return self['Subject']
@property
def date(self):
"""datetime.datetime from Date header, or None if missing/invalid"""
# equivalent to Python 3.2+ message['Date'].datetime
return self.get_date_header('Date')
@property
def text(self):
"""Contents of the (first) text/plain body part, or None"""
return self._get_body_content('text/plain')
@property
def html(self):
"""Contents of the (first) text/html body part, or None"""
return self._get_body_content('text/html')
@property
def attachments(self):
"""list of attachments (as MIMEPart objects); excludes inlines"""
return [part for part in self.walk() if part.is_attachment()]
@property
def inline_attachments(self):
"""dict of Content-ID: attachment (as MIMEPart objects)"""
return {unquote(part['Content-ID']): part for part in self.walk()
if part.is_inline_attachment() and part['Content-ID']}
def get_address_header(self, header):
"""Return the value of header parsed into a (possibly-empty) list of EmailAddress objects"""
values = self.get_all(header)
if values is not None:
values = parse_address_list(values)
return values or []
def get_date_header(self, header):
"""Return the value of header parsed into a datetime.date, or None"""
value = self[header]
if value is not None:
value = parse_rfc2822date(value)
return value
def _get_body_content(self, content_type):
# This doesn't handle as many corner cases as Python 3.6 email.message.EmailMessage.get_body,
# but should work correctly for nearly all real-world inbound messages.
# We're guaranteed to have `is_attachment` available, because all AnymailInboundMessage parts
# should themselves be AnymailInboundMessage.
for part in self.walk():
if part.get_content_type() == content_type and not part.is_attachment():
return part.get_content_text()
return None
# Backport from Python 3.5 email.message.Message
def get_content_disposition(self):
try:
return super(AnymailInboundMessage, self).get_content_disposition()
except AttributeError:
return get_content_disposition(self)
# Backport from Python 3.4.2 email.message.MIMEPart
def is_attachment(self):
return self.get_content_disposition() == 'attachment'
# New for Anymail
def is_inline_attachment(self):
return self.get_content_disposition() == 'inline'
def get_content_bytes(self):
"""Return the raw payload bytes"""
maintype = self.get_content_maintype()
if maintype == 'message':
# The attachment's payload is a single (parsed) email Message; flatten it to bytes.
# (Note that self.is_multipart() misleadingly returns True in this case.)
payload = self.get_payload()
assert len(payload) == 1 # should be exactly one message
try:
return payload[0].as_bytes() # Python 3
except AttributeError:
return payload[0].as_string().encode('utf-8')
elif maintype == 'multipart':
# The attachment itself is multipart; the payload is a list of parts,
# and it's not clear which one is the "content".
raise ValueError("get_content_bytes() is not valid on multipart messages "
"(perhaps you want as_bytes()?)")
return self.get_payload(decode=True)
def get_content_text(self, charset=None, errors=None):
"""Return the payload decoded to text"""
maintype = self.get_content_maintype()
if maintype == 'message':
# The attachment's payload is a single (parsed) email Message; flatten it to text.
# (Note that self.is_multipart() misleadingly returns True in this case.)
payload = self.get_payload()
assert len(payload) == 1 # should be exactly one message
return payload[0].as_string()
elif maintype == 'multipart':
# The attachment itself is multipart; the payload is a list of parts,
# and it's not clear which one is the "content".
raise ValueError("get_content_text() is not valid on multipart messages "
"(perhaps you want as_string()?)")
else:
payload = self.get_payload(decode=True)
if payload is None:
return payload
charset = charset or self.get_content_charset('US-ASCII')
errors = errors or 'replace'
return payload.decode(charset, errors=errors)
def as_uploaded_file(self):
"""Return the attachment converted to a Django UploadedFile"""
if self['Content-Disposition'] is None:
return None # this part is not an attachment
name = self.get_filename()
content_type = self.get_content_type()
content = self.get_content_bytes()
return SimpleUploadedFile(name, content, content_type)
#
# Construction
#
# These methods are intended primarily for internal Anymail use
# (in inbound webhook handlers)
@classmethod
def parse_raw_mime(cls, s):
"""Returns a new AnymailInboundMessage parsed from str s"""
if isinstance(s, six.text_type):
# Avoid Python 3.x issue https://bugs.python.org/issue18271
# (See test_inbound: test_parse_raw_mime_8bit_utf8)
return cls.parse_raw_mime_bytes(s.encode('utf-8'))
return EmailParser(cls).parsestr(s)
@classmethod
def parse_raw_mime_bytes(cls, b):
"""Returns a new AnymailInboundMessage parsed from bytes b"""
return EmailBytesParser(cls).parsebytes(b)
@classmethod
def parse_raw_mime_file(cls, fp):
"""Returns a new AnymailInboundMessage parsed from file-like object fp"""
if isinstance(fp.read(0), six.binary_type):
return EmailBytesParser(cls).parse(fp)
else:
return EmailParser(cls).parse(fp)
@classmethod
def construct(cls, raw_headers=None, from_email=None, to=None, cc=None, subject=None, headers=None,
text=None, text_charset='utf-8', html=None, html_charset='utf-8',
attachments=None):
"""
Returns a new AnymailInboundMessage constructed from params.
This is designed to handle the sorts of email fields typically present
in ESP parsed inbound messages. (It's not a generalized MIME message constructor.)
:param raw_headers: {str|None} base (or complete) message headers as a single string
:param from_email: {str|None} value for From header
:param to: {str|None} value for To header
:param cc: {str|None} value for Cc header
:param subject: {str|None} value for Subject header
:param headers: {sequence[(str, str)]|mapping|None} additional headers
:param text: {str|None} plaintext body
:param text_charset: {str} charset of plaintext body; default utf-8
:param html: {str|None} html body
:param html_charset: {str} charset of html body; default utf-8
:param attachments: {list[MIMEBase]|None} as returned by construct_attachment
:return: {AnymailInboundMessage}
"""
if raw_headers is not None:
msg = EmailParser(cls).parsestr(raw_headers, headersonly=True)
msg.set_payload(None) # headersonly forces an empty string payload, which breaks things later
else:
msg = cls()
if from_email is not None:
del msg['From'] # override raw_headers value, if any
msg['From'] = from_email
if to is not None:
del msg['To']
msg['To'] = to
if cc is not None:
del msg['Cc']
msg['Cc'] = cc
if subject is not None:
del msg['Subject']
msg['Subject'] = subject
if headers is not None:
try:
header_items = headers.items() # mapping
except AttributeError:
header_items = headers # sequence of (key, value)
for name, value in header_items:
msg.add_header(name, value)
# For simplicity, we always build a MIME structure that could support plaintext/html
# alternative bodies, inline attachments for the body(ies), and message attachments.
# This may be overkill for simpler messages, but the structure is never incorrect.
del msg['MIME-Version'] # override raw_headers values, if any
del msg['Content-Type']
msg['MIME-Version'] = '1.0'
msg['Content-Type'] = 'multipart/mixed'
related = cls() # container for alternative bodies and inline attachments
related['Content-Type'] = 'multipart/related'
msg.attach(related)
alternatives = cls() # container for text and html bodies
alternatives['Content-Type'] = 'multipart/alternative'
related.attach(alternatives)
if text is not None:
part = cls()
part['Content-Type'] = 'text/plain'
part.set_payload(text, charset=text_charset)
alternatives.attach(part)
if html is not None:
part = cls()
part['Content-Type'] = 'text/html'
part.set_payload(html, charset=html_charset)
alternatives.attach(part)
if attachments is not None:
for attachment in attachments:
if attachment.is_inline_attachment():
related.attach(attachment)
else:
msg.attach(attachment)
return msg
@classmethod
def construct_attachment_from_uploaded_file(cls, file, content_id=None):
# This pulls the entire file into memory; it would be better to implement
# some sort of lazy attachment where the content is only pulled in if/when
# requested (and then use file.chunks() to minimize memory usage)
return cls.construct_attachment(
content_type=file.content_type,
content=file.read(),
filename=file.name,
content_id=content_id,
charset=file.charset)
@classmethod
def construct_attachment(cls, content_type, content,
charset=None, filename=None, content_id=None, base64=False):
part = cls()
part['Content-Type'] = content_type
part['Content-Disposition'] = 'inline' if content_id is not None else 'attachment'
if filename is not None:
part.set_param('name', filename, header='Content-Type')
part.set_param('filename', filename, header='Content-Disposition')
if content_id is not None:
part['Content-ID'] = angle_wrap(content_id)
if base64:
content = b64decode(content)
payload = content
if part.get_content_maintype() == 'message':
# email.Message parses message/rfc822 parts as a "multipart" (list) payload
# whose single item is the recursively-parsed message attachment
if isinstance(content, six.binary_type):
content = content.decode()
payload = [cls.parse_raw_mime(content)]
charset = None
part.set_payload(payload, charset)
return part
django-anymail-7.0.0/anymail/utils.py 0000644 0000765 0000024 00000052550 13466660310 020527 0 ustar medmunds staff 0000000 0000000 import base64
import mimetypes
from base64 import b64encode
from datetime import datetime
from email.mime.base import MIMEBase
from email.utils import formatdate, getaddresses, unquote
from time import mktime
import six
from django.conf import settings
from django.core.mail.message import sanitize_address, DEFAULT_ATTACHMENT_MIME_TYPE
from django.utils.encoding import force_text
from django.utils.functional import Promise
from django.utils.timezone import utc, get_fixed_timezone
from requests.structures import CaseInsensitiveDict
from six.moves.urllib.parse import urlsplit, urlunsplit
try:
from collections.abc import Mapping, MutableMapping # Python 3.3+
except ImportError:
from collections import Mapping, MutableMapping
from .exceptions import AnymailConfigurationError, AnymailInvalidAddress
BASIC_NUMERIC_TYPES = six.integer_types + (float,) # int, float, and (on Python 2) long
UNSET = type('UNSET', (object,), {}) # Used as non-None default value
def combine(*args):
"""
Combines all non-UNSET args, by shallow merging mappings and concatenating sequences
>>> combine({'a': 1, 'b': 2}, UNSET, {'b': 3, 'c': 4}, UNSET)
{'a': 1, 'b': 3, 'c': 4}
>>> combine([1, 2], UNSET, [3, 4], UNSET)
[1, 2, 3, 4]
>>> combine({'a': 1}, None, {'b': 2}) # None suppresses earlier args
{'b': 2}
>>> combine()
UNSET
"""
result = UNSET
for value in args:
if value is None:
# None is a request to suppress any earlier values
result = UNSET
elif value is not UNSET:
if result is UNSET:
try:
result = value.copy() # will shallow merge if dict-like
except AttributeError:
result = value # will concatenate if sequence-like
else:
try:
result.update(value) # shallow merge if dict-like
except AttributeError:
result = result + value # concatenate if sequence-like
return result
def last(*args):
"""Returns the last of its args which is not UNSET.
(Essentially `combine` without the merge behavior)
>>> last(1, 2, UNSET, 3, UNSET, UNSET)
3
>>> last(1, 2, None, UNSET) # None suppresses earlier args
UNSET
>>> last()
UNSET
"""
for value in reversed(args):
if value is None:
# None is a request to suppress any earlier values
return UNSET
elif value is not UNSET:
return value
return UNSET
def getfirst(dct, keys, default=UNSET):
"""Returns the value of the first of keys found in dict dct.
>>> getfirst({'a': 1, 'b': 2}, ['c', 'a'])
1
>>> getfirst({'a': 1, 'b': 2}, ['b', 'a'])
2
>>> getfirst({'a': 1, 'b': 2}, ['c'])
KeyError
>>> getfirst({'a': 1, 'b': 2}, ['c'], None)
None
"""
for key in keys:
try:
return dct[key]
except KeyError:
pass
if default is UNSET:
raise KeyError("None of %s found in dict" % ', '.join(keys))
else:
return default
def update_deep(dct, other):
"""Merge (recursively) keys and values from dict other into dict dct
Works with dict-like objects: dct (and descendants) can be any MutableMapping,
and other can be any Mapping
"""
for key, value in other.items():
if key in dct and isinstance(dct[key], MutableMapping) and isinstance(value, Mapping):
update_deep(dct[key], value)
else:
dct[key] = value
# (like dict.update(), no return value)
def parse_address_list(address_list, field=None):
"""Returns a list of EmailAddress objects from strings in address_list.
Essentially wraps :func:`email.utils.getaddresses` with better error
messaging and more-useful output objects
Note that the returned list might be longer than the address_list param,
if any individual string contains multiple comma-separated addresses.
:param list[str]|str|None|list[None] address_list:
the address or addresses to parse
:param str|None field:
optional description of the source of these addresses, for error message
:return list[:class:`EmailAddress`]:
:raises :exc:`AnymailInvalidAddress`:
"""
if isinstance(address_list, six.string_types) or is_lazy(address_list):
address_list = [address_list]
if address_list is None or address_list == [None]:
return []
# For consistency with Django's SMTP backend behavior, extract all addresses
# from the list -- which may split comma-seperated strings into multiple addresses.
# (See django.core.mail.message: EmailMessage.message to/cc/bcc/reply_to handling;
# also logic for ADDRESS_HEADERS in forbid_multi_line_headers.)
address_list_strings = [force_text(address) for address in address_list] # resolve lazy strings
name_email_pairs = getaddresses(address_list_strings)
if name_email_pairs == [] and address_list_strings == [""]:
name_email_pairs = [('', '')] # getaddresses ignores a single empty string
parsed = [EmailAddress(display_name=name, addr_spec=email)
for (name, email) in name_email_pairs]
# Sanity-check, and raise useful errors
for address in parsed:
if address.username == '' or address.domain == '':
# Django SMTP allows username-only emails, but they're not meaningful with an ESP
errmsg = u"Invalid email address '{problem}' parsed from '{source}'{where}.".format(
problem=address.addr_spec,
source=u", ".join(address_list_strings),
where=u" in `%s`" % field if field else "",
)
if len(parsed) > len(address_list):
errmsg += u" (Maybe missing quotes around a display-name?)"
raise AnymailInvalidAddress(errmsg)
return parsed
def parse_single_address(address, field=None):
"""Parses a single EmailAddress from str address, or raises AnymailInvalidAddress
:param str address: the fully-formatted email str to parse
:param str|None field: optional description of the source of this address, for error message
:return :class:`EmailAddress`: if address contains a single email
:raises :exc:`AnymailInvalidAddress`: if address contains no or multiple emails
"""
parsed = parse_address_list([address], field=field)
count = len(parsed)
if count > 1:
raise AnymailInvalidAddress(
"Only one email address is allowed; found {count} in '{address}'{where}.".format(
count=count, address=address, where=" in `%s`" % field if field else ""))
else:
return parsed[0]
class EmailAddress(object):
"""A sanitized, complete email address with easy access
to display-name, addr-spec (email), etc.
Similar to Python 3.6+ email.headerregistry.Address
Instance properties, all read-only:
:ivar str display_name:
the address's display-name portion (unqouted, unescaped),
e.g., 'Display Name, Inc.'
:ivar str addr_spec:
the address's addr-spec portion (unquoted, unescaped),
e.g., 'user@example.com'
:ivar str username:
the local part (before the '@') of the addr-spec,
e.g., 'user'
:ivar str domain:
the domain part (after the '@') of the addr-spec,
e.g., 'example.com'
:ivar str address:
the fully-formatted address, with any necessary quoting and escaping,
e.g., '"Display Name, Inc." '
(also available as `str(EmailAddress)`)
"""
def __init__(self, display_name='', addr_spec=None):
self._address = None # lazy formatted address
if addr_spec is None:
try:
display_name, addr_spec = display_name # unpack (name,addr) tuple
except ValueError:
pass
self.display_name = display_name
self.addr_spec = addr_spec
try:
self.username, self.domain = addr_spec.split("@", 1)
# do we need to unquote username?
except ValueError:
self.username = addr_spec
self.domain = ''
@property
def address(self):
if self._address is None:
# (you might be tempted to use `encoding=settings.DEFAULT_CHARSET` here,
# but that always forces the display-name to quoted-printable/base64,
# even when simple ascii would work fine--and be more readable)
self._address = self.formataddr()
return self._address
def formataddr(self, encoding=None):
"""Return a fully-formatted email address, using encoding.
This is essentially the same as :func:`email.utils.formataddr`
on the EmailAddress's name and email properties, but uses
Django's :func:`~django.core.mail.message.sanitize_address`
for improved PY2/3 compatibility, consistent handling of
encoding (a.k.a. charset), and proper handling of IDN
domain portions.
:param str|None encoding:
the charset to use for the display-name portion;
default None uses ascii if possible, else 'utf-8'
(quoted-printable utf-8/base64)
"""
return sanitize_address((self.display_name, self.addr_spec), encoding)
def __str__(self):
return self.address
class Attachment(object):
"""A normalized EmailMessage.attachments item with additional functionality
Normalized to have these properties:
name: attachment filename; may be None
content: bytestream
mimetype: the content type; guessed if not explicit
inline: bool, True if attachment has a Content-ID header
content_id: for inline, the Content-ID (*with* <>); may be None
cid: for inline, the Content-ID *without* <>; may be empty string
"""
def __init__(self, attachment, encoding):
# 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.)
self._attachment = attachment
self.encoding = encoding # should we be checking attachment["Content-Encoding"] ???
self.inline = False
self.content_id = None
self.cid = ""
if isinstance(attachment, MIMEBase):
self.name = attachment.get_filename()
self.content = attachment.get_payload(decode=True)
if self.content is None:
if hasattr(attachment, 'as_bytes'):
self.content = attachment.as_bytes()
else:
# Python 2.7 fallback
self.content = attachment.as_string().encode(self.encoding)
self.mimetype = attachment.get_content_type()
content_disposition = get_content_disposition(attachment)
if content_disposition == 'inline' or (not content_disposition and 'Content-ID' in attachment):
self.inline = True
self.content_id = attachment["Content-ID"] # probably including the <...>
if self.content_id is not None:
self.cid = unquote(self.content_id) # without the <, >
else:
(self.name, self.content, self.mimetype) = attachment
self.name = force_non_lazy(self.name)
self.content = force_non_lazy(self.content)
# Guess missing mimetype from filename, borrowed from
# django.core.mail.EmailMessage._create_attachment()
if self.mimetype is None and self.name is not None:
self.mimetype, _ = mimetypes.guess_type(self.name)
if self.mimetype is None:
self.mimetype = DEFAULT_ATTACHMENT_MIME_TYPE
@property
def b64content(self):
"""Content encoded as a base64 ascii string"""
content = self.content
if isinstance(content, six.text_type):
content = content.encode(self.encoding)
return b64encode(content).decode("ascii")
def get_content_disposition(mimeobj):
"""Return the message's content-disposition if it exists, or None.
Backport of py3.5 :func:`~email.message.Message.get_content_disposition`
"""
value = mimeobj.get('content-disposition')
if value is None:
return None
# _splitparam(value)[0].lower() :
return str(value).partition(';')[0].strip().lower()
def get_anymail_setting(name, default=UNSET, esp_name=None, kwargs=None, allow_bare=False):
"""Returns an Anymail option from kwargs or Django settings.
Returns first of:
- kwargs[name] -- e.g., kwargs['api_key'] -- and name key will be popped from kwargs
- settings.ANYMAIL['_'] -- e.g., settings.ANYMAIL['MAILGUN_API_KEY']
- settings.ANYMAIL__ -- e.g., settings.ANYMAIL_MAILGUN_API_KEY
- settings._ (only if allow_bare) -- e.g., settings.MAILGUN_API_KEY
- default if provided; else raises AnymailConfigurationError
If allow_bare, allows settings._ without the ANYMAIL_ prefix:
ANYMAIL = { "MAILGUN_API_KEY": "xyz", ... }
ANYMAIL_MAILGUN_API_KEY = "xyz"
MAILGUN_API_KEY = "xyz"
"""
try:
value = kwargs.pop(name)
if name in ['username', 'password']:
# Work around a problem in django.core.mail.send_mail, which calls
# get_connection(... username=None, password=None) by default.
# We need to ignore those None defaults (else settings like
# 'SENDGRID_USERNAME' get unintentionally overridden from kwargs).
if value is not None:
return value
else:
return value
except (AttributeError, KeyError):
pass
if esp_name is not None:
setting = "{}_{}".format(esp_name.upper().replace(" ", "_"), name.upper())
else:
setting = name.upper()
anymail_setting = "ANYMAIL_%s" % setting
try:
return settings.ANYMAIL[setting]
except (AttributeError, KeyError):
try:
return getattr(settings, anymail_setting)
except AttributeError:
if allow_bare:
try:
return getattr(settings, setting)
except AttributeError:
pass
if default is UNSET:
message = "You must set %s or ANYMAIL = {'%s': ...}" % (anymail_setting, setting)
if allow_bare:
message += " or %s" % setting
message += " in your Django settings"
raise AnymailConfigurationError(message)
else:
return default
def collect_all_methods(cls, method_name):
"""Return list of all `method_name` methods for cls and its superclass chain.
List is in MRO order, with no duplicates. Methods are unbound.
(This is used to simplify mixins and subclasses that contribute to a method set,
without requiring superclass chaining, and without requiring cooperating
superclasses.)
"""
methods = []
for ancestor in cls.__mro__:
try:
validator = getattr(ancestor, method_name)
except AttributeError:
pass
else:
if validator not in methods:
methods.append(validator)
return methods
def querydict_getfirst(qdict, field, default=UNSET):
"""Like :func:`django.http.QueryDict.get`, but returns *first* value of multi-valued field.
>>> from django.http import QueryDict
>>> q = QueryDict('a=1&a=2&a=3')
>>> querydict_getfirst(q, 'a')
'1'
>>> q.get('a')
'3'
>>> q['a']
'3'
You can bind this to a QueryDict instance using the "descriptor protocol":
>>> q.getfirst = querydict_getfirst.__get__(q)
>>> q.getfirst('a')
'1'
"""
# (Why not instead define a QueryDict subclass with this method? Because there's no simple way
# to efficiently initialize a QueryDict subclass with the contents of an existing instance.)
values = qdict.getlist(field)
if len(values) > 0:
return values[0]
elif default is not UNSET:
return default
else:
return qdict[field] # raise appropriate KeyError
EPOCH = datetime(1970, 1, 1, tzinfo=utc)
def timestamp(dt):
"""Return the unix timestamp (seconds past the epoch) for datetime dt"""
# This is the equivalent of Python 3.3's datetime.timestamp
try:
return dt.timestamp()
except AttributeError:
if dt.tzinfo is None:
return mktime(dt.timetuple())
else:
return (dt - EPOCH).total_seconds()
def rfc2822date(dt):
"""Turn a datetime into a date string as specified in RFC 2822."""
# This is almost the equivalent of Python 3.3's email.utils.format_datetime,
# but treats naive datetimes as local rather than "UTC with no information ..."
timeval = timestamp(dt)
return formatdate(timeval, usegmt=True)
def angle_wrap(s):
"""Return s surrounded by angle brackets, added only if necessary"""
# This is the inverse behavior of email.utils.unquote
# (which you might think email.utils.quote would do, but it doesn't)
if len(s) > 0:
if s[0] != '<':
s = '<' + s
if s[-1] != '>':
s = s + '>'
return s
def is_lazy(obj):
"""Return True if obj is a Django lazy object."""
# See django.utils.functional.lazy. (This appears to be preferred
# to checking for `not isinstance(obj, six.text_type)`.)
return isinstance(obj, Promise)
def force_non_lazy(obj):
"""If obj is a Django lazy object, return it coerced to text; otherwise return it unchanged.
(Similar to django.utils.encoding.force_text, but doesn't alter non-text objects.)
"""
if is_lazy(obj):
return six.text_type(obj)
return obj
def force_non_lazy_list(obj):
"""Return a (shallow) copy of sequence obj, with all values forced non-lazy."""
try:
return [force_non_lazy(item) for item in obj]
except (AttributeError, TypeError):
return force_non_lazy(obj)
def force_non_lazy_dict(obj):
"""Return a (deep) copy of dict obj, with all values forced non-lazy."""
try:
return {key: force_non_lazy_dict(value) for key, value in obj.items()}
except (AttributeError, TypeError):
return force_non_lazy(obj)
def get_request_basic_auth(request):
"""Returns HTTP basic auth string sent with request, or None.
If request includes basic auth, result is string 'username:password'.
"""
try:
authtype, authdata = request.META['HTTP_AUTHORIZATION'].split()
if authtype.lower() == "basic":
return base64.b64decode(authdata).decode('utf-8')
except (IndexError, KeyError, TypeError, ValueError):
pass
return None
def get_request_uri(request):
"""Returns the "exact" url used to call request.
Like :func:`django.http.request.HTTPRequest.build_absolute_uri`,
but also inlines HTTP basic auth, if present.
"""
url = request.build_absolute_uri()
basic_auth = get_request_basic_auth(request)
if basic_auth is not None:
# must reassemble url with auth
parts = urlsplit(url)
url = urlunsplit((parts.scheme, basic_auth + '@' + parts.netloc,
parts.path, parts.query, parts.fragment))
return url
try:
from email.utils import parsedate_to_datetime # Python 3.3+
except ImportError:
from email.utils import parsedate_tz
# Backport Python 3.3+ email.utils.parsedate_to_datetime
def parsedate_to_datetime(s):
# *dtuple, tz = _parsedate_tz(data)
dtuple = parsedate_tz(s)
tz = dtuple[-1]
# if tz is None: # parsedate_tz returns 0 for "-0000"
if tz is None or (tz == 0 and "-0000" in s):
# "... indicates that the date-time contains no information
# about the local time zone" (RFC 2822 #3.3)
return datetime(*dtuple[:6])
else:
# tzinfo = datetime.timezone(datetime.timedelta(seconds=tz)) # Python 3.2+ only
tzinfo = get_fixed_timezone(tz // 60) # don't use timedelta (avoid Django bug #28739)
return datetime(*dtuple[:6], tzinfo=tzinfo)
def parse_rfc2822date(s):
"""Parses an RFC-2822 formatted date string into a datetime.datetime
Returns None if string isn't parseable. Returned datetime will be naive
if string doesn't include known timezone offset; aware if it does.
(Same as Python 3 email.utils.parsedate_to_datetime, with improved
handling for unparseable date strings.)
"""
try:
return parsedate_to_datetime(s)
except (IndexError, TypeError, ValueError):
# despite the docs, parsedate_to_datetime often dies on unparseable input
return None
class CaseInsensitiveCasePreservingDict(CaseInsensitiveDict):
"""A dict with case-insensitive keys, which preserves the *first* key set.
>>> cicpd = CaseInsensitiveCasePreservingDict()
>>> cicpd["Accept"] = "application/text+xml"
>>> cicpd["accEPT"] = "application/json"
>>> cicpd["accept"]
"application/json"
>>> cicpd.keys()
["Accept"]
Compare to CaseInsensitiveDict, which preserves *last* key set:
>>> cid = CaseInsensitiveCasePreservingDict()
>>> cid["Accept"] = "application/text+xml"
>>> cid["accEPT"] = "application/json"
>>> cid.keys()
["accEPT"]
"""
def __setitem__(self, key, value):
_k = key.lower()
try:
# retrieve earlier matching key, if any
key, _ = self._store[_k]
except KeyError:
pass
self._store[_k] = (key, value)
def copy(self):
return self.__class__(self._store.values())
django-anymail-7.0.0/anymail/exceptions.py 0000644 0000765 0000024 00000017255 13466660434 021562 0 ustar medmunds staff 0000000 0000000 from __future__ import unicode_literals
import json
from traceback import format_exception_only
import six
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
from requests import HTTPError
class AnymailError(Exception):
"""Base class for exceptions raised by Anymail
Overrides __str__ to provide additional information about
the ESP API call and response.
"""
def __init__(self, *args, **kwargs):
"""
Optional kwargs:
email_message: the original EmailMessage being sent
status_code: HTTP status code of response to ESP send call
backend: the backend instance involved
payload: data arg (*not* json-stringified) for the ESP send call
response: requests.Response from the send call
raised_from: original/wrapped Exception
esp_name: what to call the ESP (read from backend if provided)
"""
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)
self.raised_from = kwargs.pop('raised_from', None)
self.esp_name = kwargs.pop('esp_name',
self.backend.esp_name if self.backend else None)
if isinstance(self, HTTPError):
# must leave response in kwargs for HTTPError
self.response = kwargs.get('response', None)
else:
self.response = kwargs.pop('response', None)
super(AnymailError, self).__init__(*args, **kwargs)
def __str__(self):
parts = [
" ".join([six.text_type(arg) for arg in self.args]),
self.describe_raised_from(),
self.describe_send(),
self.describe_response(),
]
return "\n".join(filter(None, parts))
def describe_send(self):
"""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:
description += " to %s" % ','.join(self.email_message.to)
except AttributeError:
pass
try:
description += " from %s" % self.email_message.from_email
except AttributeError:
pass
return description
def describe_response(self):
"""Return a formatted string of self.status_code and response, or None"""
if self.status_code is None:
return None
# Decode response.reason to text -- borrowed from requests.Response.raise_for_status:
reason = self.response.reason
if isinstance(reason, six.binary_type):
try:
reason = reason.decode('utf-8')
except UnicodeDecodeError:
reason = reason.decode('iso-8859-1')
description = "%s API response %d (%s)" % (self.esp_name or "ESP", self.status_code, reason)
try:
json_response = self.response.json()
description += ":\n" + json.dumps(json_response, indent=2)
except (AttributeError, KeyError, ValueError): # not JSON = ValueError
try:
description += ": %r" % self.response.text
except AttributeError:
pass
return description
def describe_raised_from(self):
"""Return the original exception"""
if self.raised_from is None:
return None
return ''.join(format_exception_only(type(self.raised_from), self.raised_from)).strip()
class AnymailAPIError(AnymailError):
"""Exception for unsuccessful response from ESP's API."""
class AnymailRequestsAPIError(AnymailAPIError, HTTPError):
"""Exception for unsuccessful response from a requests API."""
def __init__(self, *args, **kwargs):
super(AnymailRequestsAPIError, self).__init__(*args, **kwargs)
if self.response is not None:
self.status_code = self.response.status_code
class AnymailRecipientsRefused(AnymailError):
"""Exception for send where all recipients are invalid or rejected."""
def __init__(self, message=None, *args, **kwargs):
if message is None:
message = "All message recipients were rejected or invalid"
super(AnymailRecipientsRefused, self).__init__(message, *args, **kwargs)
class AnymailInvalidAddress(AnymailError, ValueError):
"""Exception when using an invalidly-formatted email address"""
class AnymailUnsupportedFeature(AnymailError, ValueError):
"""Exception for Anymail features that the ESP doesn't support.
This is typically raised when attempting to send a Django EmailMessage that
uses options or values you might expect to work, but that are silently
ignored by or can't be communicated to the ESP's API.
It's generally *not* raised for ESP-specific limitations, like the number
of tags allowed on a message. (Anymail expects
the ESP to return an API error for these where appropriate, and tries to
avoid duplicating each ESP's validation logic locally.)
"""
class AnymailSerializationError(AnymailError, TypeError):
"""Exception for data that Anymail can't serialize for the ESP's API.
This typically results from including something like a date or Decimal
in your merge_vars.
"""
# inherits from TypeError for compatibility with JSON serialization error
def __init__(self, message=None, orig_err=None, *args, **kwargs):
if message is None:
# self.esp_name not set until super init, so duplicate logic to get esp_name
backend = kwargs.get('backend', None)
esp_name = kwargs.get('esp_name', backend.esp_name if backend 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)
class AnymailCancelSend(AnymailError):
"""Pre-send signal receiver can raise to prevent message send"""
class AnymailWebhookValidationFailure(AnymailError, SuspiciousOperation):
"""Exception when a webhook cannot be validated.
Django's SuspiciousOperation turns into
an HTTP 400 error in production.
"""
class AnymailConfigurationError(ImproperlyConfigured):
"""Exception for Anymail configuration or installation issues"""
# This deliberately doesn't inherit from AnymailError,
# because we don't want it to be swallowed by backend fail_silently
class AnymailImproperlyInstalled(AnymailConfigurationError, ImportError):
"""Exception for Anymail missing package dependencies"""
def __init__(self, missing_package, backend=""):
message = "The %s package is required to use this ESP, but isn't installed.\n" \
"(Be sure to use `pip install django-anymail[%s]` " \
"with your desired ESPs.)" % (missing_package, backend)
super(AnymailImproperlyInstalled, self).__init__(message)
# Warnings
class AnymailWarning(Warning):
"""Base warning for Anymail"""
class AnymailInsecureWebhookWarning(AnymailWarning):
"""Warns when webhook configured without any validation"""
class AnymailDeprecationWarning(AnymailWarning, DeprecationWarning):
"""Warning for deprecated Anymail features"""
# Helpers
class _LazyError(object):
"""An object that sits inert unless/until used, then raises an error"""
def __init__(self, error):
self._error = error
def __call__(self, *args, **kwargs):
raise self._error
def __getattr__(self, item):
raise self._error
django-anymail-7.0.0/anymail/urls.py 0000644 0000765 0000024 00000004704 13263443557 020361 0 ustar medmunds staff 0000000 0000000 from django.conf.urls import url
from .webhooks.amazon_ses import AmazonSESInboundWebhookView, AmazonSESTrackingWebhookView
from .webhooks.mailgun import MailgunInboundWebhookView, MailgunTrackingWebhookView
from .webhooks.mailjet import MailjetInboundWebhookView, MailjetTrackingWebhookView
from .webhooks.mandrill import MandrillCombinedWebhookView
from .webhooks.postmark import PostmarkInboundWebhookView, PostmarkTrackingWebhookView
from .webhooks.sendgrid import SendGridInboundWebhookView, SendGridTrackingWebhookView
from .webhooks.sendinblue import SendinBlueTrackingWebhookView
from .webhooks.sparkpost import SparkPostInboundWebhookView, SparkPostTrackingWebhookView
app_name = 'anymail'
urlpatterns = [
url(r'^amazon_ses/inbound/$', AmazonSESInboundWebhookView.as_view(), name='amazon_ses_inbound_webhook'),
url(r'^mailgun/inbound(_mime)?/$', MailgunInboundWebhookView.as_view(), name='mailgun_inbound_webhook'),
url(r'^mailjet/inbound/$', MailjetInboundWebhookView.as_view(), name='mailjet_inbound_webhook'),
url(r'^postmark/inbound/$', PostmarkInboundWebhookView.as_view(), name='postmark_inbound_webhook'),
url(r'^sendgrid/inbound/$', SendGridInboundWebhookView.as_view(), name='sendgrid_inbound_webhook'),
url(r'^sparkpost/inbound/$', SparkPostInboundWebhookView.as_view(), name='sparkpost_inbound_webhook'),
url(r'^amazon_ses/tracking/$', AmazonSESTrackingWebhookView.as_view(), name='amazon_ses_tracking_webhook'),
url(r'^mailgun/tracking/$', MailgunTrackingWebhookView.as_view(), name='mailgun_tracking_webhook'),
url(r'^mailjet/tracking/$', MailjetTrackingWebhookView.as_view(), name='mailjet_tracking_webhook'),
url(r'^postmark/tracking/$', PostmarkTrackingWebhookView.as_view(), name='postmark_tracking_webhook'),
url(r'^sendgrid/tracking/$', SendGridTrackingWebhookView.as_view(), name='sendgrid_tracking_webhook'),
url(r'^sendinblue/tracking/$', SendinBlueTrackingWebhookView.as_view(), name='sendinblue_tracking_webhook'),
url(r'^sparkpost/tracking/$', SparkPostTrackingWebhookView.as_view(), name='sparkpost_tracking_webhook'),
# Anymail uses a combined Mandrill webhook endpoint, to simplify Mandrill's key-validation scheme:
url(r'^mandrill/$', MandrillCombinedWebhookView.as_view(), name='mandrill_webhook'),
# This url is maintained for backwards compatibility with earlier Anymail releases:
url(r'^mandrill/tracking/$', MandrillCombinedWebhookView.as_view(), name='mandrill_tracking_webhook'),
]
django-anymail-7.0.0/anymail/webhooks/ 0000755 0000765 0000024 00000000000 13535011557 020627 5 ustar medmunds staff 0000000 0000000 django-anymail-7.0.0/anymail/webhooks/postmark.py 0000644 0000765 0000024 00000020455 13333664552 023054 0 ustar medmunds staff 0000000 0000000 import json
from django.utils.dateparse import parse_datetime
from .base import AnymailBaseWebhookView
from ..exceptions import AnymailConfigurationError
from ..inbound import AnymailInboundMessage
from ..signals import inbound, tracking, AnymailInboundEvent, AnymailTrackingEvent, EventType, RejectReason
from ..utils import getfirst, EmailAddress
class PostmarkBaseWebhookView(AnymailBaseWebhookView):
"""Base view class for Postmark webhooks"""
esp_name = "Postmark"
def parse_events(self, request):
esp_event = json.loads(request.body.decode('utf-8'))
return [self.esp_to_anymail_event(esp_event)]
def esp_to_anymail_event(self, esp_event):
raise NotImplementedError()
class PostmarkTrackingWebhookView(PostmarkBaseWebhookView):
"""Handler for Postmark delivery and engagement tracking webhooks"""
signal = tracking
event_record_types = {
# Map Postmark event RecordType --> Anymail normalized event type
'Bounce': EventType.BOUNCED, # but check Type field for further info (below)
'Click': EventType.CLICKED,
'Delivery': EventType.DELIVERED,
'Open': EventType.OPENED,
'SpamComplaint': EventType.COMPLAINED,
'Inbound': EventType.INBOUND, # future, probably
}
event_types = {
# Map Postmark bounce/spam event Type --> Anymail normalized (event type, reject reason)
'HardBounce': (EventType.BOUNCED, RejectReason.BOUNCED),
'Transient': (EventType.DEFERRED, None),
'Unsubscribe': (EventType.UNSUBSCRIBED, RejectReason.UNSUBSCRIBED),
'Subscribe': (EventType.SUBSCRIBED, None),
'AutoResponder': (EventType.AUTORESPONDED, None),
'AddressChange': (EventType.AUTORESPONDED, None),
'DnsError': (EventType.DEFERRED, None), # "temporary DNS error"
'SpamNotification': (EventType.COMPLAINED, RejectReason.SPAM),
'OpenRelayTest': (EventType.DEFERRED, None), # Receiving MTA is testing Postmark
'Unknown': (EventType.UNKNOWN, None),
'SoftBounce': (EventType.BOUNCED, RejectReason.BOUNCED), # might also receive HardBounce later
'VirusNotification': (EventType.BOUNCED, RejectReason.OTHER),
'ChallengeVerification': (EventType.AUTORESPONDED, None),
'BadEmailAddress': (EventType.REJECTED, RejectReason.INVALID),
'SpamComplaint': (EventType.COMPLAINED, RejectReason.SPAM),
'ManuallyDeactivated': (EventType.REJECTED, RejectReason.BLOCKED),
'Unconfirmed': (EventType.REJECTED, None),
'Blocked': (EventType.REJECTED, RejectReason.BLOCKED),
'SMTPApiError': (EventType.FAILED, None), # could occur if user also using Postmark SMTP directly
'InboundError': (EventType.INBOUND_FAILED, None),
'DMARCPolicy': (EventType.REJECTED, RejectReason.BLOCKED),
'TemplateRenderingFailed': (EventType.FAILED, None),
}
def esp_to_anymail_event(self, esp_event):
reject_reason = None
try:
esp_record_type = esp_event["RecordType"]
except KeyError:
if 'FromFull' in esp_event:
# This is an inbound event
event_type = EventType.INBOUND
else:
event_type = EventType.UNKNOWN
else:
event_type = self.event_record_types.get(esp_record_type, EventType.UNKNOWN)
if event_type == EventType.INBOUND:
raise AnymailConfigurationError(
"You seem to have set Postmark's *inbound* webhook "
"to Anymail's Postmark *tracking* webhook URL.")
if event_type in (EventType.BOUNCED, EventType.COMPLAINED):
# additional info is in the Type field
try:
event_type, reject_reason = self.event_types[esp_event['Type']]
except KeyError:
pass
recipient = getfirst(esp_event, ['Email', 'Recipient'], None) # Email for bounce; Recipient for open
try:
timestr = getfirst(esp_event, ['DeliveredAt', 'BouncedAt', 'ReceivedAt'])
except KeyError:
timestamp = None
else:
timestamp = parse_datetime(timestr)
try:
event_id = str(esp_event['ID']) # only in bounce events
except KeyError:
event_id = None
metadata = esp_event.get('Metadata', {})
try:
tags = [esp_event['Tag']]
except KeyError:
tags = []
return AnymailTrackingEvent(
description=esp_event.get('Description', None),
esp_event=esp_event,
event_id=event_id,
event_type=event_type,
message_id=esp_event.get('MessageID', None),
metadata=metadata,
mta_response=esp_event.get('Details', None),
recipient=recipient,
reject_reason=reject_reason,
tags=tags,
timestamp=timestamp,
user_agent=esp_event.get('UserAgent', None),
click_url=esp_event.get('OriginalLink', None),
)
class PostmarkInboundWebhookView(PostmarkBaseWebhookView):
"""Handler for Postmark inbound webhook"""
signal = inbound
def esp_to_anymail_event(self, esp_event):
if esp_event.get("RecordType", "Inbound") != "Inbound":
raise AnymailConfigurationError(
"You seem to have set Postmark's *%s* webhook "
"to Anymail's Postmark *inbound* webhook URL." % esp_event["RecordType"])
attachments = [
AnymailInboundMessage.construct_attachment(
content_type=attachment["ContentType"],
content=attachment["Content"], base64=True,
filename=attachment.get("Name", "") or None,
content_id=attachment.get("ContentID", "") or None,
)
for attachment in esp_event.get("Attachments", [])
]
message = AnymailInboundMessage.construct(
from_email=self._address(esp_event.get("FromFull")),
to=', '.join([self._address(to) for to in esp_event.get("ToFull", [])]),
cc=', '.join([self._address(cc) for cc in esp_event.get("CcFull", [])]),
# bcc? Postmark specs this for inbound events, but it's unclear how it could occur
subject=esp_event.get("Subject", ""),
headers=[(header["Name"], header["Value"]) for header in esp_event.get("Headers", [])],
text=esp_event.get("TextBody", ""),
html=esp_event.get("HtmlBody", ""),
attachments=attachments,
)
# Postmark strips these headers and provides them as separate event fields:
if "Date" in esp_event and "Date" not in message:
message["Date"] = esp_event["Date"]
if "ReplyTo" in esp_event and "Reply-To" not in message:
message["Reply-To"] = esp_event["ReplyTo"]
# Postmark doesn't have a separate envelope-sender field, but it can be extracted
# from the Received-SPF header that Postmark will have added:
if len(message.get_all("Received-SPF", [])) == 1: # (more than one? someone's up to something weird)
received_spf = message["Received-SPF"].lower()
if received_spf.startswith("pass") or received_spf.startswith("neutral"): # not fail/softfail
message.envelope_sender = message.get_param("envelope-from", None, header="Received-SPF")
message.envelope_recipient = esp_event.get("OriginalRecipient", None)
message.stripped_text = esp_event.get("StrippedTextReply", None)
message.spam_detected = message.get('X-Spam-Status', 'No').lower() == 'yes'
try:
message.spam_score = float(message['X-Spam-Score'])
except (TypeError, ValueError):
pass
return AnymailInboundEvent(
event_type=EventType.INBOUND,
timestamp=None, # Postmark doesn't provide inbound event timestamp
event_id=esp_event.get("MessageID", None), # Postmark uuid, different from Message-ID mime header
esp_event=esp_event,
message=message,
)
@staticmethod
def _address(full):
"""Return an formatted email address from a Postmark inbound {From,To,Cc}Full dict"""
if full is None:
return ""
return str(EmailAddress(
display_name=full.get('Name', ""),
addr_spec=full.get("Email", ""),
))
django-anymail-7.0.0/anymail/webhooks/sendinblue.py 0000644 0000765 0000024 00000007026 13535006032 023326 0 ustar medmunds staff 0000000 0000000 import json
from datetime import datetime
from django.utils.timezone import utc
from .base import AnymailBaseWebhookView
from ..signals import AnymailTrackingEvent, EventType, RejectReason, tracking
class SendinBlueTrackingWebhookView(AnymailBaseWebhookView):
"""Handler for SendinBlue delivery and engagement tracking webhooks"""
esp_name = "SendinBlue"
signal = tracking
def parse_events(self, request):
esp_event = json.loads(request.body.decode('utf-8'))
return [self.esp_to_anymail_event(esp_event)]
# SendinBlue's webhook payload data doesn't seem to be documented anywhere.
# There's a list of webhook events at https://apidocs.sendinblue.com/webhooks/#3.
event_types = {
# Map SendinBlue event type: Anymail normalized (event type, reject reason)
"request": (EventType.QUEUED, None), # received even if message won't be sent (e.g., before "blocked")
"delivered": (EventType.DELIVERED, None),
"hard_bounce": (EventType.BOUNCED, RejectReason.BOUNCED),
"soft_bounce": (EventType.BOUNCED, RejectReason.BOUNCED),
"blocked": (EventType.REJECTED, RejectReason.BLOCKED),
"spam": (EventType.COMPLAINED, RejectReason.SPAM),
"invalid_email": (EventType.BOUNCED, RejectReason.INVALID),
"deferred": (EventType.DEFERRED, None),
"opened": (EventType.OPENED, None), # see also unique_opened below
"click": (EventType.CLICKED, None),
"unsubscribe": (EventType.UNSUBSCRIBED, None),
"list_addition": (EventType.SUBSCRIBED, None), # shouldn't occur for transactional messages
"unique_opened": (EventType.OPENED, None), # you'll *also* receive an "opened"
}
def esp_to_anymail_event(self, esp_event):
esp_type = esp_event.get("event")
event_type, reject_reason = self.event_types.get(esp_type, (EventType.UNKNOWN, None))
recipient = esp_event.get("email")
try:
# SendinBlue supplies "ts", "ts_event" and "date" fields, which seem to be based on the
# timezone set in the account preferences (and possibly with inconsistent DST adjustment).
# "ts_epoch" is the only field that seems to be consistently UTC; it's in milliseconds
timestamp = datetime.fromtimestamp(esp_event["ts_epoch"] / 1000.0, tz=utc)
except (KeyError, ValueError):
timestamp = None
tags = []
try:
# If `tags` param set on send, webhook payload includes 'tags' array field.
tags = esp_event['tags']
except KeyError:
try:
# If `X-Mailin-Tag` header set on send, webhook payload includes single 'tag' string.
# (If header not set, webhook 'tag' will be the template name for template sends.)
tags = [esp_event['tag']]
except KeyError:
pass
try:
metadata = json.loads(esp_event["X-Mailin-custom"])
except (KeyError, TypeError):
metadata = {}
return AnymailTrackingEvent(
description=None,
esp_event=esp_event,
event_id=None, # SendinBlue doesn't provide a unique event id
event_type=event_type,
message_id=esp_event.get("message-id"),
metadata=metadata,
mta_response=esp_event.get("reason"),
recipient=recipient,
reject_reason=reject_reason,
tags=tags,
timestamp=timestamp,
user_agent=None,
click_url=esp_event.get("link"),
)
django-anymail-7.0.0/anymail/webhooks/__init__.py 0000644 0000765 0000024 00000000000 12711003175 022716 0 ustar medmunds staff 0000000 0000000 django-anymail-7.0.0/anymail/webhooks/mailjet.py 0000644 0000765 0000024 00000017572 13276615125 022645 0 ustar medmunds staff 0000000 0000000 import json
from datetime import datetime
from django.utils.timezone import utc
from .base import AnymailBaseWebhookView
from ..inbound import AnymailInboundMessage
from ..signals import inbound, tracking, AnymailInboundEvent, AnymailTrackingEvent, EventType, RejectReason
class MailjetTrackingWebhookView(AnymailBaseWebhookView):
"""Handler for Mailjet delivery and engagement tracking webhooks"""
esp_name = "Mailjet"
signal = tracking
def parse_events(self, request):
esp_events = json.loads(request.body.decode('utf-8'))
# Mailjet webhook docs say the payload is "a JSON array of event objects,"
# but that's not true if "group events" isn't enabled in webhook config...
try:
esp_events[0] # is this really an array of events?
except IndexError:
pass # yep (and it's empty?!)
except KeyError:
esp_events = [esp_events] # nope, it's a single, bare event
return [self.esp_to_anymail_event(esp_event) for esp_event in esp_events]
# https://dev.mailjet.com/guides/#events
event_types = {
# Map Mailjet event: Anymail normalized type
'sent': EventType.DELIVERED, # accepted by receiving MTA
'open': EventType.OPENED,
'click': EventType.CLICKED,
'bounce': EventType.BOUNCED,
'blocked': EventType.REJECTED,
'spam': EventType.COMPLAINED,
'unsub': EventType.UNSUBSCRIBED,
}
reject_reasons = {
# Map Mailjet error strings to Anymail normalized reject_reason
# error_related_to: recipient
'user unknown': RejectReason.BOUNCED,
'mailbox inactive': RejectReason.BOUNCED,
'quota exceeded': RejectReason.BOUNCED,
'blacklisted': RejectReason.BLOCKED, # might also be previous unsubscribe
'spam reporter': RejectReason.SPAM,
# error_related_to: domain
'invalid domain': RejectReason.BOUNCED,
'no mail host': RejectReason.BOUNCED,
'relay/access denied': RejectReason.BOUNCED,
'greylisted': RejectReason.OTHER, # see special handling below
'typofix': RejectReason.INVALID,
# error_related_to: spam (all Mailjet policy/filtering; see above for spam complaints)
'sender blocked': RejectReason.BLOCKED,
'content blocked': RejectReason.BLOCKED,
'policy issue': RejectReason.BLOCKED,
# error_related_to: mailjet
'preblocked': RejectReason.BLOCKED,
'duplicate in campaign': RejectReason.OTHER,
}
def esp_to_anymail_event(self, esp_event):
event_type = self.event_types.get(esp_event['event'], EventType.UNKNOWN)
if esp_event.get('error', None) == 'greylisted' and not esp_event.get('hard_bounce', False):
# "This is a temporary error due to possible unrecognised senders. Delivery will be re-attempted."
event_type = EventType.DEFERRED
try:
timestamp = datetime.fromtimestamp(esp_event['time'], tz=utc)
except (KeyError, ValueError):
timestamp = None
try:
# convert bigint MessageID to str to match backend AnymailRecipientStatus
message_id = str(esp_event['MessageID'])
except (KeyError, TypeError):
message_id = None
if 'error' in esp_event:
reject_reason = self.reject_reasons.get(esp_event['error'], RejectReason.OTHER)
else:
reject_reason = None
tag = esp_event.get('customcampaign', None)
tags = [tag] if tag else []
try:
metadata = json.loads(esp_event['Payload'])
except (KeyError, ValueError):
metadata = {}
return AnymailTrackingEvent(
event_type=event_type,
timestamp=timestamp,
message_id=message_id,
event_id=None,
recipient=esp_event.get('email', None),
reject_reason=reject_reason,
mta_response=esp_event.get('smtp_reply', None),
tags=tags,
metadata=metadata,
click_url=esp_event.get('url', None),
user_agent=esp_event.get('agent', None),
esp_event=esp_event,
)
class MailjetInboundWebhookView(AnymailBaseWebhookView):
"""Handler for Mailjet inbound (parse API) webhook"""
esp_name = "Mailjet"
signal = inbound
def parse_events(self, request):
esp_event = json.loads(request.body.decode('utf-8'))
return [self.esp_to_anymail_event(esp_event)]
def esp_to_anymail_event(self, esp_event):
# You could _almost_ reconstruct the raw mime message from Mailjet's Headers and Parts fields,
# but it's not clear which multipart boundary to use on each individual Part. Although each Part's
# Content-Type header still has the multipart boundary, not knowing the parent part means typical
# nested multipart structures can't be reliably recovered from the data Mailjet provides.
# We'll just use our standarized multipart inbound constructor.
headers = self._flatten_mailjet_headers(esp_event.get("Headers", {}))
attachments = [
self._construct_mailjet_attachment(part, esp_event)
for part in esp_event.get("Parts", [])
if "Attachment" in part.get("ContentRef", "") # Attachment or InlineAttachment
]
message = AnymailInboundMessage.construct(
headers=headers,
text=esp_event.get("Text-part", None),
html=esp_event.get("Html-part", None),
attachments=attachments,
)
message.envelope_sender = esp_event.get("Sender", None)
message.envelope_recipient = esp_event.get("Recipient", None)
message.spam_detected = None # Mailjet doesn't provide a boolean; you'll have to interpret spam_score
try:
message.spam_score = float(esp_event['SpamAssassinScore'])
except (KeyError, TypeError, ValueError):
pass
return AnymailInboundEvent(
event_type=EventType.INBOUND,
timestamp=None, # Mailjet doesn't provide inbound event timestamp (esp_event['Date'] is time sent)
event_id=None, # Mailjet doesn't provide an idempotent inbound event id
esp_event=esp_event,
message=message,
)
@staticmethod
def _flatten_mailjet_headers(headers):
"""Convert Mailjet's dict-of-strings-and/or-lists header format to our list-of-name-value-pairs
{'name1': 'value', 'name2': ['value1', 'value2']}
--> [('name1', 'value'), ('name2', 'value1'), ('name2', 'value2')]
"""
result = []
for name, values in headers.items():
if isinstance(values, list): # Mailjet groups repeated headers together as a list of values
for value in values:
result.append((name, value))
else:
result.append((name, values)) # single-valued (non-list) header
return result
def _construct_mailjet_attachment(self, part, esp_event):
# Mailjet includes unparsed attachment headers in each part; it's easiest to temporarily
# attach them to a MIMEPart for parsing. (We could just turn this into the attachment,
# but we want to use the payload handling from AnymailInboundMessage.construct_attachment later.)
part_headers = AnymailInboundMessage() # temporary container for parsed attachment headers
for name, value in self._flatten_mailjet_headers(part.get("Headers", {})):
part_headers.add_header(name, value)
content_base64 = esp_event[part["ContentRef"]] # Mailjet *always* base64-encodes attachments
return AnymailInboundMessage.construct_attachment(
content_type=part_headers.get_content_type(),
content=content_base64, base64=True,
filename=part_headers.get_filename(None),
content_id=part_headers.get("Content-ID", "") or None,
)
django-anymail-7.0.0/anymail/webhooks/mailgun.py 0000644 0000765 0000024 00000045403 13510442376 022643 0 ustar medmunds staff 0000000 0000000 import json
from datetime import datetime
import hashlib
import hmac
from django.utils.crypto import constant_time_compare
from django.utils.timezone import utc
from .base import AnymailBaseWebhookView
from ..exceptions import AnymailConfigurationError, AnymailWebhookValidationFailure, AnymailInvalidAddress
from ..inbound import AnymailInboundMessage
from ..signals import inbound, tracking, AnymailInboundEvent, AnymailTrackingEvent, EventType, RejectReason
from ..utils import get_anymail_setting, combine, querydict_getfirst, parse_single_address, UNSET
class MailgunBaseWebhookView(AnymailBaseWebhookView):
"""Base view class for Mailgun webhooks"""
esp_name = "Mailgun"
warn_if_no_basic_auth = False # because we validate against signature
webhook_signing_key = None # (Declaring class attr allows override by kwargs in View.as_view.)
# The `api_key` attribute name is still allowed for compatibility with earlier Anymail releases.
api_key = None # (Declaring class attr allows override by kwargs in View.as_view.)
def __init__(self, **kwargs):
# webhook_signing_key: falls back to api_key if webhook_signing_key not provided
api_key = get_anymail_setting('api_key', esp_name=self.esp_name,
kwargs=kwargs, allow_bare=True, default=None)
webhook_signing_key = get_anymail_setting('webhook_signing_key', esp_name=self.esp_name,
kwargs=kwargs, default=UNSET if api_key is None else api_key)
self.webhook_signing_key = webhook_signing_key.encode('ascii') # hmac.new requires bytes key in python 3
super(MailgunBaseWebhookView, self).__init__(**kwargs)
def validate_request(self, request):
super(MailgunBaseWebhookView, self).validate_request(request) # first check basic auth if enabled
if request.content_type == "application/json":
# New-style webhook: json payload with separate signature block
try:
event = json.loads(request.body.decode('utf-8'))
signature_block = event['signature']
token = signature_block['token']
timestamp = signature_block['timestamp']
signature = signature_block['signature']
except (KeyError, ValueError, UnicodeDecodeError) as err:
raise AnymailWebhookValidationFailure(
"Mailgun webhook called with invalid payload format",
raised_from=err)
else:
# Legacy webhook: signature fields are interspersed with other POST data
try:
# Must use the *last* value of these fields if there are conflicting merged user-variables.
# (Fortunately, Django QueryDict is specced to return the last value.)
token = request.POST['token']
timestamp = request.POST['timestamp']
signature = str(request.POST['signature']) # force to same type as hexdigest() (for python2)
except KeyError:
raise AnymailWebhookValidationFailure("Mailgun webhook called without required security fields")
expected_signature = hmac.new(key=self.webhook_signing_key, msg='{}{}'.format(timestamp, token).encode('ascii'),
digestmod=hashlib.sha256).hexdigest()
if not constant_time_compare(signature, expected_signature):
raise AnymailWebhookValidationFailure("Mailgun webhook called with incorrect signature")
class MailgunTrackingWebhookView(MailgunBaseWebhookView):
"""Handler for Mailgun delivery and engagement tracking webhooks"""
signal = tracking
def parse_events(self, request):
if request.content_type == "application/json":
esp_event = json.loads(request.body.decode('utf-8'))
return [self.esp_to_anymail_event(esp_event)]
else:
return [self.mailgun_legacy_to_anymail_event(request.POST)]
event_types = {
# Map Mailgun event: Anymail normalized type
'accepted': EventType.QUEUED, # not delivered to webhooks (8/2018)
'rejected': EventType.REJECTED,
'delivered': EventType.DELIVERED,
'failed': EventType.BOUNCED,
'opened': EventType.OPENED,
'clicked': EventType.CLICKED,
'unsubscribed': EventType.UNSUBSCRIBED,
'complained': EventType.COMPLAINED,
}
reject_reasons = {
# Map Mailgun event_data.reason: Anymail normalized RejectReason
# (these appear in webhook doc examples, but aren't actually documented anywhere)
"bounce": RejectReason.BOUNCED,
"suppress-bounce": RejectReason.BOUNCED,
"generic": RejectReason.OTHER, # ??? appears to be used for any temporary failure?
}
severities = {
# Remap some event types based on "severity" payload field
(EventType.BOUNCED, 'temporary'): EventType.DEFERRED
}
def esp_to_anymail_event(self, esp_event):
event_data = esp_event.get('event-data', {})
event_type = self.event_types.get(event_data['event'], EventType.UNKNOWN)
event_type = self.severities.get((EventType.BOUNCED, event_data.get('severity')), event_type)
# Use signature.token for event_id, rather than event_data.id,
# because the latter is only "guaranteed to be unique within a day".
event_id = esp_event.get('signature', {}).get('token')
recipient = event_data.get('recipient')
try:
timestamp = datetime.fromtimestamp(float(event_data['timestamp']), tz=utc)
except KeyError:
timestamp = None
try:
message_id = event_data['message']['headers']['message-id']
except KeyError:
message_id = None
if message_id and not message_id.startswith('<'):
message_id = "<{}>".format(message_id)
metadata = event_data.get('user-variables', {})
tags = event_data.get('tags', [])
try:
delivery_status = event_data['delivery-status']
except KeyError:
description = None
mta_response = None
else:
description = delivery_status.get('description')
mta_response = delivery_status.get('message')
if 'reason' in event_data:
reject_reason = self.reject_reasons.get(event_data['reason'], RejectReason.OTHER)
else:
reject_reason = None
if event_type == EventType.REJECTED:
# This event has a somewhat different structure than the others...
description = description or event_data.get("reject", {}).get("reason")
reject_reason = reject_reason or RejectReason.OTHER
if not recipient:
try:
to_email = parse_single_address(
event_data["message"]["headers"]["to"])
except (AnymailInvalidAddress, KeyError):
pass
else:
recipient = to_email.addr_spec
return AnymailTrackingEvent(
event_type=event_type,
timestamp=timestamp,
message_id=message_id,
event_id=event_id,
recipient=recipient,
reject_reason=reject_reason,
description=description,
mta_response=mta_response,
tags=tags,
metadata=metadata,
click_url=event_data.get('url'),
user_agent=event_data.get('client-info', {}).get('user-agent'),
esp_event=esp_event,
)
# Legacy event handling
# (Prior to 2018-06-29, these were the only Mailgun events.)
legacy_event_types = {
# Map Mailgun event: Anymail normalized type
'delivered': EventType.DELIVERED,
'dropped': EventType.REJECTED,
'bounced': EventType.BOUNCED,
'complained': EventType.COMPLAINED,
'unsubscribed': EventType.UNSUBSCRIBED,
'opened': EventType.OPENED,
'clicked': EventType.CLICKED,
# Mailgun does not send events corresponding to QUEUED or DEFERRED
}
legacy_reject_reasons = {
# Map Mailgun (SMTP) error codes to Anymail normalized reject_reason.
# By default, we will treat anything 400-599 as REJECT_BOUNCED
# so only exceptions are listed here.
499: RejectReason.TIMED_OUT, # unable to connect to MX (also covers invalid recipients)
# These 6xx codes appear to be Mailgun extensions to SMTP
# (and don't seem to be documented anywhere):
605: RejectReason.BOUNCED, # previous bounce
607: RejectReason.SPAM, # previous spam complaint
}
def mailgun_legacy_to_anymail_event(self, esp_event):
# esp_event is a Django QueryDict (from request.POST),
# which has multi-valued fields, but is *not* case-insensitive.
# Because of the way Mailgun merges user-variables into the event,
# we must generally use the *first* value of any multi-valued field
# to avoid potential conflicting user-data.
esp_event.getfirst = querydict_getfirst.__get__(esp_event)
if 'event' not in esp_event and 'sender' in esp_event:
# Inbound events don't (currently) have an event field
raise AnymailConfigurationError(
"You seem to have set Mailgun's *inbound* route "
"to Anymail's Mailgun *tracking* webhook URL.")
event_type = self.legacy_event_types.get(esp_event.getfirst('event'), EventType.UNKNOWN)
timestamp = datetime.fromtimestamp(int(esp_event['timestamp']), tz=utc) # use *last* value of timestamp
# Message-Id is not documented for every event, but seems to always be included.
# (It's sometimes spelled as 'message-id', lowercase, and missing the .)
message_id = esp_event.getfirst('Message-Id', None) or esp_event.getfirst('message-id', None)
if message_id and not message_id.startswith('<'):
message_id = "<{}>".format(message_id)
description = esp_event.getfirst('description', None)
mta_response = esp_event.getfirst('error', None) or esp_event.getfirst('notification', None)
reject_reason = None
try:
mta_status = int(esp_event.getfirst('code'))
except (KeyError, TypeError):
pass
except ValueError:
# RFC-3463 extended SMTP status code (class.subject.detail, where class is "2", "4" or "5")
try:
status_class = esp_event.getfirst('code').split('.')[0]
except (TypeError, IndexError):
# illegal SMTP status code format
pass
else:
reject_reason = RejectReason.BOUNCED if status_class in ("4", "5") else RejectReason.OTHER
else:
reject_reason = self.legacy_reject_reasons.get(
mta_status,
RejectReason.BOUNCED if 400 <= mta_status < 600
else RejectReason.OTHER)
metadata = self._extract_legacy_metadata(esp_event)
# tags are supposed to be in 'tag' fields, but are sometimes in undocumented X-Mailgun-Tag
tags = esp_event.getlist('tag', None) or esp_event.getlist('X-Mailgun-Tag', [])
return AnymailTrackingEvent(
event_type=event_type,
timestamp=timestamp,
message_id=message_id,
event_id=esp_event.get('token', None), # use *last* value of token
recipient=esp_event.getfirst('recipient', None),
reject_reason=reject_reason,
description=description,
mta_response=mta_response,
tags=tags,
metadata=metadata,
click_url=esp_event.getfirst('url', None),
user_agent=esp_event.getfirst('user-agent', None),
esp_event=esp_event,
)
def _extract_legacy_metadata(self, esp_event):
# Mailgun merges user-variables into the POST fields. If you know which user variable
# you want to retrieve--and it doesn't conflict with a Mailgun event field--that's fine.
# But if you want to extract all user-variables (like we do), it's more complicated...
event_type = esp_event.getfirst('event')
metadata = {}
if 'message-headers' in esp_event:
# For events where original message headers are available, it's most reliable
# to recover user-variables from the X-Mailgun-Variables header(s).
headers = json.loads(esp_event['message-headers'])
variables = [value for [field, value] in headers if field == 'X-Mailgun-Variables']
if len(variables) >= 1:
# Each X-Mailgun-Variables value is JSON. Parse and merge them all into single dict:
metadata = combine(*[json.loads(value) for value in variables])
elif event_type in self._known_legacy_event_fields:
# For other events, we must extract from the POST fields, ignoring known Mailgun
# event parameters, and treating all other values as user-variables.
known_fields = self._known_legacy_event_fields[event_type]
for field, values in esp_event.lists():
if field not in known_fields:
# Unknown fields are assumed to be user-variables. (There should really only be
# a single value, but just in case take the last one to match QueryDict semantics.)
metadata[field] = values[-1]
elif field == 'tag':
# There's no way to distinguish a user-variable named 'tag' from an actual tag,
# so don't treat this/these value(s) as metadata.
pass
elif len(values) == 1:
# This is an expected event parameter, and since there's only a single value
# it must be the event param, not metadata.
pass
else:
# This is an expected event parameter, but there are (at least) two values.
# One is the event param, and the other is a user-variable metadata value.
# Which is which depends on the field:
if field in {'signature', 'timestamp', 'token'}:
metadata[field] = values[0] # values = [user-variable, event-param]
else:
metadata[field] = values[-1] # values = [event-param, user-variable]
return metadata
_common_legacy_event_fields = {
# These fields are documented to appear in all Mailgun opened, clicked and unsubscribed events:
'event', 'recipient', 'domain', 'ip', 'country', 'region', 'city', 'user-agent', 'device-type',
'client-type', 'client-name', 'client-os', 'campaign-id', 'campaign-name', 'tag', 'mailing-list',
'timestamp', 'token', 'signature',
# Undocumented, but observed in actual events:
'body-plain', 'h', 'message-id',
}
_known_legacy_event_fields = {
# For all Mailgun event types that *don't* include message-headers,
# map Mailgun (not normalized) event type to set of expected event fields.
# Used for metadata extraction.
'clicked': _common_legacy_event_fields | {'url'},
'opened': _common_legacy_event_fields,
'unsubscribed': _common_legacy_event_fields,
}
class MailgunInboundWebhookView(MailgunBaseWebhookView):
"""Handler for Mailgun inbound (route forward-to-url) webhook"""
signal = inbound
def parse_events(self, request):
if request.content_type == "application/json":
esp_event = json.loads(request.body.decode('utf-8'))
event_type = esp_event.get('event-data', {}).get('event', '')
raise AnymailConfigurationError(
"You seem to have set Mailgun's *%s tracking* webhook "
"to Anymail's Mailgun *inbound* webhook URL. "
"(Or Mailgun has changed inbound events to use json.)"
% event_type)
return [self.esp_to_anymail_event(request)]
def esp_to_anymail_event(self, request):
# Inbound uses the entire Django request as esp_event, because we need POST and FILES.
# Note that request.POST is case-sensitive (unlike email.message.Message headers).
esp_event = request
if request.POST.get('event', 'inbound') != 'inbound':
# (Legacy) tracking event
raise AnymailConfigurationError(
"You seem to have set Mailgun's *%s tracking* webhook "
"to Anymail's Mailgun *inbound* webhook URL." % request.POST['event'])
if 'body-mime' in request.POST:
# Raw-MIME
message = AnymailInboundMessage.parse_raw_mime(request.POST['body-mime'])
else:
# Fully-parsed
message = self.message_from_mailgun_parsed(request)
message.envelope_sender = request.POST.get('sender', None)
message.envelope_recipient = request.POST.get('recipient', None)
message.stripped_text = request.POST.get('stripped-text', None)
message.stripped_html = request.POST.get('stripped-html', None)
message.spam_detected = message.get('X-Mailgun-Sflag', 'No').lower() == 'yes'
try:
message.spam_score = float(message['X-Mailgun-Sscore'])
except (TypeError, ValueError):
pass
return AnymailInboundEvent(
event_type=EventType.INBOUND,
timestamp=datetime.fromtimestamp(int(request.POST['timestamp']), tz=utc),
event_id=request.POST.get('token', None),
esp_event=esp_event,
message=message,
)
def message_from_mailgun_parsed(self, request):
"""Construct a Message from Mailgun's "fully-parsed" fields"""
# Mailgun transcodes all fields to UTF-8 for "fully parsed" messages
try:
attachment_count = int(request.POST['attachment-count'])
except (KeyError, TypeError):
attachments = None
else:
# Load attachments from posted files: Mailgun file field names are 1-based
att_ids = ['attachment-%d' % i for i in range(1, attachment_count+1)]
att_cids = { # filename: content-id (invert content-id-map)
att_id: cid for cid, att_id
in json.loads(request.POST.get('content-id-map', '{}')).items()
}
attachments = [
AnymailInboundMessage.construct_attachment_from_uploaded_file(
request.FILES[att_id], content_id=att_cids.get(att_id, None))
for att_id in att_ids
]
return AnymailInboundMessage.construct(
headers=json.loads(request.POST['message-headers']), # includes From, To, Cc, Subject, etc.
text=request.POST.get('body-plain', None),
html=request.POST.get('body-html', None),
attachments=attachments,
)
django-anymail-7.0.0/anymail/webhooks/sendgrid.py 0000644 0000765 0000024 00000016531 13303576414 023007 0 ustar medmunds staff 0000000 0000000 import json
from datetime import datetime
from django.utils.timezone import utc
from .base import AnymailBaseWebhookView
from ..inbound import AnymailInboundMessage
from ..signals import inbound, tracking, AnymailInboundEvent, AnymailTrackingEvent, EventType, RejectReason
class SendGridTrackingWebhookView(AnymailBaseWebhookView):
"""Handler for SendGrid delivery and engagement tracking webhooks"""
esp_name = "SendGrid"
signal = tracking
def parse_events(self, request):
esp_events = json.loads(request.body.decode('utf-8'))
return [self.esp_to_anymail_event(esp_event) for esp_event in esp_events]
event_types = {
# Map SendGrid event: Anymail normalized type
'bounce': EventType.BOUNCED,
'deferred': EventType.DEFERRED,
'delivered': EventType.DELIVERED,
'dropped': EventType.REJECTED,
'processed': EventType.QUEUED,
'click': EventType.CLICKED,
'open': EventType.OPENED,
'spamreport': EventType.COMPLAINED,
'unsubscribe': EventType.UNSUBSCRIBED,
'group_unsubscribe': EventType.UNSUBSCRIBED,
'group_resubscribe': EventType.SUBSCRIBED,
}
reject_reasons = {
# Map SendGrid reason/type strings (lowercased) to Anymail normalized reject_reason
'invalid': RejectReason.INVALID,
'unsubscribed address': RejectReason.UNSUBSCRIBED,
'bounce': RejectReason.BOUNCED,
'blocked': RejectReason.BLOCKED,
'expired': RejectReason.TIMED_OUT,
}
def esp_to_anymail_event(self, esp_event):
event_type = self.event_types.get(esp_event['event'], EventType.UNKNOWN)
try:
timestamp = datetime.fromtimestamp(esp_event['timestamp'], tz=utc)
except (KeyError, ValueError):
timestamp = None
if esp_event['event'] == 'dropped':
mta_response = None # dropped at ESP before even getting to MTA
reason = esp_event.get('type', esp_event.get('reason', '')) # cause could be in 'type' or 'reason'
reject_reason = self.reject_reasons.get(reason.lower(), RejectReason.OTHER)
else:
# MTA response is in 'response' for delivered; 'reason' for bounce
mta_response = esp_event.get('response', esp_event.get('reason', None))
reject_reason = None
# SendGrid merges metadata ('unique_args') with the event.
# We can (sort of) split metadata back out by filtering known
# SendGrid event params, though this can miss metadata keys
# that duplicate SendGrid params, and can accidentally include
# non-metadata keys if SendGrid modifies their event records.
metadata_keys = set(esp_event.keys()) - self.sendgrid_event_keys
if len(metadata_keys) > 0:
metadata = {key: esp_event[key] for key in metadata_keys}
else:
metadata = {}
return AnymailTrackingEvent(
event_type=event_type,
timestamp=timestamp,
message_id=esp_event.get('anymail_id', esp_event.get('smtp-id')), # backwards compatibility
event_id=esp_event.get('sg_event_id', None),
recipient=esp_event.get('email', None),
reject_reason=reject_reason,
mta_response=mta_response,
tags=esp_event.get('category', []),
metadata=metadata,
click_url=esp_event.get('url', None),
user_agent=esp_event.get('useragent', None),
esp_event=esp_event,
)
# Known keys in SendGrid events (used to recover metadata above)
sendgrid_event_keys = {
'anymail_id',
'asm_group_id',
'attempt', # MTA deferred count
'category',
'cert_err',
'email',
'event',
'ip',
'marketing_campaign_id',
'marketing_campaign_name',
'newsletter', # ???
'nlvx_campaign_id',
'nlvx_campaign_split_id',
'nlvx_user_id',
'pool',
'post_type',
'reason', # MTA bounce/drop reason; SendGrid suppression reason
'response', # MTA deferred/delivered message
'send_at',
'sg_event_id',
'sg_message_id',
'smtp-id',
'status', # SMTP status code
'timestamp',
'tls',
'type', # suppression reject reason ("bounce", "blocked", "expired")
'url', # click tracking
'url_offset', # click tracking
'useragent', # click/open tracking
}
class SendGridInboundWebhookView(AnymailBaseWebhookView):
"""Handler for SendGrid inbound webhook"""
esp_name = "SendGrid"
signal = inbound
def parse_events(self, request):
return [self.esp_to_anymail_event(request)]
def esp_to_anymail_event(self, request):
# Inbound uses the entire Django request as esp_event, because we need POST and FILES.
# Note that request.POST is case-sensitive (unlike email.message.Message headers).
esp_event = request
if 'headers' in request.POST:
# Default (not "Send Raw") inbound fields
message = self.message_from_sendgrid_parsed(esp_event)
elif 'email' in request.POST:
# "Send Raw" full MIME
message = AnymailInboundMessage.parse_raw_mime(request.POST['email'])
else:
raise KeyError("Invalid SendGrid inbound event data (missing both 'headers' and 'email' fields)")
try:
envelope = json.loads(request.POST['envelope'])
except (KeyError, TypeError, ValueError):
pass
else:
message.envelope_sender = envelope['from']
message.envelope_recipient = envelope['to'][0]
message.spam_detected = None # no simple boolean field; would need to parse the spam_report
try:
message.spam_score = float(request.POST['spam_score'])
except (KeyError, TypeError, ValueError):
pass
return AnymailInboundEvent(
event_type=EventType.INBOUND,
timestamp=None, # SendGrid doesn't provide an inbound event timestamp
event_id=None, # SendGrid doesn't provide an idempotent inbound message event id
esp_event=esp_event,
message=message,
)
def message_from_sendgrid_parsed(self, request):
"""Construct a Message from SendGrid's "default" (non-raw) fields"""
try:
charsets = json.loads(request.POST['charsets'])
except (KeyError, ValueError):
charsets = {}
try:
attachment_info = json.loads(request.POST['attachment-info'])
except (KeyError, ValueError):
attachments = None
else:
# Load attachments from posted files
attachments = [
AnymailInboundMessage.construct_attachment_from_uploaded_file(
request.FILES[att_id],
content_id=attachment_info[att_id].get("content-id", None))
for att_id in sorted(attachment_info.keys())
]
return AnymailInboundMessage.construct(
raw_headers=request.POST.get('headers', ""), # includes From, To, Cc, Subject, etc.
text=request.POST.get('text', None),
text_charset=charsets.get('text', 'utf-8'),
html=request.POST.get('html', None),
html_charset=charsets.get('html', 'utf-8'),
attachments=attachments,
)
django-anymail-7.0.0/anymail/webhooks/mandrill.py 0000644 0000765 0000024 00000016552 13245143003 023002 0 ustar medmunds staff 0000000 0000000 import json
from datetime import datetime
import hashlib
import hmac
from base64 import b64encode
from django.utils.crypto import constant_time_compare
from django.utils.timezone import utc
from .base import AnymailBaseWebhookView
from ..exceptions import AnymailWebhookValidationFailure
from ..inbound import AnymailInboundMessage
from ..signals import inbound, tracking, AnymailInboundEvent, AnymailTrackingEvent, EventType
from ..utils import get_anymail_setting, getfirst, get_request_uri
class MandrillSignatureMixin(object):
"""Validates Mandrill webhook signature"""
# These can be set from kwargs in View.as_view, or pulled from settings in init:
webhook_key = None # required
webhook_url = None # optional; defaults to actual url used
def __init__(self, **kwargs):
# noinspection PyUnresolvedReferences
esp_name = self.esp_name
# webhook_key is required for POST, but not for HEAD when Mandrill validates webhook url.
# Defer "missing setting" error until we actually try to use it in the POST...
webhook_key = get_anymail_setting('webhook_key', esp_name=esp_name, default=None,
kwargs=kwargs, allow_bare=True)
if webhook_key is not None:
self.webhook_key = webhook_key.encode('ascii') # hmac.new requires bytes key in python 3
self.webhook_url = get_anymail_setting('webhook_url', esp_name=esp_name, default=None,
kwargs=kwargs, allow_bare=True)
# noinspection PyArgumentList
super(MandrillSignatureMixin, self).__init__(**kwargs)
def validate_request(self, request):
if self.webhook_key is None:
# issue deferred "missing setting" error (re-call get-setting without a default)
# noinspection PyUnresolvedReferences
get_anymail_setting('webhook_key', esp_name=self.esp_name, allow_bare=True)
try:
signature = request.META["HTTP_X_MANDRILL_SIGNATURE"]
except KeyError:
raise AnymailWebhookValidationFailure("X-Mandrill-Signature header missing from webhook POST")
# Mandrill signs the exact URL (including basic auth, if used) plus the sorted POST params:
url = self.webhook_url or get_request_uri(request)
params = request.POST.dict()
signed_data = url
for key in sorted(params.keys()):
signed_data += key + params[key]
expected_signature = b64encode(hmac.new(key=self.webhook_key, msg=signed_data.encode('utf-8'),
digestmod=hashlib.sha1).digest())
if not constant_time_compare(signature, expected_signature):
raise AnymailWebhookValidationFailure(
"Mandrill webhook called with incorrect signature (for url %r)" % url)
class MandrillCombinedWebhookView(MandrillSignatureMixin, AnymailBaseWebhookView):
"""Unified view class for Mandrill tracking and inbound webhooks"""
esp_name = "Mandrill"
warn_if_no_basic_auth = False # because we validate against signature
signal = None # set in esp_to_anymail_event
def parse_events(self, request):
esp_events = json.loads(request.POST['mandrill_events'])
return [self.esp_to_anymail_event(esp_event) for esp_event in esp_events]
def esp_to_anymail_event(self, esp_event):
"""Route events to the inbound or tracking handler"""
esp_type = getfirst(esp_event, ['event', 'type'], 'unknown')
if esp_type == 'inbound':
assert self.signal is not tracking # Mandrill should never mix event types in the same batch
self.signal = inbound
return self.mandrill_inbound_to_anymail_event(esp_event)
else:
assert self.signal is not inbound # Mandrill should never mix event types in the same batch
self.signal = tracking
return self.mandrill_tracking_to_anymail_event(esp_event)
#
# Tracking events
#
event_types = {
# Message events:
'send': EventType.SENT,
'deferral': EventType.DEFERRED,
'hard_bounce': EventType.BOUNCED,
'soft_bounce': EventType.BOUNCED,
'open': EventType.OPENED,
'click': EventType.CLICKED,
'spam': EventType.COMPLAINED,
'unsub': EventType.UNSUBSCRIBED,
'reject': EventType.REJECTED,
# Sync events (we don't really normalize these well):
'whitelist': EventType.UNKNOWN,
'blacklist': EventType.UNKNOWN,
# Inbound events:
'inbound': EventType.INBOUND,
}
def mandrill_tracking_to_anymail_event(self, esp_event):
esp_type = getfirst(esp_event, ['event', 'type'], None)
event_type = self.event_types.get(esp_type, EventType.UNKNOWN)
try:
timestamp = datetime.fromtimestamp(esp_event['ts'], tz=utc)
except (KeyError, ValueError):
timestamp = None
try:
recipient = esp_event['msg']['email']
except KeyError:
try:
recipient = esp_event['reject']['email'] # sync events
except KeyError:
recipient = None
try:
mta_response = esp_event['msg']['diag']
except KeyError:
mta_response = None
try:
description = getfirst(esp_event['reject'], ['detail', 'reason'])
except KeyError:
description = None
try:
metadata = esp_event['msg']['metadata']
except KeyError:
metadata = {}
try:
tags = esp_event['msg']['tags']
except KeyError:
tags = []
return AnymailTrackingEvent(
click_url=esp_event.get('url', None),
description=description,
esp_event=esp_event,
event_type=event_type,
message_id=esp_event.get('_id', None),
metadata=metadata,
mta_response=mta_response,
recipient=recipient,
reject_reason=None, # probably map esp_event['msg']['bounce_description'], but insufficient docs
tags=tags,
timestamp=timestamp,
user_agent=esp_event.get('user_agent', None),
)
#
# Inbound events
#
def mandrill_inbound_to_anymail_event(self, esp_event):
# It's easier (and more accurate) to just work from the original raw mime message
message = AnymailInboundMessage.parse_raw_mime(esp_event['msg']['raw_msg'])
message.envelope_sender = None # (Mandrill's 'sender' field only applies to outbound messages)
message.envelope_recipient = esp_event['msg'].get('email', None)
message.spam_detected = None # no simple boolean field; would need to parse the spam_report
message.spam_score = esp_event['msg'].get('spam_report', {}).get('score', None)
try:
timestamp = datetime.fromtimestamp(esp_event['ts'], tz=utc)
except (KeyError, ValueError):
timestamp = None
return AnymailInboundEvent(
event_type=EventType.INBOUND,
timestamp=timestamp,
event_id=None, # Mandrill doesn't provide an idempotent inbound message event id
esp_event=esp_event,
message=message,
)
# Backwards-compatibility: earlier Anymail versions had only MandrillTrackingWebhookView:
MandrillTrackingWebhookView = MandrillCombinedWebhookView
django-anymail-7.0.0/anymail/webhooks/sparkpost.py 0000644 0000765 0000024 00000017007 13245143003 023222 0 ustar medmunds staff 0000000 0000000 import json
from base64 import b64decode
from datetime import datetime
from django.utils.timezone import utc
from .base import AnymailBaseWebhookView
from ..exceptions import AnymailConfigurationError
from ..inbound import AnymailInboundMessage
from ..signals import inbound, tracking, AnymailInboundEvent, AnymailTrackingEvent, EventType, RejectReason
class SparkPostBaseWebhookView(AnymailBaseWebhookView):
"""Base view class for SparkPost webhooks"""
esp_name = "SparkPost"
def parse_events(self, request):
raw_events = json.loads(request.body.decode('utf-8'))
unwrapped_events = [self.unwrap_event(raw_event) for raw_event in raw_events]
return [
self.esp_to_anymail_event(event_class, event, raw_event)
for (event_class, event, raw_event) in unwrapped_events
if event is not None # filter out empty "ping" events
]
def unwrap_event(self, raw_event):
"""Unwraps SparkPost event structure, and returns event_class, event, raw_event
raw_event is of form {'msys': {event_class: {...event...}}}
Can return None, None, raw_event for SparkPost "ping" raw_event={'msys': {}}
"""
event_classes = raw_event['msys'].keys()
try:
(event_class,) = event_classes
event = raw_event['msys'][event_class]
except ValueError: # too many/not enough event_classes to unpack
if len(event_classes) == 0:
# Empty event (SparkPost sometimes sends as a "ping")
event_class = event = None
else:
raise TypeError("Invalid SparkPost webhook event has multiple event classes: %r" % raw_event)
return event_class, event, raw_event
def esp_to_anymail_event(self, event_class, event, raw_event):
raise NotImplementedError()
class SparkPostTrackingWebhookView(SparkPostBaseWebhookView):
"""Handler for SparkPost message, engagement, and generation event webhooks"""
signal = tracking
event_types = {
# Map SparkPost event.type: Anymail normalized type
'bounce': EventType.BOUNCED,
'delivery': EventType.DELIVERED,
'injection': EventType.QUEUED,
'spam_complaint': EventType.COMPLAINED,
'out_of_band': EventType.BOUNCED,
'policy_rejection': EventType.REJECTED,
'delay': EventType.DEFERRED,
'click': EventType.CLICKED,
'open': EventType.OPENED,
'generation_failure': EventType.FAILED,
'generation_rejection': EventType.REJECTED,
'list_unsubscribe': EventType.UNSUBSCRIBED,
'link_unsubscribe': EventType.UNSUBSCRIBED,
}
reject_reasons = {
# Map SparkPost event.bounce_class: Anymail normalized reject reason.
# Can also supply (RejectReason, EventType) for bounce_class that affects our event_type.
# https://support.sparkpost.com/customer/portal/articles/1929896
'1': RejectReason.OTHER, # Undetermined (response text could not be identified)
'10': RejectReason.INVALID, # Invalid Recipient
'20': RejectReason.BOUNCED, # Soft Bounce
'21': RejectReason.BOUNCED, # DNS Failure
'22': RejectReason.BOUNCED, # Mailbox Full
'23': RejectReason.BOUNCED, # Too Large
'24': RejectReason.TIMED_OUT, # Timeout
'25': RejectReason.BLOCKED, # Admin Failure (configured policies)
'30': RejectReason.BOUNCED, # Generic Bounce: No RCPT
'40': RejectReason.BOUNCED, # Generic Bounce: unspecified reasons
'50': RejectReason.BLOCKED, # Mail Block (by the receiver)
'51': RejectReason.SPAM, # Spam Block (by the receiver)
'52': RejectReason.SPAM, # Spam Content (by the receiver)
'53': RejectReason.OTHER, # Prohibited Attachment (by the receiver)
'54': RejectReason.BLOCKED, # Relaying Denied (by the receiver)
'60': (RejectReason.OTHER, EventType.AUTORESPONDED), # Auto-Reply/vacation
'70': RejectReason.BOUNCED, # Transient Failure
'80': (RejectReason.OTHER, EventType.SUBSCRIBED), # Subscribe
'90': (RejectReason.UNSUBSCRIBED, EventType.UNSUBSCRIBED), # Unsubscribe
'100': (RejectReason.OTHER, EventType.AUTORESPONDED), # Challenge-Response
}
def esp_to_anymail_event(self, event_class, event, raw_event):
if event_class == 'relay_message':
# This is an inbound event
raise AnymailConfigurationError(
"You seem to have set SparkPost's *inbound* relay webhook URL "
"to Anymail's SparkPost *tracking* webhook URL.")
event_type = self.event_types.get(event['type'], EventType.UNKNOWN)
try:
timestamp = datetime.fromtimestamp(int(event['timestamp']), tz=utc)
except (KeyError, TypeError, ValueError):
timestamp = None
try:
tag = event['campaign_id'] # not 'rcpt_tags' -- those don't come from sending a message
tags = [tag] if tag else None
except KeyError:
tags = []
try:
reject_reason = self.reject_reasons.get(event['bounce_class'], RejectReason.OTHER)
try: # unpack (RejectReason, EventType) for reasons that change our event type
reject_reason, event_type = reject_reason
except ValueError:
pass
except KeyError:
reject_reason = None # no bounce_class
return AnymailTrackingEvent(
event_type=event_type,
timestamp=timestamp,
message_id=event.get('transmission_id', None), # not 'message_id' -- see SparkPost backend
event_id=event.get('event_id', None),
recipient=event.get('raw_rcpt_to', None), # preserves email case (vs. 'rcpt_to')
reject_reason=reject_reason,
mta_response=event.get('raw_reason', None),
# description=???,
tags=tags,
metadata=event.get('rcpt_meta', None) or {}, # message + recipient metadata
click_url=event.get('target_link_url', None),
user_agent=event.get('user_agent', None),
esp_event=raw_event,
)
class SparkPostInboundWebhookView(SparkPostBaseWebhookView):
"""Handler for SparkPost inbound relay webhook"""
signal = inbound
def esp_to_anymail_event(self, event_class, event, raw_event):
if event_class != 'relay_message':
# This is not an inbound event
raise AnymailConfigurationError(
"You seem to have set SparkPost's *tracking* webhook URL "
"to Anymail's SparkPost *inbound* relay webhook URL.")
if event['protocol'] != 'smtp':
raise AnymailConfigurationError(
"You cannot use Anymail's webhooks for SparkPost '{protocol}' relay events. "
"Anymail only handles the 'smtp' protocol".format(protocol=event['protocol']))
raw_mime = event['content']['email_rfc822']
if event['content']['email_rfc822_is_base64']:
raw_mime = b64decode(raw_mime).decode('utf-8')
message = AnymailInboundMessage.parse_raw_mime(raw_mime)
message.envelope_sender = event.get('msg_from', None)
message.envelope_recipient = event.get('rcpt_to', None)
return AnymailInboundEvent(
event_type=EventType.INBOUND,
timestamp=None, # SparkPost does not provide a relay event timestamp
event_id=None, # SparkPost does not provide an idempotent id for relay events
esp_event=raw_event,
message=message,
)
django-anymail-7.0.0/anymail/webhooks/amazon_ses.py 0000644 0000765 0000024 00000041214 13265221546 023343 0 ustar medmunds staff 0000000 0000000 import io
import json
from base64 import b64decode
from django.http import HttpResponse
from django.utils.dateparse import parse_datetime
from .base import AnymailBaseWebhookView
from ..exceptions import (
AnymailAPIError, AnymailConfigurationError, AnymailImproperlyInstalled, AnymailWebhookValidationFailure,
_LazyError)
from ..inbound import AnymailInboundMessage
from ..signals import AnymailInboundEvent, AnymailTrackingEvent, EventType, RejectReason, inbound, tracking
from ..utils import combine, get_anymail_setting, getfirst
try:
import boto3
from botocore.exceptions import ClientError
from ..backends.amazon_ses import _get_anymail_boto3_params
except ImportError:
# This module gets imported by anymail.urls, so don't complain about boto3 missing
# unless one of the Amazon SES webhook views is actually used and needs it
boto3 = _LazyError(AnymailImproperlyInstalled(missing_package='boto3', backend='amazon_ses'))
ClientError = object
_get_anymail_boto3_params = _LazyError(AnymailImproperlyInstalled(missing_package='boto3', backend='amazon_ses'))
class AmazonSESBaseWebhookView(AnymailBaseWebhookView):
"""Base view class for Amazon SES webhooks (SNS Notifications)"""
esp_name = "Amazon SES"
def __init__(self, **kwargs):
# whether to automatically respond to SNS SubscriptionConfirmation requests; default True
# (Future: could also take a TopicArn or list to auto-confirm)
self.auto_confirm_enabled = get_anymail_setting(
"auto_confirm_sns_subscriptions", esp_name=self.esp_name, kwargs=kwargs, default=True)
# boto3 params for connecting to S3 (inbound downloads) and SNS (auto-confirm subscriptions):
self.session_params, self.client_params = _get_anymail_boto3_params(kwargs=kwargs)
super(AmazonSESBaseWebhookView, self).__init__(**kwargs)
@staticmethod
def _parse_sns_message(request):
# cache so we don't have to parse the json multiple times
if not hasattr(request, '_sns_message'):
try:
body = request.body.decode(request.encoding or 'utf-8')
request._sns_message = json.loads(body)
except (TypeError, ValueError, UnicodeDecodeError) as err:
raise AnymailAPIError("Malformed SNS message body %r" % request.body, raised_from=err)
return request._sns_message
def validate_request(self, request):
# Block random posts that don't even have matching SNS headers
sns_message = self._parse_sns_message(request)
header_type = request.META.get("HTTP_X_AMZ_SNS_MESSAGE_TYPE", "<>")
body_type = sns_message.get("Type", "<>")
if header_type != body_type:
raise AnymailWebhookValidationFailure(
'SNS header "x-amz-sns-message-type: %s" doesn\'t match body "Type": "%s"'
% (header_type, body_type))
if header_type not in ["Notification", "SubscriptionConfirmation", "UnsubscribeConfirmation"]:
raise AnymailAPIError("Unknown SNS message type '%s'" % header_type)
header_id = request.META.get("HTTP_X_AMZ_SNS_MESSAGE_ID", "<>")
body_id = sns_message.get("MessageId", "<>")
if header_id != body_id:
raise AnymailWebhookValidationFailure(
'SNS header "x-amz-sns-message-id: %s" doesn\'t match body "MessageId": "%s"'
% (header_id, body_id))
# Future: Verify SNS message signature
# https://docs.aws.amazon.com/sns/latest/dg/SendMessageToHttp.verify.signature.html
def post(self, request, *args, **kwargs):
# request has *not* yet been validated at this point
if self.basic_auth and not request.META.get("HTTP_AUTHORIZATION"):
# Amazon SNS requires a proper 401 response before it will attempt to send basic auth
response = HttpResponse(status=401)
response["WWW-Authenticate"] = 'Basic realm="Anymail WEBHOOK_SECRET"'
return response
return super(AmazonSESBaseWebhookView, self).post(request, *args, **kwargs)
def parse_events(self, request):
# request *has* been validated by now
events = []
sns_message = self._parse_sns_message(request)
sns_type = sns_message.get("Type")
if sns_type == "Notification":
message_string = sns_message.get("Message")
try:
ses_event = json.loads(message_string)
except (TypeError, ValueError):
if message_string == "Successfully validated SNS topic for Amazon SES event publishing.":
pass # this Notification is generated after SubscriptionConfirmation
else:
raise AnymailAPIError("Unparsable SNS Message %r" % message_string)
else:
events = self.esp_to_anymail_events(ses_event, sns_message)
elif sns_type == "SubscriptionConfirmation":
self.auto_confirm_sns_subscription(sns_message)
# else: just ignore other SNS messages (e.g., "UnsubscribeConfirmation")
return events
def esp_to_anymail_events(self, ses_event, sns_message):
raise NotImplementedError()
def auto_confirm_sns_subscription(self, sns_message):
"""Automatically accept a subscription to Amazon SNS topics, if the request is expected.
If an SNS SubscriptionConfirmation arrives with HTTP basic auth proving it is meant for us,
automatically load the SubscribeURL to confirm the subscription.
"""
if not self.auto_confirm_enabled:
return
if not self.basic_auth:
# Note: basic_auth (shared secret) confirms the notification was meant for us.
# If WEBHOOK_SECRET isn't set, Anymail logs a warning but allows the request.
# (Also, verifying the SNS message signature would be insufficient here:
# if someone else tried to point their own SNS topic at our webhook url,
# SNS would send a SubscriptionConfirmation with a valid Amazon signature.)
raise AnymailWebhookValidationFailure(
"Anymail received an unexpected SubscriptionConfirmation request for Amazon SNS topic "
"'{topic_arn!s}'. (Anymail can automatically confirm SNS subscriptions if you set a "
"WEBHOOK_SECRET and use that in your SNS notification url. Or you can manually confirm "
"this subscription in the SNS dashboard with token '{token!s}'.)"
"".format(topic_arn=sns_message.get('TopicArn'), token=sns_message.get('Token')))
# WEBHOOK_SECRET *is* set, so the request's basic auth has been verified by now (in run_validators).
# We're good to confirm...
sns_client = boto3.session.Session(**self.session_params).client('sns', **self.client_params)
sns_client.confirm_subscription(
TopicArn=sns_message["TopicArn"], Token=sns_message["Token"], AuthenticateOnUnsubscribe='true')
class AmazonSESTrackingWebhookView(AmazonSESBaseWebhookView):
"""Handler for Amazon SES tracking notifications"""
signal = tracking
def esp_to_anymail_events(self, ses_event, sns_message):
# Amazon SES has two notification formats, which are almost exactly the same:
# - https://docs.aws.amazon.com/ses/latest/DeveloperGuide/event-publishing-retrieving-sns-contents.html
# - https://docs.aws.amazon.com/ses/latest/DeveloperGuide/notification-contents.html
# This code should handle either.
ses_event_type = getfirst(ses_event, ["eventType", "notificationType"], "<>")
if ses_event_type == "Received":
# This is an inbound event
raise AnymailConfigurationError(
"You seem to have set an Amazon SES *inbound* receipt rule to publish "
"to an SNS Topic that posts to Anymail's *tracking* webhook URL. "
"(SNS TopicArn %s)" % sns_message.get("TopicArn"))
event_id = sns_message.get("MessageId") # unique to the SNS notification
try:
timestamp = parse_datetime(sns_message["Timestamp"])
except (KeyError, ValueError):
timestamp = None
mail_object = ses_event.get("mail", {})
message_id = mail_object.get("messageId") # same as MessageId in SendRawEmail response
all_recipients = mail_object.get("destination", [])
# Recover tags and metadata from custom headers
metadata = {}
tags = []
for header in mail_object.get("headers", []):
name = header["name"].lower()
if name == "x-tag":
tags.append(header["value"])
elif name == "x-metadata":
try:
metadata = json.loads(header["value"])
except (ValueError, TypeError, KeyError):
pass
common_props = dict( # AnymailTrackingEvent props for all recipients
esp_event=ses_event,
event_id=event_id,
message_id=message_id,
metadata=metadata,
tags=tags,
timestamp=timestamp,
)
per_recipient_props = [ # generate individual events for each of these
dict(recipient=email_address)
for email_address in all_recipients
]
event_object = ses_event.get(ses_event_type.lower(), {}) # e.g., ses_event["bounce"]
if ses_event_type == "Bounce":
common_props.update(
event_type=EventType.BOUNCED,
description="{bounceType}: {bounceSubType}".format(**event_object),
reject_reason=RejectReason.BOUNCED,
)
per_recipient_props = [dict(
recipient=recipient["emailAddress"],
mta_response=recipient.get("diagnosticCode"),
) for recipient in event_object["bouncedRecipients"]]
elif ses_event_type == "Complaint":
common_props.update(
event_type=EventType.COMPLAINED,
description=event_object.get("complaintFeedbackType"),
reject_reason=RejectReason.SPAM,
user_agent=event_object.get("userAgent"),
)
per_recipient_props = [dict(
recipient=recipient["emailAddress"],
) for recipient in event_object["complainedRecipients"]]
elif ses_event_type == "Delivery":
common_props.update(
event_type=EventType.DELIVERED,
mta_response=event_object.get("smtpResponse"),
)
per_recipient_props = [dict(
recipient=recipient,
) for recipient in event_object["recipients"]]
elif ses_event_type == "Send":
common_props.update(
event_type=EventType.SENT,
)
elif ses_event_type == "Reject":
common_props.update(
event_type=EventType.REJECTED,
description=event_object["reason"],
reject_reason=RejectReason.BLOCKED,
)
elif ses_event_type == "Open":
# SES doesn't report which recipient opened the message (it doesn't
# track them separately), so just report it for all_recipients
common_props.update(
event_type=EventType.OPENED,
user_agent=event_object.get("userAgent"),
)
elif ses_event_type == "Click":
# SES doesn't report which recipient clicked the message (it doesn't
# track them separately), so just report it for all_recipients
common_props.update(
event_type=EventType.CLICKED,
user_agent=event_object.get("userAgent"),
click_url=event_object.get("link"),
)
elif ses_event_type == "Rendering Failure":
event_object = ses_event["failure"] # rather than ses_event["rendering failure"]
common_props.update(
event_type=EventType.FAILED,
description=event_object["errorMessage"],
)
else:
# Umm... new event type?
common_props.update(
event_type=EventType.UNKNOWN,
description="Unknown SES eventType '%s'" % ses_event_type,
)
return [
# AnymailTrackingEvent(**common_props, **recipient_props) # Python 3.5+ (PEP-448 syntax)
AnymailTrackingEvent(**combine(common_props, recipient_props))
for recipient_props in per_recipient_props
]
class AmazonSESInboundWebhookView(AmazonSESBaseWebhookView):
"""Handler for Amazon SES inbound notifications"""
signal = inbound
def esp_to_anymail_events(self, ses_event, sns_message):
ses_event_type = ses_event.get("notificationType")
if ses_event_type != "Received":
# This is not an inbound event
raise AnymailConfigurationError(
"You seem to have set an Amazon SES *sending* event or notification "
"to publish to an SNS Topic that posts to Anymail's *inbound* webhook URL. "
"(SNS TopicArn %s)" % sns_message.get("TopicArn"))
receipt_object = ses_event.get("receipt", {})
action_object = receipt_object.get("action", {})
mail_object = ses_event.get("mail", {})
action_type = action_object.get("type")
if action_type == "SNS":
content = ses_event.get("content")
if action_object.get("encoding") == "BASE64":
content = b64decode(content.encode("ascii"))
message = AnymailInboundMessage.parse_raw_mime_bytes(content)
else:
message = AnymailInboundMessage.parse_raw_mime(content)
elif action_type == "S3":
# download message from s3 into memory, then parse
# (SNS has 15s limit for an http response; hope download doesn't take that long)
bucket_name = action_object["bucketName"]
object_key = action_object["objectKey"]
s3 = boto3.session.Session(**self.session_params).client("s3", **self.client_params)
content = io.BytesIO()
try:
s3.download_fileobj(bucket_name, object_key, content)
content.seek(0)
message = AnymailInboundMessage.parse_raw_mime_file(content)
except ClientError as err:
# improve the botocore error message
raise AnymailBotoClientAPIError(
"Anymail AmazonSESInboundWebhookView couldn't download S3 object '{bucket_name}:{object_key}'"
"".format(bucket_name=bucket_name, object_key=object_key),
raised_from=err)
finally:
content.close()
else:
raise AnymailConfigurationError(
"Anymail's Amazon SES inbound webhook works only with 'SNS' or 'S3' receipt rule actions, "
"not SNS notifications for {action_type!s} actions. (SNS TopicArn {topic_arn!s})"
"".format(action_type=action_type, topic_arn=sns_message.get("TopicArn")))
message.envelope_sender = mail_object.get("source") # "the envelope MAIL FROM address"
try:
# "recipients that were matched by the active receipt rule"
message.envelope_recipient = receipt_object["recipients"][0]
except (KeyError, TypeError, IndexError):
pass
spam_status = receipt_object.get("spamVerdict", {}).get("status", "").upper()
message.spam_detected = {"PASS": False, "FAIL": True}.get(spam_status) # else None if unsure
event_id = mail_object.get("messageId") # "unique ID assigned to the email by Amazon SES"
try:
timestamp = parse_datetime(mail_object["timestamp"]) # "time at which the email was received"
except (KeyError, ValueError):
timestamp = None
return [AnymailInboundEvent(
event_type=EventType.INBOUND,
event_id=event_id,
message=message,
timestamp=timestamp,
esp_event=ses_event,
)]
class AnymailBotoClientAPIError(AnymailAPIError, ClientError):
"""An AnymailAPIError that is also a Boto ClientError"""
def __init__(self, *args, **kwargs):
raised_from = kwargs.pop('raised_from')
assert isinstance(raised_from, ClientError)
assert len(kwargs) == 0 # can't support other kwargs
# init self as boto ClientError (which doesn't cooperatively subclass):
super(AnymailBotoClientAPIError, self).__init__(
error_response=raised_from.response, operation_name=raised_from.operation_name)
# emulate AnymailError init:
self.args = args
self.raised_from = raised_from
django-anymail-7.0.0/anymail/webhooks/base.py 0000644 0000765 0000024 00000014211 13246074767 022125 0 ustar medmunds staff 0000000 0000000 import warnings
import six
from django.http import HttpResponse
from django.utils.crypto import constant_time_compare
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt
from django.views.generic import View
from ..exceptions import AnymailInsecureWebhookWarning, AnymailWebhookValidationFailure
from ..utils import get_anymail_setting, collect_all_methods, get_request_basic_auth
class AnymailBasicAuthMixin(object):
"""Implements webhook basic auth as mixin to AnymailBaseWebhookView."""
# Whether to warn if basic auth is not configured.
# For most ESPs, basic auth is the only webhook security,
# so the default is True. Subclasses can set False if
# they enforce other security (like signed webhooks).
warn_if_no_basic_auth = True
# List of allowable HTTP basic-auth 'user:pass' strings.
basic_auth = None # (Declaring class attr allows override by kwargs in View.as_view.)
def __init__(self, **kwargs):
self.basic_auth = get_anymail_setting('webhook_secret', default=[],
kwargs=kwargs) # no esp_name -- auth is shared between ESPs
# Allow a single string:
if isinstance(self.basic_auth, six.string_types):
self.basic_auth = [self.basic_auth]
if self.warn_if_no_basic_auth and len(self.basic_auth) < 1:
warnings.warn(
"Your Anymail webhooks are insecure and open to anyone on the web. "
"You should set WEBHOOK_SECRET in your ANYMAIL settings. "
"See 'Securing webhooks' in the Anymail docs.",
AnymailInsecureWebhookWarning)
# noinspection PyArgumentList
super(AnymailBasicAuthMixin, self).__init__(**kwargs)
def validate_request(self, request):
"""If configured for webhook basic auth, validate request has correct auth."""
if self.basic_auth:
request_auth = get_request_basic_auth(request)
# Use constant_time_compare to avoid timing attack on basic auth. (It's OK that any()
# can terminate early: we're not trying to protect how many auth strings are allowed,
# just the contents of each individual auth string.)
auth_ok = any(constant_time_compare(request_auth, allowed_auth)
for allowed_auth in self.basic_auth)
if not auth_ok:
# noinspection PyUnresolvedReferences
raise AnymailWebhookValidationFailure(
"Missing or invalid basic auth in Anymail %s webhook" % self.esp_name)
# Mixin note: Django's View.__init__ doesn't cooperate with chaining,
# so all mixins that need __init__ must appear before View in MRO.
class AnymailBaseWebhookView(AnymailBasicAuthMixin, View):
"""Base view for processing ESP event webhooks
ESP-specific implementations should subclass
and implement parse_events. They may also
want to implement validate_request
if additional security is available.
"""
def __init__(self, **kwargs):
super(AnymailBaseWebhookView, self).__init__(**kwargs)
self.validators = collect_all_methods(self.__class__, 'validate_request')
# Subclass implementation:
# Where to send events: either ..signals.inbound or ..signals.tracking
signal = None
def validate_request(self, request):
"""Check validity of webhook post, or raise AnymailWebhookValidationFailure.
AnymailBaseWebhookView includes basic auth validation.
Subclasses can implement (or provide via mixins) if the ESP supports
additional validation (such as signature checking).
*All* definitions of this method in the class chain (including mixins)
will be called. There is no need to chain to the superclass.
(See self.run_validators and collect_all_methods.)
Security note: use django.utils.crypto.constant_time_compare for string
comparisons, to avoid exposing your validation to a timing attack.
"""
# if not constant_time_compare(request.POST['signature'], expected_signature):
# raise AnymailWebhookValidationFailure("...message...")
# (else just do nothing)
pass
def parse_events(self, request):
"""Return a list of normalized AnymailWebhookEvent extracted from ESP post data.
Subclasses must implement.
"""
raise NotImplementedError()
# HTTP handlers (subclasses shouldn't need to override):
http_method_names = ["post", "head", "options"]
@method_decorator(csrf_exempt)
def dispatch(self, request, *args, **kwargs):
return super(AnymailBaseWebhookView, self).dispatch(request, *args, **kwargs)
def head(self, request, *args, **kwargs):
# Some ESPs verify the webhook with a HEAD request at configuration time
return HttpResponse()
def post(self, request, *args, **kwargs):
# Normal Django exception handling will do the right thing:
# - AnymailWebhookValidationFailure will turn into an HTTP 400 response
# (via Django SuspiciousOperation handling)
# - Any other errors (e.g., in signal dispatch) will turn into HTTP 500
# responses (via normal Django error handling). ESPs generally
# treat that as "try again later".
self.run_validators(request)
events = self.parse_events(request)
esp_name = self.esp_name
for event in events:
self.signal.send(sender=self.__class__, event=event, esp_name=esp_name)
return HttpResponse()
# Request validation (subclasses shouldn't need to override):
def run_validators(self, request):
for validator in self.validators:
validator(self, request)
@property
def esp_name(self):
"""
Read-only name of the ESP for this webhook view.
Subclasses must override with class attr. E.g.:
esp_name = "Postmark"
esp_name = "SendGrid" # (use ESP's preferred capitalization)
"""
raise NotImplementedError("%s.%s must declare esp_name class attr" %
(self.__class__.__module__, self.__class__.__name__))
django-anymail-7.0.0/setup.py 0000644 0000765 0000024 00000007467 13336332260 017100 0 ustar medmunds staff 0000000 0000000 import re
from codecs import open # to use a consistent encoding
from collections import OrderedDict
from os import path
from setuptools import setup
here = path.abspath(path.dirname(__file__))
# get versions from anymail/_version.py,
# but without importing from anymail (which would break setup)
with open(path.join(here, "anymail/_version.py"), encoding='utf-8') as f:
code = compile(f.read(), "anymail/_version.py", 'exec')
_version = {}
exec(code, _version)
version = _version["__version__"] # X.Y or X.Y.Z or X.Y.Z.dev1 etc.
release_tag = "v%s" % version # vX.Y or vX.Y.Z
def long_description_from_readme(rst):
# Freeze external links (on PyPI) to refer to this X.Y or X.Y.Z tag.
# (This relies on tagging releases with 'vX.Y' or 'vX.Y.Z' in GitHub.)
rst = re.sub(r'(?<=branch=)master' # Travis build status: branch=master --> branch=vX.Y.Z
r'|(?<=/)stable' # ReadTheDocs links: /stable --> /vX.Y.Z
r'|(?<=version=)stable', # ReadTheDocs badge: version=stable --> version=vX.Y.Z
release_tag, rst) # (?<=...) is "positive lookbehind": must be there, but won't get replaced
return rst
with open(path.join(here, 'README.rst'), encoding='utf-8') as f:
long_description = long_description_from_readme(f.read())
setup(
name="django-anymail",
version=version,
description='Django email integration for Amazon SES, Mailgun, Mailjet, Postmark, '
'SendGrid, SendinBlue, SparkPost and other transactional ESPs',
keywords="Django, email, email backend, ESP, transactional mail, "
"Amazon SES, Mailgun, Mailjet, Mandrill, Postmark, SendinBlue, SendGrid, SparkPost",
author="Mike Edmunds and Anymail contributors",
author_email="medmunds@gmail.com",
url="https://github.com/anymail/django-anymail",
license="BSD License",
packages=["anymail"],
zip_safe=False,
install_requires=["django>=1.11", "requests>=2.4.3", "six"],
extras_require={
# This can be used if particular backends have unique dependencies.
# For simplicity, requests is included in the base requirements.
"amazon_ses": ["boto3"],
"mailgun": [],
"mailjet": [],
"mandrill": [],
"postmark": [],
"sendgrid": [],
"sendinblue": [],
"sparkpost": ["sparkpost"],
},
include_package_data=True,
test_suite="runtests.runtests",
tests_require=["mock", "boto3", "sparkpost"],
classifiers=[
"Development Status :: 5 - Production/Stable",
"Programming Language :: Python",
"Programming Language :: Python :: Implementation :: PyPy",
"Programming Language :: Python :: Implementation :: CPython",
"Programming Language :: Python :: 2",
"Programming Language :: Python :: 2.7",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.4",
"Programming Language :: Python :: 3.5",
"Programming Language :: Python :: 3.6",
"Programming Language :: Python :: 3.7",
"License :: OSI Approved :: BSD License",
"Topic :: Communications :: Email",
"Topic :: Software Development :: Libraries :: Python Modules",
"Intended Audience :: Developers",
"Framework :: Django",
"Framework :: Django :: 1.11",
"Framework :: Django :: 2.0",
"Framework :: Django :: 2.1",
"Environment :: Web Environment",
],
long_description=long_description,
project_urls=OrderedDict([
("Documentation", "https://anymail.readthedocs.io/en/%s/" % release_tag),
("Source", "https://github.com/anymail/django-anymail"),
("Changelog", "https://anymail.readthedocs.io/en/%s/changelog/" % release_tag),
("Tracker", "https://github.com/anymail/django-anymail/issues"),
]),
)
django-anymail-7.0.0/setup.cfg 0000644 0000765 0000024 00000000146 13535011557 017176 0 ustar medmunds staff 0000000 0000000 [metadata]
license_file = LICENSE
[bdist_wheel]
universal = 1
[egg_info]
tag_build =
tag_date = 0
django-anymail-7.0.0/README.rst 0000644 0000765 0000024 00000013203 13464636612 017050 0 ustar medmunds staff 0000000 0000000 Anymail: Django email integration for transactional ESPs
========================================================
.. This README is reused in multiple places:
* Github: project page, exactly as it appears here
* Docs: shared-intro section gets included in docs/index.rst
quickstart section gets included in docs/quickstart.rst
* PyPI: project page (via setup.py long_description),
with several edits to freeze it to the specific PyPI release
(see long_description_from_readme in setup.py)
You can use docutils 1.0 markup, but *not* any Sphinx additions.
GitHub rst supports code-block, but *no other* block directives.
.. default-role:: literal
.. _shared-intro:
.. This shared-intro section is also included in docs/index.rst
Anymail integrates several transactional email service providers (ESPs) into Django,
with a consistent API that lets you use ESP-added features without locking your code
to a particular ESP.
It currently fully supports **Amazon SES, Mailgun, Mailjet, Postmark, SendinBlue, SendGrid,**
and **SparkPost,** and has limited support for **Mandrill.**
Anymail normalizes ESP functionality so it "just works" with Django's
built-in `django.core.mail` package. It includes:
* Support for HTML, attachments, extra headers, and other features of
`Django's built-in email `_
* Extensions that make it easy to use extra ESP functionality, like tags, metadata,
and tracking, with code that's portable between ESPs
* Simplified inline images for HTML email
* Normalized sent-message status and tracking notification, by connecting
your ESP's webhooks to Django signals
* "Batch transactional" sends using your ESP's merge and template features
* Inbound message support, to receive email through your ESP's webhooks,
with simplified, portable access to attachments and other inbound content
Anymail is released under the BSD license. It is extensively tested against
Django 1.11--2.2 (including Python 2.7, Python 3 and PyPy).
Anymail releases follow `semantic versioning `_.
.. END shared-intro
.. image:: https://travis-ci.org/anymail/django-anymail.svg?branch=master
:target: https://travis-ci.org/anymail/django-anymail
:alt: build status on Travis-CI
.. image:: https://readthedocs.org/projects/anymail/badge/?version=stable
:target: https://anymail.readthedocs.io/en/stable/
:alt: documentation on ReadTheDocs
**Resources**
* Full documentation: https://anymail.readthedocs.io/en/stable/
* Package on PyPI: https://pypi.org/project/django-anymail/
* Project on Github: https://github.com/anymail/django-anymail
* Changelog: https://anymail.readthedocs.io/en/stable/changelog/
Anymail 1-2-3
-------------
.. _quickstart:
.. This quickstart section is also included in docs/quickstart.rst
Here's how to send a message.
This example uses Mailgun, but you can substitute Mailjet or Postmark or SendGrid
or SparkPost or any other supported ESP where you see "mailgun":
1. Install Anymail from PyPI:
.. code-block:: console
$ pip install django-anymail[mailgun]
(The `[mailgun]` part installs any additional packages needed for that ESP.
Mailgun doesn't have any, but some other ESPs do.)
2. Edit your project's ``settings.py``:
.. code-block:: python
INSTALLED_APPS = [
# ...
"anymail",
# ...
]
ANYMAIL = {
# (exact settings here depend on your ESP...)
"MAILGUN_API_KEY": "",
"MAILGUN_SENDER_DOMAIN": 'mg.example.com', # your Mailgun domain, if needed
}
EMAIL_BACKEND = "anymail.backends.mailgun.EmailBackend" # or sendgrid.EmailBackend, or...
DEFAULT_FROM_EMAIL = "you@example.com" # if you don't already have this in settings
SERVER_EMAIL = "your-server@example.com" # ditto (default from-email for Django errors)
3. Now the regular `Django email functions `_
will send through your chosen ESP:
.. code-block:: python
from django.core.mail import send_mail
send_mail("It works!", "This will get sent through Mailgun",
"Anymail Sender ", ["to@example.com"])
You could send an HTML message, complete with an inline image,
custom tags and metadata:
.. code-block:: python
from django.core.mail import EmailMultiAlternatives
from anymail.message import attach_inline_image_file
msg = EmailMultiAlternatives(
subject="Please activate your account",
body="Click to activate your account: http://example.com/activate",
from_email="Example ",
to=["New User ", "account.manager@example.com"],
reply_to=["Helpdesk "])
# Include an inline image in the html:
logo_cid = attach_inline_image_file(msg, "/path/to/logo.jpg")
html = """
Please activate
your account
""".format(logo_cid=logo_cid)
msg.attach_alternative(html, "text/html")
# Optional Anymail extensions:
msg.metadata = {"user_id": "8675309", "experiment_variation": 1}
msg.tags = ["activation", "onboarding"]
msg.track_clicks = True
# Send it:
msg.send()
.. END quickstart
See the `full documentation `_
for more features and options, including receiving messages and tracking
sent message status.
django-anymail-7.0.0/AUTHORS.txt 0000644 0000765 0000024 00000000615 13303576540 017245 0 ustar medmunds staff 0000000 0000000 Anymail
=======
Mike Edmunds
Calvin Jeong
Peter Wu
Charlie DeTar
Jonathan Baugh
Noel Rignon
Josh Kersey
Anymail was forked from Djrill, which included contributions from:
Kenneth Love
Chris Jones
Mike Edmunds
ArnaudF
Théo Crevon
Rafael E. Belliard
Jared Morse
peillis
José Padilla
Jens Alm
Eric Hennings
Michael Hobbs
Sameer Al-Sakran
Kyle Gibson
Wes Winham
nikolay-saskovets
William Hector
django-anymail-7.0.0/django_anymail.egg-info/ 0000755 0000765 0000024 00000000000 13535011557 022022 5 ustar medmunds staff 0000000 0000000 django-anymail-7.0.0/django_anymail.egg-info/PKG-INFO 0000644 0000765 0000024 00000021413 13535011557 023120 0 ustar medmunds staff 0000000 0000000 Metadata-Version: 2.1
Name: django-anymail
Version: 7.0.0
Summary: Django email integration for Amazon SES, Mailgun, Mailjet, Postmark, SendGrid, SendinBlue, SparkPost and other transactional ESPs
Home-page: https://github.com/anymail/django-anymail
Author: Mike Edmunds and Anymail contributors
Author-email: medmunds@gmail.com
License: BSD License
Project-URL: Documentation, https://anymail.readthedocs.io/en/v7.0.0/
Project-URL: Source, https://github.com/anymail/django-anymail
Project-URL: Changelog, https://anymail.readthedocs.io/en/v7.0.0/changelog/
Project-URL: Tracker, https://github.com/anymail/django-anymail/issues
Description: Anymail: Django email integration for transactional ESPs
========================================================
.. This README is reused in multiple places:
* Github: project page, exactly as it appears here
* Docs: shared-intro section gets included in docs/index.rst
quickstart section gets included in docs/quickstart.rst
* PyPI: project page (via setup.py long_description),
with several edits to freeze it to the specific PyPI release
(see long_description_from_readme in setup.py)
You can use docutils 1.0 markup, but *not* any Sphinx additions.
GitHub rst supports code-block, but *no other* block directives.
.. default-role:: literal
.. _shared-intro:
.. This shared-intro section is also included in docs/index.rst
Anymail integrates several transactional email service providers (ESPs) into Django,
with a consistent API that lets you use ESP-added features without locking your code
to a particular ESP.
It currently fully supports **Amazon SES, Mailgun, Mailjet, Postmark, SendinBlue, SendGrid,**
and **SparkPost,** and has limited support for **Mandrill.**
Anymail normalizes ESP functionality so it "just works" with Django's
built-in `django.core.mail` package. It includes:
* Support for HTML, attachments, extra headers, and other features of
`Django's built-in email `_
* Extensions that make it easy to use extra ESP functionality, like tags, metadata,
and tracking, with code that's portable between ESPs
* Simplified inline images for HTML email
* Normalized sent-message status and tracking notification, by connecting
your ESP's webhooks to Django signals
* "Batch transactional" sends using your ESP's merge and template features
* Inbound message support, to receive email through your ESP's webhooks,
with simplified, portable access to attachments and other inbound content
Anymail is released under the BSD license. It is extensively tested against
Django 1.11--2.2 (including Python 2.7, Python 3 and PyPy).
Anymail releases follow `semantic versioning `_.
.. END shared-intro
.. image:: https://travis-ci.org/anymail/django-anymail.svg?branch=v7.0.0
:target: https://travis-ci.org/anymail/django-anymail
:alt: build status on Travis-CI
.. image:: https://readthedocs.org/projects/anymail/badge/?version=v7.0.0
:target: https://anymail.readthedocs.io/en/v7.0.0/
:alt: documentation on ReadTheDocs
**Resources**
* Full documentation: https://anymail.readthedocs.io/en/v7.0.0/
* Package on PyPI: https://pypi.org/project/django-anymail/
* Project on Github: https://github.com/anymail/django-anymail
* Changelog: https://anymail.readthedocs.io/en/v7.0.0/changelog/
Anymail 1-2-3
-------------
.. _quickstart:
.. This quickstart section is also included in docs/quickstart.rst
Here's how to send a message.
This example uses Mailgun, but you can substitute Mailjet or Postmark or SendGrid
or SparkPost or any other supported ESP where you see "mailgun":
1. Install Anymail from PyPI:
.. code-block:: console
$ pip install django-anymail[mailgun]
(The `[mailgun]` part installs any additional packages needed for that ESP.
Mailgun doesn't have any, but some other ESPs do.)
2. Edit your project's ``settings.py``:
.. code-block:: python
INSTALLED_APPS = [
# ...
"anymail",
# ...
]
ANYMAIL = {
# (exact settings here depend on your ESP...)
"MAILGUN_API_KEY": "",
"MAILGUN_SENDER_DOMAIN": 'mg.example.com', # your Mailgun domain, if needed
}
EMAIL_BACKEND = "anymail.backends.mailgun.EmailBackend" # or sendgrid.EmailBackend, or...
DEFAULT_FROM_EMAIL = "you@example.com" # if you don't already have this in settings
SERVER_EMAIL = "your-server@example.com" # ditto (default from-email for Django errors)
3. Now the regular `Django email functions `_
will send through your chosen ESP:
.. code-block:: python
from django.core.mail import send_mail
send_mail("It works!", "This will get sent through Mailgun",
"Anymail Sender ", ["to@example.com"])
You could send an HTML message, complete with an inline image,
custom tags and metadata:
.. code-block:: python
from django.core.mail import EmailMultiAlternatives
from anymail.message import attach_inline_image_file
msg = EmailMultiAlternatives(
subject="Please activate your account",
body="Click to activate your account: http://example.com/activate",
from_email="Example ",
to=["New User ", "account.manager@example.com"],
reply_to=["Helpdesk "])
# Include an inline image in the html:
logo_cid = attach_inline_image_file(msg, "/path/to/logo.jpg")
html = """
Please activate
your account
""".format(logo_cid=logo_cid)
msg.attach_alternative(html, "text/html")
# Optional Anymail extensions:
msg.metadata = {"user_id": "8675309", "experiment_variation": 1}
msg.tags = ["activation", "onboarding"]
msg.track_clicks = True
# Send it:
msg.send()
.. END quickstart
See the `full documentation `_
for more features and options, including receiving messages and tracking
sent message status.
Keywords: Django,email,email backend,ESP,transactional mail,Amazon SES,Mailgun,Mailjet,Mandrill,Postmark,SendinBlue,SendGrid,SparkPost
Platform: UNKNOWN
Classifier: Development Status :: 5 - Production/Stable
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: Implementation :: PyPy
Classifier: Programming Language :: Python :: Implementation :: CPython
Classifier: Programming Language :: Python :: 2
Classifier: Programming Language :: Python :: 2.7
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.4
Classifier: Programming Language :: Python :: 3.5
Classifier: Programming Language :: Python :: 3.6
Classifier: Programming Language :: Python :: 3.7
Classifier: License :: OSI Approved :: BSD License
Classifier: Topic :: Communications :: Email
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Intended Audience :: Developers
Classifier: Framework :: Django
Classifier: Framework :: Django :: 1.11
Classifier: Framework :: Django :: 2.0
Classifier: Framework :: Django :: 2.1
Classifier: Environment :: Web Environment
Provides-Extra: sendinblue
Provides-Extra: sendgrid
Provides-Extra: mailgun
Provides-Extra: mandrill
Provides-Extra: mailjet
Provides-Extra: postmark
Provides-Extra: amazon_ses
Provides-Extra: sparkpost
django-anymail-7.0.0/django_anymail.egg-info/not-zip-safe 0000644 0000765 0000024 00000000001 13535011557 024250 0 ustar medmunds staff 0000000 0000000
django-anymail-7.0.0/django_anymail.egg-info/SOURCES.txt 0000644 0000765 0000024 00000002213 13535011557 023704 0 ustar medmunds staff 0000000 0000000 AUTHORS.txt
LICENSE
MANIFEST.in
README.rst
setup.cfg
setup.py
anymail/__init__.py
anymail/_email_compat.py
anymail/_version.py
anymail/apps.py
anymail/checks.py
anymail/exceptions.py
anymail/inbound.py
anymail/message.py
anymail/signals.py
anymail/urls.py
anymail/utils.py
anymail/backends/__init__.py
anymail/backends/amazon_ses.py
anymail/backends/base.py
anymail/backends/base_requests.py
anymail/backends/console.py
anymail/backends/mailgun.py
anymail/backends/mailjet.py
anymail/backends/mandrill.py
anymail/backends/postmark.py
anymail/backends/sendgrid.py
anymail/backends/sendinblue.py
anymail/backends/sparkpost.py
anymail/backends/test.py
anymail/webhooks/__init__.py
anymail/webhooks/amazon_ses.py
anymail/webhooks/base.py
anymail/webhooks/mailgun.py
anymail/webhooks/mailjet.py
anymail/webhooks/mandrill.py
anymail/webhooks/postmark.py
anymail/webhooks/sendgrid.py
anymail/webhooks/sendinblue.py
anymail/webhooks/sparkpost.py
django_anymail.egg-info/PKG-INFO
django_anymail.egg-info/SOURCES.txt
django_anymail.egg-info/dependency_links.txt
django_anymail.egg-info/not-zip-safe
django_anymail.egg-info/requires.txt
django_anymail.egg-info/top_level.txt django-anymail-7.0.0/django_anymail.egg-info/requires.txt 0000644 0000765 0000024 00000000224 13535011557 024420 0 ustar medmunds staff 0000000 0000000 django>=1.11
requests>=2.4.3
six
[amazon_ses]
boto3
[mailgun]
[mailjet]
[mandrill]
[postmark]
[sendgrid]
[sendinblue]
[sparkpost]
sparkpost
django-anymail-7.0.0/django_anymail.egg-info/top_level.txt 0000644 0000765 0000024 00000000010 13535011557 024543 0 ustar medmunds staff 0000000 0000000 anymail
django-anymail-7.0.0/django_anymail.egg-info/dependency_links.txt 0000644 0000765 0000024 00000000001 13535011557 026070 0 ustar medmunds staff 0000000 0000000