[Date Prev][Date Next] [Thread Prev][Thread Next] [Date Index] [Thread Index]

Bug#859306: unblock: django-anymail/0.8-2



Package: release.debian.org
Severity: normal
User: release.debian.org@packages.debian.org
Usertags: unblock

Please unblock package django-anymail

This is a pre-upload request.  django-anymail 0.8-1 is currently in
in experimental.  If this is approved, I'll upload to unstable with no changes
other than the new debian/changelog entry.

This is outside the normal scope of things that would be approved, but given
the nature of the package, I believe letting the new version into stretch is
a resonable thing to do.  django-anymail is a contrib package that exists to
make it easier to integrate with various proprietary email sending services
(the code itself is Free).

The new version adds support for the new Sendgrid v3 API, but maintains v2
support through a new v2 specific backend.  I am including a separate diff of
the 0.7 v2 backend (which was just called sendgrid) and the 0.8 v2 backend
(now called sendgrid_v2) to make it easier to see that the existing code is
not much changed.

This version includes some changes from 0.7 that are incompatible.  This is a
new package and so I think we are better off updating to provide the newer
code in this release and avoid upgrade problems in Buster or if newer
versions are backported to stretch-backports.

Thanks for considering,

Scott K

unblock django-anymail/0.8-2
diff -Nru django-anymail-0.7/anymail/backends/base.py django-anymail-0.8/anymail/backends/base.py
--- django-anymail-0.7/anymail/backends/base.py	2016-12-30 15:48:52.000000000 -0500
+++ django-anymail-0.8/anymail/backends/base.py	2017-01-22 14:08:25.000000000 -0500
@@ -183,16 +183,20 @@
             # Error if *all* recipients are invalid or refused
             # (This behavior parallels smtplib.SMTPRecipientsRefused from Django's SMTP EmailBackend)
             if anymail_status.status.issubset({"invalid", "rejected"}):
-                raise AnymailRecipientsRefused(email_message=message, payload=payload, response=response)
+                raise AnymailRecipientsRefused(email_message=message, payload=payload, response=response,
+                                               backend=self)
 
     @property
     def esp_name(self):
         """
         Read-only name of the ESP for this backend.
 
-        (E.g., MailgunBackend will return "Mailgun")
+        Concrete backends must override with class attr. E.g.:
+            esp_name = "Postmark"
+            esp_name = "SendGrid"  # (use ESP's preferred capitalization)
         """
-        return self.__class__.__name__.replace("Backend", "")
+        raise NotImplementedError("%s.%s must declare esp_name class attr" %
+                                  (self.__class__.__module__, self.__class__.__name__))
 
 
 class BasePayload(object):
diff -Nru django-anymail-0.7/anymail/backends/base_requests.py django-anymail-0.8/anymail/backends/base_requests.py
--- django-anymail-0.7/anymail/backends/base_requests.py	2016-06-02 17:08:59.000000000 -0400
+++ django-anymail-0.8/anymail/backends/base_requests.py	2017-01-22 14:08:25.000000000 -0500
@@ -85,7 +85,8 @@
         parse_recipient_status)
         """
         if response.status_code != 200:
-            raise AnymailRequestsAPIError(email_message=message, payload=payload, response=response)
+            raise AnymailRequestsAPIError(email_message=message, payload=payload, response=response,
+                                          backend=self)
 
     def deserialize_json_response(self, response, payload, message):
         """Deserialize an ESP API response that's in json.
@@ -96,7 +97,8 @@
             return response.json()
         except ValueError:
             raise AnymailRequestsAPIError("Invalid JSON in %s API response" % self.esp_name,
-                                          email_message=message, payload=payload, response=response)
+                                          email_message=message, payload=payload, response=response,
+                                          backend=self)
 
 
 class RequestsPayload(BasePayload):
diff -Nru django-anymail-0.7/anymail/backends/mailgun.py django-anymail-0.8/anymail/backends/mailgun.py
--- django-anymail-0.7/anymail/backends/mailgun.py	2016-08-03 20:04:58.000000000 -0400
+++ django-anymail-0.8/anymail/backends/mailgun.py	2017-01-22 14:08:25.000000000 -0500
@@ -1,17 +1,20 @@
+import warnings
 from datetime import datetime
 
-from ..exceptions import AnymailRequestsAPIError, AnymailError
+from ..exceptions import AnymailRequestsAPIError, AnymailError, AnymailDeprecationWarning
 from ..message import AnymailRecipientStatus
 from ..utils import get_anymail_setting, rfc2822date
 
 from .base_requests import AnymailRequestsBackend, RequestsPayload
 
 
-class MailgunBackend(AnymailRequestsBackend):
+class EmailBackend(AnymailRequestsBackend):
     """
     Mailgun API Email Backend
     """
 
+    esp_name = "Mailgun"
+
     def __init__(self, **kwargs):
         """Init options from Django settings"""
         esp_name = self.esp_name
@@ -22,7 +25,7 @@
                                       default="https://api.mailgun.net/v3";)
         if not api_url.endswith("/"):
             api_url += "/"
-        super(MailgunBackend, self).__init__(api_url, **kwargs)
+        super(EmailBackend, self).__init__(api_url, **kwargs)
 
     def build_message_payload(self, message, defaults):
         return MailgunPayload(message, defaults, self)
@@ -43,15 +46,26 @@
             mailgun_message = parsed_response["message"]
         except (KeyError, TypeError):
             raise AnymailRequestsAPIError("Invalid Mailgun API response format",
-                                          email_message=message, payload=payload, response=response)
+                                          email_message=message, payload=payload, response=response,
+                                          backend=self)
         if not mailgun_message.startswith("Queued"):
             raise AnymailRequestsAPIError("Unrecognized Mailgun API message '%s'" % mailgun_message,
-                                          email_message=message, payload=payload, response=response)
+                                          email_message=message, payload=payload, response=response,
+                                          backend=self)
         # Simulate a per-recipient status of "queued":
         status = AnymailRecipientStatus(message_id=message_id, status="queued")
         return {recipient.email: status for recipient in payload.all_recipients}
 
 
+# Pre-v0.8 naming (deprecated)
+class MailgunBackend(EmailBackend):
+    def __init__(self, **kwargs):
+        warnings.warn(AnymailDeprecationWarning(
+            "Please update your EMAIL_BACKEND setting to "
+            "'anymail.backends.mailgun.EmailBackend'"))
+        super(MailgunBackend, self).__init__(**kwargs)
+
+
 class MailgunPayload(RequestsPayload):
 
     def __init__(self, message, defaults, backend, *args, **kwargs):
diff -Nru django-anymail-0.7/anymail/backends/mandrill.py django-anymail-0.8/anymail/backends/mandrill.py
--- django-anymail-0.7/anymail/backends/mandrill.py	2016-05-11 14:29:47.000000000 -0400
+++ django-anymail-0.8/anymail/backends/mandrill.py	2017-01-22 14:08:25.000000000 -0500
@@ -1,18 +1,20 @@
 import warnings
 from datetime import datetime
 
-from ..exceptions import AnymailRequestsAPIError, AnymailWarning
+from ..exceptions import AnymailRequestsAPIError, AnymailWarning, AnymailDeprecationWarning
 from ..message import AnymailRecipientStatus, ANYMAIL_STATUSES
 from ..utils import last, combine, get_anymail_setting
 
 from .base_requests import AnymailRequestsBackend, RequestsPayload
 
 
-class MandrillBackend(AnymailRequestsBackend):
+class EmailBackend(AnymailRequestsBackend):
     """
     Mandrill API Email Backend
     """
 
+    esp_name = "Mandrill"
+
     def __init__(self, **kwargs):
         """Init options from Django settings"""
         esp_name = self.esp_name
@@ -21,7 +23,7 @@
                                       default="https://mandrillapp.com/api/1.0";)
         if not api_url.endswith("/"):
             api_url += "/"
-        super(MandrillBackend, self).__init__(api_url, **kwargs)
+        super(EmailBackend, self).__init__(api_url, **kwargs)
 
     def build_message_payload(self, message, defaults):
         return MandrillPayload(message, defaults, self)
@@ -40,10 +42,20 @@
                 recipient_status[email] = AnymailRecipientStatus(message_id=message_id, status=status)
         except (KeyError, TypeError):
             raise AnymailRequestsAPIError("Invalid Mandrill API response format",
-                                          email_message=message, payload=payload, response=response)
+                                          email_message=message, payload=payload, response=response,
+                                          backend=self)
         return recipient_status
 
 
+# Pre-v0.8 naming (deprecated)
+class MandrillBackend(EmailBackend):
+    def __init__(self, **kwargs):
+        warnings.warn(AnymailDeprecationWarning(
+            "Please update your EMAIL_BACKEND setting to "
+            "'anymail.backends.mandrill.EmailBackend'"))
+        super(MandrillBackend, self).__init__(**kwargs)
+
+
 class DjrillDeprecationWarning(AnymailWarning, DeprecationWarning):
     """Warning for features carried over from Djrill that will be removed soon"""
 
diff -Nru django-anymail-0.7/anymail/backends/postmark.py django-anymail-0.8/anymail/backends/postmark.py
--- django-anymail-0.7/anymail/backends/postmark.py	2016-11-01 15:23:27.000000000 -0400
+++ django-anymail-0.8/anymail/backends/postmark.py	2017-01-22 14:08:25.000000000 -0500
@@ -1,19 +1,22 @@
 import re
+import warnings
 
 from requests.structures import CaseInsensitiveDict
 
-from ..exceptions import AnymailRequestsAPIError
+from ..exceptions import AnymailRequestsAPIError, AnymailDeprecationWarning
 from ..message import AnymailRecipientStatus
 from ..utils import get_anymail_setting
 
 from .base_requests import AnymailRequestsBackend, RequestsPayload
 
 
-class PostmarkBackend(AnymailRequestsBackend):
+class EmailBackend(AnymailRequestsBackend):
     """
     Postmark API Email Backend
     """
 
