Drop Python 2 and Django 1.11 support

Minimum supported versions are now Django 2.0, Python 3.5.

This touches a lot of code, to:
* Remove obsolete portability code and workarounds
  (six, backports of email parsers, test utils, etc.)
* Use Python 3 syntax (class defs, raise ... from, etc.)
* Correct inheritance for mixin classes
* Fix outdated docs content and links
* Suppress Python 3 "unclosed SSLSocket" ResourceWarnings
  that are beyond our control (in integration tests due to boto3, 
  python-sparkpost)
This commit is contained in:
Mike Edmunds
2020-08-01 14:53:10 -07:00
committed by GitHub
parent c803108481
commit 85cec5e9dc
87 changed files with 672 additions and 1278 deletions

View File

@@ -1,5 +1,3 @@
# -*- coding: utf-8 -*-
#
# Anymail documentation build configuration file, created by
# sphinx-quickstart
#
@@ -50,9 +48,9 @@ source_suffix = '.rst'
master_doc = 'index'
# General information about the project.
project = u'Anymail'
project = 'Anymail'
# noinspection PyShadowingBuiltins
copyright = u'Anymail contributors (see AUTHORS.txt)'
copyright = 'Anymail contributors (see AUTHORS.txt)'
# The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the
@@ -203,8 +201,8 @@ latex_elements = {
# Grouping the document tree into LaTeX files. List of tuples
# (source start file, target name, title, author, documentclass [howto/manual]).
latex_documents = [
('index', 'Anymail.tex', u'Anymail Documentation',
u'Anymail contributors (see AUTHORS.txt)', 'manual'),
('index', 'Anymail.tex', 'Anymail Documentation',
'Anymail contributors (see AUTHORS.txt)', 'manual'),
]
# The name of an image file (relative to this directory) to place at the top of
@@ -233,8 +231,8 @@ latex_documents = [
# One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section).
man_pages = [
('index', 'anymail', u'Anymail Documentation',
[u'Anymail contributors (see AUTHORS.txt)'], 1)
('index', 'anymail', 'Anymail Documentation',
['Anymail contributors (see AUTHORS.txt)'], 1)
]
# If true, show URL addresses after external links.
@@ -247,8 +245,8 @@ man_pages = [
# (source start file, target name, title, author,
# dir menu entry, description, category)
texinfo_documents = [
('index', 'Anymail', u'Anymail Documentation',
u'Anymail contributors (see AUTHORS.txt)', 'Anymail', 'Multi-ESP transactional email for Django.',
('index', 'Anymail', 'Anymail Documentation',
'Anymail contributors (see AUTHORS.txt)', 'Anymail', 'Multi-ESP transactional email for Django.',
'Miscellaneous'),
]
@@ -270,14 +268,9 @@ extlinks = {
# -- Options for Intersphinx ------------------------------------------------
intersphinx_mapping = {
'python': ('https://docs.python.org/3.6', None),
'python': ('https://docs.python.org/3.7', None),
'django': ('https://docs.djangoproject.com/en/stable/', 'https://docs.djangoproject.com/en/stable/_objects/'),
# Requests docs may be moving (Sep 2019):
# see https://github.com/psf/requests/issues/5212
# and https://github.com/psf/requests/issues/5214
'requests': ('https://docs.python-requests.org/en/latest/',
('https://docs.python-requests.org/en/latest/objects.inv',
'https://requests.kennethreitz.org/en/latest/objects.inv')),
'requests': ('https://requests.readthedocs.io/en/stable/', None),
}

View File

@@ -71,7 +71,7 @@ and Python versions. Tests are run at least once a week, to check whether ESP AP
and other dependencies have changed out from under Anymail.
For local development, the recommended test command is
:shell:`tox -e django22-py37-all,django111-py27-all,lint`, which tests a representative
:shell:`tox -e django31-py38-all,django20-py35-all,lint`, which tests a representative
combination of Python and Django versions. It also runs :pypi:`flake8` and other
code-style checkers. Some other test options are covered below, but using this
tox command catches most problems, and is a good pre-pull-request check.
@@ -98,16 +98,16 @@ Or:
$ python runtests.py tests.test_mailgun_backend tests.test_mailgun_webhooks
Or to test against multiple versions of Python and Django all at once, use :pypi:`tox`.
You'll need at least Python 2.7 and Python 3.6 available. (If your system doesn't come
with those, `pyenv`_ is a helpful way to install and manage multiple Python versions.)
You'll need some version of Python 3 available. (If your system doesn't come
with that, `pyenv`_ is a helpful way to install and manage multiple Python versions.)
.. code-block:: console
$ pip install tox # (if you haven't already)
$ tox -e django21-py36-all,django111-py27-all,lint # test recommended environments
$ tox -e django31-py38-all,django20-py35-all,lint # test recommended environments
## you can also run just some test cases, e.g.:
$ tox -e django21-py36-all,django111-py27-all tests.test_mailgun_backend tests.test_utils
$ tox -e django31-py38-all,django20-py35-all tests.test_mailgun_backend tests.test_utils
## to test more Python/Django versions:
$ tox --parallel auto # ALL 20+ envs! (in parallel if possible)
@@ -121,7 +121,7 @@ API keys or other settings. For example:
$ export MAILGUN_TEST_API_KEY='your-Mailgun-API-key'
$ export MAILGUN_TEST_DOMAIN='mail.example.com' # sending domain for that API key
$ tox -e django21-py36-all tests.test_mailgun_integration
$ tox -e django31-py38-all tests.test_mailgun_integration
Check the ``*_integration_tests.py`` files in the `tests source`_ to see which variables
are required for each ESP. Depending on the supported features, the integration tests for
@@ -180,7 +180,7 @@ Anymail's Sphinx conf sets up a few enhancements you can use in the docs:
.. _Django's added markup:
https://docs.djangoproject.com/en/stable/internals/contributing/writing-documentation/#django-specific-markup
.. _extlinks: http://www.sphinx-doc.org/en/stable/ext/extlinks.html
.. _intersphinx: http://www.sphinx-doc.org/en/master/ext/intersphinx.html
.. _extlinks: https://www.sphinx-doc.org/en/stable/usage/extensions/extlinks.html
.. _intersphinx: https://www.sphinx-doc.org/en/stable/usage/extensions/intersphinx.html
.. _Writing Documentation:
https://docs.djangoproject.com/en/stable/internals/contributing/writing-documentation/

View File

@@ -425,9 +425,9 @@ The event's :attr:`~anymail.signals.AnymailTrackingEvent.esp_event` field will b
the parsed `Mailgun webhook payload`_ as a Python `dict` with ``"signature"`` and
``"event-data"`` keys.
Anymail uses Mailgun's webhook `token` as its normalized
Anymail uses Mailgun's webhook ``token`` as its normalized
:attr:`~anymail.signals.AnymailTrackingEvent.event_id`, rather than Mailgun's
event-data `id` (which is only guaranteed to be unique during a single day).
event-data ``id`` (which is only guaranteed to be unique during a single day).
If you need the event-data id, it can be accessed in your webhook handler as
``event.esp_event["event-data"]["id"]``. (This can be helpful for working with
Mailgun's other event APIs.)

View File

@@ -3,7 +3,7 @@
Mandrill
========
Anymail integrates with the `Mandrill <http://mandrill.com/>`__
Anymail integrates with the `Mandrill <https://mandrill.com/>`__
transactional email service from MailChimp.
.. note:: **Limited Support for Mandrill**

View File

@@ -29,9 +29,10 @@ often help you pinpoint the problem...
**Double-check common issues**
* Did you add any required settings for your ESP to the `ANYMAIL` dict in your
settings.py? (E.g., ``"SENDGRID_API_KEY"`` for SendGrid.) See :ref:`supported-esps`.
settings.py? (E.g., ``"SENDGRID_API_KEY"`` for SendGrid.) Check the instructions
for the ESP you're using under :ref:`supported-esps`.
* Did you add ``'anymail'`` to the list of :setting:`INSTALLED_APPS` in settings.py?
* Are you using a valid from address? Django's default is "webmaster@localhost",
* Are you using a valid *from* address? Django's default is "webmaster@localhost",
which most ESPs reject. Either specify the ``from_email`` explicitly on every message
you send, or add :setting:`DEFAULT_FROM_EMAIL` to your settings.py.
@@ -61,8 +62,8 @@ Support
If you've gone through the troubleshooting above and still aren't sure what's wrong,
the Anymail community is happy to help. Anymail is supported and maintained by the
people who use it---like you! (The vast majority of Anymail contributors volunteer
their time, and are not employees of any ESP.)
people who use it---like you! (Anymail contributors volunteer their time, and are
not employees of any ESP.)
Here's how to contact the Anymail community:

View File

@@ -13,7 +13,7 @@ If you didn't set up webhooks when first installing Anymail, you'll need to
(You should also review :ref:`securing-webhooks`.)
Once you've enabled webhooks, Anymail will send a ``anymail.signals.inbound``
custom Django :mod:`signal <django.dispatch>` for each ESP inbound message it receives.
custom Django :doc:`signal <django:topics/signals>` for each ESP inbound message it receives.
You can connect your own receiver function to this signal for further processing.
(This is very much like how Anymail handles :ref:`status tracking <event-tracking>`
events for sent messages. Inbound events just use a different signal receiver
@@ -73,7 +73,7 @@ invoke your signal receiver once, separately, for each message in the batch.
:ref:`user-supplied content security <django:user-uploaded-content-security>`.
.. _using python-magic:
http://blog.hayleyanderson.us/2015/07/18/validating-file-types-in-django/
https://blog.hayleyanderson.us/2015/07/18/validating-file-types-in-django/
.. _inbound-event:
@@ -90,7 +90,7 @@ Normalized inbound event
.. attribute:: message
An :class:`~anymail.inbound.AnymailInboundMessage` representing the email
that was received. Most of what you're interested in will be on this `message`
that was received. Most of what you're interested in will be on this :attr:`!message`
attribute. See the full details :ref:`below <inbound-message>`.
.. attribute:: event_type
@@ -290,8 +290,6 @@ Handling Inbound Attachments
Anymail converts each inbound attachment to a specialized MIME object with
additional methods for handling attachments and integrating with Django.
It also backports some helpful MIME methods from newer versions of Python
to all versions supported by Anymail.
The attachment objects in an AnymailInboundMessage's
:attr:`~AnymailInboundMessage.attachments` list and
@@ -346,8 +344,6 @@ have these methods:
.. method:: is_attachment()
Returns `True` for a (non-inline) attachment, `False` otherwise.
(Anymail back-ports Python 3.4.2's :meth:`~email.message.EmailMessage.is_attachment` method
to all supported versions.)
.. method:: is_inline_attachment()
@@ -360,9 +356,6 @@ have these methods:
:mailheader:`Content-Disposition` header. The return value should be either "inline"
or "attachment", or `None` if the attachment is somehow missing that header.
(Anymail back-ports Python 3.5's :meth:`~email.message.Message.get_content_disposition`
method to all supported versions.)
.. method:: get_content_text(charset=None, errors='replace')
Returns the content of the attachment decoded to Unicode text.
@@ -453,7 +446,7 @@ And they may then retry sending these "failed" events, which could
cause duplicate processing in your code.
If your signal receiver code might be slow, you should instead
queue the event for later, asynchronous processing (e.g., using
something like `Celery`_).
something like :pypi:`celery`).
If your signal receiver function is defined within some other
function or instance method, you *must* use the `weak=False`
@@ -461,5 +454,3 @@ option when connecting it. Otherwise, it might seem to work at first,
but will unpredictably stop being called at some point---typically
on your production server, in a hard-to-debug way. See Django's
docs on :doc:`signals <django:topics/signals>` for more information.
.. _Celery: http://www.celeryproject.org/

View File

@@ -142,15 +142,15 @@ If you want to use Anymail's inbound or tracking webhooks:
.. code-block:: python
from django.conf.urls import include, url
from django.urls import include, re_path
urlpatterns = [
...
url(r'^anymail/', include('anymail.urls')),
re_path(r'^anymail/', include('anymail.urls')),
]
(You can change the "anymail" prefix in the first parameter to
:func:`~django.conf.urls.url` if you'd like the webhooks to be served
:func:`~django.urls.re_path` if you'd like the webhooks to be served
at some other URL. Just match whatever you use in the webhook URL you give
your ESP in the next step.)
@@ -186,7 +186,7 @@ See :ref:`event-tracking` for information on creating signal handlers and the
status tracking events you can receive. See :ref:`inbound` for information on
receiving inbound message events.
.. _mod_wsgi: http://modwsgi.readthedocs.io/en/latest/configuration-directives/WSGIPassAuthorization.html
.. _mod_wsgi: https://modwsgi.readthedocs.io/en/latest/configuration-directives/WSGIPassAuthorization.html
.. setting:: ANYMAIL
@@ -227,10 +227,11 @@ if you are using other Django apps that work with the same ESP.)
Finally, for complex use cases, you can override most settings on a per-instance
basis by providing keyword args where the instance is initialized (e.g., in a
:func:`~django.core.mail.get_connection` call to create an email backend instance,
or in `View.as_view()` call to set up webhooks in a custom urls.py). To get the kwargs
or in a `View.as_view()` call to set up webhooks in a custom urls.py). To get the kwargs
parameter for a setting, drop "ANYMAIL" and the ESP name, and lowercase the rest:
e.g., you can override ANYMAIL_MAILGUN_API_KEY by passing `api_key="abc"` to
:func:`~django.core.mail.get_connection`. See :ref:`multiple-backends` for an example.
e.g., you can override ANYMAIL_MAILGUN_API_KEY for a particular connection by calling
``get_connection("anymail.backends.mailgun.EmailBackend", api_key="abc")``.
See :ref:`multiple-backends` for an example.
There are specific Anymail settings for each ESP (like API keys and urls).
See the :ref:`supported ESPs <supported-esps>` section for details.
@@ -253,7 +254,7 @@ See :ref:`recipients-refused`.
}
.. rubric:: SEND_DEFAULTS and *ESP*\ _SEND_DEFAULTS`
.. rubric:: SEND_DEFAULTS and *ESP*\ _SEND_DEFAULTS
A `dict` of default options to apply to all messages sent through Anymail.
See :ref:`send-defaults`.

View File

@@ -26,18 +26,18 @@ The first approach is usually the simplest. The other two can be
helpful if you are working with Python development tools that
offer type checking or other static code analysis.
Availability of these features varies by ESP, and there may be additional
limitations even when an ESP does support a particular feature. Be sure
to check Anymail's docs for your :ref:`specific ESP <supported-esps>`.
If you try to use a feature your ESP does not offer, Anymail will raise
an :ref:`unsupported feature <unsupported-features>` error.
.. _anymail-send-options:
ESP send options (AnymailMessage)
---------------------------------
Availability of each of these features varies by ESP, and there may be additional
limitations even when an ESP does support a particular feature. Be sure
to check Anymail's docs for your :ref:`specific ESP <supported-esps>`.
If you try to use a feature your ESP does not offer, Anymail will raise
an :ref:`unsupported feature <unsupported-features>` error.
.. class:: AnymailMessage
A subclass of Django's :class:`~django.core.mail.EmailMultiAlternatives`
@@ -167,7 +167,7 @@ ESP send options (AnymailMessage)
ESPs have differing restrictions on tags. For portability,
it's best to stick with strings that start with an alphanumeric
character. (Also, Postmark only allows a single tag per message.)
character. (Also, a few ESPs allow only a single tag per message.)
.. caution::
@@ -359,7 +359,7 @@ ESP send status
* `'queued'` the ESP has accepted the message
and will try to send it asynchronously
* `'invalid'` the ESP considers the sender or recipient email invalid
* `'rejected'` the recipient is on an ESP blacklist
* `'rejected'` the recipient is on an ESP suppression list
(unsubscribe, previous bounces, etc.)
* `'failed'` the attempt to send failed for some other reason
* `'unknown'` anything else
@@ -402,7 +402,8 @@ ESP send status
.. code-block:: python
# This will work with a requests-based backend:
# This will work with a requests-based backend,
# for an ESP whose send API provides a JSON response:
message.anymail_status.esp_response.json()

View File

@@ -10,7 +10,7 @@ email using Django's default SMTP :class:`~django.core.mail.backends.smtp.EmailB
switching to Anymail will be easy. Anymail is designed to "just work" with Django.
If you're not familiar with Django's email functions, please take a look at
":mod:`sending email <django.core.mail>`" in the Django docs first.
:doc:`django:topics/email` in the Django docs first.
Anymail supports most of the functionality of Django's :class:`~django.core.mail.EmailMessage`
and :class:`~django.core.mail.EmailMultiAlternatives` classes.
@@ -39,8 +39,8 @@ function with the ``html_message`` parameter:
send_mail("Subject", "text body", "from@example.com",
["to@example.com"], html_message="<html>html body</html>")
However, many Django email capabilities -- and additional Anymail features --
are only available when working with an :class:`~django.core.mail.EmailMultiAlternatives`
However, many Django email capabilities---and additional Anymail features---are only
available when working with an :class:`~django.core.mail.EmailMultiAlternatives`
object. Use its :meth:`~django.core.mail.EmailMultiAlternatives.attach_alternative`
method to send HTML:
@@ -168,7 +168,8 @@ raise :exc:`~exceptions.AnymailUnsupportedFeature`.
.. setting:: ANYMAIL_IGNORE_UNSUPPORTED_FEATURES
If you'd like to silently ignore :exc:`~exceptions.AnymailUnsupportedFeature`
errors and send the messages anyway, set :setting:`!ANYMAIL_IGNORE_UNSUPPORTED_FEATURES`
errors and send the messages anyway, set
:setting:`"IGNORE_UNSUPPORTED_FEATURES" <ANYMAIL_IGNORE_UNSUPPORTED_FEATURES>`
to `True` in your settings.py:
.. code-block:: python
@@ -197,15 +198,16 @@ If a single message is sent to multiple recipients, and *any* recipient is valid
You can still examine the message's :attr:`~message.AnymailMessage.anymail_status`
property after the send to determine the status of each recipient.
You can disable this exception by setting :setting:`ANYMAIL_IGNORE_RECIPIENT_STATUS`
to `True` in your settings.py, which will cause Anymail to treat any non-API-error response
from your ESP as a successful send.
You can disable this exception by setting
:setting:`"IGNORE_RECIPIENT_STATUS" <ANYMAIL_IGNORE_RECIPIENT_STATUS>` to `True` in
your settings.py `ANYMAIL` dict, which will cause Anymail to treat *any*
response from your ESP (other than an API error) as a successful send.
.. note::
Many ESPs don't check recipient status during the send API call. For example,
Most ESPs don't check recipient status during the send API call. For example,
Mailgun always queues sent messages, so you'll never catch
:exc:`AnymailRecipientsRefused` with the Mailgun backend.
For those ESPs, use Anymail's :ref:`delivery event tracking <event-tracking>`
if you need to be notified of sends to blacklisted or invalid emails.
You can use Anymail's :ref:`delivery event tracking <event-tracking>`
if you need to be notified of sends to suppression-listed or invalid emails.

View File

@@ -211,7 +211,7 @@ for use as merge data:
# Do something this instead:
message.merge_global_data = {
'PRODUCT': product.name, # assuming name is a CharField
'TOTAL_COST': "%.2f" % total_cost,
'TOTAL_COST': "{cost:0.2f}".format(cost=total_cost),
'SHIP_DATE': ship_date.strftime('%B %d, %Y') # US-style "March 15, 2015"
}

View File

@@ -14,7 +14,7 @@ Webhook support is optional. If you haven't yet, you'll need to
project. (You may also want to review :ref:`securing-webhooks`.)
Once you've enabled webhooks, Anymail will send an ``anymail.signals.tracking``
custom Django :mod:`signal <django.dispatch>` for each ESP tracking event it receives.
custom Django :doc:`signal <django:topics/signals>` for each ESP tracking event it receives.
You can connect your own receiver function to this signal for further processing.
Be sure to read Django's `listening to signals`_ docs for information on defining
@@ -40,7 +40,7 @@ Example:
event.recipient, event.click_url))
You can define individual signal receivers, or create one big one for all
event types, which ever you prefer. You can even handle the same event
event types, whichever you prefer. You can even handle the same event
in multiple receivers, if that makes your code cleaner. These
:ref:`signal receiver functions <signal-receivers>` are documented
in more detail below.
@@ -189,8 +189,8 @@ Normalized tracking event
.. attribute:: mta_response
If available, a `str` with a raw (intended for email administrators) response
from the receiving MTA. Otherwise `None`. Often includes SMTP response codes,
but the exact format varies by ESP (and sometimes receiving MTA).
from the receiving mail transfer agent. Otherwise `None`. Often includes SMTP
response codes, but the exact format varies by ESP (and sometimes receiving MTA).
.. attribute:: user_agent
@@ -203,7 +203,7 @@ Normalized tracking event
.. attribute:: esp_event
The "raw" event data from the ESP, deserialized into a python data structure.
The "raw" event data from the ESP, deserialized into a Python data structure.
For most ESPs this is either parsed JSON (as a `dict`), or HTTP POST fields
(as a Django :class:`~django.http.QueryDict`).
@@ -230,7 +230,7 @@ Your Anymail signal receiver must be a function with this signature:
:param AnymailTrackingEvent event: The normalized tracking event.
Almost anything you'd be interested in
will be in here.
:param str esp_name: e.g., "SendMail" or "Postmark". If you are working
:param str esp_name: e.g., "SendGrid" or "Postmark". If you are working
with multiple ESPs, you can use this to distinguish
ESP-specific handling in your shared event processing.
:param \**kwargs: Required by Django's signal mechanism
@@ -259,7 +259,7 @@ And will retry sending the "failed" events, which could cause duplicate
processing in your code.
If your signal receiver code might be slow, you should instead
queue the event for later, asynchronous processing (e.g., using
something like `Celery`_).
something like :pypi:`celery`).
If your signal receiver function is defined within some other
function or instance method, you *must* use the `weak=False`
@@ -268,7 +268,6 @@ but will unpredictably stop being called at some point---typically
on your production server, in a hard-to-debug way. See Django's
`listening to signals`_ docs for more information.
.. _Celery: http://www.celeryproject.org/
.. _listening to signals:
https://docs.djangoproject.com/en/stable/topics/signals/#listening-to-signals

