[Python-modules-commits] [django-anymail] 01/04: Imported Upstream version 1.3
Scott Kitterman
kitterman at moszumanska.debian.org
Sat Feb 3 16:24:29 UTC 2018
This is an automated email from the git hooks/post-receive script.
kitterman pushed a commit to branch debian/master
in repository django-anymail.
commit 2c5f94499eb058fc7b95c309b06cd42af1e6e8a8
Author: Scott Kitterman <scott at kitterman.com>
Date: Sat Feb 3 11:14:04 2018 -0500
Imported Upstream version 1.3
---
AUTHORS.txt | 2 +
PKG-INFO | 23 ++-
README.rst | 6 +-
anymail/_version.py | 2 +-
anymail/backends/base_requests.py | 3 +
anymail/backends/console.py | 43 +++++
anymail/backends/mailjet.py | 2 +-
anymail/backends/test.py | 10 +-
anymail/inbound.py | 355 ++++++++++++++++++++++++++++++++++++
anymail/signals.py | 12 ++
anymail/urls.py | 24 ++-
anymail/utils.py | 12 ++
anymail/webhooks/base.py | 23 ++-
anymail/webhooks/mailgun.py | 79 +++++++-
anymail/webhooks/mailjet.py | 85 ++++++++-
anymail/webhooks/mandrill.py | 69 +++++--
anymail/webhooks/postmark.py | 76 +++++++-
anymail/webhooks/sendgrid.py | 94 ++++++++--
anymail/webhooks/sparkpost.py | 42 ++++-
django_anymail.egg-info/PKG-INFO | 23 ++-
django_anymail.egg-info/SOURCES.txt | 2 +
setup.cfg | 3 +-
22 files changed, 914 insertions(+), 76 deletions(-)
diff --git a/AUTHORS.txt b/AUTHORS.txt
index 28ca498..1c1d3c2 100644
--- a/AUTHORS.txt
+++ b/AUTHORS.txt
@@ -4,6 +4,8 @@ Anymail
Mike Edmunds
Calvin Jeong
Peter Wu
+Charlie DeTar
+Jonathan Baugh
Anymail was forked from Djrill, which included contributions from:
diff --git a/PKG-INFO b/PKG-INFO
index a14dc25..5c0959c 100644
--- a/PKG-INFO
+++ b/PKG-INFO
@@ -1,11 +1,12 @@
Metadata-Version: 1.1
Name: django-anymail
-Version: 1.2
+Version: 1.3
Summary: Django email backends for Mailgun, Mailjet, Postmark, SendGrid, SparkPost and other transactional ESPs
Home-page: https://github.com/anymail/django-anymail
Author: Mike Edmunds <medmunds at gmail.com>
Author-email: medmunds at gmail.com
License: BSD License
+Description-Content-Type: UNKNOWN
Description: Anymail: Django email backends for Mailgun, Mailjet, Postmark, SendGrid, SparkPost and more
===========================================================================================
@@ -38,13 +39,15 @@ Description: Anymail: Django email backends for Mailgun, Mailjet, Postmark, Send
built-in `django.core.mail` package. It includes:
* Support for HTML, attachments, extra headers, and other features of
- `Django's built-in email <https://docs.djangoproject.com/en/v1.2/topics/email/>`_
+ `Django's built-in email <https://docs.djangoproject.com/en/v1.3/topics/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.8--2.0
(including Python 2.7, Python 3 and PyPy).
@@ -52,17 +55,17 @@ Description: Anymail: Django email backends for Mailgun, Mailjet, Postmark, Send
.. END shared-intro
- .. image:: https://travis-ci.org/anymail/django-anymail.svg?branch=v1.2
+ .. image:: https://travis-ci.org/anymail/django-anymail.svg?branch=v1.3
:target: https://travis-ci.org/anymail/django-anymail
:alt: build status on Travis-CI
- .. image:: https://readthedocs.org/projects/anymail/badge/?version=v1.2
- :target: https://anymail.readthedocs.io/en/v1.2/
+ .. image:: https://readthedocs.org/projects/anymail/badge/?version=v1.3
+ :target: https://anymail.readthedocs.io/en/v1.3/
:alt: documentation on ReadTheDocs
**Resources**
- * Full documentation: https://anymail.readthedocs.io/en/v1.2/
+ * Full documentation: https://anymail.readthedocs.io/en/v1.3/
* Package on PyPI: https://pypi.python.org/pypi/django-anymail
* Project on Github: https://github.com/anymail/django-anymail
* Changelog: https://github.com/anymail/django-anymail/releases
@@ -75,6 +78,7 @@ Description: Anymail: Django email backends for Mailgun, Mailjet, Postmark, Send
.. 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":
@@ -107,7 +111,7 @@ Description: Anymail: Django email backends for Mailgun, Mailjet, Postmark, Send
DEFAULT_FROM_EMAIL = "you at example.com" # if you don't already have this in settings
- 3. Now the regular `Django email functions <https://docs.djangoproject.com/en/v1.2/topics/email/>`_
+ 3. Now the regular `Django email functions <https://docs.djangoproject.com/en/v1.3/topics/email/>`_
will send through your chosen ESP:
.. code-block:: python
@@ -151,8 +155,9 @@ Description: Anymail: Django email backends for Mailgun, Mailjet, Postmark, Send
.. END quickstart
- See the `full documentation <https://anymail.readthedocs.io/en/v1.2/>`_
- for more features and options.
+ See the `full documentation <https://anymail.readthedocs.io/en/v1.3/>`_
+ for more features and options, including receiving messages and tracking
+ sent message status.
Keywords: django,email,email backend,ESP,transactional mail,mailgun,mailjet,mandrill,postmark,sendgrid
Platform: UNKNOWN
diff --git a/README.rst b/README.rst
index db225b5..6f44491 100644
--- a/README.rst
+++ b/README.rst
@@ -37,6 +37,8 @@ built-in `django.core.mail` package. It includes:
* 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.8--2.0
(including Python 2.7, Python 3 and PyPy).
@@ -67,6 +69,7 @@ Anymail 1-2-3
.. 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":
@@ -144,4 +147,5 @@ or SparkPost or any other supported ESP where you see "mailgun":
See the `full documentation <https://anymail.readthedocs.io/en/stable/>`_
-for more features and options.
+for more features and options, including receiving messages and tracking
+sent message status.
diff --git a/anymail/_version.py b/anymail/_version.py
index 38c1316..a1e7d63 100644
--- a/anymail/_version.py
+++ b/anymail/_version.py
@@ -1,3 +1,3 @@
-VERSION = (1, 2)
+VERSION = (1, 3)
__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"
diff --git a/anymail/backends/base_requests.py b/anymail/backends/base_requests.py
index 2530244..7b4589f 100644
--- a/anymail/backends/base_requests.py
+++ b/anymail/backends/base_requests.py
@@ -4,6 +4,7 @@ import requests
# noinspection PyUnresolvedReferences
from six.moves.urllib.parse import urljoin
+from anymail.utils import get_anymail_setting
from .base import AnymailBaseBackend, BasePayload
from ..exceptions import AnymailRequestsAPIError, AnymailSerializationError
from .._version import __version__
@@ -17,6 +18,7 @@ class AnymailRequestsBackend(AnymailBaseBackend):
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
@@ -65,6 +67,7 @@ class AnymailRequestsBackend(AnymailBaseBackend):
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:
diff --git a/anymail/backends/console.py b/anymail/backends/console.py
new file mode 100644
index 0000000..5fa9ffe
--- /dev/null
+++ b/anymail/backends/console.py
@@ -0,0 +1,43 @@
+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
diff --git a/anymail/backends/mailjet.py b/anymail/backends/mailjet.py
index 6dd8003..7154ee1 100644
--- a/anymail/backends/mailjet.py
+++ b/anymail/backends/mailjet.py
@@ -115,7 +115,7 @@ class MailjetPayload(RequestsPayload):
if template_id and not self.data.get("FromEmail"):
response = self.backend.session.get(
"%sREST/template/%s/detailcontent" % (self.backend.api_url, template_id),
- auth=self.auth
+ 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)
diff --git a/anymail/backends/test.py b/anymail/backends/test.py
index 41f900b..b78b7d0 100644
--- a/anymail/backends/test.py
+++ b/anymail/backends/test.py
@@ -27,6 +27,11 @@ class EmailBackend(AnymailBaseBackend):
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 TestPayload(backend=self, message=message, defaults=defaults)
@@ -41,7 +46,10 @@ class EmailBackend(AnymailBaseBackend):
raise response
except AttributeError:
# Default is to return 'sent' for each recipient
- status = AnymailRecipientStatus(message_id=1, status='sent')
+ status = AnymailRecipientStatus(
+ message_id=self.get_esp_message_id(message),
+ status='sent'
+ )
response = {
'recipient_status': {email: status for email in payload.recipient_emails}
}
diff --git a/anymail/inbound.py b/anymail/inbound.py
new file mode 100644
index 0000000..3cce925
--- /dev/null
+++ b/anymail/inbound.py
@@ -0,0 +1,355 @@
+from base64 import b64decode
+from email import message_from_string
+from email.message import Message
+from email.utils import unquote
+
+import six
+from django.core.files.uploadedfile import SimpleUploadedFile
+
+from .utils import angle_wrap, get_content_disposition, parse_address_list, parse_rfc2822date
+
+# Python 2/3.*-compatible email.parser.HeaderParser(policy=email.policy.default)
+try:
+ # With Python 3.3+ (email6) package, can use HeaderParser with default policy
+ from email.parser import HeaderParser
+ from email.policy import default as accurate_header_unfolding_policy # vs. compat32
+
+except ImportError:
+ # Earlier Pythons don't have HeaderParser, and/or try preserve earlier compatibility bugs
+ # by failing to properly unfold headers (see RFC 5322 section 2.2.3)
+ from email.parser import Parser
+ import re
+ accurate_header_unfolding_policy = object()
+
+ class HeaderParser(Parser, object):
+ def __init__(self, _class, policy=None):
+ # This "backport" doesn't actually support policies, but we want to ensure
+ # that callers aren't trying to use HeaderParser's default compat32 policy
+ # (which doesn't properly unfold headers)
+ assert policy is accurate_header_unfolding_policy
+ super(HeaderParser, self).__init__(_class)
+
+ def parsestr(self, text, headersonly=True):
+ unfolded = self._unfold_headers(text)
+ return super(HeaderParser, self).parsestr(unfolded, headersonly=True)
+
+ @staticmethod
+ def _unfold_headers(text):
+ # "Unfolding is accomplished by simply removing any CRLF that is immediately followed by WSP"
+ # (WSP is space or tab, and per email.parser semantics, we allow CRLF, CR, or LF endings)
+ return re.sub(r'(\r\n|\r|\n)(?=[ \t])', "", text)
+
+
+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():
+ payload = part.get_payload(decode=True)
+ if payload is not None:
+ return payload.decode('utf-8')
+ 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='utf-8'):
+ """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()?)")
+ return self.get_payload(decode=True).decode(charset)
+
+ 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"""
+ return message_from_string(s, cls)
+
+ @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 = HeaderParser(cls, policy=accurate_header_unfolding_policy).parsestr(raw_headers)
+ 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
diff --git a/anymail/signals.py b/anymail/signals.py
index c5231a8..533e5b0 100644
--- a/anymail/signals.py
+++ b/anymail/signals.py
@@ -45,6 +45,18 @@ class AnymailInboundEvent(AnymailEvent):
def __init__(self, **kwargs):
super(AnymailInboundEvent, self).__init__(**kwargs)
+ self.message = kwargs.pop('message', None) # anymail.inbound.AnymailInboundMessage
+ self.recipient = kwargs.pop('recipient', None) # str: envelope recipient
+ self.sender = kwargs.pop('sender', None) # str: envelope sender
+
+ self.stripped_text = kwargs.pop('stripped_text', None) # cleaned of quotes/signatures (varies by ESP)
+ self.stripped_html = kwargs.pop('stripped_html', None)
+ self.spam_detected = kwargs.pop('spam_detected', None) # bool
+ self.spam_score = kwargs.pop('spam_score', None) # float: usually SpamAssassin
+
+ # SPF status?
+ # DKIM status?
+ # DMARC status? (no ESP has documented support yet)
class EventType:
diff --git a/anymail/urls.py b/anymail/urls.py
index e3e6cb5..75a3f77 100644
--- a/anymail/urls.py
+++ b/anymail/urls.py
@@ -1,19 +1,29 @@
from django.conf.urls import url
-from .webhooks.mailgun import MailgunTrackingWebhookView
-from .webhooks.mailjet import MailjetTrackingWebhookView
-from .webhooks.mandrill import MandrillTrackingWebhookView
-from .webhooks.postmark import PostmarkTrackingWebhookView
-from .webhooks.sendgrid import SendGridTrackingWebhookView
-from .webhooks.sparkpost import SparkPostTrackingWebhookView
+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.sparkpost import SparkPostInboundWebhookView, SparkPostTrackingWebhookView
app_name = 'anymail'
urlpatterns = [
+ 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'^mailgun/tracking/$', MailgunTrackingWebhookView.as_view(), name='mailgun_tracking_webhook'),
url(r'^mailjet/tracking/$', MailjetTrackingWebhookView.as_view(), name='mailjet_tracking_webhook'),
- url(r'^mandrill/tracking/$', MandrillTrackingWebhookView.as_view(), name='mandrill_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'^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'),
]
diff --git a/anymail/utils.py b/anymail/utils.py
index 69ead1b..4ed5bb5 100644
--- a/anymail/utils.py
+++ b/anymail/utils.py
@@ -433,6 +433,18 @@ def rfc2822date(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
diff --git a/anymail/webhooks/base.py b/anymail/webhooks/base.py
index 1f98bc6..2bfd36e 100644
--- a/anymail/webhooks/base.py
+++ b/anymail/webhooks/base.py
@@ -1,8 +1,8 @@
-import re
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
@@ -41,8 +41,13 @@ class AnymailBasicAuthMixin(object):
def validate_request(self, request):
"""If configured for webhook basic auth, validate request has correct auth."""
if self.basic_auth:
- basic_auth = get_request_basic_auth(request)
- if basic_auth is None or basic_auth not in 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)
@@ -78,8 +83,11 @@ class AnymailBaseWebhookView(AnymailBasicAuthMixin, View):
*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 request.POST['signature'] != expected_signature:
+ # if not constant_time_compare(request.POST['signature'], expected_signature):
# raise AnymailWebhookValidationFailure("...message...")
# (else just do nothing)
pass
@@ -128,6 +136,9 @@ class AnymailBaseWebhookView(AnymailBasicAuthMixin, View):
"""
Read-only name of the ESP for this webhook view.
- (E.g., MailgunTrackingWebhookView will return "Mailgun")
+ Subclasses must override with class attr. E.g.:
+ esp_name = "Postmark"
+ esp_name = "SendGrid" # (use ESP's preferred capitalization)
"""
- return re.sub(r'(Tracking|Inbox)WebhookView$', "", self.__class__.__name__)
+ raise NotImplementedError("%s.%s must declare esp_name class attr" %
+ (self.__class__.__module__, self.__class__.__name__))
diff --git a/anymail/webhooks/mailgun.py b/anymail/webhooks/mailgun.py
index 396a871..7515119 100644
--- a/anymail/webhooks/mailgun.py
+++ b/anymail/webhooks/mailgun.py
@@ -8,13 +8,15 @@ from django.utils.timezone import utc
from .base import AnymailBaseWebhookView
from ..exceptions import AnymailWebhookValidationFailure
-from ..signals import tracking, AnymailTrackingEvent, EventType, RejectReason
+from ..inbound import AnymailInboundMessage
+from ..signals import inbound, tracking, AnymailInboundEvent, AnymailTrackingEvent, EventType, RejectReason
from ..utils import get_anymail_setting, combine, querydict_getfirst
class MailgunBaseWebhookView(AnymailBaseWebhookView):
"""Base view class for Mailgun webhooks"""
+ esp_name = "Mailgun"
warn_if_no_basic_auth = False # because we validate against signature
api_key = None # (Declaring class attr allows override by kwargs in View.as_view.)
@@ -40,12 +42,6 @@ class MailgunBaseWebhookView(AnymailBaseWebhookView):
if not constant_time_compare(signature, expected_signature):
raise AnymailWebhookValidationFailure("Mailgun webhook called with incorrect signature")
- def parse_events(self, request):
- return [self.esp_to_anymail_event(request.POST)]
-
- def esp_to_anymail_event(self, esp_event):
- raise NotImplementedError()
-
class MailgunTrackingWebhookView(MailgunBaseWebhookView):
"""Handler for Mailgun delivery and engagement tracking webhooks"""
@@ -75,6 +71,9 @@ class MailgunTrackingWebhookView(MailgunBaseWebhookView):
607: RejectReason.SPAM, # previous spam complaint
}
+ def parse_events(self, request):
+ return [self.esp_to_anymail_event(request.POST)]
+
def esp_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.
@@ -194,3 +193,69 @@ class MailgunTrackingWebhookView(MailgunBaseWebhookView):
'opened': _common_event_fields,
'unsubscribed': _common_event_fields,
}
+
+
+class MailgunInboundWebhookView(MailgunBaseWebhookView):
+ """Handler for Mailgun inbound (route forward-to-url) webhook"""
+
+ 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 '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,
+ )
diff --git a/anymail/webhooks/mailjet.py b/anymail/webhooks/mailjet.py
index bb8c5da..ce536ea 100644
--- a/anymail/webhooks/mailjet.py
+++ b/anymail/webhooks/mailjet.py
@@ -4,12 +4,14 @@ from datetime import datetime
from django.utils.timezone import utc
from .base import AnymailBaseWebhookView
-from ..signals import tracking, AnymailTrackingEvent, EventType, RejectReason
+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):
@@ -95,3 +97,84 @@ class MailjetTrackingWebhookView(AnymailBaseWebhookView):
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<N> or InlineAttachment<N>
+ ]
+ 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
+
... 516 lines suppressed ...
--
Alioth's /usr/local/bin/git-commit-notice on /srv/git.debian.org/git/python-modules/packages/django-anymail.git
More information about the Python-modules-commits
mailing list