From bc1156149ace0ebd29b577c6d05f4479a974083c Mon Sep 17 00:00:00 2001 From: Mike Edmunds Date: Tue, 8 Sep 2020 14:50:26 -0700 Subject: [PATCH] Mailjet: Upgrade to Send API v3.1 [breaking] Switch from Mailjet's older v3.0 Send API to the newer v3.1 version. This is a breaking change for code using the Mailjet backend and: * Using `esp_extra`, which must be updated to the new API format * Using multiple `reply_to` addresses, which the v3.1 API doesn't allow Closes #81 --- CHANGELOG.rst | 6 + anymail/backends/mailjet.py | 281 ++++++++---------- docs/esps/mailjet.rst | 114 ++++---- tests/test_mailjet_backend.py | 461 +++++++++++++----------------- tests/test_mailjet_integration.py | 18 +- 5 files changed, 397 insertions(+), 483 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index e9509c0..9f280d0 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -37,6 +37,12 @@ Breaking changes (For compatibility with Django 1.11, stay on the Anymail `v7.2 LTS`_ extended support branch by setting your requirements to `django-anymail~=7.2`.) +* **Mailjet:** Upgrade to Mailjet's newer v3.1 send API. Most Mailjet users will not + be affected by this change, with two exceptions: (1) Mailjet's v3.1 API does not allow + multiple reply-to addresses, and (2) if you are using Anymail's `esp_extra`, you will + need to update it for compatibility with the new API. (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 diff --git a/anymail/backends/mailjet.py b/anymail/backends/mailjet.py index 6ea16ac..2a46f54 100644 --- a/anymail/backends/mailjet.py +++ b/anymail/backends/mailjet.py @@ -1,10 +1,7 @@ -from email.header import Header -from urllib.parse import quote - from .base_requests import AnymailRequestsBackend, RequestsPayload from ..exceptions import AnymailRequestsAPIError -from ..message import ANYMAIL_STATUSES, AnymailRecipientStatus -from ..utils import EmailAddress, get_anymail_setting, parse_address_list +from ..message import AnymailRecipientStatus +from ..utils import get_anymail_setting, update_deep class EmailBackend(AnymailRequestsBackend): @@ -20,7 +17,7 @@ class EmailBackend(AnymailRequestsBackend): self.api_key = get_anymail_setting('api_key', esp_name=esp_name, kwargs=kwargs, allow_bare=True) self.secret_key = get_anymail_setting('secret_key', esp_name=esp_name, kwargs=kwargs, allow_bare=True) api_url = get_anymail_setting('api_url', esp_name=esp_name, kwargs=kwargs, - default="https://api.mailjet.com/v3") + default="https://api.mailjet.com/v3.1/") if not api_url.endswith("/"): api_url += "/" super().__init__(api_url, **kwargs) @@ -29,46 +26,39 @@ class EmailBackend(AnymailRequestsBackend): return MailjetPayload(message, defaults, self) def raise_for_status(self, response, payload, message): - # Improve Mailjet's (lack of) error message for bad API key - if response.status_code == 401 and not response.content: - raise AnymailRequestsAPIError( - "Invalid Mailjet API key or secret", - email_message=message, payload=payload, response=response, backend=self) + if 400 <= response.status_code <= 499: + # Mailjet uses 4xx status codes for partial failure in batch send; + # we'll determine how to handle below in parse_recipient_status. + return super().raise_for_status(response, payload, message) def parse_recipient_status(self, response, payload, message): - # Mailjet's (v3.0) transactional send API is not covered in their reference docs. - # The response appears to be either: - # {"Sent": [{"Email": ..., "MessageID": ...}, ...]} - # where only successful recipients are included - # or if the entire call has failed: - # {"ErrorCode": nnn, "Message": ...} parsed_response = self.deserialize_json_response(response, payload, message) + + # Global error? (no messages sent) if "ErrorCode" in parsed_response: - raise AnymailRequestsAPIError(email_message=message, payload=payload, response=response, - backend=self) + raise AnymailRequestsAPIError(email_message=message, payload=payload, response=response, backend=self) recipient_status = {} try: - for key in parsed_response: - status = key.lower() - if status not in ANYMAIL_STATUSES: - status = 'unknown' - - for item in parsed_response[key]: - message_id = str(item['MessageID']) - email = item['Email'] + for result in parsed_response["Messages"]: + status = 'sent' if result["Status"] == 'success' else 'failed' # Status is 'success' or 'error' + recipients = result.get("To", []) + result.get("Cc", []) + result.get("Bcc", []) + for recipient in recipients: + email = recipient['Email'] + message_id = str(recipient['MessageID']) # MessageUUID isn't yet useful for other Mailjet APIs recipient_status[email] = AnymailRecipientStatus(message_id=message_id, status=status) + # Note that for errors, Mailjet doesn't identify the problem recipients. + # This can occur with a batch send. We patch up the missing recipients below. except (KeyError, TypeError) as err: raise AnymailRequestsAPIError("Invalid Mailjet API response format", email_message=message, payload=payload, response=response, backend=self) from err - # Make sure we ended up with a status for every original recipient - # (Mailjet only communicates "Sent") - for recipients in payload.recipients.values(): - for email in recipients: - if email.addr_spec not in recipient_status: - recipient_status[email.addr_spec] = AnymailRecipientStatus(message_id=None, status='unknown') + + # Any recipient who wasn't reported as a 'success' must have been an error: + for email in payload.recipients: + if email.addr_spec not in recipient_status: + recipient_status[email.addr_spec] = AnymailRecipientStatus(message_id=None, status='failed') return recipient_status @@ -81,188 +71,161 @@ class MailjetPayload(RequestsPayload): http_headers = { 'Content-Type': 'application/json', } - # Late binding of recipients and their variables - self.recipients = {'to': []} + self.recipients = [] # for backend parse_recipient_status self.metadata = None - self.merge_data = {} - self.merge_metadata = {} super().__init__(message, defaults, backend, auth=auth, headers=http_headers, *args, **kwargs) def get_api_endpoint(self): return "send" def serialize_data(self): - self._populate_sender_from_template() - if self.is_batch(): - self.data = {'Messages': [ - self._data_for_recipient(to_addr) - for to_addr in self.recipients['to'] - ]} return self.serialize_json(self.data) - def _data_for_recipient(self, email): - # Return send data for single recipient, without modifying self.data - data = self.data.copy() - data['To'] = self._format_email_for_mailjet(email) - - if email.addr_spec in self.merge_data: - recipient_merge_data = self.merge_data[email.addr_spec] - if 'Vars' in data: - data['Vars'] = data['Vars'].copy() # clone merge_global_data - data['Vars'].update(recipient_merge_data) - else: - data['Vars'] = recipient_merge_data - - if email.addr_spec in self.merge_metadata: - recipient_metadata = self.merge_metadata[email.addr_spec] - if self.metadata: - metadata = self.metadata.copy() # clone toplevel metadata - metadata.update(recipient_metadata) - else: - metadata = recipient_metadata - data["Mj-EventPayLoad"] = self.serialize_json(metadata) - - return data - - def _populate_sender_from_template(self): - # If no From address was given, use the address from the template. - # Unfortunately, API 3.0 requires the From address to be given, so let's - # query it when needed. This will supposedly be fixed in 3.1 with a - # public beta in May 2017. - template_id = self.data.get("Mj-TemplateID") - if template_id and not self.data.get("FromEmail"): - response = self.backend.session.get( - "%sREST/template/%s/detailcontent" % (self.backend.api_url, quote(str(template_id), safe='')), - auth=self.auth, timeout=self.backend.timeout - ) - self.backend.raise_for_status(response, None, self.message) - json_response = self.backend.deserialize_json_response(response, None, self.message) - # Populate email address header from template. - try: - headers = json_response["Data"][0]["Headers"] - if "From" in headers: - # Workaround Mailjet returning malformed From header - # if there's a comma in the template's From display-name: - from_email = headers["From"].replace(",", "||COMMA||") - parsed = parse_address_list([from_email])[0] - if parsed.display_name: - parsed = EmailAddress(parsed.display_name.replace("||COMMA||", ","), - parsed.addr_spec) - else: - parsed = EmailAddress(headers["SenderName"], headers["SenderEmail"]) - except KeyError as err: - raise AnymailRequestsAPIError("Invalid Mailjet template API response", - email_message=self.message, response=response, - backend=self.backend) from err - self.set_from_email(parsed) - - def _format_email_for_mailjet(self, email): - """Return EmailAddress email converted to a string that Mailjet can parse properly""" - # Workaround Mailjet 3.0 bug parsing RFC-2822 quoted display-name with commas - # (see test_comma_in_display_name in test_mailjet_backend for details) - if "," in email.display_name: - # Force MIME "encoded-word" encoding on name, to hide comma from Mailjet. - # We just want the RFC-2047 quoting, not the header wrapping (which will - # be Mailjet's responsibility), so set a huge maxlinelen. - encoded_name = Header(email.display_name.encode('utf-8'), - charset='utf-8', maxlinelen=1000000).encode() - email = EmailAddress(encoded_name, email.addr_spec) - return email.address - # # Payload construction # def init_payload(self): - self.data = {} + # The v3.1 Send payload. We use Globals for most parameters, + # which simplifies batch sending if it's used (and if not, + # still works as expected for ordinary send). + # https://dev.mailjet.com/email/reference/send-emails#v3_1_post_send + self.data = { + "Globals": {}, + "Messages": [], + } + + def _burst_for_batch_send(self): + """Expand the payload Messages into a separate object for each To address""" + # This can be called multiple times -- if the payload has already been burst, + # it will have no effect. + # For simplicity, this assumes that "To" is the only Messages param we use + # (because everything else goes in Globals). + if len(self.data["Messages"]) == 1: + to_recipients = self.data["Messages"][0].get("To", []) + self.data["Messages"] = [{"To": [to]} for to in to_recipients] + + @staticmethod + def _mailjet_email(email): + """Expand an Anymail EmailAddress into Mailjet's {"Email", "Name"} dict""" + result = {"Email": email.addr_spec} + if email.display_name: + result["Name"] = email.display_name + return result def set_from_email(self, email): - self.data["FromEmail"] = email.addr_spec - if email.display_name: - self.data["FromName"] = email.display_name + self.data["Globals"]["From"] = self._mailjet_email(email) - def set_recipients(self, recipient_type, emails): - assert recipient_type in ["to", "cc", "bcc"] + def set_to(self, emails): + # "To" is the one non-batch param we transmit in Messages rather than Globals. + # (See also _burst_for_batch_send, set_merge_data, and set_merge_metadata.) + if len(self.data["Messages"]) > 0: + # This case shouldn't happen. Please file a bug report if it does. + raise AssertionError("set_to called with non-empty Messages list") if emails: - self.recipients[recipient_type] = emails # save for recipient_status processing - self.data[recipient_type.capitalize()] = ", ".join( - [self._format_email_for_mailjet(email) for email in emails]) + self.data["Messages"].append({ + "To": [self._mailjet_email(email) for email in emails] + }) + self.recipients += emails + else: + # Mailjet requires a To list; cc-only messages aren't possible + self.unsupported_feature("messages without any `to` recipients") + + def set_cc(self, emails): + if emails: + self.data["Globals"]["Cc"] = [self._mailjet_email(email) for email in emails] + self.recipients += emails + + def set_bcc(self, emails): + if emails: + self.data["Globals"]["Bcc"] = [self._mailjet_email(email) for email in emails] + self.recipients += emails def set_subject(self, subject): - self.data["Subject"] = subject + self.data["Globals"]["Subject"] = subject def set_reply_to(self, emails): - headers = self.data.setdefault("Headers", {}) - if emails: - headers["Reply-To"] = ", ".join([str(email) for email in emails]) - elif "Reply-To" in headers: - del headers["Reply-To"] + if len(emails) > 0: + self.data["Globals"]["ReplyTo"] = self._mailjet_email(emails[0]) + if len(emails) > 1: + self.unsupported_feature("Multiple reply_to addresses") def set_extra_headers(self, headers): - self.data.setdefault("Headers", {}).update(headers) + self.data["Globals"]["Headers"] = headers def set_text_body(self, body): - self.data["Text-part"] = body + if body: # Django's default empty text body confuses Mailjet (esp. templates) + self.data["Globals"]["TextPart"] = body def set_html_body(self, body): - if "Html-part" in self.data: - # second html body could show up through multiple alternatives, or html body + alternative - self.unsupported_feature("multiple html parts") + if body is not None: + if "HTMLPart" in self.data["Globals"]: + # second html body could show up through multiple alternatives, or html body + alternative + self.unsupported_feature("multiple html parts") - self.data["Html-part"] = body + self.data["Globals"]["HTMLPart"] = body def add_attachment(self, attachment): + att = { + "ContentType": attachment.mimetype, + "Filename": attachment.name or "", + "Base64Content": attachment.b64content, + } if attachment.inline: - field = "Inline_attachments" - name = attachment.cid + field = "InlinedAttachments" + att["ContentID"] = attachment.cid else: field = "Attachments" - name = attachment.name or "" - self.data.setdefault(field, []).append({ - "Content-type": attachment.mimetype, - "Filename": name, - "content": attachment.b64content - }) + self.data["Globals"].setdefault(field, []).append(att) def set_envelope_sender(self, email): - self.data["Sender"] = email.addr_spec # ??? v3 docs unclear + self.data["Globals"]["Sender"] = self._mailjet_email(email) def set_metadata(self, metadata): - self.data["Mj-EventPayLoad"] = self.serialize_json(metadata) + # Mailjet expects a single string payload + self.data["Globals"]["EventPayload"] = self.serialize_json(metadata) self.metadata = metadata # keep original in case we need to merge with merge_metadata + def set_merge_metadata(self, merge_metadata): + self._burst_for_batch_send() + for message in self.data["Messages"]: + email = message["To"][0]["Email"] + if email in merge_metadata: + if self.metadata: + recipient_metadata = self.metadata.copy() + recipient_metadata.update(merge_metadata[email]) + else: + recipient_metadata = merge_metadata[email] + message["EventPayload"] = self.serialize_json(recipient_metadata) + def set_tags(self, tags): # The choices here are CustomID or Campaign, and Campaign seems closer # to how "tags" are handled by other ESPs -- e.g., you can view dashboard # statistics across all messages with the same Campaign. if len(tags) > 0: - self.data["Tag"] = tags[0] - self.data["Mj-campaign"] = tags[0] + self.data["Globals"]["CustomCampaign"] = tags[0] if len(tags) > 1: self.unsupported_feature('multiple tags (%r)' % tags) def set_track_clicks(self, track_clicks): - # 1 disables tracking, 2 enables tracking - self.data["Mj-trackclick"] = 2 if track_clicks else 1 + self.data["Globals"]["TrackClicks"] = "enabled" if track_clicks else "disabled" def set_track_opens(self, track_opens): - # 1 disables tracking, 2 enables tracking - self.data["Mj-trackopen"] = 2 if track_opens else 1 + self.data["Globals"]["TrackOpens"] = "enabled" if track_opens else "disabled" def set_template_id(self, template_id): - self.data["Mj-TemplateID"] = template_id - self.data["Mj-TemplateLanguage"] = True + self.data["Globals"]["TemplateID"] = int(template_id) # Mailjet requires integer (not string) + self.data["Globals"]["TemplateLanguage"] = True def set_merge_data(self, merge_data): - # Will be handled later in serialize_data - self.merge_data = merge_data + self._burst_for_batch_send() + for message in self.data["Messages"]: + email = message["To"][0]["Email"] + if email in merge_data: + message["Variables"] = merge_data[email] def set_merge_global_data(self, merge_global_data): - self.data["Vars"] = merge_global_data - - def set_merge_metadata(self, merge_metadata): - # Will be handled later in serialize_data - self.merge_metadata = merge_metadata + self.data["Globals"]["Variables"] = merge_global_data def set_esp_extra(self, extra): - self.data.update(extra) + update_deep(self.data, extra) diff --git a/docs/esps/mailjet.rst b/docs/esps/mailjet.rst index b31229f..f651379 100644 --- a/docs/esps/mailjet.rst +++ b/docs/esps/mailjet.rst @@ -3,23 +3,19 @@ Mailjet ======= -Anymail integrates with the `Mailjet`_ email service, using their transactional `Send API`_ (v3). +Anymail integrates with the `Mailjet`_ email service, using their transactional `Send API v3.1`_. -.. _mailjet-v31-api: +.. versionchanged:: 8.0 -.. note:: - - Mailjet has released a newer `v3.1 Send API`_, but due to mismatches between its - documentation and actual behavior, Anymail has been unable to switch to it. - Anymail's maintainers have reported the problems to Mailjet, and if and when they - are resolved, Anymail will switch to the v3.1 API. This change should be largely - transparent to your code, unless you are using Anymail's - :ref:`esp_extra ` feature to set API-specific options. + Earlier Anymail versions used Mailjet's older `Send API v3`_. The change to v3.1 fixes + some limitations of the earlier API, and should only affect your code if you use Anymail's + :ref:`esp_extra ` feature to set API-specific options or if you are + trying to send messages with :ref:`multiple reply-to addresses `. .. _Mailjet: https://www.mailjet.com/ -.. _Send API: https://dev.mailjet.com/guides/#choose-sending-method -.. _v3.1 Send API: https://dev.mailjet.com/guides/#send-api-v3-1-beta +.. _Send API v3.1: https://dev.mailjet.com/guides/#send-api-v3-1 +.. _Send API v3: https://dev.mailjet.com/guides/#send-api-v3 Settings @@ -68,9 +64,8 @@ nor ``ANYMAIL_MAILJET_API_KEY`` is set. The base url for calling the Mailjet API. -The default is ``MAILJET_API_URL = "https://api.mailjet.com/v3"`` -(It's unlikely you would need to change this. This setting cannot be used -to opt into a newer API version; the parameters are not backwards compatible.) +The default is ``MAILJET_API_URL = "https://api.mailjet.com/v3.1/"`` +(It's unlikely you would need to change this.) .. _mailjet-esp-extra: @@ -80,39 +75,50 @@ esp_extra support To use Mailjet features not directly supported by Anymail, you can set a message's :attr:`~anymail.message.AnymailMessage.esp_extra` to -a `dict` of Mailjet's `Send API json properties`_. -Your :attr:`esp_extra` dict will be merged into the -parameters Anymail has constructed for the send, with `esp_extra` -having precedence in conflicts. +a `dict` of Mailjet's `Send API body parameters`_. +Your :attr:`esp_extra` dict will be deeply merged into the Mailjet +API payload, with `esp_extra` having precedence in conflicts. -.. note:: - - Any ``esp_extra`` settings will need to be updated when Anymail changes - to use Mailjet's upcoming v3.1 API. (See :ref:`note above `.) +(Note that it's *not* possible to merge into the ``"Messages"`` key; +any value you supply would override ``"Messages"`` completely. Use ``"Globals"`` +for options to apply to all messages.) Example: .. code-block:: python message.esp_extra = { - # Mailjet v3.0 Send API options: - "Mj-prio": 3, # Use Mailjet critically-high priority queue - "Mj-CustomID": my_event_tracking_id, + # Most "Messages" options can be included under Globals: + "Globals": { + "Priority": 3, # Use Mailjet critically-high priority queue + "TemplateErrorReporting": {"Email": "dev+mailtemplatebug@example.com"}, + }, + # A few options must be at the root: + "SandboxMode": True, + "AdvanceErrorHandling": True, + # *Don't* try to set Messages: + # "Messages": [... this would override *all* recipients, not be merged ...] } -(You can also set `"esp_extra"` in Anymail's -:ref:`global send defaults ` to apply it to all -messages.) - - -.. _Send API json properties: https://dev.mailjet.com/guides/#send-api-json-properties +(You can also set `"esp_extra"` in Anymail's :ref:`global send defaults ` +to apply it to all messages.) + +.. _Send API body parameters: + https://dev.mailjet.com/email/reference/send-emails#v3_1_post_send +.. _mailjet-quirks: Limitations and quirks ---------------------- +**Single reply_to** + Mailjet's API only supports a single Reply-To email address. If your message + has two or more, you'll get an :exc:`~anymail.exceptions.AnymailUnsupportedFeature` + error---or if you've enabled :setting:`ANYMAIL_IGNORE_UNSUPPORTED_FEATURES`, + Anymail will use only the first `reply_to` address. + **Single tag** Anymail uses Mailjet's `campaign`_ option for tags, and Mailjet allows only a single campaign per message. If your message has two or more @@ -131,27 +137,26 @@ Limitations and quirks Mailjet, but this may result in an API error if you have not received special approval from Mailjet support to use custom senders. -**Commas in recipient names** - Mailjet's v3 API does not properly handle commas in recipient display-names. - (Tested July, 2017, and confirmed with Mailjet API support.) +**message_id is MessageID (not MessageUUID)** + Mailjet's Send API v3.1 returns both a "legacy" MessageID and a newer + MessageUUID for each successfully sent message. Anymail uses the MessageID + as the :attr:`~anymail.message.AnymailStatus.message_id` when reporting + :ref:`esp-send-status`, because Mailjet's other (statistics, event tracking) + APIs don't yet support MessageUUID. - If your message would be affected, Anymail attempts to work around - the problem by switching to `MIME encoded-word`_ syntax where needed. - - Most modern email clients should support this syntax, but if you run - into issues, you might want to strip commas from all - recipient names (in ``to``, ``cc``, *and* ``bcc``) before sending. - - (This should be resolved in a future release when - Anymail :ref:`switches ` to Mailjet's upcoming v3.1 API.) - -.. _MIME encoded-word: https://en.wikipedia.org/wiki/MIME#Encoded-Word +**Older limitations** .. versionchanged:: 6.0 - Earlier versions of Anymail were unable to mix ``cc`` or ``bcc`` fields - and :attr:`~anymail.message.AnymailMessage.merge_data` in the same Mailjet message. - This limitation was removed in Anymail 6.0. + Earlier versions of Anymail were unable to mix ``cc`` or ``bcc`` fields + and :attr:`~anymail.message.AnymailMessage.merge_data` in the same Mailjet message. + This limitation was removed in Anymail 6.0. + +.. versionchanged:: 8.0 + + Earlier Anymail versions had special handling to work around a Mailjet v3 API bug + with commas in recipient display names. Anymail 8.0 uses Mailjet's v3.1 API, which + does not have the bug. .. _mailjet-templates: @@ -162,6 +167,17 @@ Batch sending/merge and ESP templates Mailjet offers both :ref:`ESP stored templates ` and :ref:`batch sending ` with per-recipient merge data. +When you send a message with multiple ``to`` addresses, the +:attr:`~anymail.message.AnymailMessage.merge_data` determines how many +distinct messages are sent: + +* If :attr:`~anymail.message.AnymailMessage.merge_data` is *not* set (the default), + Anymail will tell Mailjet to send a single message, and all recipients will see + the complete list of To addresses. +* If :attr:`~anymail.message.AnymailMessage.merge_data` *is* set---even to an empty + `{}` dict, Anymail will tell Mailjet to send a separate message for each ``to`` + address, and the recipients won't see the other To addresses. + You can use a Mailjet stored transactional template by setting a message's :attr:`~anymail.message.AnymailMessage.template_id` to the template's *numeric* template ID. (*Not* the template's name. To get the diff --git a/tests/test_mailjet_backend.py b/tests/test_mailjet_backend.py index 403d398..dc4e8b1 100644 --- a/tests/test_mailjet_backend.py +++ b/tests/test_mailjet_backend.py @@ -1,3 +1,4 @@ +import json from base64 import b64encode from decimal import Decimal from email.mime.base import MIMEBase @@ -7,9 +8,7 @@ from django.core import mail from django.core.exceptions import ImproperlyConfigured from django.test import SimpleTestCase, override_settings, tag -from anymail.exceptions import (AnymailAPIError, AnymailSerializationError, - AnymailUnsupportedFeature, - AnymailRequestsAPIError) +from anymail.exceptions import AnymailAPIError, AnymailSerializationError, AnymailUnsupportedFeature from anymail.message import attach_inline_image_file from .mock_requests_backend import RequestsBackendMockAPITestCase, SessionSharingTestCases @@ -19,48 +18,27 @@ from .utils import sample_image_content, sample_image_path, SAMPLE_IMAGE_FILENAM @tag('mailjet') @override_settings(EMAIL_BACKEND='anymail.backends.mailjet.EmailBackend', ANYMAIL={ - 'MAILJET_API_KEY': '', - 'MAILJET_SECRET_KEY': '' + 'MAILJET_API_KEY': 'API KEY HERE', + 'MAILJET_SECRET_KEY': 'SECRET KEY HERE' }) class MailjetBackendMockAPITestCase(RequestsBackendMockAPITestCase): DEFAULT_RAW_RESPONSE = b"""{ - "Sent": [{ - "Email": "to@example.com", - "MessageID": 12345678901234567 + "Messages": [{ + "Status": "success", + "To": [{ + "Email": "to@example.com", + "MessageUUID": "cb927469-36fd-4c02-bce4-0d199929a207", + "MessageID": 70650219165027410, + "MessageHref": "https://api.mailjet.com/v3/message/70650219165027410" + }] }] }""" - DEFAULT_TEMPLATE_RESPONSE = b"""{ - "Count": 1, - "Data": [{ - "Text-part": "text body", - "Html-part": "html body", - "MJMLContent": "", - "Headers": { - "Subject": "Hello World!", - "SenderName": "Friendly Tester", - "SenderEmail": "some@example.com", - "ReplyEmail": "" - } - }], - "Total": 1 - }""" - def setUp(self): super().setUp() # Simple message useful for many tests self.message = mail.EmailMultiAlternatives('Subject', 'Text Body', 'from@example.com', ['to@example.com']) - def set_template_response(self, status_code=200, raw=None): - """Sets an expectation for a template and populate its response.""" - if raw is None: - raw = self.DEFAULT_TEMPLATE_RESPONSE - template_response = RequestsBackendMockAPITestCase.MockResponse(status_code, raw) - self.mock_request.side_effect = iter([ - template_response, - self.mock_request.return_value - ]) - @tag('mailjet') class MailjetBackendStandardEmailTests(MailjetBackendMockAPITestCase): @@ -70,12 +48,18 @@ class MailjetBackendStandardEmailTests(MailjetBackendMockAPITestCase): """Test basic API for simple send""" mail.send_mail('Subject here', 'Here is the message.', 'from@sender.example.com', ['to@example.com'], fail_silently=False) - self.assert_esp_called('/v3/send') + self.assert_esp_called('/v3.1/send') + + auth = self.get_api_call_auth() + self.assertEqual(auth, ('API KEY HERE', 'SECRET KEY HERE')) + data = self.get_api_call_json() - self.assertEqual(data['Subject'], "Subject here") - self.assertEqual(data['Text-part'], "Here is the message.") - self.assertEqual(data['FromEmail'], "from@sender.example.com") - self.assertEqual(data['To'], "to@example.com") + self.assertEqual(len(data['Messages']), 1) + message = data['Messages'][0] + self.assertEqual(data['Globals']['Subject'], "Subject here") + self.assertEqual(data['Globals']['TextPart'], "Here is the message.") + self.assertEqual(data['Globals']['From'], {"Email": "from@sender.example.com"}) + self.assertEqual(message['To'], [{"Email": "to@example.com"}]) def test_name_addr(self): """Make sure RFC2822 name-addr format (with display-name) is allowed @@ -84,39 +68,20 @@ class MailjetBackendStandardEmailTests(MailjetBackendMockAPITestCase): """ msg = mail.EmailMessage( 'Subject', 'Message', 'From Name ', - ['Recipient #1 ', 'to2@example.com'], + ['"Recipient, #1" ', 'to2@example.com'], cc=['Carbon Copy ', 'cc2@example.com'], bcc=['Blind Copy ', 'bcc2@example.com']) msg.send() data = self.get_api_call_json() - # See https://dev.mailjet.com/guides/#sending-a-basic-email - self.assertEqual(data['FromName'], 'From Name') - self.assertEqual(data['FromEmail'], 'from@example.com') - self.assertEqual(data['To'], 'Recipient #1 , to2@example.com') - self.assertEqual(data['Cc'], 'Carbon Copy , cc2@example.com') - self.assertEqual(data['Bcc'], 'Blind Copy , bcc2@example.com') - - def test_comma_in_display_name(self): - # Mailjet 3.0 API doesn't properly parse RFC-2822 quoted display-names from To/Cc/Bcc: - # `To: "Recipient, Ltd." ` tries to send messages to `"Recipient` - # and to `Ltd.` (neither of which are actual email addresses). - # As a workaround, force MIME "encoded-word" utf-8 encoding, which gets past Mailjet's broken parsing. - # (This shouldn't be necessary in Mailjet 3.1, where Name becomes a separate json field for Cc/Bcc.) - msg = mail.EmailMessage( - 'Subject', 'Message', '"Example, Inc." ', - ['"Recipient, Ltd." '], - cc=['"This is a very long display name, intended to test our workaround does not insert carriage returns' - ' or newlines into the encoded value, which would cause other problems" ') # this doesn't work - self.assertEqual(data['To'], '=?utf-8?q?Recipient=2C_Ltd=2E?= ') # workaround - self.assertEqual(data['Cc'], '=?utf-8?q?This_is_a_very_long_display_name=2C_intended_to_test_our_workaround' - '_does_not_insert_carriage_returns_or_newlines_into_the_encoded_value=2C_which' - '_would_cause_other_problems?= ') + self.assertEqual(len(data['Messages']), 1) + message = data['Messages'][0] + self.assertEqual(data['Globals']['From'], {"Email": "from@example.com", "Name": "From Name"}) + self.assertEqual(message['To'], [{"Email": "to1@example.com", "Name": "Recipient, #1"}, + {"Email": "to2@example.com"}]) + self.assertEqual(data['Globals']['Cc'], [{"Email": "cc1@example.com", "Name": "Carbon Copy"}, + {"Email": "cc2@example.com"}]) + self.assertEqual(data['Globals']['Bcc'], [{"Email": "bcc1@example.com", "Name": "Blind Copy"}, + {"Email": "bcc2@example.com"}]) def test_email_message(self): email = mail.EmailMessage( @@ -128,16 +93,20 @@ class MailjetBackendStandardEmailTests(MailjetBackendMockAPITestCase): 'X-MyHeader': 'my value'}) email.send() data = self.get_api_call_json() - self.assertEqual(data['Subject'], "Subject") - self.assertEqual(data['Text-part'], "Body goes here") - self.assertEqual(data['FromEmail'], "from@example.com") - self.assertEqual(data['To'], 'to1@example.com, Also To ') - self.assertEqual(data['Bcc'], 'bcc1@example.com, Also BCC ') - self.assertEqual(data['Cc'], 'cc1@example.com, Also CC ') - self.assertCountEqual(data['Headers'], { - 'Reply-To': 'another@example.com', - 'X-MyHeader': 'my value', - }) + self.assertEqual(len(data['Messages']), 1) + message = data['Messages'][0] + self.assertEqual(data['Globals']['Subject'], "Subject") + self.assertEqual(data['Globals']['TextPart'], "Body goes here") + self.assertEqual(data['Globals']['From'], {"Email": "from@example.com"}) + self.assertEqual(message['To'], [{"Email": "to1@example.com"}, + {"Email": "to2@example.com", "Name": "Also To"}]) + self.assertEqual(data['Globals']['Cc'], [{"Email": "cc1@example.com"}, + {"Email": "cc2@example.com", "Name": "Also CC"}]) + self.assertEqual(data['Globals']['Bcc'], [{"Email": "bcc1@example.com"}, + {"Email": "bcc2@example.com", "Name": "Also BCC"}]) + self.assertEqual(data['Globals']['Headers'], + {'X-MyHeader': 'my value'}) # Reply-To should be moved to own param + self.assertEqual(data['Globals']['ReplyTo'], {"Email": "another@example.com"}) def test_html_message(self): text_content = 'This is an important message.' @@ -146,26 +115,29 @@ class MailjetBackendStandardEmailTests(MailjetBackendMockAPITestCase): 'from@example.com', ['to@example.com']) email.attach_alternative(html_content, "text/html") email.send() + data = self.get_api_call_json() - self.assertEqual(data['Text-part'], text_content) - self.assertEqual(data['Html-part'], html_content) + self.assertEqual(len(data['Messages']), 1) + self.assertEqual(data['Globals']['TextPart'], text_content) + self.assertEqual(data['Globals']['HTMLPart'], html_content) # Don't accidentally send the html part as an attachment: - self.assertNotIn('Attachments', data) + self.assertNotIn('Attachments', data['Globals']) 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() + data = self.get_api_call_json() - self.assertNotIn('Text-part', data) - self.assertEqual(data['Html-part'], html_content) + self.assertNotIn('TextPart', data['Globals']) + self.assertEqual(data['Globals']['HTMLPart'], html_content) def test_extra_headers(self): self.message.extra_headers = {'X-Custom': 'string', 'X-Num': 123} self.message.send() data = self.get_api_call_json() - self.assertCountEqual(data['Headers'], { + self.assertEqual(data['Globals']['Headers'], { 'X-Custom': 'string', 'X-Num': 123, }) @@ -175,14 +147,15 @@ class MailjetBackendStandardEmailTests(MailjetBackendMockAPITestCase): with self.assertRaisesMessage(AnymailSerializationError, "Decimal"): self.message.send() + @override_settings(ANYMAIL_IGNORE_UNSUPPORTED_FEATURES=True) # Mailjet only allows single reply-to 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() data = self.get_api_call_json() - self.assertEqual(data['Headers'], { - 'Reply-To': 'reply@example.com, Other ', + self.assertEqual(data['Globals']['ReplyTo'], {"Email": "reply@example.com"}) # only the first reply_to + self.assertEqual(data['Globals']['Headers'], { 'X-Other': 'Keep' }) # don't lose other headers @@ -202,31 +175,33 @@ class MailjetBackendStandardEmailTests(MailjetBackendMockAPITestCase): self.message.send() data = self.get_api_call_json() - attachments = data['Attachments'] + attachments = data['Globals']['Attachments'] self.assertEqual(len(attachments), 3) self.assertEqual(attachments[0]["Filename"], "test.txt") - self.assertEqual(attachments[0]["Content-type"], "text/plain") - self.assertEqual(decode_att(attachments[0]["content"]).decode('ascii'), text_content) + self.assertEqual(attachments[0]["ContentType"], "text/plain") + self.assertEqual(decode_att(attachments[0]["Base64Content"]).decode('ascii'), text_content) self.assertNotIn('ContentID', attachments[0]) - self.assertEqual(attachments[1]["Content-type"], "image/png") # inferred from filename + self.assertEqual(attachments[1]["ContentType"], "image/png") # inferred from filename self.assertEqual(attachments[1]["Filename"], "test.png") - self.assertEqual(decode_att(attachments[1]["content"]), png_content) + self.assertEqual(decode_att(attachments[1]["Base64Content"]), png_content) self.assertNotIn('ContentID', attachments[1]) # make sure image not treated as inline - self.assertEqual(attachments[2]["Content-type"], "application/pdf") + self.assertEqual(attachments[2]["ContentType"], "application/pdf") self.assertEqual(attachments[2]["Filename"], "") # none - self.assertEqual(decode_att(attachments[2]["content"]), pdf_content) + self.assertEqual(decode_att(attachments[2]["Base64Content"]), pdf_content) self.assertNotIn('ContentID', attachments[2]) + self.assertNotIn('InlinedAttachments', data['Globals']) + def test_unicode_attachment_correctly_decoded(self): self.message.attach("Une pièce jointe.html", '

