[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