+    esp_name = "Postmark"
+
     def __init__(self, **kwargs):
         """Init options from Django settings"""
         esp_name = self.esp_name
@@ -22,7 +25,7 @@
                                       default="https://api.postmarkapp.com/";)
         if not api_url.endswith("/"):
             api_url += "/"
-        super(PostmarkBackend, self).__init__(api_url, **kwargs)
+        super(EmailBackend, self).__init__(api_url, **kwargs)
 
     def build_message_payload(self, message, defaults):
         return PostmarkPayload(message, defaults, self)
@@ -30,7 +33,7 @@
     def raise_for_status(self, response, payload, message):
         # We need to handle 422 responses in parse_recipient_status
         if response.status_code != 422:
-            super(PostmarkBackend, self).raise_for_status(response, payload, message)
+            super(EmailBackend, self).raise_for_status(response, payload, message)
 
     def parse_recipient_status(self, response, payload, message):
         parsed_response = self.deserialize_json_response(response, payload, message)
@@ -39,7 +42,8 @@
             msg = parsed_response["Message"]
         except (KeyError, TypeError):
             raise AnymailRequestsAPIError("Invalid Postmark API response format",
-                                          email_message=message, payload=payload, response=response)
+                                          email_message=message, payload=payload, response=response,
+                                          backend=self)
 
         message_id = parsed_response.get("MessageID", None)
         rejected_emails = []
@@ -48,7 +52,8 @@
             # Either the From address or at least one recipient was invalid. Email not sent.
             if "'From' address" in msg:
                 # Normal error
-                raise AnymailRequestsAPIError(email_message=message, payload=payload, response=response)
+                raise AnymailRequestsAPIError(email_message=message, payload=payload, response=response,
+                                              backend=self)
             else:
                 # Use AnymailRecipientsRefused logic
                 default_status = 'invalid'
@@ -61,7 +66,8 @@
             default_status = 'sent'
             rejected_emails = self.parse_inactive_recipients(msg)
         else:
-            raise AnymailRequestsAPIError(email_message=message, payload=payload, response=response)
+            raise AnymailRequestsAPIError(email_message=message, payload=payload, response=response,
+                                          backend=self)
 
         return {
             recipient.email: AnymailRecipientStatus(
@@ -89,6 +95,15 @@
             return []
 
 
+# Pre-v0.8 naming (deprecated)
+class PostmarkBackend(EmailBackend):
+    def __init__(self, **kwargs):
+        warnings.warn(AnymailDeprecationWarning(
+            "Please update your EMAIL_BACKEND setting to "
+            "'anymail.backends.postmark.EmailBackend'"))
+        super(PostmarkBackend, self).__init__(**kwargs)
+
+
 class PostmarkPayload(RequestsPayload):
 
     def __init__(self, message, defaults, backend, *args, **kwargs):
diff -Nru django-anymail-0.7/anymail/backends/sendgrid.py django-anymail-0.8/anymail/backends/sendgrid.py
--- django-anymail-0.7/anymail/backends/sendgrid.py	2016-10-13 17:57:38.000000000 -0400
+++ django-anymail-0.8/anymail/backends/sendgrid.py	2017-01-22 14:08:25.000000000 -0500
@@ -1,144 +1,139 @@
+from email.utils import quote as rfc822_quote
 import warnings
 
 from django.core.mail import make_msgid
 from requests.structures import CaseInsensitiveDict
 
-from ..exceptions import AnymailConfigurationError, AnymailRequestsAPIError, AnymailWarning
-from ..message import AnymailRecipientStatus
-from ..utils import get_anymail_setting, timestamp
-
 from .base_requests import AnymailRequestsBackend, RequestsPayload
+from ..exceptions import AnymailConfigurationError, AnymailRequestsAPIError, AnymailWarning, AnymailDeprecationWarning
+from ..message import AnymailRecipientStatus
+from ..utils import get_anymail_setting, timestamp, update_deep
 
 
-class SendGridBackend(AnymailRequestsBackend):
+class EmailBackend(AnymailRequestsBackend):
     """
-    SendGrid API Email Backend
+    SendGrid v3 API Email Backend
     """
 
+    esp_name = "SendGrid"
+
     def __init__(self, **kwargs):
         """Init options from Django settings"""
-        # Auth requires *either* SENDGRID_API_KEY or SENDGRID_USERNAME+SENDGRID_PASSWORD
         esp_name = self.esp_name
-        self.api_key = get_anymail_setting('api_key', esp_name=esp_name, kwargs=kwargs,
-                                           default=None, allow_bare=True)
-        self.username = get_anymail_setting('username', esp_name=esp_name, kwargs=kwargs,
-                                            default=None, allow_bare=True)
-        self.password = get_anymail_setting('password', esp_name=esp_name, kwargs=kwargs,
-                                            default=None, allow_bare=True)
-        if self.api_key is None and (self.username is None or self.password is None):
+
+        # Warn if v2-only username or password settings found
+        username = get_anymail_setting('username', esp_name=esp_name, kwargs=kwargs, default=None, allow_bare=True)
+        password = get_anymail_setting('password', esp_name=esp_name, kwargs=kwargs, default=None, allow_bare=True)
+        if username or password:
             raise AnymailConfigurationError(
-                "You must set either SENDGRID_API_KEY or both SENDGRID_USERNAME and "
-                "SENDGRID_PASSWORD in your Django ANYMAIL settings."
-            )
+                "SendGrid v3 API doesn't support username/password auth; Please change to API key.\n"
+                "(For legacy v2 API, use anymail.backends.sendgrid_v2.EmailBackend.)")
+
+        self.api_key = get_anymail_setting('api_key', esp_name=esp_name, kwargs=kwargs, allow_bare=True)
 
         self.generate_message_id = get_anymail_setting('generate_message_id', esp_name=esp_name,
                                                        kwargs=kwargs, default=True)
         self.merge_field_format = get_anymail_setting('merge_field_format', esp_name=esp_name,
                                                       kwargs=kwargs, default=None)
 
-        # This is SendGrid's Web API v2 (because the Web API v3 doesn't support sending)
+        # Undocumented setting to disable workaround for SendGrid display-name quoting bug (see below).
+        # If/when SendGrid fixes their API, recipient names will end up with extra double quotes
+        # until Anymail is updated to remove the workaround. In the meantime, you can disable it
+        # by adding `"SENDGRID_WORKAROUND_NAME_QUOTE_BUG": False` to your `ANYMAIL` settings.
+        self.workaround_name_quote_bug = get_anymail_setting('workaround_name_quote_bug', esp_name=esp_name,
+                                                             kwargs=kwargs, default=True)
+
+        # This is SendGrid's newer Web API v3
         api_url = get_anymail_setting('api_url', esp_name=esp_name, kwargs=kwargs,
-                                      default="https://api.sendgrid.com/api/";)
+                                      default="https://api.sendgrid.com/v3/";)
         if not api_url.endswith("/"):
             api_url += "/"
-        super(SendGridBackend, self).__init__(api_url, **kwargs)
+        super(EmailBackend, self).__init__(api_url, **kwargs)
 
     def build_message_payload(self, message, defaults):
         return SendGridPayload(message, defaults, self)
 
+    def raise_for_status(self, response, payload, message):
+        if response.status_code < 200 or response.status_code >= 300:
+            raise AnymailRequestsAPIError(email_message=message, payload=payload, response=response,
+                                          backend=self)
+
     def parse_recipient_status(self, response, payload, message):
-        parsed_response = self.deserialize_json_response(response, payload, message)
-        try:
-            sendgrid_message = parsed_response["message"]
-        except (KeyError, TypeError):
-            raise AnymailRequestsAPIError("Invalid SendGrid API response format",
-                                          email_message=message, payload=payload, response=response)
-        if sendgrid_message != "success":
-            errors = parsed_response.get("errors", [])
-            raise AnymailRequestsAPIError("SendGrid send failed: '%s'" % "; ".join(errors),
-                                          email_message=message, payload=payload, response=response)
-        # Simulate a per-recipient status of "queued":
+        # If we get here, the send call was successful.
+        # (SendGrid uses a non-2xx response for any failures, caught in raise_for_status.)
+        # SendGrid v3 doesn't provide any information in the response for a successful send,
+        # so simulate a per-recipient status of "queued":
         status = AnymailRecipientStatus(message_id=payload.message_id, status="queued")
         return {recipient.email: status for recipient in payload.all_recipients}
 
 
+# Pre-v0.8 naming (deprecated)
+class SendGridBackend(EmailBackend):
+    def __init__(self, **kwargs):
+        warnings.warn(AnymailDeprecationWarning(
+            "Please update your EMAIL_BACKEND setting to "
+            "'anymail.backends.sendgrid.EmailBackend'"))
+        super(SendGridBackend, self).__init__(**kwargs)
+
+
 class SendGridPayload(RequestsPayload):
 
     def __init__(self, message, defaults, backend, *args, **kwargs):
         self.all_recipients = []  # used for backend.parse_recipient_status
         self.generate_message_id = backend.generate_message_id
+        self.workaround_name_quote_bug = backend.workaround_name_quote_bug
         self.message_id = None  # Message-ID -- assigned in serialize_data unless provided in headers
-        self.smtpapi = {}  # SendGrid x-smtpapi field
-        self.to_list = []  # needed for build_merge_data
         self.merge_field_format = backend.merge_field_format
         self.merge_data = None  # late-bound per-recipient data
         self.merge_global_data = None
 
         http_headers = kwargs.pop('headers', {})
-        query_params = kwargs.pop('params', {})
-        if backend.api_key is not None:
-            http_headers['Authorization'] = 'Bearer %s' % backend.api_key
-        else:
-            query_params['api_user'] = backend.username
-            query_params['api_key'] = backend.password
+        http_headers['Authorization'] = 'Bearer %s' % backend.api_key
+        http_headers['Content-Type'] = 'application/json'
+        http_headers['Accept'] = 'application/json'
         super(SendGridPayload, self).__init__(message, defaults, backend,
-                                              params=query_params, headers=http_headers,
+                                              headers=http_headers,
                                               *args, **kwargs)
 
     def get_api_endpoint(self):
