Support embedded images.

Treat image attachments with a Content-ID header as embedded, rather than ordinary attachments. (Rationale is that you must set the content-id to be able to refer to the image within your html, so that's a reasonable indicator to handle it as embedded.)
This commit is contained in:
medmunds
2013-03-01 13:58:07 -08:00
parent 4e48b0b3d0
commit 09de5faebe
4 changed files with 114 additions and 29 deletions

View File

@@ -144,7 +144,10 @@ Djrill supports most of the functionality of Django's `EmailMessage`_ and
for logging. To send a single message to multiple recipients without exposing for logging. To send a single message to multiple recipients without exposing
their email addresses to each other, simply include them all in the "to" list their email addresses to each other, simply include them all in the "to" list
and leave ``preserve_recipients`` set to False.) and leave ``preserve_recipients`` set to False.)
* **Attachments:** Djrill includes a message's attachments. * **Attachments:** Djrill includes a message's attachments. Also, if an image
attachment has a Content-ID header, Djrill will tell Mandrill to treat that
as an embedded image rather than an ordinary attachment. (For an example,
see ``test_embedded_images`` in tests/test_mandrill_send.py.)
* **Headers:** Djrill accepts additional headers, but only ``Reply-To`` and * **Headers:** Djrill accepts additional headers, but only ``Reply-To`` and
``X-*`` (since that is all that Mandrill accepts). Any other extra headers ``X-*`` (since that is all that Mandrill accepts). Any other extra headers
will raise ``djrill.NotSupportedByMandrillError`` when you attempt to send the will raise ``djrill.NotSupportedByMandrillError`` when you attempt to send the

View File

@@ -207,29 +207,46 @@ class DjrillBackend(BaseEmailBackend):
"""Extend msg_dict to include any attachments in message""" """Extend msg_dict to include any attachments in message"""
if message.attachments: if message.attachments:
str_encoding = message.encoding or settings.DEFAULT_CHARSET str_encoding = message.encoding or settings.DEFAULT_CHARSET
attachments = [ mandrill_attachments = []
self._make_mandrill_attachment(attachment, str_encoding) mandrill_embedded_images = []
for attachment in message.attachments for attachment in message.attachments:
] att_dict, is_embedded = self._make_mandrill_attachment(attachment, str_encoding)
if len(attachments) > 0: if is_embedded:
msg_dict['attachments'] = attachments mandrill_embedded_images.append(att_dict)
else:
mandrill_attachments.append(att_dict)
if len(mandrill_attachments) > 0:
msg_dict['attachments'] = mandrill_attachments
if len(mandrill_embedded_images) > 0:
msg_dict['images'] = mandrill_embedded_images
def _make_mandrill_attachment(self, attachment, str_encoding=None): def _make_mandrill_attachment(self, attachment, str_encoding=None):
"""Return a Mandrill dict for an EmailMessage.attachments item""" """Returns EmailMessage.attachments item formatted for sending with Mandrill.
Returns mandrill_dict, is_embedded_image:
mandrill_dict: {"type":..., "name":..., "content":...}
is_embedded_image: True if the attachment should instead be handled as an inline image.
"""
# Note that an attachment can be either a tuple of (filename, content, # Note that an attachment can be either a tuple of (filename, content,
# mimetype) or a MIMEBase object. (Also, both filename and mimetype may # mimetype) or a MIMEBase object. (Also, both filename and mimetype may
# be missing.) # be missing.)
is_embedded_image = False
if isinstance(attachment, MIMEBase): if isinstance(attachment, MIMEBase):
filename = attachment.get_filename() name = attachment.get_filename()
content = attachment.get_payload(decode=True) content = attachment.get_payload(decode=True)
mimetype = attachment.get_content_type() mimetype = attachment.get_content_type()
# Treat image attachments that have content ids as embedded:
if attachment.get_content_maintype() == "image" and attachment["Content-ID"] is not None:
is_embedded_image = True
name = attachment["Content-ID"]
else: else:
(filename, content, mimetype) = attachment (name, content, mimetype) = attachment
# Guess missing mimetype, borrowed from # Guess missing mimetype from filename, borrowed from
# django.core.mail.EmailMessage._create_attachment() # django.core.mail.EmailMessage._create_attachment()
if mimetype is None and filename is not None: if mimetype is None and name is not None:
mimetype, _ = mimetypes.guess_type(filename) mimetype, _ = mimetypes.guess_type(name)
if mimetype is None: if mimetype is None:
mimetype = DEFAULT_ATTACHMENT_MIME_TYPE mimetype = DEFAULT_ATTACHMENT_MIME_TYPE
@@ -243,9 +260,10 @@ class DjrillBackend(BaseEmailBackend):
else: else:
raise raise
return { mandrill_attachment = {
'type': mimetype, 'type': mimetype,
'name': filename or "", 'name': name or "",
'content': content_b64.decode('ascii'), 'content': content_b64.decode('ascii'),
} }
return mandrill_attachment, is_embedded_image

