details: https://code.tryton.org/tryton/commit/91b7e4ff14ce branch: default user: Nicolas Évrard <[email protected]> date: Tue Mar 31 18:59:55 2026 +0200 description: Add support for emails on the chat channels diffstat:
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 | 289 ++++++++++++++++++++++-- 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/trytond/ir/view/chat_follower_form.xml | 10 + trytond/trytond/ir/view/chat_follower_list.xml | 6 + trytond/trytond/tests/test_chat.py | 66 +++++- 22 files changed, 1150 insertions(+), 143 deletions(-) diffs (1737 lines): diff -r ca46ca48b68a -r 91b7e4ff14ce modules/inbound_email/CHANGELOG --- a/modules/inbound_email/CHANGELOG Tue Mar 10 14:05:19 2026 +0100 +++ b/modules/inbound_email/CHANGELOG Tue Mar 31 18:59:55 2026 +0200 @@ -1,3 +1,4 @@ +* Add an action to handle chat replies * Add support for Python 3.14 * Remove support for Python 3.9 diff -r ca46ca48b68a -r 91b7e4ff14ce modules/inbound_email/doc/configuration.rst --- a/modules/inbound_email/doc/configuration.rst Tue Mar 10 14:05:19 2026 +0100 +++ b/modules/inbound_email/doc/configuration.rst Tue Mar 31 18:59:55 2026 +0200 @@ -5,6 +5,19 @@ 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 -r ca46ca48b68a -r 91b7e4ff14ce modules/inbound_email/inbound_email.py --- a/modules/inbound_email/inbound_email.py Tue Mar 10 14:05:19 2026 +0100 +++ b/modules/inbound_email/inbound_email.py Tue Mar 31 18:59:55 2026 +0200 @@ -255,6 +255,7 @@ action = fields.Selection([ (None, ""), + ('ir.chat.channel|post_inbound_email', "Post to Channel"), ], "Action") @classmethod diff -r ca46ca48b68a -r 91b7e4ff14ce modules/inbound_email/ir.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/modules/inbound_email/ir.py Tue Mar 31 18:59:55 2026 +0200 @@ -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 -r ca46ca48b68a -r 91b7e4ff14ce modules/inbound_email/message.xml --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/modules/inbound_email/message.xml Tue Mar 31 18:59:55 2026 +0200 @@ -0,0 +1,10 @@ +<?xml version="1.0"?> +<!-- This file is part of Tryton. The COPYRIGHT file at the top level of +this repository contains the full copyright notices and license terms. --> +<tryton> + <data> + <record model="ir.message" id="msg_reply_above"> + <field name="text">Please reply above the line.</field> + </record> + </data> +</tryton> diff -r ca46ca48b68a -r 91b7e4ff14ce modules/inbound_email/tests/test_module.py --- a/modules/inbound_email/tests/test_module.py Tue Mar 10 14:05:19 2026 +0100 +++ b/modules/inbound_email/tests/test_module.py Tue Mar 31 18:59:55 2026 +0200 @@ -6,6 +6,9 @@ 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 @@ "Test Inbound Email module" module = 'inbound_email' + @classmethod + def setUpClass(cls): + super().setUpClass() + email_from = config.get('email', 'from', default='') + config.set('email', 'from', '[email protected]') + cls.addClassCleanup(config.set, 'email', 'from', email_from) + config.add_section('inbound_email') + config.set('inbound_email', 'chat_reply_to', '[email protected]') + cls.addClassCleanup(config.remove_section, 'inbound_email') + def get_message(self): message = EmailMessage() message['From'] = "John Doe <[email protected]>" @@ -303,6 +316,40 @@ 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), + (['[email protected]', f'chat+{channel_id}@example.org'], + channel_id), + ('[email protected]', None), + ('[email protected]', 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 -r ca46ca48b68a -r 91b7e4ff14ce modules/inbound_email/tryton.cfg --- a/modules/inbound_email/tryton.cfg Tue Mar 10 14:05:19 2026 +0100 +++ b/modules/inbound_email/tryton.cfg Tue Mar 31 18:59:55 2026 +0200 @@ -5,6 +5,7 @@ res xml: inbound_email.xml + message.xml [register] model: @@ -12,3 +13,4 @@ inbound_email.Email inbound_email.Rule inbound_email.RuleHeader + ir.Channel diff -r ca46ca48b68a -r 91b7e4ff14ce modules/party/ir.py --- a/modules/party/ir.py Tue Mar 10 14:05:19 2026 +0100 +++ b/modules/party/ir.py Tue Mar 31 18:59:55 2026 +0200 @@ -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 @@ 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 -r ca46ca48b68a -r 91b7e4ff14ce modules/party/tryton.cfg --- a/modules/party/tryton.cfg Tue Mar 10 14:05:19 2026 +0100 +++ b/modules/party/tryton.cfg Tue Mar 31 18:59:55 2026 +0200 @@ -34,6 +34,7 @@ configuration.ConfigurationLang ir.Email ir.EmailTemplate + ir.Channel wizard: party.CheckVIES party.Replace diff -r ca46ca48b68a -r 91b7e4ff14ce sao/src/chat.js --- a/sao/src/chat.js Tue Mar 10 14:05:19 2026 +0100 +++ b/sao/src/chat.js Tue Mar 31 18:59:55 2026 +0200 @@ -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('<div/>', { 'class': 'chat', }); - let btn_group = jQuery('<div/>', { - 'class': 'btn-group', + let toolbar = jQuery('<div/>', { + 'class': 'btn-toolbar', + 'role': 'toolbar', + }).appendTo(el); + let input_group = jQuery('<div/>', { + 'class': 'input-group', 'role': 'group', - }).appendTo(el); + }).appendTo(toolbar); + this.subscribe_input = jQuery('<input/>', { + '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('<div/>', { + 'title': val.key, + }) + .append(jQuery('<img/>', { + 'src': add_size(val.avatar_url, avatar_size), + 'class': 'img-circle', + 'style': `width: ${avatar_size}px; height: ${avatar_size}px;`, + })) + .append(jQuery('<span/>', { + }) + .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('<div/>', { + 'class': 'btn-group dropdown', + 'role': 'group', + }).appendTo(toolbar); let subscribe_btn = jQuery('<button/>', { - 'class': 'btn btn-default pull-right', 'type': 'button', - 'title': Sao.i18n.gettext("Toggle notification"), + 'class': 'btn btn-default pull-right dropdown-toggle', + 'data-toggle': 'dropdown', + 'aria-expanded': false, + 'aria-haspopup': true, + 'title': Sao.i18n.gettext("Show followers"), }).append(Sao.common.ICONFACTORY.get_icon_img( 'tryton-notification')) - .appendTo(btn_group); + .appendTo(dropdown); + subscribe_btn.uniqueId(); - Sao.rpc({ - 'method': 'model.ir.chat.channel.get_followers', - 'params': [this.record, {}], - }, Sao.Session.current_session).then((followers) => { - set_subscribe_state(~followers.users.indexOf( - Sao.Session.current_session.login)); - }) + this._get_followers().then((followers) => { + let subscribed = followers.some((e) => ( + (e.type == 'user') && + (e.key == Sao.Session.current_session.login))); + set_subscribe_state(subscribed); + });; let set_subscribe_state = (subscribed) => { let img; if (subscribed) { img = 'tryton-notification-on'; - subscribe_btn.addClass('active').off().click(unsubscribe); + subscribe_btn.addClass('active'); } else { img = 'tryton-notification-off'; - subscribe_btn.removeClass('active').off().click(subscribe); + subscribe_btn.removeClass('active'); } subscribe_btn.html(Sao.common.ICONFACTORY.get_icon_img(img)); } - let subscribe = () => { - let session = Sao.Session.current_session; - Sao.rpc({ - 'method': 'model.ir.chat.channel.subscribe', - 'params': [this.record, {}], - }, session).then(() => { - set_subscribe_state(true); - }) - }; + let menu = jQuery('<ul/>', { + 'class': 'dropdown-menu dropdown-menu-right', + 'role': 'menu', + 'aria-labelledby': subscribe_btn.id, + }).appendTo(dropdown); + + dropdown.on('show.bs.dropdown', () => { + menu.empty(); + + let user_action; + if (subscribe_btn.hasClass('active')) { + user_action = jQuery('<a/>', { + 'href': '#', + 'class': 'text-danger text-uppercase', + }).text(Sao.i18n.gettext("Unsubscribe")) + .click(() => { + Sao.rpc({ + 'method': 'model.ir.chat.channel.unsubscribe', + 'params': [this.record, {}], + }, Sao.Session.current_session).then(() => { + set_subscribe_state(false); + }); + }); + } else { + user_action = jQuery('<a/>', { + 'href': '#', + 'class': 'text-primary text-uppercase', + }).text(Sao.i18n.gettext("Subscribe")) + .click(() => { + Sao.rpc({ + 'method': 'model.ir.chat.channel.subscribe', + 'params': [this.record, {}], + }, Sao.Session.current_session).then(() => { + set_subscribe_state(true); + }); + }); + } + jQuery('<li/>', { + 'role': 'presentation', + }).append(user_action).appendTo(menu); - let unsubscribe = () => { - let session = Sao.Session.current_session; - Sao.rpc({ - 'method': 'model.ir.chat.channel.unsubscribe', - 'params': [this.record, {}], - }, session).then(() => { - set_subscribe_state(false); - }) - }; + this._get_followers().then((followers) => { + followers = followers.filter(e => { + return !((e.type == 'user') && + (e.key == Sao.Session.current_session.login)); + }); + if (followers.length) { + jQuery('<li/>', { + 'class': 'divider', + }).appendTo(menu); + } + let add_follower = (follower) => { + jQuery('<li/>', { + 'role': 'presentation', + }) + .append(jQuery('<div/>', { + 'title': follower.key, + }) + .append(jQuery('<img/>', { + 'class': 'img-circle chat-avatar', + 'src': add_size(follower.avatar_url, avatar_size), + 'style': `width: ${avatar_size}px; height: ${avatar_size}px;`, + })) + .append(jQuery('<span/>') + .text(follower.name)) + .append(jQuery('<button/>', { + 'class': 'btn btn-link', + 'title': Sao.i18n.gettext("Unsubscribe"), + 'type': 'button', + }).append("×").click((evt) => { + Sao.common.sur.run( + Sao.i18n.gettext( + 'Are you sure to unsubscribe "%1" from this channel?', + follower.name) + ).then(() => { + this._remove_follower(follower); + }); + })) + ).appendTo(menu); + }; + followers.forEach(add_follower); + }); + }); this._messages = jQuery('<div/>', { 'class': 'chat-messages', @@ -167,35 +335,40 @@ } create_message(message) { - let avatar_size = 32; let timestamp = Sao.common.format_datetime( Sao.common.date_format() + ' %X', message.timestamp); - let avatar_url = ''; - if (message.avatar_url) { - let url = new URL(message.avatar_url, window.location); - url.searchParams.set('s', avatar_size); - avatar_url = url.href; + if (message.user) { + let avatar_size = 32; + let avatar_url = add_size(message.avatar_url, avatar_size); + + return jQuery('<div/>', { + 'class': 'media chat-message', + }).append(jQuery('<div/>', { + 'class': 'media-left', + }).append(jQuery('<img/>', { + 'class': 'media-object img-circle chat-avatar', + 'src': avatar_url, + 'alt': message.author, + 'style': `width: ${avatar_size}px; height: ${avatar_size}px;`, + }))).append(jQuery('<div/>', { + 'class': 'media-body well well-sm', + }).append(jQuery('<h6/>', { + 'class': 'media-heading', + }).text(message.author) + .append(jQuery('<small/>', { + 'class': 'text-muted pull-right', + }).text(timestamp))) + .append(jQuery('<div/>', { + 'class': `chat-content chat-content-${message.audience}`, + }).text(message.content))); + } else { + return jQuery('<div/>', { + 'class': 'media chat-message system-message', + }).append(jQuery('<div/>', { + 'class': 'chat-content', + 'title': timestamp, + }).text(message.content)); } - return jQuery('<div/>', { - 'class': 'media chat-message', - }).append(jQuery('<div/>', { - 'class': 'media-left', - }).append(jQuery('<img/>', { - 'class': 'media-object img-circle chat-avatar', - 'src': avatar_url, - 'alt': message.author, - 'style': `width: ${avatar_size}px; height: ${avatar_size}px;`, - }))).append(jQuery('<div/>', { - 'class': 'media-body well well-sm', - }).append(jQuery('<h6/>', { - 'class': 'media-heading', - }).text(message.author) - .append(jQuery('<small/>', { - 'class': 'text-muted pull-right', - }).text(timestamp))) - .append(jQuery('<div/>', { - 'class': `chat-content chat-content-${message.audience}`, - }).text(message.content))); } } Sao.Chat = _Chat; diff -r ca46ca48b68a -r 91b7e4ff14ce sao/src/sao.less --- a/sao/src/sao.less Tue Mar 10 14:05:19 2026 +0100 +++ b/sao/src/sao.less Tue Mar 31 18:59:55 2026 +0200 @@ -520,6 +520,40 @@ padding-left: 5px; padding-right: 5px; + .btn-toolbar { + display: flex; + + .input-group { + flex: 1; + + .img-circle { + height: 2em; + margin-inline-end: 1ex; + } + } + + ul.dropdown-menu { + width: 20em; + + div { + display: flex; + margin: 1ex; + gap: 5px; + + img { + align-self: center; + width: 32px; + height: 32px; + } + + span { + flex: 1; + align-self: center; + } + } + } + } + .chat-messages-outer { display: flex; flex-direction: column; @@ -535,6 +569,15 @@ .chat-message { margin-top: 5px; + + &.system-message { + align-items: center; + color: @text-muted; + display: flex; + font-size: small; + justify-content: center; + padding: 0 2ex; + } } .chat-content { diff -r ca46ca48b68a -r 91b7e4ff14ce tryton/tryton/chat.py --- a/tryton/tryton/chat.py Tue Mar 10 14:05:19 2026 +0100 +++ b/tryton/tryton/chat.py Tue Mar 31 18:59:55 2026 +0200 @@ -2,10 +2,11 @@ # this repository contains the full copyright notices and license terms. import gettext -from gi.repository import Gdk, Gio, GObject, Gtk +from gi.repository import Gdk, Gio, GLib, GObject, Gtk from tryton import common, rpc from tryton.bus import Bus +from tryton.common import sur _ = gettext.gettext @@ -48,6 +49,12 @@ self.emit('items-changed', len(self._messages) - 1, 0, 1) +def _follower_self(follower): + return ( + follower['type'] == 'user' + and follower['key'] == rpc._LOGIN) + + class Chat: def __init__(self, record): @@ -89,24 +96,21 @@ widget = Gtk.VBox() widget.set_spacing(3) - hbuttonbox = Gtk.HButtonBox() - hbuttonbox.set_layout(Gtk.ButtonBoxStyle.END) - widget.pack_start(hbuttonbox, expand=False, fill=True, padding=0) + hbox = Gtk.HBox() + widget.pack_start(hbox, expand=False, fill=True, padding=0) - subscribe_btn = Gtk.ToggleButton() + subscribe_entry = self.__build_follower_entry() + hbox.pack_start(subscribe_entry, expand=True, fill=True, padding=0) + + subscribe_btn = Gtk.MenuButton() subscribe_btn.set_image(common.IconFactory.get_image( 'tryton-notification', Gtk.IconSize.SMALL_TOOLBAR)) - tooltips.set_tip(subscribe_btn, _("Toggle notification")) + tooltips.set_tip(subscribe_btn, _("Show followers")) subscribe_btn.set_relief(Gtk.ReliefStyle.NONE) - hbuttonbox.pack_start( + hbox.pack_start( subscribe_btn, expand=False, fill=True, padding=0) - hbuttonbox.set_child_non_homogeneous(subscribe_btn, True) - followers = rpc.execute( - 'model', 'ir.chat.channel', 'get_followers', self.record, - rpc.CONTEXT) - - def set_subscribe_state(subscribed): + def set_subscribe_state(): if subscribed: img = 'tryton-notification-on' else: @@ -114,22 +118,86 @@ subscribe_btn.set_image(common.IconFactory.get_image( img, Gtk.IconSize.SMALL_TOOLBAR)) - subscribed = rpc._LOGIN in followers['users'] - set_subscribe_state(subscribed) - subscribe_btn.set_active(subscribed) + subscribed = any(_follower_self(f) for f in self._get_followers()) + set_subscribe_state() + + followers_grid = Gtk.Grid() + followers_grid.set_column_spacing(2) + followers_grid.set_row_spacing(2) + + subscribe_popover = Gtk.Popover() + subscribe_btn.set_popover(subscribe_popover) + subscribe_popover.add(followers_grid) + + def unsubscribe(follower): + def do_unsubscribe(btn): + confirmation_text = _('Are you sure to unsubscribe "%(name)s"' + ' from this channel?') % follower + if sur(confirmation_text): + if follower['type'] == 'user': + method = 'unsubscribe' + elif follower['type'] == 'email': + method = 'unsubscribe_email' + rpc.execute( + 'model', 'ir.chat.channel', method, + self.record, follower['key'], rpc.CONTEXT) + subscribe_popover.popdown() + return do_unsubscribe + + def fill_followers(btn): + nonlocal subscribed + for child in followers_grid.get_children(): + followers_grid.remove(child) + + followers = self._get_followers() + subscribed = any(_follower_self(f) for f in self._get_followers()) + if subscribed: + action_txt = _("Unsubscribe") + else: + action_txt = _("Subscribe") + + followers_grid.attach( + (b := Gtk.Button(action_txt)), 0, 0, 5, 1) + b.set_relief(Gtk.ReliefStyle.NONE) + b.connect('clicked', toggle_subscribe) + + followers = filter(lambda f: not _follower_self(f), followers) + for idx, follower in enumerate(followers, start=1): + if follower['avatar_url']: + image = Gtk.Image() + pixbuf = common.IconFactory.get_pixbuf_url( + follower['avatar_url'], size=16, size_param='s', + callback=image.set_from_pixbuf) + image.set_from_pixbuf(pixbuf) + followers_grid.attach(image, 0, idx, 1, 1) + followers_grid.attach( + Gtk.Label(follower['name'], halign=Gtk.Align.START), + 1, idx, 3, 1) + followers_grid.attach((b := Gtk.Button('×')), 4, idx, 1, 1) + b.set_relief(Gtk.ReliefStyle.NONE) + b.connect('clicked', unsubscribe(follower)) + + subscribe_popover.show_all() def toggle_subscribe(button): - if button.props.active: + nonlocal subscribed + if subscribed: + rpc.execute( + 'model', 'ir.chat.channel', 'unsubscribe', self.record, + rpc.CONTEXT) + else: rpc.execute( 'model', 'ir.chat.channel', 'subscribe', self.record, rpc.CONTEXT) + subscribed = not subscribed + set_subscribe_state() + if subscribed: + action_txt = _("Unsubscribe") else: - rpc.execute( - 'model', 'ir.chat.channel', 'unsubscribe', self.record, - rpc.CONTEXT) - set_subscribe_state(button.props.active) + action_txt = _("Subscribe") + button.set_label(action_txt) - subscribe_btn.connect('toggled', toggle_subscribe) + subscribe_btn.connect('clicked', fill_followers) def _submit(button): buffer = input_.get_buffer() @@ -177,7 +245,97 @@ return widget + def _get_followers(self): + return rpc.execute( + 'model', 'ir.chat.channel', 'get_followers', self.record, + rpc.CONTEXT) + + def _search_followers(self, text): + followers = rpc.execute( + 'model', 'ir.chat.channel', 'search_followers', + str(self.record), text, rpc.CONTEXT) + return filter(lambda f: not _follower_self(f), followers) + + def _add_follower(self, follower): + if follower['type'] == 'user': + rpc.execute( + 'model', 'ir.chat.channel', 'subscribe', + self.record, follower['key'], rpc.CONTEXT) + elif follower['type'] == 'email': + rpc.execute( + 'model', 'ir.chat.channel', 'subscribe_email', + self.record, follower['key'], rpc.CONTEXT) + + def __build_follower_entry(self): + tooltips = common.Tooltips() + subscribe_entry = Gtk.Entry() + subscribe_entry.set_placeholder_text(_("Add a follower")) + tooltips.set_tip( + subscribe_entry, _("Subscribe a follower to this channel")) + + def text_setter(layout, cell, store, iter_): + cell.set_property('text', store[iter_][0]['name']) + + def pixbuf_setter(layout, cell, store, iter_): + avatar_url = store[iter_][0]['avatar_url'] + if avatar_url: + pixbuf = common.IconFactory.get_pixbuf_url( + avatar_url, size=16, size_param='s') + else: + pixbuf = None + cell.set_property('pixbuf', pixbuf) + + model = Gtk.ListStore(GObject.TYPE_PYOBJECT) + completion = Gtk.EntryCompletion() + completion.set_match_func(lambda *a: True) + completion.set_property('popup_set_width', True) + completion.set_model(model) + completion.pack_start( + (pixbuf_rdr := Gtk.CellRendererPixbuf()), expand=False) + completion.set_cell_data_func(pixbuf_rdr, pixbuf_setter) + completion.pack_start( + text_rdr := (Gtk.CellRendererText()), expand=True) + completion.set_cell_data_func(text_rdr, text_setter) + subscribe_entry.set_completion(completion) + + def update(entry, search_text): + if search_text != entry.get_text(): + return + + if not search_text or not model: + model.clear() + model.search_text = search_text + return + + if getattr(model, 'search_text', None) == search_text: + return + + followers = self._search_followers(search_text) + + model.clear() + for follower in followers: + model.append([follower]) + + model.search_text = search_text + entry.emit('changed') + + def changed(entry): + search_text = entry.get_text() + GLib.timeout_add(300, update, entry, search_text) + + subscribe_entry.connect('changed', changed) + + def match_selected(completion, model, iter_): + self._add_follower(model[iter_][0]) + subscribe_entry.set_text('') + subscribe_entry.grab_focus() + + completion.connect('match-selected', match_selected) + + return subscribe_entry + def create_message(self, item): + tooltips = common.Tooltips() message = item.message row = Gtk.ListBoxRow() @@ -187,37 +345,50 @@ hbox = Gtk.HBox(spacing=10) row.add(hbox) - if avatar_url := message.get('avatar_url'): - image = Gtk.Image() - pixbuf = common.IconFactory.get_pixbuf_url( - avatar_url, size=32, size_param='s', - callback=image.set_from_pixbuf) - image.set_from_pixbuf(pixbuf) - image.set_valign(Gtk.Align.START) - hbox.pack_start(image, False, False, 0) + timestamp = message['timestamp'].strftime('%x %X') - bubble = Gtk.VBox() - hbox.pack_end(bubble, True, True, 0) + if message.get('user'): + if avatar_url := message.get('avatar_url'): + image = Gtk.Image() + pixbuf = common.IconFactory.get_pixbuf_url( + avatar_url, size=32, size_param='s', + callback=image.set_from_pixbuf) + image.set_from_pixbuf(pixbuf) + image.set_valign(Gtk.Align.START) + hbox.pack_start(image, False, False, 0) + + bubble = Gtk.VBox() + hbox.pack_end(bubble, True, True, 0) + + author = Gtk.Label(label=message['author']) + author.set_xalign(0) + author.get_style_context().add_class('dim') + + timestamp = Gtk.Label(label=timestamp) + timestamp.set_xalign(1) + timestamp.get_style_context().add_class('dim') - author = Gtk.Label(label=message['author']) - author.set_xalign(0) - author.get_style_context().add_class('dim') - timestamp = Gtk.Label(label=message['timestamp'].strftime('%x %X')) - timestamp.set_xalign(1) - timestamp.get_style_context().add_class('dim') + meta = Gtk.HBox() + meta.pack_start(author, True, True, 0) + meta.pack_end(timestamp, False, False, 0) + + content = Gtk.Label(label=message['content']) + content.set_xalign(0) + content.set_line_wrap(True) + content.set_selectable(True) + content.get_style_context().add_class( + f"chat-content-{message['audience']}") - meta = Gtk.HBox() - meta.pack_start(author, True, True, 0) - meta.pack_end(timestamp, False, False, 0) + bubble.pack_start(meta, False, False, 0) + bubble.pack_start(content, False, False, 0) + else: + content = Gtk.Label(label=message['content']) + content.set_xalign(.5) + content.set_line_wrap(True) + content.set_selectable(True) + content.get_style_context().add_class('dim') - content = Gtk.Label(label=message['content']) - content.set_xalign(0) - content.set_line_wrap(True) - content.set_selectable(True) - content.get_style_context().add_class( - f"chat-content-{message['audience']}") - - bubble.pack_start(meta, False, False, 0) - bubble.pack_start(content, False, False, 0) + hbox.pack_start(content, True, True, 0) + tooltips.set_tip(content, timestamp) return row diff -r ca46ca48b68a -r 91b7e4ff14ce trytond/CHANGELOG --- a/trytond/CHANGELOG Tue Mar 10 14:05:19 2026 +0100 +++ b/trytond/CHANGELOG Tue Mar 31 18:59:55 2026 +0200 @@ -1,3 +1,4 @@ +* Send emails on chat messages * Add path attribute on XML fields to specfiy a relative path value * Allow to include subdirectories in tryton.cfg * Add route for login / logout with cookie diff -r ca46ca48b68a -r 91b7e4ff14ce trytond/trytond/ir/chat.py --- a/trytond/trytond/ir/chat.py Tue Mar 10 14:05:19 2026 +0100 +++ b/trytond/trytond/ir/chat.py Tue Mar 31 18:59:55 2026 +0200 @@ -2,27 +2,37 @@ # this repository contains the full copyright notices and license terms. import json +import uuid +from email.message import EmailMessage +from email.utils import getaddresses, make_msgid +from operator import itemgetter from sql import Null from sql.conditionals import NullIf +import trytond.config as config from trytond.bus import Bus from trytond.i18n import gettext -from trytond.model import ChatMixin, Check, ModelSQL, Unique, fields +from trytond.model import ChatMixin, Check, ModelSQL, ModelView, Unique, fields from trytond.model.exceptions import ValidationError from trytond.pool import Pool +from trytond.pyson import Bool, Eval from trytond.rpc import RPC -from trytond.tools import firstline +from trytond.sendmail import send_message_transactional +from trytond.tools import cached_property, firstline from trytond.tools.email_ import ( - EmailNotValidError, normalize_email, validate_email) + EmailNotValidError, normalize_email, set_from_header, validate_email) from trytond.transaction import Transaction +from trytond.url import host + +FOLLOWER_SEARCH_LIMIT = 20 class InvalidEMailError(ValidationError): pass -class Channel(ModelSQL): +class Channel(ModelSQL, ModelView): "Chat Channel" __name__ = 'ir.chat.channel' @@ -30,6 +40,7 @@ "Resource", selection='get_models', required=True) followers = fields.One2Many( 'ir.chat.follower', 'channel', "Followers") + identifier = fields.Char("Identifier", readonly=True) @classmethod def __setup__(cls): @@ -38,6 +49,8 @@ cls._sql_constraints += [ ('resource_unique', Unique(t, t.resource), 'ir.msg_chat_channel_resource_unique'), + ('identifier_unique', Unique(t, t.identifier), + 'ir.msg_chat_channel_identifier_unique'), ] cls.__rpc__.update( subscribe=RPC(readonly=False), @@ -45,10 +58,33 @@ subscribe_email=RPC(readonly=False), unsubscribe_email=RPC(readonly=False), get_followers=RPC(), + search_followers=RPC(), post=RPC(readonly=False, result=int), get_models=RPC(), get=RPC(), ) + cls._buttons.update({ + 'reset_identifier': { + 'icon': 'tryton-refresh', + }, + }) + + @classmethod + def preprocess_values(cls, mode, values): + values = super().preprocess_values(mode, values) + if mode == 'create' and 'identifier' not in values: + values['identifier'] = uuid.uuid4().hex + return values + + @cached_property + def language(self): + pool = Pool() + Configuration = pool.get('ir.configuration') + + language = self.resource.chat_language(audience='public') + if language is None: + language = Configuration.get_language() + return language @classmethod def get_models(cls): @@ -83,9 +119,14 @@ @classmethod def subscribe(cls, resource, username=None): + cls.check_access(resource) + pool = Pool() Follower = pool.get('ir.chat.follower') User = pool.get('res.user') + Message = pool.get('ir.message') + ModelData = pool.get('ir.model.data') + if username is not None: user, = User.search([ ('login', '=', username), @@ -95,48 +136,126 @@ channel = cls._get_channel(resource) Follower.add_user(channel, user) + with Transaction().set_context( + language=channel.language, user=0, _notify=False): + msg = Message(ModelData.get_id('ir', 'msg_chat_follower_joined')) + cls.post(resource, msg.text % {'name': user.login}) + @classmethod - def unsubscribe(cls, resource): + def unsubscribe(cls, resource, username=None): pool = Pool() Follower = pool.get('ir.chat.follower') User = pool.get('res.user') - user = User(Transaction().user) + Message = pool.get('ir.message') + ModelData = pool.get('ir.model.data') + + if username: + cls.check_access(resource) + try: + user, = User.search([('login', '=', username)], limit=1) + except ValueError: + return None + else: + user = User(Transaction().user) + channel = cls._get_channel(resource) Follower.remove_user(channel, user) + with Transaction().set_context( + language=channel.language, user=0, _notify=False): + msg = Message(ModelData.get_id('ir', 'msg_chat_follower_left')) + cls.post(resource, msg.text % {'name': user.login}) + @classmethod def subscribe_email(cls, resource, email): + cls.check_access(resource) + pool = Pool() Follower = pool.get('ir.chat.follower') + Message = pool.get('ir.message') + ModelData = pool.get('ir.model.data') + channel = cls._get_channel(resource) Follower.add_email(channel, email) + with Transaction().set_context( + language=channel.language, user=0, _notify=False): + msg = Message(ModelData.get_id('ir', 'msg_chat_follower_joined')) + cls.post(resource, msg.text % {'name': email}) + @classmethod def unsubscribe_email(cls, resource, email): + cls.check_access(resource) + + pool = Pool() + Follower = pool.get('ir.chat.follower') + Message = pool.get('ir.message') + ModelData = pool.get('ir.model.data') + + channel = cls._get_channel(resource) + Follower.remove_email(channel, email) + + with Transaction().set_context( + language=channel.language, user=0, _notify=False): + msg = Message(ModelData.get_id('ir', 'msg_chat_follower_left')) + cls.post(resource, msg.text % {'name': email}) + + @classmethod + def _get_followers(cls, resource): pool = Pool() Follower = pool.get('ir.chat.follower') + followers = {} channel = cls._get_channel(resource) - Follower.remove_email(channel, email) + if channel: + chan_followers = Follower.search([ + ('channel', '=', channel), + ]) + for f in chan_followers: + if f.user: + followers['user', f.user.login] = { + 'type': 'user', + 'key': f.user.login, + 'name': f'{f.user.name or f.user.login}', + 'avatar_url': f.user.avatar_url, + } + elif f.email: + followers['email', f.email] = { + 'type': 'email', + 'key': f.email, + 'name': f.email, + 'avatar_url': None, + } + return followers @classmethod def get_followers(cls, resource): + return sorted( + cls._get_followers(resource).values(), key=itemgetter('name')) + + @classmethod + def _search_followers(cls, resource, text): pool = Pool() - Follower = pool.get('ir.chat.follower') - users, emails = [], [] - channel = cls._get_channel(resource) - if channel: - followers = Follower.search([ - ('channel', '=', channel), - ]) - for follower in followers: - if follower.user: - users.append(follower.user.login) - elif follower.email: - emails.append(follower.email) + User = pool.get('res.user') + + users = User.search([('rec_name', 'ilike', f'%{text}%')]) return { - 'users': users, - 'emails': emails, - } + ('user', u.login): { + 'type': 'user', + 'key': u.login, + 'name': f'{u.name or u.login}', + 'avatar_url': u.avatar_url, + } + for u in users} + + @classmethod + def search_followers(cls, resource, text, limit=FOLLOWER_SEARCH_LIMIT): + potential_followers = cls._search_followers(resource, text) + current_followers = cls.get_followers(resource) + + for follower in current_followers: + potential_followers.pop((follower['type'], follower['key']), None) + + return list(potential_followers.values())[:limit] @classmethod def post(cls, resource, content, audience='internal'): @@ -144,26 +263,115 @@ Message = pool.get('ir.chat.message') User = pool.get('res.user') transaction = Transaction() - user = User(transaction.user) - ctx_user = User(transaction.context.get('user', user)) + ctx_user = User(transaction.context.get('user', transaction.user)) channel = cls._get_channel(resource) message = Message( channel=channel, - user=user, + user=ctx_user, content=content, audience=audience) message.save() + cls.dispatch_message(message, lambda u: u == ctx_user) + + return message + + @classmethod + def _email_channel(cls, email): + return None + + @classmethod + def _email_content(cls, body): + return body + + @classmethod + def post_from_email(cls, email): + pool = Pool() + Message = pool.get('ir.chat.message') + + if 'From' in email: + from_ = getaddresses([email.get('From')])[0][1] + else: + from_ = None + channel = cls._email_channel(email) + if channel is None: + return + + if email.get_content_maintype() != 'multipart': + content = email.get_payload() + for part in email.walk(): + if part.get_content_type() == 'text/plain': + content = part.get_payload() + break + + message = Message( + channel=channel, + email=from_, + audience='public', + content=cls._email_content(content)) + message.save() + + cls.dispatch_message(message, lambda u: u == from_) + + return message + + @classmethod + def _email_from(cls, message): + return config.get('email', 'from') + + @classmethod + def _email_reply_to(cls, message): + return None + + @classmethod + def _email_body(cls, message): + return message.content + + @classmethod + def dispatch_message(cls, message, is_sender): + pool = Pool() + Message = pool.get('ir.message') + ModelData = pool.get('ir.model.data') + Bus.publish( - f'chat:{str(resource)}', { + f'chat:{str(message.channel.resource)}', { 'type': 'message', 'message': message.as_dict(), }) - for follower in channel.followers: - if follower.user != ctx_user: + + if not Transaction().context.get('_notify', True): + return + + to_email = [] + for follower in message.channel.followers: + if follower.user is not None and not is_sender(follower.user): follower.notify(message) + if (message.audience != 'internal' + and follower.email is not None + and not is_sender(follower.email)): + to_email.append(follower.email) - return message + if to_email: + with Transaction().set_context(language=message.channel.language): + subject_msg = Message(ModelData.get_id('ir', 'msg_subject')) + subject = subject_msg.text + + from_ = cls._email_from(message) + msg = EmailMessage() + set_from_header(msg, from_, from_) + if (reply_to := cls._email_reply_to(message)): + msg['Reply-To'] = reply_to + msg['Bcc'] = to_email + msg['Auto-Submitted'] = 'auto-generated' + msg['Message-ID'] = message.reference = make_msgid(domain=host()) + msg['Subject'] = subject % { + 'author': message.author, + 'resource': message.channel.resource.rec_name, + } + msg.set_content(cls._email_body(message)) + send_message_transactional(msg) + + message.save() @classmethod def get(cls, resource, before=None, after=None): @@ -180,13 +388,28 @@ messages = Message.search(domain) return [m.as_dict() for m in messages] + @classmethod + @ModelView.button + def reset_identifier(cls, channels): + for channel in channels: + channel.identifier = uuid.uuid4().hex + cls.save(channels) + class AuthorMixin: __slots__ = () author = fields.Function(fields.Char("Author"), 'on_change_with_author') - user = fields.Many2One('res.user', "User", ondelete='CASCADE') - email = fields.Char("Email") + user = fields.Many2One( + 'res.user', "User", ondelete='CASCADE', + states={ + 'invisible': Bool(Eval('email')), + }) + email = fields.Char( + "Email", + states={ + 'invisible': Bool(Eval('user')), + }) @classmethod def __setup__(cls): @@ -234,7 +457,7 @@ return self.user.avatar_url -class Follower(AuthorMixin, ModelSQL): +class Follower(AuthorMixin, ModelView, ModelSQL): "Chat Follower" __name__ = 'ir.chat.follower' @@ -253,6 +476,7 @@ Unique(t, t.channel, t.email), 'ir.msg_chat_follower_channel_email_unique'), ] + cls.__access__.add('channel') @classmethod def add_user(cls, channel, user): @@ -312,6 +536,7 @@ ('internal', "Internal"), ('public', "Public"), ], "Audience", required=True) + reference = fields.Char("Reference", readonly=True) @classmethod def default_audience(cls): diff -r ca46ca48b68a -r 91b7e4ff14ce trytond/trytond/ir/chat.xml --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/trytond/trytond/ir/chat.xml Tue Mar 31 18:59:55 2026 +0200 @@ -0,0 +1,74 @@ +<?xml version="1.0"?> +<!-- This file is part of Tryton. The COPYRIGHT file at the top level of +this repository contains the full copyright notices and license terms. --> +<tryton> + <data> + <record model="ir.ui.view" id="chat_channel_view_list"> + <field name="model">ir.chat.channel</field> + <field name="type">tree</field> + <field name="name">chat_channel_list</field> + </record> + + <record model="ir.ui.view" id="chat_channel_view_form"> + <field name="model">ir.chat.channel</field> + <field name="type">form</field> + <field name="name">chat_channel_form</field> + </record> + + <record model="ir.action.act_window" id="act_chat_channel"> + <field name="name">Channels</field> + <field name="res_model">ir.chat.channel</field> + </record> + <record model="ir.action.act_window.view" id="act_chat_channel_view1"> + <field name="sequence" eval="10"/> + <field name="view" ref="chat_channel_view_list"/> + <field name="act_window" ref="act_chat_channel"/> + </record> + <record model="ir.action.act_window.view" id="act_chat_channel_view2"> + <field name="sequence" eval="20"/> + <field name="view" ref="chat_channel_view_form"/> + <field name="act_window" ref="act_chat_channel"/> + </record> + <menuitem parent="ir.menu_models" action="act_chat_channel" id="menu_chat_channel_form"/> + + <record model="ir.model.button" id="chat_channel_reset_identifier_button"> + <field name="name">reset_identifier</field> + <field name="string">Reset Identifier</field> + <field name="model">ir.chat.channel</field> + <field name="confirm">This action will make previous entry point unusable. Do you want to continue?</field> + </record> + <record model="ir.model.button-res.group" id="chat_channel_reset_identifier_button_group_admin"> + <field name="button" ref="chat_channel_reset_identifier_button"/> + <field name="group" ref="res.group_admin"/> + </record> + + <record model="ir.model.access" id="access_chat_channel"> + <field name="model">ir.chat.channel</field> + <field name="perm_read" eval="True"/> + <field name="perm_write" eval="False"/> + <field name="perm_create" eval="False"/> + <field name="perm_delete" eval="False"/> + </record> + <record model="ir.model.access" id="access_chat_channel_admin"> + <field name="model">ir.chat.channel</field> + <field name="group" ref="res.group_admin"/> + <field name="perm_read" eval="True"/> + <field name="perm_write" eval="True"/> + <field name="perm_create" eval="True"/> + <field name="perm_delete" eval="True"/> + </record> + + <record model="ir.ui.view" id="chat_follower_view_list"> + <field name="model">ir.chat.follower</field> + <field name="type">tree</field> + <field name="name">chat_follower_list</field> + </record> + + <record model="ir.ui.view" id="chat_follower_view_form"> + <field name="model">ir.chat.follower</field> + <field name="type">form</field> + <field name="name">chat_follower_form</field> + </record> + + </data> +</tryton> diff -r ca46ca48b68a -r 91b7e4ff14ce trytond/trytond/ir/message.xml --- a/trytond/trytond/ir/message.xml Tue Mar 10 14:05:19 2026 +0100 +++ b/trytond/trytond/ir/message.xml Tue Mar 31 18:59:55 2026 +0200 @@ -478,6 +478,18 @@ <record model="ir.message" id="msg_chat_channel_resource_unique"> <field name="text">Only one channel per resource is allowed.</field> </record> + <record model="ir.message" id="msg_chat_channel_identifier_unique"> + <field name="text">Identifier must be unique per channel.</field> + </record> + <record model="ir.message" id="msg_chat_follower_joined"> + <field name="text">"%(name)s" joined</field> + </record> + <record model="ir.message" id="msg_chat_follower_left"> + <field name="text">"%(name)s" left</field> + </record> + <record model="ir.message" id="msg_subject"> + <field name="text">%(resource)s: %(author)s left a message</field> + </record> <record model="ir.message" id="msg_chat_user_or_email"> <field name="text">Only user or email can be filled.</field> </record> diff -r ca46ca48b68a -r 91b7e4ff14ce trytond/trytond/ir/tryton.cfg --- a/trytond/trytond/ir/tryton.cfg Tue Mar 10 14:05:19 2026 +0100 +++ b/trytond/trytond/ir/tryton.cfg Tue Mar 31 18:59:55 2026 +0200 @@ -22,6 +22,7 @@ queue.xml email.xml error.xml + chat.xml filestore.xml [register] diff -r ca46ca48b68a -r 91b7e4ff14ce trytond/trytond/ir/view/chat_channel_form.xml --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/trytond/trytond/ir/view/chat_channel_form.xml Tue Mar 31 18:59:55 2026 +0200 @@ -0,0 +1,13 @@ +<?xml version="1.0"?> +<!-- This file is part of Tryton. The COPYRIGHT file at the top level of +this repository contains the full copyright notices and license terms. --> +<form col="2"> + <label name="resource"/> + <field name="resource"/> + <label name="identifier"/> + <group col="2" colspan="1" id="identifier"> + <field name="identifier"/> + <button name="reset_identifier"/> + </group> + <field name="followers" colspan="2"/> +</form> diff -r ca46ca48b68a -r 91b7e4ff14ce trytond/trytond/ir/view/chat_channel_list.xml --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/trytond/trytond/ir/view/chat_channel_list.xml Tue Mar 31 18:59:55 2026 +0200 @@ -0,0 +1,7 @@ +<?xml version="1.0"?> +<!-- This file is part of Tryton. The COPYRIGHT file at the top level of +this repository contains the full copyright notices and license terms. --> +<tree> + <field name="resource" expand="2"/> + <field name="identifier" optional="1" expand="1"/> +</tree> diff -r ca46ca48b68a -r 91b7e4ff14ce trytond/trytond/ir/view/chat_follower_form.xml --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/trytond/trytond/ir/view/chat_follower_form.xml Tue Mar 31 18:59:55 2026 +0200 @@ -0,0 +1,10 @@ +<?xml version="1.0"?> +<!-- This file is part of Tryton. The COPYRIGHT file at the top level of +this repository contains the full copyright notices and license terms. --> +<form col="2"> + <label name="user"/> + <field name="user"/> + + <label name="email"/> + <field name="email"/> +</form> diff -r ca46ca48b68a -r 91b7e4ff14ce trytond/trytond/ir/view/chat_follower_list.xml --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/trytond/trytond/ir/view/chat_follower_list.xml Tue Mar 31 18:59:55 2026 +0200 @@ -0,0 +1,6 @@ +<?xml version="1.0"?> +<!-- This file is part of Tryton. The COPYRIGHT file at the top level of +this repository contains the full copyright notices and license terms. --> +<tree> + <field name="author"/> +</tree> diff -r ca46ca48b68a -r 91b7e4ff14ce trytond/trytond/tests/test_chat.py --- a/trytond/trytond/tests/test_chat.py Tue Mar 10 14:05:19 2026 +0100 +++ b/trytond/trytond/tests/test_chat.py Tue Mar 31 18:59:55 2026 +0200 @@ -1,6 +1,10 @@ # 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 email.message import EmailMessage +from email.utils import getaddresses +from unittest.mock import patch + from trytond.pool import Pool from trytond.transaction import Transaction @@ -45,7 +49,10 @@ room.chat_post('tests.msg_chat', foo="Bar", audience='public') self.run_tasks() - message, = Message.search([('channel', '=', channel)]) + message, = Message.search([ + ('channel', '=', channel), + ('audience', '=', 'public'), + ]) self.assertEqual(message.audience, 'public') self.assertEqual(message.content, "Chat Message: Bar") with Transaction().set_user(alice.id): @@ -61,3 +68,60 @@ 'action': None, 'unread': True, }) + + @with_transaction() + def test_chat_post_email(self): + "Test posting on chat send email" + pool = Pool() + Room = pool.get('test.chat.room') + Channel = pool.get('ir.chat.channel') + + room = Room() + room.save() + Channel.subscribe_email(room, "[email protected]") + Channel.subscribe_email(room, "[email protected]") + + with patch('trytond.ir.chat.send_message_transactional') as p: + room.chat_post('tests.msg_chat', foo="Bar", audience='public') + self.run_tasks() + + (msg,), _ = p.call_args + + self.assertEqual( + list(a for n, a in getaddresses([msg['Bcc']])), + ['[email protected]', '[email protected]']) + self.assertEqual(msg.get_content().strip(), "Chat Message: Bar") + + @with_transaction() + def test_chat_post_from_email(self): + "Test posting on chat from an email" + pool = Pool() + User = pool.get('res.user') + Room = pool.get('test.chat.room') + Channel = pool.get('ir.chat.channel') + Message = pool.get('ir.chat.message') + + alice, = User.create([{ + 'name': "Alice", + 'login': 'alice', + }]) + + room = Room() + room.save() + Channel.subscribe(room, alice.login) + channel = Channel._get_channel(room) + + msg = EmailMessage() + msg['From'] = '[email protected]' + msg['Subject'] = 'New message!' + msg.set_content("Test Content") + + with patch.object(Channel, '_email_channel') as find_channel: + find_channel.return_value = channel + Channel.post_from_email(msg) + + message, = Message.search([ + ('channel', '=', channel), + ('audience', '=', 'public'), + ]) + self.assertEqual(message.content, "Test Content\n")
