Inbound: improve inline content handling

* refactor: derive `AnymailInboundMessage` from `email.message.EmailMessage`
  rather than legacy Python 2.7 `email.message.Message`

* feat(inbound): replace confusing `inline_attachments` with `content_id_map`
  and `inlines`; rename `is_inline_attachment` to `is_inline`; deprecate old names 

Closes #328

---------

Co-authored-by: Mike Edmunds <medmunds@gmail.com>
This commit is contained in:
Léo Martinez
2023-07-28 00:10:58 +02:00
committed by GitHub
parent bc8ef9af0f
commit 0ac248254e
13 changed files with 212 additions and 60 deletions

View File

@@ -136,10 +136,17 @@ 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`
is an AnymailInboundMessage---an extension of Python's standard :class:`email.message.EmailMessage`
with additional features to simplify inbound handling.
In addition to the base :class:`~email.message.Message` functionality, it includes these attributes:
.. versionchanged:: 10.1
Earlier releases extended Python's legacy :class:`email.message.Message` class.
:class:`~email.message.EmailMessage` is a superset that fixes bugs and improves
compatibility with email standards.
In addition to the base :class:`~email.message.EmailMessage` functionality,
:class:`!AnymailInboundMessage` includes these attributes:
.. attribute:: envelope_sender
@@ -221,6 +228,10 @@ Normalized inbound message
The message's plaintext message body as a `str`, or `None` if the
message doesn't include a plaintext body.
For certain messages that are sent as plaintext with inline images
(such as those sometimes composed by the Apple Mail app), this will
include only the text before the first inline image.
.. attribute:: html
The message's HTML message body as a `str`, or `None` if the
@@ -228,17 +239,36 @@ Normalized inbound message
.. 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.
A `list` of all attachments to the message, or an empty list if there are
no attachments. See :ref:`inbound-attachments` below a description of the values.
.. attribute:: inline_attachments
If the inbound message includes an attached message, :attr:`!attachments`
will include the attached message and all of *its* attachments, recursively.
Consider Python's :meth:`~email.message.EmailMessage.iter_attachments` as an
alternative that doesn't descend into attached messages.
A `dict` mapping inline Content-ID references to attachment content. Each key is an
.. attribute:: inlines
A `list` of all inline content parts in the message, or an empty list if none.
See :ref:`inbound-attachments` below for a description of the values.
Like :attr:`attachments`, this will recursively descend into any attached messages.
.. versionadded:: 10.1
.. attribute:: content_id_map
A `dict` mapping inline Content-ID references to inline 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..."]``.
``message.content_id_map["abc123..."]``.
The content of each attachment is described in :ref:`inbound-attachments` below.
The value of each item is described in :ref:`inbound-attachments` below.
.. versionadded:: 10.1
This property was previously available as :attr:`!inline_attachments`.
The old name still works, but is deprecated.
.. attribute:: spam_score
@@ -267,38 +297,39 @@ Normalized inbound message
.. rubric:: Other headers, complex messages, etc.
You can use all of Python's :class:`email.message.Message` features with an
You can use all of Python's :class:`email.message.EmailMessage` features with an
AnymailInboundMessage. For example, you can access message headers using
Message's :meth:`mapping interface <email.message.Message.__getitem__>`:
EmailMessage's :meth:`mapping interface <email.message.EmailMessage.__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
And you can use Message methods like :meth:`~email.message.EmailMessage.walk` and
:meth:`~email.message.EmailMessage.get_content_type` to examine more-complex
multipart MIME messages (digests, delivery reports, or whatever).
.. _inbound-attachments:
Handling Inbound Attachments
----------------------------
Attached and inline content
---------------------------
Anymail converts each inbound attachment to a specialized MIME object with
Anymail converts each inbound attachment and inline content to a specialized MIME object with
additional methods for handling attachments and integrating with Django.
The attachment objects in an AnymailInboundMessage's
:attr:`~AnymailInboundMessage.attachments` list and
:attr:`~AnymailInboundMessage.inline_attachments` dict
The objects in an AnymailInboundMessage's
:attr:`~anymail.inbound.AnymailInboundMessage.attachments`,
:attr:`~anymail.inbound.AnymailInboundMessage.inlines`,
and :attr:`~anymail.inbound.AnymailInboundMessage.content_id_map`
have these methods:
.. class:: AnymailInboundMessage
.. method:: as_uploaded_file()
Returns the attachment converted to a Django :class:`~django.core.files.uploadedfile.UploadedFile`
Returns the content 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`:
@@ -322,9 +353,9 @@ have these methods:
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`.
See the Python docs for more info on :meth:`email.message.EmailMessage.get_content_type`,
:meth:`~email.message.EmailMessage.get_content_maintype`, and
:meth:`~email.message.EmailMessage.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
@@ -341,13 +372,19 @@ have these methods:
.. method:: is_attachment()
Returns `True` for a (non-inline) attachment, `False` otherwise.
.. method:: is_inline_attachment()
Returns `True` for an inline attachment (one with :mailheader:`Content-Disposition` "inline"),
Returns `True` for attachment content (with :mailheader:`Content-Disposition` "attachment"),
`False` otherwise.
.. method:: is_inline()
Returns `True` for inline content (with :mailheader:`Content-Disposition` "inline"),
`False` otherwise.
.. versionchanged:: 10.1
This method was previously named :meth:`!is_inline_attachment`;
the old name still works, but is deprecated.
.. method:: get_content_disposition()
Returns the lowercased value (without parameters) of the attachment's
@@ -374,7 +411,7 @@ have these methods:
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
All :class:`AnymailInboundMessage` and :class:`email.message.EmailMessage` 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