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

@@ -1,4 +1,6 @@
from email.mime.image import MIMEImage
from email.utils import unquote
import os
from django.core.mail import EmailMessage, EmailMultiAlternatives, make_msgid
@@ -28,23 +30,37 @@ class AnymailMessageMixin(object):
# noinspection PyArgumentList
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"""
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):
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"""
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.add_header('Content-ID', cid)
image.add_header('Content-Disposition', 'inline', filename=filename)
image.add_header('Content-ID', content_id)
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 = [

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 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 .utils import sample_image_content, sample_image_path, SAMPLE_IMAGE_FILENAME, AnymailTestMixin
@@ -161,8 +161,11 @@ class MailgunBackendStandardEmailTests(MailgunBackendMockAPITestCase):
self.assertEqual(len(attachments), 1)
def test_embedded_images(self):
image_data = sample_image_content() # Read from a png file
cid = attach_inline_image(self.message, image_data)
image_filename = SAMPLE_IMAGE_FILENAME
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
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.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_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("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(
"<div>This is the <i>html</i> body <img src='cid:%s'></div>" % cid,
"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 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 .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'))
def test_embedded_images(self):
image_data = sample_image_content() # Read from a png file
cid = attach_inline_image(self.message, image_data)
image_filename = SAMPLE_IMAGE_FILENAME
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
self.message.attach_alternative(html_content, "text/html")
@@ -219,11 +222,10 @@ class SendGridBackendStandardEmailTests(SendGridBackendMockAPITestCase):
self.assertEqual(data['html'], html_content)
files = self.get_api_call_files()
filename = cid # (for now)
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):
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.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')
@@ -75,7 +74,7 @@ class SendGridBackendIntegrationTests(SimpleTestCase, AnymailTestMixin):
)
message.attach("attachment1.txt", "Here is some\ntext for you", "text/plain")
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(
"<p><b>HTML:</b> with <a href='http://example.com'>link</a>"
"and image: <img src='cid:%s'></div>" % cid,
@@ -83,8 +82,6 @@ class SendGridBackendIntegrationTests(SimpleTestCase, AnymailTestMixin):
message.send()
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!")
def test_invalid_api_key(self):

View File

@@ -2,7 +2,7 @@ import mimetypes
from base64 import b64encode
from datetime import datetime
from email.mime.base import MIMEBase
from email.utils import parseaddr, formatdate
from email.utils import formatdate, parseaddr, unquote
from time import mktime
import six
@@ -100,12 +100,12 @@ class Attachment(object):
"""A normalized EmailMessage.attachments item with additional functionality
Normalized to have these properties:
name: attachment filename; may be empty string
name: attachment filename; may be None
content: bytestream
mimetype: the content type; guessed if not explicit
inline: bool, True if attachment has a Content-ID header
content_id: for inline, the Content-ID (*with* <>)
cid: for inline, the Content-ID *without* <>
content_id: for inline, the Content-ID (*with* <>); may be None
cid: for inline, the Content-ID *without* <>; may be empty string
"""
def __init__(self, attachment, encoding):
@@ -121,11 +121,12 @@ class Attachment(object):
self.name = attachment.get_filename()
self.content = attachment.get_payload(decode=True)
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.content_id = attachment["Content-ID"] # including the <...>
self.cid = self.content_id[1:-1] # without the <, >
self.content_id = attachment["Content-ID"] # probably including the <...>
if self.content_id is not None:
self.cid = unquote(self.content_id) # without the <, >
else:
(self.name, self.content, self.mimetype) = attachment
@@ -145,6 +146,18 @@ class Attachment(object):
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):
"""Returns a Django Anymail setting.