[Python-modules-commits] [django-anymail] 01/03: Imported Upstream version 1.2

Scott Kitterman kitterman at moszumanska.debian.org
Sun Nov 5 23:30:12 UTC 2017


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 14ae0c1e21d866477ca2092d3db38f8a01281b2d
Author: Scott Kitterman <scott at kitterman.com>
Date:   Sun Nov 5 18:28:44 2017 -0500

    Imported Upstream version 1.2
---
 PKG-INFO                         |  18 +++---
 README.rst                       |   2 +-
 anymail/_version.py              |   2 +-
 anymail/backends/mailgun.py      |   4 +-
 anymail/backends/mailjet.py      |  30 ++++-----
 anymail/backends/mandrill.py     |   8 +--
 anymail/backends/postmark.py     |   4 +-
 anymail/backends/sendgrid.py     |  12 ++--
 anymail/backends/sendgrid_v2.py  |  14 ++--
 anymail/backends/sparkpost.py    |  10 +--
 anymail/backends/test.py         |   6 +-
 anymail/utils.py                 | 134 +++++++++++++++++++++++++++++----------
 anymail/webhooks/mailgun.py      | 115 ++++++++++++++++++++++++---------
 anymail/webhooks/postmark.py     |   5 +-
 django_anymail.egg-info/PKG-INFO |  18 +++---
 setup.cfg                        |   3 +-
 16 files changed, 254 insertions(+), 131 deletions(-)

diff --git a/PKG-INFO b/PKG-INFO
index 6da7311..a14dc25 100644
--- a/PKG-INFO
+++ b/PKG-INFO
@@ -1,6 +1,6 @@
 Metadata-Version: 1.1
 Name: django-anymail
-Version: 1.0
+Version: 1.2
 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>
@@ -38,7 +38,7 @@ 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.0/topics/email/>`_
+          `Django's built-in email <https://docs.djangoproject.com/en/v1.2/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
@@ -46,23 +46,23 @@ Description: Anymail: Django email backends for Mailgun, Mailjet, Postmark, Send
           your ESP's webhooks to Django signals
         * "Batch transactional" sends using your ESP's merge and template features
         
-        Anymail is released under the BSD license. It is extensively tested against Django 1.8--1.11
+        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).
         Anymail releases follow `semantic versioning <http://semver.org/>`_.
         
         .. END shared-intro
         
-        .. image:: https://travis-ci.org/anymail/django-anymail.svg?branch=v1.0
+        .. image:: https://travis-ci.org/anymail/django-anymail.svg?branch=v1.2
                :target: https://travis-ci.org/anymail/django-anymail
                :alt:    build status on Travis-CI
         
-        .. image:: https://readthedocs.org/projects/anymail/badge/?version=v1.0
-               :target: https://anymail.readthedocs.io/en/v1.0/
+        .. image:: https://readthedocs.org/projects/anymail/badge/?version=v1.2
+               :target: https://anymail.readthedocs.io/en/v1.2/
                :alt:    documentation on ReadTheDocs
         
         **Resources**
         
-        * Full documentation: https://anymail.readthedocs.io/en/v1.0/
+        * Full documentation: https://anymail.readthedocs.io/en/v1.2/
         * 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
@@ -107,7 +107,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.0/topics/email/>`_
+        3. Now the regular `Django email functions <https://docs.djangoproject.com/en/v1.2/topics/email/>`_
            will send through your chosen ESP:
         
            .. code-block:: python
@@ -151,7 +151,7 @@ Description: Anymail: Django email backends for Mailgun, Mailjet, Postmark, Send
         .. END quickstart
         
         
-        See the `full documentation <https://anymail.readthedocs.io/en/v1.0/>`_
+        See the `full documentation <https://anymail.readthedocs.io/en/v1.2/>`_
         for more features and options.
         
 Keywords: django,email,email backend,ESP,transactional mail,mailgun,mailjet,mandrill,postmark,sendgrid
diff --git a/README.rst b/README.rst
index 06366e4..db225b5 100644
--- a/README.rst
+++ b/README.rst
@@ -38,7 +38,7 @@ built-in `django.core.mail` package. It includes:
   your ESP's webhooks to Django signals
 * "Batch transactional" sends using your ESP's merge and template features
 
-Anymail is released under the BSD license. It is extensively tested against Django 1.8--1.11
+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).
 Anymail releases follow `semantic versioning <http://semver.org/>`_.
 
diff --git a/anymail/_version.py b/anymail/_version.py
index f10356b..38c1316 100644
--- a/anymail/_version.py
+++ b/anymail/_version.py
@@ -1,3 +1,3 @@
-VERSION = (1, 0)
+VERSION = (1, 2)
 __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/mailgun.py b/anymail/backends/mailgun.py
index 6ba75da..ee1328d 100644
--- a/anymail/backends/mailgun.py
+++ b/anymail/backends/mailgun.py
@@ -53,7 +53,7 @@ class EmailBackend(AnymailRequestsBackend):
                                           backend=self)
         # Simulate a per-recipient status of "queued":
         status = AnymailRecipientStatus(message_id=message_id, status="queued")
-        return {recipient.email: status for recipient in payload.all_recipients}
+        return {recipient.addr_spec: status for recipient in payload.all_recipients}
 
 
 class MailgunPayload(RequestsPayload):
@@ -127,7 +127,7 @@ class MailgunPayload(RequestsPayload):
             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.email for email in emails]  # used for populate_recipient_variables
+            self.to_emails = [email.addr_spec for email in emails]  # used for populate_recipient_variables
 
     def set_subject(self, subject):
         self.data["subject"] = subject
diff --git a/anymail/backends/mailjet.py b/anymail/backends/mailjet.py
index 68ef529..6dd8003 100644
--- a/anymail/backends/mailjet.py
+++ b/anymail/backends/mailjet.py
@@ -1,6 +1,6 @@
 from ..exceptions import AnymailRequestsAPIError
 from ..message import AnymailRecipientStatus, ANYMAIL_STATUSES
-from ..utils import get_anymail_setting, ParsedEmail, parse_address_list
+from ..utils import get_anymail_setting, EmailAddress, parse_address_list
 
 from .base_requests import AnymailRequestsBackend, RequestsPayload
 
@@ -65,8 +65,8 @@ class EmailBackend(AnymailRequestsBackend):
         # (Mailjet only communicates "Sent")
         for recipients in payload.recipients.values():
             for email in recipients:
-                if email.email not in recipient_status:
-                    recipient_status[email.email] = AnymailRecipientStatus(message_id=None, status='unknown')
+                if email.addr_spec not in recipient_status:
+                    recipient_status[email.addr_spec] = AnymailRecipientStatus(message_id=None, status='unknown')
 
         return recipient_status
 
@@ -127,11 +127,11 @@ class MailjetPayload(RequestsPayload):
                     # 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.name:
-                        parsed.name = parsed.name.replace("||COMMA||", ",")
+                    if parsed.display_name:
+                        parsed = EmailAddress(parsed.display_name.replace("||COMMA||", ","),
+                                              parsed.addr_spec)
                 else:
-                    name_addr = (headers["SenderName"], headers["SenderEmail"])
-                    parsed = ParsedEmail(name_addr)
+                    parsed = EmailAddress(headers["SenderName"], headers["SenderEmail"])
             except KeyError:
                 raise AnymailRequestsAPIError("Invalid Mailjet template API response",
                                               email_message=self.message, response=response, backend=self.backend)
@@ -144,9 +144,9 @@ class MailjetPayload(RequestsPayload):
         merge_data = self.merge_data or {}
         for email in self.recipients["to"]:
             recipient = {
-                "Email": email.email,
-                "Name": email.name,
-                "Vars": merge_data.get(email.email)
+                "Email": email.addr_spec,
+                "Name": email.display_name,
+                "Vars": merge_data.get(email.addr_spec)
             }
             # Strip out empty Name and Vars
             recipient = {k: v for k, v in recipient.items() if v}
