diff --git a/addons/auth_signup/data/auth_signup_data.xml b/addons/auth_signup/data/auth_signup_data.xml index c8ded45916e..4d16b5e4951 100644 --- a/addons/auth_signup/data/auth_signup_data.xml +++ b/addons/auth_signup/data/auth_signup_data.xml @@ -9,8 +9,6 @@ - - _usertemplate diff --git a/addons/crm/tests/common.py b/addons/crm/tests/common.py index 7a04e27ddb9..49b664e8b60 100644 --- a/addons/crm/tests/common.py +++ b/addons/crm/tests/common.py @@ -18,7 +18,6 @@ class TestCrm(TestMail): cls.user_salesman_all = Users.create({ 'name': 'Riton La Chignole', 'login': 'riton', - 'alias_name': 'riton', 'email': 'riton.salesman_all@example.com', 'groups_id': [(6, 0, [user_group_employee.id, user_group_salesman_all.id])] }) diff --git a/addons/event/tests/common.py b/addons/event/tests/common.py index 6e2f0406357..dbd142a08be 100644 --- a/addons/event/tests/common.py +++ b/addons/event/tests/common.py @@ -27,7 +27,6 @@ class TestEventCommon(common.TransactionCase): self.user_eventuser = self.Users.with_context({'no_reset_password': True}).create({ 'name': 'Armande EventUser', 'login': 'Armande', - 'alias_name': 'armande', 'email': 'armande.eventuser@example.com', 'tz': 'Europe/Brussels', 'groups_id': [(6, 0, [self.group_employee_id, self.group_event_user_id])] @@ -35,7 +34,6 @@ class TestEventCommon(common.TransactionCase): self.user_eventmanager = self.Users.with_context({'no_reset_password': True}).create({ 'name': 'Bastien EventManager', 'login': 'bastien', - 'alias_name': 'bastien', 'email': 'bastien.eventmanager@example.com', 'tz': 'Europe/Brussels', 'groups_id': [(6, 0, [ diff --git a/addons/hr/models/mail_thread.py b/addons/hr/models/mail_thread.py index d27cbbd2afc..06eec55240d 100644 --- a/addons/hr/models/mail_thread.py +++ b/addons/hr/models/mail_thread.py @@ -3,45 +3,34 @@ import logging -from odoo import api, models -from odoo.addons.mail.models.mail_thread import decode_header -from odoo.tools import email_split +from odoo import api, models, _ +from odoo.tools import decode_message_header, email_split _logger = logging.getLogger(__name__) + class MailThread(models.AbstractModel): _inherit = "mail.thread" @api.model - def message_route_verify(self, message, message_dict, route, update_author=True, assert_model=True, create_fallback=True, allow_private=False): - res = super(MailThread, self).message_route_verify(message, message_dict, route, update_author, assert_model, create_fallback, allow_private) + def message_route_verify(self, message, message_dict, route, + update_author=True, assert_model=True, + create_fallback=True, allow_private=False, + drop_alias=False): + res = super(MailThread, self).message_route_verify( + message, message_dict, route, + update_author=update_author, + assert_model=assert_model, + create_fallback=create_fallback, + allow_private=allow_private, + drop_alias=drop_alias) if res: alias = route[4] - email_from = decode_header(message, 'From') + email_from = decode_message_header(message, 'From') message_id = message.get('Message-Id') - # Identically equal to the definition in mail module because sub methods are local - # variables and cannot be called with super - def _create_bounce_email(body_html): - bounce_to = decode_header(message, 'Return-Path') or email_from - bounce_mail_values = { - 'body_html': body_html, - 'subject': 'Re: %s' % message.get('subject'), - 'email_to': bounce_to, - 'auto_delete': True, - } - bounce_from = self.env['ir.mail_server']._get_default_bounce_address() - if bounce_from: - bounce_mail_values['email_from'] = 'MAILER-DAEMON <%s>' % bounce_from - self.env['mail.mail'].create(bounce_mail_values).send() - - def _warn(message): - _logger.info('Routing mail with Message-Id %s: route %s: %s', - message_id, route, message) - # Alias: check alias_contact settings for employees - if alias and alias.alias_contact == 'employees': email_address = email_split(email_from)[0] employee = self.env['hr.employee'].search([('work_email', 'ilike', email_address)], limit=1) @@ -49,7 +38,7 @@ class MailThread(models.AbstractModel): employee = self.env['hr.employee'].search([('user_id.email', 'ilike', email_address)], limit=1) if not employee: mail_template = self.env.ref('hr.mail_template_data_unknown_employee_email_address') - _warn('alias %s does not accept unknown employees, skipping' % alias.alias_name) - _create_bounce_email(mail_template.body_html) + self._routing_warn(_('alias %s does not accept unknown employees') % alias.alias_name, _('skipping'), message_id, route, False) + self._routing_create_bounce_email(email_from, mail_template.body_html, message) return False return res diff --git a/addons/hr_holidays/tests/common.py b/addons/hr_holidays/tests/common.py index 2d9f518b340..8134675266a 100644 --- a/addons/hr_holidays/tests/common.py +++ b/addons/hr_holidays/tests/common.py @@ -18,21 +18,18 @@ class TestHrHolidaysBase(common.TransactionCase): self.user_hruser_id = Users.create({ 'name': 'Armande HrUser', 'login': 'Armande', - 'alias_name': 'armande', 'email': 'armande.hruser@example.com', 'groups_id': [(6, 0, [group_employee_id, self.ref('base.group_hr_user')])] }).id self.user_hrmanager_id = Users.create({ 'name': 'Bastien HrManager', 'login': 'bastien', - 'alias_name': 'bastien', 'email': 'bastien.hrmanager@example.com', 'groups_id': [(6, 0, [group_employee_id, self.ref('base.group_hr_manager')])] }).id self.user_employee_id = Users.create({ 'name': 'David Employee', 'login': 'david', - 'alias_name': 'david', 'email': 'david.employee@example.com', 'groups_id': [(6, 0, [group_employee_id])] }).id diff --git a/addons/mail/__init__.py b/addons/mail/__init__.py index 175383c6644..72546813981 100644 --- a/addons/mail/__init__.py +++ b/addons/mail/__init__.py @@ -3,3 +3,4 @@ import models import wizard import controllers +import tests.test_mail_model diff --git a/addons/mail/data/mail_data.xml b/addons/mail/data/mail_data.xml index 874f83d9af1..3645ba2e337 100644 --- a/addons/mail/data/mail_data.xml +++ b/addons/mail/data/mail_data.xml @@ -39,6 +39,12 @@ catchall + + + mail.bounce.alias + bounce + + Discussions @@ -70,7 +76,7 @@ % endif -% if not ctx['is_discussion'] or not len(ctx['actions']) == 0 or ctx.get('button_access') or ctx.get('is_accessible'): +% if not ctx['is_discussion'] or not len(ctx['actions']) == 0 or ctx.get('button_access'):
diff --git a/addons/mail/models/__init__.py b/addons/mail/models/__init__.py index 1ba9746eb4e..2426a6dc9e6 100644 --- a/addons/mail/models/__init__.py +++ b/addons/mail/models/__init__.py @@ -4,6 +4,7 @@ import mail_message_subtype import mail_tracking_value import mail_alias import mail_followers +import mail_notification import mail_message import mail_mail import mail_thread diff --git a/addons/mail/models/mail_channel.py b/addons/mail/models/mail_channel.py index 6b3ad2b5ebf..a8bed5820f4 100644 --- a/addons/mail/models/mail_channel.py +++ b/addons/mail/models/mail_channel.py @@ -37,6 +37,8 @@ class Channel(models.Model): _inherit = ['mail.thread'] _inherits = {'mail.alias': 'alias_id'} + MAX_BOUNCE_LIMIT = 10 + def _get_default_image(self): image_path = modules.get_module_resource('mail', 'static/src/img', 'groupdefault.png') return tools.image_resize_image_big(open(image_path, 'rb').read().encode('base64')) @@ -156,14 +158,17 @@ class Channel(models.Model): @api.multi def action_unfollow(self): - partner_id = self.env.user.partner_id.id + return self._action_unfollow(self.env.user.partner_id) + + @api.multi + def _action_unfollow(self, partner): channel_info = self.channel_info('unsubscribe')[0] # must be computed before leaving the channel (access rights) - result = self.write({'channel_partner_ids': [(3, partner_id)]}) - self.env['bus.bus'].sendone((self._cr.dbname, 'res.partner', partner_id), channel_info) + result = self.write({'channel_partner_ids': [(3, partner.id)]}) + self.env['bus.bus'].sendone((self._cr.dbname, 'res.partner', partner.id), channel_info) if not self.email_send: notification = _('
left #%s
') % (self.id, self.name,) # post 'channel left' message as root since the partner just unsubscribed from the channel - self.sudo().message_post(body=notification, message_type="notification", subtype="mail.mt_comment", author_id=partner_id) + self.sudo().message_post(body=notification, message_type="notification", subtype="mail.mt_comment", author_id=partner.id) return result @api.multi @@ -201,6 +206,13 @@ class Channel(models.Model): res['headers'] = repr(headers) return res + @api.multi + def message_receive_bounce(self, email, partner, mail_id=None): + """ Override bounce management to unsubscribe bouncing addresses """ + if partner.message_bounce >= self.MAX_BOUNCE_LIMIT: + self._action_unfollow(partner) + return super(Channel, self).message_receive_bounce(email, partner, mail_id=mail_id) + @api.multi def message_get_recipient_values(self, notif_message=None, recipient_ids=None): # real mailing list: multiple recipients (hidden by X-Forge-To) diff --git a/addons/mail/models/mail_mail.py b/addons/mail/models/mail_mail.py index aecb32cb5ac..7297c763953 100644 --- a/addons/mail/models/mail_mail.py +++ b/addons/mail/models/mail_mail.py @@ -129,6 +129,19 @@ class MailMail(models.Model): :param browse_record mail: the mail that was just sent :return: True """ + notif_emails = self.filtered(lambda email: email.notification) + if notif_emails: + notifications = self.env['mail.notification'].search([ + ('mail_message_id', 'in', notif_emails.mapped('mail_message_id').ids), + ('is_email', '=', True)]) + if mail_sent: + notifications.write({ + 'email_status': 'sent', + }) + else: + notifications.write({ + 'email_status': 'exception', + }) if mail_sent: self.sudo().filtered(lambda self: self.auto_delete).unlink() return True @@ -222,9 +235,9 @@ class MailMail(models.Model): catchall_domain = self.env['ir.config_parameter'].get_param("mail.catchall.domain") if bounce_alias and catchall_domain: if mail.model and mail.res_id: - headers['Return-Path'] = '%s-%d-%s-%d@%s' % (bounce_alias, mail.id, mail.model, mail.res_id, catchall_domain) + headers['Return-Path'] = '%s+%d-%s-%d@%s' % (bounce_alias, mail.id, mail.model, mail.res_id, catchall_domain) else: - headers['Return-Path'] = '%s-%d@%s' % (bounce_alias, mail.id, catchall_domain) + headers['Return-Path'] = '%s+%d@%s' % (bounce_alias, mail.id, catchall_domain) if mail.headers: try: headers.update(safe_eval(mail.headers)) diff --git a/addons/mail/models/mail_message.py b/addons/mail/models/mail_message.py index 5aa52d755c8..7dbe4ad70d9 100644 --- a/addons/mail/models/mail_message.py +++ b/addons/mail/models/mail_message.py @@ -3,7 +3,6 @@ import logging -from email.header import decode_header from email.utils import formataddr from odoo import _, api, fields, models, SUPERUSER_ID, tools @@ -14,16 +13,6 @@ from odoo.osv import expression _logger = logging.getLogger(__name__) -def decode(text): - """Returns unicode() string conversion of the the given encoded smtp header text""" - # TDE proposal: move to tools ? - if text: - text = decode_header(text.replace('\r', '')) - # The joining space will not be needed as of Python 3.3 - # See https://hg.python.org/cpython/rev/8c03fe231877 - return ' '.join([tools.ustr(x[0], x[1]) for x in text]) - - class Message(models.Model): """ Messages model: system notification (replacing res.log notifications), comments (OpenChatter discussion) and incoming emails. """ @@ -37,11 +26,9 @@ class Message(models.Model): @api.model def _get_default_from(self): - if self.env.user.alias_name and self.env.user.alias_domain: - return formataddr((self.env.user.name, '%s@%s' % (self.env.user.alias_name, self.env.user.alias_domain))) - elif self.env.user.email: + if self.env.user.email: return formataddr((self.env.user.name, self.env.user.email)) - raise UserError(_("Unable to send email, please configure the sender's email address or alias.")) + raise UserError(_("Unable to send email, please configure the sender's email address.")) @api.model def _get_default_author(self): @@ -93,6 +80,10 @@ class Message(models.Model): help='Need Action') channel_ids = fields.Many2many( 'mail.channel', 'mail_message_mail_channel_rel', string='Channels') + # notifications + notification_ids = fields.One2many( + 'mail.notification', 'mail_message_id', 'Notifications', + auto_join=True, copy=False) # user interface starred_partner_ids = fields.Many2many( 'res.partner', 'mail_message_res_partner_starred_rel', string='Favorited By') @@ -116,20 +107,18 @@ class Message(models.Model): @api.multi def _get_needaction(self): """ Need action on a mail.message = notified on my channel """ - my_messages = self.sudo().filtered(lambda msg: self.env.user.partner_id in msg.needaction_partner_ids) + my_messages = self.env['mail.notification'].sudo().search([ + ('mail_message_id', 'in', self.ids), + ('res_partner_id', '=', self.env.user.partner_id.id), + ('is_read', '=', False)]).mapped('mail_message_id') for message in self: message.needaction = message in my_messages - @api.multi - def _is_accessible(self): - self.ensure_one() - return False - @api.model def _search_needaction(self, operator, operand): if operator == '=' and operand: - return [('needaction_partner_ids', 'in', self.env.user.partner_id.id)] - return [('needaction_partner_ids', 'not in', self.env.user.partner_id.id)] + return ['&', ('notification_ids.res_partner_id', '=', self.env.user.partner_id.id), ('notification_ids.is_read', '=', False)] + return ['&', ('notification_ids.res_partner_id', '=', self.env.user.partner_id.id), ('notification_ids.is_read', '=', True)] @api.depends('starred_partner_ids') def _get_starred(self): @@ -158,7 +147,8 @@ class Message(models.Model): """ Remove all needactions of the current partner. If channel_ids is given, restrict to messages written in one of those channels. """ partner_id = self.env.user.partner_id.id - if domain is None: + delete_mode = not self.env.user.share # delete employee notifs, keep customer ones + if domain is None and delete_mode: query = "DELETE FROM mail_message_res_partner_needaction_rel WHERE res_partner_id IN %s" args = [(partner_id,)] if channel_ids: @@ -181,7 +171,14 @@ class Message(models.Model): if channel_ids: msg_domain += [('channel_ids', 'in', channel_ids)] unread_messages = self.search(expression.AND([msg_domain, domain])) - unread_messages.sudo().write({'needaction_partner_ids': [(3, partner_id)]}) + notifications = self.env['mail.notification'].sudo().search([ + ('mail_message_id', 'in', unread_messages.ids), + ('res_partner_id', '=', self.env.user.partner_id.id), + ('is_read', '=', False)]) + if delete_mode: + notifications.unlink() + else: + notifications.write({'is_read': True}) ids = unread_messages.mapped('id') notification = {'type': 'mark_as_read', 'message_ids': ids, 'channel_ids': channel_ids} @@ -204,14 +201,20 @@ class Message(models.Model): def set_message_done(self): """ Remove the needaction from messages for the current partner. """ partner_id = self.env.user.partner_id - messages = self.filtered(lambda msg: partner_id in msg.needaction_partner_ids) - if not len(messages): + delete_mode = not self.env.user.share # delete employee notifs, keep customer ones + + notifications = self.env['mail.notification'].sudo().search([ + ('mail_message_id', 'in', self.ids), + ('res_partner_id', '=', partner_id.id), + ('is_read', '=', False)]) + + if not notifications: return - messages.sudo().write({'needaction_partner_ids': [(3, partner_id.id)]}) # notifies changes in messages through the bus. To minimize the number of # notifications, we need to group the messages depending on their channel_ids groups = [] + messages = notifications.mapped('mail_message_id') current_channel_ids = messages[0].channel_ids current_group = [] for record in messages: @@ -226,6 +229,11 @@ class Message(models.Model): current_group = [record.id] current_channel_ids = record.channel_ids + if delete_mode: + notifications.unlink() + else: + notifications.write({'is_read': True}) + for (msg_ids, channel_ids) in groups: notification = {'type': 'mark_as_read', 'message_ids': msg_ids, 'channel_ids': [c.id for c in channel_ids]} self.env['bus.bus'].sendone((self._cr.dbname, 'res.partner', partner_id.id), notification) @@ -281,6 +289,8 @@ class Message(models.Model): partners |= message.partner_ids elif not message.subtype_id and message.partner_ids: # take specified people of message without a subtype (log) partners |= message.partner_ids + if message.needaction_partner_ids: # notified + partners |= message.needaction_partner_ids if message.attachment_ids: attachments |= message.attachment_ids if message.tracking_value_ids: @@ -322,6 +332,11 @@ class Message(models.Model): else: partner_ids = [partner_tree[partner.id] for partner in message.partner_ids if partner.id in partner_tree] + + customer_email_data = [] + for notification in message.notification_ids.filtered(lambda notif: notif.res_partner_id.partner_share): + customer_email_data.append((partner_tree[notification.res_partner_id.id][0], partner_tree[notification.res_partner_id.id][1], notification.email_status)) + attachment_ids = [] for attachment in message.attachment_ids: if attachment.id in attachments_tree: @@ -334,6 +349,10 @@ class Message(models.Model): message_dict.update({ 'author_id': author, 'partner_ids': partner_ids, + 'customer_email_status': (all(d[2] == 'sent' for d in customer_email_data) and 'sent') or + (any(d[2] == 'exception' for d in customer_email_data) and 'exception') or + (any(d[2] == 'bounce' for d in customer_email_data) and 'bounce') or 'ready', + 'customer_email_data': customer_email_data, 'attachment_ids': attachment_ids, 'tracking_value_ids': tracking_value_ids, }) @@ -725,7 +744,7 @@ class Message(models.Model): #------------------------------------------------------ @api.multi - def _notify(self, force_send=False, user_signature=True): + def _notify(self, force_send=False, send_after_commit=True, user_signature=True): """ Add the related record followers to the destination partner_ids if is not a private message. Call mail_notification.notify to manage the email sending """ @@ -758,11 +777,17 @@ class Message(models.Model): if not self._context.get('mail_notify_author', False) and self_sudo.author_id: partners = partners - self_sudo.author_id - # update message - self.write({'channel_ids': [(6, 0, channels.ids)], 'needaction_partner_ids': [(6, 0, partners.ids)]}) + # update message, with maybe custom values + message_values = { + 'channel_ids': [(6, 0, channels.ids)], + 'needaction_partner_ids': [(6, 0, partners.ids)] + } + if self.model and self.res_id and hasattr(self.env[self.model], 'message_get_message_notify_values'): + message_values.update(self.env[self.model].browse(self.res_id).message_get_message_notify_values(self, message_values)) + self.write(message_values) # notify partners and channels - partners._notify(self, force_send=force_send, user_signature=user_signature) + partners._notify(self, force_send=force_send, send_after_commit=send_after_commit, user_signature=user_signature) channels._notify(self) # Discard cache, because child / parent allow reading and therefore diff --git a/addons/mail/models/mail_notification.py b/addons/mail/models/mail_notification.py new file mode 100644 index 00000000000..e14754b7e8f --- /dev/null +++ b/addons/mail/models/mail_notification.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- + +from odoo import api, fields, models + + +class Notification(models.Model): + _name = 'mail.notification' + _table = 'mail_message_res_partner_needaction_rel' + _rec_name = 'res_partner_id' + _log_access = False + _description = 'Message Notifications' + + mail_message_id = fields.Many2one( + 'mail.message', 'Message', index=True, ondelete='cascade', required=True) + res_partner_id = fields.Many2one( + 'res.partner', 'Needaction Recipient', index=True, ondelete='cascade', required=True) + is_read = fields.Boolean('Is Read', index=True) + is_email = fields.Boolean('Sent by Email', index=True) + email_status = fields.Selection([ + ('ready', 'Ready to Send'), + ('sent', 'Sent'), + ('bounce', 'Bounced'), + ('exception', 'Exception')], 'Email Status', + default='ready', index=True) + + @api.model_cr + def init(self): + self._cr.execute('SELECT indexname FROM pg_indexes WHERE indexname = %s', ('mail_notification_res_partner_id_is_read_email_status_mail_message_id',)) + if not self._cr.fetchone(): + self._cr.execute('CREATE INDEX mail_notification_res_partner_id_is_read_email_status_mail_message_id ON mail_message_res_partner_needaction_rel (res_partner_id, is_read, email_status, mail_message_id)') diff --git a/addons/mail/models/mail_thread.py b/addons/mail/models/mail_thread.py index 3980ebd4b3f..cd1b7be7aa7 100644 --- a/addons/mail/models/mail_thread.py +++ b/addons/mail/models/mail_thread.py @@ -21,20 +21,12 @@ from lxml import etree from werkzeug import url_encode from odoo import _, api, exceptions, fields, models, tools -from odoo.addons.mail.models.mail_message import decode from odoo.tools.safe_eval import safe_eval _logger = logging.getLogger(__name__) -mail_header_msgid_re = re.compile('<[^<>]+>') - - -def decode_header(message, header, separator=' '): - return separator.join(map(decode, filter(None, message.get_all(header, [])))) - - class MailThread(models.AbstractModel): ''' mail_thread model is meant to be inherited by any model that needs to act as a discussion topic on which messages can be attached. Public @@ -199,7 +191,7 @@ class MailThread(models.AbstractModel): # search for unread messages, directly in SQL to improve performances self._cr.execute(""" SELECT msg.res_id FROM mail_message msg RIGHT JOIN mail_message_res_partner_needaction_rel rel - ON rel.mail_message_id = msg.id AND rel.res_partner_id = %s + ON rel.mail_message_id = msg.id AND rel.res_partner_id = %s AND (rel.is_read = false OR rel.is_read IS NULL) WHERE msg.model = %s AND msg.res_id in %s""", (self.env.user.partner_id.id, self._name, tuple(self.ids),)) for result in self._cr.fetchall(): @@ -795,11 +787,35 @@ class MailThread(models.AbstractModel): """ Find partners related to some header fields of the message. :param string message: an email.message instance """ - s = ', '.join([decode(message.get(h)) for h in header_fields if message.get(h)]) + s = ', '.join([tools.decode_smtp_header(message.get(h)) for h in header_fields if message.get(h)]) return filter(lambda x: x, self._find_partner_from_emails(tools.email_split(s))) + def _routing_warn(self, error_message, warn_suffix, message_id, route, raise_exception): + """ Tools method used in message_route_verify: whether to log a warning or raise an error """ + full_message = _('Routing mail with Message-Id %s: route %s: %s') % (message_id, route, error_message) + if raise_exception: + raise ValueError(full_message) + else: + _logger.info(full_message + warn_suffix and '; %s' % warn_suffix or '') + + def _routing_create_bounce_email(self, email_from, body_html, message): + bounce_to = tools.decode_message_header(message, 'Return-Path') or email_from + bounce_mail_values = { + 'body_html': body_html, + 'subject': 'Re: %s' % message.get('subject'), + 'email_to': bounce_to, + 'auto_delete': True, + } + bounce_from = self.env['ir.mail_server']._get_default_bounce_address() + if bounce_from: + bounce_mail_values['email_from'] = 'MAILER-DAEMON <%s>' % bounce_from + self.env['mail.mail'].create(bounce_mail_values).send() + @api.model - def message_route_verify(self, message, message_dict, route, update_author=True, assert_model=True, create_fallback=True, allow_private=False): + def message_route_verify(self, message, message_dict, route, + update_author=True, assert_model=True, + create_fallback=True, allow_private=False, + drop_alias=False): """ Verify route validity. Check and rules: 1 - if thread_id -> check that document effectively exists; otherwise fallback on a message_new by resetting thread_id @@ -813,99 +829,82 @@ class MailThread(models.AbstractModel): check on alias parent document that the author is in the followers 'partners': check that author_id id set + + :param message: an email.message instance + :param message_dict: dictionary of values that will be given to + mail_message.create() + :param route: route to check which is a tuple (model, thread_id, + custom_values, uid, alias) + :param update_author: update message_dict['author_id']. TDE TODO: move me + :param assert_model: if an error occurs, tell whether to raise an error + or just log a warning and try other processing or + invalidate route + :param create_fallback: if the route aims at updating a record and that + record does not exists or does not support update + either fallback on creating a new record in the + same model or raise / warn + :param allow_private: allow void model / thread_id routes, aka private + discussions """ assert isinstance(route, (list, tuple)), 'A route should be a list or a tuple' assert len(route) == 5, 'A route should contain 5 elements: model, thread_id, custom_values, uid, alias record' message_id = message.get('Message-Id') - email_from = decode_header(message, 'From') + email_from = tools.decode_message_header(message, 'From') author_id = message_dict.get('author_id') model, thread_id, alias = route[0], route[1], route[4] record_set = None - def _create_bounce_email(): - bounce_to = decode_header(message, 'Return-Path') or email_from - bounce_mail_values = { - 'body_html': '

Hello,

' - '

The following email sent to %s cannot be accepted because this is ' - 'a private email address. Only allowed people can contact us at this address.

' - '
%s
' % (message.get('to'), message_dict.get('body')), - 'subject': 'Re: %s' % message.get('subject'), - 'email_to': bounce_to, - 'auto_delete': True, - } - bounce_from = self.env['ir.mail_server']._get_default_bounce_address() - if bounce_from: - bounce_mail_values['email_from'] = 'MAILER-DAEMON <%s>' % bounce_from - self.env['mail.mail'].create(bounce_mail_values).send() - - def _warn(message): - _logger.info('Routing mail with Message-Id %s: route %s: %s', - message_id, route, message) + _generic_bounce_body_html = """
+

Hello,

+

The following email sent to %s cannot be accepted because this is a private email address. + Only allowed people can contact us at this address.

+
%s
""" % (message.get('to'), message_dict.get('body')), # Wrong model if model and model not in self.env: - if assert_model: - assert model in self.env, 'Routing: unknown target model %s' % model - _warn('unknown target model %s' % model) + self._routing_warn(_('unknown target model %s') % model, '', message_id, route, assert_model) return () - # Private message: should not contain any thread_id - if not model and thread_id: - if assert_model: - if thread_id: - raise ValueError('Routing: posting a message without model should be with a null res_id (private message), received %s.' % thread_id) - _warn('posting a message without model should be with a null res_id (private message), received %s resetting thread_id' % thread_id) - thread_id = 0 - # Private message: should have a parent_id (only answers) - if not model and not message_dict.get('parent_id'): - if assert_model: - if not message_dict.get('parent_id'): - raise ValueError('Routing: posting a message without model should be with a parent_id (private mesage).') - _warn('posting a message without model should be with a parent_id (private mesage), skipping') - return False + # Private message + if not model: + # should not contain any thread_id + if thread_id: + self._routing_warn(_('posting a message without model should be with a null res_id (private message), received %s') % thread_id, _('resetting thread_id'), message_id, route, assert_model) + thread_id = 0 + # should have a parent_id (only answers) + if not message_dict.get('parent_id'): + self._routing_warn(_('posting a message without model should be with a parent_id (private message)'), _('skipping'), message_id, route, assert_model) + return False if model and thread_id: record_set = self.env[model].browse(thread_id) elif model: record_set = self.env[model] - # Existing Document: check if exists; if not, fallback on create if allowed - if thread_id and not record_set.exists(): - if create_fallback: - _warn('reply to missing document (%s,%s), fall back on new document creation' % (model, thread_id)) + # Existing Document: check if exists and model accepts the mailgateway; if not, fallback on create if allowed + if thread_id: + if not record_set.exists() and create_fallback: + self._routing_warn(_('reply to missing document (%s,%s), fall back on new document creation') % (model, thread_id), '', message_id, route, False) + thread_id = None + elif not hasattr(record_set, 'message_update') and create_fallback: + self._routing_warn(_('model %s does not accept document update, fall back on document creation') % model, '', message_id, route, False) thread_id = None - elif assert_model: - # TDE FIXME: change assert to some real error - assert record_set.exists(), 'Routing: reply to missing document (%s,%s)' % (model, thread_id) - else: - _warn('reply to missing document (%s,%s), skipping' % (model, thread_id)) - return False - # Existing Document: check model accepts the mailgateway - if thread_id and model and not hasattr(record_set, 'message_update'): - if create_fallback: - _warn('model %s does not accept document update, fall back on document creation' % model) - thread_id = None - elif assert_model: - assert hasattr(record_set, 'message_update'), 'Routing: model %s does not accept document update, crashing' % model - else: - _warn('model %s does not accept document update, skipping' % model) + if not record_set.exists(): + self._routing_warn(_('reply to missing document (%s,%s)') % (model, thread_id), _('skipping'), message_id, route, assert_model) + return False + elif not hasattr(record_set, 'message_update'): + self._routing_warn(_('model %s does not accept document update') % model, _('skipping'), message_id, route, assert_model) return False # New Document: check model accepts the mailgateway if not thread_id and model and not hasattr(record_set, 'message_new'): - if assert_model: - if not hasattr(record_set, 'message_new'): - raise ValueError( - 'Model %s does not accept document creation, crashing' % model - ) - _warn('model %s does not accept document creation, skipping' % model) + self._routing_warn(_('model %s does not accept document creation') % model, _('skipping'), message_id, route, assert_model) return False - # Update message author if asked - # We do it now because we need it for aliases (contact settings) + # Update message author if asked. We do it now because we need it for aliases (contact settings) if not author_id and update_author: author_ids = self.env['mail.thread']._find_partner_from_emails([email_from], res_model=model, res_id=thread_id) if author_ids: @@ -923,143 +922,185 @@ class MailThread(models.AbstractModel): set(partner.id for channel in obj.message_channel_ids for partner in channel.channel_partner_ids) ) if not author_id or author_id not in accepted_partner_ids: - _warn('alias %s restricted to internal followers, skipping' % alias.alias_name) - _create_bounce_email() + self._routing_warn(_('alias %s restricted to internal followers') % alias.alias_name, _('skipping'), message_id, route, False) + self._routing_create_bounce_email(email_from, _generic_bounce_body_html, message) return False elif alias and alias.alias_contact == 'partners' and not author_id: - _warn('alias %s does not accept unknown author, skipping' % alias.alias_name) - _create_bounce_email() + self._routing_warn(_('alias %s does not accept unknown author') % alias.alias_name, _('skipping'), message_id, route, False) + self._routing_create_bounce_email(email_from, _generic_bounce_body_html, message) return False if not model and not thread_id and not alias and not allow_private: - return () + return False - return (model, thread_id, route[2], route[3], None if self._context.get('drop_alias', False) else route[4]) + return (model, thread_id, route[2], route[3], None if drop_alias else route[4]) @api.model def message_route(self, message, message_dict, model=None, thread_id=None, custom_values=None): - """Attempt to figure out the correct target model, thread_id, + """ Attempt to figure out the correct target model, thread_id, custom_values and user_id to use for an incoming message. Multiple values may be returned, if a message had multiple recipients matching existing mail.aliases, for example. The following heuristics are used, in this order: - 1. If the message replies to an existing thread_id, and - properly contains the thread model in the 'In-Reply-To' - header, use this model/thread_id pair, and ignore - custom_value (not needed as no creation will take place) - 2. Look for a mail.alias entry matching the message - recipient, and use the corresponding model, thread_id, - custom_values and user_id. - 3. Fallback to the ``model``, ``thread_id`` and ``custom_values`` - provided. - 4. If all the above fails, raise an exception. - :param string message: an email.message instance - :param dict message_dict: dictionary holding message variables - :param string model: the fallback model to use if the message - does not match any of the currently configured mail aliases - (may be None if a matching alias is supposed to be present) - :type dict custom_values: optional dictionary of default field values - to pass to ``message_new`` if a new record needs to be created. - Ignored if the thread record already exists, and also if a - matching mail.alias was found (aliases define their own defaults) - :param int thread_id: optional ID of the record/thread from ``model`` - to which this mail should be attached. Only used if the message - does not reply to an existing thread and does not match any mail alias. - :return: list of [model, thread_id, custom_values, user_id, alias] + * if the message replies to an existing thread by having a Message-Id + that matches an existing mail_message.message_id, we take the original + message model/thread_id pair and ignore custom_value as no creation will + take place + * if the message replies to an existing thread by having In-Reply-To or + References matching odoo model/thread_id Message-Id and if this thread + has messages without message_id, take this model/thread_id pair and + ignore custom_value as no creation will take place (6.1 compatibility) + * look for a mail.alias entry matching the message recipients and use the + corresponding model, thread_id, custom_values and user_id. This could + lead to a thread update or creation depending on the alias + * fallback on provided ``model``, ``thread_id`` and ``custom_values`` + * raise an exception as no route has been found + + :param string message: an email.message instance + :param dict message_dict: dictionary holding parsed message variables + :param string model: the fallback model to use if the message does not match + any of the currently configured mail aliases (may be None if a matching + alias is supposed to be present) + :type dict custom_values: optional dictionary of default field values + to pass to ``message_new`` if a new record needs to be created. + Ignored if the thread record already exists, and also if a matching + mail.alias was found (aliases define their own defaults) + :param int thread_id: optional ID of the record/thread from ``model`` to + which this mail should be attached. Only used if the message does not + reply to an existing thread and does not match any mail alias. + :return: list of routes [(model, thread_id, custom_values, user_id, alias)] :raises: ValueError, TypeError """ if not isinstance(message, Message): raise TypeError('message must be an email.message.Message at this point') MailMessage = self.env['mail.message'] - Alias = self.env['mail.alias'] + Alias, dest_aliases = self.env['mail.alias'], self.env['mail.alias'] + bounce_alias = self.env['ir.config_parameter'].get_param("mail.bounce.alias") fallback_model = model - # Get email.message.Message variables for future processing + # get email.message.Message variables for future processing + local_hostname = socket.gethostname() message_id = message.get('Message-Id') - email_from = decode_header(message, 'From') - email_to = decode_header(message, 'To') - references = decode_header(message, 'References') - in_reply_to = decode_header(message, 'In-Reply-To').strip() + + # compute references to find if message is a reply to an existing thread + references = tools.decode_message_header(message, 'References') + in_reply_to = tools.decode_message_header(message, 'In-Reply-To').strip() thread_references = references or in_reply_to + reply_match, reply_model, reply_thread_id, reply_hostname, reply_private = tools.email_references(thread_references) + + # author and recipients + email_from = tools.decode_message_header(message, 'From') + email_from_localpart = (tools.email_split(email_from) or [''])[0].split('@', 1)[0].lower() + email_to = tools.decode_message_header(message, 'To') + email_to_localpart = (tools.email_split(email_to) or [''])[0].split('@', 1)[0].lower() + + # Delivered-To is a safe bet in most modern MTAs, but we have to fallback on To + Cc values + # for all the odd MTAs out there, as there is no standard header for the envelope's `rcpt_to` value. + rcpt_tos = ','.join([ + tools.decode_message_header(message, 'Delivered-To'), + tools.decode_message_header(message, 'To'), + tools.decode_message_header(message, 'Cc'), + tools.decode_message_header(message, 'Resent-To'), + tools.decode_message_header(message, 'Resent-Cc')]) + rcpt_tos_localparts = [e.split('@')[0] for e in tools.email_split(rcpt_tos)] + + # 0. Verify whether this is a bounced email and use it to collect bounce data and update notifications for customers + if bounce_alias and bounce_alias in email_to_localpart: + # Bounce regex: typical form of bounce is bounce_alias+128-crm.lead-34@domain + # group(1) = the mail ID; group(2) = the model (if any); group(3) = the record ID + bounce_re = re.compile("%s\+(\d+)-?([\w.]+)?-?(\d+)?" % re.escape(bounce_alias), re.UNICODE) + bounce_match = bounce_re.search(email_to) + + if bounce_match: + bounced_mail_id, bounced_model, bounced_thread_id = bounce_match.group(1), bounce_match.group(2), int(bounce_match.group(3)) + + email_part = next((part for part in message.walk() if part.get_content_type() == 'message/rfc822'), None) + dsn_part = next((part for part in message.walk() if part.get_content_type() == 'message/delivery-status'), None) + + partner, partner_address = self.env['res.partner'], False + if dsn_part and len(dsn_part.get_payload()) > 1: + dsn = dsn_part.get_payload()[1] + final_recipient_data = tools.decode_message_header(dsn, 'Final-Recipient') + partner_address = final_recipient_data.split(';', 1)[1].strip() + if partner_address: + partner = partner.sudo().search([('email', 'like', partner_address)]) + partner.message_receive_bounce(partner_address, partner, mail_id=bounced_mail_id) + + mail_message = self.env['mail.message'] + if email_part: + email = email_part.get_payload()[0] + bounced_message_id = tools.mail_header_msgid_re.findall(tools.decode_message_header(email, 'Message-Id')) + mail_message = MailMessage.sudo().search([('message_id', 'in', bounced_message_id)]) + + if partner and mail_message: + notifications = self.env['mail.notification'].sudo().search([ + ('mail_message_id', '=', mail_message.id), + ('res_partner_id', '=', partner.id)]) + notifications.write({ + 'email_status': 'bounce' + }) + + if bounced_model in self.env and hasattr(self.env[bounced_model], 'message_receive_bounce') and bounced_thread_id: + self.env[bounced_model].browse(bounced_thread_id).message_receive_bounce(partner_address, partner, mail_id=bounced_mail_id) + + _logger.info('Routing mail from %s to %s with Message-Id %s: bounced mail from mail %s, model: %s, thread_id: %s: dest %s (partner %s)', + email_from, email_to, message_id, bounced_mail_id, bounced_model, bounced_thread_id, partner_address, partner.id) + return [] # 0. First check if this is a bounce message or not. # See http://datatracker.ietf.org/doc/rfc3462/?include_text=1 # As all MTA does not respect this RFC (googlemail is one of them), # we also need to verify if the message come from "mailer-daemon" - localpart = (tools.email_split(email_from) or [''])[0].split('@', 1)[0].lower() - if message.get_content_type() == 'multipart/report' or localpart == 'mailer-daemon': - _logger.info("Not routing bounce email from %s to %s with Message-Id %s", - email_from, email_to, message_id) + if message.get_content_type() == 'multipart/report' or email_from_localpart == 'mailer-daemon': + _logger.info('Routing mail with Message-Id %s: not routing bounce email from %s to %s', + message_id, email_from, email_to) return [] - # 1. message is a reply to an existing message (exact match of message_id) - ref_match = thread_references and tools.reference_re.search(thread_references) - msg_references = mail_header_msgid_re.findall(thread_references) - mail_messages = MailMessage.sudo().search([('message_id', 'in', msg_references)], limit=1) - if ref_match and mail_messages: - model, thread_id = mail_messages.model, mail_messages.res_id - alias = Alias.search([('alias_name', '=', (tools.email_split(email_to) or [''])[0].split('@', 1)[0].lower())]) - alias = alias[0] if alias else None - route = self.with_context(drop_alias=True).message_route_verify( - message, message_dict, - (model, thread_id, custom_values, self._uid, alias), - update_author=True, assert_model=False, create_fallback=True) - if route: - _logger.info( - 'Routing mail from %s to %s with Message-Id %s: direct reply to msg: model: %s, thread_id: %s, custom_values: %s, uid: %s', - email_from, email_to, message_id, model, thread_id, custom_values, self._uid) - return [route] - elif route is False: - return [] + # 1. message is a reply to an existing message (exact match of message_id or compat-mode) + if reply_model and reply_thread_id: + other_alias = Alias.search([ + '&', + ('alias_name', '!=', False), + ('alias_name', '=', email_to_localpart) + ]) + if other_alias and other_alias.alias_model_id.model != reply_model: + reply_match = False - # 2. message is a reply to an existign thread (6.1 compatibility) - if ref_match: - reply_thread_id = int(ref_match.group(1)) - reply_model = ref_match.group(2) or fallback_model - reply_hostname = ref_match.group(3) - local_hostname = socket.gethostname() + if reply_match: + compat_mode = False + msg_references = tools.mail_header_msgid_re.findall(thread_references) + mail_messages = MailMessage.sudo().search([('message_id', 'in', msg_references)], limit=1) + + # message is a reply to an existing thread (6.1 compatibility) # do not match forwarded emails from another OpenERP system (thread_id collision!) - if local_hostname == reply_hostname: - thread_id, model = reply_thread_id, reply_model - if thread_id and model in self.env: - record = self.env[model].browse(thread_id) - compat_mail_msg_ids = MailMessage.search([ - ('message_id', '=', False), - ('model', '=', model), - ('res_id', '=', thread_id)]) - if compat_mail_msg_ids and record.exists() and hasattr(record, 'message_update'): - route = self.message_route_verify( - message, message_dict, - (model, thread_id, custom_values, self._uid, None), - update_author=True, assert_model=True, create_fallback=True) - if route: - # parent is invalid for a compat-reply - message_dict.pop('parent_id', None) - _logger.info( - 'Routing mail from %s to %s with Message-Id %s: direct thread reply (compat-mode) to model: %s, thread_id: %s, custom_values: %s, uid: %s', - email_from, email_to, message_id, model, thread_id, custom_values, self._uid) - return [route] - elif route is False: - return [] + if not mail_messages and local_hostname == reply_hostname and reply_thread_id and reply_model in self.env: + mail_messages = MailMessage.search([ + ('message_id', '=', False), + ('model', '=', reply_model), + ('res_id', '=', reply_thread_id)]) + compat_mode = True - # 3. Reply to a private message - if in_reply_to: - mail_message_ids = MailMessage.search([ - ('message_id', '=', in_reply_to), - '!', ('message_id', 'ilike', 'reply_to') - ], limit=1) - if mail_message_ids: + if mail_messages: + model, thread_id = mail_messages.model, mail_messages.res_id + if not reply_private: # TDE note: not sure why private mode as no alias search, copying existing behavior + dest_aliases = Alias.search([('alias_name', 'in', rcpt_tos_localparts)], limit=1) + + # TDE Note: compat mode = without context key, why ? because route = self.message_route_verify( message, message_dict, - (mail_message_ids.model, mail_message_ids.res_id, custom_values, self._uid, None), - update_author=True, assert_model=True, create_fallback=True, allow_private=True) + (model, thread_id, custom_values, self._uid, dest_aliases), + update_author=True, assert_model=reply_private, create_fallback=True, + allow_private=reply_private, drop_alias=True) if route: + # TDE Note: compat mode: parent is invalid for a compat-reply + # message_dict.pop('parent_id', None) + # TDE note: add compat mode for compat mode in debug _logger.info( - 'Routing mail from %s to %s with Message-Id %s: direct reply to a private message: %s, custom_values: %s, uid: %s', - email_from, email_to, message_id, mail_message_ids.id, custom_values, self._uid) + 'Routing mail from %s to %s with Message-Id %s%s: direct reply to msg: model: %s, thread_id: %s, custom_values: %s, uid: %s', + email_from, email_to, message_id, compat_mode and ' (compat mode)' or '', model, thread_id, custom_values, self._uid) return [route] elif route is False: return [] @@ -1068,20 +1109,11 @@ class MailThread(models.AbstractModel): message_dict.pop('parent_id', None) # 4. Look for a matching mail.alias entry - # Delivered-To is a safe bet in most modern MTAs, but we have to fallback on To + Cc values - # for all the odd MTAs out there, as there is no standard header for the envelope's `rcpt_to` value. - rcpt_tos = \ - ','.join([decode_header(message, 'Delivered-To'), - decode_header(message, 'To'), - decode_header(message, 'Cc'), - decode_header(message, 'Resent-To'), - decode_header(message, 'Resent-Cc')]) - local_parts = [e.split('@')[0] for e in tools.email_split(rcpt_tos)] - if local_parts: - aliases = Alias.search([('alias_name', 'in', local_parts)]) - if aliases: + if rcpt_tos_localparts: + dest_aliases = Alias.search([('alias_name', 'in', rcpt_tos_localparts)]) + if dest_aliases: routes = [] - for alias in aliases: + for alias in dest_aliases: user_id = alias.alias_user_id.id if not user_id: # TDE note: this could cause crashes, because no clue that the user @@ -1103,24 +1135,16 @@ class MailThread(models.AbstractModel): return routes # 5. Fallback to the provided parameters, if they work - if not thread_id: - # Legacy: fallback to matching [ID] in the Subject - match = tools.res_re.search(decode_header(message, 'Subject')) - thread_id = match and match.group(1) - # Convert into int (bug spotted in 7.0 because of str) - try: - thread_id = int(thread_id) - except: - thread_id = False - route = self.message_route_verify( - message, message_dict, - (fallback_model, thread_id, custom_values, self._uid, None), - update_author=True, assert_model=True) - if route: - _logger.info( - 'Routing mail from %s to %s with Message-Id %s: fallback to model:%s, thread_id:%s, custom_values:%s, uid:%s', - email_from, email_to, message_id, fallback_model, thread_id, custom_values, self._uid) - return [route] + if fallback_model: + route = self.message_route_verify( + message, message_dict, + (fallback_model, thread_id, custom_values, self._uid, None), + update_author=True, assert_model=True) + if route: + _logger.info( + 'Routing mail from %s to %s with Message-Id %s: fallback to model:%s, thread_id:%s, custom_values:%s, uid:%s', + email_from, email_to, message_id, fallback_model, thread_id, custom_values, self._uid) + return [route] # ValueError if no routes found and if no bounce occured raise ValueError( @@ -1285,6 +1309,22 @@ class MailThread(models.AbstractModel): self.write(update_vals) return True + @api.multi + def message_receive_bounce(self, email, partner, mail_id=None): + """Called by ``message_process`` when a bounce email (such as Undelivered + Mail Returned to Sender) is received for an existing thread. The default + behavior is to check is an integer ``message_bounce`` column exists. + If it is the case, its content is incremented. + + :param mail_id: ID of the sent email that bounced. It may not exist anymore + but it could be usefull if the information was kept. This is + used notably in mass mailing. + :param RecordSet partner: partner matching the bounced email address, if any + :param string email: email that caused the bounce """ + if 'message_bounce' in self._fields: + for record in self: + record.message_bounce = record.message_bounce + 1 + def _message_extract_payload_postprocess(self, message, body, attachments): """ Perform some cleaning / postprocess in the body and attachments extracted from the email. Note that this processing is specific to the @@ -1355,7 +1395,7 @@ class MailThread(models.AbstractModel): # RFC2231 filename = email.utils.collapse_rfc2231_value(filename).strip() else: - filename = decode(filename) + filename = tools.decode_smtp_header(filename) encoding = part.get_content_charset() # None if attachment # 0) Inline Attachments -> attachments, with a third part in the tuple to match cid / attachment @@ -1432,19 +1472,19 @@ class MailThread(models.AbstractModel): msg_dict['message_id'] = message_id if message.get('Subject'): - msg_dict['subject'] = decode(message.get('Subject')) + msg_dict['subject'] = tools.decode_smtp_header(message.get('Subject')) # Envelope fields not stored in mail.message but made available for message_new() - msg_dict['from'] = decode(message.get('from')) - msg_dict['to'] = decode(message.get('to')) - msg_dict['cc'] = decode(message.get('cc')) - msg_dict['email_from'] = decode(message.get('from')) + msg_dict['from'] = tools.decode_smtp_header(message.get('from')) + msg_dict['to'] = tools.decode_smtp_header(message.get('to')) + msg_dict['cc'] = tools.decode_smtp_header(message.get('cc')) + msg_dict['email_from'] = tools.decode_smtp_header(message.get('from')) partner_ids = self._message_find_partners(message, ['To', 'Cc']) msg_dict['partner_ids'] = [(4, partner_id) for partner_id in partner_ids] if message.get('Date'): try: - date_hdr = decode(message.get('Date')) + date_hdr = tools.decode_smtp_header(message.get('Date')) parsed_date = dateutil.parser.parse(date_hdr, fuzzy=True) if parsed_date.utcoffset() is None: # naive datetime, so we arbitrarily decide to make it @@ -1461,13 +1501,13 @@ class MailThread(models.AbstractModel): msg_dict['date'] = stored_date.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT) if message.get('In-Reply-To'): - parent_ids = self.env['mail.message'].search([('message_id', '=', decode(message['In-Reply-To'].strip()))], limit=1) + parent_ids = self.env['mail.message'].search([('message_id', '=', tools.decode_smtp_header(message['In-Reply-To'].strip()))], limit=1) if parent_ids: msg_dict['parent_id'] = parent_ids.id msg_dict['internal'] = parent_ids.subtype_id and parent_ids.subtype_id.internal or False if message.get('References') and 'parent_id' not in msg_dict: - msg_list = mail_header_msgid_re.findall(decode(message['References'])) + msg_list = tools.mail_header_msgid_re.findall(tools.decode_smtp_header(message['References'])) parent_ids = self.env['mail.message'].search([('message_id', 'in', [x.strip() for x in msg_list])], limit=1) if parent_ids: msg_dict['parent_id'] = parent_ids.id @@ -1518,7 +1558,7 @@ class MailThread(models.AbstractModel): return result @api.multi - def _find_partner_from_emails(self, emails, res_model=None, res_id=None, check_followers=True, force_create=False): + def _find_partner_from_emails(self, emails, res_model=None, res_id=None, check_followers=True, force_create=False, exclude_aliases=True): """ Utility method to find partners from email addresses. The rules are : 1 - check in document (model | self, id) followers 2 - try to find a matching partner that is also an user @@ -1530,6 +1570,9 @@ class MailThread(models.AbstractModel): is used. :param boolean check_followers: check in document followers :param boolean force_create: create a new partner if not found + :param boolean exclude_aliases: do not try to find a partner that could match an alias. Normally aliases + should not be used as partner emails but it could be the case due to some + strange manipulation """ if res_model is None: res_model = self._name @@ -1551,12 +1594,14 @@ class MailThread(models.AbstractModel): if not email_address: partner_ids.append(partner_id) continue + if exclude_aliases and self.env['mail.alias'].search([('alias_name', 'ilike', email_address)], limit=1): + partner_ids.append(partner_id) + continue + email_address = email_address[0] # first try: check in document's followers - for follower in followers: - if follower.email == email_address: - partner_id = follower.id - break + partner_id = next((partner.id for partner in followers if partner.email == email_address), False) + # second try: check in partners that are also users # Escape special SQL characters in email_address to avoid invalid matches email_address = (email_address.replace('\\', '\\\\').replace('%', '\\%').replace('_', '\\_')) @@ -1567,8 +1612,7 @@ class MailThread(models.AbstractModel): if not partners: # if no match with addr-spec, attempt substring match within name-addr pair partners = Users.search([('email', 'ilike', email_brackets)], limit=1).mapped('partner_id') - if partners: - partner_id = partners[0].id + partner_id = partners.id # third try: check in partners if not partner_id: # exact, case-insensitive match @@ -1576,8 +1620,7 @@ class MailThread(models.AbstractModel): if not partners: # if no match with addr-spec, attempt substring match within name-addr pair partners = Partner.search([('email', 'ilike', email_brackets)], limit=1) - if partners: - partner_id = partners[0].id + partner_id = partners.id if not partner_id and force_create: partner_id = self.env['res.partner'].name_create(contact)[0] partner_ids.append(partner_id) @@ -1973,10 +2016,17 @@ class MailThread(models.AbstractModel): """ if not partner_ids: return - for record_id in self.ids: + for record in self: + record.message_post_with_view( + 'mail.message_user_assigned', + composition_mode='mass_mail', + partner_ids=[(4, pid) for pid in partner_ids], + auto_delete=True, + auto_delete_message=True, + subtype_id=self.env.ref('mail.mt_note').id) messages = self.env['mail.message'].sudo().search([ ('model', '=', self._name), - ('res_id', '=', record_id), + ('res_id', '=', record.id), ('subtype_id', '!=', False), ('subtype_id.internal', '=', False)], limit=1) if messages: diff --git a/addons/mail/models/res_partner.py b/addons/mail/models/res_partner.py index 2e47dacc1dc..78404ed8ec5 100644 --- a/addons/mail/models/res_partner.py +++ b/addons/mail/models/res_partner.py @@ -4,7 +4,7 @@ import logging import threading -from odoo import _, api, fields, models +from odoo import _, api, fields, models, registry, SUPERUSER_ID from odoo.osv import expression _logger = logging.getLogger(__name__) @@ -86,7 +86,6 @@ class Partner(models.Model): 'tracking': tracking, 'is_discussion': is_discussion, 'subtype': message.subtype_id, - 'is_accessible': message._is_accessible() } @api.model @@ -133,8 +132,19 @@ class Partner(models.Model): emails |= self.env['mail.mail'].create(create_values) return emails, recipients_nbr + @api.model + def _notify_udpate_notifications(self, emails): + for email in emails: + notifications = self.env['mail.notification'].sudo().search([ + ('mail_message_id', '=', email.mail_message_id.id), + ('res_partner_id', 'in', email.recipient_ids.ids)]) + notifications.write({ + 'is_email': True, + 'email_status': 'ready', + }) + @api.multi - def _notify(self, message, force_send=False, user_signature=True): + def _notify(self, message, force_send=False, send_after_commit=True, user_signature=True): # TDE TODO: model-dependant ? (like customer -> always email ?) message_sudo = message.sudo() email_channels = message.channel_ids.filtered(lambda channel: channel.email_send) @@ -143,14 +153,19 @@ class Partner(models.Model): ('id', 'in', self.ids), ('channel_ids', 'in', email_channels.ids), ('email', '!=', message_sudo.author_id and message_sudo.author_id.email or message.email_from), - ('notify_email', '!=', 'none')])._notify_by_email(message, force_send=force_send, user_signature=user_signature) + ('notify_email', '!=', 'none')])._notify_by_email(message, force_send=force_send, send_after_commit=send_after_commit, user_signature=user_signature) self._notify_by_chat(message) return True @api.multi - def _notify_by_email(self, message, force_send=False, user_signature=True): + def _notify_by_email(self, message, force_send=False, send_after_commit=True, user_signature=True): """ Method to send email linked to notified messages. The recipients are - the recordset on which this method is called. """ + the recordset on which this method is called. + + :param boolean force_send: send notification emails now instead of letting the scheduler handle the email queue + :param boolean send_after_commit: send notification emails after the transaction end instead of durign the + transaction; this option is used only if force_send is True + :param user_signature: add current user signature to notification emails """ if not self.ids: return True @@ -166,7 +181,6 @@ class Partner(models.Model): base_template_ctx['signature'] = False base_mail_values = self._notify_prepare_email_values(message) - # classify recipients: actions / no action if message.model and message.res_id and hasattr(self.env[message.model], '_message_notification_recipients'): recipients = self.env[message.model].browse(message.res_id)._message_notification_recipients(message, self) @@ -185,6 +199,9 @@ class Partner(models.Model): fol_values = template_fol.generate_email(message.id, fields=['body_html', 'subject']) # send email new_emails, new_recipients_nbr = self._notify_send(fol_values['body'], fol_values['subject'], recipient_template_values['followers'], **base_mail_values) + # update notifications + self._notify_udpate_notifications(new_emails) + emails |= new_emails recipients_nbr += new_recipients_nbr if recipient_template_values['not_followers']: @@ -196,6 +213,9 @@ class Partner(models.Model): not_values = template_not.generate_email(message.id, fields=['body_html', 'subject']) # send email new_emails, new_recipients_nbr = self._notify_send(not_values['body'], not_values['subject'], recipient_template_values['not_followers'], **base_mail_values) + # update notifications + self._notify_udpate_notifications(new_emails) + emails |= new_emails recipients_nbr += new_recipients_nbr @@ -204,9 +224,24 @@ class Partner(models.Model): # 2. do not send emails immediately if the registry is not loaded, # to prevent sending email during a simple update of the database # using the command-line. + test_mode = getattr(threading.currentThread(), 'testing', False) if force_send and recipients_nbr < recipients_max and \ - (not self.pool._init or getattr(threading.currentThread(), 'testing', False)): - emails.send() + (not self.pool._init or test_mode): + email_ids = emails.ids + dbname = self.env.cr.dbname + + def send_notifications(): + db_registry = registry(dbname) + with db_registry.cursor() as cr: + env = api.Environment(cr, SUPERUSER_ID, {}) + env['mail.mail'].browse(email_ids).send() + + # unless asked specifically, send emails after the transaction to + # avoid side effects due to emails being sent while the transaction fails + if not test_mode and send_after_commit: + self._cr.after('commit', send_notifications) + else: + emails.send() return True @@ -226,7 +261,7 @@ class Partner(models.Model): self.env.cr.execute(""" SELECT count(*) as needaction_count FROM mail_message_res_partner_needaction_rel R - WHERE R.res_partner_id = %s """, (self.env.user.partner_id.id,)) + WHERE R.res_partner_id = %s AND (R.is_read = false OR R.is_read IS NULL)""", (self.env.user.partner_id.id,)) return self.env.cr.dictfetchall()[0].get('needaction_count') _logger.error('Call to needaction_count without partner_id') return 0 diff --git a/addons/mail/models/res_users.py b/addons/mail/models/res_users.py index 9e4294df3cd..1f95c02f078 100644 --- a/addons/mail/models/res_users.py +++ b/addons/mail/models/res_users.py @@ -14,11 +14,15 @@ class Users(models.Model): group, and the user. This is done by overriding the write method. """ _name = 'res.users' - _inherit = ['mail.alias.mixin', 'res.users'] + _inherit = ['res.users'] - alias_id = fields.Many2one('mail.alias', 'Alias', ondelete="restrict", required=True, + alias_id = fields.Many2one('mail.alias', 'Alias', ondelete="set null", required=False, help="Email address internally associated with this user. Incoming "\ "emails will appear in the user's notifications.", copy=False, auto_join=True) + alias_contact = fields.Selection([ + ('everyone', 'Everyone'), + ('partners', 'Authenticated Partners'), + ('followers', 'Followers only')], string='Alias Contact Security', related='alias_id.alias_contact') def __init__(self, pool, cr): """ Override of __init__ to add access rights on notification_email_send @@ -31,17 +35,9 @@ class Users(models.Model): type(self).SELF_WRITEABLE_FIELDS.extend(['notify_email']) # duplicate list to avoid modifying the original reference type(self).SELF_READABLE_FIELDS = list(self.SELF_READABLE_FIELDS) - type(self).SELF_READABLE_FIELDS.extend(['notify_email', 'alias_domain', 'alias_name']) + type(self).SELF_READABLE_FIELDS.extend(['notify_email']) return init_res - def get_alias_model_name(self, vals): - return self._name - - def get_alias_values(self): - values = super(Users, self).get_alias_values() - values['alias_force_thread_id'] = self.id - return values - @api.model def create(self, values): if not values.get('login', False): @@ -65,13 +61,6 @@ class Users(models.Model): self.env['mail.channel'].search([('group_ids', 'in', user_group_ids)])._subscribe_users() return write_res - @api.multi - def copy_data(self, default=None): - data = super(Users, self).copy_data(default)[0] - if data and data.get('alias_name'): - data['alias_name'] = data['login'] - return [data] - def _create_welcome_message(self): self.ensure_one() if not self.has_group('base.group_user'): diff --git a/addons/mail/security/ir.model.access.csv b/addons/mail/security/ir.model.access.csv index 384e99d91fa..e869287d2f4 100644 --- a/addons/mail/security/ir.model.access.csv +++ b/addons/mail/security/ir.model.access.csv @@ -10,6 +10,9 @@ access_mail_followers_all,mail.followers.all,model_mail_followers,,1,0,0,0 access_mail_followers_portal,mail.followers.portal,model_mail_followers,base.group_portal,1,1,1,0 access_mail_followers_user,mail.followers.user,model_mail_followers,base.group_user,1,1,1,0 access_mail_followers_system,mail.followers.system,model_mail_followers,base.group_system,1,1,1,1 +access_mail_notification_portal,mail.notification.portal,model_mail_notification,base.group_portal,1,0,0,0 +access_mail_notification_user,mail.notification.user,model_mail_notification,base.group_user,1,1,1,0 +access_mail_notification_system, mail.notification.system,model_mail_notification,base.group_system,1,1,1,1 access_mail_channel_all,mail.group.all,model_mail_channel,,1,0,0,0 access_mail_channel_user,mail.group.user,model_mail_channel,base.group_user,1,1,1,1 access_mail_channel_partner_public,mail.channel.partner.public,model_mail_channel_partner,base.group_public,1,0,0,0 @@ -30,3 +33,4 @@ access_mail_template,mail.template,model_mail_template,base.group_user,1,1,1,0 access_mail_template_system,mail.template_system,model_mail_template,base.group_system,1,1,1,1 access_mail_shortcode,mail.shortcode,model_mail_shortcode,base.group_user,1,1,1,1 access_mail_shortcode_portal,mail.shortcode.portal,model_mail_shortcode,base.group_portal,1,0,0,0 +access_mail_test_all,mail.test.all,model_mail_test,base.group_user,1,1,1,1 \ No newline at end of file diff --git a/addons/mail/security/mail_security.xml b/addons/mail/security/mail_security.xml index deab4c51438..65495db6925 100644 --- a/addons/mail/security/mail_security.xml +++ b/addons/mail/security/mail_security.xml @@ -23,6 +23,16 @@ + + mail.notifications: group_user: write its own entries + + + [('res_partner_id', '=', user.partner_id.id)] + + + + + mail.message.subtype: portal/public: read public subtypes diff --git a/addons/mail/static/src/js/chat_manager.js b/addons/mail/static/src/js/chat_manager.js index d37e9dcf282..2cf6090871f 100644 --- a/addons/mail/static/src/js/chat_manager.js +++ b/addons/mail/static/src/js/chat_manager.js @@ -132,6 +132,8 @@ function make_message (data) { attachment_ids: data.attachment_ids || [], subject: data.subject, email_from: data.email_from, + customer_email_status: data.customer_email_status, + customer_email_data: data.customer_email_data, record_name: data.record_name, tracking_value_ids: data.tracking_value_ids, channel_ids: data.channel_ids, diff --git a/addons/mail/static/src/js/chat_window.js b/addons/mail/static/src/js/chat_window.js index da8900b8230..7bdece26fb3 100644 --- a/addons/mail/static/src/js/chat_window.js +++ b/addons/mail/static/src/js/chat_window.js @@ -32,6 +32,7 @@ return Widget.extend({ autofocus: true, display_stars: true, display_reply_icon: false, + display_email_icon: false, placeholder: _t("Say something"), input_less: false, }); diff --git a/addons/mail/static/src/js/client_action.js b/addons/mail/static/src/js/client_action.js index f94997c946c..7de0b1ac83b 100644 --- a/addons/mail/static/src/js/client_action.js +++ b/addons/mail/static/src/js/client_action.js @@ -491,6 +491,7 @@ var ChatAction = Widget.extend(ControlPanelMixin, { display_empty_channel: !messages.length && !this.domain.length, display_no_match: !messages.length && this.domain.length, display_subject: this.channel.mass_mailing || this.channel.id === "channel_inbox", + display_email_icon: false, display_reply_icon: true, }; }, diff --git a/addons/mail/static/src/js/thread.js b/addons/mail/static/src/js/thread.js index ea593d21dfa..2176eff55fa 100644 --- a/addons/mail/static/src/js/thread.js +++ b/addons/mail/static/src/js/thread.js @@ -72,6 +72,7 @@ var Thread = Widget.extend({ display_document_link: true, display_avatar: true, squash_close_messages: true, + display_email_icon: true, display_reply_icon: false, }); this.expanded_msg_ids = []; diff --git a/addons/mail/static/src/less/thread.less b/addons/mail/static/src/less/thread.less index 5da7d2b4963..7a9efa45731 100644 --- a/addons/mail/static/src/less/thread.less +++ b/addons/mail/static/src/less/thread.less @@ -70,7 +70,8 @@ } } - i.o_thread_message_star, i.o_thread_message_needaction, i.o_thread_message_reply { + + i.o_thread_message_star, i.o_thread_message_needaction, i.o_thread_message_reply, i.o_thread_message_email { cursor: pointer; opacity: 0; &.fa-star { @@ -84,7 +85,7 @@ opacity: @mail-thread-side-date-opacity; } - i.o_thread_message_star, i.o_thread_message_needaction, i.o_thread_message_reply { + i.o_thread_message_star, i.o_thread_message_needaction, i.o_thread_message_reply, i.o_thread_message_email { opacity: @mail-thread-icon-opacity; &:hover { opacity: 1; @@ -146,9 +147,48 @@ } } - .o_thread_message_star, .o_thread_message_needaction, .o_thread_message_reply { + .o_thread_message_star, .o_thread_message_needaction, .o_thread_message_reply, .o_thread_message_email { padding: 4px; } + + i.o_thread_message_email { + &.o_thread_message_email_ready { + color: grey; + } + &.o_thread_message_email_exception { + color: red; + opacity: 1; + } + &.o_thread_message_email_bounce { + color: red; + opacity: 1; + } + } + + .o_thread_tooltip_container { + display: inline; + position: relative; + + .o_thread_tooltip_content { + top: 10px; + background: @gray-lighter; + padding: 4px; + border: solid @gray-lighter 1px; + border-radius: 5px; + color: gray; + width: 300px; + opacity: 0; + position: absolute; + transition: opacity 0.5s; + i.fa-exclamation { + color: red; + } + } + + .o_thread_tooltip:hover + .o_thread_tooltip_content { + opacity: 1; + } + } } } .o_thread_title { diff --git a/addons/mail/static/src/xml/thread.xml b/addons/mail/static/src/xml/thread.xml index 9896cb0ce6e..47e84e38f60 100644 --- a/addons/mail/static/src/xml/thread.xml +++ b/addons/mail/static/src/xml/thread.xml @@ -94,6 +94,21 @@ (from #) + + + + + + + + + + + +
+
+
+
' % (self.user_employee.name, self.user_employee.alias_name, _domain)]), - 'message_post: notification email wrong email_from: should use alias of sender') + set(['%s <%s>' % (self.user_employee.name, self.user_employee.email)]), + 'message_post: notification email wrong email_from: should use sender email') self.assertEqual(set(m['email_to'][0] for m in self._mails), set(['%s <%s>' % (self.partner_1.name, self.partner_1.email), '%s <%s>' % (self.env.user.name, self.env.user.email)])) diff --git a/addons/mail/tests/test_mail_gateway.py b/addons/mail/tests/test_mail_gateway.py index 147d0ed6c81..3ce8c531f7c 100644 --- a/addons/mail/tests/test_mail_gateway.py +++ b/addons/mail/tests/test_mail_gateway.py @@ -298,6 +298,14 @@ class TestMailgateway(TestMail): 'alias_model_id': self.mail_channel_model.id, 'alias_contact': 'everyone'}) + # test@.. will cause the creation of new mail.test + self.mail_test_model = self.env['ir.model'].search([('model', '=', 'mail.test')], limit=1) + self.alias_2 = self.env['mail.alias'].create({ + 'alias_name': 'test', + 'alias_user_id': False, + 'alias_model_id': self.mail_test_model.id, + 'alias_contact': 'everyone'}) + # Set a first message on public group to test update and hierarchy self.fake_email = self.env['mail.message'].create({ 'model': 'mail.channel', @@ -512,7 +520,7 @@ class TestMailgateway(TestMail): @mute_logger('openerp.addons.mail.models.mail_thread', 'openerp.models') def test_message_process_in_reply_to(self): - """ Incoming email using in-rely-to should go into the right destination event with a wrong destination """ + """ Incoming email using in-rely-to should go into the right destination even with a wrong destination """ self.format_and_process( MAIL_TEMPLATE, email_from='valid.other@gmail.com', msg_id='<1198923581.41972151344608186800.JavaMail.diff1@agrolait.com>', @@ -524,7 +532,7 @@ class TestMailgateway(TestMail): @mute_logger('openerp.addons.mail.models.mail_thread', 'openerp.models') def test_message_process_references(self): - """ Incoming email using in-rely-to should go into the right destination event with a wrong destination """ + """ Incoming email using references should go into the right destination even with a wrong destination """ self.format_and_process( MAIL_TEMPLATE, to='erroneous@example.com', extra='References: <2233@a.com>\r\n\t<3edss_dsa@b.com> %s' % self.fake_email.message_id, @@ -533,6 +541,34 @@ class TestMailgateway(TestMail): self.assertEqual(len(self.group_public.message_ids), 2, 'message_process: group should contain one new message') self.assertEqual(len(self.fake_email.child_ids), 1, 'message_process: new message should be children of the existing one') + @mute_logger('openerp.addons.mail.models.mail_thread', 'openerp.models') + def test_message_process_references_forward(self): + """ Incoming email using references but with alias forward should not go into references destination """ + res_test = self.format_and_process( + MAIL_TEMPLATE, to='test@example.com', + subject='My Dear Forward', + extra='References: <2233@a.com>\r\n\t<3edss_dsa@b.com> %s' % self.fake_email.message_id, + msg_id='<1198923581.41972151344608186800.JavaMail.4@agrolait.com>', + target_model='mail.test') + + self.assertEqual(len(self.group_public.message_ids), 1, 'message_process: group should not contain new message') + self.assertEqual(len(self.fake_email.child_ids), 0, 'message_process: original email should not contain childs') + self.assertEqual(res_test.name, 'My Dear Forward') + self.assertEqual(len(res_test.message_ids), 1) + + @mute_logger('openerp.addons.mail.models.mail_thread', 'openerp.models') + def test_message_process_references_forward_cc(self): + """ Incoming email using references but with alias forward should not go into references destination """ + self.format_and_process( + MAIL_TEMPLATE, to='erroneous@example.com', cc='test@example.com', + subject='My Dear Forward', + extra='References: <2233@a.com>\r\n\t<3edss_dsa@b.com> %s' % self.fake_email.message_id, + msg_id='<1198923581.41972151344608186800.JavaMail.4@agrolait.com>', + target_model='mail.test') + + self.assertEqual(len(self.group_public.message_ids), 2, 'message_process: group should contain one new message') + self.assertEqual(len(self.fake_email.child_ids), 1, 'message_process: new message should be children of the existing one') + @mute_logger('openerp.addons.mail.models.mail_thread', 'openerp.models') def test_message_process_model_res_id(self): """ Incoming email with ref holding model / res_id but that does not match any message in the thread: must raise since OpenERP saas-3 """ @@ -560,15 +596,8 @@ class TestMailgateway(TestMail): to='noone@example.com>', subject='spam', extra='In-Reply-To: <12321321-openerp-%d-mail.channel@neighbor.com>' % frog_groups.id) - # 4. Subject contains [] + model passed to message+process -> only attached to group, but not to mail (not in msg1.child_ids) - self.format_and_process( - MAIL_TEMPLATE, - to='erroneous@example.com', email_from='other6@gmail.com', - extra='', subject='Re: [%s] 1' % self.group_public.id, model='mail.channel', - msg_id='<1198923581.41972151344608186803.JavaMail.5@agrolait.com>') - # Test created messages - self.assertEqual(len(self.group_public.message_ids), 3, 'message_process: group should contain 6 messages') + self.assertEqual(len(self.group_public.message_ids), 2, 'message_process: group should contain 6 messages') self.assertEqual(len(self.group_public.message_ids[0].child_ids), 0, 'message_process: msg1 should not have children') @mute_logger('openerp.addons.mail.models.mail_thread', 'openerp.models') @@ -700,21 +729,42 @@ class TestMailgateway(TestMail): self.assertEqual(msg.needaction_partner_ids, self.user_employee.partner_id | self.env.user.partner_id, 'message_post: private discussion: incorrect notified recipients when replying') - # Do bert forward it to an alias - msg = self.env['mail.message'].browse(msg1.id) + @mute_logger('openerp.addons.mail.models.mail_thread', 'openerp.models', 'openerp.addons.mail.models.mail_mail') + def test_forward_parent_id(self): + msg = self.group_pigs.sudo(self.user_employee).message_post(no_auto_thread=True, subtype='mail.mt_comment') + self.assertNotIn(msg.model, msg.message_id) + self.assertNotIn('-%d-' % msg.res_id, msg.message_id) + self.assertIn('reply_to', msg.message_id) + # forward it to a new thread AND an existing thread - for i, to in enumerate(['groups', 'public']): - fw_msg_id = '' % (i,) - fw_message = MAIL_TEMPLATE.format(to='%s@example.com' % (to,), - cc='', - subject='FW: Re: 1', - email_from='b.t@example.com', - extra='References: %s' % msg.message_id, - msg_id=fw_msg_id) - self.env['mail.thread'].message_process(None, fw_message) + fw_msg_id = '' + fw_message = MAIL_TEMPLATE.format(to='groups@example.com', + cc='', + subject='FW: Re: 1', + email_from='b.t@example.com', + extra='In-Reply-To: %s' % msg.message_id, + msg_id=fw_msg_id) + self.env['mail.thread'].message_process(None, fw_message) + msg_fw = self.env['mail.message'].search([('message_id', '=', fw_msg_id)]) + self.assertEqual(len(msg_fw), 1) + channel = self.env['mail.channel'].search([('name', "=", msg_fw.subject)]) + self.assertEqual(len(channel), 1) + self.assertEqual(msg_fw.model, 'mail.channel') + self.assertFalse(msg_fw.parent_id) + self.assertTrue(msg_fw.res_id == channel.id) - msg_fw = self.env['mail.message'].search([('message_id', '=', fw_msg_id)]) - self.assertEqual(len(msg_fw), 1) - - self.assertEqual(msg_fw.model, 'mail.channel') - self.assertFalse(msg_fw.parent_id) + fw_msg_id = '' + fw_message = MAIL_TEMPLATE.format(to='public@example.com', + cc='', + subject='FW: Re: 2', + email_from='b.t@example.com', + extra='In-Reply-To: %s' % msg.message_id, + msg_id=fw_msg_id) + self.env['mail.thread'].message_process(None, fw_message) + msg_fw = self.env['mail.message'].search([('message_id', '=', fw_msg_id)]) + self.assertEqual(len(msg_fw), 1) + channel = self.env['mail.channel'].search([('name', "=", msg_fw.subject)]) + self.assertEqual(len(channel), 0) + self.assertEqual(msg_fw.model, 'mail.channel') + self.assertFalse(msg_fw.parent_id) + self.assertTrue(msg_fw.res_id == self.group_public.id) diff --git a/addons/mail/tests/test_mail_message.py b/addons/mail/tests/test_mail_message.py index 3df8b40a9b9..ab095f5f3ca 100644 --- a/addons/mail/tests/test_mail_message.py +++ b/addons/mail/tests/test_mail_message.py @@ -49,8 +49,8 @@ class TestMailMessage(TestMail): msg = self.env['mail.message'].sudo(self.user_employee).create({}) self.assertIn('-private', msg.message_id, 'mail_message: message_id for a void message should be a "private" one') - self.assertEqual(msg.reply_to, '%s <%s@%s>' % (self.user_employee.name, self.user_employee.alias_name, alias_domain)) - self.assertEqual(msg.email_from, '%s <%s@%s>' % (self.user_employee.name, self.user_employee.alias_name, alias_domain)) + self.assertEqual(msg.reply_to, '%s <%s>' % (self.user_employee.name, self.user_employee.email)) + self.assertEqual(msg.email_from, '%s <%s>' % (self.user_employee.name, self.user_employee.email)) def test_mail_message_values_alias_catchall(self): alias_domain = 'example.com' @@ -61,7 +61,7 @@ class TestMailMessage(TestMail): msg = self.env['mail.message'].sudo(self.user_employee).create({}) self.assertIn('-private', msg.message_id, 'mail_message: message_id for a void message should be a "private" one') self.assertEqual(msg.reply_to, '%s <%s@%s>' % (self.env.user.company_id.name, alias_catchall, alias_domain)) - self.assertEqual(msg.email_from, '%s <%s@%s>' % (self.user_employee.name, self.user_employee.alias_name, alias_domain)) + self.assertEqual(msg.email_from, '%s <%s>' % (self.user_employee.name, self.user_employee.email)) def test_mail_message_values_document_no_alias(self): self.env['ir.config_parameter'].search([('key', '=', 'mail.catchall.domain')]).unlink() @@ -85,7 +85,7 @@ class TestMailMessage(TestMail): }) self.assertIn('-openerp-%d-mail.channel' % self.group_pigs.id, msg.message_id, 'mail_message: message_id for a void message should be a "private" one') self.assertEqual(msg.reply_to, '%s %s <%s@%s>' % (self.env.user.company_id.name, self.group_pigs.name, self.group_pigs.alias_name, alias_domain)) - self.assertEqual(msg.email_from, '%s <%s@%s>' % (self.user_employee.name, self.user_employee.alias_name, alias_domain)) + self.assertEqual(msg.email_from, '%s <%s>' % (self.user_employee.name, self.user_employee.email)) def test_mail_message_values_document_alias_catchall(self): alias_domain = 'example.com' @@ -99,7 +99,7 @@ class TestMailMessage(TestMail): }) self.assertIn('-openerp-%d-mail.channel' % self.group_pigs.id, msg.message_id, 'mail_message: message_id for a void message should be a "private" one') self.assertEqual(msg.reply_to, '%s %s <%s@%s>' % (self.env.user.company_id.name, self.group_pigs.name, self.group_pigs.alias_name, alias_domain)) - self.assertEqual(msg.email_from, '%s <%s@%s>' % (self.user_employee.name, self.user_employee.alias_name, alias_domain)) + self.assertEqual(msg.email_from, '%s <%s>' % (self.user_employee.name, self.user_employee.email)) def test_mail_message_values_no_auto_thread(self): msg = self.env['mail.message'].sudo(self.user_employee).create({ diff --git a/addons/mail/tests/test_mail_model.py b/addons/mail/tests/test_mail_model.py new file mode 100644 index 00000000000..bde1231870d --- /dev/null +++ b/addons/mail/tests/test_mail_model.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models + + +class MailTest(models.Model): + """ A mail.channel is a discussion group that may behave like a listener + on documents. """ + _description = 'Test Mail Model' + _name = 'mail.test' + _mail_post_access = 'read' + _inherit = ['mail.thread'] + + name = fields.Char() + alias_id = fields.Many2one( + 'mail.alias', 'Alias') diff --git a/addons/mail/views/mail_message_views.xml b/addons/mail/views/mail_message_views.xml index 338e3f473f4..636aefa0863 100644 --- a/addons/mail/views/mail_message_views.xml +++ b/addons/mail/views/mail_message_views.xml @@ -59,10 +59,24 @@ - - - - + + + + + + + + + + + + + + + + + + diff --git a/addons/mail/views/mail_templates.xml b/addons/mail/views/mail_templates.xml index 192486b6eaa..97d9c834821 100644 --- a/addons/mail/views/mail_templates.xml +++ b/addons/mail/views/mail_templates.xml @@ -38,5 +38,17 @@

+ + diff --git a/addons/mail/views/res_users_views.xml b/addons/mail/views/res_users_views.xml index 7cd4b25689b..c8b18ba4f07 100644 --- a/addons/mail/views/res_users_views.xml +++ b/addons/mail/views/res_users_views.xml @@ -27,17 +27,8 @@ -