Add inbound mail handling

Add normalized event, signal, and webhooks for inbound mail.

Closes #43
Closes #86
This commit is contained in:
Mike Edmunds
2018-02-02 10:38:53 -08:00
committed by GitHub
parent c924c9ec03
commit b57eb94f64
35 changed files with 2968 additions and 130 deletions

View File

@@ -48,6 +48,10 @@ Email Service Provider |Mailgun| |Mailjet| |Mandrill|
---------------------------------------------------------------------------------------------------------------------
:attr:`~AnymailMessage.anymail_status` Yes Yes Yes Yes Yes Yes
|AnymailTrackingEvent| from webhooks Yes Yes Yes Yes Yes Yes
.. rubric:: :ref:`Inbound handling <inbound>`
---------------------------------------------------------------------------------------------------------------------
|AnymailInboundEvent| from webhooks Yes Yes Yes Yes Yes Yes
============================================ ========== ========== ========== ========== ========== ===========
@@ -63,6 +67,7 @@ meaningless. (And even specific features don't matter if you don't plan to use t
.. |SendGrid| replace:: :ref:`sendgrid-backend`
.. |SparkPost| replace:: :ref:`sparkpost-backend`
.. |AnymailTrackingEvent| replace:: :class:`~anymail.signals.AnymailTrackingEvent`
.. |AnymailInboundEvent| replace:: :class:`~anymail.signals.AnymailInboundEvent`
Other ESPs

View File

@@ -215,3 +215,36 @@ a Django :class:`~django.http.QueryDict` object of `Mailgun event fields`_.
.. _Mailgun dashboard: https://mailgun.com/app/dashboard
.. _Mailgun event fields: https://documentation.mailgun.com/user_manual.html#webhooks
.. _mailgun-inbound:
Inbound webhook
---------------
If you want to receive email from Mailgun through Anymail's normalized :ref:`inbound <inbound>`
handling, follow Mailgun's `Receiving, Storing and Fowarding Messages`_ guide to set up
an inbound route that forwards to Anymail's inbound webhook. (You can configure routes
using Mailgun's API, or simply using the "Routes" tab in your `Mailgun dashboard`_.)
The *action* for your route will be either:
:samp:`forward("https://{random}:{random}@{yoursite.example.com}/anymail/mailgun/inbound/")`
:samp:`forward("https://{random}:{random}@{yoursite.example.com}/anymail/mailgun/inbound_mime/")`
* *random:random* is an :setting:`ANYMAIL_WEBHOOK_AUTHORIZATION` shared secret
* *yoursite.example.com* is your Django site
Anymail accepts either of Mailgun's "fully-parsed" (.../inbound/) and "raw MIME" (.../inbound_mime/)
formats; the URL tells Mailgun which you want. Because Anymail handles parsing and normalizing the data,
both are equally easy to use. The raw MIME option will give the most accurate representation of *any*
received email (including complex forms like multi-message mailing list digests). The fully-parsed option
*may* use less memory while processing messages with many large attachments.
If you want to use Anymail's normalized :attr:`~anymail.inbound.AnymailInboundMessage.spam_detected` and
:attr:`~anymail.inbound.AnymailInboundMessage.spam_score` attributes, you'll need to set your Mailgun
domain's inbound spam filter to "Deliver spam, but add X-Mailgun-SFlag and X-Mailgun-SScore headers"
(in the `Mailgun dashboard`_ on the "Domains" tab).
.. _Receiving, Storing and Fowarding Messages:
https://documentation.mailgun.com/en/latest/user_manual.html#receiving-forwarding-and-storing-messages

View File

@@ -249,3 +249,26 @@ for each event in the batch.)
.. _Event tracking (triggers): https://app.mailjet.com/account/triggers
.. _Mailjet event: https://dev.mailjet.com/guides/#events
.. _mailjet-inbound:
Inbound webhook
---------------
If you want to receive email from Mailjet through Anymail's normalized :ref:`inbound <inbound>`
handling, follow Mailjet's `Parse API inbound emails`_ guide to set up Anymail's inbound webhook.
The parseroute Url parameter will be:
:samp:`https://{random}:{random}@{yoursite.example.com}/anymail/mailjet/inbound/`
* *random:random* is an :setting:`ANYMAIL_WEBHOOK_AUTHORIZATION` shared secret
* *yoursite.example.com* is your Django site
Once you've done Mailjet's "basic setup" to configure the Parse API webhook, you can skip
ahead to the "use your own domain" section of their guide. (Anymail normalizes the inbound
event for you, so you won't need to worry about Mailjet's event and attachment formats.)
.. _Parse API inbound emails:
https://dev.mailjet.com/guides/#parse-api-inbound-emails