@@ -163,9 +163,9 @@ class MailjetPayload(RequestsPayload):
             # Workaround Mailjet 3.0 bug parsing display-name with commas
             # (see test_comma_in_display_name in test_mailjet_backend for details)
             formatted_emails = [
-                email.address if "," not in email.name
+                email.address if "," not in email.display_name
                 # else name has a comma, so force it into MIME encoded-word utf-8 syntax:
-                else ParsedEmail((email.name.encode('utf-8'), email.email)).formataddr('utf-8')
+                else EmailAddress(email.display_name.encode('utf-8'), email.addr_spec).formataddr('utf-8')
                 for email in emails
             ]
             self.data[recipient_type.capitalize()] = ", ".join(formatted_emails)
@@ -175,9 +175,9 @@ class MailjetPayload(RequestsPayload):
         }
 
     def set_from_email(self, email):
-        self.data["FromEmail"] = email.email
-        if email.name:
-            self.data["FromName"] = email.name
+        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"]
diff --git a/anymail/backends/mandrill.py b/anymail/backends/mandrill.py
index 758e7c4..efe3197 100644
--- a/anymail/backends/mandrill.py
+++ b/anymail/backends/mandrill.py
@@ -95,14 +95,14 @@ class MandrillPayload(RequestsPayload):
         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.email