-        return "mail.send.json"
+        return "mail/send"
+
+    def init_payload(self):
+        self.data = {  # becomes json
+            "personalizations": [{}],
+            "headers": CaseInsensitiveDict(),
+        }
 
     def serialize_data(self):
         """Performs any necessary serialization on self.data, and returns the result."""
 
         if self.generate_message_id:
             self.ensure_message_id()
-
         self.build_merge_data()
-        if self.merge_data is not None:
-            # Move the 'to' recipients to smtpapi, so SG does batch send
-            # (else all recipients would see each other's emails).
-            # Regular 'to' must still be a valid email (even though "ignored")...
-            # we use the from_email as recommended by SG support
-            # (See https://github.com/anymail/django-anymail/pull/14#issuecomment-220231250)
-            self.smtpapi['to'] = [email.address for email in self.to_list]
-            self.data['to'] = [self.data['from']]
-            self.data['toname'] = [self.data.get('fromname', " ")]
-
-        # Serialize x-smtpapi to json:
-        if len(self.smtpapi) > 0:
-            # If esp_extra was also used to set x-smtpapi, need to merge it
-            if "x-smtpapi" in self.data:
-                esp_extra_smtpapi = self.data["x-smtpapi"]
-                for key, value in esp_extra_smtpapi.items():
-                    if key == "filters":
-                        # merge filters (else it's difficult to mix esp_extra with other features)
-                        self.smtpapi.setdefault(key, {}).update(value)
-                    else:
-                        # all other keys replace any current value
-                        self.smtpapi[key] = value
-            self.data["x-smtpapi"] = self.serialize_json(self.smtpapi)
-        elif "x-smtpapi" in self.data:
-            self.data["x-smtpapi"] = self.serialize_json(self.data["x-smtpapi"])
 
-        # Serialize extra headers to json:
         headers = self.data["headers"]
-        self.data["headers"] = self.serialize_json(dict(headers.items()))
+        if "Reply-To" in headers:
+            # Reply-To must be in its own param
+            reply_to = headers.pop('Reply-To')
+            self.set_reply_to([self.parsed_email(reply_to)])
+        if len(headers) > 0:
+            self.data["headers"] = dict(headers)  # flatten to normal dict for json serialization
+        else:
+            del self.data["headers"]  # don't send empty headers
 
-        return self.data
+        return self.serialize_json(self.data)
 
     def ensure_message_id(self):
         """Ensure message has a known Message-ID for later event tracking"""
-        headers = self.data["headers"]
-        if "Message-ID" not in headers:
+        if "Message-ID" not in self.data["headers"]:
             # Only make our own if caller hasn't already provided one
-            headers["Message-ID"] = self.make_message_id()
-        self.message_id = headers["Message-ID"]
+            self.data["headers"]["Message-ID"] = self.make_message_id()
+        self.message_id = self.data["headers"]["Message-ID"]
 
         # Workaround for missing message ID (smtp-id) in SendGrid engagement events
         # (click and open tracking): because unique_args get merged into the raw event
         # record, we can supply the 'smtp-id' field for any events missing it.
-        self.smtpapi.setdefault('unique_args', {})['smtp-id'] = self.message_id
+        self.data.setdefault("custom_args", {})["smtp-id"] = self.message_id
 
     def make_message_id(self):
         """Returns a Message-ID that could be used for this payload
@@ -146,20 +141,33 @@
         Tries to use the from_email's domain as the Message-ID's domain
         """
         try:
-            _, domain = self.data["from"].split("@")
+            _, domain = self.data["from"]["email"].split("@")
         except (AttributeError, KeyError, TypeError, ValueError):
             domain = None
         return make_msgid(domain=domain)
 
     def build_merge_data(self):
-        """Set smtpapi['sub'] and ['section']"""
+        """Set personalizations[...]['substitutions'] and data['sections']"""
+        merge_field_format = self.merge_field_format or '{}'
+
         if self.merge_data is not None:
-            # Convert from {to1: {a: A1, b: B1}, to2: {a: A2}}  (merge_data format)
-            # to {a: [A1, A2], b: [B1, ""]}  ({field: [data in to-list order], ...})
+            # Burst apart each to-email in personalizations[0] into a separate
+            # personalization, and add merge_data for that recipient
+            assert len(self.data["personalizations"]) == 1
+            base_personalizations = self.data["personalizations"].pop()
+            to_list = base_personalizations.pop("to")  # {email, name?} for each message.to
             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]
+            for recipient in to_list:
+                personalization = base_personalizations.copy()  # captures cc, bcc, and any esp_extra
+                personalization["to"] = [recipient]
+                try:
+                    recipient_data = self.merge_data[recipient["email"]]
+                    personalization["substitutions"] = {merge_field_format.format(field): data
+                                                        for field, data in recipient_data.items()}
+                    all_fields = all_fields.union(recipient_data.keys())
+                except KeyError:
+                    pass  # no merge_data for this recipient
+                self.data["personalizations"].append(personalization)
 
             if self.merge_field_format is None and all(field.isalnum() for field in all_fields):
                 warnings.warn(
@@ -168,143 +176,172 @@
                     "Search SENDGRID_MERGE_FIELD_FORMAT in the Anymail docs for more info.",
                     AnymailWarning)
 
-            sub_field_fmt = self.merge_field_format or '{}'
-            sub_fields = {field: sub_field_fmt.format(field) for field in all_fields}
-
-            self.smtpapi['sub'] = {
-                # If field data is missing for recipient, use (formatted) field as the substitution.
-                # (This allows default to resolve from global "section" substitutions.)
-                sub_fields[field]: [self.merge_data.get(recipient, {}).get(field, sub_fields[field])
-                                    for recipient in recipients]
-                for field in all_fields
-            }
-
         if self.merge_global_data is not None:
-            section_field_fmt = self.merge_field_format or '{}'
-            self.smtpapi['section'] = {
-                section_field_fmt.format(field): data
+            # (merge into any existing 'sections' from esp_extra)
+            self.data.setdefault("sections", {}).update({
+                merge_field_format.format(field): data
                 for field, data in self.merge_global_data.items()
-            }
+            })
+
+            # Confusingly, "Section tags have to be contained within a Substitution tag"
+            # (https://sendgrid.com/docs/API_Reference/SMTP_API/section_tags.html),
+            # so we need to insert a "-field-": "-field-" identity fallback for each
+            # missing global field in the recipient substitutions...
+            global_fields = [merge_field_format.format(field)
+                             for field in self.merge_global_data.keys()]
+            for personalization in self.data["personalizations"]:
+                substitutions = personalization.setdefault("substitutions", {})
+                substitutions.update({field: field for field in global_fields
+                                      if field not in substitutions})
+
+            if (self.merge_field_format is None and
+                    all(field.isalnum() for field in self.merge_global_data.keys())):
+                warnings.warn(
+                    "Your SendGrid global merge fields don't seem to have delimiters, "
+                    "which can cause unexpected results with Anymail's merge_data. "
+                    "Search SENDGRID_MERGE_FIELD_FORMAT in the Anymail docs for more info.",
+                    AnymailWarning)
 
     #
     # Payload construction
     #
 
-    def init_payload(self):
-        self.data = {}  # {field: [multiple, values]}
-        self.files = {}
-        self.data['headers'] = CaseInsensitiveDict()  # headers keys are case-insensitive
-
-    def set_from_email(self, email):
-        self.data["from"] = email.email
+    @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:
-            self.data["fromname"] = email.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)
+            else:
+                obj["name"] = email.name
+        return obj
 
-    def set_to(self, emails):
-        self.to_list = emails  # track for later use by build_merge_data
-        self.set_recipients('to', emails)
+    def set_from_email(self, email):
+        self.data["from"] = self.email_object(email)
 
     def set_recipients(self, recipient_type, emails):
         assert recipient_type in ["to", "cc", "bcc"]
         if emails:
-            self.data[recipient_type] = [email.email 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]
+            workaround_name_quote_bug = self.workaround_name_quote_bug
+            # Normally, exactly one "personalizations" entry for all recipients
+            # (Exception: with merge_data; will be burst apart later.)
+            self.data["personalizations"][0][recipient_type] = \
+                [self.email_object(email, workaround_name_quote_bug) for email in emails]
             self.all_recipients += emails  # used for backend.parse_recipient_status
 
     def set_subject(self, subject):
-        self.data["subject"] = subject
+        if subject != "":  # see note in set_text_body about template rendering
+            self.data["subject"] = subject
 
     def set_reply_to(self, emails):
-        # Note: SendGrid mangles the 'replyto' API param: it drops
-        # all but the last email in a multi-address replyto, and
-        # drops all the display names. [tested 2016-03-10]
-        #
-        # To avoid those quirks, we provide a fully-formed Reply-To
-        # in the custom headers, which makes it through intact.
-        if emails:
-            reply_to = ", ".join([email.address for email in emails])
-            self.data["headers"]["Reply-To"] = reply_to
+        # SendGrid only supports a single address in the reply_to API param.
+        if len(emails) > 1:
+            self.unsupported_feature("multiple reply_to addresses")
+        if len(emails) > 0:
+            self.data["reply_to"] = self.email_object(emails[0])
 
     def set_extra_headers(self, headers):
         # SendGrid requires header values to be strings -- not integers.
         # We'll stringify ints and floats; anything else is the caller's responsibility.
-        # (This field gets converted to json in self.serialize_data)
         self.data["headers"].update({
             k: str(v) if isinstance(v, (int, float)) else v
             for k, v in headers.items()
         })
 
     def set_text_body(self, body):
-        self.data["text"] = body
+        # Empty strings (the EmailMessage default) can cause unexpected SendGrid
+        # template rendering behavior, such as ignoring the HTML template and
+        # rendering HTML from the plaintext template instead.
+        # Treat an empty string as a request to omit the body
+        # (which means use the template content if present.)
+        if body != "":
+            self.data.setdefault("content", []).append({
+                "type": "text/plain",
+                "value": body,
+            })
 
     def set_html_body(self, body):
