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`_ (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`.) 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 * 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 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 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 .base_requests import AnymailRequestsBackend, RequestsPayload
from ..exceptions import AnymailRequestsAPIError from ..exceptions import AnymailRequestsAPIError
from ..message import ANYMAIL_STATUSES, AnymailRecipientStatus from ..message import AnymailRecipientStatus
from ..utils import EmailAddress, get_anymail_setting, parse_address_list from ..utils import get_anymail_setting, update_deep
class EmailBackend(AnymailRequestsBackend): 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.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) 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, 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("/"): if not api_url.endswith("/"):
api_url += "/" api_url += "/"
super().__init__(api_url, **kwargs) super().__init__(api_url, **kwargs)
@@ -29,46 +26,39 @@ class EmailBackend(AnymailRequestsBackend):
return MailjetPayload(message, defaults, self) return MailjetPayload(message, defaults, self)
def raise_for_status(self, response, payload, message): def raise_for_status(self, response, payload, message):
# Improve Mailjet's (lack of) error message for bad API key if 400 <= response.status_code <= 499:
if response.status_code == 401 and not response.content: # Mailjet uses 4xx status codes for partial failure in batch send;
raise AnymailRequestsAPIError( # we'll determine how to handle below in parse_recipient_status.
"Invalid Mailjet API key or secret", return
email_message=message, payload=payload, response=response, backend=self)
super().raise_for_status(response, payload, message) super().raise_for_status(response, payload, message)
def parse_recipient_status(self, 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) parsed_response = self.deserialize_json_response(response, payload, message)
# Global error? (no messages sent)
if "ErrorCode" in parsed_response: if "ErrorCode" in parsed_response:
raise AnymailRequestsAPIError(email_message=message, payload=payload, response=response, raise AnymailRequestsAPIError(email_message=message, payload=payload, response=response, backend=self)
backend=self)
recipient_status = {} recipient_status = {}
try: try:
for key in parsed_response: for result in parsed_response["Messages"]:
status = key.lower() status = 'sent' if result["Status"] == 'success' else 'failed' # Status is 'success' or 'error'
if status not in ANYMAIL_STATUSES: recipients = result.get("To", []) + result.get("Cc", []) + result.get("Bcc", [])
status = 'unknown' for recipient in recipients:
email = recipient['Email']
for item in parsed_response[key]: message_id = str(recipient['MessageID']) # MessageUUID isn't yet useful for other Mailjet APIs
message_id = str(item['MessageID'])
email = item['Email']
recipient_status[email] = AnymailRecipientStatus(message_id=message_id, status=status) 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: except (KeyError, TypeError) as err:
raise AnymailRequestsAPIError("Invalid Mailjet API response format", raise AnymailRequestsAPIError("Invalid Mailjet API response format",
email_message=message, payload=payload, response=response, email_message=message, payload=payload, response=response,
backend=self) from err backend=self) from err
# Make sure we ended up with a status for every original recipient
# (Mailjet only communicates "Sent") # Any recipient who wasn't reported as a 'success' must have been an error:
for recipients in payload.recipients.values(): for email in payload.recipients:
for email in recipients: if email.addr_spec not in recipient_status:
if email.addr_spec not in recipient_status: recipient_status[email.addr_spec] = AnymailRecipientStatus(message_id=None, status='failed')
recipient_status[email.addr_spec] = AnymailRecipientStatus(message_id=None, status='unknown')
return recipient_status return recipient_status
@@ -81,188 +71,161 @@ class MailjetPayload(RequestsPayload):
http_headers = { http_headers = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
} }
# Late binding of recipients and their variables self.recipients = [] # for backend parse_recipient_status
self.recipients = {'to': []}
self.metadata = None self.metadata = None
self.merge_data = {}
self.merge_metadata = {}
super().__init__(message, defaults, backend, auth=auth, headers=http_headers, *args, **kwargs) super().__init__(message, defaults, backend, auth=auth, headers=http_headers, *args, **kwargs)
def get_api_endpoint(self): def get_api_endpoint(self):
return "send" return "send"
def serialize_data(self): 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) 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 # Payload construction
# #
def init_payload(self): 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): def set_from_email(self, email):
self.data["FromEmail"] = email.addr_spec self.data["Globals"]["From"] = self._mailjet_email(email)
if email.display_name:
self.data["FromName"] = email.display_name
def set_recipients(self, recipient_type, emails): def set_to(self, emails):
assert recipient_type in ["to", "cc", "bcc"] # "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: if emails:
self.recipients[recipient_type] = emails # save for recipient_status processing self.data["Messages"].append({
self.data[recipient_type.capitalize()] = ", ".join( "To": [self._mailjet_email(email) for email in emails]
[self._format_email_for_mailjet(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): def set_subject(self, subject):
self.data["Subject"] = subject self.data["Globals"]["Subject"] = subject
def set_reply_to(self, emails): def set_reply_to(self, emails):
headers = self.data.setdefault("Headers", {}) if len(emails) > 0:
if emails: self.data["Globals"]["ReplyTo"] = self._mailjet_email(emails[0])
headers["Reply-To"] = ", ".join([str(email) for email in emails]) if len(emails) > 1:
elif "Reply-To" in headers: self.unsupported_feature("Multiple reply_to addresses")
del headers["Reply-To"]
def set_extra_headers(self, headers): def set_extra_headers(self, headers):
self.data.setdefault("Headers", {}).update(headers) self.data["Globals"]["Headers"] = headers
def set_text_body(self, body): 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): def set_html_body(self, body):
if "Html-part" in self.data: if body is not None:
# second html body could show up through multiple alternatives, or html body + alternative if "HTMLPart" in self.data["Globals"]:
self.unsupported_feature("multiple html parts") # 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): def add_attachment(self, attachment):
att = {
"ContentType": attachment.mimetype,
"Filename": attachment.name or "",
"Base64Content": attachment.b64content,
}
if attachment.inline: if attachment.inline:
field = "Inline_attachments" field = "InlinedAttachments"
name = attachment.cid att["ContentID"] = attachment.cid
else: else:
field = "Attachments" field = "Attachments"
name = attachment.name or "" self.data["Globals"].setdefault(field, []).append(att)
self.data.setdefault(field, []).append({
"Content-type": attachment.mimetype,
"Filename": name,
"content": attachment.b64content
})
def set_envelope_sender(self, email): 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): 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 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): def set_tags(self, tags):
# The choices here are CustomID or Campaign, and Campaign seems closer # 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 # to how "tags" are handled by other ESPs -- e.g., you can view dashboard
# statistics across all messages with the same Campaign. # statistics across all messages with the same Campaign.
if len(tags) > 0: if len(tags) > 0:
self.data["Tag"] = tags[0] self.data["Globals"]["CustomCampaign"] = tags[0]
self.data["Mj-campaign"] = tags[0]
if len(tags) > 1: if len(tags) > 1:
self.unsupported_feature('multiple tags (%r)' % tags) self.unsupported_feature('multiple tags (%r)' % tags)
def set_track_clicks(self, track_clicks): def set_track_clicks(self, track_clicks):
# 1 disables tracking, 2 enables tracking self.data["Globals"]["TrackClicks"] = "enabled" if track_clicks else "disabled"
self.data["Mj-trackclick"] = 2 if track_clicks else 1
def set_track_opens(self, track_opens): def set_track_opens(self, track_opens):
# 1 disables tracking, 2 enables tracking self.data["Globals"]["TrackOpens"] = "enabled" if track_opens else "disabled"
self.data["Mj-trackopen"] = 2 if track_opens else 1
def set_template_id(self, template_id): def set_template_id(self, template_id):
self.data["Mj-TemplateID"] = template_id self.data["Globals"]["TemplateID"] = int(template_id) # Mailjet requires integer (not string)
self.data["Mj-TemplateLanguage"] = True self.data["Globals"]["TemplateLanguage"] = True
def set_merge_data(self, merge_data): def set_merge_data(self, merge_data):
# Will be handled later in serialize_data self._burst_for_batch_send()
self.merge_data = merge_data 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): def set_merge_global_data(self, merge_global_data):
self.data["Vars"] = merge_global_data self.data["Globals"]["Variables"] = merge_global_data
def set_merge_metadata(self, merge_metadata):
# Will be handled later in serialize_data
self.merge_metadata = merge_metadata
def set_esp_extra(self, extra): def set_esp_extra(self, extra):
self.data.update(extra) update_deep(self.data, extra)

