diff --git a/.travis.yml b/.travis.yml index 7a592942..531b4d25 100644 --- a/.travis.yml +++ b/.travis.yml @@ -55,7 +55,6 @@ jobs: # Install without optional extras (don't need to cover entire matrix) - { env: TOXENV=django31-py37-none, python: 3.7 } - { env: TOXENV=django31-py37-amazon_ses, python: 3.7 } - - { env: TOXENV=django31-py37-sparkpost, python: 3.7 } # Test some specific older package versions - { env: TOXENV=django22-py37-all-old_urllib3, python: 3.7 } diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 9f280d09..8bea60ea 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -43,6 +43,18 @@ Breaking changes need to update it for compatibility with the new API. (See `docs `__.) +* **SparkPost:** Switch away from the (now unmaintained) Python SparkPost library to + calling the SparkPost API directly. The "sparkpost" package is no longer necessary and + can be removed from your project requirements. Most SparkPost users will not be + affected by this change, with two exceptions: (1) You must provide a + ``SPARKPOST_API_KEY`` in your Anymail settings (Anymail does not check environment + variables); and (2) if you use Anymail's `esp_extra` you will need to update it with + SparkPost Transmissions API parameters. + + As part of this change esp_extra now allows use of several SparkPost features, such + as A/B testing, that were unavailable through the Python SparkPost library. (See + `docs `__.) + * Remove Anymail internal code related to supporting Python 2 and older Django versions. This does not change the documented API, but may affect you if your code borrowed from Anymail's undocumented internals. (You should be able to switch @@ -54,6 +66,12 @@ Breaking changes inheritance. (For some helpful background, see this comment about `mixin superclass ordering `__.) +Features +~~~~~~~~ + +* **SparkPost:** Add support for AMP for Email, via + ``message.attach_alternative("...AMPHTML content...", "text/x-amp-html")``. + v7.2.1 ------ diff --git a/anymail/backends/sparkpost.py b/anymail/backends/sparkpost.py index a0eba3b4..e4c04560 100644 --- a/anymail/backends/sparkpost.py +++ b/anymail/backends/sparkpost.py @@ -1,82 +1,46 @@ -from .base import AnymailBaseBackend, BasePayload -from ..exceptions import AnymailAPIError, AnymailImproperlyInstalled, AnymailConfigurationError +from .base_requests import AnymailRequestsBackend, RequestsPayload +from ..exceptions import AnymailRequestsAPIError from ..message import AnymailRecipientStatus -from ..utils import get_anymail_setting +from ..utils import get_anymail_setting, update_deep -try: - from sparkpost import SparkPost, SparkPostException -except ImportError as err: - raise AnymailImproperlyInstalled(missing_package='sparkpost', backend='sparkpost') from err - -class EmailBackend(AnymailBaseBackend): +class EmailBackend(AnymailRequestsBackend): """ - SparkPost Email Backend (using python-sparkpost client) + SparkPost Email Backend """ esp_name = "SparkPost" def __init__(self, **kwargs): """Init options from Django settings""" - super().__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) - - # SPARKPOST_API_URL is optional - default is set by library; - # if provided, must be a full SparkPost API endpoint, including "/v1" if appropriate - api_url = get_anymail_setting('api_url', esp_name=self.esp_name, kwargs=kwargs, default=None) - extra_sparkpost_params = {} - if api_url is not None: - if api_url.endswith("/"): - api_url = api_url[:-1] - extra_sparkpost_params['base_uri'] = _FullSparkPostEndpoint(api_url) - - try: - self.sp = SparkPost(self.api_key, **extra_sparkpost_params) # SparkPost API instance - except SparkPostException as err: - # This is almost certainly a missing API key - raise AnymailConfigurationError( - "Error initializing SparkPost: %s\n" - "You may need to set ANYMAIL = {'SPARKPOST_API_KEY': ...} " - "or ANYMAIL_SPARKPOST_API_KEY in your Django settings, " - "or SPARKPOST_API_KEY in your environment." % str(err) - ) from err - - # Note: SparkPost python API doesn't expose requests session sharing - # (so there's no need to implement open/close connection management here) + kwargs=kwargs, allow_bare=True) + api_url = get_anymail_setting('api_url', esp_name=self.esp_name, kwargs=kwargs, + default="https://api.sparkpost.com/api/v1/") + if not api_url.endswith("/"): + api_url += "/" + super().__init__(api_url, **kwargs) def build_message_payload(self, message, defaults): return SparkPostPayload(message, defaults, self) - def post_to_esp(self, payload, message): - params = payload.get_api_params() - try: - response = self.sp.transmissions.send(**params) - except SparkPostException as err: - raise AnymailAPIError( - str(err), backend=self, email_message=message, payload=payload, - response=getattr(err, 'response', None), # SparkPostAPIException requests.Response - status_code=getattr(err, 'status', None), # SparkPostAPIException HTTP status_code - ) from err - return response - def parse_recipient_status(self, response, payload, message): + parsed_response = self.deserialize_json_response(response, payload, message) try: - accepted = response['total_accepted_recipients'] - rejected = response['total_rejected_recipients'] - transmission_id = response['id'] + results = parsed_response["results"] + accepted = results["total_accepted_recipients"] + rejected = results["total_rejected_recipients"] + transmission_id = results["id"] except (KeyError, TypeError) as err: - raise AnymailAPIError( - "%s in SparkPost.transmissions.send result %r" % (str(err), response), - backend=self, email_message=message, payload=payload, - ) from err + raise AnymailRequestsAPIError("Invalid SparkPost API response format", + email_message=message, payload=payload, + response=response, backend=self) from err # SparkPost doesn't (yet*) tell us *which* recipients were accepted or rejected. # (* looks like undocumented 'rcpt_to_errors' might provide this info.) # If all are one or the other, we can report a specific status; # else just report 'unknown' for all recipients. - recipient_count = len(payload.all_recipients) + recipient_count = len(payload.recipients) if accepted == recipient_count and rejected == 0: status = 'queued' elif rejected == recipient_count and accepted == 0: @@ -84,174 +48,202 @@ def parse_recipient_status(self, response, payload, message): else: # mixed results, or wrong total status = 'unknown' recipient_status = AnymailRecipientStatus(message_id=transmission_id, status=status) - return {recipient.addr_spec: recipient_status for recipient in payload.all_recipients} - - -class SparkPostPayload(BasePayload): - def init_payload(self): - self.params = {} - self.all_recipients = [] - self.to_emails = [] - self.merge_data = {} - self.merge_metadata = {} - - def get_api_params(self): - # Compose recipients param from to_emails and merge_data (if any) - recipients = [] + return {recipient.addr_spec: recipient_status for recipient in payload.recipients} + + +class SparkPostPayload(RequestsPayload): + def __init__(self, message, defaults, backend, *args, **kwargs): + http_headers = { + 'Authorization': backend.api_key, + 'Content-Type': 'application/json', + } + self.recipients = [] # all recipients, for backend parse_recipient_status + self.cc_and_bcc = [] # for _finalize_recipients + super().__init__(message, defaults, backend, headers=http_headers, *args, **kwargs) + + def get_api_endpoint(self): + return "transmissions/" + + def serialize_data(self): + self._finalize_recipients() + return self.serialize_json(self.data) + + def _finalize_recipients(self): + # https://www.sparkpost.com/docs/faq/cc-bcc-with-rest-api/ + # self.data["recipients"] is currently a list of all to-recipients. We need to add + # all cc and bcc recipients. Exactly how depends on whether this is a batch send. if self.is_batch(): - # Build JSON recipient structures - for email in self.to_emails: - rcpt = {'address': {'email': email.addr_spec}} - if email.display_name: - rcpt['address']['name'] = email.display_name - try: - rcpt['substitution_data'] = self.merge_data[email.addr_spec] - except KeyError: - pass # no merge_data or none for this recipient - try: - rcpt['metadata'] = self.merge_metadata[email.addr_spec] - except KeyError: - pass # no merge_metadata or none for this recipient - recipients.append(rcpt) + # For batch sends, must duplicate the cc/bcc for *every* to-recipient + # (using each to-recipient's metadata and substitutions). + extra_recipients = [] + for to_recipient in self.data["recipients"]: + for email in self.cc_and_bcc: + extra = to_recipient.copy() # capture "metadata" and "substitutions", if any + extra["address"] = { + "email": email.addr_spec, + "header_to": to_recipient["address"]["header_to"], + } + extra_recipients.append(extra) + self.data["recipients"].extend(extra_recipients) else: - # Just use simple recipients list - recipients = [email.address for email in self.to_emails] - if recipients: - self.params['recipients'] = recipients - - # Must remove empty string "content" params when using stored template - if self.params.get('template', None): - for content_param in ['subject', 'text', 'html']: - try: - if not self.params[content_param]: - del self.params[content_param] - except KeyError: - pass + # For non-batch sends, we need to patch up *everyone's* displayed + # "To" header to show all the "To" recipients... + full_to_header = ", ".join( + to_recipient["address"]["header_to"] + for to_recipient in self.data["recipients"]) + for recipient in self.data["recipients"]: + recipient["address"]["header_to"] = full_to_header + # ... and then simply add the cc/bcc to the end of the list. + # (There is no per-recipient data, or it would be a batch send.) + self.data["recipients"].extend( + {"address": { + "email": email.addr_spec, + "header_to": full_to_header, + }} + for email in self.cc_and_bcc) + + # + # Payload construction + # - return self.params + def init_payload(self): + # The JSON payload: + self.data = { + "content": {}, + "recipients": [], + } def set_from_email_list(self, emails): # SparkPost supports multiple From email addresses, # as a single comma-separated string - self.params['from_email'] = ", ".join([email.address for email in emails]) + self.data["content"]["from"] = ", ".join(email.address for email in emails) def set_to(self, emails): if emails: - self.to_emails = emails # bound to params['recipients'] in get_api_params - self.all_recipients += emails + # In the recipient address, "email" is the addr spec to deliver to, + # and "header_to" is a fully-composed "To" header to display. + # (We use "header_to" rather than "name" to simplify some logic + # in _finalize_recipients; the results end up the same.) + self.data["recipients"].extend( + {"address": { + "email": email.addr_spec, + "header_to": email.address, + }} + for email in emails) + self.recipients += emails def set_cc(self, emails): + # https://www.sparkpost.com/docs/faq/cc-bcc-with-rest-api/ if emails: - self.params['cc'] = [email.address for email in emails] - self.all_recipients += emails + # Add the Cc header, visible to all recipients: + cc_header = ", ".join(email.address for email in emails) + self.data["content"].setdefault("headers", {})["Cc"] = cc_header + # Actual recipients are added later, in _finalize_recipients + self.cc_and_bcc += emails + self.recipients += emails def set_bcc(self, emails): if emails: - self.params['bcc'] = [email.address for email in emails] - self.all_recipients += emails + # Actual recipients are added later, in _finalize_recipients + self.cc_and_bcc += emails + self.recipients += emails def set_subject(self, subject): - self.params['subject'] = subject + self.data["content"]["subject"] = subject def set_reply_to(self, emails): if emails: - # reply_to is only documented as a single email, but this seems to work: - self.params['reply_to'] = ', '.join([email.address for email in emails]) + self.data["content"]["reply_to"] = ", ".join(email.address for email in emails) def set_extra_headers(self, headers): if headers: - self.params['custom_headers'] = dict(headers) # convert CaseInsensitiveDict to plain dict for SP lib + self.data["content"].setdefault("headers", {}).update(headers) def set_text_body(self, body): - self.params['text'] = body + self.data["content"]["text"] = body def set_html_body(self, body): - if 'html' in self.params: + if "html" in self.data["content"]: # second html body could show up through multiple alternatives, or html body + alternative self.unsupported_feature("multiple html parts") - self.params['html'] = body + self.data["content"]["html"] = body - def add_attachment(self, attachment): - if attachment.inline: - param = 'inline_images' - name = attachment.cid + def add_alternative(self, content, mimetype): + if mimetype.lower() == "text/x-amp-html": + if "amp_html" in self.data["content"]: + self.unsupported_feature("multiple html parts") + self.data["content"]["amp_html"] = content else: - param = 'attachments' - name = attachment.name or '' - - self.params.setdefault(param, []).append({ - 'type': attachment.mimetype, - 'name': name, - 'data': attachment.b64content}) + super().add_alternative(content, mimetype) + + def set_attachments(self, atts): + attachments = [{ + "name": att.name or "", + "type": att.content_type, + "data": att.b64content, + } for att in atts if not att.inline] + if attachments: + self.data["content"]["attachments"] = attachments + + inline_images = [{ + "name": att.cid, + "type": att.mimetype, + "data": att.b64content, + } for att in atts if att.inline] + if inline_images: + self.data["content"]["inline_images"] = inline_images # Anymail-specific payload construction def set_envelope_sender(self, email): - self.params['return_path'] = email.addr_spec + self.data["return_path"] = email.addr_spec def set_metadata(self, metadata): - self.params['metadata'] = metadata + self.data["metadata"] = metadata + + def set_merge_metadata(self, merge_metadata): + for recipient in self.data["recipients"]: + to_email = recipient["address"]["email"] + if to_email in merge_metadata: + recipient["metadata"] = merge_metadata[to_email] def set_send_at(self, send_at): try: - self.params['start_time'] = send_at.replace(microsecond=0).isoformat() + start_time = send_at.replace(microsecond=0).isoformat() except (AttributeError, TypeError): - self.params['start_time'] = send_at # assume user already formatted + start_time = send_at # assume user already formatted + self.data.setdefault("options", {})["start_time"] = start_time def set_tags(self, tags): if len(tags) > 0: - self.params['campaign'] = tags[0] + self.data["campaign_id"] = tags[0] if len(tags) > 1: - self.unsupported_feature('multiple tags (%r)' % tags) + self.unsupported_feature("multiple tags (%r)" % tags) def set_track_clicks(self, track_clicks): - self.params['track_clicks'] = track_clicks + self.data.setdefault("options", {})["click_tracking"] = track_clicks def set_track_opens(self, track_opens): - self.params['track_opens'] = track_opens + self.data.setdefault("options", {})["open_tracking"] = track_opens def set_template_id(self, template_id): - # 'template' transmissions.send param becomes 'template_id' in API json 'content' - self.params['template'] = template_id + self.data["content"]["template_id"] = template_id + # Must remove empty string "content" params when using stored template + for content_param in ["subject", "text", "html"]: + try: + if not self.data["content"][content_param]: + del self.data["content"][content_param] + except KeyError: + pass def set_merge_data(self, merge_data): - self.merge_data = merge_data # merged into params['recipients'] in get_api_params - - def set_merge_metadata(self, merge_metadata): - self.merge_metadata = merge_metadata # merged into params['recipients'] in get_api_params + for recipient in self.data["recipients"]: + to_email = recipient["address"]["email"] + if to_email in merge_data: + recipient["substitution_data"] = merge_data[to_email] def set_merge_global_data(self, merge_global_data): - self.params['substitution_data'] = merge_global_data + self.data["substitution_data"] = merge_global_data # ESP-specific payload construction def set_esp_extra(self, extra): - self.params.update(extra) - - -class _FullSparkPostEndpoint(str): - """A string-like object that allows using a complete SparkPost API endpoint url as base_uri: - - sp = SparkPost(api_key, base_uri=_FullSparkPostEndpoint('https://api.sparkpost.com/api/labs')) - - Works around SparkPost.__init__ code `self.base_uri = base_uri + '/api/v' + version`, - which makes it difficult to simply copy and paste full API endpoints from SparkPost's docs - (https://developers.sparkpost.com/api/index.html#header-api-endpoints) -- and completely - prevents using the labs API endpoint (which has no "v" in it). - - Should work with all python-sparkpost releases through at least v1.3.6. - """ - _expect = ['/api/v', '1'] # ignore attempts to concatenate these with me (in order) - - def __add__(self, other): - expected = self._expect[0] - self._expect = self._expect[1:] # (makes a copy for this instance) - if other == expected: - # ignore this operation - if self._expect: - return self - else: - return str(self) # my work is done; just be a normal str now - else: - # something changed in python-sparkpost; please open an Anymail issue to fix - raise ValueError( - "This version of Anymail is not compatible with this version of python-sparkpost.\n" - "(_FullSparkPostEndpoint(%r) expected %r but got %r)" % (self, expected, other)) + update_deep(self.data, extra) diff --git a/docs/contributing.rst b/docs/contributing.rst index aaf45a9f..11c1b953 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -91,7 +91,7 @@ Or: .. code-block:: console - $ pip install mock boto3 sparkpost # install test dependencies + $ pip install mock boto3 # install test dependencies $ python runtests.py ## this command can also run just a few test cases, e.g.: diff --git a/docs/esps/sparkpost.rst b/docs/esps/sparkpost.rst index 2470ca24..73088be4 100644 --- a/docs/esps/sparkpost.rst +++ b/docs/esps/sparkpost.rst @@ -4,22 +4,20 @@ SparkPost ========= Anymail integrates with the `SparkPost`_ email service, using their -Python :pypi:`sparkpost` API client package. +`Transmissions API`_. -.. _SparkPost: https://www.sparkpost.com/ - - -Installation ------------- +.. versionchanged:: 8.0 -You must ensure the :pypi:`sparkpost` package is installed to use Anymail's SparkPost -backend. Either include the "sparkpost" option when you install Anymail: + Earlier Anymail versions used the official Python :pypi:`sparkpost` API client. + That library is no longer maintained, and Anymail now calls SparkPost's HTTP API + directly. This change should not affect most users, but you should make sure you + provide :setting:`SPARKPOST_API_KEY ` in your + Anymail settings (Anymail doesn't check environment variables), and if you are + using Anymail's :ref:`esp_extra ` you will need to update that + to use Transmissions API parameters. - .. code-block:: console - - $ pip install "django-anymail[sparkpost]" - -or separately run `pip install sparkpost`. +.. _SparkPost: https://www.sparkpost.com/ +.. _Transmissions API: https://developers.sparkpost.com/api/transmissions/ Settings @@ -44,9 +42,6 @@ in your settings.py. A SparkPost API key with at least the "Transmissions: Read/Write" permission. (Manage API keys in your `SparkPost account API keys`_.) -This setting is optional; if not provided, the SparkPost API client will attempt -to read your API key from the `SPARKPOST_API_KEY` environment variable. - .. code-block:: python ANYMAIL = { @@ -58,6 +53,13 @@ Anymail will also look for ``SPARKPOST_API_KEY`` at the root of the settings file if neither ``ANYMAIL["SPARKPOST_API_KEY"]`` nor ``ANYMAIL_SPARKPOST_API_KEY`` is set. +.. versionchanged:: 8.0 + + This setting is required. If you store your API key in an environment variable, load + it into your Anymail settings: ``"SPARKPOST_API_KEY": os.environ["SPARKPOST_API_KEY"]``. + (Earlier Anymail releases used the SparkPost Python library, which would look for + the environment variable.) + .. _SparkPost account API keys: https://app.sparkpost.com/account/credentials @@ -65,8 +67,7 @@ nor ``ANYMAIL_SPARKPOST_API_KEY`` is set. .. rubric:: SPARKPOST_API_URL -The `SparkPost API Endpoint`_ to use. This setting is optional; if not provided, Anymail will -use the :pypi:`python-sparkpost` client default endpoint (``"https://api.sparkpost.com/api/v1"``). +The `SparkPost API Endpoint`_ to use. The default is ``"https://api.sparkpost.com/api/v1"``. Set this to use a SparkPost EU account, or to work with any other API endpoint including SparkPost Enterprise API and SparkPost Labs. @@ -79,8 +80,6 @@ SparkPost Enterprise API and SparkPost Labs. } You must specify the full, versioned API endpoint as shown above (not just the base_uri). -This setting only affects Anymail's calls to SparkPost, and will not apply to other code -using :pypi:`python-sparkpost`. .. _SparkPost API Endpoint: https://developers.sparkpost.com/api/index.html#header-api-endpoints @@ -90,28 +89,47 @@ using :pypi:`python-sparkpost`. esp_extra support ----------------- -To use SparkPost features not directly supported by Anymail, you can -set a message's :attr:`~anymail.message.AnymailMessage.esp_extra` to -a `dict` of parameters for python-sparkpost's `transmissions.send method`_. -Any keys in your :attr:`esp_extra` dict will override Anymail's normal -values for that parameter. +To use SparkPost features not directly supported by Anymail, you can set +a message's :attr:`~anymail.message.AnymailMessage.esp_extra` to a `dict` +of `transmissions API request body`_ data. Anymail will deeply merge your overrides +into the normal API payload it has constructed, with esp_extra taking precedence +in conflicts. -Example: +Example (you probably wouldn't combine all of these options at once): .. code-block:: python message.esp_extra = { - 'transactional': True, # treat as transactional for unsubscribe and suppression - 'description': "Marketing test-run for new templates", - 'use_draft_template': True, + "options": { + # Treat as transactional for unsubscribe and suppression: + "transactional": True, + # Override your default dedicated IP pool: + "ip_pool": "transactional_pool", + }, + # Add a description: + "description": "Test-run for new templates", + "content": { + # Use draft rather than published template: + "use_draft_template": True, + # Use an A/B test: + "ab_test_id": "highlight_support_links", + }, + # Use a stored recipients list (overrides message to/cc/bcc): + "recipients": { + "list_id": "design_team" + }, } +Note that including ``"recipients"`` in esp_extra will *completely* override the +recipients list Anymail generates from your message's to/cc/bcc fields, along with any +per-recipient :attr:`~anymail.message.AnymailMessage.merge_data` and +:attr:`~anymail.message.AnymailMessage.merge_metadata`. (You can also set `"esp_extra"` in Anymail's :ref:`global send defaults ` to apply it to all messages.) -.. _transmissions.send method: - https://python-sparkpost.readthedocs.io/en/latest/api/transmissions.html#sparkpost.transmissions.Transmissions.send +.. _transmissions API request body: + https://developers.sparkpost.com/api/transmissions/#header-request-body @@ -151,6 +169,13 @@ Limitations and quirks (SparkPost's "recipient tags" are not available for tagging *messages*. They're associated with individual *addresses* in stored recipient lists.) +**AMP for Email** + SparkPost supports sending AMPHTML email content. To include it, use + ``message.attach_alternative("...AMPHTML content...", "text/x-amp-html")`` + (and be sure to also include regular HTML and/or text bodies, too). + + .. versionadded:: 8.0 + **Envelope sender may use domain only** Anymail's :attr:`~anymail.message.AnymailMessage.envelope_sender` is used to populate SparkPost's `'return_path'` parameter. Anymail supplies the full diff --git a/docs/tips/performance.rst b/docs/tips/performance.rst index 03c92baa..6e127b48 100644 --- a/docs/tips/performance.rst +++ b/docs/tips/performance.rst @@ -12,11 +12,6 @@ used with Django's batch-sending functions :func:`~django.core.mail.send_mass_ma :meth:`connection.send_messages`. See :ref:`django:topics-sending-multiple-emails` in the Django docs for more info and an example. -(The exception is when Anymail wraps an ESP's official Python package, and that -package doesn't support connection reuse. Django's batch-sending functions will -still work, but will incur the overhead of creating a separate connection for each -message sent. Currently, only SparkPost has this limitation.) - If you need even more performance, you may want to consider your ESP's batch-sending features. When supported by your ESP, Anymail can send multiple messages with a single API call. See :ref:`batch-send` for details, and be sure to check the diff --git a/setup.py b/setup.py index aed8d13f..e02dec64 100644 --- a/setup.py +++ b/setup.py @@ -54,11 +54,11 @@ def long_description_from_readme(rst): "postmark": [], "sendgrid": [], "sendinblue": [], - "sparkpost": ["sparkpost"], + "sparkpost": [], }, include_package_data=True, test_suite="runtests.runtests", - tests_require=["mock", "boto3", "sparkpost"], + tests_require=["mock", "boto3"], classifiers=[ "Development Status :: 5 - Production/Stable", "Programming Language :: Python", diff --git a/tests/test_sparkpost_backend.py b/tests/test_sparkpost_backend.py index b7261f40..5a1a5d63 100644 --- a/tests/test_sparkpost_backend.py +++ b/tests/test_sparkpost_backend.py @@ -1,83 +1,54 @@ -import os +import json from datetime import date, datetime +from decimal import Decimal from email.mime.base import MIMEBase from email.mime.image import MIMEImage -from io import BytesIO +from email.mime.text import MIMEText -import requests from django.core import mail -from django.test import SimpleTestCase, override_settings, tag +from django.test import override_settings, tag from django.utils.timezone import get_fixed_timezone, override as override_current_timezone, utc -from mock import patch from anymail.exceptions import ( AnymailAPIError, AnymailConfigurationError, AnymailInvalidAddress, AnymailRecipientsRefused, - AnymailUnsupportedFeature) + AnymailSerializationError, AnymailUnsupportedFeature) from anymail.message import attach_inline_image_file -from .utils import AnymailTestMixin, SAMPLE_IMAGE_FILENAME, decode_att, sample_image_content, sample_image_path + +from .mock_requests_backend import RequestsBackendMockAPITestCase +from .utils import SAMPLE_IMAGE_FILENAME, decode_att, sample_image_content, sample_image_path @tag('sparkpost') @override_settings(EMAIL_BACKEND='anymail.backends.sparkpost.EmailBackend', ANYMAIL={'SPARKPOST_API_KEY': 'test_api_key'}) -class SparkPostBackendMockAPITestCase(AnymailTestMixin, SimpleTestCase): +class SparkPostBackendMockAPITestCase(RequestsBackendMockAPITestCase): """TestCase that uses SparkPostEmailBackend with a mocked transmissions.send API""" + DEFAULT_RAW_RESPONSE = b"""{ + "results": { + "id": "12345678901234567890", + "total_accepted_recipients": 1, + "total_rejected_recipients": 0 + } + }""" + def setUp(self): super().setUp() - self.patch_send = patch('sparkpost.Transmissions.send', autospec=True) - self.mock_send = self.patch_send.start() - self.addCleanup(self.patch_send.stop) - self.set_mock_response() - # Simple message useful for many tests self.message = mail.EmailMultiAlternatives('Subject', 'Text Body', 'from@example.com', ['to@example.com']) - def set_mock_response(self, accepted=1, rejected=0, raw=None): - # SparkPost.transmissions.send returns the parsed 'result' field - # from the transmissions/send JSON response - self.mock_send.return_value = raw or { - "id": "12345678901234567890", - "total_accepted_recipients": accepted, - "total_rejected_recipients": rejected, - } - return self.mock_send.return_value - - def set_mock_failure(self, status_code=400, raw=b'{"errors":[{"message":"test error"}]}', encoding='utf-8'): - from sparkpost.exceptions import SparkPostAPIException - # Need to build a real(-ish) requests.Response for SparkPostAPIException - response = requests.Response() - response.status_code = status_code - response.encoding = encoding - response.raw = BytesIO(raw) - response.url = "/mock/send" - self.mock_send.side_effect = SparkPostAPIException(response) - - def get_send_params(self): - """Returns kwargs params passed to the mock send API. - - Fails test if API wasn't called. - """ - if self.mock_send.call_args is None: - raise AssertionError("API was not called") - (args, kwargs) = self.mock_send.call_args - return kwargs - - def get_send_api_key(self): - """Returns api_key on SparkPost api object used for mock send - - Fails test if API wasn't called - """ - if self.mock_send.call_args is None: - raise AssertionError("API was not called") - (args, kwargs) = self.mock_send.call_args - mock_self = args[0] - return mock_self.api_key - - def assert_esp_not_called(self, msg=None): - if self.mock_send.called: - raise AssertionError(msg or "ESP API was called and shouldn't have been") + def set_mock_result(self, accepted=1, rejected=0, id="12345678901234567890"): + """Set a mock response that reflects count of accepted/rejected recipients""" + raw = json.dumps({ + "results": { + "id": id, + "total_accepted_recipients": accepted, + "total_rejected_recipients": rejected, + } + }).encode("utf-8") + self.set_mock_response(raw=raw) + return raw @tag('sparkpost') @@ -88,56 +59,88 @@ def test_send_mail(self): """Test basic API for simple send""" mail.send_mail('Subject here', 'Here is the message.', 'from@example.com', ['to@example.com'], fail_silently=False) - params = self.get_send_params() - self.assertEqual(params['subject'], "Subject here") - self.assertEqual(params['text'], "Here is the message.") - self.assertEqual(params['from_email'], "from@example.com") - self.assertEqual(params['recipients'], ["to@example.com"]) - self.assertEqual(self.get_send_api_key(), 'test_api_key') + self.assert_esp_called('/api/v1/transmissions/') + + headers = self.get_api_call_headers() + self.assertEqual("test_api_key", headers["Authorization"]) + + data = self.get_api_call_json() + self.assertEqual(data["content"]["subject"], "Subject here") + self.assertEqual(data["content"]["text"], "Here is the message.") + self.assertEqual(data["content"]["from"], "from@example.com") + self.assertEqual(data['recipients'], [{ + "address": {"email": "to@example.com", "header_to": "to@example.com"} + }]) def test_name_addr(self): """Make sure RFC2822 name-addr format (with display-name) is allowed (Test both sender and recipient addresses) """ - self.set_mock_response(accepted=6) + self.set_mock_result(accepted=6) msg = mail.EmailMessage( 'Subject', 'Message', 'From Name ', ['Recipient #1 ', 'to2@example.com'], cc=['Carbon Copy ', 'cc2@example.com'], bcc=['Blind Copy ', 'bcc2@example.com']) msg.send() - params = self.get_send_params() - self.assertEqual(params['from_email'], "From Name ") - # We pre-parse the to-field emails (merge_data also gets attached there): - self.assertEqual(params['recipients'], ['Recipient #1 ', 'to2@example.com']) - # We let python-sparkpost parse the other email fields: - self.assertEqual(params['cc'], ['Carbon Copy ', 'cc2@example.com']) - self.assertEqual(params['bcc'], ['Blind Copy ', 'bcc2@example.com']) - - def test_email_message(self): - self.set_mock_response(accepted=6) + + data = self.get_api_call_json() + self.assertEqual(data["content"]["from"], "From Name ") + # This also checks recipient generation for cc and bcc. Because it's *not* + # a batch send, each recipient should see a To header reflecting all To addresses. + self.assertCountEqual(data["recipients"], [ + {"address": { + "email": "to1@example.com", + "header_to": "Recipient #1 , to2@example.com", + }}, + {"address": { + "email": "to2@example.com", + "header_to": "Recipient #1 , to2@example.com", + }}, + # cc and bcc must be explicitly specified as recipients + {"address": { + "email": "cc1@example.com", + "header_to": "Recipient #1 , to2@example.com", + }}, + {"address": { + "email": "cc2@example.com", + "header_to": "Recipient #1 , to2@example.com", + }}, + {"address": { + "email": "bcc1@example.com", + "header_to": "Recipient #1 , to2@example.com", + }}, + {"address": { + "email": "bcc2@example.com", + "header_to": "Recipient #1 , to2@example.com", + }}, + ]) + # Make sure we added a formatted Cc header visible to recipients + # (and not a Bcc header) + self.assertEqual(data["content"]["headers"], { + "Cc": "Carbon Copy , cc2@example.com" + }) + + def test_custom_headers(self): + self.set_mock_result(accepted=6) email = mail.EmailMessage( - 'Subject', 'Body goes here', 'from@example.com', - ['to1@example.com', 'Also To '], - bcc=['bcc1@example.com', 'Also BCC '], - cc=['cc1@example.com', 'Also CC '], + 'Subject', 'Body goes here', 'from@example.com', ['to1@example.com'], + cc=['cc1@example.com'], headers={'Reply-To': 'another@example.com', 'X-MyHeader': 'my value', 'Message-ID': 'mycustommsgid@example.com'}) email.send() - params = self.get_send_params() - self.assertEqual(params['subject'], "Subject") - self.assertEqual(params['text'], "Body goes here") - self.assertEqual(params['from_email'], "from@example.com") - self.assertEqual(params['recipients'], ['to1@example.com', 'Also To ']) - self.assertEqual(params['bcc'], ['bcc1@example.com', 'Also BCC ']) - self.assertEqual(params['cc'], ['cc1@example.com', 'Also CC ']) - self.assertEqual(params['reply_to'], 'another@example.com') - self.assertEqual(params['custom_headers'], { - 'X-MyHeader': 'my value', - 'Message-ID': 'mycustommsgid@example.com'}) + + data = self.get_api_call_json() + self.assertEqual(data["content"]["headers"], { + # Reply-To moved to separate param (below) + "X-MyHeader": "my value", + "Message-ID": "mycustommsgid@example.com", + "Cc": "cc1@example.com", # Cc header added + }) + self.assertEqual(data["content"]["reply_to"], "another@example.com") def test_html_message(self): text_content = 'This is an important message.' @@ -146,29 +149,33 @@ def test_html_message(self): 'from@example.com', ['to@example.com']) email.attach_alternative(html_content, "text/html") email.send() - params = self.get_send_params() - self.assertEqual(params['text'], text_content) - self.assertEqual(params['html'], html_content) + + data = self.get_api_call_json() + self.assertEqual(data["content"]["text"], text_content) + self.assertEqual(data["content"]["html"], html_content) # Don't accidentally send the html part as an attachment: - self.assertNotIn('attachments', params) + self.assertNotIn("attachments", data["content"]) def test_html_only_message(self): html_content = '