-            if email.name:
-                self.data["message"]["from_name"] = email.name
+            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.email, "name": email.name, "type": recipient_type})
+        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):
diff --git a/anymail/backends/postmark.py b/anymail/backends/postmark.py
index a144e9a..3a2e840 100644
--- a/anymail/backends/postmark.py
+++ b/anymail/backends/postmark.py
@@ -69,9 +69,9 @@ class EmailBackend(AnymailRequestsBackend):
                                           backend=self)
 
         return {
-            recipient.email: AnymailRecipientStatus(
+            recipient.addr_spec: AnymailRecipientStatus(
                 message_id=message_id,
-                status=('rejected' if recipient.email.lower() in rejected_emails
+                status=('rejected' if recipient.addr_spec.lower() in rejected_emails
                         else default_status)
             )
             for recipient in payload.all_recipients
diff --git a/anymail/backends/sendgrid.py b/anymail/backends/sendgrid.py
index 1e702d5..89ddff2 100644
--- a/anymail/backends/sendgrid.py
+++ b/anymail/backends/sendgrid.py
@@ -64,7 +64,7 @@ class EmailBackend(AnymailRequestsBackend):
         # SendGrid v3 doesn't provide any information in the response for a successful send,
         # so simulate a per-recipient status of "queued":
         status = AnymailRecipientStatus(message_id=payload.message_id, status="queued")
-        return {recipient.email: status for recipient in payload.all_recipients}
+        return {recipient.addr_spec: status for recipient in payload.all_recipients}
 
 
 class SendGridPayload(RequestsPayload):
@@ -199,17 +199,17 @@ class SendGridPayload(RequestsPayload):
 
     @staticmethod
     def email_object(email, workaround_name_quote_bug=False):
-        """Converts ParsedEmail to SendGrid API {email, name} dict"""
-        obj = {"email": email.email}
-        if email.name:
+        """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.name)
+                obj["name"] = '"%s"' % rfc822_quote(email.display_name)
             else:
-                obj["name"] = email.name
+                obj["name"] = email.display_name
         return obj
 
     def set_from_email(self, email):
diff --git a/anymail/backends/sendgrid_v2.py b/anymail/backends/sendgrid_v2.py
index 889b763..41138b2 100644
--- a/anymail/backends/sendgrid_v2.py
+++ b/anymail/backends/sendgrid_v2.py
@@ -63,7 +63,7 @@ class EmailBackend(AnymailRequestsBackend):
                                           backend=self)
         # Simulate a per-recipient status of "queued":
         status = AnymailRecipientStatus(message_id=payload.message_id, status="queued")
-        return {recipient.email: status for recipient in payload.all_recipients}
+        return {recipient.addr_spec: status for recipient in payload.all_recipients}
 
 
 class SendGridPayload(RequestsPayload):
@@ -166,7 +166,7 @@ class SendGridPayload(RequestsPayload):
             all_fields = set()
             for recipient_data in self.merge_data.values():
                 all_fields = all_fields.union(recipient_data.keys())
-            recipients = [email.email for email in self.to_list]
+            recipients = [email.addr_spec for email in self.to_list]
 
             if self.merge_field_format is None and all(field.isalnum() for field in all_fields):
                 warnings.warn(
@@ -203,9 +203,9 @@ class SendGridPayload(RequestsPayload):
         self.data['headers'] = CaseInsensitiveDict()  # headers keys are case-insensitive
 
     def set_from_email(self, email):
-        self.data["from"] = email.email
-        if email.name:
-            self.data["fromname"] = email.name
+        self.data["from"] = email.addr_spec
+        if email.display_name:
+            self.data["fromname"] = email.display_name
 
     def set_to(self, emails):
         self.to_list = emails  # track for later use by build_merge_data
@@ -214,9 +214,9 @@ class SendGridPayload(RequestsPayload):
     def set_recipients(self, recipient_type, emails):
         assert recipient_type in ["to", "cc", "bcc"]
         if emails:
-            self.data[recipient_type] = [email.email for email in emails]
+            self.data[recipient_type] = [email.addr_spec for email in emails]
             empty_name = " "  # SendGrid API balks on complete empty name fields
-            self.data[recipient_type + "name"] = [email.name or empty_name for email in emails]
+            self.data[recipient_type + "name"] = [email.display_name or empty_name for email in emails]
             self.all_recipients += emails  # used for backend.parse_recipient_status
 
     def set_subject(self, subject):
diff --git a/anymail/backends/sparkpost.py b/anymail/backends/sparkpost.py
index 20e652e..622925f 100644
--- a/anymail/backends/sparkpost.py
+++ b/anymail/backends/sparkpost.py
@@ -76,7 +76,7 @@ class EmailBackend(AnymailBaseBackend):
         else:  # mixed results, or wrong total
             status = 'unknown'
         recipient_status = AnymailRecipientStatus(message_id=transmission_id, status=status)
-        return {recipient.email: recipient_status for recipient in payload.all_recipients}
+        return {recipient.addr_spec: recipient_status for recipient in payload.all_recipients}
 
 
 class SparkPostPayload(BasePayload):
@@ -92,11 +92,11 @@ class SparkPostPayload(BasePayload):
         if len(self.merge_data) > 0:
             # Build JSON recipient structures
             for email in self.to_emails:
-                rcpt = {'address': {'email': email.email}}
-                if email.name:
-                    rcpt['address']['name'] = email.name
+                rcpt = {'address': {'email': email.addr_spec}}
+                if email.display_name:
+                    rcpt['address']['name'] = email.display_name
                 try:
-                    rcpt['substitution_data'] = self.merge_data[email.email]
+                    rcpt['substitution_data'] = self.merge_data[email.addr_spec]
                 except KeyError:
                     pass  # no merge_data or none for this recipient
                 recipients.append(rcpt)
diff --git a/anymail/backends/test.py b/anymail/backends/test.py
index cc7c407..41f900b 100644
--- a/anymail/backends/test.py
+++ b/anymail/backends/test.py
@@ -68,15 +68,15 @@ class TestPayload(BasePayload):
 
     def set_to(self, emails):
         self.params['to'] = emails
-        self.recipient_emails += [email.email for email in emails]
+        self.recipient_emails += [email.addr_spec for email in emails]
 
     def set_cc(self, emails):
         self.params['cc'] = emails
-        self.recipient_emails += [email.email for email in emails]
+        self.recipient_emails += [email.addr_spec for email in emails]
 
     def set_bcc(self, emails):
         self.params['bcc'] = emails
-        self.recipient_emails += [email.email for email in emails]
+        self.recipient_emails += [email.addr_spec for email in emails]
 
     def set_subject(self, subject):
         self.params['subject'] = subject
diff --git a/anymail/utils.py b/anymail/utils.py
index 7241d08..69ead1b 100644
--- a/anymail/utils.py
+++ b/anymail/utils.py
@@ -12,7 +12,7 @@ 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
+from django.utils.timezone import utc, get_fixed_timezone
 # noinspection PyUnresolvedReferences
 from six.moves.urllib.parse import urlsplit, urlunsplit
 
@@ -118,7 +118,7 @@ def update_deep(dct, other):
 
 
 def parse_address_list(address_list):
-    """Returns a list of ParsedEmail objects from strings in address_list.
+    """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
@@ -128,7 +128,7 @@ def parse_address_list(address_list):
 
     :param list[str]|str|None|list[None] address_list:
         the address or addresses to parse
-    :return list[:class:`ParsedEmail`]:
+    :return list[:class:`EmailAddress`]:
     :raises :exc:`AnymailInvalidAddress`:
     """
     if isinstance(address_list, six.string_types) or is_lazy(address_list):
@@ -145,14 +145,15 @@ def parse_address_list(address_list):
     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 = [ParsedEmail(name_email_pair) for name_email_pair in name_email_pairs]
+    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.localpart == '' or address.domain == '':
-            # Django SMTP allows localpart-only emails, but they're not meaningful with an ESP
+        if address.username == '' or address.domain == '':
+            # Django SMTP allows username-only emails, but they're not meaningful with an ESP
             errmsg = "Invalid email address '%s' parsed from '%s'." % (
-                address.email, ", ".join(address_list_strings))
+                address.addr_spec, ", ".join(address_list_strings))
             if len(parsed) > len(address_list):
                 errmsg += " (Maybe missing quotes around a display-name?)"
             raise AnymailInvalidAddress(errmsg)
@@ -160,46 +161,46 @@ def parse_address_list(address_list):
     return parsed
 
 
-class ParsedEmail(object):
-    """A sanitized, complete email address with separate name and email properties.
+class EmailAddress(object):
+    """A sanitized, complete email address with easy access
+    to display-name, addr-spec (email), etc.
 
-    (Intended for Anymail internal use.)
+    Similar to Python 3.6+ email.headerregistry.Address
 
     Instance properties, all read-only:
-    :ivar str name:
+    :ivar str display_name:
         the address's display-name portion (unqouted, unescaped),
         e.g., 'Display Name, Inc.'
-    :ivar str email:
+    :ivar str addr_spec:
         the address's addr-spec portion (unquoted, unescaped),
         e.g., 'user at example.com'
-    :ivar str address:
-        the fully-formatted address, with any necessary quoting and escaping,
-        e.g., '"Display Name, Inc." <user at example.com>'
-    :ivar str localpart:
-        the local part (before the '@') of email,
+    :ivar str username:
+        the local part (before the '@') of the addr-spec,
         e.g., 'user'
     :ivar str domain:
-        the domain part (after the '@') of email,
+        the domain part (after the '@') of the addr-spec,
         e.g., 'example.com'
-    """
 
-    def __init__(self, name_email_pair):
-        """Construct a ParsedEmail.
-
-        You generally should use :func:`parse_address_list` rather than creating
-        ParsedEmail objects directly.
+    :ivar str address:
+        the fully-formatted address, with any necessary quoting and escaping,
+        e.g., '"Display Name, Inc." <user at example.com>'
+        (also available as `str(EmailAddress)`)
+    """
 
-        :param tuple(str, str) name_email_pair:
-            the display-name and addr-spec (both unquoted) for the address,
-            as returned by :func:`email.utils.parseaddr` and
-            :func:`email.utils.getaddresses`
-        """
+    def __init__(self, display_name='', addr_spec=None):
         self._address = None  # lazy formatted address
-        self.name, self.email = name_email_pair
+        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.localpart, self.domain = self.email.split("@", 1)
+            self.username, self.domain = addr_spec.split("@", 1)
+            # do we need to unquote username?
         except ValueError:
-            self.localpart = self.email
+            self.username = addr_spec
             self.domain = ''
 
     @property
@@ -215,7 +216,7 @@ class ParsedEmail(object):
         """Return a fully-formatted email address, using encoding.
 
         This is essentially the same as :func:`email.utils.formataddr`
-        on the ParsedEmail's name and email properties, but uses
+        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
@@ -226,7 +227,7 @@ class ParsedEmail(object):
             default None uses ascii if possible, else 'utf-8'
             (quoted-printable utf-8/base64)
         """
-        return sanitize_address((self.name, self.email), encoding)
+        return sanitize_address((self.display_name, self.addr_spec), encoding)
 
     def __str__(self):
         return self.address
@@ -381,6 +382,34 @@ def collect_all_methods(cls, method_name):
     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)
 
 
@@ -466,3 +495,40 @@ def get_request_uri(request):
         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
diff --git a/anymail/webhooks/mailgun.py b/anymail/webhooks/mailgun.py
index cd3de0f..396a871 100644
--- a/anymail/webhooks/mailgun.py
+++ b/anymail/webhooks/mailgun.py
@@ -9,7 +9,7 @@ from django.utils.timezone import utc
 from .base import AnymailBaseWebhookView
 from ..exceptions import AnymailWebhookValidationFailure
 from ..signals import tracking, AnymailTrackingEvent, EventType, RejectReason
-from ..utils import get_anymail_setting, combine
+from ..utils import get_anymail_setting, combine, querydict_getfirst
 
 
 class MailgunBaseWebhookView(AnymailBaseWebhookView):
@@ -28,6 +28,8 @@ class MailgunBaseWebhookView(AnymailBaseWebhookView):
     def validate_request(self, request):
         super(MailgunBaseWebhookView, self).validate_request(request)  # first check basic auth if enabled
         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)
