Skip to content

Commit 5482757

Browse files
committed
Improve inline-image handling
* Add filename param to attach_inline_image * Add attach_inline_image_file function (parallels EmailMessage.attach and attach_file) * Use `Content-Disposition: inline` to decide whether an attachment should be handled inline (whether or not it's an image, and whether or not it has a Content-ID) * Stop conflating filename and Content-ID, for ESPs that allow both. (Solves problem where Google Inbox was displaying inline images as attachments when sent through SendGrid.)
1 parent 701726c commit 5482757

File tree

10 files changed

+133
-61
lines changed

10 files changed

+133
-61
lines changed

README.rst

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ or any other supported ESP where you see "mailgun":
107107
.. code-block:: python
108108
109109
from django.core.mail import EmailMultiAlternatives
110-
from anymail.message import attach_inline_image
110+
from anymail.message import attach_inline_image_file
111111
112112
msg = EmailMultiAlternatives(
113113
subject="Please activate your account",
@@ -117,7 +117,7 @@ or any other supported ESP where you see "mailgun":
117117
reply_to=["Helpdesk <[email protected]>"])
118118
119119
# Include an inline image in the html:
120-
logo_cid = attach_inline_image(msg, open("logo.jpg", "rb").read())
120+
logo_cid = attach_inline_image_file(msg, "/path/to/logo.jpg")
121121
html = """<img alt="Logo" src="cid:{logo_cid}">
122122
<p>Please <a href="http://example.com/activate">activate</a>
123123
your account</p>""".format(logo_cid=logo_cid)

anymail/message.py

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
from email.mime.image import MIMEImage
2+
from email.utils import unquote
3+
import os
24

35
from django.core.mail import EmailMessage, EmailMultiAlternatives, make_msgid
46

@@ -28,23 +30,37 @@ def __init__(self, *args, **kwargs):
2830
# noinspection PyArgumentList
2931
super(AnymailMessageMixin, self).__init__(*args, **kwargs)
3032

31-
def attach_inline_image(self, content, subtype=None, idstring="img", domain=None):
33+
def attach_inline_image_file(self, path, subtype=None, idstring="img", domain=None):
34+
"""Add inline image from file path to an EmailMessage, and return its content id"""
35+
assert isinstance(self, EmailMessage)
36+
return attach_inline_image_file(self, path, subtype, idstring, domain)
37+
38+
def attach_inline_image(self, content, filename=None, subtype=None, idstring="img", domain=None):
3239
"""Add inline image and return its content id"""
3340
assert isinstance(self, EmailMessage)
34-
return attach_inline_image(self, content, subtype, idstring, domain)
41+
return attach_inline_image(self, content, filename, subtype, idstring, domain)
3542

3643

3744
class AnymailMessage(AnymailMessageMixin, EmailMultiAlternatives):
3845
pass
3946

4047

41-
def attach_inline_image(message, content, subtype=None, idstring="img", domain=None):
48+
def attach_inline_image_file(message, path, subtype=None, idstring="img", domain=None):
49+
"""Add inline image from file path to an EmailMessage, and return its content id"""
50+
filename = os.path.basename(path)
51+
with open(path, 'rb') as f:
52+
content = f.read()
53+
return attach_inline_image(message, content, filename, subtype, idstring, domain)
54+
55+
56+
def attach_inline_image(message, content, filename=None, subtype=None, idstring="img", domain=None):
4257
"""Add inline image to an EmailMessage, and return its content id"""
43-
cid = make_msgid(idstring, domain) # Content ID per RFC 2045 section 7 (with <...>)
58+
content_id = make_msgid(idstring, domain) # Content ID per RFC 2045 section 7 (with <...>)
4459
image = MIMEImage(content, subtype)
45-
image.add_header('Content-ID', cid)
60+
image.add_header('Content-Disposition', 'inline', filename=filename)
61+
image.add_header('Content-ID', content_id)
4662
message.attach(image)
47-
return cid[1:-1] # Without <...>, for use as the <img> tag src
63+
return unquote(content_id) # Without <...>, for use as the <img> tag src
4864

4965

5066
ANYMAIL_STATUSES = [

anymail/tests/test_mailgun_backend.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from django.utils.timezone import get_fixed_timezone, override as override_current_timezone
1515

1616
from anymail.exceptions import AnymailAPIError, AnymailSerializationError, AnymailUnsupportedFeature
17-
from anymail.message import attach_inline_image
17+
from anymail.message import attach_inline_image_file
1818

1919
from .mock_requests_backend import RequestsBackendMockAPITestCase
2020
from .utils import sample_image_content, sample_image_path, SAMPLE_IMAGE_FILENAME, AnymailTestMixin
@@ -161,8 +161,11 @@ def test_unicode_attachment_correctly_decoded(self):
161161
self.assertEqual(len(attachments), 1)
162162

163163
def test_embedded_images(self):
164-
image_data = sample_image_content() # Read from a png file
165-
cid = attach_inline_image(self.message, image_data)
164+
image_filename = SAMPLE_IMAGE_FILENAME
165+
image_path = sample_image_path(image_filename)
166+
image_data = sample_image_content(image_filename)
167+
168+
cid = attach_inline_image_file(self.message, image_path)
166169
html_content = '<p>This has an <img src="cid:%s" alt="inline" /> image.</p>' % cid
167170
self.message.attach_alternative(html_content, "text/html")
168171

anymail/tests/test_mailgun_integration.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,7 @@
1212
from anymail.exceptions import AnymailAPIError
1313
from anymail.message import AnymailMessage
1414

15-
from .utils import sample_image_content, AnymailTestMixin
16-
15+
from .utils import AnymailTestMixin, sample_image_path
1716

1817
MAILGUN_TEST_API_KEY = os.getenv('MAILGUN_TEST_API_KEY')
1918
MAILGUN_TEST_DOMAIN = os.getenv('MAILGUN_TEST_DOMAIN')
@@ -105,7 +104,7 @@ def test_all_options(self):
105104
)
106105
message.attach("attachment1.txt", "Here is some\ntext for you", "text/plain")
107106
message.attach("attachment2.csv", "ID,Name\n1,3", "text/csv")
108-
cid = message.attach_inline_image(sample_image_content(), domain=MAILGUN_TEST_DOMAIN)
107+
cid = message.attach_inline_image_file(sample_image_path(), domain=MAILGUN_TEST_DOMAIN)
109108
message.attach_alternative(
110109
"<div>This is the <i>html</i> body <img src='cid:%s'></div>" % cid,
111110
"text/html")

anymail/tests/test_sendgrid_backend.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from django.utils.timezone import get_fixed_timezone, override as override_current_timezone
1717

1818
from anymail.exceptions import AnymailAPIError, AnymailSerializationError, AnymailUnsupportedFeature
19-
from anymail.message import attach_inline_image
19+
from anymail.message import attach_inline_image_file
2020

2121
from .mock_requests_backend import RequestsBackendMockAPITestCase
2222
from .utils import sample_image_content, sample_image_path, SAMPLE_IMAGE_FILENAME, AnymailTestMixin
@@ -209,8 +209,11 @@ def test_unicode_attachment_correctly_decoded(self):
209209
('Une pièce jointe.html', '<p>\u2019</p>', 'text/html'))
210210

