Inline / embedded attachments in emails sent by Google API through Python are also shown attached

Question:

I’ve been losing my mind over this issue for the past few days. To clarify my issue, I am trying to send emails with images embedded in them. I mostly use these embedded images for footer or social media icons. The problem is that these embedded images are then shown in the inbox, as shown here. Weirdly enough, when the email is opened, no attachment is visible but the image is embedded as shown here. While the problem seems to behave slightly differently for each email provider, all the big ones seem to have the same issue of showing the attachment in the inbox, but not when having the specific email open.

I use Python’s standard MIME library, with Python’s (relatively) new EmailMessage MIME object. My process looks a bit like this:

html_text = MIMEText(message_str, 'html')
html_part = MIMEMultipart('related')
html_part.attach(html_text)

for attachment in attachments:
    mime_attachment = MIMEImage(base64.b64decode(attachment.get('data')),
        _subtype=attachment.get('type'))
    mime_attachment.add_header('Content-ID', f'<{attachment.get("name")}>')
    mime_attachment.add_header('Content-Disposition', 'inline',
        filename=f'{attachment.get("name")}')
    mime_attachment.add_header('Content-Transfer-Encoding', 'base64')
    html_part.attach(mime_attachment)

root_message = EmailMessage()
root_message.make_mixed()
root_message.attach(html_part)

Which means my MIME hierarchy looks like this:

  • multipart/mixed
    • multipart/related
      • text/html
    • image/png

I’ve used many different MIME types and hierarchies, but I am now using this website as reference.

After this whole MIME process I just convert it to bytes, base64 encode it and send it through the GMail API.

The original message that Gmail shows me looks fine, all types are correct as seen in the MIME hierarchy above. The image part is shown as:

--00000000000064aaa005efdc1b1b
Content-Type: image/png; name="9a57c8bb80784228ae77975a659f83c6.png"
Content-Disposition: inline; filename="9a57c8bb80784228ae77975a659f83c6.png"
Content-Transfer-Encoding: base64
Content-ID: <9a57c8bb80784228ae77975a659f83c6>
X-Attachment-Id: cb9a6d0733ed31c7_0.0.2

I’ve tried many different MIME hierarchies, and have also tried to use EmailMessage’s add_attachment to no avail. I sometimes get the inline image working, with no attachment in the inbox, but when I open the e-mail the entire image is inside of an attachment of message/rfc822 type, in base64 encoding. At this point I’m throwing random stuff at the wall and looking what sticks, but whenever something takes a step in the right direction stuff gets weird.

Using externally hosted images is not an option for me, and neither is encoding the entire image to base64 and putting it in the html.

I hope someone can provide me with any insights. This has worked for me before, so I am very confused about all this. I thank you in advance.

Edit: I’ve gone ahead and refactored the code – I’ve tried a few things in the meantime with no results though. I tried creating a MIMEPart payload by hand, and using EmailMessage’s add_attachment, to no avail. I have also tried just about any combination of mixed/alternative/relative, with no improvement. Here is my updated code:

# Creating container for HTML and adding pixel and message (alternative part and images go here)
related_part = MIMEMultipart('related')

# Creating container for html_part and images (HTML and plain go here)
alternative_part = MIMEMultipart('alternative')

# Creating a MIMEText object to hold our HTML
html_text = MIMEText(message_str, 'html')
alternative_part.attach(html_text)

# Adding content to the related part, making the hierarchy like so:
#   * multipart/related
#       * multipart/alternative
#           * html/text
related_part.attach(alternative_part)

# Adding attachments to the container, to look like so:
#   * multipart/related
#       * multipart/alternative
#           * html/text
#       * image / png
for attachment in attachments:
    mime_attachment = MIMEImage(base64.b64decode(attachment.get('data')), _subtype=attachment.get('type'))
    mime_attachment.add_header('Content-ID', f'<{attachment.get("name")}>')
    mime_attachment.add_header('Content-Disposition', 'inline')
                               #filename=f'{attachment.get("name")}.{attachment.get("type")}')
    mime_attachment.add_header('Content-Transfer-Encoding', 'base64')
    related_part.attach(mime_attachment)

# Creating the root EmailMessage
root_message = EmailMessage(EmailPolicy(utf8=True))
root_message.make_mixed()

# Attaching the actual message to the root, to look like this:
#   * multipart/mixed
#       * multipart/related
#           * multipart/alternative
#               * html/text
#           * image / png
root_message.attach(related_part)

root_message['To'] = f"{recipient_name} <{recipient_email}>"
root_message['From'] = f"{self.sender_name} <{self.sender_email}>"
root_message['Subject'] = subject_str
return {
    'raw': base64.urlsafe_b64encode(root_message.as_bytes()).decode()
}

The returned data is sent through Google’s API client like so:

service.users().messages().send(userId="me", body=mail_data).execute()

I’ve escalated this issue to Google’s tracker as well: https://issuetracker.google.com/issues/263427102

Asked By: Bram van Ulden

||

Answers:

The problem was the file size. Seemingly, most mail providers have a maximum file size for inlined images. These inlined images will stil display inlined on most devices and clients, however they will still be shown as an attachment at the same time.

Gmail, Gsuite, Office, and other webmail all have different maximum file sizes. When I enforced a maximum of 15kB, none of these mail providers complained and all my files are nice and inlined.

Of course, having hundreds of email messages I wasn’t going to do that by hand, and I wrote a small script using PIL/Pillow to decrease image size until a certain size is reached.

img_out = io.BytesIO(image_data)
img = Image.open(img_out)

while getsizeof(img_out) > 15000:
    img_out = io.BytesIO()
    width, height = img.size
    new_width, new_height = int(width * 0.95), int(height*0.95)
    img.thumbnail((new_width, new_height), Image.Resampling.LANCZOS)
    img.save(img_out, 'jpeg')

image_data = img_out.getvalue()
with open('downscaled_image.jpeg', 'wb') as writefile:
    writefile.write(image_data)
Answered By: Bram van Ulden