mirror of
https://github.com/pacnpal/django-anymail.git
synced 2025-12-20 03:41:05 -05:00
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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
BIN
djrill/tests/sample_image.png
Normal file
BIN
djrill/tests/sample_image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 579 B |
@@ -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'])
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user