211211
def test_embedded_images(self):
212-
image_data = sample_image_content() # Read from a png file
213-
cid = attach_inline_image(self.message, image_data)
212+
image_filename = SAMPLE_IMAGE_FILENAME
213+
image_path = sample_image_path(image_filename)
214+
image_data = sample_image_content(image_filename)
215+
216+
cid = attach_inline_image_file(self.message, image_path) # Read from a png file
214217
html_content = '<p>This has an <img src="cid:%s" alt="inline" /> image.</p>' % cid
215218
self.message.attach_alternative(html_content, "text/html")
216219

@@ -219,11 +222,10 @@ def test_embedded_images(self):
219222
self.assertEqual(data['html'], html_content)
220223

221224
files = self.get_api_call_files()
222-
filename = cid # (for now)
223225
self.assertEqual(files, {
224-
'files[%s]' % filename: (filename, image_data, "image/png"),
226+
'files[%s]' % image_filename: (image_filename, image_data, "image/png"),
225227
})
226-
self.assertEqual(data['content[%s]' % filename], cid)
228+
self.assertEqual(data['content[%s]' % image_filename], cid)
227229

228230
def test_attached_images(self):
229231
image_filename = SAMPLE_IMAGE_FILENAME

anymail/tests/test_sendgrid_integration.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,7 @@
1010
from anymail.exceptions import AnymailAPIError
1111
from anymail.message import AnymailMessage
1212