@@ -75,27 +77,31 @@ class MailgunTrackingWebhookView(MailgunBaseWebhookView):
 
     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
-
-        event_type = self.event_types.get(esp_event['event'], EventType.UNKNOWN)
-        timestamp = datetime.fromtimestamp(int(esp_event['timestamp']), tz=utc)
+        # 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)
+
+        event_type = self.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 <angle-brackets>.)
-        message_id = esp_event.get('Message-Id', esp_event.get('message-id', None))
+        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.get('description', None)
-        mta_response = esp_event.get('error', esp_event.get('notification', None))
+        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['code'])
+            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['code'].split('.')[0]
+                status_class = esp_event.getfirst('code').split('.')[0]
             except (TypeError, IndexError):
                 # illegal SMTP status code format
                 pass
@@ -107,37 +113,84 @@ class MailgunTrackingWebhookView(MailgunBaseWebhookView):
                 RejectReason.BOUNCED if 400 <= mta_status < 600
                 else RejectReason.OTHER)
 
-        # Mailgun merges metadata fields with the other event fields.
-        # However, it also includes the original message headers,
-        # which have the metadata separately as X-Mailgun-Variables.
-        try:
-            headers = json.loads(esp_event['message-headers'])
-        except (KeyError, ):
-            metadata = {}
-        else:
-            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])
-            else:
-                metadata = {}
+        metadata = self._extract_metadata(esp_event)
 
-        # tags are sometimes delivered as X-Mailgun-Tag fields, sometimes as tag
-        tags = esp_event.getlist('tag', esp_event.getlist('X-Mailgun-Tag', []))
+        # 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),
-            recipient=esp_event.get('recipient', None),
+            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.get('url', None),
-            user_agent=esp_event.get('user-agent', None),
+            click_url=esp_event.getfirst('url', None),
+            user_agent=esp_event.getfirst('user-agent', None),
             esp_event=esp_event,
         )
+
+    def _extract_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_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_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_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_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_event_fields | {'url'},
+        'opened': _common_event_fields,
+        'unsubscribed': _common_event_fields,
+    }
diff --git a/anymail/webhooks/postmark.py b/anymail/webhooks/postmark.py
index 672029c..5f19585 100644
--- a/anymail/webhooks/postmark.py
+++ b/anymail/webhooks/postmark.py
@@ -49,7 +49,7 @@ class PostmarkTrackingWebhookView(PostmarkBaseWebhookView):
         'DMARCPolicy': (EventType.REJECTED, RejectReason.BLOCKED),
         'TemplateRenderingFailed': (EventType.FAILED, None),
         # DELIVERED doesn't have a Type field; detected separately below
-        # CLICKED doesn't have a Postmark webhook (yet?)
+        # CLICKED doesn't have a Type field; detected separately below
         # OPENED doesn't have a Type field; detected separately below
         # INBOUND doesn't have a Type field; should come in through different webhook
     }
