mirror of
https://github.com/pacnpal/django-anymail.git
synced 2025-12-22 12:51:06 -05:00
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:
@@ -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 = [
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user