From 44642bec77ccfee1b1f51738a9934e4bfdc2f2a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20=C3=89vrard?= Date: Tue, 31 Mar 2026 18:59:55 +0200 Subject: [PATCH] Add support for emails on the chat channels --- modules/inbound_email/CHANGELOG | 1 + modules/inbound_email/doc/configuration.rst | 13 + modules/inbound_email/inbound_email.py | 1 + modules/inbound_email/ir.py | 79 +++++ modules/inbound_email/message.xml | 10 + modules/inbound_email/tests/test_module.py | 47 +++ modules/inbound_email/tryton.cfg | 2 + modules/party/ir.py | 55 +++- modules/party/tryton.cfg | 1 + sao/src/chat.js | 291 ++++++++++++++---- sao/src/sao.less | 43 +++ tryton/tryton/chat.py | 271 +++++++++++++--- trytond/CHANGELOG | 1 + trytond/trytond/ir/chat.py | 281 +++++++++++++++-- trytond/trytond/ir/chat.xml | 74 +++++ trytond/trytond/ir/message.xml | 12 + trytond/trytond/ir/tryton.cfg | 1 + trytond/trytond/ir/view/chat_channel_form.xml | 13 + trytond/trytond/ir/view/chat_channel_list.xml | 7 + .../trytond/ir/view/chat_follower_form.xml | 10 + .../trytond/ir/view/chat_follower_list.xml | 6 + trytond/trytond/tests/test_chat.py | 66 +++- 22 files changed, 1146 insertions(+), 139 deletions(-) create mode 100644 modules/inbound_email/ir.py create mode 100644 modules/inbound_email/message.xml create mode 100644 trytond/trytond/ir/chat.xml create mode 100644 trytond/trytond/ir/view/chat_channel_form.xml create mode 100644 trytond/trytond/ir/view/chat_channel_list.xml create mode 100644 trytond/trytond/ir/view/chat_follower_form.xml create mode 100644 trytond/trytond/ir/view/chat_follower_list.xml diff --git a/modules/inbound_email/CHANGELOG b/modules/inbound_email/CHANGELOG index 6a1ec5474f..19e9d25129 100644 --- a/modules/inbound_email/CHANGELOG +++ b/modules/inbound_email/CHANGELOG @@ -1,3 +1,4 @@ +* Add an action to handle chat replies * Add support for Python 3.14 * Remove support for Python 3.9 diff --git a/modules/inbound_email/doc/configuration.rst b/modules/inbound_email/doc/configuration.rst index e8ddfe7c36..97c19993e5 100644 --- a/modules/inbound_email/doc/configuration.rst +++ b/modules/inbound_email/doc/configuration.rst @@ -5,6 +5,19 @@ Configuration The *Inbound Email Module* uses values from settings in the ``[inbound_email]`` section of the :ref:`trytond:topics-configuration`. +.. _config-inbound_email.chat_reply_to: + +``chat_reply_to`` +================= + +``chat_reply_to`` defines the email address used in the ``Reply-To`` header +when sending a message from a chat channel. +Tryton uses `sub-addressing`_ to discriminate between channels. + +.. _`sub-addressing`: https://en.wikipedia.org/wiki/Email_address#Sub-addressing + +Default: To the value of :ref:`trytond:config-email.from`. + .. _config-inboud_email.filestore: ``filestore`` diff --git a/modules/inbound_email/inbound_email.py b/modules/inbound_email/inbound_email.py index 844041db70..e5f001d64c 100644 --- a/modules/inbound_email/inbound_email.py +++ b/modules/inbound_email/inbound_email.py @@ -255,6 +255,7 @@ class Rule(sequence_ordered(), ModelSQL, ModelView): action = fields.Selection([ (None, ""), + ('ir.chat.channel|post_inbound_email', "Post to Channel"), ], "Action") @classmethod diff --git a/modules/inbound_email/ir.py b/modules/inbound_email/ir.py new file mode 100644 index 0000000000..f116aa9a1d --- /dev/null +++ b/modules/inbound_email/ir.py @@ -0,0 +1,79 @@ +# 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 email.parser +import email.policy +import itertools +import re +from email.utils import getaddresses + +import trytond.config as config +from trytond.pool import Pool, PoolMeta +from trytond.transaction import Transaction + +REPLY_LINE = '\N{EM DASH}' * 80 +DESTINATION_RE = re.compile(r"^[^@]*\+([0-9a-f]{32})@", re.IGNORECASE) + + +def _get_channel_identifier(email): + base = config.get( + 'inbound_email', 'chat_reply_to', + default=config.get('email', 'from')) + _, our_domain = base.split('@', 1) + + for key in ('To', 'Cc'): + if key not in email: + continue + addresses = [a for _, a in getaddresses(email.get_all(key))] + for address in addresses: + _, domain = address.split('@', 1) + if domain != our_domain: + continue + if (m := DESTINATION_RE.match(address)): + channel_id, = m.groups() + return channel_id + return None + + +class Channel(metaclass=PoolMeta): + __name__ = 'ir.chat.channel' + + @classmethod + def _email_channel(cls, email): + channels = [] + if (channel_id := _get_channel_identifier(email)): + channels = cls.search([('identifier', '=', channel_id)]) + return None if len(channels) != 1 else channels[0] + + @classmethod + def _email_content(cls, body): + return '\n'.join(itertools.takewhile( + lambda l: REPLY_LINE not in l, + body.splitlines())) + + @classmethod + def post_inbound_email(cls, inbound_email): + parser = email.parser.BytesParser(policy=email.policy.default) + message = parser.parsebytes(inbound_email.data) + return cls.post_from_email(message) + + @classmethod + def _email_reply_to(cls, message): + base = config.get( + 'inbound_email', 'chat_reply_to', + default=config.get('email', 'from')) + local_part, domain_part = base.split('@', 1) + if '+' in local_part: + local_part, _ = local_part.split('+', 1) + local_part += f'+{message.channel.identifier}' + return '@'.join((local_part, domain_part)) + + @classmethod + def _email_body(cls, message): + pool = Pool() + Message = pool.get('ir.message') + ModelData = pool.get('ir.model.data') + + with Transaction().set_context(language=message.channel.language): + above_msg = Message(ModelData.get_id('ir', 'msg_reply_above')) + return f'{REPLY_LINE}\n{above_msg.text}\n\n{message.content}' diff --git a/modules/inbound_email/message.xml b/modules/inbound_email/message.xml new file mode 100644 index 0000000000..1eadd0743b --- /dev/null +++ b/modules/inbound_email/message.xml @@ -0,0 +1,10 @@ + + + + + + Please reply above the line. + + + diff --git a/modules/inbound_email/tests/test_module.py b/modules/inbound_email/tests/test_module.py index e184a82a3a..7767495fb1 100644 --- a/modules/inbound_email/tests/test_module.py +++ b/modules/inbound_email/tests/test_module.py @@ -6,6 +6,9 @@ import uuid from email.message import EmailMessage from unittest.mock import patch +import trytond.config as config +from trytond.modules.inbound_email.ir import ( + REPLY_LINE, _get_channel_identifier) from trytond.pool import Pool from trytond.protocols.wrappers import HTTPStatus from trytond.tests.test_tryton import ( @@ -17,6 +20,16 @@ class InboundEmailTestCase(ModuleTestCase): "Test Inbound Email module" module = 'inbound_email' + @classmethod + def setUpClass(cls): + super().setUpClass() + email_from = config.get('email', 'from', default='') + config.set('email', 'from', 'tryton@example.org') + cls.addClassCleanup(config.set, 'email', 'from', email_from) + config.add_section('inbound_email') + config.set('inbound_email', 'chat_reply_to', 'chat@example.org') + cls.addClassCleanup(config.remove_section, 'inbound_email') + def get_message(self): message = EmailMessage() message['From'] = "John Doe " @@ -303,6 +316,40 @@ class InboundEmailTestCase(ModuleTestCase): func.assert_called_once_with(email, rule) self.assertEqual(email.result, result) + def test_get_channel_identifier(self): + "Test parsing the recipients to find the email channel" + channel_id = uuid.uuid4().hex + + for value, expected in [ + (f'chat+{channel_id}@example.org', channel_id), + (['chat@example.org', f'chat+{channel_id}@example.org'], + channel_id), + ('chat@example.org', None), + ('chat+foo@example.org', None), + (f'{channel_id}@example.org', None), + (f'chat+{channel_id}@example.com', None), + ]: + for recipient_hdr in ('To', 'Cc'): + with self.subTest(f"{recipient_hdr}: {value}"): + msg = EmailMessage() + msg[recipient_hdr] = value + self.assertEqual(_get_channel_identifier(msg), expected) + + @with_transaction() + def test_find_email_content(self): + "Test finding the content of the reply" + pool = Pool() + Channel = pool.get('ir.chat.channel') + + for (body, expected) in [ + ("Foo", "Foo"), + ("Foo\n-----\nBar", "Foo\n-----\nBar"), + (f"Foo\n{REPLY_LINE}\nLorem Ipsum", "Foo"), + (f"Foo\n> {REPLY_LINE}\n> Lorem Ipsum", "Foo"), + ]: + with self.subTest(body): + self.assertEqual(Channel._email_content(body), expected) + class InboundEmailRouteTestCase(RouteTestCase): "Test Inbound Email route" diff --git a/modules/inbound_email/tryton.cfg b/modules/inbound_email/tryton.cfg index 3214276fca..6531d41063 100644 --- a/modules/inbound_email/tryton.cfg +++ b/modules/inbound_email/tryton.cfg @@ -5,6 +5,7 @@ depends: res xml: inbound_email.xml + message.xml [register] model: @@ -12,3 +13,4 @@ model: inbound_email.Email inbound_email.Rule inbound_email.RuleHeader + ir.Channel diff --git a/modules/party/ir.py b/modules/party/ir.py index c0b1614f5f..5ff9ddcd1b 100644 --- a/modules/party/ir.py +++ b/modules/party/ir.py @@ -2,7 +2,7 @@ # this repository contains the full copyright notices and license terms. from trytond.model import fields from trytond.pool import Pool, PoolMeta -from trytond.tools import escape_wildcard +from trytond.tools import escape_wildcard, grouped_slice from trytond.transaction import Transaction @@ -101,3 +101,56 @@ class EmailTemplate(metaclass=PoolMeta): elif record.party.lang: language = record.party.lang return language + + +class Channel(metaclass=PoolMeta): + __name__ = 'ir.chat.channel' + + @classmethod + def _get_followers(cls, resource): + pool = Pool() + Party = pool.get('party.party') + ContactMechanism = pool.get('party.contact_mechanism') + + followers = super()._get_followers(resource) + has_avatar = 'avatar_url' in Party._fields + + for emails in grouped_slice([e for t, e in followers if t == 'email']): + contacts = ContactMechanism.search([ + ('type', '=', 'email'), + ('value', 'in', list(emails)), + ]) + followers |= {('email', c.value): { + 'type': 'email', + 'key': c.value, + 'name': f'{c.name}', + 'avatar_url': c.party.avatar_url if has_avatar else None, + } for c in contacts} + + return followers + + @classmethod + def _search_followers(cls, resource, text): + pool = Pool() + Party = pool.get('party.party') + ContactMechanism = pool.get('party.contact_mechanism') + + followers = super()._search_followers(resource, text) + has_avatar = 'avatar_url' in Party._fields + + contacts = ContactMechanism.search([ + 'OR', + [ + ('rec_name', 'ilike', '{text}%'), + ('type', '=', 'email'), + ], + ('name', 'ilike', f'%{text}%'), + ]) + return followers | { + ('email', c.value): { + 'type': 'email', + 'key': c.value, + 'name': f'{c.name}', + 'avatar_url': c.party.avatar_url if has_avatar else None, + } + for c in contacts} diff --git a/modules/party/tryton.cfg b/modules/party/tryton.cfg index 8e2461168a..ee9606a394 100644 --- a/modules/party/tryton.cfg +++ b/modules/party/tryton.cfg @@ -34,6 +34,7 @@ model: configuration.ConfigurationLang ir.Email ir.EmailTemplate + ir.Channel wizard: party.CheckVIES party.Replace diff --git a/sao/src/chat.js b/sao/src/chat.js index 461a85fccd..7e0b57d1f3 100644 --- a/sao/src/chat.js +++ b/sao/src/chat.js @@ -3,6 +3,16 @@ (function() { 'use strict'; + function add_size(url, size=32) { + if (url) { + url = new URL(url, window.location); + url.searchParams.set('s', size); + return url.href; + } else { + return ''; + } + } + class _Chat { constructor(record) { this.notify = this.notify.bind(this); @@ -46,63 +56,221 @@ }); } + _get_followers() { + return Sao.rpc({ + 'method': 'model.ir.chat.channel.get_followers', + 'params': [this.record, {}], + }, Sao.Session.current_session); + } + + _search_followers(text) { + if (!text) { + return jQuery.when(); + } + + return Sao.rpc({ + 'method': 'model.ir.chat.channel.search_followers', + 'params': [this.record, text, {}], + }, Sao.Session.current_session).then(followers => { + return followers.filter(e => { + return !((e.type == 'user') && + (e.key == Sao.Session.current_session.login)); + }); + }); + } + + _add_follower(follower) { + let method; + switch (follower.type) { + case 'user': + method = 'subscribe'; + break; + case 'email': + method = 'subscribe_email'; + break; + } + Sao.rpc({ + 'method': `model.ir.chat.channel.${method}`, + 'params': [this.record, follower.key, {}], + }, Sao.Session.current_session); + } + + _remove_follower(follower) { + let method; + switch (follower.type) { + case 'user': + method = 'unsubscribe'; + break; + case 'email': + method = 'unsubscribe_email'; + break; + } + Sao.rpc({ + 'method': `model.ir.chat.channel.${method}`, + 'params': [this.record, follower.key, {}], + }, Sao.Session.current_session); + } + __build() { let el = jQuery('
', { 'class': 'chat', }); - let btn_group = jQuery('
', { - 'class': 'btn-group', - 'role': 'group', + let toolbar = jQuery('
', { + 'class': 'btn-toolbar', + 'role': 'toolbar', }).appendTo(el); + let input_group = jQuery('
', { + 'class': 'input-group', + 'role': 'group', + }).appendTo(toolbar); + this.subscribe_input = jQuery('', { + 'class': 'form-control input-sm', + 'placeholder': Sao.i18n.gettext("Add a follower"), + 'title': Sao.i18n.gettext("Subscribe a follower to this channel"), + }).appendTo(input_group); + let avatar_size = 32; + + let format = function(val) { + return jQuery('
', { + 'title': val.key, + }) + .append(jQuery('', { + 'src': add_size(val.avatar_url, avatar_size), + 'class': 'img-circle', + 'style': `width: ${avatar_size}px; height: ${avatar_size}px;`, + })) + .append(jQuery('', { + }) + .text(val.name)); + }; + new Sao.common.InputCompletion( + input_group, this._search_followers.bind(this), + (follower) => { + this._add_follower(follower); + this.subscribe_input.val(''); + this.subscribe_input.focus(); + }, format); + + let dropdown = jQuery('
', { + 'class': 'btn-group dropdown', + 'role': 'group', + }).appendTo(toolbar); let subscribe_btn = jQuery('