This is an important message.

' email = mail.EmailMessage('Subject', html_content, 'from@example.com', ['to@example.com']) email.content_subtype = "html" # Main content is now text/html email.send() - params = self.get_send_params() - self.assertNotIn('text', params) - self.assertEqual(params['html'], html_content) + + data = self.get_api_call_json() + self.assertNotIn("text", data["content"]) + self.assertEqual(data["content"]["html"], html_content) def test_reply_to(self): email = mail.EmailMessage('Subject', 'Body goes here', 'from@example.com', ['to1@example.com'], reply_to=['reply@example.com', 'Other '], headers={'X-Other': 'Keep'}) email.send() - params = self.get_send_params() - self.assertEqual(params['reply_to'], 'reply@example.com, Other ') - self.assertEqual(params['custom_headers'], {'X-Other': 'Keep'}) # don't lose other headers + + data = self.get_api_call_json() + self.assertEqual(data["content"]["reply_to"], + "reply@example.com, Other ") + self.assertEqual(data["content"]["headers"], {"X-Other": "Keep"}) # don't lose other headers def test_attachments(self): text_content = "* Item one\n* Item two\n* Item three" @@ -185,30 +192,39 @@ def test_attachments(self): self.message.attach(mimeattachment) self.message.send() - params = self.get_send_params() - attachments = params['attachments'] + data = self.get_api_call_json() + attachments = data["content"]["attachments"] self.assertEqual(len(attachments), 3) - self.assertEqual(attachments[0]['type'], 'text/plain') - self.assertEqual(attachments[0]['name'], 'test.txt') - self.assertEqual(decode_att(attachments[0]['data']).decode('ascii'), text_content) - self.assertEqual(attachments[1]['type'], 'image/png') # inferred from filename - self.assertEqual(attachments[1]['name'], 'test.png') - self.assertEqual(decode_att(attachments[1]['data']), png_content) - self.assertEqual(attachments[2]['type'], 'application/pdf') - self.assertEqual(attachments[2]['name'], '') # none - self.assertEqual(decode_att(attachments[2]['data']), pdf_content) + self.assertEqual(attachments[0]["type"], "text/plain") + self.assertEqual(attachments[0]["name"], "test.txt") + self.assertEqual(decode_att(attachments[0]["data"]).decode("ascii"), text_content) + self.assertEqual(attachments[1]["type"], "image/png") # inferred from filename + self.assertEqual(attachments[1]["name"], "test.png") + self.assertEqual(decode_att(attachments[1]["data"]), png_content) + self.assertEqual(attachments[2]["type"], "application/pdf") + self.assertEqual(attachments[2]["name"], "") # none + self.assertEqual(decode_att(attachments[2]["data"]), pdf_content) # Make sure the image attachment is not treated as embedded: - self.assertNotIn('inline_images', params) + self.assertNotIn("inline_images", data["content"]) def test_unicode_attachment_correctly_decoded(self): # Slight modification from the Django unicode docs: # http://django.readthedocs.org/en/latest/ref/unicode.html#email self.message.attach("Une pièce jointe.html", '