\u2019

', mimetype='text/html') self.message.send() data = self.get_api_call_json() - self.assertEqual(data['Attachments'], [{ + self.assertEqual(data['Globals']['Attachments'], [{ 'Filename': 'Une pièce jointe.html', - 'Content-type': 'text/html', - 'content': b64encode('

\u2019

'.encode('utf-8')).decode('ascii') + 'ContentType': 'text/html', + 'Base64Content': b64encode('

\u2019

'.encode('utf-8')).decode('ascii') }]) def test_embedded_images(self): @@ -240,13 +215,16 @@ class MailjetBackendStandardEmailTests(MailjetBackendMockAPITestCase): self.message.send() data = self.get_api_call_json() - self.assertEqual(data['Html-part'], html_content) + self.assertEqual(data['Globals']['HTMLPart'], html_content) - attachments = data['Inline_attachments'] + attachments = data['Globals']['InlinedAttachments'] self.assertEqual(len(attachments), 1) - self.assertEqual(attachments[0]['Filename'], cid) - self.assertEqual(attachments[0]['Content-type'], 'image/png') - self.assertEqual(decode_att(attachments[0]["content"]), image_data) + self.assertEqual(attachments[0]['Filename'], image_filename) + self.assertEqual(attachments[0]['ContentID'], cid) + self.assertEqual(attachments[0]['ContentType'], 'image/png') + self.assertEqual(decode_att(attachments[0]["Base64Content"]), image_data) + + self.assertNotIn('Attachments', data['Globals']) def test_attached_images(self): image_filename = SAMPLE_IMAGE_FILENAME @@ -262,16 +240,16 @@ class MailjetBackendStandardEmailTests(MailjetBackendMockAPITestCase): self.message.send() data = self.get_api_call_json() - self.assertEqual(data['Attachments'], [ + self.assertEqual(data['Globals']['Attachments'], [ { 'Filename': image_filename, # the named one - 'Content-type': 'image/png', - 'content': image_data_b64, + 'ContentType': 'image/png', + 'Base64Content': image_data_b64, }, { 'Filename': '', # the unnamed one - 'Content-type': 'image/png', - 'content': image_data_b64, + 'ContentType': 'image/png', + 'Base64Content': image_data_b64, }, ]) @@ -299,17 +277,16 @@ class MailjetBackendStandardEmailTests(MailjetBackendMockAPITestCase): """Empty to, cc, bcc, and reply_to shouldn't generate empty fields""" self.message.send() data = self.get_api_call_json() - self.assertNotIn('Cc', data) - self.assertNotIn('Bcc', data) - self.assertNotIn('ReplyTo', data) + self.assertNotIn('Cc', data['Globals']) + self.assertNotIn('Bcc', data['Globals']) + self.assertNotIn('ReplyTo', data['Globals']) - # Test empty `to` -- but send requires at least one recipient somewhere (like cc) + def test_empty_to_list(self): + # Mailjet v3.1 doesn't support cc-only or bcc-only messages self.message.to = [] self.message.cc = ['cc@example.com'] - self.message.send() - data = self.get_api_call_json() - self.assertNotIn('To', data) - self.assertEqual(data['Cc'], 'cc@example.com') + with self.assertRaisesMessage(AnymailUnsupportedFeature, "messages without any `to` recipients"): + self.message.send() def test_api_failure(self): self.set_mock_response(status_code=500) @@ -323,12 +300,14 @@ class MailjetBackendStandardEmailTests(MailjetBackendMockAPITestCase): def test_api_error_includes_details(self): """AnymailAPIError should include ESP's error message""" - # JSON error response: - error_response = b"""{ - "ErrorCode": 451, - "Message": "Helpful explanation from Mailjet." - }""" - self.set_mock_response(status_code=200, raw=error_response) + # JSON error response - global error: + error_response = json.dumps({ + "ErrorIdentifier": "06df1144-c6f3-4ca7-8885-7ec5d4344113", + "ErrorCode": "mj-0002", + "ErrorMessage": "Helpful explanation from Mailjet.", + "StatusCode": 400 + }).encode('utf-8') + self.set_mock_response(status_code=400, raw=error_response) with self.assertRaisesMessage(AnymailAPIError, "Helpful explanation from Mailjet"): self.message.send() @@ -342,15 +321,6 @@ class MailjetBackendStandardEmailTests(MailjetBackendMockAPITestCase): with self.assertRaises(AnymailAPIError): self.message.send() - def test_invalid_api_key(self): - """Anymail should add a helpful message for an invalid API key""" - # Mailjet just returns a 401 error -- without additional explanation -- - # for invalid keys. We want to provide users something more helpful - # than just "Mailjet API response 401: - self.set_mock_response(status_code=401, reason="Unauthorized", raw=None) - with self.assertRaisesMessage(AnymailAPIError, "Invalid Mailjet API key or secret"): - self.message.send() - @tag('mailjet') class MailjetBackendAnymailFeatureTests(MailjetBackendMockAPITestCase): @@ -360,7 +330,7 @@ class MailjetBackendAnymailFeatureTests(MailjetBackendMockAPITestCase): self.message.envelope_sender = "bounce-handler@bounces.example.com" self.message.send() data = self.get_api_call_json() - self.assertEqual(data['Sender'], "bounce-handler@bounces.example.com") + self.assertEqual(data['Globals']['Sender'], {"Email": "bounce-handler@bounces.example.com"}) def test_metadata(self): # Mailjet expects the payload to be a single string @@ -368,7 +338,7 @@ class MailjetBackendAnymailFeatureTests(MailjetBackendMockAPITestCase): self.message.metadata = {'user_id': "12345", 'items': 6} self.message.send() data = self.get_api_call_json() - self.assertJSONEqual(data['Mj-EventPayLoad'], {"user_id": "12345", "items": 6}) + self.assertJSONEqual(data['Globals']['EventPayload'], {"user_id": "12345", "items": 6}) def test_send_at(self): self.message.send_at = 1651820889 # 2022-05-06 07:08:09 UTC @@ -379,7 +349,7 @@ class MailjetBackendAnymailFeatureTests(MailjetBackendMockAPITestCase): self.message.tags = ["receipt"] self.message.send() data = self.get_api_call_json() - self.assertEqual(data['Mj-campaign'], "receipt") + self.assertEqual(data['Globals']['CustomCampaign'], "receipt") self.message.tags = ["receipt", "repeat-user"] with self.assertRaisesMessage(AnymailUnsupportedFeature, 'multiple tags'): @@ -389,19 +359,18 @@ class MailjetBackendAnymailFeatureTests(MailjetBackendMockAPITestCase): self.message.track_opens = True self.message.send() data = self.get_api_call_json() - self.assertEqual(data['Mj-trackopen'], 2) + self.assertEqual(data['Globals']['TrackOpens'], 'enabled') def test_track_clicks(self): self.message.track_clicks = True self.message.send() data = self.get_api_call_json() - self.assertEqual(data['Mj-trackclick'], 2) + self.assertEqual(data['Globals']['TrackClicks'], 'enabled') - # Also explicit "None" for False (to override server default) self.message.track_clicks = False self.message.send() data = self.get_api_call_json() - self.assertEqual(data['Mj-trackclick'], 1) + self.assertEqual(data['Globals']['TrackClicks'], 'disabled') def test_template(self): # template_id can be str or int (but must be numeric ID -- not the template's name) @@ -409,110 +378,39 @@ class MailjetBackendAnymailFeatureTests(MailjetBackendMockAPITestCase): self.message.merge_global_data = {'name': "Alice", 'group': "Developers"} self.message.send() data = self.get_api_call_json() - self.assertEqual(data['Mj-TemplateID'], '1234567') - self.assertEqual(data['Vars'], {'name': "Alice", 'group': "Developers"}) + self.assertEqual(data['Globals']['TemplateID'], 1234567) # must be integer + self.assertEqual(data['Globals']['TemplateLanguage'], True) # required to use variables + self.assertEqual(data['Globals']['Variables'], {'name': "Alice", 'group': "Developers"}) def test_template_populate_from_sender(self): - self.set_template_response() + # v3.1 API allows omitting From param to use template's sender self.message.template_id = '1234567' - self.message.from_email = None + self.message.from_email = None # must set to None after constructing EmailMessage self.message.send() data = self.get_api_call_json() - self.assertEqual(data['Mj-TemplateID'], '1234567') - self.assertEqual(data['FromName'], 'Friendly Tester') - self.assertEqual(data['FromEmail'], 'some@example.com') - - def test_template_populate_from(self): - # Note: Mailjet fails to properly quote the From field's display-name - # if the template sender name contains commas (as shown here): - template_response_content = b'''{ - "Count": 1, - "Data": [{ - "Text-part": "text body", - "Html-part": "html body", - "MJMLContent": "", - "Headers": { - "Subject": "Hello World!!", - "From": "Widgets, Inc. ", - "Reply-To": "" - } - }], - "Total": 1 - }''' - self.set_template_response(raw=template_response_content) - self.message.template_id = '1234568' - self.message.from_email = None - self.message.send() - data = self.get_api_call_json() - self.assertEqual(data['Mj-TemplateID'], '1234568') - self.assertEqual(data['FromName'], 'Widgets, Inc.') - self.assertEqual(data['FromEmail'], 'noreply@example.com') - - def test_template_not_found(self): - template_response_content = b'''{ - "ErrorInfo": "", - "ErrorMessage": "Object not found", - "StatusCode": 404 - }''' - self.set_template_response(status_code=404, raw=template_response_content) - self.message.template_id = '1234560' - self.message.from_email = None - with self.assertRaises(AnymailRequestsAPIError): - self.message.send() - - def test_template_unexpected_response(self): - # Missing headers (not sure if possible though). - template_response_content = b'''{ - "Count": 1, - "Data": [{ - "Text-part": "text body", - "Html-part": "html body", - "MJMLContent": "", - "Headers": { - } - }], - "Total": 1 - }''' - self.set_template_response(raw=template_response_content) - self.message.template_id = '1234561' - self.message.from_email = None - with self.assertRaisesMessage(AnymailRequestsAPIError, "template API"): - self.message.send() - - def test_template_invalid_response(self): - """Test scenario when MJ service returns no JSON for some reason.""" - template_response_content = b'''total garbage''' - self.set_template_response(raw=template_response_content) - self.message.template_id = '1234562' - self.message.from_email = None - with self.assertRaisesMessage(AnymailRequestsAPIError, "Invalid JSON"): - self.message.send() + self.assertNotIn('From', data['Globals']) # use template's sender as From def test_merge_data(self): self.message.to = ['alice@example.com', 'Bob '] - self.message.cc = ['cc@example.com'] - self.message.template_id = '1234567' self.message.merge_data = { 'alice@example.com': {'name': "Alice", 'group': "Developers"}, 'bob@example.com': {'name': "Bob"}, } - self.message.merge_global_data = {'group': "Users", 'site': "ExampleCo"} + self.message.merge_global_data = {'group': "Default Group", 'global': "Global value"} self.message.send() - data = self.get_api_call_json() messages = data['Messages'] - self.assertEqual(len(messages), 2) - self.assertEqual(messages[0]['To'], 'alice@example.com') - self.assertEqual(messages[0]['Cc'], 'cc@example.com') - self.assertEqual(messages[0]['Mj-TemplateID'], '1234567') - self.assertEqual(messages[0]['Vars'], - {'name': "Alice", 'group': "Developers", 'site': "ExampleCo"}) + self.assertEqual(len(messages), 2) # with merge_data, each 'to' gets separate message - self.assertEqual(messages[1]['To'], 'Bob ') - self.assertEqual(messages[1]['Cc'], 'cc@example.com') - self.assertEqual(messages[1]['Mj-TemplateID'], '1234567') - self.assertEqual(messages[1]['Vars'], - {'name': "Bob", 'group': "Users", 'site': "ExampleCo"}) + self.assertEqual(messages[0]['To'], [{"Email": "alice@example.com"}]) + self.assertEqual(messages[1]['To'], [{"Email": "bob@example.com", "Name": "Bob"}]) + + # global merge_data is sent in Globals + self.assertEqual(data['Globals']['Variables'], {'group': "Default Group", 'global': "Global value"}) + + # per-recipient merge_data is sent in Messages (and Mailjet will merge with Globals) + self.assertEqual(messages[0]['Variables'], {'name': "Alice", 'group': "Developers"}) + self.assertEqual(messages[1]['Variables'], {'name': "Bob"}) def test_merge_metadata(self): self.message.to = ['alice@example.com', 'Bob '] @@ -526,12 +424,12 @@ class MailjetBackendAnymailFeatureTests(MailjetBackendMockAPITestCase): data = self.get_api_call_json() messages = data['Messages'] self.assertEqual(len(messages), 2) - self.assertEqual(messages[0]['To'], 'alice@example.com') + self.assertEqual(messages[0]['To'][0]['Email'], "alice@example.com") # metadata and merge_metadata[recipient] are combined: - self.assertJSONEqual(messages[0]['Mj-EventPayLoad'], + self.assertJSONEqual(messages[0]['EventPayload'], {'order_id': 123, 'tier': 'premium', 'notification_batch': 'zx912'}) - self.assertEqual(messages[1]['To'], 'Bob ') - self.assertJSONEqual(messages[1]['Mj-EventPayLoad'], + self.assertEqual(messages[1]['To'][0]['Email'], "bob@example.com") + self.assertJSONEqual(messages[1]['EventPayload'], {'order_id': 678, 'notification_batch': 'zx912'}) def test_default_omits_options(self): @@ -543,35 +441,53 @@ class MailjetBackendAnymailFeatureTests(MailjetBackendMockAPITestCase): """ self.message.send() data = self.get_api_call_json() - self.assertNotIn('Mj-campaign', data) - self.assertNotIn('Mj-EventPayLoad', data) - self.assertNotIn('Mj-TemplateID', data) - self.assertNotIn('Vars', data) - self.assertNotIn('Mj-trackopen', data) - self.assertNotIn('Mj-trackclick', data) + self.assertNotIn('CustomCampaign', data["Globals"]) + self.assertNotIn('EventPayload', data["Globals"]) + self.assertNotIn('HTMLPart', data["Globals"]) + self.assertNotIn('TemplateID', data["Globals"]) + self.assertNotIn('TemplateLanguage', data["Globals"]) + self.assertNotIn('Variables', data["Globals"]) + self.assertNotIn('TrackOpens', data["Globals"]) + self.assertNotIn('TrackClicks', data["Globals"]) def test_esp_extra(self): + # Anymail deep merges Mailjet esp_extra into the v3.1 Send API payload. + # Most options you'd want to override are in Globals, though a few are + # at the root. Note that it's *not* possible to merge into Messages + # (though you could completely replace it). self.message.esp_extra = { - 'MJ-TemplateErrorDeliver': True, - 'MJ-TemplateErrorReporting': 'bugs@example.com' + 'Globals': { + 'TemplateErrorDeliver': True, + 'TemplateErrorReporting': 'bugs@example.com', + }, + 'SandboxMode': True, } self.message.send() data = self.get_api_call_json() - self.assertEqual(data['MJ-TemplateErrorDeliver'], True) - self.assertEqual(data['MJ-TemplateErrorReporting'], 'bugs@example.com') + self.assertEqual(data["Globals"]['TemplateErrorDeliver'], True) + self.assertEqual(data["Globals"]['TemplateErrorReporting'], 'bugs@example.com') + self.assertIs(data['SandboxMode'], True) + # Make sure the backend params are also still there + self.assertEqual(data["Globals"]['Subject'], "Subject") # noinspection PyUnresolvedReferences def test_send_attaches_anymail_status(self): """ The anymail_status should be attached to the message when it is sent """ - response_content = b"""{ - "Sent": [{ - "Email": "to1@example.com", - "MessageID": 12345678901234500 + response_content = json.dumps({ + "Messages": [{ + "Status": "success", + "To": [{ + "Email": "to1@example.com", + "MessageUUID": "cb927469-36fd-4c02-bce4-0d199929a207", + "MessageID": 12345678901234500, + "MessageHref": "https://api.mailjet.com/v3/message/12345678901234500" + }] }] - }""" + }).encode('utf-8') self.set_mock_response(raw=response_content) - msg = mail.EmailMessage('Subject', 'Message', 'from@example.com', ['to1@example.com'],) + msg = mail.EmailMessage('Subject', 'Message', 'from@example.com', ['to1@example.com']) sent = msg.send() + self.assertEqual(sent, 1) self.assertEqual(msg.anymail_status.status, {'sent'}) self.assertEqual(msg.anymail_status.message_id, "12345678901234500") @@ -580,34 +496,45 @@ class MailjetBackendAnymailFeatureTests(MailjetBackendMockAPITestCase): self.assertEqual(msg.anymail_status.esp_response.content, response_content) # noinspection PyUnresolvedReferences - def test_status_includes_all_recipients(self): + def test_mixed_status(self): """The status should include an entry for each recipient""" - # Note that Mailjet's response only communicates "Sent" status; not failed addresses. - # (This is an example response from before the workaround for commas in display-names...) - response_content = b"""{ - "Sent": [{ - "Email": "to1@example.com", - "MessageID": 12345678901234500 + # Mailjet's v3.1 API will partially fail a batch send, allowing valid emails to go out. + # The API response doesn't identify the failed email addresses; make sure we represent + # them correctly in the anymail_status. + response_content = json.dumps({ + "Messages": [{ + "Status": "success", + "CustomID": "", + "To": [{ + "Email": "to-good@example.com", + "MessageUUID": "556e896a-e041-4836-bb35-8bb75ee308c5", + "MessageID": 12345678901234500, + "MessageHref": "https://api.mailjet.com/v3/REST/message/12345678901234500" + }], + "Cc": [], + "Bcc": [] }, { - "Email": "\\"Recipient", - "MessageID": 12345678901234501 - }, { - "Email": "Also", - "MessageID": 12345678901234502 + "Errors": [{ + "ErrorIdentifier": "f480a5a2-0334-4e08-b2b7-f372ce5669e0", + "ErrorCode": "mj-0013", + "StatusCode": 400, + "ErrorMessage": "\"invalid@123.4\" is an invalid email address.", + "ErrorRelatedTo": ["To[0].Email"] + }], + "Status": "error" }] - }""" - self.set_mock_response(raw=response_content) - msg = mail.EmailMessage('Subject', 'Message', 'from@example.com', - ['to1@example.com', '"Recipient, Also" '],) + }).encode('utf-8') + self.set_mock_response(raw=response_content, status_code=400) # Mailjet uses 400 for partial success + msg = mail.EmailMessage('Subject', 'Message', 'from@example.com', ['to-good@example.com', 'invalid@123.4']) sent = msg.send() + self.assertEqual(sent, 1) - self.assertEqual(msg.anymail_status.status, {'sent', 'unknown'}) - self.assertEqual(msg.anymail_status.recipients['to1@example.com'].status, 'sent') - self.assertEqual(msg.anymail_status.recipients['to1@example.com'].message_id, "12345678901234500") - self.assertEqual(msg.anymail_status.recipients['to2@example.com'].status, 'unknown') # because, whoops - self.assertEqual(msg.anymail_status.recipients['to2@example.com'].message_id, None) - self.assertEqual(msg.anymail_status.message_id, - {"12345678901234500", "12345678901234501", "12345678901234502", None}) + self.assertEqual(msg.anymail_status.status, {'sent', 'failed'}) + self.assertEqual(msg.anymail_status.recipients['to-good@example.com'].status, 'sent') + self.assertEqual(msg.anymail_status.recipients['to-good@example.com'].message_id, "12345678901234500") + self.assertEqual(msg.anymail_status.recipients['invalid@123.4'].status, 'failed') + self.assertEqual(msg.anymail_status.recipients['invalid@123.4'].message_id, None) + self.assertEqual(msg.anymail_status.message_id, {"12345678901234500", None}) self.assertEqual(msg.anymail_status.esp_response.content, response_content) # noinspection PyUnresolvedReferences diff --git a/tests/test_mailjet_integration.py b/tests/test_mailjet_integration.py index dc7c806..b55f900 100644 --- a/tests/test_mailjet_integration.py +++ b/tests/test_mailjet_integration.py @@ -16,8 +16,10 @@ MAILJET_TEST_SECRET_KEY = os.getenv('MAILJET_TEST_SECRET_KEY') @unittest.skipUnless(MAILJET_TEST_API_KEY and MAILJET_TEST_SECRET_KEY, "Set MAILJET_TEST_API_KEY and MAILJET_TEST_SECRET_KEY " "environment variables to run Mailjet integration tests") -@override_settings(ANYMAIL_MAILJET_API_KEY=MAILJET_TEST_API_KEY, - ANYMAIL_MAILJET_SECRET_KEY=MAILJET_TEST_SECRET_KEY, +@override_settings(ANYMAIL={"MAILJET_API_KEY": MAILJET_TEST_API_KEY, + "MAILJET_SECRET_KEY": MAILJET_TEST_SECRET_KEY, + "MAILJET_SEND_DEFAULTS": {"esp_extra": {"SandboxMode": True}}, # don't actually send mail + }, EMAIL_BACKEND="anymail.backends.mailjet.EmailBackend") class MailjetBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): """Mailjet API integration tests @@ -27,12 +29,11 @@ class MailjetBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): as the API key and API secret key, respectively. If those variables are not set, these tests won't run. - Mailjet doesn't (in v3.0) offer a test/sandbox mode -- it tries to send everything - you ask. + These tests enable Mailjet's SandboxMode to avoid sending any email; + remove the esp_extra setting above if you are trying to actually send test messages. Mailjet also doesn't support unverified senders (so no from@example.com). We've set up @test-mj.anymail.info as a validated sending domain for these tests. - """ def setUp(self): @@ -63,7 +64,7 @@ class MailjetBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): to=['test+to1@anymail.info', '"Recipient, 2nd" '], cc=['test+cc1@anymail.info', 'Copy 2 '], bcc=['test+bcc1@anymail.info', 'Blind Copy 2 '], - reply_to=['reply1@example.com', '"Reply, 2nd" '], + reply_to=['"Reply, To" '], # Mailjet only supports single reply_to headers={"X-Anymail-Test": "value"}, metadata={"meta1": "simple string", "meta2": 2}, @@ -120,10 +121,11 @@ class MailjetBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): recipient_status = message.anymail_status.recipients self.assertEqual(recipient_status['test+to1@anymail.info'].status, 'sent') - @override_settings(ANYMAIL_MAILJET_API_KEY="Hey, that's not an API key!") + @override_settings(ANYMAIL={"MAILJET_API_KEY": "Hey, that's not an API key!", + "MAILJET_SECRET_KEY": "and this isn't the secret for it"}) def test_invalid_api_key(self): with self.assertRaises(AnymailAPIError) as cm: self.message.send() err = cm.exception self.assertEqual(err.status_code, 401) - self.assertIn("Invalid Mailjet API key or secret", str(err)) + self.assertIn("API key authentication/authorization failure", str(err))