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("&times;").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")

Reply via email to