Add support for emails on the chat channels
This commit is contained in:
@@ -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``
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}'
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user