diff --git a/mail_embed_image/README.rst b/mail_embed_image/README.rst new file mode 100644 index 0000000000..32d79d601c --- /dev/null +++ b/mail_embed_image/README.rst @@ -0,0 +1,86 @@ +================ +Mail Embed Image +================ + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:a365995cc3558fa6f105e5354c6a4317efd6453f04a5647e0acdff4c5adb3c12 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fsocial-lightgray.png?logo=github + :target: https://github.com/OCA/social/tree/16.0/mail_embed_image + :alt: OCA/social +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/social-16-0/social-16-0-mail_embed_image + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/social&target_branch=16.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +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:: + :local: + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Therp BV + +Contributors +~~~~~~~~~~~~ + +* George Daramouskas +* Giovanni Francesco Capalbo +* Italo LOPES +* Stéphane Mangin + +Maintainers +~~~~~~~~~~~ + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/social `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/mail_embed_image/__init__.py b/mail_embed_image/__init__.py new file mode 100644 index 0000000000..c8c7f8b183 --- /dev/null +++ b/mail_embed_image/__init__.py @@ -0,0 +1,3 @@ +# Copyright 2019 Therp BV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from . import models diff --git a/mail_embed_image/__manifest__.py b/mail_embed_image/__manifest__.py new file mode 100644 index 0000000000..490e30af18 --- /dev/null +++ b/mail_embed_image/__manifest__.py @@ -0,0 +1,21 @@ +# 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", + "version": "16.0.1.0.0", + "author": "Therp BV,Odoo Community Association (OCA)", + "license": "AGPL-3", + "category": "Social", + "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/i18n/mail_embed_image.pot b/mail_embed_image/i18n/mail_embed_image.pot new file mode 100644 index 0000000000..061376b78a --- /dev/null +++ b/mail_embed_image/i18n/mail_embed_image.pot @@ -0,0 +1,20 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * mail_embed_image +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 10.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: <>\n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: mail_embed_image +#: model:ir.model,name:mail_embed_image.model_ir_mail_server +msgid "ir.mail_server" +msgstr "" + diff --git a/mail_embed_image/models/__init__.py b/mail_embed_image/models/__init__.py new file mode 100644 index 0000000000..80f961a489 --- /dev/null +++ b/mail_embed_image/models/__init__.py @@ -0,0 +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 new file mode 100644 index 0000000000..ede5261471 --- /dev/null +++ b/mail_embed_image/models/ir_mail_server.py @@ -0,0 +1,118 @@ +# 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 +from email.mime.image import MIMEImage + +import requests +from lxml.html import fromstring, tostring + +from odoo import models + +_logger = logging.getLogger(__name__) + + +class IrMailServer(models.Model): + _inherit = "ir.mail_server" + + def build_email( + self, + email_from, + email_to, + subject, + body, + email_cc=None, + email_bcc=None, + reply_to=False, + attachments=None, + message_id=None, + references=None, + object_id=False, + subtype="plain", + headers=None, + body_alternative=None, + subtype_alternative="plain", + ): + image_embedding_method = self.env.company.image_embedding_method + fileparts = None + 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, + email_to=email_to, + subject=subject, + body=body, + email_cc=email_cc, + email_bcc=email_bcc, + reply_to=reply_to, + attachments=attachments, + message_id=message_id, + references=references, + object_id=object_id, + subtype=subtype, + headers=headers, + body_alternative=body_alternative, + subtype_alternative=subtype_alternative, + ) + 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) + fileparts = [] + # 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") + filepart.add_header("Content-ID", f"<{cid}>") + filepart.add_header( + "Content-Disposition", + "inline", + filename=filename_encoded, + ) + img.set("src", f"cid:{cid}") + fileparts.append(filepart) + 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 new file mode 100644 index 0000000000..69816562d8 --- /dev/null +++ b/mail_embed_image/readme/CONTRIBUTORS.rst @@ -0,0 +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 new file mode 100644 index 0000000000..79b2830f87 --- /dev/null +++ b/mail_embed_image/readme/DESCRIPTION.rst @@ -0,0 +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/icon.png b/mail_embed_image/static/description/icon.png new file mode 100644 index 0000000000..3a0328b516 Binary files /dev/null and b/mail_embed_image/static/description/icon.png differ diff --git a/mail_embed_image/static/description/index.html b/mail_embed_image/static/description/index.html new file mode 100644 index 0000000000..6e142de0aa --- /dev/null +++ b/mail_embed_image/static/description/index.html @@ -0,0 +1,433 @@ + + + + + +Mail Embed Image + + + +
+

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

+ +
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Therp BV
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+Odoo Community Association +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/social project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/mail_embed_image/tests/__init__.py b/mail_embed_image/tests/__init__.py new file mode 100644 index 0000000000..4ad3303d0c --- /dev/null +++ b/mail_embed_image/tests/__init__.py @@ -0,0 +1,3 @@ +# Copyright 2019 Therp BV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from . import test_mail_embed_image diff --git a/mail_embed_image/tests/test_mail_embed_image.py b/mail_embed_image/tests/test_mail_embed_image.py new file mode 100644 index 0000000000..20fd0ce8e0 --- /dev/null +++ b/mail_embed_image/tests/test_mail_embed_image.py @@ -0,0 +1,143 @@ +# Copyright 2019 Therp BV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +import base64 + +from lxml import html +from requests import get + +from odoo.tests import common + + +class TestMailEmbedImage(common.TransactionCase): + @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( + """ +
+ this is an email + + +
""" + % ( + # won't be hit because we ignore embedded images + base64.b64encode(self.image_content).decode("utf-8"), + # dito, not uploaded content + self.image_url, + ) + ) + ) + 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": + # 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, 'cid:')]" + ) + ) + # verify 1 replaced image + self.assertEqual(images_in_mail, 1) + # verify 1 attachment present + self.assertEqual( + [ + x.get_content_type() + for x in res.walk() + if x.get_content_type().startswith("image/") + ], + ["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. +
+ +
+
+
+
+ +
diff --git a/setup/mail_embed_image/odoo/addons/mail_embed_image b/setup/mail_embed_image/odoo/addons/mail_embed_image new file mode 120000 index 0000000000..d2efe33fa5 --- /dev/null +++ b/setup/mail_embed_image/odoo/addons/mail_embed_image @@ -0,0 +1 @@ +../../../../mail_embed_image \ No newline at end of file diff --git a/setup/mail_embed_image/setup.py b/setup/mail_embed_image/setup.py new file mode 100644 index 0000000000..28c57bb640 --- /dev/null +++ b/setup/mail_embed_image/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)