Skip to content

Commit

Permalink
[16.0][IMP] mail_embed_image: improve embedding type:
Browse files Browse the repository at this point in the history
 - CIDs are not working in some email managers (gmail and office365)
   To allow this behavior we need to change the content type to
   multipart/related and reorganize the parts (see comments in code)
 - A new option to embed images is to include the base64 content inside
   the src.
 - Then to select these options, a selected field has been added to company
  • Loading branch information
StephaneMangin committed Dec 19, 2024
1 parent ff2514d commit 476b0ef
Show file tree
Hide file tree
Showing 11 changed files with 260 additions and 32 deletions.
7 changes: 7 additions & 0 deletions mail_embed_image/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ Mail Embed Image
This module finds images attached to outgoing emails and replaces their urls
with cids. This will avoid rendering issues with some email clients.

It also provides 2 options to embed internal URL images in a mail body:
- CIDs: add fileparts as CIDs
- Data URLs: add images as data URLs

This option is configurable in an company settings variables.

**Table of contents**

.. contents::
Expand Down Expand Up @@ -60,6 +66,7 @@ Contributors
* George Daramouskas <[email protected]>
* Giovanni Francesco Capalbo <[email protected]>
* Italo LOPES <[email protected]>
* Stéphane Mangin <[email protected]>

Maintainers
~~~~~~~~~~~
Expand Down
5 changes: 5 additions & 0 deletions mail_embed_image/__manifest__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# Copyright 2019 Therp BV <https://therp.nl>
# Copyright 2024 Camptocamp SA
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
{
"name": "Mail Embed Image",
Expand All @@ -9,8 +10,12 @@
"summary": "Replace img.src's which start with http with inline cids",
"website": "https://github.com/OCA/social",
"depends": [
"mail",
"web",
],
"data": [
"views/res_config_settings_views.xml",
],
"installable": True,
"application": False,
}
3 changes: 3 additions & 0 deletions mail_embed_image/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
# Copyright 2019 Therp BV <https://therp.nl>
# Copyright 2024 Camptocamp SA
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
from . import ir_mail_server
from . import company
from . import res_config_settings
17 changes: 17 additions & 0 deletions mail_embed_image/models/company.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Copyright 2024 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import fields, models


class ResCompany(models.Model):
_inherit = "res.company"

image_embedding_method = fields.Selection(
selection=[
("none", "No postprocessing"),
("cid", "Content-ID (Gmail, Office compatible)"),
("data", "HTML Inline Data"),
],
default="cid",
required=True,
)
92 changes: 79 additions & 13 deletions mail_embed_image/models/ir_mail_server.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
# Copyright 2019 Therp BV <https://therp.nl>
# Copyright 2024 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).

import logging
import uuid
from base64 import b64encode
from email.mime.image import MIMEImage
from email.mime.multipart import MIMEMultipart

import requests
from lxml.html import fromstring, tostring
Expand Down Expand Up @@ -32,9 +37,12 @@ def build_email(
body_alternative=None,
subtype_alternative="plain",
):
image_embedding_method = self.env.company.image_embedding_method
fileparts = None
if subtype == "html":
if subtype == "html" and image_embedding_method != "none":
body, fileparts = self._build_email_replace_img_src(body)

