Skip to content

Commit 0ac2482

Browse files
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 <[email protected]>
1 parent bc8ef9a commit 0ac2482

13 files changed

+210
-58
lines changed

CHANGELOG.rst

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,33 @@ vNext
3030

3131
*Unreleased changes*
3232

33+
Features
34+
~~~~~~~~
35+
36+
* **Inbound:** Improve `AnymailInboundMessage`'s handling of inline content:
37+
38+
* Rename `inline_attachments` to `content_id_map`, more accurately reflecting its function.
39+
* Add new `inlines` property that provides a complete list of inline content,
40+
whether or not it includes a *Content-ID*. This is helpful for accessing
41+
inline images that appear directly in a *multipart/mixed* body, such as those
42+
created by the Apple Mail app.
43+
* Rename `is_inline_attachment()` to just `is_inline()`.
44+
45+
The renamed items are still available, but deprecated, under their old names.
46+
See `docs <http://anymail.dev/en/latest/inbound/#anymail.inbound.AnymailInboundMessage>`__.
47+
(Thanks to `@martinezleoml`_.)
48+
49+
* **Inbound:** `AnymailInboundMessage` now derives from Python's
50+
`email.message.EmailMessage`, which provides improved compatibility with
51+
email standards. (Thanks to `@martinezleoml`_.)
52+
53+
54+
Deprecations
55+
~~~~~~~~~~~~
56+
57+
* **Inbound:** `AnymailInboundMessage.inline_attachments` and `.is_inline_attachment()`
58+
have been renamed---see above.
59+
3360
Other
3461
~~~~~
3562

@@ -1525,6 +1552,7 @@ Features
15251552
.. _@Lekensteyn: https://github.com/Lekensteyn
15261553
.. _@lewistaylor: https://github.com/lewistaylor
15271554
.. _@mark-mishyn: https://github.com/mark-mishyn
1555+
.. _@martinezleoml: https://github.com/martinezleoml
15281556
.. _@mbk-ok: https://github.com/mbk-ok
15291557
.. _@mwheels: https://github.com/mwheels
15301558
.. _@nuschk: https://github.com/nuschk

anymail/inbound.py

Lines changed: 42 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,33 @@
1+
import warnings
12
from base64 import b64decode
2-
from email.message import Message
3+
from email.message import EmailMessage
34
from email.parser import BytesParser, Parser
45
from email.policy import default as default_policy
56
from email.utils import unquote
67

78
from django.core.files.uploadedfile import SimpleUploadedFile
89

10+
from .exceptions import AnymailDeprecationWarning
911
from .utils import angle_wrap, parse_address_list, parse_rfc2822date
1012

1113

12-
class AnymailInboundMessage(Message):
14+
class AnymailInboundMessage(EmailMessage):
1315
"""
1416
A normalized, parsed inbound email message.
1517
16-
A subclass of email.message.Message, with some additional
17-
convenience properties, plus helpful methods backported
18-
from Python 3.6+ email.message.EmailMessage (or really, MIMEPart)
18+
A subclass of email.message.EmailMessage, with some additional
19+
convenience properties.
1920
"""
2021

21-
# Why Python email.message.Message rather than django.core.mail.EmailMessage?
22+
# Why Python email.message.EmailMessage rather than django.core.mail.EmailMessage?
2223
# Django's EmailMessage is really intended for constructing a (limited subset of)
23-
# Message to send; Message is better designed for representing arbitrary messages:
24+
# an EmailMessage to send; Python's EmailMessage is better designed for representing
25+
# arbitrary messages:
2426
#
25-
# * Message is easily parsed from raw mime (which is an inbound format provided
26-
# by many ESPs), and can accurately represent any mime email received
27-
# * Message can represent repeated header fields (e.g., "Received") which
28-
# are common in inbound messages
27+
# * Python's EmailMessage is easily parsed from raw mime (which is an inbound format
28+
# provided by many ESPs), and can accurately represent any mime email received
29+
# * Python's EmailMessage can represent repeated header fields (e.g., "Received")
30+
# which are common in inbound messages
2931
# * Django's EmailMessage defaults a bunch of properties in ways that aren't helpful
3032
# (e.g., from_email from settings)
3133