@@ -62,6 +62,8 @@ class PostmarkTrackingWebhookView(PostmarkBaseWebhookView):
         except KeyError:
             if 'FirstOpen' in esp_event:
                 event_type = EventType.OPENED
+            elif 'OriginalLink' in esp_event:
+                event_type = EventType.CLICKED
             elif 'DeliveredAt' in esp_event:
                 event_type = EventType.DELIVERED
             elif 'From' in esp_event:
@@ -103,4 +105,5 @@ class PostmarkTrackingWebhookView(PostmarkBaseWebhookView):
             tags=tags,
             timestamp=timestamp,
             user_agent=esp_event.get('UserAgent', None),
+            click_url=esp_event.get('OriginalLink', None),
         )
diff --git a/django_anymail.egg-info/PKG-INFO b/django_anymail.egg-info/PKG-INFO
index 6da7311..a14dc25 100644
--- a/django_anymail.egg-info/PKG-INFO
+++ b/django_anymail.egg-info/PKG-INFO
@@ -1,6 +1,6 @@
 Metadata-Version: 1.1
 Name: django-anymail
-Version: 1.0
+Version: 1.2
 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>
@@ -38,7 +38,7 @@ 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.0/topics/email/>`_
+          `Django's built-in email <https://docs.djangoproject.com/en/v1.2/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
@@ -46,23 +46,23 @@ Description: Anymail: Django email backends for Mailgun, Mailjet, Postmark, Send
           your ESP's webhooks to Django signals
         * "Batch transactional" sends using your ESP's merge and template features
         
-        Anymail is released under the BSD license. It is extensively tested against Django 1.8--1.11
+        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).
         Anymail releases follow `semantic versioning <http://semver.org/>`_.
         
         .. END shared-intro
         
-        .. image:: https://travis-ci.org/anymail/django-anymail.svg?branch=v1.0
+        .. image:: https://travis-ci.org/anymail/django-anymail.svg?branch=v1.2
                :target: https://travis-ci.org/anymail/django-anymail
                :alt:    build status on Travis-CI
         
-        .. image:: https://readthedocs.org/projects/anymail/badge/?version=v1.0
-               :target: https://anymail.readthedocs.io/en/v1.0/
+        .. image:: https://readthedocs.org/projects/anymail/badge/?version=v1.2
+               :target: https://anymail.readthedocs.io/en/v1.2/
                :alt:    documentation on ReadTheDocs
         
         **Resources**
         
-        * Full documentation: https://anymail.readthedocs.io/en/v1.0/
+        * Full documentation: https://anymail.readthedocs.io/en/v1.2/
         * 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
@@ -107,7 +107,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.0/topics/email/>`_
+        3. Now the regular `Django email functions <https://docs.djangoproject.com/en/v1.2/topics/email/>`_
            will send through your chosen ESP:
         
            .. code-block:: python
@@ -151,7 +151,7 @@ Description: Anymail: Django email backends for Mailgun, Mailjet, Postmark, Send
         .. END quickstart
         
         
-        See the `full documentation <https://anymail.readthedocs.io/en/v1.0/>`_
+        See the `full documentation <https://anymail.readthedocs.io/en/v1.2/>`_
         for more features and options.
         
 Keywords: django,email,email backend,ESP,transactional mail,mailgun,mailjet,mandrill,postmark,sendgrid
diff --git a/setup.cfg b/setup.cfg
index 8bfd5a1..72f9d44 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,4 +1,5 @@
 [egg_info]
-tag_build = 
+tag_svn_revision = 0
 tag_date = 0
+tag_build = 
 

-- 
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