-        if "html" in self.data:
-            # second html body could show up through multiple alternatives, or html body + alternative
-            self.unsupported_feature("multiple html parts")
-        self.data["html"] = body
+        # SendGrid's API permits multiple html bodies
+        # "If you choose to include the text/plain or text/html mime types, they must be
+        # the first indices of the content array in the order text/plain, text/html."
+        if body != "":  # see note in set_text_body about template rendering
+            self.data.setdefault("content", []).append({
+                "type": "text/html",
+                "value": body,
+            })
+
+    def add_alternative(self, content, mimetype):
+        # SendGrid is one of the few ESPs that supports arbitrary alternative parts in their API
+        self.data.setdefault("content", []).append({
+            "type": mimetype,
+            "value": content,
+        })
 
     def add_attachment(self, attachment):
-        filename = attachment.name or ""
+        att = {
+            "content": attachment.b64content,
+            "type": attachment.mimetype,
+            "filename": attachment.name or '',  # required -- submit empty string if unknown
+        }
         if attachment.inline:
-            filename = filename or attachment.cid  # must have non-empty name for the cid matching
-            content_field = "content[%s]" % filename
-            self.data[content_field] = attachment.cid
-
-        files_field = "files[%s]" % filename
-        if files_field in self.files:
-            # It's possible SendGrid could actually handle this case (needs testing),
-            # but requests doesn't seem to accept a list of tuples for a files field.
-            # (See the MailgunBackend version for a different approach that might work.)
-            self.unsupported_feature(
-                "multiple attachments with the same filename ('%s')" % filename if filename
-                else "multiple unnamed attachments")
-
-        self.files[files_field] = (filename, attachment.content, attachment.mimetype)
+            att["disposition"] = "inline"
+            att["content_id"] = attachment.cid
+        self.data.setdefault("attachments", []).append(att)
 
     def set_metadata(self, metadata):
-        self.smtpapi['unique_args'] = metadata
+        # SendGrid requires custom_args values to be strings -- not integers.
+        # (And issues the cryptic error {"field": null, "message": "Bad Request", "help": null}
+        # if they're not.)
+        # We'll stringify ints and floats; anything else is the caller's responsibility.
+        self.data["custom_args"] = {
+            k: str(v) if isinstance(v, (int, float)) else v
+            for k, v in metadata.items()
+        }
 
     def set_send_at(self, send_at):
         # Backend has converted pretty much everything to
         # a datetime by here; SendGrid expects unix timestamp
-        self.smtpapi["send_at"] = int(timestamp(send_at))  # strip microseconds
+        self.data["send_at"] = int(timestamp(send_at))  # strip microseconds
 
     def set_tags(self, tags):
-        self.smtpapi["category"] = tags
-
-    def add_filter(self, filter_name, setting, val):
-        self.smtpapi.setdefault('filters', {})\
-            .setdefault(filter_name, {})\
-            .setdefault('settings', {})[setting] = val
+        self.data["categories"] = tags
 
     def set_track_clicks(self, track_clicks):
-        self.add_filter('clicktrack', 'enable', int(track_clicks))
+        self.data.setdefault("tracking_settings", {})["click_tracking"] = {
+            "enable": track_clicks,
+        }
 
     def set_track_opens(self, track_opens):
-        # SendGrid's opentrack filter also supports a "replace"
-        # parameter, which Anymail doesn't offer directly.
-        # (You could add it through esp_extra.)
-        self.add_filter('opentrack', 'enable', int(track_opens))
+        # SendGrid's open_tracking setting also supports a "substitution_tag" parameter,
+        # which Anymail doesn't offer directly. (You could add it through esp_extra.)
+        self.data.setdefault("tracking_settings", {})["open_tracking"] = {
+            "enable": track_opens,
+        }
 
     def set_template_id(self, template_id):
-        self.add_filter('templates', 'enable', 1)
-        self.add_filter('templates', 'template_id', template_id)
-        # Must ensure text and html are non-empty, or template parts won't render.
-        # https://sendgrid.com/docs/API_Reference/Web_API_v3/Transactional_Templates/smtpapi.html#-Text-or-HTML-Templates
-        if not self.data.get("text", ""):
-            self.data["text"] = " "
-        if not self.data.get("html", ""):
-            self.data["html"] = " "
+        self.data["template_id"] = template_id
 
     def set_merge_data(self, merge_data):
-        # Becomes smtpapi['sub'] in build_merge_data, after we know recipients and merge_field_format.
+        # Becomes personalizations[...]['substitutions'] in build_merge_data,
+        # after we know recipients and merge_field_format.
         self.merge_data = merge_data
 
     def set_merge_global_data(self, merge_global_data):
-        # Becomes smtpapi['section'] in build_merge_data, after we know merge_field_format.
+        # Becomes data['section'] in build_merge_data, after we know merge_field_format.
         self.merge_global_data = merge_global_data
 
     def set_esp_extra(self, extra):