# TODO check if we can add attachments here.
result = super(IrMailServer, self).build_email(
email_from=email_from,
email_to=email_to,
Expand All @@ -53,32 +61,84 @@ def build_email(
subtype_alternative=subtype_alternative,
)
if fileparts:
# Multipart method MUST be multipart/related for CIDs embedding
# Gmail and Office won't process the images otherwise
if image_embedding_method == "cid":
result.set_type("multipart/related")
for fpart in fileparts:
result.attach(fpart)
# after all part where added, we need to reorganize the parts
#
# Before:
# - boundary 1
# - text/plain
# - text/html
# - image/png
# After:
# - boundary 1
# - multipart/alternative
# - boundary 2
# - text/plain
# - text/html
# - image/png
# If an attachment is present, the parts are already in the right
# order in this case, we don't need to reorganize the parts
# but if we find later text/plain or text/html parts, we will need
# to append them to the first multipart/alternative.
#
# It possible to have multiple parts of type multipart/alternative,
# but it's not a common case.
all_parts = []
for part in result.iter_parts():
if part.get_content_type() == "multipart/alternative":
all_parts.append(part)

if not all_parts:
all_parts = [MIMEMultipart("alternative")]

for part in result.iter_parts():
if part.get_content_type() in ["text/html", "text/plain"]:
all_parts[0].attach(part)
elif part.get_content_type() == "multipart/alternative":
pass
else:
all_parts.append(part)
result.set_payload(all_parts)
return result

def _build_email_replace_img_src(self, html_body):
"""Replace img src with base64 encoded image."""
if not html_body:
return html_body

base_url = self.env["ir.config_parameter"].get_param("web.base.url")
image_embedding_method = self.env.company.image_embedding_method
root = fromstring(html_body)
images = root.xpath("//img")
fileparts = []
for img in images:
src = img.get("src")
if src and not src.startswith("data:") and not src.startswith("base64:"):
try:
response = requests.get(src, timeout=10)
_logger.debug("Fetching image from %s", src)
if response.status_code == 200:
# Limit results to only internal resources to avoid malicious external
# image injections
for img in root.xpath(
".//img[starts-with(@src, '%s')]"
"| .//img[starts-with(@src, '/web/image')]" % (base_url)
):
image_path = img.get("src")
try:
response = requests.get(image_path, timeout=10)
_logger.debug("Fetching image from %s", image_path)
if response.status_code == 200:
image_content = response.content
filepart = MIMEImage(image_content)
if image_embedding_method == "data":
raw_content = filepart.get_payload(decode=True)
base_64_content = b64encode(raw_content).decode("utf-8")
mimetype = filepart.get_content_type()
img.set("src", f"data:{mimetype};base64,{base_64_content}")
elif image_embedding_method == "cid":
cid = uuid.uuid4().hex
# convert cid to rfc2047 encoding
filename_encoded = "=?utf-8?b?%s?=" % b64encode(
cid.encode("utf-8")
).decode("utf-8")
image_content = response.content
filepart = MIMEImage(image_content)
filepart.add_header("Content-ID", f"<{cid}>")
filepart.add_header(
"Content-Disposition",
Expand All @@ -87,6 +147,12 @@ def _build_email_replace_img_src(self, html_body):
)
img.set("src", f"cid:{cid}")
fileparts.append(filepart)
except Exception as e:
_logger.warning("Could not get %s: %s", img.get("src"), str(e))
else:
_logger.warning(
"Could not get %s: HTTP status code %s",
img.get("src"),
response.status_code,
)
except Exception as e:
_logger.warning("Could not get %s: %s", img.get("src"), str(e))
return tostring(root, encoding="unicode"), fileparts
12 changes: 12 additions & 0 deletions mail_embed_image/models/res_config_settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Copyright 2024 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import fields, models


class ResConfigSettings(models.TransientModel):
_inherit = "res.config.settings"

image_embedding_method = fields.Selection(
related="company_id.image_embedding_method",
readonly=False,
)
1 change: 1 addition & 0 deletions mail_embed_image/readme/CONTRIBUTORS.rst
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
* George Daramouskas <[email protected]>
* Giovanni Francesco Capalbo <[email protected]>
* Italo LOPES <[email protected]>
* Stéphane Mangin <[email protected]>
6 changes: 6 additions & 0 deletions mail_embed_image/readme/DESCRIPTION.rst
Original file line number Diff line number Diff line change
@@ -1,2 +1,8 @@
This module finds images attached to outgoing emails and replaces their urls
with cids. This will avoid rendering issues with some email clients.

It also provides 2 options to embed internal URL images in a mail body:
- CIDs: add fileparts as CIDs
- Data URLs: add images as data URLs

This option is configurable in an company settings variables.
10 changes: 10 additions & 0 deletions mail_embed_image/static/description/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,15 @@ <h1 class="title">Mail Embed Image</h1>
<p><a class="reference external image-reference" href="https://odoo-community.org/page/development-status"><img alt="Beta" src="https://img.shields.io/badge/maturity-Beta-yellow.png" /></a> <a class="reference external image-reference" href="http://www.gnu.org/licenses/agpl-3.0-standalone.html"><img alt="License: AGPL-3" src="https://img.shields.io/badge/licence-AGPL--3-blue.png" /></a> <a class="reference external image-reference" href="https://github.com/OCA/social/tree/16.0/mail_embed_image"><img alt="OCA/social" src="https://img.shields.io/badge/github-OCA%2Fsocial-lightgray.png?logo=github" /></a> <a class="reference external image-reference" href="https://translation.odoo-community.org/projects/social-16-0/social-16-0-mail_embed_image"><img alt="Translate me on Weblate" src="https://img.shields.io/badge/weblate-Translate%20me-F47D42.png" /></a> <a class="reference external image-reference" href="https://runboat.odoo-community.org/builds?repo=OCA/social&amp;target_branch=16.0"><img alt="Try me on Runboat" src="https://img.shields.io/badge/runboat-Try%20me-875A7B.png" /></a></p>
<p>This module finds images attached to outgoing emails and replaces their urls
with cids. This will avoid rendering issues with some email clients.</p>
<dl class="docutils">
<dt>It also provides 2 options to embed internal URL images in a mail body:</dt>
<dd><ul class="first last simple">
<li>CIDs: add fileparts as CIDs</li>
<li>Data URLs: add images as data URLs</li>
</ul>
</dd>
</dl>
<p>This option is configurable in an company settings variables.</p>
<p><strong>Table of contents</strong></p>
<div class="contents local topic" id="contents">
<ul class="simple">
Expand Down Expand Up @@ -405,6 +414,7 @@ <h2><a class="toc-backref" href="#toc-entry-4">Contributors</a></h2>
<li>George Daramouskas &lt;<a class="reference external" href="mailto:gdaramouskas&#64;therp.nl">gdaramouskas&#64;therp.nl</a>&gt;</li>
<li>Giovanni Francesco Capalbo &lt;<a class="reference external" href="mailto:giovanni&#64;therp.nl">giovanni&#64;therp.nl</a>&gt;</li>
<li>Italo LOPES &lt;<a class="reference external" href="mailto:italo.lopes&#64;camptocamp.com">italo.lopes&#64;camptocamp.com</a>&gt;</li>
<li>Stéphane Mangin &lt;<a class="reference external" href="mailto:stephane.mangin&#64;camptocamp.com">stephane.mangin&#64;camptocamp.com</a>&gt;</li>
</ul>
</div>
<div class="section" id="maintainers">
Expand Down
113 changes: 94 additions & 19 deletions mail_embed_image/tests/test_mail_embed_image.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Copyright 2019 Therp BV <https://therp.nl>
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
from base64 import b64encode
import base64

from lxml import html
from requests import get
Expand All @@ -9,14 +9,23 @@


class TestMailEmbedImage(common.TransactionCase):
def test_mail_embed_image(self):
"""We pass a mail with <img src="..." /> tags to build_email,
and then look into the result, check there were attachments
created and you find xpaths like //img[src] have a cid"""
# DATA
base_url = self.env["ir.config_parameter"].get_param("web.base.url")
image_url = base_url + "/mail_embed_image/static/description/icon.png"
image = get(image_url, timeout=10).content
@classmethod
def setUpClass(cls):
super(TestMailEmbedImage, cls).setUpClass()
cls.company = cls.env.ref("base.main_company")
base_url = cls.env["ir.config_parameter"].get_param("web.base.url")
cls.image_url = base_url + "/mail_embed_image/static/description/icon.png"
cls.image_content = get(cls.image_url, timeout=10).content
cls.email_from = "[email protected]"
cls.email_to = "[email protected]"
cls.subject = "test mail"

def build_email(self, option="cid"):
"""Build an email with a given embedding option
option -- the embedding option to use according to the company setting
"""
self.company.image_embedding_method = option
body = html.tostring(
html.fromstring(
"""
Expand All @@ -27,24 +36,61 @@ def test_mail_embed_image(self):
</div>"""
% (
# won't be hit because we ignore embedded images
b64encode(image),
base64.b64encode(self.image_content).decode("utf-8"),
# dito, not uploaded content
image_url,
self.image_url,
)
)
)
email_from = "[email protected]"
email_to = "[email protected]"
subject = "test mail"
# END DATA
res = self.env["ir.mail_server"].build_email(
email_from,
[email_to],
subject,
return self.env["ir.mail_server"].build_email(
self.email_from,
[self.email_to],
self.subject,
body,
subtype="html",
subtype_alternative="plain",
)

def test_mail_embed_image_option_none(self):
"""No embedding option
We pass a mail with <img src="..." /> tags to build_email,
and then look into the result, check there no changes were made"""
res = self.build_email("none")
images_in_mail = 0
for part in res.walk():
if part.get_content_type() == "text/html":
# we do not search in text, just in case that texts exists in
# the text elsewhere (not probable, but this is better)
images_in_mail += len(
html.fromstring(part.get_payload(decode=True)).xpath(
"//img[starts-with(@src, 'data:image/png;base64,')]"
)
)
images_in_mail += len(
html.fromstring(part.get_payload(decode=True)).xpath(
"//img[starts-with(@src, 'cid:')]"
)
)
# verify 0 replaced images
self.assertEqual(images_in_mail, 0)
# verify 0 attachment present
self.assertEqual(
[
x.get_content_type()
for x in res.walk()
if x.get_content_type().startswith("image/")
],
[],
)

def test_mail_embed_image_option_cids(self):
"""CIDs attachement option
We pass a mail with <img src="..." /> tags to build_email,
and then look into the result, check there were attachments
created and you find xpaths like //img[src] have a cid"""
res = self.build_email("cid")
images_in_mail = 0
for part in res.walk():
if part.get_content_type() == "text/html":
Expand All @@ -66,3 +112,32 @@ def test_mail_embed_image(self):
],
["image/png"],
)

def test_mail_embed_image_option_data(self):
"""Data URL option
We pass a mail with <img src="..." /> tags to build_email,
and then look into the result, check there were attachments
created and you find xpaths like //img[src] have a data URL"""
res = self.build_email("data")
images_in_mail = 0
for part in res.walk():
if part.get_content_type() == "text/html":
# we do not search in text, just in case that texts exists in
# the text elsewhere (not probable, but this is better)
images_in_mail += len(
html.fromstring(part.get_payload(decode=True)).xpath(
"//img[starts-with(@src, 'data:image/png;base64,')]"
)
)
# verify 2 replaced image
self.assertEqual(images_in_mail, 1)
# verify 0 attachment present
self.assertEqual(
[
x.get_content_type()
for x in res.walk()
if x.get_content_type().startswith("image/")
],
[],
)
Loading

0 comments on commit 476b0ef

Please sign in to comment.