View File

@@ -185,27 +185,31 @@ See the `Mandrill's template docs`_ for more information.
.. _mandrill-webhooks:
.. _mandrill-inbound:
Status tracking webhooks
------------------------
Status tracking and inbound webhooks
------------------------------------
If you are using Anymail's normalized :ref:`status tracking <event-tracking>`,
setting up Anymail's webhook URL requires deploying your Django project twice:
If you are using Anymail's normalized :ref:`status tracking <event-tracking>`
and/or :ref:`inbound <inbound>` handling, setting up Anymail's webhook URL
requires deploying your Django project twice:
1. First, follow the instructions to
:ref:`configure Anymail's webhooks <webhooks-configuration>`. You *must*
deploy before adding the webhook URL to Mandrill, because it will attempt
:ref:`configure Anymail's webhooks <webhooks-configuration>`. You *must deploy*
before adding the webhook URL to Mandrill, because Mandrill will attempt
to verify the URL against your production server.
Follow `Mandrill's instructions`_ to add Anymail's webhook URL in their settings:
Once you've deployed, then set Anymail's webhook URL in Mandrill, following their
instructions for `tracking event webhooks`_ (be sure to check the boxes for the
events you want to receive) and/or `inbound route webhooks`_.
In either case, the webhook url is:
:samp:`https://{random}:{random}@{yoursite.example.com}/anymail/mandrill/tracking/`
:samp:`https://{random}:{random}@{yoursite.example.com}/anymail/mandrill/`
* *random:random* is an :setting:`ANYMAIL_WEBHOOK_AUTHORIZATION` shared secret
* *yoursite.example.com* is your Django site
Be sure to check the boxes in the Mandrill settings for the event types you want to receive.
The same Anymail tracking URL can handle all Mandrill "message" and "change" events.
* (Note: Unlike Anymail's other supported ESPs, the Mandrill webhook uses this
single url for both tracking and inbound events.)
2. Mandrill will provide you a "webhook authentication key" once it verifies the URL
is working. Add this to your Django project's Anymail settings under
@@ -226,7 +230,7 @@ else fails, you can set Anymail's :setting:`MANDRILL_WEBHOOK_URL <ANYMAIL_MANDRI
to the same public webhook URL you gave Mandrill.
Mandrill will report these Anymail :attr:`~anymail.signals.AnymailTrackingEvent.event_type`\s:
sent, rejected, deferred, bounced, opened, clicked, complained, unsubscribed. Mandrill does
sent, rejected, deferred, bounced, opened, clicked, complained, unsubscribed, inbound. Mandrill does
not support delivered events. Mandrill "whitelist" and "blacklist" change events will show up
as Anymail's unknown event_type.
@@ -235,8 +239,18 @@ a `dict` of Mandrill event fields, for a single event. (Although Mandrill calls
webhooks with batches of events, Anymail will invoke your signal receiver separately
for each event in the batch.)
.. _Mandrill's instructions:
.. _tracking event webhooks:
https://mandrill.zendesk.com/hc/en-us/articles/205583217-Introduction-to-Webhooks
.. _inbound route webhooks:
https://mandrill.zendesk.com/hc/en-us/articles/205583197-Inbound-Email-Processing-Overview
.. versionchanged:: 1.3
Earlier Anymail releases used :samp:`.../anymail/mandrill/{tracking}/` as the tracking
webhook url. With the addition of inbound handling, Anymail has dropped "tracking"
from the recommended url for new installations. But the older url is still
supported. Existing installations can continue to use it---and can even install it
on a Mandrill *inbound* route to avoid issuing a new webhook key.
.. _migrating-from-djrill:
@@ -298,8 +312,15 @@ Changes to settings
Use :setting:`ANYMAIL_MANDRILL_WEBHOOK_KEY` instead.
``DJRILL_WEBHOOK_URL``
Use :setting:`ANYMAIL_MANDRILL_WEBHOOK_URL`, or eliminate if
your Django server is not behind a proxy that changes hostnames.
Often no longer required: Anymail can normally use Django's
:meth:`HttpRequest.build_absolute_uri <django.http.HttpRequest.build_absolute_uri>`
to figure out the complete webhook url that Mandrill called.
If you are experiencing webhook authorization errors, the best solution is to adjust
your Django :setting:`SECURE_PROXY_SSL_HEADER`, :setting:`USE_X_FORWARDED_HOST`, and/or
:setting:`USE_X_FORWARDED_PORT` settings to work with your proxy server.
If that's not possible, you can set :setting:`ANYMAIL_MANDRILL_WEBHOOK_URL` to explicitly
declare the webhook url.
Changes to EmailMessage attributes
@@ -393,14 +414,17 @@ parameters is that most logging and analytics systems are aware of the
need to keep auth secret.)
Anymail replaces `djrill.signals.webhook_event` with
`anymail.signals.tracking` for delivery tracking events.
(It does not currently handle inbound message webhooks.)
`anymail.signals.tracking` for delivery tracking events,
and `anymail.signals.inbound` for inbound events.
Anymail parses and normalizes
the event data passed to the signal receiver: see :ref:`event-tracking`.
the event data passed to the signal receiver: see :ref:`event-tracking`
and :ref:`inbound`.
The equivalent of Djrill's ``data`` parameter is available
to your signal receiver as
:attr:`event.esp_event <anymail.signals.AnymailTrackingEvent.esp_event>`,
and for most events, the equivalent of Djrill's ``event_type`` parameter
is `event.esp_event['event']`. But consider working with Anymail's
normalized :class:`~anymail.signals.AnymailTrackingEvent` instead.
normalized :class:`~anymail.signals.AnymailTrackingEvent` and
:class:`~anymail.signals.AnymailInboundEvent` instead for easy portability
to other ESPs.

