Add support for emails on the chat channels

This commit is contained in:
Nicolas Évrard
2026-03-31 18:59:55 +02:00
parent 6a38874d57
commit 44642bec77
22 changed files with 1146 additions and 139 deletions
+1
View File
@@ -1,3 +1,4 @@
* Add an action to handle chat replies
* Add support for Python 3.14
* Remove support for Python 3.9
@@ -5,6 +5,19 @@ Configuration
The *Inbound Email Module* uses values from settings in the ``[inbound_email]``
section of the :ref:`trytond:topics-configuration`.
.. _config-inbound_email.chat_reply_to:
``chat_reply_to``
=================
``chat_reply_to`` defines the email address used in the ``Reply-To`` header
when sending a message from a chat channel.
Tryton uses `sub-addressing`_ to discriminate between channels.
.. _`sub-addressing`: https://en.wikipedia.org/wiki/Email_address#Sub-addressing
Default: To the value of :ref:`trytond:config-email.from`.
.. _config-inboud_email.filestore:
``filestore``
+1
View File
@@ -255,6 +255,7 @@ class Rule(sequence_ordered(), ModelSQL, ModelView):
action = fields.Selection([
(None, ""),
('ir.chat.channel|post_inbound_email', "Post to Channel"),
], "Action")
@classmethod
+79
View File
@@ -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}'
+10
View File
@@ -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>
@@ -6,6 +6,9 @@ import uuid
from email.message import EmailMessage
from unittest.mock import patch
import trytond.config as config
from trytond.modules.inbound_email.ir import (
REPLY_LINE, _get_channel_identifier)
from trytond.pool import Pool
from trytond.protocols.wrappers import HTTPStatus
from trytond.tests.test_tryton import (
@@ -17,6 +20,16 @@ class InboundEmailTestCase(ModuleTestCase):
"Test Inbound Email module"
module = 'inbound_email'
@classmethod
def setUpClass(cls):
super().setUpClass()
email_from = config.get('email', 'from', default='')
config.set('email', 'from', 'tryton@example.org')
cls.addClassCleanup(config.set, 'email', 'from', email_from)
config.add_section('inbound_email')
config.set('inbound_email', 'chat_reply_to', 'chat@example.org')
cls.addClassCleanup(config.remove_section, 'inbound_email')
def get_message(self):
message = EmailMessage()
message['From'] = "John Doe <john.doe@example.com>"
@@ -303,6 +316,40 @@ class InboundEmailTestCase(ModuleTestCase):
func.assert_called_once_with(email, rule)
self.assertEqual(email.result, result)
def test_get_channel_identifier(self):
"Test parsing the recipients to find the email channel"
channel_id = uuid.uuid4().hex
for value, expected in [
(f'chat+{channel_id}@example.org', channel_id),
(['chat@example.org', f'chat+{channel_id}@example.org'],
channel_id),
('chat@example.org', None),
('chat+foo@example.org', None),
(f'{channel_id}@example.org', None),
(f'chat+{channel_id}@example.com', None),
]:
for recipient_hdr in ('To', 'Cc'):
with self.subTest(f"{recipient_hdr}: {value}"):
msg = EmailMessage()
msg[recipient_hdr] = value
self.assertEqual(_get_channel_identifier(msg), expected)
@with_transaction()
def test_find_email_content(self):
"Test finding the content of the reply"
pool = Pool()
Channel = pool.get('ir.chat.channel')
for (body, expected) in [
("Foo", "Foo"),
("Foo\n-----\nBar", "Foo\n-----\nBar"),
(f"Foo\n{REPLY_LINE}\nLorem Ipsum", "Foo"),
(f"Foo\n> {REPLY_LINE}\n> Lorem Ipsum", "Foo"),
]:
with self.subTest(body):
self.assertEqual(Channel._email_content(body), expected)
class InboundEmailRouteTestCase(RouteTestCase):
"Test Inbound Email route"
+2
View File
@@ -5,6 +5,7 @@ depends:
res
xml:
inbound_email.xml
message.xml
[register]
model:
@@ -12,3 +13,4 @@ model:
inbound_email.Email
inbound_email.Rule
inbound_email.RuleHeader
ir.Channel