\u2019

', mimetype='text/html') self.message.send() - params = self.get_send_params() - attachments = params['attachments'] + data = self.get_api_call_json() + attachments = data["content"]["attachments"] self.assertEqual(len(attachments), 1) + def test_attachment_charset(self): + # SparkPost allows charset param in attachment type + self.message.attach(MIMEText("Une pièce jointe", "plain", "iso8859-1")) + self.message.send() + data = self.get_api_call_json() + attachment = data["content"]["attachments"][0] + self.assertEqual(attachment["type"], 'text/plain; charset="iso8859-1"') + self.assertEqual(decode_att(attachment["data"]), "Une pièce jointe".encode("iso8859-1")) + def test_embedded_images(self): image_filename = SAMPLE_IMAGE_FILENAME image_path = sample_image_path(image_filename) @@ -219,15 +235,15 @@ def test_embedded_images(self): self.message.attach_alternative(html_content, "text/html") self.message.send() - params = self.get_send_params() - self.assertEqual(params['html'], html_content) + data = self.get_api_call_json() + self.assertEqual(data["content"]["html"], html_content) - self.assertEqual(len(params['inline_images']), 1) - self.assertEqual(params['inline_images'][0]["type"], "image/png") - self.assertEqual(params['inline_images'][0]["name"], cid) - self.assertEqual(decode_att(params['inline_images'][0]["data"]), image_data) + self.assertEqual(len(data["content"]["inline_images"]), 1) + self.assertEqual(data["content"]["inline_images"][0]["type"], "image/png") + self.assertEqual(data["content"]["inline_images"][0]["name"], cid) + self.assertEqual(decode_att(data["content"]["inline_images"][0]["data"]), image_data) # Make sure neither the html nor the inline image is treated as an attachment: - self.assertNotIn('attachments', params) + self.assertNotIn("attachments", data["content"]) def test_attached_images(self): image_filename = SAMPLE_IMAGE_FILENAME @@ -240,8 +256,8 @@ def test_attached_images(self): self.message.attach(image) self.message.send() - params = self.get_send_params() - attachments = params['attachments'] + data = self.get_api_call_json() + attachments = data["content"]["attachments"] self.assertEqual(len(attachments), 2) self.assertEqual(attachments[0]["type"], "image/png") self.assertEqual(attachments[0]["name"], image_filename) @@ -250,15 +266,24 @@ def test_attached_images(self): self.assertEqual(attachments[1]["name"], "") # unknown -- not attached as file self.assertEqual(decode_att(attachments[1]["data"]), image_data) # Make sure the image attachments are not treated as embedded: - self.assertNotIn('inline_images', params) + self.assertNotIn("inline_images", data["content"]) def test_multiple_html_alternatives(self): - # Multiple alternatives not allowed + # Multiple text/html alternatives not allowed self.message.attach_alternative("

