[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