View File

@@ -201,3 +201,26 @@ a `dict` of Postmark `delivery <http://developer.postmarkapp.com/developer-deliv
or `open <http://developer.postmarkapp.com/developer-open-webhook.html>`_ webhook data.
.. _Postmark account settings: https://account.postmarkapp.com/servers
.. _postmark-inbound:
Inbound webhook
---------------
If you want to receive email from Postmark through Anymail's normalized :ref:`inbound <inbound>`
handling, follow Postmark's `Inbound Processing`_ guide to configure
an inbound server pointing to Anymail's inbound webhook.
The InboundHookUrl setting will be:
:samp:`https://{random}:{random}@{yoursite.example.com}/anymail/postmark/inbound/`
* *random:random* is an :setting:`ANYMAIL_WEBHOOK_AUTHORIZATION` shared secret
* *yoursite.example.com* is your Django site
Anymail handles the "parse an email" part of Postmark's instructions for you, but you'll
likely want to work through the other sections to set up a custom inbound domain, and
perhaps configure inbound spam blocking.
.. _Inbound Processing: https://postmarkapp.com/developer/user-guide/inbound

View File

@@ -302,6 +302,37 @@ for each event in the batch.)
.. _Sendgrid event: https://sendgrid.com/docs/API_Reference/Webhooks/event.html
.. _sendgrid-inbound:
Inbound webhook
---------------
If you want to receive email from SendGrid through Anymail's normalized :ref:`inbound <inbound>`
handling, follow SendGrid's `Inbound Parse Webhook`_ guide to set up
Anymail's inbound webhook.
The Destination URL setting will be:
:samp:`https://{random}:{random}@{yoursite.example.com}/anymail/sendgrid/inbound/`
* *random:random* is an :setting:`ANYMAIL_WEBHOOK_AUTHORIZATION` shared secret
* *yoursite.example.com* is your Django site
Be sure the URL has a trailing slash. (SendGrid's inbound processing won't follow Django's
:setting:`APPEND_SLASH` redirect.)
If you want to use Anymail's normalized :attr:`~anymail.inbound.AnymailInboundMessage.spam_detected` and
:attr:`~anymail.inbound.AnymailInboundMessage.spam_score` attributes, be sure to enable the "Check
incoming emails for spam" checkbox.
You have a choice for SendGrid's "POST the raw, full MIME message" checkbox. Anymail will handle
either option (and you can change it at any time). Enabling raw MIME will give the most accurate
representation of *any* received email (including complex forms like multi-message mailing list
digests). But disabling it *may* use less memory while processing messages with many large attachments.
.. _Inbound Parse Webhook:
https://sendgrid.com/docs/Classroom/Basics/Inbound_Parse_Webhook/setting_up_the_inbound_parse_webhook.html
.. _sendgrid-v3-upgrade:

View File

@@ -220,3 +220,23 @@ The esp_event is the raw, `wrapped json event structure`_ as provided by SparkPo
https://support.sparkpost.com/customer/portal/articles/1976204-webhook-event-reference
.. _wrapped json event structure:
https://support.sparkpost.com/customer/en/portal/articles/2311698-comparing-webhook-and-message-event-data
.. _sparkpost-inbound:
Inbound webhook
---------------
If you want to receive email from SparkPost through Anymail's normalized :ref:`inbound <inbound>`
handling, follow SparkPost's `Enabling Inbound Email Relaying`_ guide to set up
Anymail's inbound webhook.
The target parameter for the Relay Webhook will be:
:samp:`https://{random}:{random}@{yoursite.example.com}/anymail/sparkpost/inbound/`
* *random:random* is an :setting:`ANYMAIL_WEBHOOK_AUTHORIZATION` shared secret
* *yoursite.example.com* is your Django site
.. _Enabling Inbound Email Relaying:
https://www.sparkpost.com/docs/tech-resources/inbound-email-relay-webhook/

454
docs/inbound.rst Normal file
View File

@@ -0,0 +1,454 @@
.. _inbound:
Receiving mail
==============
.. versionadded:: 1.3
For ESPs that support receiving inbound email, Anymail offers normalized handling
of inbound events.
If you didn't set up webhooks when first installing Anymail, you'll need to
:ref:`configure webhooks <webhooks-configuration>` to get started with inbound email.
(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.
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
and have different event parameters.)
Be sure to read Django's :doc:`listening to signals <django:topics/signals>` docs
for information on defining and connecting signal receivers.
Example:
.. code-block:: python
from anymail.signals import inbound
from django.dispatch import receiver
@receiver(inbound) # add weak=False if inside some other function/class
def handle_inbound(sender, event, esp_name, **kwargs):
message = event.message
print("Received message from %s (envelope sender %s) with subject '%s'" % (
message.from_email, message.envelope_sender, message.subject))
Some ESPs batch up multiple inbound messages into a single webhook call. Anymail will
invoke your signal receiver once, separately, for each message in the batch.
.. _inbound-security:
.. warning:: **Be careful with inbound email**
Inbound email is user-supplied content. There are all kinds of ways a
malicious sender can abuse the email format to give your app misleading
or dangerous data. Treat inbound email content with the same suspicion
you'd apply to any user-submitted data. Among other concerns:
* Senders can spoof the From header. An inbound message's
:attr:`~anymail.inbound.AnymailInboundMessage.from_email` may
or may not match the actual address that sent the message. (There are both
legitimate and malicious uses for this capability.)
* Most other fields in email can be falsified. E.g., an inbound message's
:attr:`~anymail.inbound.AnymailInboundMessage.date` may or may not accurately
reflect when the message was sent.
* Inbound attachments have the same security concerns as user-uploaded files.
If you process inbound attachments, you'll need to verify that the
attachment content is valid.
This is particularly important if you publish the attachment content
through your app. For example, an "image" attachment could actually contain an
executable file or raw HTML. You wouldn't want to serve that as a user's avatar.
It's *not* sufficient to check the attachment's content-type or
filename extension---senders can falsify both of those.
Consider `using python-magic`_ or a similar approach
to validate the *actual attachment content*.
The Django docs have additional notes on
: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/
.. _inbound-event:
Normalized inbound event
------------------------
.. class:: anymail.signals.AnymailInboundEvent
The `event` parameter to Anymail's `inbound`
:ref:`signal receiver <inbound-signal-receivers>` is an object
with the following attributes:
.. 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`
attribute. See the full details :ref:`below <inbound-message>`.
.. attribute:: event_type
A normalized `str` identifying the type of event. For inbound events,
this is always `'inbound'`.
.. attribute:: timestamp
A `~datetime.datetime` indicating when the inbound event was generated
by the ESP, if available; otherwise `None`. (Very few ESPs provide this info.)
This is typically when the ESP received the message or shortly
thereafter. (Use :attr:`event.message.date <anymail.inbound.AnymailInboundMessage.date>`
if you're interested in when the message was sent.)
(The timestamp's timezone is often UTC, but the exact behavior depends
on your ESP and account settings. Anymail ensures that this value is
an *aware* datetime with an accurate timezone.)
.. attribute:: event_id
A `str` unique identifier for the event, if available; otherwise `None`.
Can be used to avoid processing the same event twice. The exact format varies
by ESP, and very few ESPs provide an event_id for inbound messages.
An alternative approach to avoiding duplicate processing is to use the
inbound message's :mailheader:`Message-ID` header (``event.message['Message-ID']``).
.. attribute:: esp_event
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 sometimes the
complete Django :class:`~django.http.HttpRequest` received by the webhook.
This gives you (non-portable) access to original event provided by your ESP,
which can be helpful if you need to access data Anymail doesn't normalize.
.. _inbound-message:
Normalized inbound message
--------------------------
.. class:: anymail.inbound.AnymailInboundMessage
The :attr:`~AnymailInboundEvent.message` attribute of an :class:`AnymailInboundEvent`
is an AnymailInboundMessage---an extension of Python's standard :class:`email.message.Message`
with additional features to simplify inbound handling.
In addition to the base :class:`~email.message.Message` functionality, it includes these attributes:
.. attribute:: envelope_sender
The actual sending address of the inbound message, as determined by your ESP.
This is a `str` "addr-spec"---just the email address portion without any display
name (``"sender@example.com"``)---or `None` if the ESP didn't provide a value.
The envelope sender often won't match the message's From header---for example,
messages sent on someone's behalf (mailing lists, invitations) or when a spammer
deliberately falsifies the From address.
.. attribute:: envelope_recipient
The actual destination address the inbound message was delivered to.
This is a `str` "addr-spec"---just the email address portion without any display
name (``"recipient@example.com"``)---or `None` if the ESP didn't provide a value.
The envelope recipient may not appear in the To or Cc recipient lists---for example,
if your inbound address is bcc'd on a message.
.. attribute:: from_email
The value of the message's From header. Anymail converts this to an
:class:`~anymail.utils.EmailAddress` object, which makes it easier to access
the parsed address fields:
.. code-block:: python
>>> str(message.from_email) # the fully-formatted address
'"Dr. Justin Customer, CPA" <jcustomer@example.com>'
>>> message.from_email.addr_spec # the "email" portion of the address
'jcustomer@example.com'
>>> message.from_email.display_name # empty string if no display name
'Dr. Justin Customer, CPA'
>>> message.from_email.domain
'example.com'
>>> message.from_email.username
'jcustomer'
(This API is borrowed from Python 3.6's :class:`email.headerregistry.Address`.)
If the message has an invalid or missing From header, this property will be `None`.
Note that From headers can be misleading; see :attr:`envelope_sender`.
.. attribute:: to
A `list` of of parsed :class:`~anymail.utils.EmailAddress` objects from the To header,
or an empty list if that header is missing or invalid. Each address in the list
has the same properties as shown above for :attr:`from_email`.
See :attr:`envelope_recipient` if you need to know the actual inbound address
that received the inbound message.
.. attribute:: cc
A `list` of of parsed :class:`~anymail.utils.EmailAddress` objects, like :attr:`to`,
but from the Cc headers.
.. attribute:: subject
The value of the message's Subject header, as a `str`, or `None` if there is no Subject
header.
.. attribute:: date
The value of the message's Date header, as a `~datetime.datetime` object, or `None`
if the Date header is missing or invalid. This attribute will almost always be an
aware datetime (with a timezone); in rare cases it can be naive if the sending mailer
indicated that it had no timezone information available.
The Date header is the sender's claim about when it sent the message, which isn't
necessarily accurate. (If you need to know when the message was received at your ESP,
that might be available in :attr:`event.timestamp <anymail.signals.AnymailInboundEvent.timestamp>`.
If not, you'd need to parse the messages's :mailheader:`Received` headers,
which can be non-trivial.)
.. attribute:: text
The message's plaintext message body as a `str`, or `None` if the
message doesn't include a plaintext body.
.. attribute:: html
The message's HTML message body as a `str`, or `None` if the
message doesn't include an HTML body.
.. attribute:: attachments
A `list` of all (non-inline) attachments to the message, or an empty list if there are
no attachments. See :ref:`inbound-attachments` below for the contents of each list item.
.. attribute:: inline_attachments
A `dict` mapping inline Content-ID references to attachment content. Each key is an
"unquoted" cid without angle brackets. E.g., if the :attr:`html` body contains
``<img src="cid:abc123...">``, you could get that inline image using
``message.inline_attachments["abc123..."]``.
The content of each attachment is described in :ref:`inbound-attachments` below.
.. attribute:: spam_score
A `float` spam score (usually from SpamAssassin) if your ESP provides it; otherwise `None`.
The range of values varies by ESP and spam-filtering configuration, so you may need to
experiment to find a useful threshold.
.. attribute:: spam_detected
If your ESP provides a simple yes/no spam determination, a `bool` indicating whether the
ESP thinks the inbound message is probably spam. Otherwise `None`. (Most ESPs just assign
a :attr:`spam_score` and leave its interpretation up to you.)
.. attribute:: stripped_text
If provided by your ESP, a simplified version the inbound message's plaintext body;
otherwise `None`.
What exactly gets "stripped" varies by ESP, but it often omits quoted replies
and sometimes signature blocks. (And ESPs who do offer stripped bodies
usually consider the feature experimental.)
.. attribute:: stripped_html
Like :attr:`stripped_text`, but for the HTML body. (Very few ESPs support this.)
.. rubric:: Other headers, complex messages, etc.
You can use all of Python's :class:`email.message.Message` features with an
AnymailInboundMessage. For example, you can access message headers using
Message's :meth:`mapping interface <email.message.Message.__getitem__>`:
.. code-block:: python
message['reply-to'] # the Reply-To header (header keys are case-insensitive)
message.getall('DKIM-Signature') # list of all DKIM-Signature headers
And you can use Message methods like :meth:`~email.message.Message.walk` and
:meth:`~email.message.Message.get_content_type` to examine more-complex
multipart MIME messages (digests, delivery reports, or whatever).
.. _inbound-attachments:
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
:attr:`~AnymailInboundMessage.inline_attachments` dict
have these methods:
.. class:: AnymailInboundMessage
.. method:: as_uploaded_file()
Returns the attachment converted to a Django :class:`~django.core.files.uploadedfile.UploadedFile`
object. This is suitable for assigning to a model's :class:`~django.db.models.FileField`
or :class:`~django.db.models.ImageField`:
.. code-block:: python
# allow users to mail in jpeg attachments to set their profile avatars...
if attachment.get_content_type() == "image/jpeg":
# for security, you must verify the content is really a jpeg
# (you'll need to supply the is_valid_jpeg function)
if is_valid_jpeg(attachment.get_content_bytes()):
user.profile.avatar_image = attachment.as_uploaded_file()
See Django's docs on :doc:`django:topics/files` for more information
on working with uploaded files.
.. method:: get_content_type()
.. method:: get_content_maintype()
.. method:: get_content_subtype()
The type of attachment content, as specified by the sender. (But remember
attachments are essentially user-uploaded content, so you should
:ref:`never trust the sender <inbound-security>`.)
See the Python docs for more info on :meth:`email.message.Message.get_content_type`,
:meth:`~email.message.Message.get_content_maintype`, and
:meth:`~email.message.Message.get_content_subtype`.
(Note that you *cannot* determine the attachment type using code like
``issubclass(attachment, email.mime.image.MIMEImage)``. You should instead use something
like ``attachment.get_content_maintype() == 'image'``. The email package's specialized
MIME subclasses are designed for constructing new messages, and aren't used
for parsing existing, inbound email messages.)
.. method:: get_filename()
The original filename of the attachment, as specified by the sender.
*Never* use this filename directly to write files---that would be a huge security hole.
(What would your app do if the sender gave the filename "/etc/passwd" or "../settings.py"?)
.. 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()
Returns `True` for an inline attachment (one with :mailheader:`Content-Disposition` "inline"),
`False` otherwise.
.. method:: get_content_disposition()
Returns the lowercased value (without parameters) of the attachment's
: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='utf-8')
Returns the content of the attachment decoded to a `str` in the given charset.
(This is generally only appropriate for text or message-type attachments.)
.. method:: get_content_bytes()
Returns the raw content of the attachment as bytes. (This will automatically decode
any base64-encoded attachment data.)
.. rubric:: Complex attachments
An Anymail inbound attachment is actually just an :class:`AnymailInboundMessage` instance,
following the Python email package's usual recursive representation of MIME messages.
All :class:`AnymailInboundMessage` and :class:`email.message.Message` functionality
is available on attachment objects (though of course not all features are meaningful in all contexts).
This can be helpful for, e.g., parsing email messages that are forwarded as attachments
to an inbound message.
Anymail loads all attachment content into memory as it processes each inbound
message. This may limit the size of attachments your app can handle, beyond
any attachment size limits imposed by your ESP. Depending on how your ESP transmits
attachments, you may also need to adjust Django's :setting:`DATA_UPLOAD_MAX_MEMORY_SIZE`
setting to successfully receive larger attachments.
.. _inbound-signal-receivers:
Inbound signal receiver functions
---------------------------------
Your Anymail inbound signal receiver must be a function with this signature:
.. function:: def my_handler(sender, event, esp_name, **kwargs):
(You can name it anything you want.)
:param class sender: The source of the event. (One of the
:mod:`anymail.webhook.*` View classes, but you
generally won't examine this parameter; it's
required by Django's signal mechanism.)
:param AnymailInboundEvent event: The normalized inbound event.
Almost anything you'd be interested in
will be in here---usually in the
:class:`~anymail.inbound.AnymailInboundMessage`
found in `event.message`.
:param str esp_name: e.g., "SendMail" 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
(to support future extensions).
:returns: nothing
:raises: any exceptions in your signal receiver will result
in a 400 HTTP error to the webhook. See discussion
below.
.. TODO: this section is almost exactly duplicated from tracking. Combine somehow?
If (any of) your signal receivers raise an exception, Anymail
will discontinue processing the current batch of events and return
an HTTP 400 error to the ESP. Most ESPs respond to this by re-sending
the event(s) later, a limited number of times.
This is the desired behavior for transient problems (e.g., your
Django database being unavailable), but can cause confusion in other
error cases. You may want to catch some (or all) exceptions
in your signal receiver, log the problem for later follow up,
and allow Anymail to return the normal 200 success response
to your ESP.
Some ESPs impose strict time limits on webhooks, and will consider
them failed if they don't respond within (say) five seconds.
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`_).
If your signal receiver function is defined within some other
function or instance method, you *must* use the `weak=False`
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

