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
This commit is contained in:
Mike Edmunds
2020-09-08 14:50:26 -07:00
committed by GitHub
parent cca653fcba
commit bc1156149a
5 changed files with 397 additions and 483 deletions

View File

@@ -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 <https://anymail.readthedocs.io/en/latest/esps/mailjet/#esp-extra-support>`__.)
* 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

View File

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

View File

@@ -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 <mailjet-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 <mailjet-esp-extra>` feature to set API-specific options or if you are
trying to send messages with :ref:`multiple reply-to addresses <mailjet-quirks>`.
.. _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 <mailjet-v31-api>`.)
(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 <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 <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 <mailjet-v31-api>` 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 <esp-stored-templates>`
and :ref:`batch sending <batch-send>` 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

View File

@@ -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 <from@example.com>',
['Recipient #1 <to1@example.com>', 'to2@example.com'],
['"Recipient, #1" <to1@example.com>', 'to2@example.com'],
cc=['Carbon Copy <cc1@example.com>', 'cc2@example.com'],
bcc=['Blind Copy <bcc1@example.com>', '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 <to1@example.com>, to2@example.com')
self.assertEqual(data['Cc'], 'Carbon Copy <cc1@example.com>, cc2@example.com')
self.assertEqual(data['Bcc'], 'Blind Copy <bcc1@example.com>, 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." <to@example.com>` 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." <from@example.com>',
['"Recipient, Ltd." <to@example.com>'],
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" <long@example.com']
)
msg.send()
data = self.get_api_call_json()
self.assertEqual(data['FromName'], 'Example, Inc.')
self.assertEqual(data['FromEmail'], 'from@example.com')
# self.assertEqual(data['To'], '"Recipient, Ltd." <to@example.com>') # this doesn't work
self.assertEqual(data['To'], '=?utf-8?q?Recipient=2C_Ltd=2E?= <to@example.com>') # 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?= <long@example.com>')
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 <to2@example.com>')
self.assertEqual(data['Bcc'], 'bcc1@example.com, Also BCC <bcc2@example.com>')
self.assertEqual(data['Cc'], 'cc1@example.com, Also CC <cc2@example.com>')
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 = '<p>This is an <strong>important</strong> message.</p>'
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 <reply2@example.com>'],
headers={'X-Other': 'Keep'})
email.send()
data = self.get_api_call_json()
self.assertEqual(data['Headers'], {
'Reply-To': 'reply@example.com, Other <reply2@example.com>',
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", '<p>\u2019</p>', 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('<p>\u2019</p>'.encode('utf-8')).decode('ascii')
'ContentType': 'text/html',
'Base64Content': b64encode('<p>\u2019</p>'.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. <noreply@example.com>",
"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 <bob@example.com>']
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 <bob@example.com>')
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 <bob@example.com>']
@@ -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 <bob@example.com>')
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" <to2@example.com>'],)
}).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

View File

@@ -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" <test+to2@anymail.info>'],
cc=['test+cc1@anymail.info', 'Copy 2 <test+cc1@anymail.info>'],
bcc=['test+bcc1@anymail.info', 'Blind Copy 2 <test+bcc2@anymail.info>'],
reply_to=['reply1@example.com', '"Reply, 2nd" <reply2@example.com>'],
reply_to=['"Reply, To" <reply2@example.com>'], # 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))