-        self.merge_field_format = extra.pop('merge_field_format', self.merge_field_format)
-        self.data.update(extra)
+        self.merge_field_format = extra.pop("merge_field_format", self.merge_field_format)
+        if "x-smtpapi" in extra:
+            raise AnymailConfigurationError(
+                "You are attempting to use SendGrid v2 API-style x-smtpapi params "
+                "with the SendGrid v3 API. Please update your `esp_extra` to the new API, "
+                "or use 'anymail.backends.sendgrid_v2.EmailBackend' for the old API."
+            )
+        update_deep(self.data, extra)
+
diff -Nru django-anymail-0.7/anymail/backends/sendgrid_v2.py django-anymail-0.8/anymail/backends/sendgrid_v2.py
--- django-anymail-0.7/anymail/backends/sendgrid_v2.py	1969-12-31 19:00:00.000000000 -0500
+++ django-anymail-0.8/anymail/backends/sendgrid_v2.py	2017-01-22 14:08:25.000000000 -0500
@@ -0,0 +1,317 @@
+import warnings
+
+from django.core.mail import make_msgid
+from requests.structures import CaseInsensitiveDict
+
+from ..exceptions import AnymailConfigurationError, AnymailRequestsAPIError, AnymailWarning
+from ..message import AnymailRecipientStatus
+from ..utils import get_anymail_setting, timestamp
+
+from .base_requests import AnymailRequestsBackend, RequestsPayload
+
+
+class EmailBackend(AnymailRequestsBackend):
+    """
+    SendGrid v2 API Email Backend (deprecated)
+    """
+
+    esp_name = "SendGrid"
+
+    def __init__(self, **kwargs):
+        """Init options from Django settings"""
+        # Auth requires *either* SENDGRID_API_KEY or SENDGRID_USERNAME+SENDGRID_PASSWORD
+        esp_name = self.esp_name
+        self.api_key = get_anymail_setting('api_key', esp_name=esp_name, kwargs=kwargs,
+                                           default=None, allow_bare=True)
+        self.username = get_anymail_setting('username', esp_name=esp_name, kwargs=kwargs,
+                                            default=None, allow_bare=True)
+        self.password = get_anymail_setting('password', esp_name=esp_name, kwargs=kwargs,
+                                            default=None, allow_bare=True)
+        if self.api_key is None and (self.username is None or self.password is None):
+            raise AnymailConfigurationError(
+                "You must set either SENDGRID_API_KEY or both SENDGRID_USERNAME and "
+                "SENDGRID_PASSWORD in your Django ANYMAIL settings."
+            )
+
+        self.generate_message_id = get_anymail_setting('generate_message_id', esp_name=esp_name,
+                                                       kwargs=kwargs, default=True)
+        self.merge_field_format = get_anymail_setting('merge_field_format', esp_name=esp_name,
+                                                      kwargs=kwargs, default=None)
+
+        # This is SendGrid's older Web API v2
+        api_url = get_anymail_setting('api_url', esp_name=esp_name, kwargs=kwargs,
+                                      default="https://api.sendgrid.com/api/";)
+        if not api_url.endswith("/"):
+            api_url += "/"
+        super(EmailBackend, self).__init__(api_url, **kwargs)
+
+    def build_message_payload(self, message, defaults):
+        return SendGridPayload(message, defaults, self)
+
+    def parse_recipient_status(self, response, payload, message):
+        parsed_response = self.deserialize_json_response(response, payload, message)
+        try:
+            sendgrid_message = parsed_response["message"]
+        except (KeyError, TypeError):
+            raise AnymailRequestsAPIError("Invalid SendGrid API response format",
+                                          email_message=message, payload=payload, response=response,
+                                          backend=self)
+        if sendgrid_message != "success":
+            errors = parsed_response.get("errors", [])
+            raise AnymailRequestsAPIError("SendGrid send failed: '%s'" % "; ".join(errors),
+                                          email_message=message, payload=payload, response=response,
+                                          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}
+
+
+class SendGridPayload(RequestsPayload):
+    """
+    SendGrid v2 API Mail Send payload
+    """
+
+    def __init__(self, message, defaults, backend, *args, **kwargs):
+        self.all_recipients = []  # used for backend.parse_recipient_status
+        self.generate_message_id = backend.generate_message_id
+        self.message_id = None  # Message-ID -- assigned in serialize_data unless provided in headers
+        self.smtpapi = {}  # SendGrid x-smtpapi field
+        self.to_list = []  # needed for build_merge_data
+        self.merge_field_format = backend.merge_field_format
+        self.merge_data = None  # late-bound per-recipient data
+        self.merge_global_data = None
+
+        http_headers = kwargs.pop('headers', {})
+        query_params = kwargs.pop('params', {})
+        if backend.api_key is not None:
+            http_headers['Authorization'] = 'Bearer %s' % backend.api_key
+        else:
+            query_params['api_user'] = backend.username
+            query_params['api_key'] = backend.password
+        super(SendGridPayload, self).__init__(message, defaults, backend,
+                                              params=query_params, headers=http_headers,
+                                              *args, **kwargs)
+
+    def get_api_endpoint(self):
+        return "mail.send.json"
+
+    def serialize_data(self):
+        """Performs any necessary serialization on self.data, and returns the result."""
+
+        if self.generate_message_id:
+            self.ensure_message_id()
+
+        self.build_merge_data()
+        if self.merge_data is not None:
+            # Move the 'to' recipients to smtpapi, so SG does batch send
+            # (else all recipients would see each other's emails).
+            # Regular 'to' must still be a valid email (even though "ignored")...
+            # we use the from_email as recommended by SG support
+            # (See https://github.com/anymail/django-anymail/pull/14#issuecomment-220231250)
+            self.smtpapi['to'] = [email.address for email in self.to_list]
+            self.data['to'] = [self.data['from']]
+            self.data['toname'] = [self.data.get('fromname', " ")]
+
+        # Serialize x-smtpapi to json:
+        if len(self.smtpapi) > 0:
+            # If esp_extra was also used to set x-smtpapi, need to merge it
+            if "x-smtpapi" in self.data:
+                esp_extra_smtpapi = self.data["x-smtpapi"]
+                for key, value in esp_extra_smtpapi.items():
+                    if key == "filters":
+                        # merge filters (else it's difficult to mix esp_extra with other features)
+                        self.smtpapi.setdefault(key, {}).update(value)
+                    else:
+                        # all other keys replace any current value
+                        self.smtpapi[key] = value
+            self.data["x-smtpapi"] = self.serialize_json(self.smtpapi)
+        elif "x-smtpapi" in self.data:
+            self.data["x-smtpapi"] = self.serialize_json(self.data["x-smtpapi"])
+
+        # Serialize extra headers to json:
+        headers = self.data["headers"]
+        self.data["headers"] = self.serialize_json(dict(headers.items()))
+
+        return self.data
+
+    def ensure_message_id(self):
+        """Ensure message has a known Message-ID for later event tracking"""
+        headers = self.data["headers"]
+        if "Message-ID" not in headers:
+            # Only make our own if caller hasn't already provided one
+            headers["Message-ID"] = self.make_message_id()
+        self.message_id = headers["Message-ID"]
+
+        # Workaround for missing message ID (smtp-id) in SendGrid engagement events
+        # (click and open tracking): because unique_args get merged into the raw event
+        # record, we can supply the 'smtp-id' field for any events missing it.
+        self.smtpapi.setdefault('unique_args', {})['smtp-id'] = self.message_id
+
+    def make_message_id(self):
+        """Returns a Message-ID that could be used for this payload
+
+        Tries to use the from_email's domain as the Message-ID's domain
+        """
+        try:
+            _, domain = self.data["from"].split("@")
+        except (AttributeError, KeyError, TypeError, ValueError):
+            domain = None
+        return make_msgid(domain=domain)
+
+    def build_merge_data(self):
+        """Set smtpapi['sub'] and ['section']"""
+        if self.merge_data is not None:
+            # Convert from {to1: {a: A1, b: B1}, to2: {a: A2}}  (merge_data format)
+            # to {a: [A1, A2], b: [B1, ""]}  ({field: [data in to-list order], ...})
+            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]
+
+            if self.merge_field_format is None and all(field.isalnum() for field in all_fields):
+                warnings.warn(
+                    "Your SendGrid merge fields don't seem to have delimiters, "
+                    "which can cause unexpected results with Anymail's merge_data. "
+                    "Search SENDGRID_MERGE_FIELD_FORMAT in the Anymail docs for more info.",
+                    AnymailWarning)
+
+            sub_field_fmt = self.merge_field_format or '{}'
+            sub_fields = {field: sub_field_fmt.format(field) for field in all_fields}
+
+            self.smtpapi['sub'] = {
+                # If field data is missing for recipient, use (formatted) field as the substitution.
+                # (This allows default to resolve from global "section" substitutions.)
+                sub_fields[field]: [self.merge_data.get(recipient, {}).get(field, sub_fields[field])
+                                    for recipient in recipients]
+                for field in all_fields
+            }
+
+        if self.merge_global_data is not None:
+            section_field_fmt = self.merge_field_format or '{}'
+            self.smtpapi['section'] = {
+                section_field_fmt.format(field): data
+                for field, data in self.merge_global_data.items()
+            }
+
+    #
+    # Payload construction
+    #
+
+    def init_payload(self):
+        self.data = {}  # {field: [multiple, values]}
+        self.files = {}
+        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
+
+    def set_to(self, emails):
+        self.to_list = emails  # track for later use by build_merge_data
+        self.set_recipients('to', emails)
+
+    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]
+            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.all_recipients += emails  # used for backend.parse_recipient_status
+
+    def set_subject(self, subject):
+        self.data["subject"] = subject
+
+    def set_reply_to(self, emails):
+        # Note: SendGrid mangles the 'replyto' API param: it drops
+        # all but the last email in a multi-address replyto, and
+        # drops all the display names. [tested 2016-03-10]
+        #
+        # To avoid those quirks, we provide a fully-formed Reply-To
+        # in the custom headers, which makes it through intact.
+        if emails:
+            reply_to = ", ".join([email.address for email in emails])
+            self.data["headers"]["Reply-To"] = reply_to
+
+    def set_extra_headers(self, headers):
+        # SendGrid requires header values to be strings -- not integers.
+        # We'll stringify ints and floats; anything else is the caller's responsibility.
+        # (This field gets converted to json in self.serialize_data)
+        self.data["headers"].update({
+            k: str(v) if isinstance(v, (int, float)) else v
+            for k, v in headers.items()
+        })
+
+    def set_text_body(self, body):
+        self.data["text"] = body
+
+    def set_html_body(self, body):
+        if "html" in self.data:
+            # second html body could show up through multiple alternatives, or html body + alternative
+            self.unsupported_feature("multiple html parts")
+        self.data["html"] = body
+
+    def add_attachment(self, attachment):
+        filename = attachment.name or ""
+        if attachment.inline:
+            filename = filename or attachment.cid  # must have non-empty name for the cid matching
+            content_field = "content[%s]" % filename
+            self.data[content_field] = attachment.cid
+
+        files_field = "files[%s]" % filename
+        if files_field in self.files:
+            # It's possible SendGrid could actually handle this case (needs testing),
+            # but requests doesn't seem to accept a list of tuples for a files field.
+            # (See the Mailgun EmailBackend version for a different approach that might work.)
+            self.unsupported_feature(
+                "multiple attachments with the same filename ('%s')" % filename if filename
+                else "multiple unnamed attachments")
+
+        self.files[files_field] = (filename, attachment.content, attachment.mimetype)
+
+    def set_metadata(self, metadata):
+        self.smtpapi['unique_args'] = metadata
+
+    def set_send_at(self, send_at):
+        # Backend has converted pretty much everything to
+        # a datetime by here; SendGrid expects unix timestamp
+        self.smtpapi["send_at"] = int(timestamp(send_at))  # strip microseconds
+
+    def set_tags(self, tags):
+        self.smtpapi["category"] = tags
+
+    def add_filter(self, filter_name, setting, val):
+        self.smtpapi.setdefault('filters', {})\
+            .setdefault(filter_name, {})\
+            .setdefault('settings', {})[setting] = val
+
+    def set_track_clicks(self, track_clicks):
+        self.add_filter('clicktrack', 'enable', int(track_clicks))
+
+    def set_track_opens(self, track_opens):
+        # SendGrid's opentrack filter also supports a "replace"
+        # parameter, which Anymail doesn't offer directly.
+        # (You could add it through esp_extra.)
+        self.add_filter('opentrack', 'enable', int(track_opens))
+
+    def set_template_id(self, template_id):
+        self.add_filter('templates', 'enable', 1)
+        self.add_filter('templates', 'template_id', template_id)
+        # Must ensure text and html are non-empty, or template parts won't render.
+        # https://sendgrid.com/docs/API_Reference/Web_API_v3/Transactional_Templates/smtpapi.html#-Text-or-HTML-Templates
+        if not self.data.get("text", ""):
+            self.data["text"] = " "
+        if not self.data.get("html", ""):
+            self.data["html"] = " "
+
+    def set_merge_data(self, merge_data):
+        # Becomes smtpapi['sub'] in build_merge_data, after we know recipients and merge_field_format.
+        self.merge_data = merge_data
+
+    def set_merge_global_data(self, merge_global_data):
+        # Becomes smtpapi['section'] in build_merge_data, after we know merge_field_format.
+        self.merge_global_data = merge_global_data
+
+    def set_esp_extra(self, extra):
+        self.merge_field_format = extra.pop('merge_field_format', self.merge_field_format)
+        self.data.update(extra)
diff -Nru django-anymail-0.7/anymail/backends/sparkpost.py django-anymail-0.8/anymail/backends/sparkpost.py
--- django-anymail-0.7/anymail/backends/sparkpost.py	2016-08-03 20:04:58.000000000 -0400
+++ django-anymail-0.8/anymail/backends/sparkpost.py	2017-01-22 14:08:25.000000000 -0500
@@ -1,7 +1,10 @@
 from __future__ import absolute_import  # we want the sparkpost package, not our own module
 
+import warnings
+
 from .base import AnymailBaseBackend, BasePayload
-from ..exceptions import AnymailAPIError, AnymailImproperlyInstalled, AnymailConfigurationError
+from ..exceptions import (AnymailAPIError, AnymailImproperlyInstalled,
+                          AnymailConfigurationError, AnymailDeprecationWarning)
 from ..message import AnymailRecipientStatus
 from ..utils import get_anymail_setting
 
@@ -11,14 +14,16 @@
     raise AnymailImproperlyInstalled(missing_package='sparkpost', backend='sparkpost')
 
 
-class SparkPostBackend(AnymailBaseBackend):
+class EmailBackend(AnymailBaseBackend):
     """
     SparkPost Email Backend (using python-sparkpost client)
     """
 