@@ -21,6 +21,7 @@ Documentation
quickstart
installation
sending/index
inbound
esps/index
tips/index
troubleshooting

View File

@@ -6,28 +6,23 @@ Installation and configuration
Installing Anymail
------------------
It's easiest to install Anymail from PyPI using pip.
To use Anymail in your Django project:
1. Install the django-anymail app. It's easiest to install from PyPI using pip:
.. code-block:: console
$ pip install django-anymail[sendgrid,sparkpost]
The `[sendgrid,sparkpost]` part of that command tells pip you also
want to install additional packages required for those ESPs.
You can give one or more comma-separated, lowercase ESP names.
(Most ESPs don't have additional requirements, so you can often
just skip this. Or change your mind later. Anymail will let you know
if there are any missing dependencies when you try to use it.)
The `[sendgrid,sparkpost]` part of that command tells pip you also
want to install additional packages required for those ESPs.
You can give one or more comma-separated, lowercase ESP names.
(Most ESPs don't have additional requirements, so you can often
just skip this. Or change your mind later. Anymail will let you know
if there are any missing dependencies when you try to use it.)
.. _backend-configuration:
Configuring Django's email backend
----------------------------------
To use Anymail for sending email, edit your Django project's :file:`settings.py`:
1. Add :mod:`anymail` to your :setting:`INSTALLED_APPS` (anywhere in the list):
2. Edit your Django project's :file:`settings.py`, and add :mod:`anymail`
to your :setting:`INSTALLED_APPS` (anywhere in the list):
.. code-block:: python
@@ -37,8 +32,8 @@ To use Anymail for sending email, edit your Django project's :file:`settings.py`
# ...
]
2. Add an :setting:`ANYMAIL` settings dict, substituting the appropriate settings for
your ESP:
3. Also in :file:`settings.py`, add an :setting:`ANYMAIL` settings dict,
substituting the appropriate settings for your ESP. E.g.:
.. code-block:: python
@@ -49,7 +44,20 @@ To use Anymail for sending email, edit your Django project's :file:`settings.py`
The exact settings vary by ESP.
See the :ref:`supported ESPs <supported-esps>` section for specifics.
3. Change your existing Django :setting:`EMAIL_BACKEND` to the Anymail backend
Then continue with either or both of the next two sections, depending
on which Anymail features you want to use.
.. _backend-configuration:
Configuring Django's email backend
----------------------------------
To use Anymail for *sending* email from Django, make additional changes
in your project's :file:`settings.py`. (Skip this section if you are only
planning to *receive* email.)
1. Change your existing Django :setting:`EMAIL_BACKEND` to the Anymail backend
for your ESP. For example, to send using Mailgun by default:
.. code-block:: python
@@ -60,25 +68,27 @@ To use Anymail for sending email, edit your Django project's :file:`settings.py`
use :ref:`multiple Anymail backends <multiple-backends>` to send particular
messages through different ESPs.)
Finally, if you don't already have a :setting:`DEFAULT_FROM_EMAIL` in your settings,
this is a good time to add one. (Django's default is "webmaster\@localhost",
which some ESPs will reject.)
2. If you don't already have a :setting:`DEFAULT_FROM_EMAIL` in your settings,
this is a good time to add one. (Django's default is "webmaster\@localhost",
which some ESPs will reject.)
With the settings above, you are ready to send outgoing email through your ESP.
If you also want to enable status tracking, continue with the
optional settings below. Otherwise, skip ahead to :ref:`sending-email`.
If you also want to enable status tracking or inbound handling, continue with the
settings below. Otherwise, skip ahead to :ref:`sending-email`.
.. _webhooks-configuration:
Configuring status tracking webhooks (optional)
-----------------------------------------------
Configuring tracking and inbound webhooks
-----------------------------------------
Anymail can optionally connect to your ESP's event webhooks to notify your app
of status like bounced and rejected emails, successful delivery, message opens
and clicks, and other tracking.
Anymail can optionally connect to your ESP's event webhooks to notify your app of:
If you aren't using Anymail's webhooks, skip this section.
* status tracking events for sent email, like bounced or rejected messages,
successful delivery, message opens and clicks, etc.
* inbound message events, if you are set up to receive email through your ESP
Skip this section if you won't be using Anymail's webhooks.
.. warning::
@@ -87,14 +97,13 @@ If you aren't using Anymail's webhooks, skip this section.
that could expose your users' emails and other private information,
or subject your app to malicious input data.
At a minimum, your site should **use SSL** (https), and you should
At a minimum, your site should **use https** and you should
configure **webhook authorization** as described below.
See :ref:`securing-webhooks` for additional information.
If you want to use Anymail's status tracking webhooks, follow the steps above
to :ref:`configure an Anymail backend <backend-configuration>`, and then:
If you want to use Anymail's inbound or tracking webhooks:
1. In your :file:`settings.py`, add
:setting:`WEBHOOK_AUTHORIZATION <ANYMAIL_WEBHOOK_AUTHORIZATION>`
@@ -148,31 +157,33 @@ to :ref:`configure an Anymail backend <backend-configuration>`, and then:
3. Enter the webhook URL(s) into your ESP's dashboard or control panel.
In most cases, the URL will be:
:samp:`https://{random}:{random}@{yoursite.example.com}/anymail/{esp}/tracking/`
:samp:`https://{random}:{random}@{yoursite.example.com}/anymail/{esp}/{type}/`
* "https" (rather than http) is *strongly recommended*
* *random:random* is the WEBHOOK_AUTHORIZATION string you created in step 1
* *yoursite.example.com* is your Django site
* "anymail" is the url prefix (from step 2)
* *esp* is the lowercase name of your ESP (e.g., "sendgrid" or "mailgun")
* "tracking" is used for Anymail's sent-mail event tracking webhooks
* *type* is either "tracking" for Anymail's sent-mail event tracking webhooks,
or "inbound" for receiving email
Some ESPs support different webhooks for different tracking events. You can
usually enter the same Anymail webhook URL for all of them (or all that you
want to receive). But be sure to check the specific details for your ESP
under :ref:`supported-esps`.
usually enter the same Anymail *tracking* webhook URL for all of them (or all that you
want to receive)---but be sure to use the separate *inbound* URL for inbound webhooks.
And always check the specific details for your ESP under :ref:`supported-esps`.
Also, some ESPs try to validate the webhook URL immediately when you enter it.
If so, you'll need to deploy your Django project to your live server before you
can complete this step.
Some WSGI servers may need additional settings to pass HTTP authorization headers
through to Django. For example, Apache with `mod_wsgi`_ requires
`WSGIPassAuthorization On`, else Anymail will complain about "missing or invalid
basic auth" when your webhook is called.
Some WSGI servers may need additional settings to pass HTTP authorization headers
through to Django. For example, Apache with `mod_wsgi`_ requires
`WSGIPassAuthorization On`, else Anymail will complain about "missing or invalid
basic auth" when your webhook is called.
See :ref:`event-tracking` for information on creating signal handlers and the
status tracking events you can receive.
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

View File

@@ -17,5 +17,6 @@ Problems? We have some :ref:`troubleshooting` info that may help.
Now that you've got Anymail working, you might be interested in:
* :ref:`Sending email with Anymail <sending-email>`
* :ref:`Receiving inbound email <inbound>`
* :ref:`ESP-specific information <supported-esps>`
* :ref:`All the docs <main-toc>`