13-
from .utils import sample_image_content, AnymailTestMixin
14-
13+
from .utils import AnymailTestMixin, sample_image_path
1514

1615
SENDGRID_TEST_API_KEY = os.getenv('SENDGRID_TEST_API_KEY')
1716

@@ -75,16 +74,14 @@ def test_all_options(self):
7574
)
7675
message.attach("attachment1.txt", "Here is some\ntext for you", "text/plain")
7776
message.attach("attachment2.csv", "ID,Name\n1,Amy Lina", "text/csv")
78-
cid = message.attach_inline_image(sample_image_content())
77+
cid = message.attach_inline_image_file(sample_image_path())
7978
message.attach_alternative(
8079
"<p><b>HTML:</b> with <a href='http://example.com'>link</a>"
8180
"and image: <img src='cid:%s'></div>" % cid,
8281
"text/html")
8382

8483
message.send()
8584
self.assertEqual(message.anymail_status.status, {'queued'}) # SendGrid always queues
86-
message_id = message.anymail_status.message_id
87-
print(message_id)
8885

8986
@override_settings(ANYMAIL_SENDGRID_API_KEY="Hey, that's not an API key!")
9087
def test_invalid_api_key(self):

anymail/utils.py

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from base64 import b64encode
33
from datetime import datetime
44
from email.mime.base import MIMEBase
5-
from email.utils import parseaddr, formatdate
5+
from email.utils import formatdate, parseaddr, unquote
66
from time import mktime
77

88
import six
@@ -100,12 +100,12 @@ class Attachment(object):
100100
"""A normalized EmailMessage.attachments item with additional functionality
101101
102102
Normalized to have these properties:
103-
name: attachment filename; may be empty string
103+
name: attachment filename; may be None
104104
content: bytestream
105105
mimetype: the content type; guessed if not explicit
106106
inline: bool, True if attachment has a Content-ID header
107-
content_id: for inline, the Content-ID (*with* <>)
108-
cid: for inline, the Content-ID *without* <>
107+
content_id: for inline, the Content-ID (*with* <>); may be None
108+
cid: for inline, the Content-ID *without* <>; may be empty string
109109
"""
110110

111111
def __init__(self, attachment, encoding):
@@ -121,11 +121,12 @@ def __init__(self, attachment, encoding):
121121
self.name = attachment.get_filename()
122122
self.content = attachment.get_payload(decode=True)
123123
self.mimetype = attachment.get_content_type()
124-
# Treat image attachments that have content ids as inline:
125-
if attachment.get_content_maintype() == "image" and attachment["Content-ID"] is not None:
124+
125+
if get_content_disposition(attachment) == 'inline':
126126
self.inline = True
127-
self.content_id = attachment["Content-ID"] # including the <...>
128-
self.cid = self.content_id[1:-1] # without the <, >
127+
self.content_id = attachment["Content-ID"] # probably including the <...>
128+
if self.content_id is not None:
129+
self.cid = unquote(self.content_id) # without the <, >
129130
else:
130131
(self.name, self.content, self.mimetype) = attachment
131132

@@ -145,6 +146,18 @@ def b64content(self):
145146
return b64encode(content).decode("ascii")
146147

147148

