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
 - 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 Nov 14, 2024
1 parent 8fcc1b9 commit 44883e1
Show file tree
Hide file tree
Showing 7 changed files with 196 additions and 32 deletions.
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 attachment"),
("cid", "CIDs attachment"),
("data", "Data SRC"),
],
default="cid", # previous module version only supported CID
required=True,
)
52 changes: 39 additions & 13 deletions mail_embed_image/models/ir_mail_server.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
# 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
Expand Down Expand Up @@ -32,8 +36,9 @@ 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)
result = super(IrMailServer, self).build_email(
email_from=email_from,
Expand All @@ -55,30 +60,45 @@ def build_email(
if fileparts:
for fpart in fileparts:
result.attach(fpart)
# 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")
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 +107,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,
)
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 = "test@example.com"
cls.email_to = "test@example.com"
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 = "test@example.com"
email_to = "test@example.com"
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/")
],
[],
)
26 changes: 26 additions & 0 deletions mail_embed_image/views/res_config_settings_views.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>

<record id="res_config_settings_view_form" model="ir.ui.view">
<field name="name">res.config.settings.view.form.inherit.mail</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="mail.res_config_settings_view_form" />
<field name="arch" type="xml">
<div id="companies_setting" position="inside">
<br />
<div
class="o_setting_right_pane"
id="mail_templates_setting"
groups="mail.group_mail_template_editor,base.group_system"
>
<span class="o_form_label">Email Preprocessing</span>
<div class="text-muted">
Method used to embed images in HTML emails. CIDs attachment does not work with all email clients. Data SRC is more reliable.
</div>
<field name="image_embedding_method" />
</div>
</div>
</field>
</record>

</odoo>

0 comments on commit 44883e1

Please sign in to comment.