@@ -103,13 +105,30 @@ def attachments(self):
103105
"""list of attachments (as MIMEPart objects); excludes inlines"""
104106
return [part for part in self.walk() if part.is_attachment()]
105107

108+
@property
109+
def inlines(self):
110+
"""list of inline parts (as MIMEPart objects)"""
111+
return [part for part in self.walk() if part.is_inline()]
112+
106113
@property
107114
def inline_attachments(self):
115+
"""DEPRECATED: use content_id_map instead"""
116+
warnings.warn(
117+
"inline_attachments has been renamed to content_id_map and will be removed"
118+
" in the near future.",
119+
AnymailDeprecationWarning,
120+
)
121+
122+
return self.content_id_map
123+
124+
@property
125+
def content_id_map(self):
108126
"""dict of Content-ID: attachment (as MIMEPart objects)"""
127+
109128
return {
110129
unquote(part["Content-ID"]): part
111130
for part in self.walk()
112-
if part.is_inline_attachment() and part["Content-ID"] is not None
131+
if part.is_inline() and part["Content-ID"] is not None
113132
}
114133

115134
def get_address_header(self, header):
@@ -143,13 +162,19 @@ def _get_body_content(self, content_type):
143162
return part.get_content_text()
144163
return None
145164

146-
# Hoisted from email.message.MIMEPart
147-
def is_attachment(self):
148-
return self.get_content_disposition() == "attachment"
165+
def is_inline(self):
166+
return self.get_content_disposition() == "inline"
149167

150168
# New for Anymail
151169
def is_inline_attachment(self):
152-
return self.get_content_disposition() == "inline"
170+
"""DEPRECATED: use in_inline instead"""
171+
warnings.warn(
172+
"is_inline_attachment has been renamed to is_inline and will be removed"
173+
" in the near future.",
174+
AnymailDeprecationWarning,
175+
)
176+
177+
return self.is_inline()
153178