+    esp_name = "SparkPost"
+
     def __init__(self, **kwargs):
         """Init options from Django settings"""
-        super(SparkPostBackend, self).__init__(**kwargs)
+        super(EmailBackend, self).__init__(**kwargs)
         # SPARKPOST_API_KEY is optional - library reads from env by default
         self.api_key = get_anymail_setting('api_key', esp_name=self.esp_name,
                                            kwargs=kwargs, allow_bare=True, default=None)
@@ -77,6 +82,15 @@
         return {recipient.email: recipient_status for recipient in payload.all_recipients}
 
 
+# Pre-v0.8 naming (deprecated)
+class SparkPostBackend(EmailBackend):
+    def __init__(self, **kwargs):
+        warnings.warn(AnymailDeprecationWarning(
+            "Please update your EMAIL_BACKEND setting to "
+            "'anymail.backends.sparkpost.EmailBackend'"))
+        super(SparkPostBackend, self).__init__(**kwargs)
+
+
 class SparkPostPayload(BasePayload):
     def init_payload(self):
         self.params = {}
diff -Nru django-anymail-0.7/anymail/backends/test.py django-anymail-0.8/anymail/backends/test.py
--- django-anymail-0.7/anymail/backends/test.py	2016-12-28 15:05:52.000000000 -0500
+++ django-anymail-0.8/anymail/backends/test.py	2017-01-22 14:08:25.000000000 -0500
@@ -5,13 +5,15 @@
 from ..utils import get_anymail_setting
 
 
-class TestBackend(AnymailBaseBackend):
+class EmailBackend(AnymailBaseBackend):
     """
     Anymail backend that doesn't do anything.
 
     Used for testing Anymail common backend functionality.
     """
 
+    esp_name = "Test"
+
     def __init__(self, *args, **kwargs):
         # Init options from Django settings
         esp_name = self.esp_name
@@ -19,7 +21,7 @@
                                                   kwargs=kwargs, allow_bare=True)
         self.recorded_send_params = get_anymail_setting('recorded_send_params', default=[],
                                                         esp_name=esp_name, kwargs=kwargs)
-        super(TestBackend, self).__init__(*args, **kwargs)
+        super(EmailBackend, self).__init__(*args, **kwargs)
 
     def build_message_payload(self, message, defaults):
         return TestPayload(backend=self, message=message, defaults=defaults)
@@ -47,6 +49,14 @@
             raise AnymailAPIError('Unparsable test response')
 
 
+# Pre-v0.8 naming (immediately deprecated for this undocumented test feature)
+class TestBackend(object):
+    def __init__(self, **kwargs):
+        raise NotImplementedError(
+            "Anymail's (undocumented) TestBackend has been renamed to "
+            "'anymail.backends.test.EmailBackend'")
+
+
 class TestPayload(BasePayload):
     # For test purposes, just keep a dict of the params we've received.
     # (This approach is also useful for native API backends -- think of
diff -Nru django-anymail-0.7/anymail/exceptions.py django-anymail-0.8/anymail/exceptions.py
--- django-anymail-0.7/anymail/exceptions.py	2016-12-13 17:58:59.000000000 -0500
+++ django-anymail-0.8/anymail/exceptions.py	2017-01-22 14:08:25.000000000 -0500
@@ -17,15 +17,19 @@
         Optional kwargs:
           email_message: the original EmailMessage being sent
           status_code: HTTP status code of response to ESP send call
+          backend: the backend instance involved
           payload: data arg (*not* json-stringified) for the ESP send call
           response: requests.Response from the send call
           raised_from: original/wrapped Exception
