From 64f7d31d141062cd35800822c149cfba4c24b963 Mon Sep 17 00:00:00 2001 From: Leo Antunes Date: Thu, 11 Oct 2018 23:29:00 +0200 Subject: [PATCH] also consider Content-ID when marking attachment as inline (#126) Handle MIME attachments with Content-ID as inline by default. Treat MIME attachments that have a *Content-ID* but no explicit *Content-Disposition* header as inline, matching the behavior of many email clients. --- CHANGELOG.rst | 4 +++ anymail/utils.py | 3 ++- docs/sending/django_email.rst | 9 +++++++ tests/test_utils.py | 47 +++++++++++++++++++++++++++++++++++ 4 files changed, 62 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 9c18a2d..3fd83e1 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -33,6 +33,10 @@ v4.3 Features ~~~~~~~~ +* Treat MIME attachments that have a *Content-ID* but no explicit *Content-Disposition* + header as inline, matching the behavior of many email clients. For maximum + compatibility, you should always set both (or use Anymail's inline helper functions). + (Thanks `@costela`_.) * Add (undocumented) DEBUG_API_REQUESTS Anymail setting. When enabled, prints raw API request and response during send. Currently implemented only for Requests-based backends (all but Amazon SES and SparkPost). Because this can expose API keys and diff --git a/anymail/utils.py b/anymail/utils.py index defb912..c543967 100644 --- a/anymail/utils.py +++ b/anymail/utils.py @@ -291,7 +291,8 @@ class Attachment(object): self.content = attachment.as_string().encode(self.encoding) self.mimetype = attachment.get_content_type() - if get_content_disposition(attachment) == 'inline': + content_disposition = get_content_disposition(attachment) + if content_disposition == 'inline' or (not content_disposition and 'Content-ID' in attachment): self.inline = True self.content_id = attachment["Content-ID"] # probably including the <...> if self.content_id is not None: diff --git a/docs/sending/django_email.rst b/docs/sending/django_email.rst index 2273e34..439aa65 100644 --- a/docs/sending/django_email.rst +++ b/docs/sending/django_email.rst @@ -88,6 +88,15 @@ and :meth:`~email.message.Message.add_header` should be helpful.) Even if you mark an attachment as inline, some email clients may decide to also display it as an attachment. This is largely outside your control. +.. versionchanged:: 4.3 + + For convenience, Anymail will treat an attachment with a :mailheader:`Content-ID` + but no :mailheader:`Content-Disposition` as inline. (Many---though not all---email + clients make the same assumption. But to ensure consistent behavior with non-Anymail + email backends, you should always set *both* :mailheader:`Content-ID` and + :mailheader:`Content-Disposition: inline` headers for inline images. Or just use + Anymail's :ref:`inline image helpers `, which handle this for you.) + .. _message-headers: diff --git a/tests/test_utils.py b/tests/test_utils.py index a7d4a51..5af0c8d 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,6 +1,7 @@ # Tests for the anymail/utils.py module # (not to be confused with utilities for testing found in in tests/utils.py) import base64 +from email.mime.image import MIMEImage from unittest import skipIf import six @@ -21,6 +22,7 @@ except ImportError: from anymail.exceptions import AnymailInvalidAddress, _LazyError from anymail.utils import ( parse_address_list, parse_single_address, EmailAddress, + Attachment, is_lazy, force_non_lazy, force_non_lazy_dict, force_non_lazy_list, update_deep, get_request_uri, get_request_basic_auth, parse_rfc2822date, querydict_getfirst) @@ -161,6 +163,51 @@ class ParseAddressListTests(SimpleTestCase): parse_single_address(" ") +class NormalizedAttachmentTests(SimpleTestCase): + """Test utils.Attachment""" + + # (Several basic tests could be added here) + + def test_content_disposition_attachment(self): + image = MIMEImage(b";-)", "x-emoticon") + image["Content-Disposition"] = 'attachment; filename="emoticon.txt"' + att = Attachment(image, "ascii") + self.assertEqual(att.name, "emoticon.txt") + self.assertEqual(att.content, b";-)") + self.assertFalse(att.inline) + self.assertIsNone(att.content_id) + self.assertEqual(att.cid, "") + + def test_content_disposition_inline(self): + image = MIMEImage(b";-)", "x-emoticon") + image["Content-Disposition"] = 'inline' + att = Attachment(image, "ascii") + self.assertIsNone(att.name) + self.assertEqual(att.content, b";-)") + self.assertTrue(att.inline) # even without the Content-ID + self.assertIsNone(att.content_id) + self.assertEqual(att.cid, "") + + image["Content-ID"] = "" + att = Attachment(image, "ascii") + self.assertEqual(att.content_id, "") + self.assertEqual(att.cid, "abc123@example.net") + + def test_content_id_implies_inline(self): + """A MIME object with a Content-ID should be assumed to be inline""" + image = MIMEImage(b";-)", "x-emoticon") + image["Content-ID"] = "" + att = Attachment(image, "ascii") + self.assertTrue(att.inline) + self.assertEqual(att.content_id, "") + + # ... but not if explicit Content-Disposition says otherwise + image["Content-Disposition"] = "attachment" + att = Attachment(image, "ascii") + self.assertFalse(att.inline) + self.assertIsNone(att.content_id) # ignored for non-inline Attachment + + class LazyCoercionTests(SimpleTestCase): """Test utils.is_lazy and force_non_lazy*"""