154179
def get_content_bytes(self):
155180
"""Return the raw payload bytes"""
@@ -331,7 +356,7 @@ def construct(
331356

332357
if attachments is not None:
333358
for attachment in attachments:
334-
if attachment.is_inline_attachment():
359+
if attachment.is_inline():
335360
related.attach(attachment)
336361
else:
337362
msg.attach(attachment)

docs/inbound.rst

Lines changed: 63 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -136,10 +136,17 @@ Normalized inbound message
136136
.. class:: anymail.inbound.AnymailInboundMessage
137137

138138
The :attr:`~AnymailInboundEvent.message` attribute of an :class:`AnymailInboundEvent`
139-
is an AnymailInboundMessage---an extension of Python's standard :class:`email.message.Message`
139+
is an AnymailInboundMessage---an extension of Python's standard :class:`email.message.EmailMessage`
140140
with additional features to simplify inbound handling.
141141

142-
In addition to the base :class:`~email.message.Message` functionality, it includes these attributes:
142+
.. versionchanged:: 10.1
143+
144+
Earlier releases extended Python's legacy :class:`email.message.Message` class.
145+
:class:`~email.message.EmailMessage` is a superset that fixes bugs and improves
146+
compatibility with email standards.
147+
148+
In addition to the base :class:`~email.message.EmailMessage` functionality,
149+
:class:`!AnymailInboundMessage` includes these attributes:
143150

144151
.. attribute:: envelope_sender
145152

@@ -221,24 +228,47 @@ Normalized inbound message
221228
The message's plaintext message body as a `str`, or `None` if the
222229
message doesn't include a plaintext body.
223230

231+
For certain messages that are sent as plaintext with inline images
232+
(such as those sometimes composed by the Apple Mail app), this will
233+
include only the text before the first inline image.
234+
224235
.. attribute:: html
225236

226237
The message's HTML message body as a `str`, or `None` if the
227238
message doesn't include an HTML body.
228239

229240
.. attribute:: attachments
230241

231-
A `list` of all (non-inline) attachments to the message, or an empty list if there are
232-
no attachments. See :ref:`inbound-attachments` below for the contents of each list item.
242+
A `list` of all attachments to the message, or an empty list if there are
243+
no attachments. See :ref:`inbound-attachments` below a description of the values.
244+
245+
If the inbound message includes an attached message, :attr:`!attachments`
246+
will include the attached message and all of *its* attachments, recursively.
247+
Consider Python's :meth:`~email.message.EmailMessage.iter_attachments` as an
248+
alternative that doesn't descend into attached messages.
249+
250+
.. attribute:: inlines
251+
252+
A `list` of all inline content parts in the message, or an empty list if none.
253+
See :ref:`inbound-attachments` below for a description of the values.
254+
255+
Like :attr:`attachments`, this will recursively descend into any attached messages.
233256

234-
.. attribute:: inline_attachments
257+
.. versionadded:: 10.1
235258

236-
A `dict` mapping inline Content-ID references to attachment content. Each key is an
259+
.. attribute:: content_id_map
260+
261+
A `dict` mapping inline Content-ID references to inline content. Each key is an
237262
"unquoted" cid without angle brackets. E.g., if the :attr:`html` body contains
238263
``<img src="cid:abc123...">``, you could get that inline image using
239-
``message.inline_attachments["abc123..."]``.
264+
``message.content_id_map["abc123..."]``.
265+
266+
The value of each item is described in :ref:`inbound-attachments` below.
267+
268+
.. versionadded:: 10.1
240269

241-
The content of each attachment is described in :ref:`inbound-attachments` below.
270+
This property was previously available as :attr:`!inline_attachments`.
271+
The old name still works, but is deprecated.
242272

243273
.. attribute:: spam_score
244274

@@ -267,38 +297,39 @@ Normalized inbound message
267297

268298
.. rubric:: Other headers, complex messages, etc.
269299

270-
You can use all of Python's :class:`email.message.Message` features with an
300+
You can use all of Python's :class:`email.message.EmailMessage` features with an
271301
AnymailInboundMessage. For example, you can access message headers using
272-
Message's :meth:`mapping interface <email.message.Message.__getitem__>`:
302+
EmailMessage's :meth:`mapping interface <email.message.EmailMessage.__getitem__>`:
273303

274304
.. code-block:: python
275305
276306
message['reply-to'] # the Reply-To header (header keys are case-insensitive)
277307
message.getall('DKIM-Signature') # list of all DKIM-Signature headers
278308
279-
And you can use Message methods like :meth:`~email.message.Message.walk` and
280-
:meth:`~email.message.Message.get_content_type` to examine more-complex
309+
And you can use Message methods like :meth:`~email.message.EmailMessage.walk` and
310+
:meth:`~email.message.EmailMessage.get_content_type` to examine more-complex
281311
multipart MIME messages (digests, delivery reports, or whatever).
282312

283313

284314
.. _inbound-attachments:
285315

286-
Handling Inbound Attachments
287-
----------------------------
316+
Attached and inline content
317+
---------------------------
288318

289-
Anymail converts each inbound attachment to a specialized MIME object with
319+
Anymail converts each inbound attachment and inline content to a specialized MIME object with
290320
additional methods for handling attachments and integrating with Django.
291321

292-
The attachment objects in an AnymailInboundMessage's
293-
:attr:`~AnymailInboundMessage.attachments` list and
294-
:attr:`~AnymailInboundMessage.inline_attachments` dict
322+
The objects in an AnymailInboundMessage's
323+
:attr:`~anymail.inbound.AnymailInboundMessage.attachments`,
324+
:attr:`~anymail.inbound.AnymailInboundMessage.inlines`,
325+
and :attr:`~anymail.inbound.AnymailInboundMessage.content_id_map`
295326
have these methods:
296327

297328
.. class:: AnymailInboundMessage
298329

299330
.. method:: as_uploaded_file()
300331

301-
Returns the attachment converted to a Django :class:`~django.core.files.uploadedfile.UploadedFile`
332+
Returns the content converted to a Django :class:`~django.core.files.uploadedfile.UploadedFile`
302333
object. This is suitable for assigning to a model's :class:`~django.db.models.FileField`
303334
or :class:`~django.db.models.ImageField`:
304335

@@ -322,9 +353,9 @@ have these methods:
322353
attachments are essentially user-uploaded content, so you should
323354
:ref:`never trust the sender <inbound-security>`.)
324355

