From 47b33e5209f6a17fc51f306a87e59bd19f0e9d9b Mon Sep 17 00:00:00 2001 From: Stephane Mangin Date: Thu, 14 Nov 2024 18:33:11 +0100 Subject: [PATCH] [16.0][IMP] mail_embed_image: improve embedding type: - 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 --- mail_embed_image/README.rst | 7 ++ mail_embed_image/__manifest__.py | 5 + mail_embed_image/models/__init__.py | 3 + mail_embed_image/models/company.py | 17 +++ mail_embed_image/models/ir_mail_server.py | 52 ++++++-- .../models/res_config_settings.py | 12 ++ mail_embed_image/readme/CONTRIBUTORS.rst | 1 + mail_embed_image/readme/DESCRIPTION.rst | 6 + .../static/description/index.html | 10 ++ .../tests/test_mail_embed_image.py | 113 +++++++++++++++--- .../views/res_config_settings_views.xml | 26 ++++ 11 files changed, 220 insertions(+), 32 deletions(-) create mode 100644 mail_embed_image/models/company.py create mode 100644 mail_embed_image/models/res_config_settings.py create mode 100644 mail_embed_image/views/res_config_settings_views.xml diff --git a/mail_embed_image/README.rst b/mail_embed_image/README.rst index 66e641bca4..32d79d601c 100644 --- a/mail_embed_image/README.rst +++ b/mail_embed_image/README.rst @@ -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:: @@ -60,6 +66,7 @@ Contributors * George Daramouskas * Giovanni Francesco Capalbo * Italo LOPES +* Stéphane Mangin Maintainers ~~~~~~~~~~~ diff --git a/mail_embed_image/__manifest__.py b/mail_embed_image/__manifest__.py index ca23b71fdd..490e30af18 100644 --- a/mail_embed_image/__manifest__.py +++ b/mail_embed_image/__manifest__.py @@ -1,4 +1,5 @@ # Copyright 2019 Therp BV +# Copyright 2024 Camptocamp SA # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). { "name": "Mail Embed Image", @@ -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, } diff --git a/mail_embed_image/models/__init__.py b/mail_embed_image/models/__init__.py index 02d2fee24d..80f961a489 100644 --- a/mail_embed_image/models/__init__.py +++ b/mail_embed_image/models/__init__.py @@ -1,3 +1,6 @@ # Copyright 2019 Therp BV +# 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 diff --git a/mail_embed_image/models/company.py b/mail_embed_image/models/company.py new file mode 100644 index 0000000000..cb987e6a9c --- /dev/null +++ b/mail_embed_image/models/company.py @@ -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, + ) diff --git a/mail_embed_image/models/ir_mail_server.py b/mail_embed_image/models/ir_mail_server.py index a98cb7561b..ede5261471 100644 --- a/mail_embed_image/models/ir_mail_server.py +++ b/mail_embed_image/models/ir_mail_server.py @@ -1,3 +1,7 @@ +# Copyright 2019 Therp BV +# Copyright 2024 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + import logging import uuid from base64 import b64encode @@ -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, @@ -55,6 +60,10 @@ 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): @@ -62,23 +71,34 @@ def _build_email_replace_img_src(self, html_body): 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", @@ -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 diff --git a/mail_embed_image/models/res_config_settings.py b/mail_embed_image/models/res_config_settings.py new file mode 100644 index 0000000000..96b06ded01 --- /dev/null +++ b/mail_embed_image/models/res_config_settings.py @@ -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, + ) diff --git a/mail_embed_image/readme/CONTRIBUTORS.rst b/mail_embed_image/readme/CONTRIBUTORS.rst index dc3a82820b..69816562d8 100644 --- a/mail_embed_image/readme/CONTRIBUTORS.rst +++ b/mail_embed_image/readme/CONTRIBUTORS.rst @@ -1,3 +1,4 @@ * George Daramouskas * Giovanni Francesco Capalbo * Italo LOPES +* Stéphane Mangin diff --git a/mail_embed_image/readme/DESCRIPTION.rst b/mail_embed_image/readme/DESCRIPTION.rst index 1856fc3768..79b2830f87 100644 --- a/mail_embed_image/readme/DESCRIPTION.rst +++ b/mail_embed_image/readme/DESCRIPTION.rst @@ -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. diff --git a/mail_embed_image/static/description/index.html b/mail_embed_image/static/description/index.html index f47cf51749..6e142de0aa 100644 --- a/mail_embed_image/static/description/index.html +++ b/mail_embed_image/static/description/index.html @@ -371,6 +371,15 @@

Mail Embed Image

Beta License: AGPL-3 OCA/social Translate me on Weblate Try me on Runboat

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

diff --git a/mail_embed_image/tests/test_mail_embed_image.py b/mail_embed_image/tests/test_mail_embed_image.py index a2076024a4..20fd0ce8e0 100644 --- a/mail_embed_image/tests/test_mail_embed_image.py +++ b/mail_embed_image/tests/test_mail_embed_image.py @@ -1,6 +1,6 @@ # Copyright 2019 Therp BV # 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 @@ -9,14 +9,23 @@ class TestMailEmbedImage(common.TransactionCase): - def test_mail_embed_image(self): - """We pass a mail with 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( """ @@ -27,24 +36,61 @@ def test_mail_embed_image(self):
""" % ( # 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 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 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": @@ -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 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/") + ], + [], + ) diff --git a/mail_embed_image/views/res_config_settings_views.xml b/mail_embed_image/views/res_config_settings_views.xml new file mode 100644 index 0000000000..67196e0f9a --- /dev/null +++ b/mail_embed_image/views/res_config_settings_views.xml @@ -0,0 +1,26 @@ + + + + + res.config.settings.view.form.inherit.mail + res.config.settings + + +
+
+
+ Email Preprocessing +
+ Method used to embed images in HTML emails. CIDs attachment does not work with all email clients. Data SRC is more reliable. +
+ +
+
+
+
+ +