diff --git a/odex25_base/base_odex/models/res_users.py b/odex25_base/base_odex/models/res_users.py index 39e42fd11..c6f3ddf3a 100644 --- a/odex25_base/base_odex/models/res_users.py +++ b/odex25_base/base_odex/models/res_users.py @@ -25,3 +25,4 @@ class ResUsers(models.Model): def get_res_users_id(self): for rec in self: rec.res_users_id = rec.id + \ No newline at end of file diff --git a/odex25_base/dynamic_reject_workflow/models/reject_workflow.py b/odex25_base/dynamic_reject_workflow/models/reject_workflow.py index f61da0654..8666e37fb 100644 --- a/odex25_base/dynamic_reject_workflow/models/reject_workflow.py +++ b/odex25_base/dynamic_reject_workflow/models/reject_workflow.py @@ -80,14 +80,16 @@ class RejectButton(models.Model): class MailThread(models.AbstractModel): _inherit = 'mail.thread' - reason = fields.Text(string='Reason/Justification') + reason = fields.Text(string='Reason/Justification', + tracking=True + ) def action_reject_workflow(self): return { 'name': _('Reject'), 'type': 'ir.actions.act_window', 'view_mode': 'form', - 'res_model': 'reject.wizard', + 'res_model': 'all.reject.wizard', 'target': 'new', } diff --git a/odex25_base/dynamic_reject_workflow/security/ir.model.access.csv b/odex25_base/dynamic_reject_workflow/security/ir.model.access.csv index a3086ebed..b1ea188d4 100644 --- a/odex25_base/dynamic_reject_workflow/security/ir.model.access.csv +++ b/odex25_base/dynamic_reject_workflow/security/ir.model.access.csv @@ -1,5 +1,6 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink access_reject_workflow_manager,reject_workflow_manager,model_reject_workflow,reject_group_manager,1,1,1,1 +access_all_reject_wizard_manager,all_reject_wizard_manager,model_all_reject_wizard,reject_group_manager,1,1,1,1 access_reject_button,reject_button,model_reject_button,base.group_user,1,1,1,1 access_reject_workflow,reject_workflow,model_reject_workflow,base.group_user,1,0,0,0 access_reject_button_wizard_manager,reject_button_wizard_manager,model_reject_button_wizard,reject_group_manager,1,1,1,1 diff --git a/odex25_base/dynamic_reject_workflow/wizard/reject_wizard.py b/odex25_base/dynamic_reject_workflow/wizard/reject_wizard.py index 03c309dc4..3c4352f4f 100644 --- a/odex25_base/dynamic_reject_workflow/wizard/reject_wizard.py +++ b/odex25_base/dynamic_reject_workflow/wizard/reject_wizard.py @@ -3,12 +3,13 @@ from odoo import models, fields class RejectWizard(models.TransientModel): - _name = 'reject.wizard' - _description = 'Reject Wizard' + _name = 'all.reject.wizard' + _description = 'All Reject Wizard' reason = fields.Text(string='Reason/Justification') def button_confirm(self): + print("*************RejectWizard*******************button_confirm**************") context = dict(self._context) active_model = context.get('active_model') active_id = context.get('active_id') diff --git a/odex25_base/dynamic_reject_workflow/wizard/reject_wizard.xml b/odex25_base/dynamic_reject_workflow/wizard/reject_wizard.xml index b1adc7e31..bc66de78b 100644 --- a/odex25_base/dynamic_reject_workflow/wizard/reject_wizard.xml +++ b/odex25_base/dynamic_reject_workflow/wizard/reject_wizard.xml @@ -2,8 +2,8 @@ - reject.wizard.view - reject.wizard + all.reject.wizard.view + all.reject.wizard
diff --git a/odex25_base/inbox_notif_email/.idea/.gitignore b/odex25_base/inbox_notif_email/.idea/.gitignore new file mode 100644 index 000000000..3cb95ef77 --- /dev/null +++ b/odex25_base/inbox_notif_email/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Datasource local storage ignored files +/../../../../../../../:\Users\Adnan Usmani\Downloads\Compressed\inbox_notif_email\.idea/dataSources/ +/dataSources.local.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/odex25_base/inbox_notif_email/.idea/inbox_notif_email.iml b/odex25_base/inbox_notif_email/.idea/inbox_notif_email.iml new file mode 100644 index 000000000..92b8cabcf --- /dev/null +++ b/odex25_base/inbox_notif_email/.idea/inbox_notif_email.iml @@ -0,0 +1,15 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/odex25_base/inbox_notif_email/.idea/inspectionProfiles/Project_Default.xml b/odex25_base/inbox_notif_email/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 000000000..a5550a38f --- /dev/null +++ b/odex25_base/inbox_notif_email/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,14 @@ + + + + \ No newline at end of file diff --git a/odex25_base/inbox_notif_email/.idea/inspectionProfiles/profiles_settings.xml b/odex25_base/inbox_notif_email/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 000000000..105ce2da2 --- /dev/null +++ b/odex25_base/inbox_notif_email/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/odex25_base/inbox_notif_email/.idea/misc.xml b/odex25_base/inbox_notif_email/.idea/misc.xml new file mode 100644 index 000000000..d1e22ecb8 --- /dev/null +++ b/odex25_base/inbox_notif_email/.idea/misc.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/odex25_base/inbox_notif_email/.idea/modules.xml b/odex25_base/inbox_notif_email/.idea/modules.xml new file mode 100644 index 000000000..ce31c98f2 --- /dev/null +++ b/odex25_base/inbox_notif_email/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/odex25_base/inbox_notif_email/.idea/vcs.xml b/odex25_base/inbox_notif_email/.idea/vcs.xml new file mode 100644 index 000000000..6c0b86358 --- /dev/null +++ b/odex25_base/inbox_notif_email/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/odex25_base/inbox_notif_email/__init__.py b/odex25_base/inbox_notif_email/__init__.py new file mode 100644 index 000000000..511a0ca3a --- /dev/null +++ b/odex25_base/inbox_notif_email/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- + +from . import controllers +from . import models \ No newline at end of file diff --git a/odex25_base/inbox_notif_email/__manifest__.py b/odex25_base/inbox_notif_email/__manifest__.py new file mode 100644 index 000000000..ea36517b9 --- /dev/null +++ b/odex25_base/inbox_notif_email/__manifest__.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +{ + 'name': "inbox_notif_email", + + 'summary': """ + odoo notification and email notification both""", + + 'description': """ + It will add one more option to send notification via email and odoo notification both + """, + + 'author': "odoozone", + 'website': 'http://odoozone.com/', + + # Categories can be used to filter modules in modules listing + # Check https://github.com/odoo/odoo/blob/14.0/odoo/addons/base/data/ir_module_category_data.xml + # for the full list + 'category': 'Discuss', + 'version': '0.1', + + # any module necessary for this one to work correctly + 'depends': ['mail'], + 'license': 'LGPL-3', + + # always loaded + 'data': [ + # 'security/ir.model.access.csv', + 'views/views.xml', + 'views/templates.xml', + ], + # only loaded in demonstration mode + 'demo': [ + 'demo/demo.xml',], + + 'images': [ +'static/description/banner.PNG', +'static/description/icon.png', +'static/description/image1.png', + ], +} diff --git a/odex25_base/inbox_notif_email/controllers/__init__.py b/odex25_base/inbox_notif_email/controllers/__init__.py new file mode 100644 index 000000000..457bae27e --- /dev/null +++ b/odex25_base/inbox_notif_email/controllers/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + +from . import controllers \ No newline at end of file diff --git a/odex25_base/inbox_notif_email/controllers/controllers.py b/odex25_base/inbox_notif_email/controllers/controllers.py new file mode 100644 index 000000000..5bae6c964 --- /dev/null +++ b/odex25_base/inbox_notif_email/controllers/controllers.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# from odoo import http + + +# class InboxNotifEmail(http.Controller): +# @http.route('/inbox_notif_email/inbox_notif_email/', auth='public') +# def index(self, **kw): +# return "Hello, world" + +# @http.route('/inbox_notif_email/inbox_notif_email/objects/', auth='public') +# def list(self, **kw): +# return http.request.render('inbox_notif_email.listing', { +# 'root': '/inbox_notif_email/inbox_notif_email', +# 'objects': http.request.env['inbox_notif_email.inbox_notif_email'].search([]), +# }) + +# @http.route('/inbox_notif_email/inbox_notif_email/objects//', auth='public') +# def object(self, obj, **kw): +# return http.request.render('inbox_notif_email.object', { +# 'object': obj +# }) diff --git a/odex25_base/inbox_notif_email/demo/demo.xml b/odex25_base/inbox_notif_email/demo/demo.xml new file mode 100644 index 000000000..bad20acdb --- /dev/null +++ b/odex25_base/inbox_notif_email/demo/demo.xml @@ -0,0 +1,30 @@ + + + + + \ No newline at end of file diff --git a/odex25_base/inbox_notif_email/models/__init__.py b/odex25_base/inbox_notif_email/models/__init__.py new file mode 100644 index 000000000..5305644df --- /dev/null +++ b/odex25_base/inbox_notif_email/models/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + +from . import models \ No newline at end of file diff --git a/odex25_base/inbox_notif_email/models/models.py b/odex25_base/inbox_notif_email/models/models.py new file mode 100644 index 000000000..64c7f27e6 --- /dev/null +++ b/odex25_base/inbox_notif_email/models/models.py @@ -0,0 +1,2933 @@ +# -*- coding: utf-8 -*- + +# from odoo import models, fields, api + + +# class inbox_notif_email(models.Model): +# _name = 'inbox_notif_email.inbox_notif_email' +# _description = 'inbox_notif_email.inbox_notif_email' + +# name = fields.Char() +# value = fields.Integer() +# value2 = fields.Float(compute="_value_pc", store=True) +# description = fields.Text() +# +# @api.depends('value') +# def _value_pc(self): +# for record in self: +# record.value2 = float(record.value) / 100 +import ast +import base64 +import datetime +import inspect + +import dateutil +import email +import email.policy +import hashlib +import hmac +import lxml +import logging +import pytz +import re +import socket +import time +import threading + +from collections import namedtuple +from email.message import EmailMessage +from email import message_from_string, policy +from lxml import etree +from werkzeug import urls +from xmlrpc import client as xmlrpclib + +from odoo import _, api, exceptions, fields, models, tools, registry, SUPERUSER_ID +from odoo.exceptions import MissingError +from odoo.osv import expression + +from odoo.tools import ustr +from odoo.tools.misc import clean_context, split_every + +_logger = logging.getLogger(__name__) + + +class Users(models.Model): + _name = 'res.users' + _inherit = ['res.users'] + notification_type = fields.Selection([ + ('email', 'Handle by Emails'), + ('inbox', 'Handle in Odoo'), + ('both', 'Handle by both') + ], + 'Notification', required=True, default='email', + help="Policy on how to handle Chatter notifications:\n" + "- Handle by Emails: notifications are sent to your email address\n" + "- Handle in Odoo: notifications appear in your Odoo Inbox") + + +class MailNotification(models.Model): + _inherit = 'mail.notification' + notification_type = fields.Selection([ + ('inbox', 'Inbox'), ('email', 'Email'),('both', 'both') + ], string='Notification Type', default='inbox', index=True, required=True) + # _sql_constraints = [ + # # email notification;: partner is required + # ('notification_partner_required', + # "CHECK(notification_type NOT IN ('email', 'inbox','both') OR res_partner_id IS NOT NULL)", + # 'Customer is required for inbox / email notification'), + # ] + + +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 + methods are prefixed with ``message_`` in order to avoid name + collisions with methods of the models that will inherit from this class. + + ``mail.thread`` defines fields used to handle and display the + communication history. ``mail.thread`` also manages followers of + inheriting classes. All features and expected behavior are managed + by mail.thread. Widgets has been designed for the 7.0 and following + versions of Odoo. + + Inheriting classes are not required to implement any method, as the + default implementation will work for any model. However it is common + to override at least the ``message_new`` and ``message_update`` + methods (calling ``super``) to add model-specific behavior at + creation and update of a thread when processing incoming emails. + + Options: + - _mail_flat_thread: if set to True, all messages without parent_id + are automatically attached to the first message posted on the + ressource. If set to False, the display of Chatter is done using + threads, and no parent_id is automatically set. + + MailThread features can be somewhat controlled through context keys : + + - ``mail_create_nosubscribe``: at create or message_post, do not subscribe + uid to the record thread + - ``mail_create_nolog``: at create, do not log the automatic ' + created' message + - ``mail_notrack``: at create and write, do not perform the value tracking + creating messages + - ``tracking_disable``: at create and write, perform no MailThread features + (auto subscription, tracking, post, ...) + - ``mail_notify_force_send``: if less than 50 email notifications to send, + send them directly instead of using the queue; True by default + ''' + _inherit = 'mail.thread' + _description = 'Email Thread' + _mail_flat_thread = True # flatten the discussino history + _mail_post_access = 'write' # access required on the document to post on it + _Attachment = namedtuple('Attachment', ('fname', 'content', 'info')) + + message_is_follower = fields.Boolean( + 'Is Follower', compute='_compute_is_follower', search='_search_is_follower') + message_follower_ids = fields.One2many( + 'mail.followers', 'res_id', string='Followers', groups='base.group_user') + message_partner_ids = fields.Many2many( + comodel_name='res.partner', string='Followers (Partners)', + compute='_get_followers', search='_search_follower_partners', + groups='base.group_user') + message_channel_ids = fields.Many2many( + comodel_name='mail.channel', string='Followers (Channels)', + compute='_get_followers', search='_search_follower_channels', + groups='base.group_user') + message_ids = fields.One2many( + 'mail.message', 'res_id', string='Messages', + domain=lambda self: [('message_type', '!=', 'user_notification')], auto_join=True) + message_unread = fields.Boolean( + 'Unread Messages', compute='_get_message_unread', + help="If checked, new messages require your attention.") + message_unread_counter = fields.Integer( + 'Unread Messages Counter', compute='_get_message_unread', + help="Number of unread messages") + message_needaction = fields.Boolean( + 'Action Needed', compute='_get_message_needaction', search='_search_message_needaction', + help="If checked, new messages require your attention.") + message_needaction_counter = fields.Integer( + 'Number of Actions', compute='_get_message_needaction', + help="Number of messages which requires an action") + message_has_error = fields.Boolean( + 'Message Delivery error', compute='_compute_message_has_error', search='_search_message_has_error', + help="If checked, some messages have a delivery error.") + message_has_error_counter = fields.Integer( + 'Number of errors', compute='_compute_message_has_error', + help="Number of messages with delivery error") + message_attachment_count = fields.Integer('Attachment Count', compute='_compute_message_attachment_count', groups="base.group_user") + message_main_attachment_id = fields.Many2one(string="Main Attachment", comodel_name='ir.attachment', index=True, copy=False) + + @api.depends('message_follower_ids') + def _get_followers(self): + for thread in self: + thread.message_partner_ids = thread.message_follower_ids.mapped('partner_id') + thread.message_channel_ids = thread.message_follower_ids.mapped('channel_id') + + @api.model + def _search_follower_partners(self, operator, operand): + """Search function for message_follower_ids + + Do not use with operator 'not in'. Use instead message_is_followers + """ + # TOFIX make it work with not in + assert operator != "not in", "Do not search message_follower_ids with 'not in'" + followers = self.env['mail.followers'].sudo().search([ + ('res_model', '=', self._name), + ('partner_id', operator, operand)]) + # using read() below is much faster than followers.mapped('res_id') + return [('id', 'in', [res['res_id'] for res in followers.read(['res_id'])])] + + @api.model + def _search_follower_channels(self, operator, operand): + """Search function for message_follower_ids + + Do not use with operator 'not in'. Use instead message_is_followers + """ + # TOFIX make it work with not in + assert operator != "not in", "Do not search message_follower_ids with 'not in'" + followers = self.env['mail.followers'].sudo().search([ + ('res_model', '=', self._name), + ('channel_id', operator, operand)]) + # using read() below is much faster than followers.mapped('res_id') + return [('id', 'in', [res['res_id'] for res in followers.read(['res_id'])])] + + @api.depends('message_follower_ids') + def _compute_is_follower(self): + followers = self.env['mail.followers'].sudo().search([ + ('res_model', '=', self._name), + ('res_id', 'in', self.ids), + ('partner_id', '=', self.env.user.partner_id.id), + ]) + print(followers); + # using read() below is much faster than followers.mapped('res_id') + following_ids = [res['res_id'] for res in followers.read(['res_id'])] + for record in self: + record.message_is_follower = record.id in following_ids + + @api.model + def _search_is_follower(self, operator, operand): + followers = self.env['mail.followers'].sudo().search([ + ('res_model', '=', self._name), + ('partner_id', '=', self.env.user.partner_id.id), + ]) + # Cases ('message_is_follower', '=', True) or ('message_is_follower', '!=', False) + if (operator == '=' and operand) or (operator == '!=' and not operand): + # using read() below is much faster than followers.mapped('res_id') + return [('id', 'in', [res['res_id'] for res in followers.read(['res_id'])])] + else: + # using read() below is much faster than followers.mapped('res_id') + return [('id', 'not in', [res['res_id'] for res in followers.read(['res_id'])])] + + def _get_message_unread(self): + partner_id = self.env.user.partner_id.id + res = dict.fromkeys(self.ids, 0) + if self.ids: + # 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_mail_channel_rel rel + ON rel.mail_message_id = msg.id + RIGHT JOIN mail_channel_partner cp + ON (cp.channel_id = rel.mail_channel_id AND cp.partner_id = %s AND + (cp.seen_message_id IS NULL OR cp.seen_message_id < msg.id)) + WHERE msg.model = %s AND msg.res_id = ANY(%s) AND + msg.message_type != 'user_notification' AND + (msg.author_id IS NULL OR msg.author_id != %s) AND + (msg.message_type not in ('notification', 'user_notification') OR msg.model != 'mail.channel')""", + (partner_id, self._name, list(self.ids), partner_id,)) + for result in self._cr.fetchall(): + res[result[0]] += 1 + + for record in self: + record.message_unread_counter = res.get(record._origin.id, 0) + record.message_unread = bool(record.message_unread_counter) + + def _get_message_needaction(self): + res = dict.fromkeys(self.ids, 0) + if self.ids: + # 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 AND (rel.is_read = false OR rel.is_read IS NULL) + WHERE msg.model = %s AND msg.res_id in %s AND msg.message_type != 'user_notification'""", + (self.env.user.partner_id.id, self._name, tuple(self.ids),)) + for result in self._cr.fetchall(): + res[result[0]] += 1 + + for record in self: + record.message_needaction_counter = res.get(record._origin.id, 0) + record.message_needaction = bool(record.message_needaction_counter) + + @api.model + def _search_message_needaction(self, operator, operand): + return [('message_ids.needaction', operator, operand)] + + def _compute_message_has_error(self): + res = {} + if self.ids: + self._cr.execute(""" SELECT msg.res_id, COUNT(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.notification_status in ('exception','bounce') + WHERE msg.author_id = %s AND msg.model = %s AND msg.res_id in %s AND msg.message_type != 'user_notification' + GROUP BY msg.res_id""", + (self.env.user.partner_id.id, self._name, tuple(self.ids),)) + res.update(self._cr.fetchall()) + + for record in self: + record.message_has_error_counter = res.get(record._origin.id, 0) + record.message_has_error = bool(record.message_has_error_counter) + + @api.model + def _search_message_has_error(self, operator, operand): + message_ids = self.env['mail.message']._search([('has_error', operator, operand), ('author_id', '=', self.env.user.partner_id.id)]) + return [('message_ids', 'in', message_ids)] + + def _compute_message_attachment_count(self): + read_group_var = self.env['ir.attachment'].read_group([('res_id', 'in', self.ids), ('res_model', '=', self._name)], + fields=['res_id'], + groupby=['res_id']) + + attachment_count_dict = dict((d['res_id'], d['res_id_count']) for d in read_group_var) + for record in self: + record.message_attachment_count = attachment_count_dict.get(record.id, 0) + + # ------------------------------------------------------------ + # CRUD + # ------------------------------------------------------------ + + # @api.model_create_multi + # def create(self, vals_list): + # """ Chatter override : + # - subscribe uid + # - subscribe followers of parent + # - log a creation message + # """ + # if self._context.get('tracking_disable'): + # threads = super(MailThread, self).create(vals_list) + # threads._discard_tracking() + # return threads + # curframe = inspect.currentframe() + # calframe = inspect.getouterframes(curframe, 2) + # print("vals_list : ", vals_list) + # print('caller name:', calframe) + # + # threads = super(MailThread, self).create(vals_list) + # + # + # # subscribe uid unless asked not to + # if not self._context.get('mail_create_nosubscribe'): + # for thread in threads: + # self.env['mail.followers']._insert_followers( + # thread._name, thread.ids, self.env.user.partner_id.ids, + # None, None, None, + # customer_ids=[], + # check_existing=False + # ) + # + # # auto_subscribe: take values and defaults into account + # create_values_list = {} + # for thread, values in zip(threads, vals_list): + # create_values = dict(values) + # for key, val in self._context.items(): + # if key.startswith('default_') and key[8:] not in create_values: + # create_values[key[8:]] = val + # thread._message_auto_subscribe(create_values, followers_existing_policy='update') + # create_values_list[thread.id] = create_values + # + # # automatic logging unless asked not to (mainly for various testing purpose) + # if not self._context.get('mail_create_nolog'): + # threads_no_subtype = self.env[self._name] + # for thread in threads: + # subtype = thread._creation_subtype() + # if subtype: # if we have a subtype, post message to notify users from _message_auto_subscribe + # thread.sudo().message_post(subtype_id=subtype.id, author_id=self.env.user.partner_id.id) + # else: + # threads_no_subtype += thread + # if threads_no_subtype: + # bodies = dict( + # (thread.id, thread._creation_message()) + # for thread in threads_no_subtype) + # threads_no_subtype._message_log_batch(bodies=bodies) + # + # # post track template if a tracked field changed + # threads._discard_tracking() + # if not self._context.get('mail_notrack'): + # fnames = self._get_tracked_fields() + # for thread in threads: + # create_values = create_values_list[thread.id] + # changes = [fname for fname in fnames if create_values.get(fname)] + # # based on tracked field to stay consistent with write + # # we don't consider that a falsy field is a change, to stay consistent with previous implementation, + # # but we may want to change that behaviour later. + # thread._message_track_post_template(changes) + # + # return threads + + def write(self, values): + if self._context.get('tracking_disable'): + return super(MailThread, self).write(values) + + if not self._context.get('mail_notrack'): + self._prepare_tracking(self._fields) + + # Perform write + result = super(MailThread, self).write(values) + + # update followers + self._message_auto_subscribe(values) + + return result + + def unlink(self): + """ Override unlink to delete messages and followers. This cannot be + cascaded, because link is done through (res_model, res_id). """ + if not self: + return True + # discard pending tracking + self._discard_tracking() + self.env['mail.message'].search([('model', '=', self._name), ('res_id', 'in', self.ids)]).sudo().unlink() + res = super(MailThread, self).unlink() + self.env['mail.followers'].sudo().search( + [('res_model', '=', self._name), ('res_id', 'in', self.ids)] + ).unlink() + return res + + def copy_data(self, default=None): + # avoid tracking multiple temporary changes during copy + return super(MailThread, self.with_context(mail_notrack=True)).copy_data(default=default) + + @api.model + def get_empty_list_help(self, help): + """ Override of BaseModel.get_empty_list_help() to generate an help message + that adds alias information. """ + model = self._context.get('empty_list_help_model') + res_id = self._context.get('empty_list_help_id') + catchall_domain = self.env['ir.config_parameter'].sudo().get_param("mail.catchall.domain") + document_name = self._context.get('empty_list_help_document_name', _('document')) + nothing_here = not help + alias = None + + if catchall_domain and model and res_id: # specific res_id -> find its alias (i.e. section_id specified) + record = self.env[model].sudo().browse(res_id) + # check that the alias effectively creates new records + if record.alias_id and record.alias_id.alias_name and \ + record.alias_id.alias_model_id and \ + record.alias_id.alias_model_id.model == self._name and \ + record.alias_id.alias_force_thread_id == 0: + alias = record.alias_id + if not alias and catchall_domain and model: # no res_id or res_id not linked to an alias -> generic help message, take a generic alias of the model + Alias = self.env['mail.alias'] + aliases = Alias.search([ + ("alias_parent_model_id.model", "=", model), + ("alias_name", "!=", False), + ('alias_force_thread_id', '=', False), + ('alias_parent_thread_id', '=', False)], order='id ASC') + if aliases and len(aliases) == 1: + alias = aliases[0] + + if alias: + email_link = "%(email)s" % {'email': alias.display_name} + if nothing_here: + return "

%(dyn_help)s

" % { + 'dyn_help': _("Add a new %(document)s or send an email to %(email_link)s", + document=document_name, + email_link=email_link, + ) + } + # do not add alias two times if it was added previously + if "oe_view_nocontent_alias" not in help: + return "%(static_help)s

%(dyn_help)s

" % { + 'static_help': help, + 'dyn_help': _("Create new %(document)s by sending an email to %(email_link)s", + document=document_name, + email_link=email_link, + ) + } + + if nothing_here: + return "

%(dyn_help)s

" % { + 'dyn_help': _("Create new %(document)s", document=document_name), + } + + return help + + # ------------------------------------------------------ + # MODELS / CRUD HELPERS + # ------------------------------------------------------ + + def _compute_field_value(self, field): + if not self._context.get('tracking_disable') and not self._context.get('mail_notrack'): + self._prepare_tracking(f.name for f in self.pool.field_computed[field] if f.store) + + return super()._compute_field_value(field) + + def _creation_subtype(self): + """ Give the subtypes triggered by the creation of a record + + :returns: a subtype browse record (empty if no subtype is triggered) + """ + return self.env['mail.message.subtype'] + + def _get_creation_message(self): + """ Deprecated, remove in 14+ """ + return self._creation_message() + + def _creation_message(self): + """ Get the creation message to log into the chatter at the record's creation. + :returns: The message's body to log. + """ + self.ensure_one() + doc_name = self.env['ir.model']._get(self._name).name + return _('%s created', doc_name) + + @api.model + def get_mail_message_access(self, res_ids, operation, model_name=None): + """ Deprecated, remove with v14+ """ + return self._get_mail_message_access(res_ids, operation, model_name=model_name) + + @api.model + def _get_mail_message_access(self, res_ids, operation, model_name=None): + """ mail.message check permission rules for related document. This method is + meant to be inherited in order to implement addons-specific behavior. + A common behavior would be to allow creating messages when having read + access rule on the document, for portal document such as issues. """ + + DocModel = self.env[model_name] if model_name else self + create_allow = getattr(DocModel, '_mail_post_access', 'write') + + if operation in ['write', 'unlink']: + check_operation = 'write' + elif operation == 'create' and create_allow in ['create', 'read', 'write', 'unlink']: + check_operation = create_allow + elif operation == 'create': + check_operation = 'write' + else: + check_operation = operation + return check_operation + + def _valid_field_parameter(self, field, name): + # allow tracking on models inheriting from 'mail.thread' + return name == 'tracking' or super()._valid_field_parameter(field, name) + + def with_lang(self): + """ Deprecated, remove in 14+ """ + return self._fallback_lang() + + def _fallback_lang(self): + if not self._context.get("lang"): + return self.with_context(lang=self.env.user.lang) + return self + + # ------------------------------------------------------ + # WRAPPERS AND TOOLS + # ------------------------------------------------------ + + def message_change_thread(self, new_thread): + """ + Transfer the list of the mail thread messages from an model to another + + :param id : the old res_id of the mail.message + :param new_res_id : the new res_id of the mail.message + :param new_model : the name of the new model of the mail.message + + Example : my_lead.message_change_thread(my_project_task) + will transfer the context of the thread of my_lead to my_project_task + """ + self.ensure_one() + # get the subtype of the comment Message + subtype_comment = self.env['ir.model.data'].xmlid_to_res_id('mail.mt_comment') + + # get the ids of the comment and not-comment of the thread + # TDE check: sudo on mail.message, to be sure all messages are moved ? + MailMessage = self.env['mail.message'] + msg_comment = MailMessage.search([ + ('model', '=', self._name), + ('res_id', '=', self.id), + ('message_type', '!=', 'user_notification'), + ('subtype_id', '=', subtype_comment)]) + msg_not_comment = MailMessage.search([ + ('model', '=', self._name), + ('res_id', '=', self.id), + ('message_type', '!=', 'user_notification'), + ('subtype_id', '!=', subtype_comment)]) + + # update the messages + msg_comment.write({"res_id": new_thread.id, "model": new_thread._name}) + msg_not_comment.write({"res_id": new_thread.id, "model": new_thread._name, "subtype_id": None}) + return True + + # ------------------------------------------------------ + # TRACKING / LOG + # ------------------------------------------------------ + + def _prepare_tracking(self, fields): + """ Prepare the tracking of ``fields`` for ``self``. + + :param fields: iterable of fields names to potentially track + """ + fnames = self._get_tracked_fields().intersection(fields) + if not fnames: + return + self.env.cr.precommit.add(self._finalize_tracking) + initial_values = self.env.cr.precommit.data.setdefault(f'mail.tracking.{self._name}', {}) + for record in self: + if not record.id: + continue + values = initial_values.setdefault(record.id, {}) + if values is not None: + for fname in fnames: + values.setdefault(fname, record[fname]) + + def _discard_tracking(self): + """ Prevent any tracking of fields on ``self``. """ + if not self._get_tracked_fields(): + return + self.env.cr.precommit.add(self._finalize_tracking) + initial_values = self.env.cr.precommit.data.setdefault(f'mail.tracking.{self._name}', {}) + # disable tracking by setting initial values to None + for id_ in self.ids: + initial_values[id_] = None + + def _finalize_tracking(self): + """ Generate the tracking messages for the records that have been + prepared with ``_prepare_tracking``. + """ + initial_values = self.env.cr.precommit.data.pop(f'mail.tracking.{self._name}', {}) + ids = [id_ for id_, vals in initial_values.items() if vals] + if not ids: + return + records = self.browse(ids).sudo() + fnames = self._get_tracked_fields() + context = clean_context(self._context) + tracking = records.with_context(context).message_track(fnames, initial_values) + for record in records: + changes, tracking_value_ids = tracking.get(record.id, (None, None)) + record._message_track_post_template(changes) + # this method is called after the main flush() and just before commit(); + # we have to flush() again in case we triggered some recomputations + self.flush() + + @tools.ormcache('self.env.uid', 'self.env.su') + def _get_tracked_fields(self): + """ Return the set of tracked fields names for the current model. """ + fields = { + name + for name, field in self._fields.items() + if getattr(field, 'tracking', None) or getattr(field, 'track_visibility', None) + } + + return fields and set(self.fields_get(fields)) + + def _message_track_post_template(self, changes): + if not changes: + return True + # Clean the context to get rid of residual default_* keys + # that could cause issues afterward during the mail.message + # generation. Example: 'default_parent_id' would refer to + # the parent_id of the current record that was used during + # its creation, but could refer to wrong parent message id, + # leading to a traceback in case the related message_id + # doesn't exist + self = self.with_context(clean_context(self._context)) + templates = self._track_template(changes) + for field_name, (template, post_kwargs) in templates.items(): + if not template: + continue + if isinstance(template, str): + self._fallback_lang().message_post_with_view(template, **post_kwargs) + else: + self._fallback_lang().message_post_with_template(template.id, **post_kwargs) + return True + + def _track_template(self, changes): + return dict() + + def message_track(self, tracked_fields, initial_values): + """ Track updated values. Comparing the initial and current values of + the fields given in tracked_fields, it generates a message containing + the updated values. This message can be linked to a mail.message.subtype + given by the ``_track_subtype`` method. + + :param tracked_fields: iterable of field names to track + :param initial_values: mapping {record_id: {field_name: value}} + :return: mapping {record_id: (changed_field_names, tracking_value_ids)} + containing existing records only + """ + if not tracked_fields: + return True + + tracked_fields = self.fields_get(tracked_fields) + tracking = dict() + for record in self: + try: + tracking[record.id] = record._message_track(tracked_fields, initial_values[record.id]) + except MissingError: + continue + + for record in self: + changes, tracking_value_ids = tracking.get(record.id, (None, None)) + if not changes: + continue + + # find subtypes and post messages or log if no subtype found + subtype = False + # By passing this key, that allows to let the subtype empty and so don't sent email because partners_to_notify from mail_message._notify will be empty + if not self._context.get('mail_track_log_only'): + subtype = record._track_subtype(dict((col_name, initial_values[record.id][col_name]) for col_name in changes)) + if subtype: + if not subtype.exists(): + _logger.debug('subtype "%s" not found' % subtype.name) + continue + record.message_post(subtype_id=subtype.id, tracking_value_ids=tracking_value_ids) + elif tracking_value_ids: + record._message_log(tracking_value_ids=tracking_value_ids) + + return tracking + + def static_message_track(self, record, tracked_fields, initial): + """ Deprecated, remove in v14+ """ + return record._mail_track(tracked_fields, initial) + + def _message_track(self, tracked_fields, initial): + """ Moved to ``BaseModel._mail_track()`` """ + return self._mail_track(tracked_fields, initial) + + def _track_subtype(self, init_values): + """ Give the subtypes triggered by the changes on the record according + to values that have been updated. + + :param init_values: the original values of the record; only modified fields + are present in the dict + :type init_values: dict + :returns: a subtype browse record or False if no subtype is trigerred + """ + return False + + # ------------------------------------------------------ + # MAIL GATEWAY + # ------------------------------------------------------ + + def _routing_warn(self, error_message, message_id, route, raise_exception=True): + """ Tools method used in _routing_check_route: whether to log a warning or raise an error """ + short_message = _("Mailbox unavailable - %s", error_message) + full_message = ('Routing mail with Message-Id %s: route %s: %s' % + (message_id, route, error_message)) + _logger.info(full_message) + if raise_exception: + # sender should not see private diagnostics info, just the error + raise ValueError(short_message) + + def _routing_create_bounce_email(self, email_from, body_html, message, **mail_values): + bounce_to = tools.decode_message_header(message, 'Return-Path') or email_from + bounce_mail_values = { + 'author_id': False, + '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'] = tools.formataddr(('MAILER-DAEMON', bounce_from)) + elif self.env['ir.config_parameter'].sudo().get_param("mail.catchall.alias") not in message['To']: + bounce_mail_values['email_from'] = tools.decode_message_header(message, 'To') + else: + bounce_mail_values['email_from'] = tools.formataddr(('MAILER-DAEMON', self.env.user.email_normalized)) + bounce_mail_values.update(mail_values) + self.env['mail.mail'].sudo().create(bounce_mail_values).send() + + @api.model + def _routing_handle_bounce(self, email_message, message_dict): + """ Handle bounce of incoming email. Based on values of the bounce (email + and related partner, send message and its messageID) + + * find blacklist-enabled records with email_normalized = bounced email + and call ``_message_receive_bounce`` on each of them to propagate + bounce information through various records linked to same email; + * if not already done (i.e. if original record is not blacklist enabled + like a bounce on an applicant), find record linked to bounced message + and call ``_message_receive_bounce``; + + :param email_message: incoming email; + :type email_message: email.message; + :param message_dict: dictionary holding already-parsed values and in + which bounce-related values will be added; + :type message_dict: dictionary; + """ + bounced_record, bounced_record_done = False, False + bounced_email, bounced_partner = message_dict['bounced_email'], message_dict['bounced_partner'] + bounced_msg_id, bounced_message = message_dict['bounced_msg_id'], message_dict['bounced_message'] + + if bounced_email: + bounced_model, bounced_res_id = bounced_message.model, bounced_message.res_id + + if bounced_model and bounced_model in self.env and bounced_res_id: + bounced_record = self.env[bounced_model].sudo().browse(bounced_res_id).exists() + + bl_models = self.env['ir.model'].sudo().search(['&', ('is_mail_blacklist', '=', True), ('model', '!=', 'mail.thread.blacklist')]) + for model in [bl_model for bl_model in bl_models if bl_model.model in self.env]: # transient test mode + rec_bounce_w_email = self.env[model.model].sudo().search([('email_normalized', '=', bounced_email)]) + rec_bounce_w_email._message_receive_bounce(bounced_email, bounced_partner) + bounced_record_done = bool(bounced_record and model.model == bounced_model and bounced_record in rec_bounce_w_email) + + # set record as bounced unless already done due to blacklist mixin + if bounced_record and not bounced_record_done and issubclass(type(bounced_record), self.pool['mail.thread']): + bounced_record._message_receive_bounce(bounced_email, bounced_partner) + + if bounced_partner and bounced_message: + self.env['mail.notification'].sudo().search([ + ('mail_message_id', '=', bounced_message.id), + ('res_partner_id', 'in', bounced_partner.ids)] + ).write({'notification_status': 'bounce'}) + + if bounced_record: + _logger.info('Routing mail from %s to %s with Message-Id %s: not routing bounce email from %s replying to %s (model %s ID %s)', + message_dict['email_from'], message_dict['to'], message_dict['message_id'], bounced_email, bounced_msg_id, bounced_model, bounced_res_id) + elif bounced_email: + _logger.info('Routing mail from %s to %s with Message-Id %s: not routing bounce email from %s replying to %s (no document found)', + message_dict['email_from'], message_dict['to'], message_dict['message_id'], bounced_email, bounced_msg_id) + else: + _logger.info('Routing mail from %s to %s with Message-Id %s: not routing bounce email.', + message_dict['email_from'], message_dict['to'], message_dict['message_id']) + + @api.model + def _routing_check_route(self, message, message_dict, route, raise_exception=True): + """ 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 + 2 - check that message_update exists if thread_id is set; or at least + that message_new exist + 3 - if there is an alias, check alias_contact: + 'followers' and thread_id: + check on target document that the author is in the followers + 'followers' and alias_parent_thread_id: + 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 raise_exception: if an error occurs, tell whether to raise an error + or just log a warning and try other processing or + invalidate route + """ + + 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_dict['message_id'] + email_from = message_dict['email_from'] + author_id = message_dict.get('author_id') + model, thread_id, alias = route[0], route[1], route[4] + record_set = None + + # Wrong model + if not model: + self._routing_warn(_('target model unspecified'), message_id, route, raise_exception) + return () + elif model not in self.env: + self._routing_warn(_('unknown target model %s', model), message_id, route, raise_exception) + return () + record_set = self.env[model].browse(thread_id) if thread_id else self.env[model] + + # 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(): + self._routing_warn( + _('reply to missing document (%(model)s,%(thread)s), fall back on document creation', model=model, thread=thread_id), + message_id, + route, + False + ) + thread_id = None + elif not hasattr(record_set, 'message_update'): + self._routing_warn(_('reply to model %s that does not accept document update, fall back on document creation', model), message_id, route, False) + thread_id = None + + # New Document: check model accepts the mailgateway + if not thread_id and model and not hasattr(record_set, 'message_new'): + self._routing_warn(_('model %s does not accept document creation', model), message_id, route, raise_exception) + return () + + # Update message author. We do it now because we need it for aliases (contact settings) + if not author_id: + if record_set: + authors = self._mail_find_partner_from_emails([email_from], records=record_set) + elif alias and alias.alias_parent_model_id and alias.alias_parent_thread_id: + records = self.env[alias.alias_parent_model_id.model].browse(alias.alias_parent_thread_id) + authors = self._mail_find_partner_from_emails([email_from], records=records) + else: + authors = self._mail_find_partner_from_emails([email_from], records=None) + if authors: + message_dict['author_id'] = authors[0].id + + # Alias: check alias_contact settings + if alias: + if thread_id: + obj = record_set[0] + elif alias.alias_parent_model_id and alias.alias_parent_thread_id: + obj = self.env[alias.alias_parent_model_id.model].browse(alias.alias_parent_thread_id) + else: + obj = self.env[model] + error_message = obj._alias_get_error_message(message, message_dict, alias) + if error_message: + self._routing_warn( + _('alias %(name)s: %(error)s', name=alias.alias_name, error=error_message or _('unknown error')), + message_id, + route, + False + ) + body = alias._get_alias_bounced_body(message_dict) + self._routing_create_bounce_email(email_from, body, message, references=message_id) + return False + + return (model, thread_id, route[2], route[3], route[4]) + + @api.model + def _routing_reset_bounce(self, email_message, message_dict): + """Called by ``message_process`` when a new mail is received from an email address. + If the email is related to a partner, we consider that the number of message_bounce + is not relevant anymore as the email is valid - as we received an email from this + address. The model is here hardcoded because we cannot know with which model the + incomming mail match. We consider that if a mail arrives, we have to clear bounce for + each model having bounce count. + + :param email_from: email address that sent the incoming email.""" + valid_email = message_dict['email_from'] + if valid_email: + bl_models = self.env['ir.model'].sudo().search(['&', ('is_mail_blacklist', '=', True), ('model', '!=', 'mail.thread.blacklist')]) + for model in [bl_model for bl_model in bl_models if bl_model.model in self.env]: # transient test mode + self.env[model.model].sudo().search([('message_bounce', '>', 0), ('email_normalized', '=', valid_email)])._message_reset_bounce(valid_email) + + @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, + 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: + + * 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; + * 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, EmailMessage): + raise TypeError('message must be an email.message.EmailMessage at this point') + catchall_alias = self.env['ir.config_parameter'].sudo().get_param("mail.catchall.alias") + bounce_alias = self.env['ir.config_parameter'].sudo().get_param("mail.bounce.alias") + fallback_model = model + + # get email.message.Message variables for future processing + local_hostname = socket.gethostname() + message_id = message_dict['message_id'] + + # compute references to find if message is a reply to an existing thread + thread_references = message_dict['references'] or message_dict['in_reply_to'] + msg_references = [ + re.sub(r'[\r\n\t ]+', r'', ref) # "Unfold" buggy references + for ref in tools.mail_header_msgid_re.findall(thread_references) + if 'reply_to' not in ref + ] + mail_messages = self.env['mail.message'].sudo().search([('message_id', 'in', msg_references)], limit=1, order='id desc, message_id') + is_a_reply = bool(mail_messages) + reply_model, reply_thread_id = mail_messages.model, mail_messages.res_id + + # author and recipients + email_from = message_dict['email_from'] + email_from_localpart = (tools.email_split(email_from) or [''])[0].split('@', 1)[0].lower() + email_to = message_dict['to'] + email_to_localparts = [ + e.split('@', 1)[0].lower() + for e in (tools.email_split(email_to) or ['']) + ] + # 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_localparts = [ + e.split('@')[0].lower() + for e in tools.email_split(message_dict['recipients']) + ] + rcpt_tos_valid_localparts = [to for to in rcpt_tos_localparts] + + # 0. Handle bounce: verify whether this is a bounced email and use it to collect bounce data and update notifications for customers + # 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 message (not alias) + # 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" + # If not a bounce: reset bounce information + if bounce_alias and any(email.startswith(bounce_alias) for email in email_to_localparts): + bounce_re = re.compile("%s\+(\d+)-?([\w.]+)?-?(\d+)?" % re.escape(bounce_alias), re.UNICODE) + bounce_match = bounce_re.search(email_to) + if bounce_match: + self._routing_handle_bounce(message, message_dict) + return [] + if message.get_content_type() == 'multipart/report' or email_from_localpart == 'mailer-daemon': + self._routing_handle_bounce(message, message_dict) + return [] + self._routing_reset_bounce(message, message_dict) + + # 1. Handle reply + # if destination = alias with different model -> consider it is a forward and not a reply + # if destination = alias with same model -> check contact settings as they still apply + if reply_model and reply_thread_id: + other_model_aliases = self.env['mail.alias'].search([ + '&', '&', + ('alias_name', '!=', False), + ('alias_name', 'in', email_to_localparts), + ('alias_model_id.model', '!=', reply_model), + ]) + if other_model_aliases: + is_a_reply = False + rcpt_tos_valid_localparts = [to for to in rcpt_tos_valid_localparts if to in other_model_aliases.mapped('alias_name')] + + if is_a_reply: + dest_aliases = self.env['mail.alias'].search([ + ('alias_name', 'in', rcpt_tos_localparts), + ('alias_model_id.model', '=', reply_model) + ], limit=1) + + user_id = self._mail_find_user_for_gateway(email_from, alias=dest_aliases).id or self._uid + route = self._routing_check_route( + message, message_dict, + (reply_model, reply_thread_id, custom_values, user_id, dest_aliases), + raise_exception=False) + 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, reply_model, reply_thread_id, custom_values, self._uid) + return [route] + elif route is False: + return [] + + # 2. Handle new incoming email by checking aliases and applying their settings + if rcpt_tos_localparts: + # no route found for a matching reference (or reply), so parent is invalid + message_dict.pop('parent_id', None) + + # check it does not directly contact catchall + if catchall_alias and email_to_localparts and all(email_localpart == catchall_alias for email_localpart in email_to_localparts): + _logger.info('Routing mail from %s to %s with Message-Id %s: direct write to catchall, bounce', email_from, email_to, message_id) + body = self.env.ref('mail.mail_bounce_catchall')._render({ + 'message': message, + }, engine='ir.qweb') + self._routing_create_bounce_email(email_from, body, message, references=message_id, reply_to=self.env.company.email) + return [] + + dest_aliases = self.env['mail.alias'].search([('alias_name', 'in', rcpt_tos_valid_localparts)]) + if dest_aliases: + routes = [] + for alias in dest_aliases: + user_id = self._mail_find_user_for_gateway(email_from, alias=alias).id or self._uid + route = (alias.alias_model_id.model, alias.alias_force_thread_id, ast.literal_eval(alias.alias_defaults), user_id, alias) + route = self._routing_check_route(message, message_dict, route, raise_exception=True) + if route: + _logger.info( + 'Routing mail from %s to %s with Message-Id %s: direct alias match: %r', + email_from, email_to, message_id, route) + routes.append(route) + return routes + + # 3. Fallback to the provided parameters, if they work + if fallback_model: + # no route found for a matching reference (or reply), so parent is invalid + message_dict.pop('parent_id', None) + user_id = self._mail_find_user_for_gateway(email_from).id or self._uid + route = self._routing_check_route( + message, message_dict, + (fallback_model, thread_id, custom_values, user_id, None), + raise_exception=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, user_id) + return [route] + + # ValueError if no routes found and if no bounce occured + raise ValueError( + 'No possible route found for incoming message from %s to %s (Message-Id %s:). ' + 'Create an appropriate mail.alias or force the destination model.' % + (email_from, email_to, message_id) + ) + + @api.model + def _message_route_process(self, message, message_dict, routes): + self = self.with_context(attachments_mime_plainxml=True) # import XML attachments as text + # postpone setting message_dict.partner_ids after message_post, to avoid double notifications + original_partner_ids = message_dict.pop('partner_ids', []) + thread_id = False + for model, thread_id, custom_values, user_id, alias in routes or (): + subtype_id = False + related_user = self.env['res.users'].browse(user_id) + Model = self.env[model].with_context(mail_create_nosubscribe=True, mail_create_nolog=True) + if not (thread_id and hasattr(Model, 'message_update') or hasattr(Model, 'message_new')): + raise ValueError( + "Undeliverable mail with Message-Id %s, model %s does not accept incoming emails" % + (message_dict['message_id'], model) + ) + + # disabled subscriptions during message_new/update to avoid having the system user running the + # email gateway become a follower of all inbound messages + ModelCtx = Model.with_user(related_user).sudo() + if thread_id and hasattr(ModelCtx, 'message_update'): + thread = ModelCtx.browse(thread_id) + thread.message_update(message_dict) + else: + # if a new thread is created, parent is irrelevant + message_dict.pop('parent_id', None) + thread = ModelCtx.message_new(message_dict, custom_values) + thread_id = thread.id + subtype_id = thread._creation_subtype().id + + # replies to internal message are considered as notes, but parent message + # author is added in recipients to ensure he is notified of a private answer + parent_message = False + if message_dict.get('parent_id'): + parent_message = self.env['mail.message'].sudo().browse(message_dict['parent_id']) + partner_ids = [] + if not subtype_id: + if message_dict.get('is_internal'): + subtype_id = self.env['ir.model.data'].xmlid_to_res_id('mail.mt_note') + if parent_message and parent_message.author_id: + partner_ids = [parent_message.author_id.id] + else: + subtype_id = self.env['ir.model.data'].xmlid_to_res_id('mail.mt_comment') + + post_params = dict(subtype_id=subtype_id, partner_ids=partner_ids, **message_dict) + # remove computational values not stored on mail.message and avoid warnings when creating it + for x in ('from', 'to', 'cc', 'recipients', 'references', 'in_reply_to', 'bounced_email', 'bounced_message', 'bounced_msg_id', 'bounced_partner'): + post_params.pop(x, None) + new_msg = False + if thread._name == 'mail.thread': # message with parent_id not linked to record + new_msg = thread.message_notify(**post_params) + else: + # parsing should find an author independently of user running mail gateway, and ensure it is not odoobot + partner_from_found = message_dict.get('author_id') and message_dict['author_id'] != self.env['ir.model.data'].xmlid_to_res_id('base.partner_root') + thread = thread.with_context(mail_create_nosubscribe=not partner_from_found) + new_msg = thread.message_post(**post_params) + + if new_msg and original_partner_ids: + # postponed after message_post, because this is an external message and we don't want to create + # duplicate emails due to notifications + new_msg.write({'partner_ids': original_partner_ids}) + return thread_id + + @api.model + def message_process(self, model, message, custom_values=None, + save_original=False, strip_attachments=False, + thread_id=None): + """ Process an incoming RFC2822 email message, relying on + ``mail.message.parse()`` for the parsing operation, + and ``message_route()`` to figure out the target model. + + Once the target model is known, its ``message_new`` method + is called with the new message (if the thread record did not exist) + or its ``message_update`` method (if it did). + + :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) + :param message: source of the RFC2822 message + :type message: string or xmlrpclib.Binary + :type dict custom_values: optional dictionary of 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 bool save_original: whether to keep a copy of the original + email source attached to the message after it is imported. + :param bool strip_attachments: whether to strip all attachments + before processing the message, in order to save some space. + :param int thread_id: optional ID of the record/thread from ``model`` + to which this mail should be attached. When provided, this + overrides the automatic detection based on the message + headers. + """ + # extract message bytes - we are forced to pass the message as binary because + # we don't know its encoding until we parse its headers and hence can't + # convert it to utf-8 for transport between the mailgate script and here. + if isinstance(message, xmlrpclib.Binary): + message = bytes(message.data) + if isinstance(message, str): + message = message.encode('utf-8') + message = email.message_from_bytes(message, policy=email.policy.SMTP) + + # parse the message, verify we are not in a loop by checking message_id is not duplicated + msg_dict = self.message_parse(message, save_original=save_original) + if strip_attachments: + msg_dict.pop('attachments', None) + + existing_msg_ids = self.env['mail.message'].search([('message_id', '=', msg_dict['message_id'])], limit=1) + if existing_msg_ids: + _logger.info('Ignored mail from %s to %s with Message-Id %s: found duplicated Message-Id during processing', + msg_dict.get('email_from'), msg_dict.get('to'), msg_dict.get('message_id')) + return False + + # find possible routes for the message + routes = self.message_route(message, msg_dict, model, thread_id, custom_values) + thread_id = self._message_route_process(message, msg_dict, routes) + return thread_id + + @api.model + def message_new(self, msg_dict, custom_values=None): + """Called by ``message_process`` when a new message is received + for a given thread model, if the message did not belong to + an existing thread. + The default behavior is to create a new record of the corresponding + model (based on some very basic info extracted from the message). + Additional behavior may be implemented by overriding this method. + + :param dict msg_dict: a map containing the email details and + attachments. See ``message_process`` and + ``mail.message.parse`` for details. + :param dict custom_values: optional dictionary of additional + field values to pass to create() + when creating the new thread record. + Be careful, these values may override + any other values coming from the message. + :rtype: int + :return: the id of the newly created thread object + """ + data = {} + if isinstance(custom_values, dict): + data = custom_values.copy() + fields = self.fields_get() + name_field = self._rec_name or 'name' + if name_field in fields and not data.get('name'): + data[name_field] = msg_dict.get('subject', '') + return self.create(data) + + def message_update(self, msg_dict, update_vals=None): + """Called by ``message_process`` when a new message is received + for an existing thread. The default behavior is to update the record + with update_vals taken from the incoming email. + Additional behavior may be implemented by overriding this + method. + :param dict msg_dict: a map containing the email details and + attachments. See ``message_process`` and + ``mail.message.parse()`` for details. + :param dict update_vals: a dict containing values to update records + given their ids; if the dict is None or is + void, no write operation is performed. + """ + if update_vals: + self.write(update_vals) + return True + + def _message_receive_bounce(self, email, partner): + """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 do nothing. This method is meant to be overridden in various + modules to add some specific behavior like blacklist management or mass + mailing statistics update. check is an integer ``message_bounce`` column exists. + If it is the case, its content is incremented. + + :param string email: email that caused the bounce; + :param record partner: partner matching the bounced email address, if any; + """ + pass + + def _message_reset_bounce(self, email): + """Called by ``message_process`` when an email is considered as not being + a bounce. The default behavior is to do nothing. This method is meant to + be overridden in various modules to add some specific behavior like + blacklist management. + + :param string email: email for which to reset bounce information + """ + pass + + def _message_parse_extract_payload_postprocess(self, message, payload_dict): + """ Perform some cleaning / postprocess in the body and attachments + extracted from the email. Note that this processing is specific to the + mail module, and should not contain security or generic html cleaning. + Indeed those aspects should be covered by the html_sanitize method + located in tools. """ + body, attachments = payload_dict['body'], payload_dict['attachments'] + if not body: + return payload_dict + try: + root = lxml.html.fromstring(body) + except ValueError: + # In case the email client sent XHTML, fromstring will fail because 'Unicode strings + # with encoding declaration are not supported'. + root = lxml.html.fromstring(body.encode('utf-8')) + + postprocessed = False + to_remove = [] + for node in root.iter(): + if 'o_mail_notification' in (node.get('class') or '') or 'o_mail_notification' in (node.get('summary') or ''): + postprocessed = True + if node.getparent() is not None: + to_remove.append(node) + if node.tag == 'img' and node.get('src', '').startswith('cid:'): + cid = node.get('src').split(':', 1)[1] + related_attachment = [attach for attach in attachments if attach[2] and attach[2].get('cid') == cid] + if related_attachment: + node.set('data-filename', related_attachment[0][0]) + postprocessed = True + + for node in to_remove: + node.getparent().remove(node) + if postprocessed: + body = etree.tostring(root, pretty_print=False, encoding='unicode') + return {'body': body, 'attachments': attachments} + + def _message_parse_extract_payload(self, message, save_original=False): + """Extract body as HTML and attachments from the mail message""" + attachments = [] + body = u'' + if save_original: + attachments.append(self._Attachment('original_email.eml', message.as_string(), {})) + + # Be careful, content-type may contain tricky content like in the + # following example so test the MIME type with startswith() + # + # Content-Type: multipart/related; + # boundary="_004_3f1e4da175f349248b8d43cdeb9866f1AMSPR06MB343eurprd06pro_"; + # type="text/html" + if message.get_content_maintype() == 'text': + encoding = message.get_content_charset() + body = message.get_content() + body = tools.ustr(body, encoding, errors='replace') + if message.get_content_type() == 'text/plain': + # text/plain ->
+                body = tools.append_content_to_html(u'', body, preserve=True)
+        else:
+            alternative = False
+            mixed = False
+            html = u''
+            for part in message.walk():
+                if part.get_content_type() == 'multipart/alternative':
+                    alternative = True
+                if part.get_content_type() == 'multipart/mixed':
+                    mixed = True
+                if part.get_content_maintype() == 'multipart':
+                    continue  # skip container
+
+                filename = part.get_filename()  # I may not properly handle all charsets
+                encoding = part.get_content_charset()  # None if attachment
+
+                # 0) Inline Attachments -> attachments, with a third part in the tuple to match cid / attachment
+                if filename and part.get('content-id'):
+                    inner_cid = part.get('content-id').strip('><')
+                    attachments.append(self._Attachment(filename, part.get_content(), {'cid': inner_cid}))
+                    continue
+                # 1) Explicit Attachments -> attachments
+                if filename or part.get('content-disposition', '').strip().startswith('attachment'):
+                    attachments.append(self._Attachment(filename or 'attachment', part.get_content(), {}))
+                    continue
+                # 2) text/plain -> 
+                if part.get_content_type() == 'text/plain' and (not alternative or not body):
+                    body = tools.append_content_to_html(body, tools.ustr(part.get_content(),
+                                                                         encoding, errors='replace'), preserve=True)
+                # 3) text/html -> raw
+                elif part.get_content_type() == 'text/html':
+                    # mutlipart/alternative have one text and a html part, keep only the second
+                    # mixed allows several html parts, append html content
+                    append_content = not alternative or (html and mixed)
+                    html = tools.ustr(part.get_content(), encoding, errors='replace')
+                    if not append_content:
+                        body = html
+                    else:
+                        body = tools.append_content_to_html(body, html, plaintext=False)
+                    # we only strip_classes here everything else will be done in by html field of mail.message
+                    body = tools.html_sanitize(body, sanitize_tags=False, strip_classes=True)
+                # 4) Anything else -> attachment
+                else:
+                    attachments.append(self._Attachment(filename or 'attachment', part.get_content(), {}))
+
+        return self._message_parse_extract_payload_postprocess(message, {'body': body, 'attachments': attachments})
+
+    def _message_parse_extract_bounce(self, email_message, message_dict):
+        """ Parse email and extract bounce information to be used in future
+        processing.
+
+        :param email_message: an email.message instance;
+        :param message_dict: dictionary holding already-parsed values;
+
+        :return dict: bounce-related values will be added, containing
+
+          * bounced_email: email that bounced (normalized);
+          * bounce_partner: res.partner recordset whose email_normalized =
+            bounced_email;
+          * bounced_msg_id: list of message_ID references (<...@myserver>) linked
+            to the email that bounced;
+          * bounced_message: if found, mail.message recordset matching bounced_msg_id;
+        """
+        if not isinstance(email_message, EmailMessage):
+            raise TypeError('message must be an email.message.EmailMessage at this point')
+
+        email_part = next((part for part in email_message.walk() if part.get_content_type() in {'message/rfc822', 'text/rfc822-headers'}), None)
+        dsn_part = next((part for part in email_message.walk() if part.get_content_type() == 'message/delivery-status'), None)
+
+        bounced_email = False
+        bounced_partner = self.env['res.partner'].sudo()
+        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')
+            bounced_email = tools.email_normalize(final_recipient_data.split(';', 1)[1].strip())
+            if bounced_email:
+                bounced_partner = self.env['res.partner'].sudo().search([('email_normalized', '=', bounced_email)])
+
+        bounced_msg_id = False
+        bounced_message = self.env['mail.message'].sudo()
+        if email_part:
+            if email_part.get_content_type() == 'text/rfc822-headers':
+                # Convert the message body into a message itself
+                email_payload = message_from_string(email_part.get_payload(), policy=policy.SMTP)
+            else:
+                email_payload = email_part.get_payload()[0]
+            bounced_msg_id = tools.mail_header_msgid_re.findall(tools.decode_message_header(email_payload, 'Message-Id'))
+            if bounced_msg_id:
+                bounced_message = self.env['mail.message'].sudo().search([('message_id', 'in', bounced_msg_id)])
+
+        return {
+            'bounced_email': bounced_email,
+            'bounced_partner': bounced_partner,
+            'bounced_msg_id': bounced_msg_id,
+            'bounced_message': bounced_message,
+        }
+
+    @api.model
+    def message_parse(self, message, save_original=False):
+        """ Parses an email.message.Message representing an RFC-2822 email
+        and returns a generic dict holding the message details.
+
+        :param message: email to parse
+        :type message: email.message.Message
+        :param bool save_original: whether the returned dict should include
+            an ``original`` attachment containing the source of the message
+        :rtype: dict
+        :return: A dict with the following structure, where each field may not
+            be present if missing in original message::
+
+            { 'message_id': msg_id,
+              'subject': subject,
+              'email_from': from,
+              'to': to + delivered-to,
+              'cc': cc,
+              'recipients': delivered-to + to + cc + resent-to + resent-cc,
+              'partner_ids': partners found based on recipients emails,
+              'body': unified_body,
+              'references': references,
+              'in_reply_to': in-reply-to,
+              'parent_id': parent mail.message based on in_reply_to or references,
+              'is_internal': answer to an internal message (note),
+              'date': date,
+              'attachments': [('file1', 'bytes'),
+                              ('file2', 'bytes')}
+            }
+        """
+        if not isinstance(message, EmailMessage):
+            raise ValueError(_('Message should be a valid EmailMessage instance'))
+        msg_dict = {'message_type': 'email'}
+
+        message_id = message.get('Message-Id')
+        if not message_id:
+            # Very unusual situation, be we should be fault-tolerant here
+            message_id = "<%s@localhost>" % time.time()
+            _logger.debug('Parsing Message without message-id, generating a random one: %s', message_id)
+        msg_dict['message_id'] = message_id.strip()
+
+        if message.get('Subject'):
+            msg_dict['subject'] = tools.decode_message_header(message, 'Subject')
+
+        email_from = tools.decode_message_header(message, 'From')
+        email_cc = tools.decode_message_header(message, 'cc')
+        email_from_list = tools.email_split_and_format(email_from)
+        email_cc_list = tools.email_split_and_format(email_cc)
+        msg_dict['email_from'] = email_from_list[0] if email_from_list else email_from
+        msg_dict['from'] = msg_dict['email_from']  # compatibility for message_new
+        msg_dict['cc'] = ','.join(email_cc_list) if email_cc_list else email_cc
+        # 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.
+        msg_dict['recipients'] = ','.join(set(formatted_email
+            for address in [
+                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')
+            ] if address
+            for formatted_email in tools.email_split_and_format(address))
+        )
+        msg_dict['to'] = ','.join(set(formatted_email
+            for address in [
+                tools.decode_message_header(message, 'Delivered-To'),
+                tools.decode_message_header(message, 'To')
+            ] if address
+            for formatted_email in tools.email_split_and_format(address))
+        )
+        partner_ids = [x.id for x in self._mail_find_partner_from_emails(tools.email_split(msg_dict['recipients']), records=self) if x]
+        msg_dict['partner_ids'] = partner_ids
+        # compute references to find if email_message is a reply to an existing thread
+        msg_dict['references'] = tools.decode_message_header(message, 'References')
+        msg_dict['in_reply_to'] = tools.decode_message_header(message, 'In-Reply-To').strip()
+
+        if message.get('Date'):
+            try:
+                date_hdr = tools.decode_message_header(message, '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
+                    # UTC, there's no better choice. Should not happen,
+                    # as RFC2822 requires timezone offset in Date headers.
+                    stored_date = parsed_date.replace(tzinfo=pytz.utc)
+                else:
+                    stored_date = parsed_date.astimezone(tz=pytz.utc)
+            except Exception:
+                _logger.info('Failed to parse Date header %r in incoming mail '
+                             'with message-id %r, assuming current date/time.',
+                             message.get('Date'), message_id)
+                stored_date = datetime.datetime.now()
+            msg_dict['date'] = stored_date.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT)
+
+        parent_ids = False
+        if msg_dict['in_reply_to']:
+            parent_ids = self.env['mail.message'].search([('message_id', '=', msg_dict['in_reply_to'])], limit=1)
+        if msg_dict['references'] and not parent_ids:
+            references_msg_id_list = tools.mail_header_msgid_re.findall(msg_dict['references'])
+            parent_ids = self.env['mail.message'].search([('message_id', 'in', [x.strip() for x in references_msg_id_list])], limit=1)
+        if parent_ids:
+            msg_dict['parent_id'] = parent_ids.id
+            msg_dict['is_internal'] = parent_ids.subtype_id and parent_ids.subtype_id.internal or False
+
+        msg_dict.update(self._message_parse_extract_payload(message, save_original=save_original))
+        msg_dict.update(self._message_parse_extract_bounce(message, msg_dict))
+        return msg_dict
+
+    # ------------------------------------------------------
+    # RECIPIENTS MANAGEMENT TOOLS
+    # ------------------------------------------------------
+
+    @api.model
+    def _message_get_default_recipients_on_records(self, records):
+        """ Moved to ``BaseModel._message_get_default_recipients()`` """
+        return records._message_get_default_recipients()
+
+    def _message_add_suggested_recipient(self, result, partner=None, email=None, reason=''):
+        """ Called by _message_get_suggested_recipients, to add a suggested
+            recipient in the result dictionary. The form is :
+                partner_id, partner_name or partner_name, reason """
+        self.ensure_one()
+        if email and not partner:
+            # get partner info from email
+            partner_info = self._message_partner_info_from_emails([email])[0]
+            if partner_info.get('partner_id'):
+                partner = self.env['res.partner'].sudo().browse([partner_info['partner_id']])[0]
+        if email and email in [val[1] for val in result[self.ids[0]]]:  # already existing email -> skip
+            return result
+        if partner and partner in self.message_partner_ids:  # recipient already in the followers -> skip
+            return result
+        if partner and partner.id in [val[0] for val in result[self.ids[0]]]:  # already existing partner ID -> skip
+            return result
+        if partner and partner.email:  # complete profile: id, name 
+            result[self.ids[0]].append((partner.id, partner.email_formatted, reason))
+        elif partner:  # incomplete profile: id, name
+            result[self.ids[0]].append((partner.id, '%s' % (partner.name), reason))
+        else:  # unknown partner, we are probably managing an email address
+            result[self.ids[0]].append((False, email, reason))
+        return result
+
+    def _message_get_suggested_recipients(self):
+        """ Returns suggested recipients for ids. Those are a list of
+        tuple (partner_id, partner_name, reason), to be managed by Chatter. """
+        result = dict((res_id, []) for res_id in self.ids)
+        if 'user_id' in self._fields:
+            for obj in self.sudo():  # SUPERUSER because of a read on res.users that would crash otherwise
+                if not obj.user_id or not obj.user_id.partner_id:
+                    continue
+                obj._message_add_suggested_recipient(result, partner=obj.user_id.partner_id, reason=self._fields['user_id'].string)
+        return result
+
+    def _mail_search_on_user(self, normalized_emails, extra_domain=False):
+        """ Find partners linked to users, given an email address that will
+        be normalized. Search is done as sudo on res.users model to avoid domain
+        on partner like ('user_ids', '!=', False) that would not be efficient. """
+        domain = [('email_normalized', 'in', normalized_emails)]
+        if extra_domain:
+            domain = expression.AND([domain, extra_domain])
+        partners = self.env['res.users'].sudo().search(domain, order='name ASC').mapped('partner_id')
+        # return a search on partner to filter results current user should not see (multi company for example)
+        return self.env['res.partner'].search([('id', 'in', partners.ids)])
+
+    def _mail_search_on_partner(self, normalized_emails, extra_domain=False):
+        domain = [('email_normalized', 'in', normalized_emails)]
+        if extra_domain:
+            domain = expression.AND([domain, extra_domain])
+        return self.env['res.partner'].search(domain)
+
+    def _mail_find_user_for_gateway(self, email, alias=None):
+        """ Utility method to find user from email address that can create documents
+        in the target model. Purpose is to link document creation to users whenever
+        possible, for example when creating document through mailgateway.
+
+        Heuristic
+
+          * alias owner record: fetch in its followers for user with matching email;
+          * find any user with matching emails;
+          * try alias owner as fallback;
+
+        Note that standard search order is applied.
+
+        :param str email: will be sanitized and parsed to find email;
+        :param mail.alias alias: optional alias. Used to fetch owner followers
+          or fallback user (alias owner);
+        :param fallback_model: if not alias, related model to check access rights;
+
+        :return res.user user: user matching email or void recordset if none found
+        """
+        # find normalized emails and exclude aliases (to avoid subscribing alias emails to records)
+        normalized_email = tools.email_normalize(email)
+        catchall_domain = self.env['ir.config_parameter'].sudo().get_param("mail.catchall.domain")
+        if normalized_email and catchall_domain:
+            left_part = normalized_email.split('@')[0] if normalized_email.split('@')[1] == catchall_domain.lower() else False
+            if left_part:
+                if self.env['mail.alias'].sudo().search_count([('alias_name', '=', left_part)]):
+                    return self.env['res.users']
+
+        if alias and alias.alias_parent_model_id and alias.alias_parent_thread_id:
+            followers = self.env['mail.followers'].search([
+                ('res_model', '=', alias.alias_parent_model_id.model),
+                ('res_id', '=', alias.alias_parent_thread_id)]
+            ).mapped('partner_id')
+        else:
+            followers = self.env['res.partner']
+
+        follower_users = self.env['res.users'].search([
+            ('partner_id', 'in', followers.ids), ('email_normalized', '=', normalized_email)
+        ], limit=1) if followers else self.env['res.users']
+        matching_user = follower_users[0] if follower_users else self.env['res.users']
+        if matching_user:
+            return matching_user
+
+        if not matching_user:
+            std_users = self.env['res.users'].sudo().search([('email_normalized', '=', normalized_email)], limit=1, order='name ASC')
+            matching_user = std_users[0] if std_users else self.env['res.users']
+        if matching_user:
+            return matching_user
+
+        if not matching_user and alias and alias.alias_user_id:
+            matching_user = alias and alias.alias_user_id
+        if matching_user:
+            return matching_user
+
+        return matching_user
+
+    @api.model
+    def _mail_find_partner_from_emails(self, emails, records=None, force_create=False):
+        """ Utility method to find partners from email addresses. If no partner is
+        found, create new partners if force_create is enabled. Search heuristics
+
+          * 1: check in records (record set) followers if records is mail.thread
+               enabled and if check_followers parameter is enabled;
+          * 2: search for partners with user;
+          * 3: search for partners;
+
+        :param records: record set on which to check followers;
+        :param list emails: list of email addresses for finding partner;
+        :param boolean force_create: create a new partner if not found
+
+        :return list partners: a list of partner records ordered as given emails.
+          If no partner has been found and/or created for a given emails its
+          matching partner is an empty record.
+        """
+        if records and issubclass(type(records), self.pool['mail.thread']):
+            followers = records.mapped('message_partner_ids')
+        else:
+            followers = self.env['res.partner']
+        catchall_domain = self.env['ir.config_parameter'].sudo().get_param("mail.catchall.domain")
+
+        # first, build a normalized email list and remove those linked to aliases to avoid adding aliases as partners
+        normalized_emails = [tools.email_normalize(contact) for contact in emails if tools.email_normalize(contact)]
+        if catchall_domain:
+            domain_left_parts = [email.split('@')[0] for email in normalized_emails if email and email.split('@')[1] == catchall_domain.lower()]
+            if domain_left_parts:
+                found_alias_names = self.env['mail.alias'].sudo().search([('alias_name', 'in', domain_left_parts)]).mapped('alias_name')
+                normalized_emails = [email for email in normalized_emails if email.split('@')[0] not in found_alias_names]
+
+        done_partners = [follower for follower in followers if follower.email_normalized in normalized_emails]
+        remaining = [email for email in normalized_emails if email not in [partner.email_normalized for partner in done_partners]]
+
+        user_partners = self._mail_search_on_user(remaining)
+        done_partners += [user_partner for user_partner in user_partners]
+        remaining = [email for email in normalized_emails if email not in [partner.email_normalized for partner in done_partners]]
+
+        partners = self._mail_search_on_partner(remaining)
+        done_partners += [partner for partner in partners]
+        remaining = [email for email in normalized_emails if email not in [partner.email_normalized for partner in done_partners]]
+
+        # iterate and keep ordering
+        partners = []
+        for contact in emails:
+            normalized_email = tools.email_normalize(contact)
+            partner = next((partner for partner in done_partners if partner.email_normalized == normalized_email), self.env['res.partner'])
+            if not partner and force_create and normalized_email in normalized_emails:
+                partner = self.env['res.partner'].browse(self.env['res.partner'].name_create(contact)[0])
+            partners.append(partner)
+        return partners
+
+    def _message_partner_info_from_emails(self, emails, link_mail=False):
+        """ Convert a list of emails into a list partner_ids and a list
+            new_partner_ids. The return value is non conventional because
+            it is meant to be used by the mail widget.
+
+            :return dict: partner_ids and new_partner_ids """
+        self.ensure_one()
+        MailMessage = self.env['mail.message'].sudo()
+        partners = self._mail_find_partner_from_emails(emails, records=self)
+        result = list()
+        for idx, contact in enumerate(emails):
+            partner = partners[idx]
+            partner_info = {'full_name': partner.email_formatted if partner else contact, 'partner_id': partner.id}
+            result.append(partner_info)
+            # link mail with this from mail to the new partner id
+            if link_mail and partner:
+                MailMessage.search([
+                    ('email_from', '=ilike', partner.email_normalized),
+                    ('author_id', '=', False)
+                ]).write({'author_id': partner.id})
+        return result
+
+    # ------------------------------------------------------
+    # MESSAGE POST API
+    # ------------------------------------------------------
+
+    def _message_post_process_attachments(self, attachments, attachment_ids, message_values):
+        """ Preprocess attachments for mail_thread.message_post() or mail_mail.create().
+
+        :param list attachments: list of attachment tuples in the form ``(name,content)``, #todo xdo update that
+                                 where content is NOT base64 encoded
+        :param list attachment_ids: a list of attachment ids, not in tomany command form
+        :param dict message_data: model: the model of the attachments parent record,
+          res_id: the id of the attachments parent record
+        """
+        return_values = {}
+        body = message_values.get('body')
+        model = message_values['model']
+        res_id = message_values['res_id']
+
+        m2m_attachment_ids = []
+        if attachment_ids:
+            # taking advantage of cache looks better in this case, to check
+            filtered_attachment_ids = self.env['ir.attachment'].sudo().browse(attachment_ids).filtered(
+                lambda a: a.res_model == 'mail.compose.message' and a.create_uid.id == self._uid)
+            # update filtered (pending) attachments to link them to the proper record
+            if filtered_attachment_ids:
+                filtered_attachment_ids.write({'res_model': model, 'res_id': res_id})
+            # prevent public and portal users from using attachments that are not theirs
+            if not self.env.user.has_group('base.group_user'):
+                attachment_ids = filtered_attachment_ids.ids
+
+            m2m_attachment_ids += [(4, id) for id in attachment_ids]
+        # Handle attachments parameter, that is a dictionary of attachments
+
+        if attachments: # generate
+            cids_in_body = set()
+            names_in_body = set()
+            cid_list = []
+            name_list = []
+
+            if body:
+                root = lxml.html.fromstring(tools.ustr(body))
+                # first list all attachments that will be needed in body
+                for node in root.iter('img'):
+                    if node.get('src', '').startswith('cid:'):
+                        cids_in_body.add(node.get('src').split('cid:')[1])
+                    elif node.get('data-filename'):
+                        names_in_body.add(node.get('data-filename'))
+            attachement_values_list = []
+
+            # generate values
+            for attachment in attachments:
+                cid = False
+                if len(attachment) == 2:
+                    name, content = attachment
+                elif len(attachment) == 3:
+                    name, content, info = attachment
+                    cid = info and info.get('cid')
+                else:
+                    continue
+                if isinstance(content, str):
+                    content = content.encode('utf-8')
+                elif isinstance(content, EmailMessage):
+                    content = content.as_bytes()
+                elif content is None:
+                    continue
+                attachement_values= {
+                    'name': name,
+                    'datas': base64.b64encode(content),
+                    'type': 'binary',
+                    'description': name,
+                    'res_model': model,
+                    'res_id': res_id,
+                }
+                if body and (cid and cid in cids_in_body or name in names_in_body):
+                    attachement_values['access_token'] = self.env['ir.attachment']._generate_access_token()
+                attachement_values_list.append(attachement_values)
+                # keep cid and name list synced with attachement_values_list length to match ids latter
+                cid_list.append(cid)
+                name_list.append(name)
+            new_attachments = self.env['ir.attachment'].create(attachement_values_list)
+            cid_mapping = {}
+            name_mapping = {}
+            for counter, new_attachment in enumerate(new_attachments):
+                cid = cid_list[counter]
+                if 'access_token' in attachement_values_list[counter]:
+                    if cid:
+                        cid_mapping[cid] = (new_attachment.id, attachement_values_list[counter]['access_token'])
+                    name = name_list[counter]
+                    name_mapping[name] = (new_attachment.id, attachement_values_list[counter]['access_token'])
+                m2m_attachment_ids.append((4, new_attachment.id))
+
+            # note: right know we are only taking attachments and ignoring attachment_ids.
+            if (cid_mapping or name_mapping) and body:
+                postprocessed = False
+                for node in root.iter('img'):
+                    attachment_data = False
+                    if node.get('src', '').startswith('cid:'):
+                        cid = node.get('src').split('cid:')[1]
+                        attachment_data = cid_mapping.get(cid)
+                    if not attachment_data and node.get('data-filename'):
+                        attachment_data = name_mapping.get(node.get('data-filename'), False)
+                    if attachment_data:
+                        node.set('src', '/web/image/%s?access_token=%s' % attachment_data)
+                        postprocessed = True
+                if postprocessed:
+                    return_values['body'] = lxml.html.tostring(root, pretty_print=False, encoding='UTF-8')
+        return_values['attachment_ids'] = m2m_attachment_ids
+        return return_values
+
+    @api.returns('mail.message', lambda value: value.id)
+    def message_post(self, *,
+                     body='', subject=None, message_type='notification',
+                     email_from=None, author_id=None, parent_id=False,
+                     subtype_xmlid=None, subtype_id=False, partner_ids=None, channel_ids=None,
+                     attachments=None, attachment_ids=None,
+                     add_sign=True, record_name=False,
+                     **kwargs):
+        """ Post a new message in an existing thread, returning the new
+            mail.message ID.
+            :param str body: body of the message, usually raw HTML that will
+                be sanitized
+            :param str subject: subject of the message
+            :param str message_type: see mail_message.message_type field. Can be anything but
+                user_notification, reserved for message_notify
+            :param int parent_id: handle thread formation
+            :param int subtype_id: subtype_id of the message, mainly use fore
+                followers mechanism
+            :param list(int) partner_ids: partner_ids to notify
+            :param list(int) channel_ids: channel_ids to notify
+            :param list(tuple(str,str), tuple(str,str, dict) or int) attachments : list of attachment tuples in the form
+                ``(name,content)`` or ``(name,content, info)``, where content is NOT base64 encoded
+            :param list id attachment_ids: list of existing attachement to link to this message
+                -Should only be setted by chatter
+                -Attachement object attached to mail.compose.message(0) will be attached
+                    to the related document.
+            Extra keyword arguments will be used as default column values for the
+            new mail.message record.
+            :return int: ID of newly created mail.message
+        """
+        self.ensure_one()  # should always be posted on a record, use message_notify if no record
+        # split message additional values from notify additional values
+        msg_kwargs = dict((key, val) for key, val in kwargs.items() if key in self.env['mail.message']._fields)
+        notif_kwargs = dict((key, val) for key, val in kwargs.items() if key not in msg_kwargs)
+
+        if self._name == 'mail.thread' or not self.id or message_type == 'user_notification':
+            raise ValueError('message_post should only be call to post message on record. Use message_notify instead')
+
+        if 'model' in msg_kwargs or 'res_id' in msg_kwargs:
+            raise ValueError("message_post doesn't support model and res_id parameters anymore. Please call message_post on record.")
+        if 'subtype' in kwargs:
+            raise ValueError("message_post doesn't support subtype parameter anymore. Please give a valid subtype_id or subtype_xmlid value instead.")
+
+        self = self._fallback_lang() # add lang to context imediatly since it will be usefull in various flows latter.
+
+        # Explicit access rights check, because display_name is computed as sudo.
+        self.check_access_rights('read')
+        self.check_access_rule('read')
+        record_name = record_name or self.display_name
+
+        partner_ids = set(partner_ids or [])
+        channel_ids = set(channel_ids or [])
+
+        if any(not isinstance(pc_id, int) for pc_id in partner_ids | channel_ids):
+            raise ValueError('message_post partner_ids and channel_ids must be integer list, not commands')
+
+        # Find the message's author
+        author_id, email_from = self._message_compute_author(author_id, email_from, raise_exception=True)
+
+        if subtype_xmlid:
+            subtype_id = self.env['ir.model.data'].xmlid_to_res_id(subtype_xmlid)
+        if not subtype_id:
+            subtype_id = self.env['ir.model.data'].xmlid_to_res_id('mail.mt_note')
+
+        # automatically subscribe recipients if asked to
+        if self._context.get('mail_post_autofollow') and partner_ids:
+            self.message_subscribe(list(partner_ids))
+
+        MailMessage_sudo = self.env['mail.message'].sudo()
+        if self._mail_flat_thread and not parent_id:
+            parent_message = MailMessage_sudo.search([('res_id', '=', self.id), ('model', '=', self._name), ('message_type', '!=', 'user_notification')], order="id ASC", limit=1)
+            # parent_message searched in sudo for performance, only used for id.
+            # Note that with sudo we will match message with internal subtypes.
+            parent_id = parent_message.id if parent_message else False
+        elif parent_id:
+            old_parent_id = parent_id
+            parent_message = MailMessage_sudo.search([('id', '=', parent_id), ('parent_id', '!=', False)], limit=1)
+            # avoid loops when finding ancestors
+            processed_list = []
+            if parent_message:
+                new_parent_id = parent_message.parent_id and parent_message.parent_id.id
+                while (new_parent_id and new_parent_id not in processed_list):
+                    processed_list.append(new_parent_id)
+                    parent_message = parent_message.parent_id
+                parent_id = parent_message.id
+
+        values = dict(msg_kwargs)
+        values.update({
+            'author_id': author_id,
+            'email_from': email_from,
+            'model': self._name,
+            'res_id': self.id,
+            'body': body,
+            'subject': subject or False,
+            'message_type': message_type,
+            'parent_id': parent_id,
+            'subtype_id': subtype_id,
+            'partner_ids': partner_ids,
+            'channel_ids': channel_ids,
+            'add_sign': add_sign,
+            'record_name': record_name,
+        })
+        attachments = attachments or []
+        attachment_ids = attachment_ids or []
+        attachement_values = self._message_post_process_attachments(attachments, attachment_ids, values)
+        values.update(attachement_values)  # attachement_ids, [body]
+
+        new_message = self._message_create(values)
+
+        # Set main attachment field if necessary
+        self._message_set_main_attachment_id(values['attachment_ids'])
+
+        if values['author_id'] and values['message_type'] != 'notification' and not self._context.get('mail_create_nosubscribe'):
+            if self.env['res.partner'].browse(values['author_id']).active:  # we dont want to add odoobot/inactive as a follower
+                self._message_subscribe([values['author_id']])
+
+        self._message_post_after_hook(new_message, values)
+        self._notify_thread(new_message, values, **notif_kwargs)
+        return new_message
+
+    def _message_set_main_attachment_id(self, attachment_ids):  # todo move this out of mail.thread
+        if not self._abstract and attachment_ids and not self.message_main_attachment_id:
+            all_attachments = self.env['ir.attachment'].browse([attachment_tuple[1] for attachment_tuple in attachment_ids])
+            prioritary_attachments = all_attachments.filtered(lambda x: x.mimetype.endswith('pdf')) \
+                                     or all_attachments.filtered(lambda x: x.mimetype.startswith('image')) \
+                                     or all_attachments
+            self.sudo().with_context(tracking_disable=True).write({'message_main_attachment_id': prioritary_attachments[0].id})
+
+    def _message_post_after_hook(self, message, msg_vals):
+        """ Hook to add custom behavior after having posted the message. Both
+        message and computed value are given, to try to lessen query count by
+        using already-computed values instead of having to rebrowse things. """
+        pass
+
+    # ------------------------------------------------------
+    # MESSAGE POST TOOLS
+    # ------------------------------------------------------
+
+    def message_post_with_view(self, views_or_xmlid, **kwargs):
+        """ Helper method to send a mail / post a message using a view_id to
+        render using the ir.qweb engine. This method is stand alone, because
+        there is nothing in template and composer that allows to handle
+        views in batch. This method should probably disappear when templates
+        handle ir ui views. """
+        values = kwargs.pop('values', None) or dict()
+        try:
+            from odoo.addons.http_routing.models.ir_http import slug
+            values['slug'] = slug
+        except ImportError:
+            values['slug'] = lambda self: self.id
+        if isinstance(views_or_xmlid, str):
+            views = self.env.ref(views_or_xmlid, raise_if_not_found=False)
+        else:
+            views = views_or_xmlid
+        if not views:
+            return
+        for record in self:
+            values['object'] = record
+            rendered_template = views._render(values, engine='ir.qweb', minimal_qcontext=True)
+            kwargs['body'] = rendered_template
+            record.message_post_with_template(False, **kwargs)
+
+    def message_post_with_template(self, template_id, email_layout_xmlid=None, auto_commit=False, **kwargs):
+        """ Helper method to send a mail with a template
+            :param template_id : the id of the template to render to create the body of the message
+            :param **kwargs : parameter to create a mail.compose.message woaerd (which inherit from mail.message)
+        """
+        # Get composition mode, or force it according to the number of record in self
+        if not kwargs.get('composition_mode'):
+            kwargs['composition_mode'] = 'comment' if len(self.ids) == 1 else 'mass_mail'
+        if not kwargs.get('message_type'):
+            kwargs['message_type'] = 'notification'
+        res_id = kwargs.get('res_id', self.ids and self.ids[0] or 0)
+        res_ids = kwargs.get('res_id') and [kwargs['res_id']] or self.ids
+
+        # Create the composer
+        composer = self.env['mail.compose.message'].with_context(
+            active_id=res_id,
+            active_ids=res_ids,
+            active_model=kwargs.get('model', self._name),
+            default_composition_mode=kwargs['composition_mode'],
+            default_model=kwargs.get('model', self._name),
+            default_res_id=res_id,
+            default_template_id=template_id,
+            custom_layout=email_layout_xmlid,
+        ).create(kwargs)
+        # Simulate the onchange (like trigger in form the view) only
+        # when having a template in single-email mode
+        if template_id:
+            update_values = composer.onchange_template_id(template_id, kwargs['composition_mode'], self._name, res_id)['value']
+            composer.write(update_values)
+        return composer.send_mail(auto_commit=auto_commit)
+
+    def message_notify(self, *,
+                       partner_ids=False, parent_id=False, model=False, res_id=False,
+                       author_id=None, email_from=None, body='', subject=False, **kwargs):
+        """ Shortcut allowing to notify partners of messages that shouldn't be
+        displayed on a document. It pushes notifications on inbox or by email depending
+        on the user configuration, like other notifications. """
+        if self:
+            self.ensure_one()
+        # split message additional values from notify additional values
+        msg_kwargs = dict((key, val) for key, val in kwargs.items() if key in self.env['mail.message']._fields)
+        notif_kwargs = dict((key, val) for key, val in kwargs.items() if key not in msg_kwargs)
+
+        author_id, email_from = self._message_compute_author(author_id, email_from, raise_exception=True)
+
+        if not partner_ids:
+            _logger.warning('Message notify called without recipient_ids, skipping')
+            return self.env['mail.message']
+
+        if not (model and res_id):  # both value should be set or none should be set (record)
+            model = False
+            res_id = False
+
+        MailThread = self.env['mail.thread']
+        values = {
+            'parent_id': parent_id,
+            'model': self._name if self else False,
+            'res_id': self.id if self else False,
+            'message_type': 'user_notification',
+            'subject': subject,
+            'body': body,
+            'author_id': author_id,
+            'email_from': email_from,
+            'partner_ids': partner_ids,
+            'subtype_id': self.env['ir.model.data'].xmlid_to_res_id('mail.mt_note'),
+            'is_internal': True,
+            'record_name': False,
+            'reply_to': MailThread._notify_get_reply_to(default=email_from, records=None)[False],
+            'message_id': tools.generate_tracking_message_id('message-notify'),
+        }
+        values.update(msg_kwargs)
+        new_message = MailThread._message_create(values)
+        MailThread._notify_thread(new_message, values, **notif_kwargs)
+        return new_message
+
+    def _message_log(self, *, body='', author_id=None, email_from=None, subject=False, message_type='notification', **kwargs):
+        """ Shortcut allowing to post note on a document. It does not perform
+        any notification and pre-computes some values to have a short code
+        as optimized as possible. This method is private as it does not check
+        access rights and perform the message creation as sudo to speedup
+        the log process. This method should be called within methods where
+        access rights are already granted to avoid privilege escalation. """
+        self.ensure_one()
+        author_id, email_from = self._message_compute_author(author_id, email_from, raise_exception=False)
+
+        message_values = {
+            'subject': subject,
+            'body': body,
+            'author_id': author_id,
+            'email_from': email_from,
+            'message_type': message_type,
+            'model': kwargs.get('model', self._name),
+            'res_id': self.ids[0] if self.ids else False,
+            'subtype_id': self.env['ir.model.data'].xmlid_to_res_id('mail.mt_note'),
+            'is_internal': True,
+            'record_name': False,
+            'reply_to': self.env['mail.thread']._notify_get_reply_to(default=email_from, records=None)[False],
+            'message_id': tools.generate_tracking_message_id('message-notify'),  # why? this is all but a notify
+        }
+        message_values.update(kwargs)
+        return self.sudo()._message_create(message_values)
+
+    def _message_log_batch(self, bodies, author_id=None, email_from=None, subject=False, message_type='notification'):
+        """ Shortcut allowing to post notes on a batch of documents. It achieve the
+        same purpose as _message_log, done in batch to speedup quick note log.
+
+          :param bodies: dict {record_id: body}
+        """
+        author_id, email_from = self._message_compute_author(author_id, email_from, raise_exception=False)
+
+        base_message_values = {
+            'subject': subject,
+            'author_id': author_id,
+            'email_from': email_from,
+            'message_type': message_type,
+            'model': self._name,
+            'subtype_id': self.env['ir.model.data'].xmlid_to_res_id('mail.mt_note'),
+            'is_internal': True,
+            'record_name': False,
+            'reply_to': self.env['mail.thread']._notify_get_reply_to(default=email_from, records=None)[False],
+            'message_id': tools.generate_tracking_message_id('message-notify'),  # why? this is all but a notify
+        }
+        values_list = [dict(base_message_values,
+                            res_id=record.id,
+                            body=bodies.get(record.id, ''))
+                       for record in self]
+        return self.sudo()._message_create(values_list)
+
+    def _message_compute_author(self, author_id=None, email_from=None, raise_exception=True):
+        """ Tool method computing author information for messages. Purpose is
+        to ensure maximum coherence between author / current user / email_from
+        when sending emails. """
+        if author_id is None:
+            if email_from:
+                author = self._mail_find_partner_from_emails([email_from])[0]
+            else:
+                author = self.env.user.partner_id
+                email_from = author.email_formatted
+            author_id = author.id
+
+        if email_from is None:
+            if author_id:
+                author = self.env['res.partner'].browse(author_id)
+                email_from = author.email_formatted
+
+        # superuser mode without author email -> probably public user; anyway we don't want to crash
+        if not email_from and not self.env.su and raise_exception:
+            raise exceptions.UserError(_("Unable to log message, please configure the sender's email address."))
+
+        return author_id, email_from
+
+    def _message_create(self, values_list):
+        if not isinstance(values_list, (list)):
+            values_list = [values_list]
+        create_values_list = []
+        for values in values_list:
+            create_values = dict(values)
+            # Avoid warnings about non-existing fields
+            for x in ('from', 'to', 'cc', 'canned_response_ids'):
+                create_values.pop(x, None)
+            create_values['partner_ids'] = [(4, pid) for pid in create_values.get('partner_ids', [])]
+            create_values['channel_ids'] = [(4, cid) for cid in create_values.get('channel_ids', [])]
+            create_values_list.append(create_values)
+        if 'default_child_ids' in self._context:
+            ctx = {key: val for key, val in self._context.items() if key != 'default_child_ids'}
+            self = self.with_context(ctx)
+        return self.env['mail.message'].create(create_values_list)
+
+    # ------------------------------------------------------
+    # NOTIFICATION API
+    # ------------------------------------------------------
+
+    def _notify_thread(self, message, msg_vals=False, notify_by_email=True, **kwargs):
+        """ Main notification method. This method basically does two things
+
+         * call ``_notify_compute_recipients`` that computes recipients to
+           notify based on message record or message creation values if given
+           (to optimize performance if we already have data computed);
+         * performs the notification process by calling the various notification
+           methods implemented;
+
+        This method cnn be overridden to intercept and postpone notification
+        mechanism like mail.channel moderation.
+
+        :param message: mail.message record to notify;
+        :param msg_vals: dictionary of values used to create the message. If given
+          it is used instead of accessing ``self`` to lessen query count in some
+          simple cases where no notification is actually required;
+
+        Kwargs allow to pass various parameters that are given to sub notification
+        methods. See those methods for more details about the additional parameters.
+        Parameters used for email-style notifications
+        """
+        msg_vals = msg_vals if msg_vals else {}
+        rdata = self._notify_compute_recipients(message, msg_vals)
+        if not rdata:
+            return False
+
+        message_values = {}
+        if rdata['channels']:
+            message_values['channel_ids'] = [(6, 0, [r['id'] for r in rdata['channels']])]
+
+        self._notify_record_by_inbox(message, rdata, msg_vals=msg_vals, **kwargs)
+        if notify_by_email:
+            self._notify_record_by_email(message, rdata, msg_vals=msg_vals, **kwargs)
+
+        return rdata
+
+    def _notify_record_by_inbox(self, message, recipients_data, msg_vals=False, **kwargs):
+        """ Notification method: inbox. Do two main things
+
+          * create an inbox notification for users;
+          * create channel / message link (channel_ids field of mail.message);
+          * send bus notifications;
+
+        TDE/XDO TODO: flag rdata directly, with for example r['notif'] = 'ocn_client' and r['needaction']=False
+        and correctly override notify_recipients
+        """
+        channel_ids = [r['id'] for r in recipients_data['channels']]
+        if channel_ids:
+            message.write({'channel_ids': [(6, 0, channel_ids)]})
+
+        inbox_pids = [r['id'] for r in recipients_data['partners'] if r['notif'] in('inbox','both')]
+        if inbox_pids:
+            notif_create_values = [{
+                'mail_message_id': message.id,
+                'res_partner_id': pid,
+                'notification_type': 'inbox',
+                'notification_status': 'sent',
+            } for pid in inbox_pids]
+            self.env['mail.notification'].sudo().create(notif_create_values)
+
+        bus_notifications = []
+        if inbox_pids or channel_ids:
+            message_format_values = False
+            if inbox_pids:
+                message_format_values = message.message_format()[0]
+                for partner_id in inbox_pids:
+                    bus_notifications.append([(self._cr.dbname, 'ir.needaction', partner_id), dict(message_format_values)])
+            if channel_ids:
+                channels = self.env['mail.channel'].sudo().browse(channel_ids)
+                bus_notifications += channels._channel_message_notifications(message, message_format_values)
+
+        if bus_notifications:
+            self.env['bus.bus'].sudo().sendmany(bus_notifications)
+
+    def _notify_record_by_email(self, message, recipients_data, msg_vals=False,
+                                model_description=False, mail_auto_delete=True, check_existing=False,
+                                force_send=True, send_after_commit=True,
+                                **kwargs):
+        """ Method to send email linked to notified messages.
+
+        :param message: mail.message record to notify;
+        :param recipients_data: see ``_notify_thread``;
+        :param msg_vals: see ``_notify_thread``;
+
+        :param model_description: model description used in email notification process
+          (computed if not given);
+        :param mail_auto_delete: delete notification emails once sent;
+        :param check_existing: check for existing notifications to update based on
+          mailed recipient, otherwise create new notifications;
+
+        :param force_send: send emails directly instead of using queue;
+        :param send_after_commit: if force_send, tells whether to send emails after
+          the transaction has been committed using a post-commit hook;
+        """
+        partners_data = [r for r in recipients_data['partners'] if r['notif'] in ('email','both')]
+        if not partners_data:
+            return True
+
+        model = msg_vals.get('model') if msg_vals else message.model
+        model_name = model_description or (self._fallback_lang().env['ir.model']._get(model).display_name if model else False) # one query for display name
+        recipients_groups_data = self._notify_classify_recipients(partners_data, model_name, msg_vals=msg_vals)
+
+        if not recipients_groups_data:
+            return True
+        force_send = self.env.context.get('mail_notify_force_send', force_send)
+
+        template_values = self._notify_prepare_template_context(message, msg_vals, model_description=model_description) # 10 queries
+
+        email_layout_xmlid = msg_vals.get('email_layout_xmlid') if msg_vals else message.email_layout_xmlid
+        template_xmlid = email_layout_xmlid if email_layout_xmlid else 'mail.message_notification_email'
+        try:
+            base_template = self.env.ref(template_xmlid, raise_if_not_found=True).with_context(lang=template_values['lang']) # 1 query
+        except ValueError:
+            _logger.warning('QWeb template %s not found when sending notification emails. Sending without layouting.' % (template_xmlid))
+            base_template = False
+
+        mail_subject = message.subject or (message.record_name and 'Re: %s' % message.record_name) # in cache, no queries
+        # prepare notification mail values
+        base_mail_values = {
+            'mail_message_id': message.id,
+            'mail_server_id': message.mail_server_id.id, # 2 query, check acces + read, may be useless, Falsy, when will it be used?
+            'auto_delete': mail_auto_delete,
+            # due to ir.rule, user have no right to access parent message if message is not published
+            'references': message.parent_id.sudo().message_id if message.parent_id else False,
+            'subject': mail_subject,
+        }
+        base_mail_values = self._notify_by_email_add_values(base_mail_values)
+
+        # Clean the context to get rid of residual default_* keys that could cause issues during
+        # the mail.mail creation.
+        # Example: 'default_state' would refer to the default state of a previously created record
+        # from another model that in turns triggers an assignation notification that ends up here.
+        # This will lead to a traceback when trying to create a mail.mail with this state value that
+        # doesn't exist.
+        SafeMail = self.env['mail.mail'].sudo().with_context(clean_context(self._context))
+        SafeNotification = self.env['mail.notification'].sudo().with_context(clean_context(self._context))
+        emails = self.env['mail.mail'].sudo()
+
+        # loop on groups (customer, portal, user,  ... + model specific like group_sale_salesman)
+        notif_create_values = []
+        recipients_max = 50
+        for recipients_group_data in recipients_groups_data:
+            # generate notification email content
+            recipients_ids = recipients_group_data.pop('recipients')
+            render_values = {**template_values, **recipients_group_data}
+            # {company, is_discussion, lang, message, model_description, record, record_name, signature, subtype, tracking_values, website_url}
+            # {actions, button_access, has_button_access, recipients}
+
+            if base_template:
+                mail_body = base_template._render(render_values, engine='ir.qweb', minimal_qcontext=True)
+            else:
+                mail_body = message.body
+            mail_body = self.env['mail.render.mixin']._replace_local_links(mail_body)
+
+            # create email
+            for recipients_ids_chunk in split_every(recipients_max, recipients_ids):
+                recipient_values = self._notify_email_recipient_values(recipients_ids_chunk)
+                email_to = recipient_values['email_to']
+                recipient_ids = recipient_values['recipient_ids']
+
+                create_values = {
+                    'body_html': mail_body,
+                    'subject': mail_subject,
+                    'recipient_ids': [(4, pid) for pid in recipient_ids],
+                }
+                if email_to:
+                    create_values['email_to'] = email_to
+                create_values.update(base_mail_values)  # mail_message_id, mail_server_id, auto_delete, references, headers
+                email = SafeMail.create(create_values)
+
+                if email and recipient_ids:
+                    tocreate_recipient_ids = list(recipient_ids)
+                    if check_existing:
+                        existing_notifications = self.env['mail.notification'].sudo().search([
+                            ('mail_message_id', '=', message.id),
+                            ('notification_type', '=', 'email'),
+                            ('res_partner_id', 'in', tocreate_recipient_ids)
+                        ])
+                        if existing_notifications:
+                            tocreate_recipient_ids = [rid for rid in recipient_ids if rid not in existing_notifications.mapped('res_partner_id.id')]
+                            existing_notifications.write({
+                                'notification_status': 'ready',
+                                'mail_id': email.id,
+                            })
+                    notif_create_values += [{
+                        'mail_message_id': message.id,
+                        'res_partner_id': recipient_id,
+                        'notification_type': 'email',
+                        'mail_id': email.id,
+                        'is_read': True,  # discard Inbox notification
+                        'notification_status': 'ready',
+                    } for recipient_id in tocreate_recipient_ids]
+                emails |= email
+
+        if notif_create_values:
+            SafeNotification.create(notif_create_values)
+
+        # NOTE:
+        #   1. for more than 50 followers, use the queue system
+        #   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 len(emails) < recipients_max and (not self.pool._init or test_mode):
+            # 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:
+                email_ids = emails.ids
+                dbname = self.env.cr.dbname
+                _context = self._context
+
+                @self.env.cr.postcommit.add
+                def send_notifications():
+                    db_registry = registry(dbname)
+                    with api.Environment.manage(), db_registry.cursor() as cr:
+                        env = api.Environment(cr, SUPERUSER_ID, _context)
+                        env['mail.mail'].browse(email_ids).send()
+            else:
+                emails.send()
+
+        return True
+
+    @api.model
+    def _notify_prepare_template_context(self, message, msg_vals, model_description=False, mail_auto_delete=True):
+        # compute send user and its related signature
+        signature = ''
+        user = self.env.user
+        author = message.env['res.partner'].browse(msg_vals.get('author_id')) if msg_vals else message.author_id
+        model = msg_vals.get('model') if msg_vals else message.model
+        add_sign = msg_vals.get('add_sign') if msg_vals else message.add_sign
+        subtype_id = msg_vals.get('subtype_id') if msg_vals else message.subtype_id.id
+        message_id = message.id
+        record_name = msg_vals.get('record_name') if msg_vals else message.record_name
+        author_user = user if user.partner_id == author else author.user_ids[0] if author and author.user_ids else False
+        # trying to use user (self.env.user) instead of browing user_ids if he is the author will give a sudo user,
+        # improving access performances and cache usage.
+        if author_user:
+            user = author_user
+            if add_sign:
+                signature = user.signature
+        else:
+            if add_sign:
+                signature = "

--
%s

" % author.name + + company = self.company_id.sudo() if self and 'company_id' in self else user.company_id + if company.website: + website_url = 'http://%s' % company.website if not company.website.lower().startswith(('http:', 'https:')) else company.website + else: + website_url = False + + # Retrieve the language in which the template was rendered, in order to render the custom + # layout in the same language. + # TDE FIXME: this whole brol should be cleaned ! + lang = self.env.context.get('lang') + if {'default_template_id', 'default_model', 'default_res_id'} <= self.env.context.keys(): + template = self.env['mail.template'].browse(self.env.context['default_template_id']) + if template and template.lang: + lang = template._render_lang([self.env.context['default_res_id']])[self.env.context['default_res_id']] + + if not model_description and model: + model_description = self.env['ir.model'].with_context(lang=lang)._get(model).display_name + + tracking = [] + if msg_vals.get('tracking_value_ids', True) if msg_vals else bool(self): # could be tracking + for tracking_value in self.env['mail.tracking.value'].sudo().search([('mail_message_id', '=', message.id)]): + groups = tracking_value.field_groups + if not groups or self.env.is_superuser() or self.user_has_groups(groups): + tracking.append((tracking_value.field_desc, + tracking_value.get_old_display_value()[0], + tracking_value.get_new_display_value()[0])) + + is_discussion = subtype_id == self.env['ir.model.data'].xmlid_to_res_id('mail.mt_comment') + + return { + 'message': message, + 'signature': signature, + 'website_url': website_url, + 'company': company, + 'model_description': model_description, + 'record': self, + 'record_name': record_name, + 'tracking_values': tracking, + 'is_discussion': is_discussion, + 'subtype': message.subtype_id, + 'lang': lang, + } + + def _notify_by_email_add_values(self, base_mail_values): + """ Add model-specific values to the dictionary used to create the + notification email. Its base behavior is to compute model-specific + headers. + + :param dict base_mail_values: base mail.mail values, holding message + to notify (mail_message_id and its fields), server, references, subject. + """ + headers = self._notify_email_headers() + if headers: + base_mail_values['headers'] = headers + return base_mail_values + + def _notify_compute_recipients(self, message, msg_vals): + """ Compute recipients to notify based on subtype and followers. This + method returns data structured as expected for ``_notify_recipients``. """ + msg_sudo = message.sudo() + # get values from msg_vals or from message if msg_vals doen't exists + pids = msg_vals.get('partner_ids', []) if msg_vals else msg_sudo.partner_ids.ids + cids = msg_vals.get('channel_ids', []) if msg_vals else msg_sudo.channel_ids.ids + message_type = msg_vals.get('message_type') if msg_vals else msg_sudo.message_type + subtype_id = msg_vals.get('subtype_id') if msg_vals else msg_sudo.subtype_id.id + # is it possible to have record but no subtype_id ? + recipient_data = { + 'partners': [], + 'channels': [], + } + res = self.env['mail.followers']._get_recipient_data(self, message_type, subtype_id, pids, cids) + if not res: + return recipient_data + + author_id = msg_vals.get('author_id') or message.author_id.id + for pid, cid, active, pshare, ctype, notif, groups in res: + if pid and pid == author_id and not self.env.context.get('mail_notify_author'): # do not notify the author of its own messages + continue + if pid: + if active is False: + continue + pdata = {'id': pid, 'active': active, 'share': pshare, 'groups': groups or []} + if notif == 'inbox': + recipient_data['partners'].append(dict(pdata, notif=notif, type='user')) + elif not pshare and notif: # has an user and is not shared, is therefore user + recipient_data['partners'].append(dict(pdata, notif=notif, type='user')) + elif pshare and notif: # has an user but is shared, is therefore portal + recipient_data['partners'].append(dict(pdata, notif=notif, type='portal')) + else: # has no user, is therefore customer + recipient_data['partners'].append(dict(pdata, notif=notif if notif else 'email', type='customer')) + elif cid: + recipient_data['channels'].append({'id': cid, 'notif': notif, 'type': ctype}) + + # add partner ids in email channels + email_cids = [r['id'] for r in recipient_data['channels'] if r['notif'] == 'email'] + if email_cids: + # we are doing a similar search in ocn_client + # Could be interesting to make everything in a single query. + # ocn_client: (searching all partners linked to channels of type chat). + # here : (searching all partners linked to channels with notif email if email is not the author one) + # TDE FIXME: use email_sanitized + email_from = msg_vals.get('email_from') or message.email_from + email_from = self.env['res.partner']._parse_partner_name(email_from)[1] + exept_partner = [r['id'] for r in recipient_data['partners']] + if author_id: + exept_partner.append(author_id) + + sql_query = """ select distinct on (p.id) p.id from res_partner p + left join mail_channel_partner mcp on p.id = mcp.partner_id + left join mail_channel c on c.id = mcp.channel_id + left join res_users u on p.id = u.partner_id + where (u.notification_type != 'inbox' or u.id is null) + and (p.email != ANY(%s) or p.email is null) + and c.id = ANY(%s) + and p.id != ANY(%s)""" + + self.env.cr.execute(sql_query, (([email_from], ), (email_cids, ), (exept_partner, ))) + for partner_id in self._cr.fetchall(): + # ocn_client: will add partners to recipient recipient_data. more ocn notifications. We neeed to filter them maybe + recipient_data['partners'].append({'id': partner_id[0], 'share': True, 'active': True, 'notif': 'email', 'type': 'channel_email', 'groups': []}) + + return recipient_data + + @api.model + def _notify_encode_link(self, base_link, params): + secret = self.env['ir.config_parameter'].sudo().get_param('database.secret') + token = '%s?%s' % (base_link, ' '.join('%s=%s' % (key, params[key]) for key in sorted(params))) + hm = hmac.new(secret.encode('utf-8'), token.encode('utf-8'), hashlib.sha1).hexdigest() + return hm + + def _notify_get_action_link(self, link_type, **kwargs): + """ Prepare link to an action: view document, follow document, ... """ + params = { + 'model': kwargs.get('model', self._name), + 'res_id': kwargs.get('res_id', self.ids and self.ids[0] or False), + } + # whitelist accepted parameters: action (deprecated), token (assign), access_token + # (view), auth_signup_token and auth_login (for auth_signup support) + params.update(dict( + (key, value) + for key, value in kwargs.items() + if key in ('action', 'token', 'access_token', 'auth_signup_token', 'auth_login') + )) + + if link_type in ['view', 'assign', 'follow', 'unfollow']: + base_link = '/mail/%s' % link_type + elif link_type == 'controller': + controller = kwargs.get('controller') + params.pop('model') + base_link = '%s' % controller + else: + return '' + + if link_type not in ['view']: + token = self._notify_encode_link(base_link, params) + params['token'] = token + + link = '%s?%s' % (base_link, urls.url_encode(params)) + if self: + link = self[0].get_base_url() + link + + return link + + def _notify_get_groups(self, msg_vals=None): + """ Return groups used to classify recipients of a notification email. + Groups is a list of tuple containing of form (group_name, group_func, + group_data) where + * group_name is an identifier used only to be able to override and manipulate + groups. Default groups are user (recipients linked to an employee user), + portal (recipients linked to a portal user) and customer (recipients not + linked to any user). An example of override use would be to add a group + linked to a res.groups like Hr Officers to set specific action buttons to + them. + * group_func is a function pointer taking a partner record as parameter. This + method will be applied on recipients to know whether they belong to a given + group or not. Only first matching group is kept. Evaluation order is the + list order. + * group_data is a dict containing parameters for the notification email + * has_button_access: whether to display Access in email. True + by default for new groups, False for portal / customer. + * button_access: dict with url and title of the button + * actions: list of action buttons to display in the notification email. + Each action is a dict containing url and title of the button. + Groups has a default value that you can find in mail_thread + ``_notify_classify_recipients`` method. + """ + return [ + ( + 'user', + lambda pdata: pdata['type'] == 'user', + {} + ), ( + 'portal', + lambda pdata: pdata['type'] == 'portal', + {'has_button_access': False} + ), ( + 'customer', + lambda pdata: True, + {'has_button_access': False} + ) + ] + + def _notify_classify_recipients(self, recipient_data, model_name, msg_vals=None): + """ Classify recipients to be notified of a message in groups to have + specific rendering depending on their group. For example users could + have access to buttons customers should not have in their emails. + Module-specific grouping should be done by overriding ``_notify_get_groups`` + method defined here-under. + :param recipient_data:todo xdo UPDATE ME + return example: + [{ + 'actions': [], + 'button_access': {'title': 'View Simple Chatter Model', + 'url': '/mail/view?model=mail.test.simple&res_id=1497'}, + 'has_button_access': False, + 'recipients': [11] + }, + { + 'actions': [], + 'button_access': {'title': 'View Simple Chatter Model', + 'url': '/mail/view?model=mail.test.simple&res_id=1497'}, + 'has_button_access': False, + 'recipients': [4, 5, 6] + }, + { + 'actions': [], + 'button_access': {'title': 'View Simple Chatter Model', + 'url': '/mail/view?model=mail.test.simple&res_id=1497'}, + 'has_button_access': True, + 'recipients': [10, 11, 12] + }] + only return groups with recipients + """ + # keep a local copy of msg_vals as it may be modified to include more information about groups or links + local_msg_vals = dict(msg_vals) if msg_vals else {} + groups = self._notify_get_groups(msg_vals=local_msg_vals) + access_link = self._notify_get_action_link('view', **local_msg_vals) + + if model_name: + view_title = _('View %s', model_name) + else: + view_title = _('View') + + # fill group_data with default_values if they are not complete + for group_name, group_func, group_data in groups: + group_data.setdefault('notification_group_name', group_name) + group_data.setdefault('notification_is_customer', False) + group_data.setdefault('has_button_access', True) + group_button_access = group_data.setdefault('button_access', {}) + group_button_access.setdefault('url', access_link) + group_button_access.setdefault('title', view_title) + group_data.setdefault('actions', list()) + group_data.setdefault('recipients', list()) + + # classify recipients in each group + for recipient in recipient_data: + for group_name, group_func, group_data in groups: + if group_func(recipient): + group_data['recipients'].append(recipient['id']) + break + + result = [] + for group_name, group_method, group_data in groups: + if group_data['recipients']: + result.append(group_data) + + return result + + @api.model + def _notify_get_reply_to_on_records(self, default=None, records=None, company=None, doc_names=None): + """ Moved to ``BaseModel._notify_get_reply_to()`` """ + records = records if records else self + return records._notify_get_reply_to(default=default, company=company, doc_names=doc_names) + + def _notify_email_recipient_values(self, recipient_ids): + """ Format email notification recipient values to store on the notification + mail.mail. Basic method just set the recipient partners as mail_mail + recipients. Override to generate other mail values like email_to or + email_cc. + :param recipient_ids: res.partner recordset to notify + """ + return { + 'email_to': False, + 'recipient_ids': recipient_ids, + } + + # ------------------------------------------------------ + # FOLLOWERS API + # ------------------------------------------------------ + + def message_subscribe(self, partner_ids=None, channel_ids=None, subtype_ids=None): + """ Main public API to add followers to a record set. Its main purpose is + to perform access rights checks before calling ``_message_subscribe``. """ + if not self or (not partner_ids and not channel_ids): + return True + + partner_ids = partner_ids or [] + channel_ids = channel_ids or [] + adding_current = set(partner_ids) == set([self.env.user.partner_id.id]) + customer_ids = [] if adding_current else None + + if not channel_ids and partner_ids and adding_current: + try: + self.check_access_rights('read') + self.check_access_rule('read') + except exceptions.AccessError: + return False + else: + self.check_access_rights('write') + self.check_access_rule('write') + + # filter inactive and private addresses + if partner_ids and not adding_current: + partner_ids = self.env['res.partner'].sudo().search([('id', 'in', partner_ids), ('active', '=', True), ('type', '!=', 'private')]).ids + + return self._message_subscribe(partner_ids, channel_ids, subtype_ids, customer_ids=customer_ids) + + def _message_subscribe(self, partner_ids=None, channel_ids=None, subtype_ids=None, customer_ids=None): + """ Main private API to add followers to a record set. This method adds + partners and channels, given their IDs, as followers of all records + contained in the record set. + + If subtypes are given existing followers are erased with new subtypes. + If default one have to be computed only missing followers will be added + with default subtypes matching the record set model. + + This private method does not specifically check for access right. Use + ``message_subscribe`` public API when not sure about access rights. + + :param customer_ids: see ``_insert_followers`` """ + if not self: + return True + curframe = inspect.currentframe() + calframe = inspect.getouterframes(curframe, 2) + print('caller name:', calframe) + + if not subtype_ids: + self.env['mail.followers']._insert_followers( + self._name, self.ids, partner_ids, None, channel_ids, None, + customer_ids=customer_ids, check_existing=True, existing_policy='skip') + else: + self.env['mail.followers']._insert_followers( + self._name, self.ids, + partner_ids, dict((pid, subtype_ids) for pid in partner_ids), + channel_ids, dict((cid, subtype_ids) for cid in channel_ids), + customer_ids=customer_ids, check_existing=True, existing_policy='replace') + + return True + + def message_unsubscribe(self, partner_ids=None, channel_ids=None): + """ Remove partners from the records followers. """ + # not necessary for computation, but saves an access right check + if not partner_ids and not channel_ids: + return True + user_pid = self.env.user.partner_id.id + if not channel_ids and set(partner_ids) == set([user_pid]): + self.check_access_rights('read') + self.check_access_rule('read') + else: + self.check_access_rights('write') + self.check_access_rule('write') + self.env['mail.followers'].sudo().search([ + ('res_model', '=', self._name), + ('res_id', 'in', self.ids), + '|', + ('partner_id', 'in', partner_ids or []), + ('channel_id', 'in', channel_ids or []) + ]).unlink() + + def _message_auto_subscribe_followers(self, updated_values, default_subtype_ids): + """ Optional method to override in addons inheriting from mail.thread. + Return a list tuples containing ( + partner ID, + subtype IDs (or False if model-based default subtypes), + QWeb template XML ID for notification (or False is no specific + notification is required), + ), aka partners and their subtype and possible notification to send + using the auto subscription mechanism linked to updated values. + + Default value of this method is to return the new responsible of + documents. This is done using relational fields linking to res.users + with track_visibility set. Since OpenERP v7 it is considered as being + responsible for the document and therefore standard behavior is to + subscribe the user and send him a notification. + + Override this method to change that behavior and/or to add people to + notify, using possible custom notification. + + :param updated_values: see ``_message_auto_subscribe`` + :param default_subtype_ids: coming from ``_get_auto_subscription_subtypes`` + """ + fnames = [] + field = self._fields.get('user_id') + user_id = updated_values.get('user_id') + if field and user_id and field.comodel_name == 'res.users' and (getattr(field, 'track_visibility', False) or getattr(field, 'tracking', False)): + user = self.env['res.users'].sudo().browse(user_id) + try: # avoid to make an exists, lets be optimistic and try to read it. + if user.active: + return [(user.partner_id.id, default_subtype_ids, 'mail.message_user_assigned' if user != self.env.user else False)] + except: + pass + return [] + + def _message_auto_subscribe_notify(self, partner_ids, template): + """ Notify new followers, using a template to render the content of the + notification message. Notifications pushed are done using the standard + notification mechanism in mail.thread. It is either inbox either email + depending on the partner state: no user (email, customer), share user + (email, customer) or classic user (notification_type) + + :param partner_ids: IDs of partner to notify; + :param template: XML ID of template used for the notification; + """ + if not self or self.env.context.get('mail_auto_subscribe_no_notify'): + return + if not self.env.registry.ready: # Don't send notification during install + return + + view = self.env['ir.ui.view'].browse(self.env['ir.model.data'].xmlid_to_res_id(template)) + + for record in self: + model_description = self.env['ir.model']._get(record._name).display_name + values = { + 'object': record, + 'model_description': model_description, + 'access_link': record._notify_get_action_link('view'), + } + assignation_msg = view._render(values, engine='ir.qweb', minimal_qcontext=True) + assignation_msg = self.env['mail.render.mixin']._replace_local_links(assignation_msg) + record.message_notify( + subject=_('You have been assigned to %s', record.display_name), + body=assignation_msg, + partner_ids=partner_ids, + record_name=record.display_name, + email_layout_xmlid='mail.mail_notification_light', + model_description=model_description, + ) + + def _message_auto_subscribe(self, updated_values, followers_existing_policy='skip'): + """ Handle auto subscription. Auto subscription is done based on two + main mechanisms + + * using subtypes parent relationship. For example following a parent record + (i.e. project) with subtypes linked to child records (i.e. task). See + mail.message.subtype ``_get_auto_subscription_subtypes``; + * calling _message_auto_subscribe_notify that returns a list of partner + to subscribe, as well as data about the subtypes and notification + to send. Base behavior is to subscribe responsible and notify them; + + Adding application-specific auto subscription should be done by overriding + ``_message_auto_subscribe_followers``. It should return structured data + for new partner to subscribe, with subtypes and eventual notification + to perform. See that method for more details. + + :param updated_values: values modifying the record trigerring auto subscription + """ + curframe = inspect.currentframe() + calframe = inspect.getouterframes(curframe, 2) + print('caller name:', calframe) + if not self: + return True + + new_partners, new_channels = dict(), dict() + + # return data related to auto subscription based on subtype matching (aka: + # default task subtypes or subtypes from project triggering task subtypes) + updated_relation = dict() + child_ids, def_ids, all_int_ids, parent, relation = self.env['mail.message.subtype']._get_auto_subscription_subtypes(self._name) + + # check effectively modified relation field + for res_model, fnames in relation.items(): + for field in (fname for fname in fnames if updated_values.get(fname)): + updated_relation.setdefault(res_model, set()).add(field) + udpated_fields = [fname for fnames in updated_relation.values() for fname in fnames if updated_values.get(fname)] + + if udpated_fields: + # fetch "parent" subscription data (aka: subtypes on project to propagate on task) + doc_data = [(model, [updated_values[fname] for fname in fnames]) for model, fnames in updated_relation.items()] + res = self.env['mail.followers']._get_subscription_data(doc_data, None, None, include_pshare=True, include_active=True) + for fid, rid, pid, cid, subtype_ids, pshare, active in res: + # use project.task_new -> task.new link + sids = [parent[sid] for sid in subtype_ids if parent.get(sid)] + # add checked subtypes matching model_name + sids += [sid for sid in subtype_ids if sid not in parent and sid in child_ids] + if pid and active: # auto subscribe only active partners + if pshare: # remove internal subtypes for customers + new_partners[pid] = set(sids) - set(all_int_ids) + else: + new_partners[pid] = set(sids) + if cid: # never subscribe channels to internal subtypes + new_channels[cid] = set(sids) - set(all_int_ids) + + notify_data = dict() + res = self._message_auto_subscribe_followers(updated_values, def_ids) + for pid, sids, template in res: + new_partners.setdefault(pid, sids) + if template: + partner = self.env['res.partner'].browse(pid) + lang = partner.lang if partner else None + notify_data.setdefault((template, lang), list()).append(pid) + + self.env['mail.followers']._insert_followers( + self._name, self.ids, + list(new_partners), new_partners, + list(new_channels), new_channels, + check_existing=True, existing_policy=followers_existing_policy) + + # notify people from auto subscription, for example like assignation + for (template, lang), pids in notify_data.items(): + self.with_context(lang=lang)._message_auto_subscribe_notify(pids, template) + + return True + + # ------------------------------------------------------ + # CONTROLLERS + # ------------------------------------------------------ + + def _get_mail_redirect_suggested_company(self): + """ Return the suggested company to be set on the context + in case of a mail redirection to the record. To avoid multi + company issues when clicking on a link sent by email, this + could be called to try setting the most suited company on + the allowed_company_ids in the context. This method can be + overridden, for example on the hr.leave model, where the + most suited company is the company of the leave type, as + specified by the ir.rule. + """ + if 'company_id' in self: + return self.company_id + return False \ No newline at end of file diff --git a/odex25_base/inbox_notif_email/security/ir.model.access.csv b/odex25_base/inbox_notif_email/security/ir.model.access.csv new file mode 100644 index 000000000..e95836d64 --- /dev/null +++ b/odex25_base/inbox_notif_email/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_inbox_notif_email_inbox_notif_email,inbox_notif_email.inbox_notif_email,model_inbox_notif_email_inbox_notif_email,base.group_user,1,1,1,1 \ No newline at end of file diff --git a/odex25_base/inbox_notif_email/static/description/banner.PNG b/odex25_base/inbox_notif_email/static/description/banner.PNG new file mode 100644 index 000000000..98cab95f6 Binary files /dev/null and b/odex25_base/inbox_notif_email/static/description/banner.PNG differ diff --git a/odex25_base/inbox_notif_email/static/description/icon.png b/odex25_base/inbox_notif_email/static/description/icon.png new file mode 100644 index 000000000..0deb66745 Binary files /dev/null and b/odex25_base/inbox_notif_email/static/description/icon.png differ diff --git a/odex25_base/inbox_notif_email/static/description/image1.png b/odex25_base/inbox_notif_email/static/description/image1.png new file mode 100644 index 000000000..98cab95f6 Binary files /dev/null and b/odex25_base/inbox_notif_email/static/description/image1.png differ diff --git a/odex25_base/inbox_notif_email/static/description/image10.png b/odex25_base/inbox_notif_email/static/description/image10.png new file mode 100644 index 000000000..a614bd5bb Binary files /dev/null and b/odex25_base/inbox_notif_email/static/description/image10.png differ diff --git a/odex25_base/inbox_notif_email/static/description/image2.png b/odex25_base/inbox_notif_email/static/description/image2.png new file mode 100644 index 000000000..9ad2bb0b9 Binary files /dev/null and b/odex25_base/inbox_notif_email/static/description/image2.png differ diff --git a/odex25_base/inbox_notif_email/static/description/image3.png b/odex25_base/inbox_notif_email/static/description/image3.png new file mode 100644 index 000000000..0e9e47021 Binary files /dev/null and b/odex25_base/inbox_notif_email/static/description/image3.png differ diff --git a/odex25_base/inbox_notif_email/static/description/image4.png b/odex25_base/inbox_notif_email/static/description/image4.png new file mode 100644 index 000000000..18b074a46 Binary files /dev/null and b/odex25_base/inbox_notif_email/static/description/image4.png differ diff --git a/odex25_base/inbox_notif_email/static/description/image5.png b/odex25_base/inbox_notif_email/static/description/image5.png new file mode 100644 index 000000000..777d1f84e Binary files /dev/null and b/odex25_base/inbox_notif_email/static/description/image5.png differ diff --git a/odex25_base/inbox_notif_email/static/description/image6.png b/odex25_base/inbox_notif_email/static/description/image6.png new file mode 100644 index 000000000..42ff122d7 Binary files /dev/null and b/odex25_base/inbox_notif_email/static/description/image6.png differ diff --git a/odex25_base/inbox_notif_email/static/description/image7.png b/odex25_base/inbox_notif_email/static/description/image7.png new file mode 100644 index 000000000..098f48e36 Binary files /dev/null and b/odex25_base/inbox_notif_email/static/description/image7.png differ diff --git a/odex25_base/inbox_notif_email/static/description/image8.png b/odex25_base/inbox_notif_email/static/description/image8.png new file mode 100644 index 000000000..ee12616c5 Binary files /dev/null and b/odex25_base/inbox_notif_email/static/description/image8.png differ diff --git a/odex25_base/inbox_notif_email/static/description/image9.png b/odex25_base/inbox_notif_email/static/description/image9.png new file mode 100644 index 000000000..0ffa976a1 Binary files /dev/null and b/odex25_base/inbox_notif_email/static/description/image9.png differ diff --git a/odex25_base/inbox_notif_email/static/description/index.html b/odex25_base/inbox_notif_email/static/description/index.html new file mode 100644 index 000000000..f0db026af --- /dev/null +++ b/odex25_base/inbox_notif_email/static/description/index.html @@ -0,0 +1,93 @@ +
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+

+ odoo notification and email notification both +

+

+ The Problem +By default, Odoo provides two options for Notification Management: + +Handle by Emails: notifications are sent to the users' email. This is the default option for new user creation
+Handle in Odoo: notifications appear in the users' Odoo Inbox but no email sent to the users
+This module does the following
+Handle by both now offers both inbox and email notifications +

+
+
+
+ +
+
+ +
+
+
+ +
+

Handle by both

+
+
+
+
+
+
+
+
+ +
+
+
+
+ + + + +
+ +
+ + +
+
+ Please contact mdadnanusmani@gmail.com for any query +
+
diff --git a/odex25_base/inbox_notif_email/views/templates.xml b/odex25_base/inbox_notif_email/views/templates.xml new file mode 100644 index 000000000..cea6b39ad --- /dev/null +++ b/odex25_base/inbox_notif_email/views/templates.xml @@ -0,0 +1,24 @@ + + + + + \ No newline at end of file diff --git a/odex25_base/inbox_notif_email/views/views.xml b/odex25_base/inbox_notif_email/views/views.xml new file mode 100644 index 000000000..61b3ad709 --- /dev/null +++ b/odex25_base/inbox_notif_email/views/views.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/odex25_base/odex25_apps_features/wizard/base_module_uninstall.py b/odex25_base/odex25_apps_features/wizard/base_module_uninstall.py index 8e96435c8..00b15403a 100644 --- a/odex25_base/odex25_apps_features/wizard/base_module_uninstall.py +++ b/odex25_base/odex25_apps_features/wizard/base_module_uninstall.py @@ -11,9 +11,9 @@ class BaseModuleUninstall(models.TransientModel): is_restrict = fields.Boolean() password = fields.Char(required=True) - def action_uninstall(self): - if not tools.config.get('uninstall_password'): - raise ValidationError("Uninstall password not yet set!") - if self.password != tools.config.get('uninstall_password').replace('\'', ''): - raise ValidationError("Invalid Password!") - return super(BaseModuleUninstall, self).action_uninstall() + # def action_uninstall(self): + # if not tools.config.get('uninstall_password'): + # raise ValidationError("Uninstall password not yet set!") + # if self.password != tools.config.get('uninstall_password').replace('\'', ''): + # raise ValidationError("Invalid Password!") + # return super(BaseModuleUninstall, self).action_uninstall()