325-
See the Python docs for more info on :meth:`email.message.Message.get_content_type`,
326-
:meth:`~email.message.Message.get_content_maintype`, and
327-
:meth:`~email.message.Message.get_content_subtype`.
356+
See the Python docs for more info on :meth:`email.message.EmailMessage.get_content_type`,
357+
:meth:`~email.message.EmailMessage.get_content_maintype`, and
358+
:meth:`~email.message.EmailMessage.get_content_subtype`.
328359

329360
(Note that you *cannot* determine the attachment type using code like
330361
``issubclass(attachment, email.mime.image.MIMEImage)``. You should instead use something
@@ -341,13 +372,19 @@ have these methods:
341372

342373
.. method:: is_attachment()
343374

344-
Returns `True` for a (non-inline) attachment, `False` otherwise.
375+
Returns `True` for attachment content (with :mailheader:`Content-Disposition` "attachment"),
376+
`False` otherwise.
345377

346-
.. method:: is_inline_attachment()
378+
.. method:: is_inline()
347379

348-
Returns `True` for an inline attachment (one with :mailheader:`Content-Disposition` "inline"),
380+
Returns `True` for inline content (with :mailheader:`Content-Disposition` "inline"),
349381
`False` otherwise.
350382

383+
.. versionchanged:: 10.1
384+
385+
This method was previously named :meth:`!is_inline_attachment`;
386+
the old name still works, but is deprecated.
387+
351388
.. method:: get_content_disposition()
352389

353390
Returns the lowercased value (without parameters) of the attachment's
@@ -374,7 +411,7 @@ have these methods:
374411

375412
An Anymail inbound attachment is actually just an :class:`AnymailInboundMessage` instance,
376413
following the Python email package's usual recursive representation of MIME messages.
377-
All :class:`AnymailInboundMessage` and :class:`email.message.Message` functionality
414+
All :class:`AnymailInboundMessage` and :class:`email.message.EmailMessage` functionality
378415
is available on attachment objects (though of course not all features are meaningful in all contexts).
379416

380417
This can be helpful for, e.g., parsing email messages that are forwarded as attachments

tests/test_amazon_ses_backend.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -267,7 +267,7 @@ def test_embedded_images(self):
267267

268268
self.assertEqual(sent_message.html, html_content)
269269

270-
inlines = sent_message.inline_attachments
270+
inlines = sent_message.content_id_map
271271
self.assertEqual(len(inlines), 1)
272272
self.assertEqual(inlines[cid].get_content_type(), "image/png")
273273
self.assertEqual(inlines[cid].get_filename(), image_filename)

tests/test_amazon_ses_backendv1.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -274,7 +274,7 @@ def test_embedded_images(self):
274274

275275
self.assertEqual(sent_message.html, html_content)
276276

277-
inlines = sent_message.inline_attachments
277+
inlines = sent_message.content_id_map
278278
self.assertEqual(len(inlines), 1)
279279
self.assertEqual(inlines[cid].get_content_type(), "image/png")
280280
self.assertEqual(inlines[cid].get_filename(), image_filename)

0 commit comments

Comments
 (0)