149+
def get_content_disposition(mimeobj):
150+
"""Return the message's content-disposition if it exists, or None.
151+
152+
Backport of py3.5 :func:`~email.message.Message.get_content_disposition`
153+
"""
154+
value = mimeobj.get('content-disposition')
155+
if value is None:
156+
return None
157+
# _splitparam(value)[0].lower() :
158+
return str(value).partition(';')[0].strip().lower()
159+
160+
148161
def get_anymail_setting(setting, default=UNSET, allow_bare=False):
149162
"""Returns a Django Anymail setting.
150163

docs/esps/mandrill.rst

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,14 +88,14 @@ Changes to settings
8888
the values from :setting:`ANYMAIL_SEND_DEFAULTS`.
8989

9090
``MANDRILL_SUBACCOUNT``
91-
Use :attr:`esp_extra` in :setting:`ANYMAIL_MANDRILL_SEND_DEFAULTS`:
91+
Use :setting:`ANYMAIL_MANDRILL_SEND_DEFAULTS`:
9292

9393
.. code-block:: python
9494
9595
ANYMAIL = {
9696
...
9797
"MANDRILL_SEND_DEFAULTS": {
98-
"esp_extra": {"subaccount": "<your subaccount>"}
98+
"subaccount": "<your subaccount>"
9999
}
100100
}
101101
@@ -149,3 +149,17 @@ Changes to EmailMessage attributes
149149
to your code. In the future, the Mandrill-only attributes
150150
will be moved into the
151151
:attr:`~anymail.message.AnymailMessage.esp_extra` dict.
152+
153+
**Inline images**
154+
Djrill (incorrectly) used the presence of a :mailheader:`Content-ID`
155+
header to decide whether to treat an image as inline. Anymail
156+
looks for :mailheader:`Content-Disposition: inline`.
157+
158+
If you were constructing MIMEImage inline image attachments
159+
for your Djrill messages, in addition to setting the Content-ID,
160+
you should also add::
161+
162+
image.add_header('Content-Disposition', 'inline')
163+
164+
Or better yet, use Anymail's new :ref:`inline-images`
165+
helper functions to attach your inline images.

docs/sending/anymail_additions.rst

Lines changed: 40 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,15 @@ ESP send options (AnymailMessage)
182182
:class:`AnymailMessageMixin` objects. Unlike the attributes above,
183183
they can't be used on an arbitrary :class:`~django.core.mail.EmailMessage`.)
184184

185-
.. method:: attach_inline_image(content, subtype=None, idstring="img", domain=None)
185+
.. method:: attach_inline_image_file(path, subtype=None, idstring="img", domain=None)
186+
187+
Attach an inline (embedded) image to the message and return its :mailheader:`Content-ID`.
188+
189+
This calls :func:`attach_inline_image_file` on the message. See :ref:`inline-images`
190+
for details and an example.
191+
192+
193+
.. method:: attach_inline_image(content, filename=None, subtype=None, idstring="img", domain=None)
186194

187195
Attach an inline (embedded) image to the message and return its :mailheader:`Content-ID`.
188196

@@ -303,9 +311,17 @@ ESP send status
303311
Inline images
304312
-------------
305313

306-
Anymail includes a convenience function to simplify attaching inline images to email.
314+
Anymail includes convenience functions to simplify attaching inline images to email.
315+
316+
These functions work with *any* Django :class:`~django.core.mail.EmailMessage` --
317+
they're not specific to Anymail email backends. You can use them with messages sent
318+
through Django's SMTP backend or any other that properly supports MIME attachments.
319+
320+
(Both functions are also available as convenience methods on Anymail's
321+
:class:`~anymail.message.AnymailMessage` and :class:`~anymail.message.AnymailMessageMixin`
322+
classes.)
307323

308-
.. function:: attach_inline_image(message, content, subtype=None, idstring="img", domain=None)
324+
.. function:: attach_inline_image_file(message, path, subtype=None, idstring="img", domain=None)
309325

310326
Attach an inline (embedded) image to the message and return its :mailheader:`Content-ID`.
311327

@@ -315,23 +331,20 @@ Anymail includes a convenience function to simplify attaching inline images to e
315331
.. code-block:: python
316332
317333
from django.core.mail import EmailMultiAlternatives
318-
from anymail.message import attach_inline_image
319-
320-
# read image content -- be sure to open the file in binary mode:
321-
with f = open("path/to/picture.jpg", "rb"):
322-
raw_image_data = f.read()
334+
from anymail.message import attach_inline_image_file
323335
324336
message = EmailMultiAlternatives( ... )
325-
cid = attach_inline_image(message, raw_image_data)
337+
cid = attach_inline_image_file(message, 'path/to/picture.jpg')
326338
html = '... <img alt="Picture" src="cid:%s"> ...' % cid
327-
message.attach_alternative(html, "text/html")
339+
message.attach_alternative(html, 'text/html')
328340
329341
message.send()
330342
331343
332344
`message` must be an :class:`~django.core.mail.EmailMessage` (or subclass) object.
333345

334-
`content` must be the binary image data (e.g., read from a file).
346+
`path` must be the pathname to an image file. (Its basename will also be used as the
347+
attachment's filename, which may be visible in some email clients.)
335348

336349
`subtype` is an optional MIME :mimetype:`image` subtype, e.g., `"png"` or `"jpg"`.
337350
By default, this is determined automatically from the content.
@@ -342,14 +355,23 @@ Anymail includes a convenience function to simplify attaching inline images to e
342355
(But be aware the default `domain` can leak your server's local hostname
343356
in the resulting email.)
344357

345-
This function works with *any* Django :class:`~django.core.mail.EmailMessage` --
346-
it's not specific to Anymail email backends. You can use it with messages sent
347-
through Django's SMTP backend or any other that properly supports MIME attachments.
348358

349-
(This function is also available as the
350-
:meth:`~anymail.message.AnymailMessage.attach_inline_image` method
351-
on Anymail's :class:`~anymail.message.AnymailMessage` and
352-
:class:`~anymail.message.AnymailMessageMixin` classes.)
359+
.. function:: attach_inline_image(message, content, filename=None, subtype=None, idstring="img", domain=None)
360+
361+
This is a version of :func:`attach_inline_image_file` that accepts raw
362+
image data, rather than reading it from a file.
363+
364+
`message` must be an :class:`~django.core.mail.EmailMessage` (or subclass) object.
365+
366+
`content` must be the binary image data
367+
368+
`filename` is an optional `str` that will be used as as the attachment's
369+
filename -- e.g., `"picture.jpg"`. This may be visible in email clients that
370+
choose to display the image as an attachment as well as making it available
371+
for inline use (this is up to the email client). It should be a base filename,
372+
without any path info.
373+
374+
`subtype`, `idstring` and `domain` are as described in :func:`attach_inline_image_file`
353375

354376

355377
.. _send-defaults:

docs/sending/django_email.rst

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -73,14 +73,20 @@ will send.
7373

7474
.. rubric:: Inline images
7575

76-
If your message has any image attachments with :mailheader:`Content-ID` headers,
77-
Anymail will tell your ESP to treat them as inline images rather than ordinary
78-
attached files.
79-
80-
You can construct an inline image attachment yourself with Python's
81-
:class:`python:email.mime.image.MIMEImage`, or you can use the convenience
82-
function :func:`~message.attach_inline_image` included with
83-
Anymail. See :ref:`inline-images` in the "Anymail additions" section.
76+
If your message has any attachments with :mailheader:`Content-Disposition: inline`
77+
headers, Anymail will tell your ESP to treat them as inline rather than ordinary
78+
attached files. If you want to reference an attachment from an `<img>` in your
79+
HTML source, the attachment also needs a :mailheader:`Content-ID` header.
80+
81+
Anymail's comes with :func:`~message.attach_inline_image` and
82+
:func:`~message.attach_inline_image_file` convenience functions that
83+
do the right thing. See :ref:`inline-images` in the "Anymail additions" section.
84+
85+
(If you prefer to do the work yourself, Python's :class:`~email.mime.image.MIMEImage`
86+
and :meth:`~email.message.Message.add_header` should be helpful.)
87+
88+
Even if you mark an attachment as inline, some email clients may decide to also
89+
display it as an attachment. This is largely outside your control.
8490

8591

8692
.. _message-headers:

0 commit comments

Comments
 (0)