diff --git a/trytond/CHANGELOG b/trytond/CHANGELOG
index 20555e072a..c77737ac1f 100644
--- a/trytond/CHANGELOG
+++ b/trytond/CHANGELOG
@@ -1,3 +1,4 @@
+* Support the conversion of MJML reports to HTML
* Allow filtering users to be notified by cron tasks
Version 7.8.0 - 2025-12-15
diff --git a/trytond/doc/ref/report.rst b/trytond/doc/ref/report.rst
index 333abe1f20..a223763cf3 100644
--- a/trytond/doc/ref/report.rst
+++ b/trytond/doc/ref/report.rst
@@ -129,6 +129,10 @@ Class methods are:
Email
=====
+Email reports must have ``HTML`` as output format.
+`MJML `_ syntax can be used with the ``XML`` input format and
+``HTML`` output format to generate responsive emails.
+
.. function:: get_email(report, record, languages)
Returns the :py:class:`~email.message.EmailMessage` and title using the
@@ -136,3 +140,10 @@ Email
:class:`~trytond.model.ModelStorage` record for each language.
.. note:: Order languages with the preferred last.
+
+.. function:: mjml_to_html(content)
+
+ Converts ``MJML`` content to ``HTML``.
+
+ .. warning::
+ It may return the content unconverted if some dependencies are missing.
diff --git a/trytond/setup.py b/trytond/setup.py
index 742674aa14..c0e16bb795 100755
--- a/trytond/setup.py
+++ b/trytond/setup.py
@@ -81,7 +81,7 @@ setup(name=name,
'trytond.ir.ui': ['*.xml', '*.rng', '*.rnc'],
'trytond.res': [
'tryton.cfg', '*.xml', '*.html', 'view/*.xml', 'locale/*.po'],
- 'trytond.tests': ['tryton.cfg', '*.xml', '*.txt'],
+ 'trytond.tests': ['tryton.cfg', '*.xml', '*.txt', '*.mjml'],
},
scripts=[
'bin/trytond',
@@ -154,6 +154,7 @@ setup(name=name,
'Levenshtein': ['python-Levenshtein'],
'html2text': ['html2text'],
'weasyprint': ['weasyprint'],
+ 'mjml': ['mrml'],
'coroutine': ['gevent>=1.1'],
'image': ['pillow'],
'barcode': ['python-barcode[images]'],
diff --git a/trytond/tox.ini b/trytond/tox.ini
index a8ebea2070..448bbd0011 100644
--- a/trytond/tox.ini
+++ b/trytond/tox.ini
@@ -8,6 +8,7 @@ extras =
barcode
qrcode
email-validation
+ mjml
commands =
coverage run --omit=*/tests/* -m xmlrunner discover -s trytond.tests {posargs}
commands_post =
diff --git a/trytond/trytond/report/__init__.py b/trytond/trytond/report/__init__.py
index 3967dd8114..b29485fb47 100644
--- a/trytond/trytond/report/__init__.py
+++ b/trytond/trytond/report/__init__.py
@@ -1,5 +1,5 @@
# This file is part of Tryton. The COPYRIGHT file at the top level of
# this repository contains the full copyright notices and license terms.
-from .report import Report, get_email
+from .report import Report, get_email, mjml_to_html
-__all__ = [Report, get_email]
+__all__ = [Report, get_email, mjml_to_html]
diff --git a/trytond/trytond/report/report.py b/trytond/trytond/report/report.py
index 136aa76ae0..a713931f88 100644
--- a/trytond/trytond/report/report.py
+++ b/trytond/trytond/report/report.py
@@ -130,6 +130,19 @@ class TranslateFactory:
self.report_name, text, text_plural, n)
+def _lazy_import(name):
+ attr_name = f'_mod_{name}'
+ if not hasattr(_lazy_import, attr_name):
+ try:
+ mod = __import__(name)
+ except ImportError:
+ mod = None
+ setattr(_lazy_import, attr_name, mod)
+ else:
+ mod = getattr(_lazy_import, attr_name)
+ return mod
+
+
class Report(URLMixin, PoolBase):
@classmethod
@@ -414,6 +427,13 @@ class Report(URLMixin, PoolBase):
and output_format == 'pdf'):
return output_format, weasyprint.HTML(string=data).write_pdf()
+ if (input_format == 'xml'
+ and output_format == 'html'
+ and isinstance(data, str)
+ and data.startswith('
+
+
+
+ ${user.name}
+
+
+
+
diff --git a/trytond/trytond/tests/report.py b/trytond/trytond/tests/report.py
index 645c2ad87c..471ac510ed 100644
--- a/trytond/trytond/tests/report.py
+++ b/trytond/trytond/tests/report.py
@@ -9,7 +9,12 @@ class TestReport(Report):
__name__ = 'test.test_report'
+class TestReportMJML(Report):
+ __name__ = 'test.test_report_mjml'
+
+
def register(module):
Pool.register(
TestReport,
+ TestReportMJML,
module=module, type_='report')
diff --git a/trytond/trytond/tests/report.xml b/trytond/trytond/tests/report.xml
index 227d9fe2ee..04068ba568 100644
--- a/trytond/trytond/tests/report.xml
+++ b/trytond/trytond/tests/report.xml
@@ -9,5 +9,13 @@ this repository contains the full copyright notices and license terms. -->
tests/report.txt
txt
+
+
+ Test Report MJML
+ test.test_report_mjml
+ tests/report.mjml
+ xml
+ html
+
diff --git a/trytond/trytond/tests/test_report.py b/trytond/trytond/tests/test_report.py
index 4140da7387..2bb7ace205 100644
--- a/trytond/trytond/tests/test_report.py
+++ b/trytond/trytond/tests/test_report.py
@@ -1,9 +1,15 @@
# This file is part of Tryton. The COPYRIGHT file at the top level of
# this repository contains the full copyright notices and license terms.
import datetime
+import unittest
from email.message import EmailMessage
from unittest.mock import Mock, patch
+try:
+ import mrml
+except ImportError:
+ mrml = None
+
from trytond.model.exceptions import AccessError
from trytond.pool import Pool
from trytond.report.report import Report, get_email
@@ -87,6 +93,18 @@ class ReportTestCase(TestCase):
with self.assertRaises(AccessError):
Report.execute([1], {'model': 'test.access'})
+ @unittest.skipUnless(mrml, "required mrml")
+ @with_transaction()
+ def test_convert_mjml_to_html(self):
+ "Test convert MJML to HTML"
+ pool = Pool()
+ Report = pool.get('test.test_report_mjml', type='report')
+
+ oext, content, _, _ = Report.execute([1], {})
+
+ self.assertEqual(oext, 'html')
+ self.assertEqual(content[:15], '')
+
@with_transaction()
def test_get_email_html(self):
"Test get email"