View File

@@ -7,7 +7,7 @@ ESP's templating languages and merge capabilities are generally not compatible
with each other, which can make it hard to move email templates between them.
But since you're working in Django, you already have access to the
extremely-full-featured :mod:`Django templating system <django.template>`.
extremely-full-featured :doc:`Django templating system <django:topics/templates>`.
You don't even have to use Django's template syntax: it supports other
template languages (like Jinja2).
@@ -15,7 +15,7 @@ You're probably already using Django's templating system for your HTML pages,
so it can be an easy decision to use it for your email, too.
To compose email using *Django* templates, you can use Django's
:func:`~django.template.loaders.django.template.loader.render_to_string`
:func:`~django.template.loader.render_to_string`
template shortcut to build the body and html.
Example that builds an email from the templates ``message_subject.txt``,
@@ -24,16 +24,14 @@ Example that builds an email from the templates ``message_subject.txt``,
.. code-block:: python
from django.core.mail import EmailMultiAlternatives
from django.template import Context
from django.template.loader import render_to_string
merge_data = {
'ORDERNO': "12345", 'TRACKINGNO': "1Z987"
}
plaintext_context = Context(autoescape=False) # HTML escaping not appropriate in plaintext
subject = render_to_string("message_subject.txt", merge_data, plaintext_context)
text_body = render_to_string("message_body.txt", merge_data, plaintext_context)
subject = render_to_string("message_subject.txt", merge_data).strip()
text_body = render_to_string("message_body.txt", merge_data)
html_body = render_to_string("message_body.html", merge_data)
msg = EmailMultiAlternatives(subject=subject, from_email="store@example.com",
@@ -41,6 +39,9 @@ Example that builds an email from the templates ``message_subject.txt``,
msg.attach_alternative(html_body, "text/html")
msg.send()
Tip: use Django's :ttag:`{% autoescape off %}<autoescape>` template tag in your
plaintext ``.txt`` templates to avoid inappropriate HTML escaping.
Helpful add-ons
---------------
@@ -48,8 +49,6 @@ Helpful add-ons
These (third-party) packages can be helpful for building your email
in Django:
.. TODO: flesh this out
* :pypi:`django-templated-mail`, :pypi:`django-mail-templated`, or :pypi:`django-mail-templated-simple`
for building messages from sets of Django templates.
* :pypi:`premailer` for inlining css before sending

View File

@@ -73,10 +73,10 @@ Basic usage is covered in the
:ref:`webhooks configuration <webhooks-configuration>` docs.
If something posts to your webhooks without the required shared
secret as basic auth in the HTTP_AUTHORIZATION header, Anymail will
secret as basic auth in the HTTP *Authorization* header, Anymail will
raise an :exc:`AnymailWebhookValidationFailure` error, which is
a subclass of Django's :exc:`~django.core.exceptions.SuspiciousOperation`.
This will result in an HTTP 400 response, without further processing
This will result in an HTTP 400 "bad request" response, without further processing
the data or calling your signal receiver function.
In addition to a single "random:random" string, you can give a list