+          esp_name: what to call the ESP (read from backend if provided)
         """
         self.backend = kwargs.pop('backend', None)
         self.email_message = kwargs.pop('email_message', None)
         self.payload = kwargs.pop('payload', None)
         self.status_code = kwargs.pop('status_code', None)
         self.raised_from = kwargs.pop('raised_from', None)
+        self.esp_name = kwargs.pop('esp_name',
+                                   self.backend.esp_name if self.backend else None)
         if isinstance(self, HTTPError):
             # must leave response in kwargs for HTTPError
             self.response = kwargs.get('response', None)
@@ -61,7 +65,7 @@
         """Return a formatted string of self.status_code and response, or None"""
         if self.status_code is None:
             return None
-        description = "ESP API response %d:" % self.status_code
+        description = "%s API response %d:" % (self.esp_name or "ESP", self.status_code)
         try:
             json_response = self.response.json()
             description += "\n" + json.dumps(json_response, indent=2)
@@ -131,7 +135,9 @@
 
     def __init__(self, message=None, orig_err=None, *args, **kwargs):
         if message is None:
-            esp_name = kwargs["backend"].esp_name if "backend" in kwargs else "the ESP"
+            # self.esp_name not set until super init, so duplicate logic to get esp_name
+            backend = kwargs.get('backend', None)
+            esp_name = kwargs.get('esp_name', backend.esp_name if backend else "the ESP")
             message = "Don't know how to send this data to %s. " \
                       "Try converting it to a string or number first." % esp_name
         if orig_err is not None:
@@ -175,3 +181,7 @@
 
 class AnymailInsecureWebhookWarning(AnymailWarning):
     """Warns when webhook configured without any validation"""
+
+
+class AnymailDeprecationWarning(AnymailWarning, DeprecationWarning):
+    """Warning for deprecated Anymail features"""
diff -Nru django-anymail-0.7/anymail/utils.py django-anymail-0.8/anymail/utils.py
--- django-anymail-0.7/anymail/utils.py	2016-12-30 15:48:52.000000000 -0500
+++ django-anymail-0.8/anymail/utils.py	2017-01-19 22:03:55.000000000 -0500
@@ -1,5 +1,7 @@
+import base64
 import mimetypes
 from base64 import b64encode
+from collections import Mapping, MutableMapping
 from datetime import datetime
 from email.mime.base import MIMEBase
 from email.utils import formatdate, getaddresses, unquote
@@ -11,6 +13,8 @@
 from django.utils.encoding import force_text
 from django.utils.functional import Promise
 from django.utils.timezone import utc
+# noinspection PyUnresolvedReferences
+from six.moves.urllib.parse import urlsplit, urlunsplit
 
 from .exceptions import AnymailConfigurationError, AnymailInvalidAddress
 
@@ -95,6 +99,20 @@
         return default
 
 
+def update_deep(dct, other):
+    """Merge (recursively) keys and values from dict other into dict dct
+
+    Works with dict-like objects: dct (and descendants) can be any MutableMapping,
+    and other can be any Mapping
+    """
+    for key, value in other.items():
+        if key in dct and isinstance(dct[key], MutableMapping) and isinstance(value, Mapping):
+            update_deep(dct[key], value)
+        else:
+            dct[key] = value
+    # (like dict.update(), no return value)
+
+
 def parse_one_addr(address):
     # This is email.utils.parseaddr, but without silently returning
     # partial content if there are commas or parens in the string:
@@ -327,3 +345,33 @@
         return {key: force_non_lazy_dict(value) for key, value in obj.items()}
     except (AttributeError, TypeError):
         return force_non_lazy(obj)
+
+
+def get_request_basic_auth(request):
+    """Returns HTTP basic auth string sent with request, or None.
+
+    If request includes basic auth, result is string 'username:password'.
+    """
+    try:
+        authtype, authdata = request.META['HTTP_AUTHORIZATION'].split()
+        if authtype.lower() == "basic":
+            return base64.b64decode(authdata).decode('utf-8')
+    except (IndexError, KeyError, TypeError, ValueError):
+        pass
+    return None
+
+
+def get_request_uri(request):
+    """Returns the "exact" url used to call request.
+
+    Like :func:`django.http.request.HTTPRequest.build_absolute_uri`,
+    but also inlines HTTP basic auth, if present.
+    """
+    url = request.build_absolute_uri()
+    basic_auth = get_request_basic_auth(request)
+    if basic_auth is not None:
+        # must reassemble url with auth
+        parts = urlsplit(url)
+        url = urlunsplit((parts.scheme, basic_auth + '@' + parts.netloc,
+                          parts.path, parts.query, parts.fragment))
+    return url
diff -Nru django-anymail-0.7/anymail/_version.py django-anymail-0.8/anymail/_version.py
--- django-anymail-0.7/anymail/_version.py	2016-12-30 18:08:50.000000000 -0500
+++ django-anymail-0.8/anymail/_version.py	2017-02-01 18:55:25.000000000 -0500
@@ -1,3 +1,3 @@
-VERSION = (0, 7)
+VERSION = (0, 8)
 __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 -Nru django-anymail-0.7/anymail/webhooks/base.py django-anymail-0.8/anymail/webhooks/base.py
--- django-anymail-0.7/anymail/webhooks/base.py	2016-08-22 13:52:28.000000000 -0400
+++ django-anymail-0.8/anymail/webhooks/base.py	2017-01-19 22:03:55.000000000 -0500
@@ -1,15 +1,14 @@
-import base64
 import re
-import six
 import warnings
 
+import six
 from django.http import HttpResponse
 from django.utils.decorators import method_decorator
 from django.views.decorators.csrf import csrf_exempt
 from django.views.generic import View
 
 from ..exceptions import AnymailInsecureWebhookWarning, AnymailWebhookValidationFailure
-from ..utils import get_anymail_setting, collect_all_methods
+from ..utils import get_anymail_setting, collect_all_methods, get_request_basic_auth
 
 
 class AnymailBasicAuthMixin(object):
@@ -42,16 +41,8 @@
     def validate_request(self, request):
         """If configured for webhook basic auth, validate request has correct auth."""
         if self.basic_auth:
-            valid = False
-            try:
-                authtype, authdata = request.META['HTTP_AUTHORIZATION'].split()
-                if authtype.lower() == "basic":
-                    auth = base64.b64decode(authdata).decode('utf-8')
-                    if auth in self.basic_auth:
-                        valid = True
-            except (IndexError, KeyError, TypeError, ValueError):
-                valid = False
-            if not valid:
+            basic_auth = get_request_basic_auth(request)
+            if basic_auth is None or basic_auth not in self.basic_auth:
                 # noinspection PyUnresolvedReferences
                 raise AnymailWebhookValidationFailure(
                     "Missing or invalid basic auth in Anymail %s webhook" % self.esp_name)
diff -Nru django-anymail-0.7/anymail/webhooks/mandrill.py django-anymail-0.8/anymail/webhooks/mandrill.py
--- django-anymail-0.7/anymail/webhooks/mandrill.py	2016-12-16 14:07:23.000000000 -0500
+++ django-anymail-0.8/anymail/webhooks/mandrill.py	2017-01-19 22:03:55.000000000 -0500
@@ -10,7 +10,7 @@
 from .base import AnymailBaseWebhookView
 from ..exceptions import AnymailWebhookValidationFailure, AnymailConfigurationError
 from ..signals import tracking, AnymailTrackingEvent, EventType
-from ..utils import get_anymail_setting, getfirst
+from ..utils import get_anymail_setting, getfirst, get_request_uri
 
 
 class MandrillSignatureMixin(object):
@@ -45,16 +45,18 @@
         except KeyError:
             raise AnymailWebhookValidationFailure("X-Mandrill-Signature header missing from webhook POST")
 
-        # Mandrill signs the exact URL plus the sorted POST params:
-        signed_data = self.webhook_url or request.build_absolute_uri()
+        # Mandrill signs the exact URL (including basic auth, if used) plus the sorted POST params:
+        url = self.webhook_url or get_request_uri(request)
         params = request.POST.dict()
+        signed_data = url
         for key in sorted(params.keys()):
             signed_data += key + params[key]
 
         expected_signature = b64encode(hmac.new(key=self.webhook_key, msg=signed_data.encode('utf-8'),
                                                 digestmod=hashlib.sha1).digest())
         if not constant_time_compare(signature, expected_signature):
-            raise AnymailWebhookValidationFailure("Mandrill webhook called with incorrect signature")
+            raise AnymailWebhookValidationFailure(
+                "Mandrill webhook called with incorrect signature (for url %r)" % url)
 
 
 class MandrillBaseWebhookView(MandrillSignatureMixin, AnymailBaseWebhookView):
diff -Nru django-anymail-0.7/debian/changelog django-anymail-0.8/debian/changelog
--- django-anymail-0.7/debian/changelog	2017-01-02 08:18:44.000000000 -0500
+++ django-anymail-0.8/debian/changelog	2017-04-01 16:38:55.000000000 -0400
@@ -1,3 +1,9 @@
+django-anymail (0.8-1) experimental; urgency=medium
+
+  * New upstream release
+
+ -- Scott Kitterman <scott@kitterman.com>  Sat, 01 Apr 2017 16:37:22 -0400
+
 django-anymail (0.7-1) unstable; urgency=medium
 
   * New upstream release
diff -Nru django-anymail-0.7/debian/.git-dpm django-anymail-0.8/debian/.git-dpm
--- django-anymail-0.7/debian/.git-dpm	2017-01-02 08:14:47.000000000 -0500
+++ django-anymail-0.8/debian/.git-dpm	2017-04-01 16:36:32.000000000 -0400
@@ -1,11 +1,11 @@
 # see git-dpm(1) from git-dpm package
-107063a19afd93a49dd2f8de54f47b72261201b6
-107063a19afd93a49dd2f8de54f47b72261201b6
-107063a19afd93a49dd2f8de54f47b72261201b6
-107063a19afd93a49dd2f8de54f47b72261201b6
-django-anymail_0.7.orig.tar.gz
-ea08f23ec93e7f46fd5496bb944cd05a918ca7c9
-38072
+836528d7ac66abbf47479fbb5b05b89e57c2707a
+836528d7ac66abbf47479fbb5b05b89e57c2707a
+836528d7ac66abbf47479fbb5b05b89e57c2707a
+836528d7ac66abbf47479fbb5b05b89e57c2707a
+django-anymail_0.8.orig.tar.gz
+8561666686c4ac3eefc154b788eb7c05f98b971a
+41671
 debianTag="debian/%e%v"
 patchedTag="patched/%e%v"
 upstreamTag="upstream/%e%u"
diff -Nru django-anymail-0.7/django_anymail.egg-info/PKG-INFO django-anymail-0.8/django_anymail.egg-info/PKG-INFO
--- django-anymail-0.7/django_anymail.egg-info/PKG-INFO	2016-12-30 18:13:20.000000000 -0500
+++ django-anymail-0.8/django_anymail.egg-info/PKG-INFO	2017-02-01 19:05:08.000000000 -0500
@@ -1,6 +1,6 @@
 Metadata-Version: 1.1
 Name: django-anymail
-Version: 0.7
+Version: 0.8
 Summary: Django email backends for Mailgun, Postmark, SendGrid and other transactional ESPs
 Home-page: https://github.com/anymail/django-anymail
 Author: Mike Edmunds <medmunds@gmail.com>
@@ -9,10 +9,12 @@
 Description: Anymail: Django email backends for Mailgun, Postmark, SendGrid, SparkPost and more
         ==================================================================================
         
-         **EARLY DEVELOPMENT**
+         **PRE-1.0**
         
-         This project is undergoing rapid development to get to a 1.0 release.
-         Before 1.0, minor version bumps might include breaking changes.
+         Although several projects are using this package in production,
+         the API and feature set are still evolving, and the package has
+         not yet reached 1.0 status. Before 1.0, minor version bumps might
+         include breaking changes (following semantic versioning rules).
          Please check the
          `release notes <https://github.com/anymail/django-anymail/releases>`_
         
@@ -45,7 +47,7 @@
         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/stable/topics/email/>`_
+          `Django's built-in email <https://docs.djangoproject.com/en/v0.8/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
@@ -53,23 +55,23 @@
           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.10
+        Anymail is released under the BSD license. It is extensively tested against Django 1.8--1.11
         (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=v0.7
+        .. image:: https://travis-ci.org/anymail/django-anymail.svg?branch=v0.8
                :target: https://travis-ci.org/anymail/django-anymail
                :alt:    build status on Travis-CI
         
-        .. image:: https://readthedocs.org/projects/anymail/badge/?version=v0.7
-               :target: https://anymail.readthedocs.io/en/v0.7/
+        .. image:: https://readthedocs.org/projects/anymail/badge/?version=v0.8
+               :target: https://anymail.readthedocs.io/en/v0.8/
                :alt:    documentation on ReadTheDocs
         
         **Resources**
         
-        * Full documentation: https://anymail.readthedocs.io/en/v0.7/
+        * Full documentation: https://anymail.readthedocs.io/en/v0.8/
         * Package on PyPI: https://pypi.python.org/pypi/django-anymail
         * Project on Github: https://github.com/anymail/django-anymail
         
@@ -109,11 +111,11 @@
                     "MAILGUN_API_KEY": "<your Mailgun key>",
                     "MAILGUN_SENDER_DOMAIN": 'mg.example.com',  # your Mailgun domain, if needed
                 }
-                EMAIL_BACKEND = "anymail.backends.mailgun.MailgunBackend"  # or sendgrid.SendGridBackend, or...
+                EMAIL_BACKEND = "anymail.backends.mailgun.EmailBackend"  # or sendgrid.EmailBackend, or...
                 DEFAULT_FROM_EMAIL = "you@example.com"  # if you don't already have this in settings
         
         
-        3. Now the regular `Django email functions <https://docs.djangoproject.com/en/stable/topics/email/>`_
+        3. Now the regular `Django email functions <https://docs.djangoproject.com/en/v0.8/topics/email/>`_
            will send through your chosen ESP:
         
            .. code-block:: python
@@ -157,7 +159,7 @@
         .. END quickstart
         
         
-        See the `full documentation <https://anymail.readthedocs.io/en/v0.7/>`_
+        See the `full documentation <https://anymail.readthedocs.io/en/v0.8/>`_
         for more features and options.
         
 Keywords: django,email,email backend,ESP,transactional mail,mailgun,mandrill,postmark,sendgrid
diff -Nru django-anymail-0.7/django_anymail.egg-info/SOURCES.txt django-anymail-0.8/django_anymail.egg-info/SOURCES.txt
--- django-anymail-0.7/django_anymail.egg-info/SOURCES.txt	2016-12-30 18:13:20.000000000 -0500
+++ django-anymail-0.8/django_anymail.egg-info/SOURCES.txt	2017-02-01 19:05:08.000000000 -0500
@@ -17,6 +17,7 @@
 anymail/backends/mandrill.py
 anymail/backends/postmark.py
 anymail/backends/sendgrid.py
+anymail/backends/sendgrid_v2.py
 anymail/backends/sparkpost.py
 anymail/backends/test.py
 anymail/webhooks/__init__.py
diff -Nru django-anymail-0.7/PKG-INFO django-anymail-0.8/PKG-INFO
--- django-anymail-0.7/PKG-INFO	2016-12-30 18:13:24.000000000 -0500
+++ django-anymail-0.8/PKG-INFO	2017-02-01 19:05:09.000000000 -0500
@@ -1,6 +1,6 @@
 Metadata-Version: 1.1
 Name: django-anymail
-Version: 0.7
+Version: 0.8
 Summary: Django email backends for Mailgun, Postmark, SendGrid and other transactional ESPs
 Home-page: https://github.com/anymail/django-anymail
 Author: Mike Edmunds <medmunds@gmail.com>
@@ -9,10 +9,12 @@
 Description: Anymail: Django email backends for Mailgun, Postmark, SendGrid, SparkPost and more
         ==================================================================================
         
-         **EARLY DEVELOPMENT**
+         **PRE-1.0**
         
-         This project is undergoing rapid development to get to a 1.0 release.
-         Before 1.0, minor version bumps might include breaking changes.
+         Although several projects are using this package in production,
+         the API and feature set are still evolving, and the package has
+         not yet reached 1.0 status. Before 1.0, minor version bumps might
+         include breaking changes (following semantic versioning rules).
          Please check the
          `release notes <https://github.com/anymail/django-anymail/releases>`_
         
@@ -45,7 +47,7 @@
         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/stable/topics/email/>`_
+          `Django's built-in email <https://docs.djangoproject.com/en/v0.8/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
@@ -53,23 +55,23 @@
           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.10
+        Anymail is released under the BSD license. It is extensively tested against Django 1.8--1.11
         (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=v0.7
+        .. image:: https://travis-ci.org/anymail/django-anymail.svg?branch=v0.8
                :target: https://travis-ci.org/anymail/django-anymail
                :alt:    build status on Travis-CI
         
-        .. image:: https://readthedocs.org/projects/anymail/badge/?version=v0.7
-               :target: https://anymail.readthedocs.io/en/v0.7/
+        .. image:: https://readthedocs.org/projects/anymail/badge/?version=v0.8
+               :target: https://anymail.readthedocs.io/en/v0.8/
                :alt:    documentation on ReadTheDocs
         
         **Resources**
         
-        * Full documentation: https://anymail.readthedocs.io/en/v0.7/
+        * Full documentation: https://anymail.readthedocs.io/en/v0.8/
         * Package on PyPI: https://pypi.python.org/pypi/django-anymail
         * Project on Github: https://github.com/anymail/django-anymail
         
@@ -109,11 +111,11 @@
                     "MAILGUN_API_KEY": "<your Mailgun key>",
                     "MAILGUN_SENDER_DOMAIN": 'mg.example.com',  # your Mailgun domain, if needed
                 }
-                EMAIL_BACKEND = "anymail.backends.mailgun.MailgunBackend"  # or sendgrid.SendGridBackend, or...
+                EMAIL_BACKEND = "anymail.backends.mailgun.EmailBackend"  # or sendgrid.EmailBackend, or...
                 DEFAULT_FROM_EMAIL = "you@example.com"  # if you don't already have this in settings
         
         
-        3. Now the regular `Django email functions <https://docs.djangoproject.com/en/stable/topics/email/>`_
+        3. Now the regular `Django email functions <https://docs.djangoproject.com/en/v0.8/topics/email/>`_
            will send through your chosen ESP:
         
            .. code-block:: python
@@ -157,7 +159,7 @@
         .. END quickstart
         
         
-        See the `full documentation <https://anymail.readthedocs.io/en/v0.7/>`_
+        See the `full documentation <https://anymail.readthedocs.io/en/v0.8/>`_
         for more features and options.
         
 Keywords: django,email,email backend,ESP,transactional mail,mailgun,mandrill,postmark,sendgrid
diff -Nru django-anymail-0.7/README.rst django-anymail-0.8/README.rst
--- django-anymail-0.7/README.rst	2016-11-01 15:23:27.000000000 -0400
+++ django-anymail-0.8/README.rst	2017-01-26 17:03:53.000000000 -0500
@@ -1,10 +1,12 @@
 Anymail: Django email backends for Mailgun, Postmark, SendGrid, SparkPost and more
 ==================================================================================
 
- **EARLY DEVELOPMENT**
+ **PRE-1.0**
 
- This project is undergoing rapid development to get to a 1.0 release.
- Before 1.0, minor version bumps might include breaking changes.
+ Although several projects are using this package in production,
+ the API and feature set are still evolving, and the package has
+ not yet reached 1.0 status. Before 1.0, minor version bumps might
+ include breaking changes (following semantic versioning rules).
  Please check the
  `release notes <https://github.com/anymail/django-anymail/releases>`_
 
@@ -45,7 +47,7 @@
   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.10
+Anymail is released under the BSD license. It is extensively tested against Django 1.8--1.11
 (including Python 2.7, Python 3 and PyPy).
 Anymail releases follow `semantic versioning <http://semver.org/>`_.
 
@@ -55,13 +57,13 @@
        :target: https://travis-ci.org/anymail/django-anymail
        :alt:    build status on Travis-CI
 
-.. image:: https://readthedocs.org/projects/anymail/badge/?version=latest
-       :target: https://anymail.readthedocs.io/en/latest/
+.. image:: https://readthedocs.org/projects/anymail/badge/?version=stable
+       :target: https://anymail.readthedocs.io/en/stable/
        :alt:    documentation on ReadTheDocs
 
 **Resources**
 
-* Full documentation: https://anymail.readthedocs.io/en/latest/
+* Full documentation: https://anymail.readthedocs.io/en/stable/
 * Package on PyPI: https://pypi.python.org/pypi/django-anymail
 * Project on Github: https://github.com/anymail/django-anymail
 
@@ -101,7 +103,7 @@
             "MAILGUN_API_KEY": "<your Mailgun key>",
             "MAILGUN_SENDER_DOMAIN": 'mg.example.com',  # your Mailgun domain, if needed
         }
-        EMAIL_BACKEND = "anymail.backends.mailgun.MailgunBackend"  # or sendgrid.SendGridBackend, or...
+        EMAIL_BACKEND = "anymail.backends.mailgun.EmailBackend"  # or sendgrid.EmailBackend, or...
         DEFAULT_FROM_EMAIL = "you@example.com"  # if you don't already have this in settings
 
 
@@ -149,5 +151,5 @@
 .. END quickstart
 
 
-See the `full documentation <https://anymail.readthedocs.io/en/latest/>`_
+See the `full documentation <https://anymail.readthedocs.io/en/stable/>`_
 for more features and options.
diff -Nru django-anymail-0.7/setup.py django-anymail-0.8/setup.py
--- django-anymail-0.7/setup.py	2016-11-01 15:23:27.000000000 -0400
+++ django-anymail-0.8/setup.py	2017-01-26 15:10:39.000000000 -0500
@@ -11,12 +11,12 @@
 
 
 def long_description_from_readme(rst):
-    # Freeze external links to refer to this X.Y version (on PyPI).
-    # (This relies on tagging or branching releases with 'vX.Y' in GitHub.)
-    release = 'v%s' % __minor_version__  # vX.Y
-    rst = re.sub(r'(?<=branch=)master'     # Travis build status: branch=master --> branch=vX.Y
-                 r'|(?<=/)latest'          # ReadTheDocs links: /latest --> /vX.Y
-                 r'|(?<=version=)latest',  # ReadTheDocs badge: version=latest --> version=vX.Y
+    # Freeze external links (on PyPI) to refer to this X.Y or X.Y.Z tag.
+    # (This relies on tagging releases with 'vX.Y' or 'vX.Y.Z' in GitHub.)
+    release = 'v%s' % __version__  # vX.Y or vX.Y.Z
+    rst = re.sub(r'(?<=branch=)master'     # Travis build status: branch=master --> branch=vX.Y.Z
+                 r'|(?<=/)stable'          # ReadTheDocs links: /stable --> /vX.Y.Z
+                 r'|(?<=version=)stable',  # ReadTheDocs badge: version=stable --> version=vX.Y.Z
                  release, rst)  # (?<=...) is "positive lookbehind": must be there, but won't get replaced
     return rst
 
--- django-anymail-0.7/anymail/backends/sendgrid.py	2016-10-13 17:57:38.000000000 -0400
+++ django-anymail/anymail/backends/sendgrid_v2.py	2017-04-01 16:36:31.990006359 -0400
@@ -10,11 +10,13 @@
 from .base_requests import AnymailRequestsBackend, RequestsPayload
 
 
-class SendGridBackend(AnymailRequestsBackend):
+class EmailBackend(AnymailRequestsBackend):
     """