Binary file not shown.

After

Width:  |  Height:  |  Size: 579 B

View File

@@ -1,17 +1,39 @@
from base64 import b64decode from base64 import b64decode
from email.mime.base import MIMEBase from email.mime.base import MIMEBase
from email.mime.image import MIMEImage
import os
from django.conf import settings from django.conf import settings
from django.core import mail from django.core import mail
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from django.core.mail import make_msgid
from djrill import MandrillAPIError, NotSupportedByMandrillError from djrill import MandrillAPIError, NotSupportedByMandrillError
from djrill.tests.mock_backend import DjrillBackendMockAPITestCase from djrill.tests.mock_backend import DjrillBackendMockAPITestCase
def decode_att(att):
"""Returns the original data from base64-encoded attachment content"""
return b64decode(att.encode('ascii'))
class DjrillBackendTests(DjrillBackendMockAPITestCase): class DjrillBackendTests(DjrillBackendMockAPITestCase):
"""Test Djrill backend support for Django mail wrappers""" """Test Djrill backend support for Django mail wrappers"""
sample_image_filename = "sample_image.png"
def sample_image_pathname(self):
"""Returns path to an actual image file in the tests directory"""
test_dir = os.path.dirname(os.path.abspath(__file__))
path = os.path.join(test_dir, self.sample_image_filename)
return path
def sample_image_content(self):
"""Returns contents of an actual image file from the tests directory"""
filename = self.sample_image_pathname()
with open(filename, "rb") as f:
return f.read()
def test_send_mail(self): def test_send_mail(self):
mail.send_mail('Subject here', 'Here is the message.', mail.send_mail('Subject here', 'Here is the message.',
'from@example.com', ['to@example.com'], fail_silently=False) 'from@example.com', ['to@example.com'], fail_silently=False)
@@ -97,12 +119,10 @@ class DjrillBackendTests(DjrillBackendMockAPITestCase):
self.assertFalse('attachments' in data['message']) self.assertFalse('attachments' in data['message'])
def test_attachments(self): def test_attachments(self):
email = mail.EmailMessage('Subject', 'Body goes here', email = mail.EmailMessage('Subject', 'Body goes here', 'from@example.com', ['to1@example.com'])
'from@example.com', ['to1@example.com'])
text_content = "* Item one\n* Item two\n* Item three" text_content = "* Item one\n* Item two\n* Item three"
email.attach(filename="test.txt", content=text_content, email.attach(filename="test.txt", content=text_content, mimetype="text/plain")
mimetype="text/plain")
# Should guess mimetype if not provided... # Should guess mimetype if not provided...
png_content = b"PNG\xb4 pretend this is the contents of a png file" png_content = b"PNG\xb4 pretend this is the contents of a png file"
@@ -120,27 +140,70 @@ class DjrillBackendTests(DjrillBackendMockAPITestCase):
mimetype="application/vnd.ms-powerpoint") mimetype="application/vnd.ms-powerpoint")
email.send() email.send()
def decode_att(att):
return b64decode(att.encode('ascii'))
data = self.get_api_call_data() data = self.get_api_call_data()
attachments = data['message']['attachments'] attachments = data['message']['attachments']
self.assertEqual(len(attachments), 4) self.assertEqual(len(attachments), 4)
self.assertEqual(attachments[0]["type"], "text/plain") self.assertEqual(attachments[0]["type"], "text/plain")
self.assertEqual(attachments[0]["name"], "test.txt") self.assertEqual(attachments[0]["name"], "test.txt")
self.assertEqual(decode_att(attachments[0]["content"]).decode('ascii'), self.assertEqual(decode_att(attachments[0]["content"]).decode('ascii'), text_content)
text_content) self.assertEqual(attachments[1]["type"], "image/png") # inferred from filename
self.assertEqual(attachments[1]["type"], "image/png") # inferred
self.assertEqual(attachments[1]["name"], "test.png") self.assertEqual(attachments[1]["name"], "test.png")
self.assertEqual(decode_att(attachments[1]["content"]), png_content) self.assertEqual(decode_att(attachments[1]["content"]), png_content)
self.assertEqual(attachments[2]["type"], "application/pdf") self.assertEqual(attachments[2]["type"], "application/pdf")
self.assertEqual(attachments[2]["name"], "") # none self.assertEqual(attachments[2]["name"], "") # none
self.assertEqual(decode_att(attachments[2]["content"]), pdf_content) self.assertEqual(decode_att(attachments[2]["content"]), pdf_content)
self.assertEqual(attachments[3]["type"], self.assertEqual(attachments[3]["type"], "application/vnd.ms-powerpoint")
"application/vnd.ms-powerpoint")
self.assertEqual(attachments[3]["name"], "presentation.ppt") self.assertEqual(attachments[3]["name"], "presentation.ppt")
self.assertEqual(decode_att(attachments[3]["content"]), ppt_content) self.assertEqual(decode_att(attachments[3]["content"]), ppt_content)
# Make sure the image attachment is not treated as embedded:
self.assertFalse('images' in data['message'])
def test_embedded_images(self):
image_data = self.sample_image_content() # Read from a png file
image_cid = make_msgid("img") # Content ID per RFC 2045 section 7 (with <...>)
image_cid_no_brackets = image_cid[1:-1] # Without <...>, for use as the <img> tag src
text_content = 'This has an inline image.'
html_content = '<p>This has an <img src="cid:%s" alt="inline" /> image.</p>' % image_cid_no_brackets
email = mail.EmailMultiAlternatives('Subject', text_content, 'from@example.com', ['to@example.com'])
email.attach_alternative(html_content, "text/html")
image = MIMEImage(image_data)
image.add_header('Content-ID', image_cid)
email.attach(image)
email.send()
data = self.get_api_call_data()
self.assertEqual(data['message']['text'], text_content)
self.assertEqual(data['message']['html'], html_content)
self.assertEqual(len(data['message']['images']), 1)
self.assertEqual(data['message']['images'][0]["type"], "image/png")
self.assertEqual(data['message']['images'][0]["name"], image_cid)
self.assertEqual(decode_att(data['message']['images'][0]["content"]), image_data)
# Make sure neither the html nor the inline image is treated as an attachment:
self.assertFalse('attachments' in data['message'])
def test_attached_images(self):
image_data = self.sample_image_content()
email = mail.EmailMultiAlternatives('Subject', 'Message', 'from@example.com', ['to@example.com'])
email.attach_file(self.sample_image_pathname()) # option 1: attach as a file
image = MIMEImage(image_data) # option 2: construct the MIMEImage and attach it directly
email.attach(image)
email.send()
data = self.get_api_call_data()
attachments = data['message']['attachments']
self.assertEqual(len(attachments), 2)
self.assertEqual(attachments[0]["type"], "image/png")
self.assertEqual(attachments[0]["name"], self.sample_image_filename)
self.assertEqual(decode_att(attachments[0]["content"]), image_data)
self.assertEqual(attachments[1]["type"], "image/png")
self.assertEqual(attachments[1]["name"], "") # unknown -- not attached as file
self.assertEqual(decode_att(attachments[1]["content"]), image_data)
# Make sure the image attachments are not treated as embedded:
self.assertFalse('images' in data['message'])
def test_extra_header_errors(self): def test_extra_header_errors(self):
email = mail.EmailMessage('Subject', 'Body', 'from@example.com', email = mail.EmailMessage('Subject', 'Body', 'from@example.com',
@@ -321,5 +384,6 @@ class DjrillMandrillFeatureTests(DjrillBackendMockAPITestCase):
self.assertFalse('global_merge_vars' in data['message']) self.assertFalse('global_merge_vars' in data['message'])
self.assertFalse('merge_vars' in data['message']) self.assertFalse('merge_vars' in data['message'])
self.assertFalse('recipient_metadata' in data['message']) self.assertFalse('recipient_metadata' in data['message'])
self.assertFalse('images' in data['message'])