First html is OK

", "text/html") self.message.attach_alternative("

But not second html

", "text/html") with self.assertRaises(AnymailUnsupportedFeature): self.message.send() + def test_amp_html_alternative(self): + # SparkPost *does* support text/x-amp-html alongside text/html + self.message.attach_alternative("

HTML

", "text/html") + self.message.attach_alternative("

And AMP HTML

", "text/x-amp-html") + self.message.send() + data = self.get_api_call_json() + self.assertEqual(data["content"]["html"], "

HTML

") + self.assertEqual(data["content"]["amp_html"], "

And AMP HTML

") + def test_html_alternative(self): # Only html alternatives allowed self.message.attach_alternative("{'not': 'allowed'}", "application/json") @@ -275,24 +300,30 @@ def test_alternatives_fail_silently(self): def test_suppress_empty_address_lists(self): """Empty to, cc, bcc, and reply_to shouldn't generate empty headers""" self.message.send() - params = self.get_send_params() - self.assertNotIn('cc', params) - self.assertNotIn('bcc', params) - self.assertNotIn('reply_to', params) + data = self.get_api_call_json() + self.assertNotIn("headers", data["content"]) # No Cc, Bcc or Reply-To header + self.assertNotIn("reply_to", data["content"]) + def test_empty_to(self): # Test empty `to` -- but send requires at least one recipient somewhere (like cc) self.message.to = [] - self.message.cc = ['cc@example.com'] + self.message.cc = ["cc@example.com"] self.message.send() - params = self.get_send_params() - self.assertNotIn('recipients', params) + data = self.get_api_call_json() + self.assertEqual(data["recipients"], [{ + "address": { + "email": "cc@example.com", + # This results in a message with an empty To header, as desired: + "header_to": "", + }, + }]) def test_multiple_from_emails(self): """SparkPost supports multiple addresses in from_email""" self.message.from_email = 'first@example.com, "From, also" ' self.message.send() - params = self.get_send_params() - self.assertEqual(params['from_email'], + data = self.get_api_call_json() + self.assertEqual(data["content"]["from"], 'first@example.com, "From, also" ') # Make sure the far-more-likely scenario of a single from_email @@ -302,13 +333,13 @@ def test_multiple_from_emails(self): self.message.send() def test_api_failure(self): - self.set_mock_failure(status_code=400) + self.set_mock_response(status_code=400) with self.assertRaisesMessage(AnymailAPIError, "SparkPost API response 400"): self.message.send() def test_api_failure_fail_silently(self): # Make sure fail_silently is respected - self.set_mock_failure() + self.set_mock_response(status_code=400) sent = self.message.send(fail_silently=True) self.assertEqual(sent, 0) @@ -319,7 +350,7 @@ def test_api_error_includes_details(self): "message": "Helpful explanation from your ESP" }] }""" - self.set_mock_failure(raw=failure_response) + self.set_mock_response(status_code=400, raw=failure_response) with self.assertRaisesMessage(AnymailAPIError, "Helpful explanation from your ESP"): self.message.send() @@ -331,14 +362,14 @@ class SparkPostBackendAnymailFeatureTests(SparkPostBackendMockAPITestCase): def test_envelope_sender(self): self.message.envelope_sender = "bounce-handler@bounces.example.com" self.message.send() - params = self.get_send_params() - self.assertEqual(params['return_path'], "bounce-handler@bounces.example.com") + data = self.get_api_call_json() + self.assertEqual(data["return_path"], "bounce-handler@bounces.example.com") def test_metadata(self): self.message.metadata = {'user_id': "12345", 'items': 'spark, post'} self.message.send() - params = self.get_send_params() - self.assertEqual(params['metadata'], {'user_id': "12345", 'items': 'spark, post'}) + data = self.get_api_call_json() + self.assertEqual(data["metadata"], {'user_id': "12345", 'items': 'spark, post'}) def test_send_at(self): utc_plus_6 = get_fixed_timezone(6 * 60) @@ -349,45 +380,45 @@ def test_send_at(self): # Timezone-aware datetime converted to UTC: self.message.send_at = datetime(2016, 3, 4, 5, 6, 7, tzinfo=utc_minus_8) self.message.send() - params = self.get_send_params() - self.assertEqual(params['start_time'], "2016-03-04T05:06:07-08:00") + data = self.get_api_call_json() + self.assertEqual(data["options"]["start_time"], "2016-03-04T05:06:07-08:00") # Explicit UTC: self.message.send_at = datetime(2016, 3, 4, 5, 6, 7, tzinfo=utc) self.message.send() - params = self.get_send_params() - self.assertEqual(params['start_time'], "2016-03-04T05:06:07+00:00") + data = self.get_api_call_json() + self.assertEqual(data["options"]["start_time"], "2016-03-04T05:06:07+00:00") # Timezone-naive datetime assumed to be Django current_timezone # (also checks stripping microseconds) self.message.send_at = datetime(2022, 10, 11, 12, 13, 14, 567) self.message.send() - params = self.get_send_params() - self.assertEqual(params['start_time'], "2022-10-11T12:13:14+06:00") + data = self.get_api_call_json() + self.assertEqual(data["options"]["start_time"], "2022-10-11T12:13:14+06:00") # Date-only treated as midnight in current timezone self.message.send_at = date(2022, 10, 22) self.message.send() - params = self.get_send_params() - self.assertEqual(params['start_time'], "2022-10-22T00:00:00+06:00") + data = self.get_api_call_json() + self.assertEqual(data["options"]["start_time"], "2022-10-22T00:00:00+06:00") # POSIX timestamp self.message.send_at = 1651820889 # 2022-05-06 07:08:09 UTC self.message.send() - params = self.get_send_params() - self.assertEqual(params['start_time'], "2022-05-06T07:08:09+00:00") + data = self.get_api_call_json() + self.assertEqual(data["options"]["start_time"], "2022-05-06T07:08:09+00:00") # String passed unchanged (this is *not* portable between ESPs) self.message.send_at = "2022-10-13T18:02:00-11:30" self.message.send() - params = self.get_send_params() - self.assertEqual(params['start_time'], "2022-10-13T18:02:00-11:30") + data = self.get_api_call_json() + self.assertEqual(data["options"]["start_time"], "2022-10-13T18:02:00-11:30") def test_tags(self): self.message.tags = ["receipt"] self.message.send() - params = self.get_send_params() - self.assertEqual(params['campaign'], "receipt") + data = self.get_api_call_json() + self.assertEqual(data["campaign_id"], "receipt") self.message.tags = ["receipt", "repeat-user"] with self.assertRaisesMessage(AnymailUnsupportedFeature, 'multiple tags'): @@ -398,66 +429,77 @@ def test_tracking(self): self.message.track_opens = True self.message.track_clicks = False self.message.send() - params = self.get_send_params() - self.assertEqual(params['track_opens'], True) - self.assertEqual(params['track_clicks'], False) + data = self.get_api_call_json() + self.assertEqual(data["options"]["open_tracking"], True) + self.assertEqual(data["options"]["click_tracking"], False) # ...and the opposite way self.message.track_opens = False self.message.track_clicks = True self.message.send() - params = self.get_send_params() - self.assertEqual(params['track_opens'], False) - self.assertEqual(params['track_clicks'], True) + data = self.get_api_call_json() + self.assertEqual(data["options"]["open_tracking"], False) + self.assertEqual(data["options"]["click_tracking"], True) def test_template_id(self): message = mail.EmailMultiAlternatives(from_email='from@example.com', to=['to@example.com']) message.template_id = "welcome_template" message.send() - params = self.get_send_params() - self.assertEqual(params['template'], "welcome_template") + data = self.get_api_call_json() + self.assertEqual(data["content"]["template_id"], "welcome_template") # SparkPost disallows all content (even empty strings) with stored template: - self.assertNotIn('subject', params) - self.assertNotIn('text', params) - self.assertNotIn('html', params) + self.assertNotIn("subject", data["content"]) + self.assertNotIn("text", data["content"]) + self.assertNotIn("html", data["content"]) def test_merge_data(self): - self.set_mock_response(accepted=2) + self.set_mock_result(accepted=4) # two 'to' plus one 'cc' for each 'to' self.message.to = ['alice@example.com', 'Bob '] - self.message.body = "Hi %recipient.name%. Welcome to %recipient.group% at %recipient.site%." + self.message.cc = ['cc@example.com'] + self.message.body = "Hi {{address.name}}. Welcome to {{group}} at {{site}}." self.message.merge_data = { 'alice@example.com': {'name': "Alice", 'group': "Developers"}, 'bob@example.com': {'name': "Bob"}, # and leave group undefined 'nobody@example.com': {'name': "Not a recipient for this message"}, } self.message.merge_global_data = {'group': "Users", 'site': "ExampleCo"} + self.message.send() - params = self.get_send_params() - self.assertEqual(params['recipients'], [ - {'address': {'email': 'alice@example.com'}, - 'substitution_data': {'name': "Alice", 'group': "Developers"}}, - {'address': {'email': 'bob@example.com', 'name': 'Bob'}, - 'substitution_data': {'name': "Bob"}} - ]) - self.assertEqual(params['substitution_data'], {'group': "Users", 'site': "ExampleCo"}) + data = self.get_api_call_json() + self.assertEqual({"group": "Users", "site": "ExampleCo"}, data["substitution_data"]) + self.assertEqual([{ + "address": {"email": "alice@example.com", "header_to": "alice@example.com"}, + "substitution_data": {"name": "Alice", "group": "Developers"}, + }, { + "address": {"email": "bob@example.com", "header_to": "Bob "}, + "substitution_data": {"name": "Bob"}, + }, { # duplicated for cc recipients... + "address": {"email": "cc@example.com", "header_to": "alice@example.com"}, + "substitution_data": {"name": "Alice", "group": "Developers"}, + }, { + "address": {"email": "cc@example.com", "header_to": "Bob "}, + "substitution_data": {"name": "Bob"}, + }], data["recipients"]) def test_merge_metadata(self): - self.set_mock_response(accepted=2) + self.set_mock_result(accepted=2) self.message.to = ['alice@example.com', 'Bob '] self.message.merge_metadata = { 'alice@example.com': {'order_id': 123}, 'bob@example.com': {'order_id': 678, 'tier': 'premium'}, } self.message.metadata = {'notification_batch': 'zx912'} + self.message.send() - params = self.get_send_params() - self.assertEqual(params['recipients'], [ - {'address': {'email': 'alice@example.com'}, - 'metadata': {'order_id': 123}}, - {'address': {'email': 'bob@example.com', 'name': 'Bob'}, - 'metadata': {'order_id': 678, 'tier': 'premium'}} - ]) - self.assertEqual(params['metadata'], {'notification_batch': 'zx912'}) + data = self.get_api_call_json() + self.assertEqual([{ + "address": {"email": "alice@example.com", "header_to": "alice@example.com"}, + "metadata": {"order_id": 123}, + }, { + "address": {"email": "bob@example.com", "header_to": "Bob "}, + "metadata": {"order_id": 678, "tier": "premium"} + }], data["recipients"]) + self.assertEqual(data["metadata"], {"notification_batch": "zx912"}) def test_default_omits_options(self): """Make sure by default we don't send any ESP-specific options. @@ -467,31 +509,42 @@ def test_default_omits_options(self): that your ESP account settings apply by default. """ self.message.send() - params = self.get_send_params() - self.assertNotIn('campaign', params) - self.assertNotIn('metadata', params) - self.assertNotIn('start_time', params) - self.assertNotIn('substitution_data', params) - self.assertNotIn('template', params) - self.assertNotIn('track_clicks', params) - self.assertNotIn('track_opens', params) + data = self.get_api_call_json() + self.assertNotIn("campaign_id", data) + self.assertNotIn("metadata", data) + self.assertNotIn("options", data) # covers start_time, click_tracking, open_tracking + self.assertNotIn("substitution_data", data) + self.assertNotIn("template_id", data["content"]) def test_esp_extra(self): self.message.esp_extra = { - 'future_sparkpost_send_param': 'some-value', + "description": "The description", + "options": { + "transactional": True, + }, + "content": { + "use_draft_template": True, + "ab_test_id": "highlight_support_links", + }, } + self.message.track_clicks = True self.message.send() - params = self.get_send_params() - self.assertEqual(params['future_sparkpost_send_param'], 'some-value') + data = self.get_api_call_json() + self.assertEqual(data["description"], "The description") + self.assertEqual(data["options"], { + "transactional": True, + "click_tracking": True, # deep merge + }) + self.assertDictMatches({ + "use_draft_template": True, + "ab_test_id": "highlight_support_links", + "text": "Text Body", # deep merge + "subject": "Subject", # deep merge + }, data["content"]) def test_send_attaches_anymail_status(self): """The anymail_status should be attached to the message when it is sent """ - response_content = { - 'id': '9876543210', - 'total_accepted_recipients': 1, - 'total_rejected_recipients': 0, - } - self.set_mock_response(raw=response_content) + response_content = self.set_mock_result(accepted=1, rejected=0, id="9876543210") msg = mail.EmailMessage('Subject', 'Message', 'from@example.com', ['to1@example.com'],) sent = msg.send() self.assertEqual(sent, 1) @@ -499,12 +552,12 @@ def test_send_attaches_anymail_status(self): self.assertEqual(msg.anymail_status.message_id, '9876543210') self.assertEqual(msg.anymail_status.recipients['to1@example.com'].status, 'queued') self.assertEqual(msg.anymail_status.recipients['to1@example.com'].message_id, '9876543210') - self.assertEqual(msg.anymail_status.esp_response, response_content) + self.assertEqual(msg.anymail_status.esp_response.content, response_content) @override_settings(ANYMAIL_IGNORE_RECIPIENT_STATUS=True) # exception is tested later def test_send_all_rejected(self): """The anymail_status should be 'rejected' when all recipients rejected""" - self.set_mock_response(accepted=0, rejected=2) + self.set_mock_result(accepted=0, rejected=2) msg = mail.EmailMessage('Subject', 'Message', 'from@example.com', ['to1@example.com', 'to2@example.com'],) msg.send() @@ -514,7 +567,7 @@ def test_send_all_rejected(self): def test_send_some_rejected(self): """The anymail_status should be 'unknown' when some recipients accepted and some rejected""" - self.set_mock_response(accepted=1, rejected=1) + self.set_mock_result(accepted=1, rejected=1) msg = mail.EmailMessage('Subject', 'Message', 'from@example.com', ['to1@example.com', 'to2@example.com'],) msg.send() @@ -525,7 +578,7 @@ def test_send_some_rejected(self): def test_send_unexpected_count(self): """The anymail_status should be 'unknown' when the total result count doesn't match the number of recipients""" - self.set_mock_response(accepted=3, rejected=0) # but only 2 in the to-list + self.set_mock_result(accepted=3, rejected=0) # but only 2 in the to-list msg = mail.EmailMessage('Subject', 'Message', 'from@example.com', ['to1@example.com', 'to2@example.com'],) msg.send() @@ -536,7 +589,7 @@ def test_send_unexpected_count(self): # noinspection PyUnresolvedReferences def test_send_failed_anymail_status(self): """ If the send fails, anymail_status should contain initial values""" - self.set_mock_failure() + self.set_mock_response(status_code=400) sent = self.message.send(fail_silently=True) self.assertEqual(sent, 0) self.assertIsNone(self.message.anymail_status.status) @@ -547,20 +600,25 @@ def test_send_failed_anymail_status(self): # noinspection PyUnresolvedReferences def test_send_unparsable_response(self): """If the send succeeds, but result is unexpected format, should raise an API exception""" - response_content = {'wrong': 'format'} + response_content = b"""{"wrong": "format"}""" self.set_mock_response(raw=response_content) with self.assertRaises(AnymailAPIError): self.message.send() self.assertIsNone(self.message.anymail_status.status) self.assertIsNone(self.message.anymail_status.message_id) self.assertEqual(self.message.anymail_status.recipients, {}) - self.assertEqual(self.message.anymail_status.esp_response, response_content) + self.assertEqual(self.message.anymail_status.esp_response.content, response_content) - # test_json_serialization_errors: - # Although SparkPost will raise JSON serialization errors, they're coming - # from deep within the python-sparkpost implementation. Since it's an - # implementation detail of that package, Anymail doesn't try to catch or - # modify those errors. + def test_json_serialization_errors(self): + """Try to provide more information about non-json-serializable data""" + self.message.tags = [Decimal('19.99')] # yeah, don't do this + with self.assertRaises(AnymailSerializationError) as cm: + self.message.send() + print(self.get_api_call_json()) + err = cm.exception + self.assertIsInstance(err, TypeError) # compatibility with json.dumps + self.assertIn("Don't know how to send this data to SparkPost", str(err)) # our added context + self.assertRegex(str(err), r"Decimal.*is not JSON serializable") # original message @tag('sparkpost') @@ -568,14 +626,14 @@ class SparkPostBackendRecipientsRefusedTests(SparkPostBackendMockAPITestCase): """Should raise AnymailRecipientsRefused when *all* recipients are rejected or invalid""" def test_recipients_refused(self): - self.set_mock_response(accepted=0, rejected=2) + self.set_mock_result(accepted=0, rejected=2) msg = mail.EmailMessage('Subject', 'Body', 'from@example.com', ['invalid@localhost', 'reject@example.com']) with self.assertRaises(AnymailRecipientsRefused): msg.send() def test_fail_silently(self): - self.set_mock_response(accepted=0, rejected=2) + self.set_mock_result(accepted=0, rejected=2) sent = mail.send_mail('Subject', 'Body', 'from@example.com', ['invalid@localhost', 'reject@example.com'], fail_silently=True) @@ -583,7 +641,7 @@ def test_fail_silently(self): def test_mixed_response(self): """If *any* recipients are valid or queued, no exception is raised""" - self.set_mock_response(accepted=2, rejected=2) + self.set_mock_result(accepted=2, rejected=2) msg = mail.EmailMessage('Subject', 'Body', 'from@example.com', ['invalid@localhost', 'valid@example.com', 'reject@example.com', 'also.valid@example.com']) @@ -599,48 +657,35 @@ def test_mixed_response(self): @override_settings(ANYMAIL_IGNORE_RECIPIENT_STATUS=True) def test_settings_override(self): """No exception with ignore setting""" - self.set_mock_response(accepted=0, rejected=2) + self.set_mock_result(accepted=0, rejected=2) sent = mail.send_mail('Subject', 'Body', 'from@example.com', ['invalid@localhost', 'reject@example.com']) self.assertEqual(sent, 1) # refused message is included in sent count @tag('sparkpost') -@override_settings(EMAIL_BACKEND="anymail.backends.sparkpost.EmailBackend") -class SparkPostBackendConfigurationTests(AnymailTestMixin, SimpleTestCase): +class SparkPostBackendConfigurationTests(SparkPostBackendMockAPITestCase): """Test various SparkPost client options""" + @override_settings(ANYMAIL={}) # clear SPARKPOST_API_KEY from SparkPostBackendMockAPITestCase def test_missing_api_key(self): with self.assertRaises(AnymailConfigurationError) as cm: - mail.get_connection() # this init's SparkPost without actually trying to send anything + mail.send_mail('Subject', 'Message', 'from@example.com', ['to@example.com']) errmsg = str(cm.exception) # Make sure the error mentions the different places to set the key self.assertRegex(errmsg, r'\bSPARKPOST_API_KEY\b') self.assertRegex(errmsg, r'\bANYMAIL_SPARKPOST_API_KEY\b') - def test_api_key_in_env(self): - """SparkPost package allows API key in env var; make sure Anymail works with that""" - with patch.dict( - os.environ, - {'SPARKPOST_API_KEY': 'key_from_environment'}): - conn = mail.get_connection() - # Poke into implementation details to verify: - self.assertIsNone(conn.api_key) # Anymail prop - self.assertEqual(conn.sp.api_key, 'key_from_environment') # SparkPost prop - @override_settings(ANYMAIL={ "SPARKPOST_API_URL": "https://api.eu.sparkpost.com/api/v1", - "SPARKPOST_API_KEY": "example-key", + "SPARKPOST_API_KEY": "test_api_key", }) def test_sparkpost_api_url(self): - conn = mail.get_connection() # this init's the backend without sending anything - # Poke into implementation details to verify: - self.assertEqual(conn.sp.base_uri, "https://api.eu.sparkpost.com/api/v1") + mail.send_mail('Subject', 'Message', 'from@example.com', ['to@example.com']) + self.assert_esp_called("https://api.eu.sparkpost.com/api/v1/transmissions/") # can also override on individual connection (and even use non-versioned labs endpoint) - conn2 = mail.get_connection(api_url="https://api.sparkpost.com/api/labs") - self.assertEqual(conn2.sp.base_uri, "https://api.sparkpost.com/api/labs") - - # double-check _FullSparkPostEndpoint won't interfere with additional str ops - self.assertEqual(conn.sp.base_uri + "/transmissions/send", - "https://api.eu.sparkpost.com/api/v1/transmissions/send") + connection = mail.get_connection(api_url="https://api.sparkpost.com/api/labs") + mail.send_mail('Subject', 'Message', 'from@example.com', ['to@example.com'], + connection=connection) + self.assert_esp_called("https://api.sparkpost.com/api/labs/transmissions/") diff --git a/tests/test_sparkpost_integration.py b/tests/test_sparkpost_integration.py index 453047ff..3d9a8d36 100644 --- a/tests/test_sparkpost_integration.py +++ b/tests/test_sparkpost_integration.py @@ -1,6 +1,5 @@ import os import unittest -import warnings from datetime import datetime, timedelta from django.test import SimpleTestCase, override_settings, tag @@ -41,19 +40,6 @@ def setUp(self): 'test@test-sp.anymail.info', ['to@test.sink.sparkpostmail.com']) self.message.attach_alternative('