-    SendGrid API Email Backend
+    SendGrid v2 API Email Backend (deprecated)
     """
 
+    esp_name = "SendGrid"
+
     def __init__(self, **kwargs):
         """Init options from Django settings"""
         # Auth requires *either* SENDGRID_API_KEY or SENDGRID_USERNAME+SENDGRID_PASSWORD
@@ -36,12 +38,12 @@
         self.merge_field_format = get_anymail_setting('merge_field_format', esp_name=esp_name,
                                                       kwargs=kwargs, default=None)
 
-        # This is SendGrid's Web API v2 (because the Web API v3 doesn't support sending)
+        # This is SendGrid's older Web API v2
         api_url = get_anymail_setting('api_url', esp_name=esp_name, kwargs=kwargs,
                                       default="https://api.sendgrid.com/api/";)
         if not api_url.endswith("/"):
             api_url += "/"
-        super(SendGridBackend, self).__init__(api_url, **kwargs)
+        super(EmailBackend, self).__init__(api_url, **kwargs)
 
     def build_message_payload(self, message, defaults):
         return SendGridPayload(message, defaults, self)
@@ -52,17 +54,22 @@
             sendgrid_message = parsed_response["message"]
         except (KeyError, TypeError):
             raise AnymailRequestsAPIError("Invalid SendGrid API response format",
-                                          email_message=message, payload=payload, response=response)
+                                          email_message=message, payload=payload, response=response,
+                                          backend=self)
         if sendgrid_message != "success":
             errors = parsed_response.get("errors", [])
             raise AnymailRequestsAPIError("SendGrid send failed: '%s'" % "; ".join(errors),
-                                          email_message=message, payload=payload, response=response)
+                                          email_message=message, payload=payload, response=response,
+                                          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}
 
 
 class SendGridPayload(RequestsPayload):
+    """
+    SendGrid v2 API Mail Send payload
+    """
 
     def __init__(self, message, defaults, backend, *args, **kwargs):
         self.all_recipients = []  # used for backend.parse_recipient_status
@@ -255,7 +262,7 @@
         if files_field in self.files:
             # It's possible SendGrid could actually handle this case (needs testing),
             # but requests doesn't seem to accept a list of tuples for a files field.
-            # (See the MailgunBackend version for a different approach that might work.)
+            # (See the Mailgun EmailBackend version for a different approach that might work.)
             self.unsupported_feature(
                 "multiple attachments with the same filename ('%s')" % filename if filename
                 else "multiple unnamed attachments")

Reply to: