mirror of
https://github.com/pacnpal/django-anymail.git
synced 2025-12-20 11:51:05 -05:00
Improve and document JSON serialization for Mandrill API
* Add some context to exceptions on unserializable values (addresses #89). * Document need to format merge data (into something JSON-serializable). * Add RemovedInDjrill2 DeprecationWarning. * Deprecate blanket date/datetime serialization.
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
from requests import HTTPError
|
from requests import HTTPError
|
||||||
|
import warnings
|
||||||
|
|
||||||
|
|
||||||
class MandrillAPIError(HTTPError):
|
class MandrillAPIError(HTTPError):
|
||||||
@@ -32,3 +33,11 @@ class NotSupportedByMandrillError(ValueError):
|
|||||||
avoid duplicating Mandrill's validation logic locally.)
|
avoid duplicating Mandrill's validation logic locally.)
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class RemovedInDjrill2(DeprecationWarning):
|
||||||
|
"""Functionality due for deprecation in Djrill 2.0"""
|
||||||
|
|
||||||
|
|
||||||
|
def removed_in_djrill_2(message, stacklevel=1):
|
||||||
|
warnings.warn(message, category=RemovedInDjrill2, stacklevel=stacklevel + 1)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from django.core.mail.message import sanitize_address, DEFAULT_ATTACHMENT_MIME_T
|
|||||||
# Oops: this file has the same name as our app, and cannot be renamed.
|
# Oops: this file has the same name as our app, and cannot be renamed.
|
||||||
#from djrill import MANDRILL_API_URL, MandrillAPIError, NotSupportedByMandrillError
|
#from djrill import MANDRILL_API_URL, MandrillAPIError, NotSupportedByMandrillError
|
||||||
from ... import MANDRILL_API_URL, MandrillAPIError, NotSupportedByMandrillError
|
from ... import MANDRILL_API_URL, MandrillAPIError, NotSupportedByMandrillError
|
||||||
|
from ...exceptions import removed_in_djrill_2
|
||||||
|
|
||||||
from base64 import b64encode
|
from base64 import b64encode
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
@@ -18,22 +19,36 @@ import requests
|
|||||||
DjrillBackendHTTPError = MandrillAPIError # Backwards-compat Djrill<=0.2.0
|
DjrillBackendHTTPError = MandrillAPIError # Backwards-compat Djrill<=0.2.0
|
||||||
|
|
||||||
|
|
||||||
class JSONDateUTCEncoder(json.JSONEncoder):
|
def encode_date_for_mandrill(dt):
|
||||||
"""JSONEncoder that encodes dates in string format used by Mandrill.
|
"""Format a date or datetime for use as a Mandrill API date field
|
||||||
|
|
||||||
datetime becomes "YYYY-MM-DD HH:MM:SS"
|
datetime becomes "YYYY-MM-DD HH:MM:SS"
|
||||||
converted to UTC, if timezone-aware
|
converted to UTC, if timezone-aware
|
||||||
microseconds removed
|
microseconds removed
|
||||||
date becomes "YYYY-MM-DD 00:00:00"
|
date becomes "YYYY-MM-DD 00:00:00"
|
||||||
|
anything else gets returned intact
|
||||||
"""
|
"""
|
||||||
def default(self, obj):
|
if isinstance(dt, datetime):
|
||||||
if isinstance(obj, datetime):
|
dt = dt.replace(microsecond=0)
|
||||||
dt = obj.replace(microsecond=0)
|
|
||||||
if dt.utcoffset() is not None:
|
if dt.utcoffset() is not None:
|
||||||
dt = (dt - dt.utcoffset()).replace(tzinfo=None)
|
dt = (dt - dt.utcoffset()).replace(tzinfo=None)
|
||||||
return dt.isoformat(' ')
|
return dt.isoformat(' ')
|
||||||
elif isinstance(obj, date):
|
elif isinstance(dt, date):
|
||||||
return obj.isoformat() + ' 00:00:00'
|
return dt.isoformat() + ' 00:00:00'
|
||||||
|
else:
|
||||||
|
return dt
|
||||||
|
|
||||||
|
|
||||||
|
class JSONDateUTCEncoder(json.JSONEncoder):
|
||||||
|
"""[deprecated] JSONEncoder that encodes dates in string format used by Mandrill."""
|
||||||
|
def default(self, obj):
|
||||||
|
if isinstance(obj, date):
|
||||||
|
removed_in_djrill_2(
|
||||||
|
"You've used the date '%r' as a Djrill message attribute "
|
||||||
|
"(perhaps in merge vars or metadata). Djrill 2.0 will require "
|
||||||
|
"you to explicitly convert this date to a string." % obj
|
||||||
|
)
|
||||||
|
return encode_date_for_mandrill(obj)
|
||||||
return super(JSONDateUTCEncoder, self).default(obj)
|
return super(JSONDateUTCEncoder, self).default(obj)
|
||||||
|
|
||||||
|
|
||||||
@@ -104,7 +119,19 @@ class DjrillBackend(BaseEmailBackend):
|
|||||||
raise
|
raise
|
||||||
return False
|
return False
|
||||||
|
|
||||||
response = requests.post(api_url, data=json.dumps(api_params, cls=JSONDateUTCEncoder))
|
try:
|
||||||
|
api_data = json.dumps(api_params, cls=JSONDateUTCEncoder)
|
||||||
|
except TypeError as err:
|
||||||
|
# Add some context to the "not JSON serializable" message
|
||||||
|
if not err.args:
|
||||||
|
err.args = ('',)
|
||||||
|
err.args = (
|
||||||
|
err.args[0] + " in a Djrill message (perhaps it's a merge var?)."
|
||||||
|
" Try converting it to a string or number first.",
|
||||||
|
) + err.args[1:]
|
||||||
|
raise err
|
||||||
|
|
||||||
|
response = requests.post(api_url, data=api_data)
|
||||||
|
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
|
|
||||||
@@ -175,12 +202,17 @@ class DjrillBackend(BaseEmailBackend):
|
|||||||
"""Extend api_params to include Mandrill global-send options set on message"""
|
"""Extend api_params to include Mandrill global-send options set on message"""
|
||||||
# Mandrill attributes that can be copied directly:
|
# Mandrill attributes that can be copied directly:
|
||||||
mandrill_attrs = [
|
mandrill_attrs = [
|
||||||
'async', 'ip_pool', 'send_at'
|
'async', 'ip_pool'
|
||||||
]
|
]
|
||||||
for attr in mandrill_attrs:
|
for attr in mandrill_attrs:
|
||||||
if hasattr(message, attr):
|
if hasattr(message, attr):
|
||||||
api_params[attr] = getattr(message, attr)
|
api_params[attr] = getattr(message, attr)
|
||||||
|
|
||||||
|
# Mandrill attributes that require conversion:
|
||||||
|
if hasattr(message, 'send_at'):
|
||||||
|
api_params['send_at'] = encode_date_for_mandrill(message.send_at)
|
||||||
|
|
||||||
|
|
||||||
def _make_mandrill_to_list(self, message, recipients, recipient_type="to"):
|
def _make_mandrill_to_list(self, message, recipients, recipient_type="to"):
|
||||||
"""Create a Mandrill 'to' field from a list of emails.
|
"""Create a Mandrill 'to' field from a list of emails.
|
||||||
|
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ import six
|
|||||||
|
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
from .utils import override_settings
|
from .utils import BackportedAssertions, override_settings
|
||||||
|
|
||||||
|
|
||||||
@override_settings(MANDRILL_API_KEY="FAKE_API_KEY_FOR_TESTING",
|
@override_settings(MANDRILL_API_KEY="FAKE_API_KEY_FOR_TESTING",
|
||||||
EMAIL_BACKEND="djrill.mail.backends.djrill.DjrillBackend")
|
EMAIL_BACKEND="djrill.mail.backends.djrill.DjrillBackend")
|
||||||
class DjrillBackendMockAPITestCase(TestCase):
|
class DjrillBackendMockAPITestCase(TestCase, BackportedAssertions):
|
||||||
"""TestCase that uses Djrill EmailBackend with a mocked Mandrill API"""
|
"""TestCase that uses Djrill EmailBackend with a mocked Mandrill API"""
|
||||||
|
|
||||||
class MockResponse(requests.Response):
|
class MockResponse(requests.Response):
|
||||||
|
|||||||
@@ -1,10 +1,56 @@
|
|||||||
# Tests deprecated Djrill features
|
# Tests deprecated Djrill features
|
||||||
|
|
||||||
|
from datetime import date, datetime
|
||||||
|
import warnings
|
||||||
|
|
||||||
|
from django.core import mail
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
from djrill.mail import DjrillMessage
|
from djrill.mail import DjrillMessage
|
||||||
from djrill import MandrillAPIError, NotSupportedByMandrillError
|
from djrill import MandrillAPIError, NotSupportedByMandrillError
|
||||||
|
|
||||||
|
from .mock_backend import DjrillBackendMockAPITestCase
|
||||||
|
|
||||||
|
|
||||||
|
class DjrillBackendDeprecationTests(DjrillBackendMockAPITestCase):
|
||||||
|
|
||||||
|
def test_deprecated_json_date_encoding(self):
|
||||||
|
"""Djrill 2.0+ avoids a blanket JSONDateUTCEncoder"""
|
||||||
|
# Djrill allows dates for send_at, so shouldn't warn:
|
||||||
|
message = mail.EmailMessage('Subject', 'Body', 'from@example.com', ['to@example.com'])
|
||||||
|
message.send_at = datetime(2022, 10, 11, 12, 13, 14, 567)
|
||||||
|
self.assertNotWarns(DeprecationWarning, message.send)
|
||||||
|
|
||||||
|
# merge_vars need to be json-serializable, so should generate a warning:
|
||||||
|
message = mail.EmailMessage('Subject', 'Body', 'from@example.com', ['to@example.com'])
|
||||||
|
message.global_merge_vars = {'DATE': date(2022, 10, 11)}
|
||||||
|
self.assertWarnsMessage(DeprecationWarning,
|
||||||
|
"Djrill 2.0 will require you to explicitly convert this date to a string",
|
||||||
|
message.send)
|
||||||
|
# ... but should still encode the date (for now):
|
||||||
|
data = self.get_api_call_data()
|
||||||
|
self.assertEqual(data['message']['global_merge_vars'],
|
||||||
|
[{'name': 'DATE', 'content': "2022-10-11 00:00:00"}])
|
||||||
|
|
||||||
|
def assertWarnsMessage(self, warning, message, callable, *args, **kwds):
|
||||||
|
"""Checks that `callable` issues a warning of category `warning` containing `message`"""
|
||||||
|
with warnings.catch_warnings(record=True) as warned:
|
||||||
|
warnings.simplefilter("always")
|
||||||
|
callable(*args, **kwds)
|
||||||
|
self.assertGreater(len(warned), 0, msg="No warnings issued")
|
||||||
|
self.assertTrue(
|
||||||
|
any(issubclass(w.category, warning) and message in str(w.message) for w in warned),
|
||||||
|
msg="%r(%r) not found in %r" % (warning, message, [str(w) for w in warned]))
|
||||||
|
|
||||||
|
def assertNotWarns(self, warning, callable, *args, **kwds):
|
||||||
|
"""Checks that `callable` does not issue any warnings of category `warning`"""
|
||||||
|
with warnings.catch_warnings(record=True) as warned:
|
||||||
|
warnings.simplefilter("always")
|
||||||
|
callable(*args, **kwds)
|
||||||
|
relevant_warnings = [w for w in warned if issubclass(w.category, warning)]
|
||||||
|
self.assertEqual(len(relevant_warnings), 0,
|
||||||
|
msg="Unexpected warnings %r" % [str(w) for w in relevant_warnings])
|
||||||
|
|
||||||
|
|
||||||
class DjrillMessageTests(TestCase):
|
class DjrillMessageTests(TestCase):
|
||||||
"""Test the DjrillMessage class (deprecated as of Djrill v0.2.0)
|
"""Test the DjrillMessage class (deprecated as of Djrill v0.2.0)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from __future__ import unicode_literals
|
|||||||
|
|
||||||
from base64 import b64decode
|
from base64 import b64decode
|
||||||
from datetime import date, datetime, timedelta, tzinfo
|
from datetime import date, datetime, timedelta, tzinfo
|
||||||
|
from decimal import Decimal
|
||||||
from email.mime.base import MIMEBase
|
from email.mime.base import MIMEBase
|
||||||
from email.mime.image import MIMEImage
|
from email.mime.image import MIMEImage
|
||||||
import json
|
import json
|
||||||
@@ -507,6 +508,16 @@ class DjrillMandrillFeatureTests(DjrillBackendMockAPITestCase):
|
|||||||
self.assertEqual(sent, 0)
|
self.assertEqual(sent, 0)
|
||||||
self.assertIsNone(msg.mandrill_response)
|
self.assertIsNone(msg.mandrill_response)
|
||||||
|
|
||||||
|
def test_json_serialization_warnings(self):
|
||||||
|
"""Try to provide more information about non-json-serializable data"""
|
||||||
|
self.message.global_merge_vars = {'PRICE': Decimal('19.99')}
|
||||||
|
with self.assertRaisesMessage(
|
||||||
|
TypeError,
|
||||||
|
"Decimal('19.99') is not JSON serializable in a Djrill message (perhaps "
|
||||||
|
"it's a merge var?). Try converting it to a string or number first."
|
||||||
|
):
|
||||||
|
self.message.send()
|
||||||
|
|
||||||
|
|
||||||
@override_settings(EMAIL_BACKEND="djrill.mail.backends.djrill.DjrillBackend")
|
@override_settings(EMAIL_BACKEND="djrill.mail.backends.djrill.DjrillBackend")
|
||||||
class DjrillImproperlyConfiguredTests(TestCase):
|
class DjrillImproperlyConfiguredTests(TestCase):
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
|
import re
|
||||||
|
import six
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
|
'BackportedAssertions',
|
||||||
'override_settings',
|
'override_settings',
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -66,3 +70,19 @@ except ImportError:
|
|||||||
# new_value = getattr(settings, key, None)
|
# new_value = getattr(settings, key, None)
|
||||||
# setting_changed.send(sender=settings._wrapped.__class__,
|
# setting_changed.send(sender=settings._wrapped.__class__,
|
||||||
# setting=key, value=new_value)
|
# setting=key, value=new_value)
|
||||||
|
|
||||||
|
|
||||||
|
class BackportedAssertions(object):
|
||||||
|
"""Handful of useful TestCase assertions backported to Python 2.6/Django 1.3"""
|
||||||
|
|
||||||
|
# Backport from Python 2.7/3.1
|
||||||
|
def assertIn(self, member, container, msg=None):
|
||||||
|
"""Just like self.assertTrue(a in b), but with a nicer default message."""
|
||||||
|
if member not in container:
|
||||||
|
self.fail(msg or '%r not found in %r' % (member, container))
|
||||||
|
|
||||||
|
# Backport from Django 1.4
|
||||||
|
def assertRaisesMessage(self, expected_exception, expected_message,
|
||||||
|
callable_obj=None, *args, **kwargs):
|
||||||
|
return six.assertRaisesRegex(self, expected_exception, re.escape(expected_message),
|
||||||
|
callable_obj, *args, **kwargs)
|
||||||
|
|||||||
@@ -7,6 +7,35 @@ Among other things, this means that minor updates
|
|||||||
and breaking changes will always increment the
|
and breaking changes will always increment the
|
||||||
major version number (1.x to 2.0).
|
major version number (1.x to 2.0).
|
||||||
|
|
||||||
|
Upcoming Changes in Djrill 2.0
|
||||||
|
------------------------------
|
||||||
|
|
||||||
|
Djrill 2.0 is under development and will include some breaking changes.
|
||||||
|
Although the changes won't impact most Djrill users, the current
|
||||||
|
version of Djrill (1.4) will try to warn you if you use things
|
||||||
|
that will change. (Warnings appear in the console when running Django
|
||||||
|
in debug mode.)
|
||||||
|
|
||||||
|
* **Dates in merge data and other attributes**
|
||||||
|
|
||||||
|
Djrill automatically converts :attr:`send_at` `date` and `datetime`
|
||||||
|
values to the ISO 8601 string format expected by the Mandrill API.
|
||||||
|
|
||||||
|
Unintentionally, it also converts dates used in other Mandrill message
|
||||||
|
attributes (such as :attr:`merge_vars` or :attr:`metadata`) where it
|
||||||
|
might not be expected (or appropriate).
|
||||||
|
|
||||||
|
Djrill 2.0 will remove this automatic date formatting, except
|
||||||
|
for attributes that are inherently dates (currently only `send_at`).
|
||||||
|
|
||||||
|
To assist in detecting code relying on the (undocumented) current
|
||||||
|
behavior, Djrill 1.4 will report a `DeprecationWarning` for `date`
|
||||||
|
or `datetime` values used in any Mandrill message attributes other
|
||||||
|
than `send_at`. See :ref:`formatting-merge-data` for other options.
|
||||||
|
|
||||||
|
|
||||||
|
Change Log
|
||||||
|
----------
|
||||||
|
|
||||||
Version 1.4 (development):
|
Version 1.4 (development):
|
||||||
|
|
||||||
@@ -15,6 +44,9 @@ Version 1.4 (development):
|
|||||||
(Specifying a :ref:`Reply-To header <message-headers>`
|
(Specifying a :ref:`Reply-To header <message-headers>`
|
||||||
still works, with any version of Django,
|
still works, with any version of Django,
|
||||||
and will override the reply_to param if you use both.)
|
and will override the reply_to param if you use both.)
|
||||||
|
* More-helpful exception when using a non-JSON-serializable
|
||||||
|
type in merge_vars and other Djrill message attributes
|
||||||
|
* Deprecation warnings for upcoming 2.0 changes (see above)
|
||||||
|
|
||||||
|
|
||||||
Version 1.3:
|
Version 1.3:
|
||||||
|
|||||||
@@ -198,6 +198,9 @@ Most of the options from the Mandrill
|
|||||||
|
|
||||||
message.global_merge_vars = {'company': "ACME", 'offer': "10% off"}
|
message.global_merge_vars = {'company': "ACME", 'offer': "10% off"}
|
||||||
|
|
||||||
|
Merge data must be strings or other JSON-serializable types.
|
||||||
|
(See :ref:`formatting-merge-data` for details.)
|
||||||
|
|
||||||
.. attribute:: merge_vars
|
.. attribute:: merge_vars
|
||||||
|
|
||||||
``dict``: per-recipient merge variables (most useful with :ref:`mandrill-templates`). The keys
|
``dict``: per-recipient merge variables (most useful with :ref:`mandrill-templates`). The keys
|
||||||
@@ -209,6 +212,9 @@ Most of the options from the Mandrill
|
|||||||
'rr@example.com': {'offer': "instant tunnel paint"}
|
'rr@example.com': {'offer': "instant tunnel paint"}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Merge data must be strings or other JSON-serializable types.
|
||||||
|
(See :ref:`formatting-merge-data` for details.)
|
||||||
|
|
||||||
.. attribute:: tags
|
.. attribute:: tags
|
||||||
|
|
||||||
``list`` of ``str``: tags to apply to the message, for filtering reports in the Mandrill
|
``list`` of ``str``: tags to apply to the message, for filtering reports in the Mandrill
|
||||||
@@ -245,12 +251,18 @@ Most of the options from the Mandrill
|
|||||||
|
|
||||||
message.metadata = {'customer': customer.id, 'order': order.reference_number}
|
message.metadata = {'customer': customer.id, 'order': order.reference_number}
|
||||||
|
|
||||||
|
Mandrill restricts metadata keys to alphanumeric characters and underscore, and
|
||||||
|
metadata values to numbers, strings, boolean values, and None (null).
|
||||||
|
|
||||||
.. attribute:: recipient_metadata
|
.. attribute:: recipient_metadata
|
||||||
|
|
||||||
``dict``: per-recipient metadata values. Keys are the recipient email addresses,
|
``dict``: per-recipient metadata values. Keys are the recipient email addresses,
|
||||||
and values are dicts of metadata for each recipient (similar to
|
and values are dicts of metadata for each recipient (similar to
|
||||||
:attr:`merge_vars`)
|
:attr:`merge_vars`)
|
||||||
|
|
||||||
|
Mandrill restricts metadata keys to alphanumeric characters and underscore, and
|
||||||
|
metadata values to numbers, strings, boolean values, and None (null).
|
||||||
|
|
||||||
.. attribute:: async
|
.. attribute:: async
|
||||||
|
|
||||||
``Boolean``: whether Mandrill should use an async mode optimized for bulk sending.
|
``Boolean``: whether Mandrill should use an async mode optimized for bulk sending.
|
||||||
|
|||||||
@@ -37,6 +37,45 @@ and will ignore any `body` text set on the `EmailMessage`.
|
|||||||
All of Djrill's other :ref:`Mandrill-specific options <mandrill-send-support>`
|
All of Djrill's other :ref:`Mandrill-specific options <mandrill-send-support>`
|
||||||
can be used with templates.
|
can be used with templates.
|
||||||
|
|
||||||
|
|
||||||
|
.. _formatting-merge-data:
|
||||||
|
|
||||||
|
Formatting Merge Data
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
If you're using dates, datetimes, Decimals, or anything other than strings and integers,
|
||||||
|
you'll need to format them into strings for use as merge data::
|
||||||
|
|
||||||
|
product = Product.objects.get(123) # A Django model
|
||||||
|
total_cost = Decimal('19.99')
|
||||||
|
ship_date = date(2015, 11, 18)
|
||||||
|
|
||||||
|
# Won't work -- you'll get "not JSON serializable" exceptions:
|
||||||
|
msg.global_merge_vars = {
|
||||||
|
'PRODUCT': product,
|
||||||
|
'TOTAL_COST': total_cost,
|
||||||
|
'SHIP_DATE': ship_date
|
||||||
|
}
|
||||||
|
|
||||||
|
# Do something this instead:
|
||||||
|
msg.global_merge_vars = {
|
||||||
|
'PRODUCT': product.name, # assuming name is a CharField
|
||||||
|
'TOTAL_COST': "%.2f" % total_cost,
|
||||||
|
'SHIP_DATE': ship_date.strftime('%B %d, %Y') # US-style "March 15, 2015"
|
||||||
|
}
|
||||||
|
|
||||||
|
These are just examples. You'll need to determine the best way to format
|
||||||
|
your merge data as strings.
|
||||||
|
|
||||||
|
Although floats are allowed in merge vars, you'll generally want to format them
|
||||||
|
into strings yourself to avoid surprises with floating-point precision.
|
||||||
|
|
||||||
|
Technically, Djrill will accept anything serializable by the Python json package --
|
||||||
|
which means advanced template users can include dicts and lists as merge vars
|
||||||
|
(for templates designed to handle objects and arrays).
|
||||||
|
See the Python :class:`json.JSONEncoder` docs for a list of allowable types.
|
||||||
|
|
||||||
|
|
||||||
How To Use Default Mandrill Subject and From fields
|
How To Use Default Mandrill Subject and From fields
|
||||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user