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.)
This commit is contained in:
medmunds
2016-03-11 19:14:11 -08:00
parent 701726c59d
commit 54827579d3
10 changed files with 132 additions and 60 deletions

View File

@@ -107,7 +107,7 @@ or any other supported ESP where you see "mailgun":
.. code-block:: python .. code-block:: python
from django.core.mail import EmailMultiAlternatives from django.core.mail import EmailMultiAlternatives
from anymail.message import attach_inline_image from anymail.message import attach_inline_image_file
msg = EmailMultiAlternatives( msg = EmailMultiAlternatives(
subject="Please activate your account", subject="Please activate your account",
@@ -117,7 +117,7 @@ or any other supported ESP where you see "mailgun":
reply_to=["Helpdesk <support@example.com>"]) reply_to=["Helpdesk <support@example.com>"])
# Include an inline image in the html: # Include an inline image in the html:
logo_cid = attach_inline_image(msg, open("logo.jpg", "rb").read()) logo_cid = attach_inline_image_file(msg, "/path/to/logo.jpg")
html = """<img alt="Logo" src="cid:{logo_cid}"> html = """<img alt="Logo" src="cid:{logo_cid}">
<p>Please <a href="http://example.com/activate">activate</a> <p>Please <a href="http://example.com/activate">activate</a>
your account</p>""".format(logo_cid=logo_cid) your account</p>""".format(logo_cid=logo_cid)

View File

@@ -1,4 +1,6 @@
from email.mime.image import MIMEImage from email.mime.image import MIMEImage
from email.utils import unquote
import os
from django.core.mail import EmailMessage, EmailMultiAlternatives, make_msgid from django.core.mail import EmailMessage, EmailMultiAlternatives, make_msgid
@@ -28,23 +30,37 @@ class AnymailMessageMixin(object):
# noinspection PyArgumentList # noinspection PyArgumentList
super(AnymailMessageMixin, self).__init__(*args, **kwargs) super(AnymailMessageMixin, self).__init__(*args, **kwargs)
def attach_inline_image(self, content, subtype=None, idstring="img", domain=None): def attach_inline_image_file(self, path, subtype=None, idstring="img", domain=None):
"""Add inline image from file path to an EmailMessage, and return its content id"""
assert isinstance(self, EmailMessage)
return attach_inline_image_file(self, path, subtype, idstring, domain)
def attach_inline_image(self, content, filename=None, subtype=None, idstring="img", domain=None):
"""Add inline image and return its content id""" """Add inline image and return its content id"""
assert isinstance(self, EmailMessage) assert isinstance(self, EmailMessage)
return attach_inline_image(self, content, subtype, idstring, domain) return attach_inline_image(self, content, filename, subtype, idstring, domain)
class AnymailMessage(AnymailMessageMixin, EmailMultiAlternatives): class AnymailMessage(AnymailMessageMixin, EmailMultiAlternatives):
pass pass
def attach_inline_image(message, content, subtype=None, idstring="img", domain=None): def attach_inline_image_file(message, path, subtype=None, idstring="img", domain=None):
"""Add inline image from file path to an EmailMessage, and return its content id"""
filename = os.path.basename(path)
with open(path, 'rb') as f:
content = f.read()
return attach_inline_image(message, content, filename, subtype, idstring, domain)
def attach_inline_image(message, content, filename=None, subtype=None, idstring="img", domain=None):
"""Add inline image to an EmailMessage, and return its content id""" """Add inline image to an EmailMessage, and return its content id"""
cid = make_msgid(idstring, domain) # Content ID per RFC 2045 section 7 (with <...>) content_id = make_msgid(idstring, domain) # Content ID per RFC 2045 section 7 (with <...>)
image = MIMEImage(content, subtype) image = MIMEImage(content, subtype)
image.add_header('Content-ID', cid) image.add_header('Content-Disposition', 'inline', filename=filename)
image.add_header('Content-ID', content_id)
message.attach(image) message.attach(image)
return cid[1:-1] # Without <...>, for use as the <img> tag src return unquote(content_id) # Without <...>, for use as the <img> tag src
ANYMAIL_STATUSES = [ ANYMAIL_STATUSES = [

View File

@@ -14,7 +14,7 @@ from django.test.utils import override_settings
from django.utils.timezone import get_fixed_timezone, override as override_current_timezone from django.utils.timezone import get_fixed_timezone, override as override_current_timezone
from anymail.exceptions import AnymailAPIError, AnymailSerializationError, AnymailUnsupportedFeature from anymail.exceptions import AnymailAPIError, AnymailSerializationError, AnymailUnsupportedFeature
from anymail.message import attach_inline_image from anymail.message import attach_inline_image_file
from .mock_requests_backend import RequestsBackendMockAPITestCase from .mock_requests_backend import RequestsBackendMockAPITestCase
from .utils import sample_image_content, sample_image_path, SAMPLE_IMAGE_FILENAME, AnymailTestMixin from .utils import sample_image_content, sample_image_path, SAMPLE_IMAGE_FILENAME, AnymailTestMixin
@@ -161,8 +161,11 @@ class MailgunBackendStandardEmailTests(MailgunBackendMockAPITestCase):
self.assertEqual(len(attachments), 1) self.assertEqual(len(attachments), 1)
def test_embedded_images(self): def test_embedded_images(self):
image_data = sample_image_content() # Read from a png file image_filename = SAMPLE_IMAGE_FILENAME
cid = attach_inline_image(self.message, image_data) image_path = sample_image_path(image_filename)
image_data = sample_image_content(image_filename)
cid = attach_inline_image_file(self.message, image_path)
html_content = '<p>This has an <img src="cid:%s" alt="inline" /> image.</p>' % cid html_content = '<p>This has an <img src="cid:%s" alt="inline" /> image.</p>' % cid
self.message.attach_alternative(html_content, "text/html") self.message.attach_alternative(html_content, "text/html")

View File

@@ -12,8 +12,7 @@ from django.test.utils import override_settings
from anymail.exceptions import AnymailAPIError from anymail.exceptions import AnymailAPIError
from anymail.message import AnymailMessage from anymail.message import AnymailMessage
from .utils import sample_image_content, AnymailTestMixin from .utils import AnymailTestMixin, sample_image_path
MAILGUN_TEST_API_KEY = os.getenv('MAILGUN_TEST_API_KEY') MAILGUN_TEST_API_KEY = os.getenv('MAILGUN_TEST_API_KEY')
MAILGUN_TEST_DOMAIN = os.getenv('MAILGUN_TEST_DOMAIN') MAILGUN_TEST_DOMAIN = os.getenv('MAILGUN_TEST_DOMAIN')
@@ -105,7 +104,7 @@ class MailgunBackendIntegrationTests(SimpleTestCase, AnymailTestMixin):
) )
message.attach("attachment1.txt", "Here is some\ntext for you", "text/plain") message.attach("attachment1.txt", "Here is some\ntext for you", "text/plain")
message.attach("attachment2.csv", "ID,Name\n1,3", "text/csv") message.attach("attachment2.csv", "ID,Name\n1,3", "text/csv")
cid = message.attach_inline_image(sample_image_content(), domain=MAILGUN_TEST_DOMAIN) cid = message.attach_inline_image_file(sample_image_path(), domain=MAILGUN_TEST_DOMAIN)
message.attach_alternative( message.attach_alternative(
"<div>This is the <i>html</i> body <img src='cid:%s'></div>" % cid, "<div>This is the <i>html</i> body <img src='cid:%s'></div>" % cid,
"text/html") "text/html")

View File

@@ -16,7 +16,7 @@ from django.test.utils import override_settings
from django.utils.timezone import get_fixed_timezone, override as override_current_timezone from django.utils.timezone import get_fixed_timezone, override as override_current_timezone
from anymail.exceptions import AnymailAPIError, AnymailSerializationError, AnymailUnsupportedFeature from anymail.exceptions import AnymailAPIError, AnymailSerializationError, AnymailUnsupportedFeature
from anymail.message import attach_inline_image from anymail.message import attach_inline_image_file
from .mock_requests_backend import RequestsBackendMockAPITestCase from .mock_requests_backend import RequestsBackendMockAPITestCase
from .utils import sample_image_content, sample_image_path, SAMPLE_IMAGE_FILENAME, AnymailTestMixin from .utils import sample_image_content, sample_image_path, SAMPLE_IMAGE_FILENAME, AnymailTestMixin
@@ -209,8 +209,11 @@ class SendGridBackendStandardEmailTests(SendGridBackendMockAPITestCase):
('Une pièce jointe.html', '<p>\u2019</p>', 'text/html')) ('Une pièce jointe.html', '<p>\u2019</p>', 'text/html'))
def test_embedded_images(self): def test_embedded_images(self):
image_data = sample_image_content() # Read from a png file image_filename = SAMPLE_IMAGE_FILENAME
cid = attach_inline_image(self.message, image_data) image_path = sample_image_path(image_filename)
image_data = sample_image_content(image_filename)
cid = attach_inline_image_file(self.message, image_path) # Read from a png file
html_content = '<p>This has an <img src="cid:%s" alt="inline" /> image.</p>' % cid html_content = '<p>This has an <img src="cid:%s" alt="inline" /> image.</p>' % cid
self.message.attach_alternative(html_content, "text/html") self.message.attach_alternative(html_content, "text/html")
@@ -219,11 +222,10 @@ class SendGridBackendStandardEmailTests(SendGridBackendMockAPITestCase):
self.assertEqual(data['html'], html_content) self.assertEqual(data['html'], html_content)
files = self.get_api_call_files() files = self.get_api_call_files()
filename = cid # (for now)
self.assertEqual(files, { self.assertEqual(files, {
'files[%s]' % filename: (filename, image_data, "image/png"), 'files[%s]' % image_filename: (image_filename, image_data, "image/png"),
}) })
self.assertEqual(data['content[%s]' % filename], cid) self.assertEqual(data['content[%s]' % image_filename], cid)
def test_attached_images(self): def test_attached_images(self):
image_filename = SAMPLE_IMAGE_FILENAME image_filename = SAMPLE_IMAGE_FILENAME

View File

@@ -10,8 +10,7 @@ from django.test.utils import override_settings
from anymail.exceptions import AnymailAPIError from anymail.exceptions import AnymailAPIError
from anymail.message import AnymailMessage from anymail.message import AnymailMessage
from .utils import sample_image_content, AnymailTestMixin from .utils import AnymailTestMixin, sample_image_path
SENDGRID_TEST_API_KEY = os.getenv('SENDGRID_TEST_API_KEY') SENDGRID_TEST_API_KEY = os.getenv('SENDGRID_TEST_API_KEY')
@@ -75,7 +74,7 @@ class SendGridBackendIntegrationTests(SimpleTestCase, AnymailTestMixin):
) )
message.attach("attachment1.txt", "Here is some\ntext for you", "text/plain") message.attach("attachment1.txt", "Here is some\ntext for you", "text/plain")
message.attach("attachment2.csv", "ID,Name\n1,Amy Lina", "text/csv") message.attach("attachment2.csv", "ID,Name\n1,Amy Lina", "text/csv")
cid = message.attach_inline_image(sample_image_content()) cid = message.attach_inline_image_file(sample_image_path())
message.attach_alternative( message.attach_alternative(
"<p><b>HTML:</b> with <a href='http://example.com'>link</a>" "<p><b>HTML:</b> with <a href='http://example.com'>link</a>"
"and image: <img src='cid:%s'></div>" % cid, "and image: <img src='cid:%s'></div>" % cid,
@@ -83,8 +82,6 @@ class SendGridBackendIntegrationTests(SimpleTestCase, AnymailTestMixin):
message.send() message.send()
self.assertEqual(message.anymail_status.status, {'queued'}) # SendGrid always queues self.assertEqual(message.anymail_status.status, {'queued'}) # SendGrid always queues
message_id = message.anymail_status.message_id
print(message_id)
@override_settings(ANYMAIL_SENDGRID_API_KEY="Hey, that's not an API key!") @override_settings(ANYMAIL_SENDGRID_API_KEY="Hey, that's not an API key!")
def test_invalid_api_key(self): def test_invalid_api_key(self):

View File

@@ -2,7 +2,7 @@ import mimetypes
from base64 import b64encode from base64 import b64encode
from datetime import datetime from datetime import datetime
from email.mime.base import MIMEBase from email.mime.base import MIMEBase
from email.utils import parseaddr, formatdate from email.utils import formatdate, parseaddr, unquote
from time import mktime from time import mktime
import six import six
@@ -100,12 +100,12 @@ class Attachment(object):
"""A normalized EmailMessage.attachments item with additional functionality """A normalized EmailMessage.attachments item with additional functionality
Normalized to have these properties: Normalized to have these properties:
name: attachment filename; may be empty string name: attachment filename; may be None
content: bytestream content: bytestream
mimetype: the content type; guessed if not explicit mimetype: the content type; guessed if not explicit
inline: bool, True if attachment has a Content-ID header inline: bool, True if attachment has a Content-ID header
content_id: for inline, the Content-ID (*with* <>) content_id: for inline, the Content-ID (*with* <>); may be None
cid: for inline, the Content-ID *without* <> cid: for inline, the Content-ID *without* <>; may be empty string
""" """
def __init__(self, attachment, encoding): def __init__(self, attachment, encoding):
@@ -121,11 +121,12 @@ class Attachment(object):
self.name = attachment.get_filename() self.name = attachment.get_filename()
self.content = attachment.get_payload(decode=True) self.content = attachment.get_payload(decode=True)
self.mimetype = attachment.get_content_type() self.mimetype = attachment.get_content_type()
# Treat image attachments that have content ids as inline:
if attachment.get_content_maintype() == "image" and attachment["Content-ID"] is not None: if get_content_disposition(attachment) == 'inline':
self.inline = True self.inline = True
self.content_id = attachment["Content-ID"] # including the <...> self.content_id = attachment["Content-ID"] # probably including the <...>
self.cid = self.content_id[1:-1] # without the <, > if self.content_id is not None:
self.cid = unquote(self.content_id) # without the <, >
else: else:
(self.name, self.content, self.mimetype) = attachment (self.name, self.content, self.mimetype) = attachment
@@ -145,6 +146,18 @@ class Attachment(object):
return b64encode(content).decode("ascii") return b64encode(content).decode("ascii")
def get_content_disposition(mimeobj):
"""Return the message's content-disposition if it exists, or None.
Backport of py3.5 :func:`~email.message.Message.get_content_disposition`
"""
value = mimeobj.get('content-disposition')
if value is None:
return None
# _splitparam(value)[0].lower() :
return str(value).partition(';')[0].strip().lower()
def get_anymail_setting(setting, default=UNSET, allow_bare=False): def get_anymail_setting(setting, default=UNSET, allow_bare=False):
"""Returns a Django Anymail setting. """Returns a Django Anymail setting.

View File

@@ -88,14 +88,14 @@ Changes to settings
the values from :setting:`ANYMAIL_SEND_DEFAULTS`. the values from :setting:`ANYMAIL_SEND_DEFAULTS`.
``MANDRILL_SUBACCOUNT`` ``MANDRILL_SUBACCOUNT``
Use :attr:`esp_extra` in :setting:`ANYMAIL_MANDRILL_SEND_DEFAULTS`: Use :setting:`ANYMAIL_MANDRILL_SEND_DEFAULTS`:
.. code-block:: python .. code-block:: python
ANYMAIL = { ANYMAIL = {
... ...
"MANDRILL_SEND_DEFAULTS": { "MANDRILL_SEND_DEFAULTS": {
"esp_extra": {"subaccount": "<your subaccount>"} "subaccount": "<your subaccount>"
} }
} }
@@ -149,3 +149,17 @@ Changes to EmailMessage attributes
to your code. In the future, the Mandrill-only attributes to your code. In the future, the Mandrill-only attributes
will be moved into the will be moved into the
:attr:`~anymail.message.AnymailMessage.esp_extra` dict. :attr:`~anymail.message.AnymailMessage.esp_extra` dict.
**Inline images**
Djrill (incorrectly) used the presence of a :mailheader:`Content-ID`
header to decide whether to treat an image as inline. Anymail
looks for :mailheader:`Content-Disposition: inline`.
If you were constructing MIMEImage inline image attachments
for your Djrill messages, in addition to setting the Content-ID,
you should also add::
image.add_header('Content-Disposition', 'inline')
Or better yet, use Anymail's new :ref:`inline-images`
helper functions to attach your inline images.

View File

@@ -182,7 +182,15 @@ ESP send options (AnymailMessage)
:class:`AnymailMessageMixin` objects. Unlike the attributes above, :class:`AnymailMessageMixin` objects. Unlike the attributes above,
they can't be used on an arbitrary :class:`~django.core.mail.EmailMessage`.) they can't be used on an arbitrary :class:`~django.core.mail.EmailMessage`.)
.. method:: attach_inline_image(content, subtype=None, idstring="img", domain=None) .. method:: attach_inline_image_file(path, subtype=None, idstring="img", domain=None)
Attach an inline (embedded) image to the message and return its :mailheader:`Content-ID`.
This calls :func:`attach_inline_image_file` on the message. See :ref:`inline-images`
for details and an example.
.. method:: attach_inline_image(content, filename=None, subtype=None, idstring="img", domain=None)
Attach an inline (embedded) image to the message and return its :mailheader:`Content-ID`. Attach an inline (embedded) image to the message and return its :mailheader:`Content-ID`.
@@ -303,9 +311,17 @@ ESP send status
Inline images Inline images
------------- -------------
Anymail includes a convenience function to simplify attaching inline images to email. Anymail includes convenience functions to simplify attaching inline images to email.
.. function:: attach_inline_image(message, content, subtype=None, idstring="img", domain=None) These functions work with *any* Django :class:`~django.core.mail.EmailMessage` --
they're not specific to Anymail email backends. You can use them with messages sent
through Django's SMTP backend or any other that properly supports MIME attachments.
(Both functions are also available as convenience methods on Anymail's
:class:`~anymail.message.AnymailMessage` and :class:`~anymail.message.AnymailMessageMixin`
classes.)
.. function:: attach_inline_image_file(message, path, subtype=None, idstring="img", domain=None)
Attach an inline (embedded) image to the message and return its :mailheader:`Content-ID`. Attach an inline (embedded) image to the message and return its :mailheader:`Content-ID`.
@@ -315,23 +331,20 @@ Anymail includes a convenience function to simplify attaching inline images to e
.. code-block:: python .. code-block:: python
from django.core.mail import EmailMultiAlternatives from django.core.mail import EmailMultiAlternatives
from anymail.message import attach_inline_image from anymail.message import attach_inline_image_file
# read image content -- be sure to open the file in binary mode:
with f = open("path/to/picture.jpg", "rb"):
raw_image_data = f.read()
message = EmailMultiAlternatives( ... ) message = EmailMultiAlternatives( ... )
cid = attach_inline_image(message, raw_image_data) cid = attach_inline_image_file(message, 'path/to/picture.jpg')
html = '... <img alt="Picture" src="cid:%s"> ...' % cid html = '... <img alt="Picture" src="cid:%s"> ...' % cid
message.attach_alternative(html, "text/html") message.attach_alternative(html, 'text/html')
message.send() message.send()
`message` must be an :class:`~django.core.mail.EmailMessage` (or subclass) object. `message` must be an :class:`~django.core.mail.EmailMessage` (or subclass) object.
`content` must be the binary image data (e.g., read from a file). `path` must be the pathname to an image file. (Its basename will also be used as the
attachment's filename, which may be visible in some email clients.)
`subtype` is an optional MIME :mimetype:`image` subtype, e.g., `"png"` or `"jpg"`. `subtype` is an optional MIME :mimetype:`image` subtype, e.g., `"png"` or `"jpg"`.
By default, this is determined automatically from the content. 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
(But be aware the default `domain` can leak your server's local hostname (But be aware the default `domain` can leak your server's local hostname
in the resulting email.) in the resulting email.)
This function works with *any* Django :class:`~django.core.mail.EmailMessage` --
it's not specific to Anymail email backends. You can use it with messages sent
through Django's SMTP backend or any other that properly supports MIME attachments.
(This function is also available as the .. function:: attach_inline_image(message, content, filename=None, subtype=None, idstring="img", domain=None)
:meth:`~anymail.message.AnymailMessage.attach_inline_image` method
on Anymail's :class:`~anymail.message.AnymailMessage` and This is a version of :func:`attach_inline_image_file` that accepts raw
:class:`~anymail.message.AnymailMessageMixin` classes.) image data, rather than reading it from a file.
`message` must be an :class:`~django.core.mail.EmailMessage` (or subclass) object.
`content` must be the binary image data
`filename` is an optional `str` that will be used as as the attachment's
filename -- e.g., `"picture.jpg"`. This may be visible in email clients that
choose to display the image as an attachment as well as making it available
for inline use (this is up to the email client). It should be a base filename,
without any path info.
`subtype`, `idstring` and `domain` are as described in :func:`attach_inline_image_file`
.. _send-defaults: .. _send-defaults:

View File

@@ -73,14 +73,20 @@ will send.
.. rubric:: Inline images .. rubric:: Inline images
If your message has any image attachments with :mailheader:`Content-ID` headers, If your message has any attachments with :mailheader:`Content-Disposition: inline`
Anymail will tell your ESP to treat them as inline images rather than ordinary headers, Anymail will tell your ESP to treat them as inline rather than ordinary
attached files. attached files. If you want to reference an attachment from an `<img>` in your
HTML source, the attachment also needs a :mailheader:`Content-ID` header.
You can construct an inline image attachment yourself with Python's Anymail's comes with :func:`~message.attach_inline_image` and
:class:`python:email.mime.image.MIMEImage`, or you can use the convenience :func:`~message.attach_inline_image_file` convenience functions that
function :func:`~message.attach_inline_image` included with do the right thing. See :ref:`inline-images` in the "Anymail additions" section.
Anymail. See :ref:`inline-images` in the "Anymail additions" section.
(If you prefer to do the work yourself, Python's :class:`~email.mime.image.MIMEImage`
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.
.. _message-headers: .. _message-headers: