Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[16.0][MIG] mail_embed_image module #1499

Open
wants to merge 19 commits into
base: 16.0
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 <gdaramouskas@therp.nl>
* Giovanni Francesco Capalbo <giovanni@therp.nl>
* Italo LOPES <italo.lopes@camptocamp.com>
* Stéphane Mangin <stephane.mangin@camptocamp.com>

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 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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please add a test case where we have an image with a relative url, I believe this will fail here

_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,
)
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 <gdaramouskas@therp.nl>
* Giovanni Francesco Capalbo <giovanni@therp.nl>
* Italo LOPES <italo.lopes@camptocamp.com>
* Stéphane Mangin <stephane.mangin@camptocamp.com>
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 = "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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that actually depends very much on the clients you target, with old outlooks it's the other way around. I'd propose not to make claim about what works better, and rather suggest to test with whatever are the expected mail clients

</div>
<field name="image_embedding_method" />
</div>
</div>
</field>
</record>

</odoo>
Loading