HTML content

', "text/html") - # The SparkPost Python package uses requests directly, without managing sessions, and relies - # on GC to close connections. This leads to harmless (but valid) warnings about unclosed - # ssl.SSLSocket during cleanup: https://github.com/psf/requests/issues/1882 - # There's not much we can do about that, short of switching from the SparkPost package - # to our own requests backend implementation (which *does* manage sessions properly). - # Unless/until we do that, filter the warnings to avoid test noise. - # Filter in TestCase.setUp because unittest resets the warning filters for each test. - # https://stackoverflow.com/a/26620811/647002 - from anymail.backends.base_requests import AnymailRequestsBackend - from anymail.backends.sparkpost import EmailBackend as SparkPostBackend - assert not issubclass(SparkPostBackend, AnymailRequestsBackend) # else this filter can be removed - warnings.filterwarnings("ignore", message=r"unclosed "], # Limit the live b/cc's to avoid running through our small monthly allowance: diff --git a/tox.ini b/tox.ini index a58cbd41..bb41cde4 100644 --- a/tox.ini +++ b/tox.ini @@ -15,7 +15,7 @@ envlist = # ... then prereleases (if available): djangoDev-py{36,37,38}-all # ... then partial installation (limit extras): - django31-py37-{none,amazon_ses,sparkpost} + django31-py37-{none,amazon_ses} # ... then older versions of some dependencies: django22-py37-all-old_urllib3 @@ -32,12 +32,10 @@ deps = mock extras = all,amazon_ses: amazon_ses - all,sparkpost: sparkpost setenv = # tell runtests.py to limit some test tags based on extras factor - none: ANYMAIL_SKIP_TESTS=amazon_ses,sparkpost + none: ANYMAIL_SKIP_TESTS=amazon_ses amazon_ses: ANYMAIL_ONLY_TEST=amazon_ses - sparkpost: ANYMAIL_ONLY_TEST=sparkpost ignore_outcome = # CI that wants to handle errors itself can set TOX_FORCE_IGNORE_OUTCOME=false djangoDev: {env:TOX_FORCE_IGNORE_OUTCOME:true}