View File

@@ -3,23 +3,19 @@
Mailjet 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:: 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
Mailjet has released a newer `v3.1 Send API`_, but due to mismatches between its :ref:`esp_extra <mailjet-esp-extra>` feature to set API-specific options or if you are
documentation and actual behavior, Anymail has been unable to switch to it. trying to send messages with :ref:`multiple reply-to addresses <mailjet-quirks>`.
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.
.. _Mailjet: https://www.mailjet.com/ .. _Mailjet: https://www.mailjet.com/
.. _Send API: https://dev.mailjet.com/guides/#choose-sending-method .. _Send API v3.1: https://dev.mailjet.com/guides/#send-api-v3-1
.. _v3.1 Send API: https://dev.mailjet.com/guides/#send-api-v3-1-beta .. _Send API v3: https://dev.mailjet.com/guides/#send-api-v3
Settings Settings
@@ -68,9 +64,8 @@ nor ``ANYMAIL_MAILJET_API_KEY`` is set.
The base url for calling the Mailjet API. The base url for calling the Mailjet API.
The default is ``MAILJET_API_URL = "https://api.mailjet.com/v3"`` The default is ``MAILJET_API_URL = "https://api.mailjet.com/v3.1/"``
(It's unlikely you would need to change this. This setting cannot be used (It's unlikely you would need to change this.)
to opt into a newer API version; the parameters are not backwards compatible.)
.. _mailjet-esp-extra: .. _mailjet-esp-extra:
@@ -80,39 +75,50 @@ esp_extra support
To use Mailjet features not directly supported by Anymail, you can To use Mailjet features not directly supported by Anymail, you can
set a message's :attr:`~anymail.message.AnymailMessage.esp_extra` to set a message's :attr:`~anymail.message.AnymailMessage.esp_extra` to
a `dict` of Mailjet's `Send API json properties`_. a `dict` of Mailjet's `Send API body parameters`_.
Your :attr:`esp_extra` dict will be merged into the Your :attr:`esp_extra` dict will be deeply merged into the Mailjet
parameters Anymail has constructed for the send, with `esp_extra` API payload, with `esp_extra` having precedence in conflicts.
having precedence in conflicts.
.. note:: (Note that it's *not* possible to merge into the ``"Messages"`` key;
any value you supply would override ``"Messages"`` completely. Use ``"Globals"``
Any ``esp_extra`` settings will need to be updated when Anymail changes for options to apply to all messages.)
to use Mailjet's upcoming v3.1 API. (See :ref:`note above <mailjet-v31-api>`.)
Example: Example:
.. code-block:: python .. code-block:: python
message.esp_extra = { message.esp_extra = {
# Mailjet v3.0 Send API options: # Most "Messages" options can be included under Globals:
"Mj-prio": 3, # Use Mailjet critically-high priority queue "Globals": {
"Mj-CustomID": my_event_tracking_id, "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 (You can also set `"esp_extra"` in Anymail's :ref:`global send defaults <send-defaults>`
:ref:`global send defaults <send-defaults>` to apply it to all to apply it to all messages.)
messages.)
.. _Send API body parameters:
https://dev.mailjet.com/email/reference/send-emails#v3_1_post_send
.. _Send API json properties: https://dev.mailjet.com/guides/#send-api-json-properties
.. _mailjet-quirks:
Limitations and 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** **Single tag**
Anymail uses Mailjet's `campaign`_ option for tags, and Mailjet allows Anymail uses Mailjet's `campaign`_ option for tags, and Mailjet allows
only a single campaign per message. If your message has two or more 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 Mailjet, but this may result in an API error if you have not received
special approval from Mailjet support to use custom senders. special approval from Mailjet support to use custom senders.
**Commas in recipient names** **message_id is MessageID (not MessageUUID)**
Mailjet's v3 API does not properly handle commas in recipient display-names. Mailjet's Send API v3.1 returns both a "legacy" MessageID and a newer
(Tested July, 2017, and confirmed with Mailjet API support.) 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 **Older limitations**
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
.. versionchanged:: 6.0 .. versionchanged:: 6.0
Earlier versions of Anymail were unable to mix ``cc`` or ``bcc`` fields Earlier versions of Anymail were unable to mix ``cc`` or ``bcc`` fields
and :attr:`~anymail.message.AnymailMessage.merge_data` in the same Mailjet message. and :attr:`~anymail.message.AnymailMessage.merge_data` in the same Mailjet message.
This limitation was removed in Anymail 6.0. 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: .. _mailjet-templates:
@@ -162,6 +167,17 @@ Batch sending/merge and ESP templates
Mailjet offers both :ref:`ESP stored templates <esp-stored-templates>` Mailjet offers both :ref:`ESP stored templates <esp-stored-templates>`
and :ref:`batch sending <batch-send>` with per-recipient merge data. 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 You can use a Mailjet stored transactional template by setting a message's
:attr:`~anymail.message.AnymailMessage.template_id` to the :attr:`~anymail.message.AnymailMessage.template_id` to the
template's *numeric* template ID. (*Not* the template's name. To get 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 base64 import b64encode
from decimal import Decimal from decimal import Decimal
from email.mime.base import MIMEBase from email.mime.base import MIMEBase
@@ -7,9 +8,7 @@ from django.core import mail
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from django.test import SimpleTestCase, override_settings, tag from django.test import SimpleTestCase, override_settings, tag
from anymail.exceptions import (AnymailAPIError, AnymailSerializationError, from anymail.exceptions import AnymailAPIError, AnymailSerializationError, AnymailUnsupportedFeature
AnymailUnsupportedFeature,
AnymailRequestsAPIError)
from anymail.message import attach_inline_image_file from anymail.message import attach_inline_image_file
from .mock_requests_backend import RequestsBackendMockAPITestCase, SessionSharingTestCases 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') @tag('mailjet')
@override_settings(EMAIL_BACKEND='anymail.backends.mailjet.EmailBackend', @override_settings(EMAIL_BACKEND='anymail.backends.mailjet.EmailBackend',
ANYMAIL={ ANYMAIL={
'MAILJET_API_KEY': '', 'MAILJET_API_KEY': 'API KEY HERE',
'MAILJET_SECRET_KEY': '' 'MAILJET_SECRET_KEY': 'SECRET KEY HERE'
}) })
class MailjetBackendMockAPITestCase(RequestsBackendMockAPITestCase): class MailjetBackendMockAPITestCase(RequestsBackendMockAPITestCase):
DEFAULT_RAW_RESPONSE = b"""{ DEFAULT_RAW_RESPONSE = b"""{
"Sent": [{ "Messages": [{
"Email": "to@example.com", "Status": "success",
"MessageID": 12345678901234567 "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): def setUp(self):
super().setUp() super().setUp()
# Simple message useful for many tests # Simple message useful for many tests
self.message = mail.EmailMultiAlternatives('Subject', 'Text Body', 'from@example.com', ['to@example.com']) 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') @tag('mailjet')
class MailjetBackendStandardEmailTests(MailjetBackendMockAPITestCase): class MailjetBackendStandardEmailTests(MailjetBackendMockAPITestCase):
@@ -70,12 +48,18 @@ class MailjetBackendStandardEmailTests(MailjetBackendMockAPITestCase):
"""Test basic API for simple send""" """Test basic API for simple send"""
mail.send_mail('Subject here', 'Here is the message.', mail.send_mail('Subject here', 'Here is the message.',
'from@sender.example.com', ['to@example.com'], fail_silently=False) '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() data = self.get_api_call_json()
self.assertEqual(data['Subject'], "Subject here") self.assertEqual(len(data['Messages']), 1)
self.assertEqual(data['Text-part'], "Here is the message.") message = data['Messages'][0]
self.assertEqual(data['FromEmail'], "from@sender.example.com") self.assertEqual(data['Globals']['Subject'], "Subject here")
self.assertEqual(data['To'], "to@example.com") 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): def test_name_addr(self):
"""Make sure RFC2822 name-addr format (with display-name) is allowed """Make sure RFC2822 name-addr format (with display-name) is allowed
@@ -84,39 +68,20 @@ class MailjetBackendStandardEmailTests(MailjetBackendMockAPITestCase):
""" """
msg = mail.EmailMessage( msg = mail.EmailMessage(
'Subject', 'Message', 'From Name <from@example.com>', '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'], cc=['Carbon Copy <cc1@example.com>', 'cc2@example.com'],
bcc=['Blind Copy <bcc1@example.com>', 'bcc2@example.com']) bcc=['Blind Copy <bcc1@example.com>', 'bcc2@example.com'])
msg.send() msg.send()
data = self.get_api_call_json() data = self.get_api_call_json()
# See https://dev.mailjet.com/guides/#sending-a-basic-email self.assertEqual(len(data['Messages']), 1)
self.assertEqual(data['FromName'], 'From Name') message = data['Messages'][0]
self.assertEqual(data['FromEmail'], 'from@example.com') self.assertEqual(data['Globals']['From'], {"Email": "from@example.com", "Name": "From Name"})
self.assertEqual(data['To'], 'Recipient #1 <to1@example.com>, to2@example.com') self.assertEqual(message['To'], [{"Email": "to1@example.com", "Name": "Recipient, #1"},
self.assertEqual(data['Cc'], 'Carbon Copy <cc1@example.com>, cc2@example.com') {"Email": "to2@example.com"}])
self.assertEqual(data['Bcc'], 'Blind Copy <bcc1@example.com>, bcc2@example.com') self.assertEqual(data['Globals']['Cc'], [{"Email": "cc1@example.com", "Name": "Carbon Copy"},
{"Email": "cc2@example.com"}])
def test_comma_in_display_name(self): self.assertEqual(data['Globals']['Bcc'], [{"Email": "bcc1@example.com", "Name": "Blind Copy"},
# Mailjet 3.0 API doesn't properly parse RFC-2822 quoted display-names from To/Cc/Bcc: {"Email": "bcc2@example.com"}])
# `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>')
def test_email_message(self): def test_email_message(self):
email = mail.EmailMessage( email = mail.EmailMessage(
@@ -128,16 +93,20 @@ class MailjetBackendStandardEmailTests(MailjetBackendMockAPITestCase):
'X-MyHeader': 'my value'}) 'X-MyHeader': 'my value'})
email.send() email.send()
data = self.get_api_call_json() data = self.get_api_call_json()
self.assertEqual(data['Subject'], "Subject") self.assertEqual(len(data['Messages']), 1)
self.assertEqual(data['Text-part'], "Body goes here") message = data['Messages'][0]
self.assertEqual(data['FromEmail'], "from@example.com") self.assertEqual(data['Globals']['Subject'], "Subject")
self.assertEqual(data['To'], 'to1@example.com, Also To <to2@example.com>') self.assertEqual(data['Globals']['TextPart'], "Body goes here")
self.assertEqual(data['Bcc'], 'bcc1@example.com, Also BCC <bcc2@example.com>') self.assertEqual(data['Globals']['From'], {"Email": "from@example.com"})
self.assertEqual(data['Cc'], 'cc1@example.com, Also CC <cc2@example.com>') self.assertEqual(message['To'], [{"Email": "to1@example.com"},
self.assertCountEqual(data['Headers'], { {"Email": "to2@example.com", "Name": "Also To"}])
'Reply-To': 'another@example.com', self.assertEqual(data['Globals']['Cc'], [{"Email": "cc1@example.com"},
'X-MyHeader': 'my value', {"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): def test_html_message(self):
text_content = 'This is an important message.' text_content = 'This is an important message.'
@@ -146,26 +115,29 @@ class MailjetBackendStandardEmailTests(MailjetBackendMockAPITestCase):
'from@example.com', ['to@example.com']) 'from@example.com', ['to@example.com'])
email.attach_alternative(html_content, "text/html") email.attach_alternative(html_content, "text/html")
email.send() email.send()
data = self.get_api_call_json() data = self.get_api_call_json()
self.assertEqual(data['Text-part'], text_content) self.assertEqual(len(data['Messages']), 1)
self.assertEqual(data['Html-part'], html_content) self.assertEqual(data['Globals']['TextPart'], text_content)
self.assertEqual(data['Globals']['HTMLPart'], html_content)
# Don't accidentally send the html part as an attachment: # 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): def test_html_only_message(self):
html_content = '<p>This is an <strong>important</strong> message.</p>' html_content = '<p>This is an <strong>important</strong> message.</p>'
email = mail.EmailMessage('Subject', html_content, 'from@example.com', ['to@example.com']) email = mail.EmailMessage('Subject', html_content, 'from@example.com', ['to@example.com'])
email.content_subtype = "html" # Main content is now text/html email.content_subtype = "html" # Main content is now text/html
email.send() email.send()
data = self.get_api_call_json() data = self.get_api_call_json()
self.assertNotIn('Text-part', data) self.assertNotIn('TextPart', data['Globals'])
self.assertEqual(data['Html-part'], html_content) self.assertEqual(data['Globals']['HTMLPart'], html_content)
def test_extra_headers(self): def test_extra_headers(self):
self.message.extra_headers = {'X-Custom': 'string', 'X-Num': 123} self.message.extra_headers = {'X-Custom': 'string', 'X-Num': 123}
self.message.send() self.message.send()
data = self.get_api_call_json() data = self.get_api_call_json()
self.assertCountEqual(data['Headers'], { self.assertEqual(data['Globals']['Headers'], {
'X-Custom': 'string', 'X-Custom': 'string',
'X-Num': 123, 'X-Num': 123,
}) })
@@ -175,14 +147,15 @@ class MailjetBackendStandardEmailTests(MailjetBackendMockAPITestCase):
with self.assertRaisesMessage(AnymailSerializationError, "Decimal"): with self.assertRaisesMessage(AnymailSerializationError, "Decimal"):
self.message.send() self.message.send()
@override_settings(ANYMAIL_IGNORE_UNSUPPORTED_FEATURES=True) # Mailjet only allows single reply-to
def test_reply_to(self): def test_reply_to(self):
email = mail.EmailMessage('Subject', 'Body goes here', 'from@example.com', ['to1@example.com'], email = mail.EmailMessage('Subject', 'Body goes here', 'from@example.com', ['to1@example.com'],
reply_to=['reply@example.com', 'Other <reply2@example.com>'], reply_to=['reply@example.com', 'Other <reply2@example.com>'],
headers={'X-Other': 'Keep'}) headers={'X-Other': 'Keep'})
email.send() email.send()
data = self.get_api_call_json() data = self.get_api_call_json()
self.assertEqual(data['Headers'], { self.assertEqual(data['Globals']['ReplyTo'], {"Email": "reply@example.com"}) # only the first reply_to
'Reply-To': 'reply@example.com, Other <reply2@example.com>', self.assertEqual(data['Globals']['Headers'], {
'X-Other': 'Keep' 'X-Other': 'Keep'
}) # don't lose other headers }) # don't lose other headers
@@ -202,31 +175,33 @@ class MailjetBackendStandardEmailTests(MailjetBackendMockAPITestCase):
self.message.send() self.message.send()
data = self.get_api_call_json() data = self.get_api_call_json()
attachments = data['Attachments'] attachments = data['Globals']['Attachments']
self.assertEqual(len(attachments), 3) self.assertEqual(len(attachments), 3)
self.assertEqual(attachments[0]["Filename"], "test.txt") self.assertEqual(attachments[0]["Filename"], "test.txt")
self.assertEqual(attachments[0]["Content-type"], "text/plain") self.assertEqual(attachments[0]["ContentType"], "text/plain")
self.assertEqual(decode_att(attachments[0]["content"]).decode('ascii'), text_content) self.assertEqual(decode_att(attachments[0]["Base64Content"]).decode('ascii'), text_content)
self.assertNotIn('ContentID', attachments[0]) 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(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.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(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('ContentID', attachments[2])
self.assertNotIn('InlinedAttachments', data['Globals'])
def test_unicode_attachment_correctly_decoded(self): def test_unicode_attachment_correctly_decoded(self):
self.message.attach("Une pièce jointe.html", '<p>\u2019</p>', mimetype='text/html') self.message.attach("Une pièce jointe.html", '<p>\u2019</p>', mimetype='text/html')
self.message.send() self.message.send()
data = self.get_api_call_json() data = self.get_api_call_json()
self.assertEqual(data['Attachments'], [{ self.assertEqual(data['Globals']['Attachments'], [{
'Filename': 'Une pièce jointe.html', 'Filename': 'Une pièce jointe.html',
'Content-type': 'text/html', 'ContentType': 'text/html',
'content': b64encode('<p>\u2019</p>'.encode('utf-8')).decode('ascii') 'Base64Content': b64encode('<p>\u2019</p>'.encode('utf-8')).decode('ascii')
}]) }])
def test_embedded_images(self): def test_embedded_images(self):
@@ -240,13 +215,16 @@ class MailjetBackendStandardEmailTests(MailjetBackendMockAPITestCase):
self.message.send() self.message.send()
data = self.get_api_call_json() 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(len(attachments), 1)
self.assertEqual(attachments[0]['Filename'], cid) self.assertEqual(attachments[0]['Filename'], image_filename)
self.assertEqual(attachments[0]['Content-type'], 'image/png') self.assertEqual(attachments[0]['ContentID'], cid)
self.assertEqual(decode_att(attachments[0]["content"]), image_data) 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): def test_attached_images(self):
image_filename = SAMPLE_IMAGE_FILENAME image_filename = SAMPLE_IMAGE_FILENAME
@@ -262,16 +240,16 @@ class MailjetBackendStandardEmailTests(MailjetBackendMockAPITestCase):
self.message.send() self.message.send()
data = self.get_api_call_json() data = self.get_api_call_json()
self.assertEqual(data['Attachments'], [ self.assertEqual(data['Globals']['Attachments'], [
{ {
'Filename': image_filename, # the named one 'Filename': image_filename, # the named one
'Content-type': 'image/png', 'ContentType': 'image/png',
'content': image_data_b64, 'Base64Content': image_data_b64,
}, },
{ {
'Filename': '', # the unnamed one 'Filename': '', # the unnamed one
'Content-type': 'image/png', 'ContentType': 'image/png',
'content': image_data_b64, 'Base64Content': image_data_b64,
}, },
]) ])
@@ -299,17 +277,16 @@ class MailjetBackendStandardEmailTests(MailjetBackendMockAPITestCase):
"""Empty to, cc, bcc, and reply_to shouldn't generate empty fields""" """Empty to, cc, bcc, and reply_to shouldn't generate empty fields"""
self.message.send() self.message.send()
data = self.get_api_call_json() data = self.get_api_call_json()
self.assertNotIn('Cc', data) self.assertNotIn('Cc', data['Globals'])
self.assertNotIn('Bcc', data) self.assertNotIn('Bcc', data['Globals'])
self.assertNotIn('ReplyTo', data) 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.to = []
self.message.cc = ['cc@example.com'] self.message.cc = ['cc@example.com']
self.message.send() with self.assertRaisesMessage(AnymailUnsupportedFeature, "messages without any `to` recipients"):
data = self.get_api_call_json() self.message.send()
self.assertNotIn('To', data)
self.assertEqual(data['Cc'], 'cc@example.com')
def test_api_failure(self): def test_api_failure(self):
self.set_mock_response(status_code=500) self.set_mock_response(status_code=500)
@@ -323,12 +300,14 @@ class MailjetBackendStandardEmailTests(MailjetBackendMockAPITestCase):
def test_api_error_includes_details(self): def test_api_error_includes_details(self):
"""AnymailAPIError should include ESP's error message""" """AnymailAPIError should include ESP's error message"""
# JSON error response: # JSON error response - global error:
error_response = b"""{ error_response = json.dumps({
"ErrorCode": 451, "ErrorIdentifier": "06df1144-c6f3-4ca7-8885-7ec5d4344113",
"Message": "Helpful explanation from Mailjet." "ErrorCode": "mj-0002",
}""" "ErrorMessage": "Helpful explanation from Mailjet.",
self.set_mock_response(status_code=200, raw=error_response) "StatusCode": 400
}).encode('utf-8')
self.set_mock_response(status_code=400, raw=error_response)
with self.assertRaisesMessage(AnymailAPIError, "Helpful explanation from Mailjet"): with self.assertRaisesMessage(AnymailAPIError, "Helpful explanation from Mailjet"):
self.message.send() self.message.send()
@@ -342,15 +321,6 @@ class MailjetBackendStandardEmailTests(MailjetBackendMockAPITestCase):
with self.assertRaises(AnymailAPIError): with self.assertRaises(AnymailAPIError):
self.message.send() 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') @tag('mailjet')
class MailjetBackendAnymailFeatureTests(MailjetBackendMockAPITestCase): class MailjetBackendAnymailFeatureTests(MailjetBackendMockAPITestCase):
@@ -360,7 +330,7 @@ class MailjetBackendAnymailFeatureTests(MailjetBackendMockAPITestCase):
self.message.envelope_sender = "bounce-handler@bounces.example.com" self.message.envelope_sender = "bounce-handler@bounces.example.com"
self.message.send() self.message.send()
data = self.get_api_call_json() 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): def test_metadata(self):
# Mailjet expects the payload to be a single string # 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.metadata = {'user_id': "12345", 'items': 6}
self.message.send() self.message.send()
data = self.get_api_call_json() 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): def test_send_at(self):
self.message.send_at = 1651820889 # 2022-05-06 07:08:09 UTC 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.tags = ["receipt"]
self.message.send() self.message.send()
data = self.get_api_call_json() data = self.get_api_call_json()
self.assertEqual(data['Mj-campaign'], "receipt") self.assertEqual(data['Globals']['CustomCampaign'], "receipt")
self.message.tags = ["receipt", "repeat-user"] self.message.tags = ["receipt", "repeat-user"]
with self.assertRaisesMessage(AnymailUnsupportedFeature, 'multiple tags'): with self.assertRaisesMessage(AnymailUnsupportedFeature, 'multiple tags'):
@@ -389,19 +359,18 @@ class MailjetBackendAnymailFeatureTests(MailjetBackendMockAPITestCase):
self.message.track_opens = True self.message.track_opens = True
self.message.send() self.message.send()
data = self.get_api_call_json() data = self.get_api_call_json()
self.assertEqual(data['Mj-trackopen'], 2) self.assertEqual(data['Globals']['TrackOpens'], 'enabled')
def test_track_clicks(self): def test_track_clicks(self):
self.message.track_clicks = True self.message.track_clicks = True
self.message.send() self.message.send()
data = self.get_api_call_json() 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.track_clicks = False
self.message.send() self.message.send()
data = self.get_api_call_json() data = self.get_api_call_json()
self.assertEqual(data['Mj-trackclick'], 1) self.assertEqual(data['Globals']['TrackClicks'], 'disabled')
def test_template(self): def test_template(self):
# template_id can be str or int (but must be numeric ID -- not the template's name) # 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.merge_global_data = {'name': "Alice", 'group': "Developers"}
self.message.send() self.message.send()
data = self.get_api_call_json() data = self.get_api_call_json()
self.assertEqual(data['Mj-TemplateID'], '1234567') self.assertEqual(data['Globals']['TemplateID'], 1234567) # must be integer
self.assertEqual(data['Vars'], {'name': "Alice", 'group': "Developers"}) 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): 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.template_id = '1234567'
self.message.from_email = None self.message.from_email = None # must set to None after constructing EmailMessage
self.message.send() self.message.send()
data = self.get_api_call_json() data = self.get_api_call_json()
self.assertEqual(data['Mj-TemplateID'], '1234567') self.assertNotIn('From', data['Globals']) # use template's sender as From
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()
def test_merge_data(self): def test_merge_data(self):
self.message.to = ['alice@example.com', 'Bob <bob@example.com>'] 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 = { self.message.merge_data = {
'alice@example.com': {'name': "Alice", 'group': "Developers"}, 'alice@example.com': {'name': "Alice", 'group': "Developers"},
'bob@example.com': {'name': "Bob"}, '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() self.message.send()
data = self.get_api_call_json() data = self.get_api_call_json()
messages = data['Messages'] messages = data['Messages']
self.assertEqual(len(messages), 2) self.assertEqual(len(messages), 2) # with merge_data, each 'to' gets separate message
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(messages[1]['To'], 'Bob <bob@example.com>') self.assertEqual(messages[0]['To'], [{"Email": "alice@example.com"}])
self.assertEqual(messages[1]['Cc'], 'cc@example.com') self.assertEqual(messages[1]['To'], [{"Email": "bob@example.com", "Name": "Bob"}])
self.assertEqual(messages[1]['Mj-TemplateID'], '1234567')
self.assertEqual(messages[1]['Vars'], # global merge_data is sent in Globals
{'name': "Bob", 'group': "Users", 'site': "ExampleCo"}) 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): def test_merge_metadata(self):
self.message.to = ['alice@example.com', 'Bob <bob@example.com>'] self.message.to = ['alice@example.com', 'Bob <bob@example.com>']
@@ -526,12 +424,12 @@ class MailjetBackendAnymailFeatureTests(MailjetBackendMockAPITestCase):
data = self.get_api_call_json() data = self.get_api_call_json()
messages = data['Messages'] messages = data['Messages']
self.assertEqual(len(messages), 2) 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: # 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'}) {'order_id': 123, 'tier': 'premium', 'notification_batch': 'zx912'})
self.assertEqual(messages[1]['To'], 'Bob <bob@example.com>') self.assertEqual(messages[1]['To'][0]['Email'], "bob@example.com")
self.assertJSONEqual(messages[1]['Mj-EventPayLoad'], self.assertJSONEqual(messages[1]['EventPayload'],
{'order_id': 678, 'notification_batch': 'zx912'}) {'order_id': 678, 'notification_batch': 'zx912'})
def test_default_omits_options(self): def test_default_omits_options(self):
@@ -543,35 +441,53 @@ class MailjetBackendAnymailFeatureTests(MailjetBackendMockAPITestCase):
""" """
self.message.send() self.message.send()
data = self.get_api_call_json() data = self.get_api_call_json()
self.assertNotIn('Mj-campaign', data) self.assertNotIn('CustomCampaign', data["Globals"])
self.assertNotIn('Mj-EventPayLoad', data) self.assertNotIn('EventPayload', data["Globals"])
self.assertNotIn('Mj-TemplateID', data) self.assertNotIn('HTMLPart', data["Globals"])
self.assertNotIn('Vars', data) self.assertNotIn('TemplateID', data["Globals"])
self.assertNotIn('Mj-trackopen', data) self.assertNotIn('TemplateLanguage', data["Globals"])
self.assertNotIn('Mj-trackclick', data) self.assertNotIn('Variables', data["Globals"])
self.assertNotIn('TrackOpens', data["Globals"])
self.assertNotIn('TrackClicks', data["Globals"])
def test_esp_extra(self): 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 = { self.message.esp_extra = {
'MJ-TemplateErrorDeliver': True, 'Globals': {
'MJ-TemplateErrorReporting': 'bugs@example.com' 'TemplateErrorDeliver': True,
'TemplateErrorReporting': 'bugs@example.com',
},
'SandboxMode': True,
} }
self.message.send() self.message.send()
data = self.get_api_call_json() data = self.get_api_call_json()
self.assertEqual(data['MJ-TemplateErrorDeliver'], True) self.assertEqual(data["Globals"]['TemplateErrorDeliver'], True)
self.assertEqual(data['MJ-TemplateErrorReporting'], 'bugs@example.com') 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 # noinspection PyUnresolvedReferences
def test_send_attaches_anymail_status(self): def test_send_attaches_anymail_status(self):
""" The anymail_status should be attached to the message when it is sent """ """ The anymail_status should be attached to the message when it is sent """
response_content = b"""{ response_content = json.dumps({
"Sent": [{ "Messages": [{
"Email": "to1@example.com", "Status": "success",
"MessageID": 12345678901234500 "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) 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() sent = msg.send()
self.assertEqual(sent, 1) self.assertEqual(sent, 1)
self.assertEqual(msg.anymail_status.status, {'sent'}) self.assertEqual(msg.anymail_status.status, {'sent'})
self.assertEqual(msg.anymail_status.message_id, "12345678901234500") 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) self.assertEqual(msg.anymail_status.esp_response.content, response_content)
# noinspection PyUnresolvedReferences # noinspection PyUnresolvedReferences
def test_status_includes_all_recipients(self): def test_mixed_status(self):
"""The status should include an entry for each recipient""" """The status should include an entry for each recipient"""
# Note that Mailjet's response only communicates "Sent" status; not failed addresses. # Mailjet's v3.1 API will partially fail a batch send, allowing valid emails to go out.
# (This is an example response from before the workaround for commas in display-names...) # The API response doesn't identify the failed email addresses; make sure we represent
response_content = b"""{ # them correctly in the anymail_status.
"Sent": [{ response_content = json.dumps({
"Email": "to1@example.com", "Messages": [{
"MessageID": 12345678901234500 "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", "Errors": [{
"MessageID": 12345678901234501 "ErrorIdentifier": "f480a5a2-0334-4e08-b2b7-f372ce5669e0",
}, { "ErrorCode": "mj-0013",
"Email": "Also", "StatusCode": 400,
"MessageID": 12345678901234502 "ErrorMessage": "\"invalid@123.4\" is an invalid email address.",
"ErrorRelatedTo": ["To[0].Email"]
}],
"Status": "error"
}] }]
}""" }).encode('utf-8')
self.set_mock_response(raw=response_content) self.set_mock_response(raw=response_content, status_code=400) # Mailjet uses 400 for partial success
msg = mail.EmailMessage('Subject', 'Message', 'from@example.com', msg = mail.EmailMessage('Subject', 'Message', 'from@example.com', ['to-good@example.com', 'invalid@123.4'])
['to1@example.com', '"Recipient, Also" <to2@example.com>'],)
sent = msg.send() sent = msg.send()
self.assertEqual(sent, 1) self.assertEqual(sent, 1)
self.assertEqual(msg.anymail_status.status, {'sent', 'unknown'}) self.assertEqual(msg.anymail_status.status, {'sent', 'failed'})
self.assertEqual(msg.anymail_status.recipients['to1@example.com'].status, 'sent') self.assertEqual(msg.anymail_status.recipients['to-good@example.com'].status, 'sent')
self.assertEqual(msg.anymail_status.recipients['to1@example.com'].message_id, "12345678901234500") self.assertEqual(msg.anymail_status.recipients['to-good@example.com'].message_id, "12345678901234500")
self.assertEqual(msg.anymail_status.recipients['to2@example.com'].status, 'unknown') # because, whoops self.assertEqual(msg.anymail_status.recipients['invalid@123.4'].status, 'failed')
self.assertEqual(msg.anymail_status.recipients['to2@example.com'].message_id, None) self.assertEqual(msg.anymail_status.recipients['invalid@123.4'].message_id, None)
self.assertEqual(msg.anymail_status.message_id, self.assertEqual(msg.anymail_status.message_id, {"12345678901234500", None})
{"12345678901234500", "12345678901234501", "12345678901234502", None})
self.assertEqual(msg.anymail_status.esp_response.content, response_content) self.assertEqual(msg.anymail_status.esp_response.content, response_content)
# noinspection PyUnresolvedReferences # 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, @unittest.skipUnless(MAILJET_TEST_API_KEY and MAILJET_TEST_SECRET_KEY,
"Set 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") "environment variables to run Mailjet integration tests")
@override_settings(ANYMAIL_MAILJET_API_KEY=MAILJET_TEST_API_KEY, @override_settings(ANYMAIL={"MAILJET_API_KEY": MAILJET_TEST_API_KEY,
ANYMAIL_MAILJET_SECRET_KEY=MAILJET_TEST_SECRET_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") EMAIL_BACKEND="anymail.backends.mailjet.EmailBackend")
class MailjetBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): class MailjetBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
"""Mailjet API integration tests """Mailjet API integration tests
@@ -27,12 +29,11 @@ class MailjetBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
as the API key and API secret key, respectively. as the API key and API secret key, respectively.
If those variables are not set, these tests won't run. 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 These tests enable Mailjet's SandboxMode to avoid sending any email;
you ask. 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). 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. We've set up @test-mj.anymail.info as a validated sending domain for these tests.
""" """
def setUp(self): def setUp(self):
@@ -63,7 +64,7 @@ class MailjetBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
to=['test+to1@anymail.info', '"Recipient, 2nd" <test+to2@anymail.info>'], to=['test+to1@anymail.info', '"Recipient, 2nd" <test+to2@anymail.info>'],
cc=['test+cc1@anymail.info', 'Copy 2 <test+cc1@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>'], 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"}, headers={"X-Anymail-Test": "value"},
metadata={"meta1": "simple string", "meta2": 2}, metadata={"meta1": "simple string", "meta2": 2},
@@ -120,10 +121,11 @@ class MailjetBackendIntegrationTests(AnymailTestMixin, SimpleTestCase):
recipient_status = message.anymail_status.recipients recipient_status = message.anymail_status.recipients
self.assertEqual(recipient_status['test+to1@anymail.info'].status, 'sent') 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): def test_invalid_api_key(self):
with self.assertRaises(AnymailAPIError) as cm: with self.assertRaises(AnymailAPIError) as cm:
self.message.send() self.message.send()
err = cm.exception err = cm.exception
self.assertEqual(err.status_code, 401) 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))