1# -*- coding: utf-8 -*-
2# Part of Odoo. See LICENSE file for full copyright and licensing details.
3
4import ast
5import base64
6import datetime
7import dateutil
8import email
9import email.policy
10import hashlib
11import hmac
12import lxml
13import logging
14import pytz
15import re
16import socket
17import time
18import threading
19
20from collections import namedtuple
21from email.message import EmailMessage
22from email import message_from_string, policy
23from lxml import etree
24from werkzeug import urls
25from xmlrpc import client as xmlrpclib
26
27from odoo import _, api, exceptions, fields, models, tools, registry, SUPERUSER_ID
28from odoo.exceptions import MissingError
29from odoo.osv import expression
30
31from odoo.tools import ustr
32from odoo.tools.misc import clean_context, split_every
33
34_logger = logging.getLogger(__name__)
35
36
37class MailThread(models.AbstractModel):
38    ''' mail_thread model is meant to be inherited by any model that needs to
39        act as a discussion topic on which messages can be attached. Public
40        methods are prefixed with ``message_`` in order to avoid name
41        collisions with methods of the models that will inherit from this class.
42
43        ``mail.thread`` defines fields used to handle and display the
44        communication history. ``mail.thread`` also manages followers of
45        inheriting classes. All features and expected behavior are managed
46        by mail.thread. Widgets has been designed for the 7.0 and following
47        versions of Odoo.
48
49        Inheriting classes are not required to implement any method, as the
50        default implementation will work for any model. However it is common
51        to override at least the ``message_new`` and ``message_update``
52        methods (calling ``super``) to add model-specific behavior at
53        creation and update of a thread when processing incoming emails.
54
55        Options:
56            - _mail_flat_thread: if set to True, all messages without parent_id
57                are automatically attached to the first message posted on the
58                ressource. If set to False, the display of Chatter is done using
59                threads, and no parent_id is automatically set.
60
61    MailThread features can be somewhat controlled through context keys :
62
63     - ``mail_create_nosubscribe``: at create or message_post, do not subscribe
64       uid to the record thread
65     - ``mail_create_nolog``: at create, do not log the automatic '<Document>
66       created' message
67     - ``mail_notrack``: at create and write, do not perform the value tracking
68       creating messages
69     - ``tracking_disable``: at create and write, perform no MailThread features
70       (auto subscription, tracking, post, ...)
71     - ``mail_notify_force_send``: if less than 50 email notifications to send,
72       send them directly instead of using the queue; True by default
73    '''
74    _name = 'mail.thread'
75    _description = 'Email Thread'
76    _mail_flat_thread = True  # flatten the discussino history
77    _mail_post_access = 'write'  # access required on the document to post on it
78    _Attachment = namedtuple('Attachment', ('fname', 'content', 'info'))
79
80    message_is_follower = fields.Boolean(
81        'Is Follower', compute='_compute_is_follower', search='_search_is_follower')
82    message_follower_ids = fields.One2many(
83        'mail.followers', 'res_id', string='Followers', groups='base.group_user')
84    message_partner_ids = fields.Many2many(
85        comodel_name='res.partner', string='Followers (Partners)',
86        compute='_get_followers', search='_search_follower_partners',
87        groups='base.group_user')
88    message_channel_ids = fields.Many2many(
89        comodel_name='mail.channel', string='Followers (Channels)',
90        compute='_get_followers', search='_search_follower_channels',
91        groups='base.group_user')
92    message_ids = fields.One2many(
93        'mail.message', 'res_id', string='Messages',
94        domain=lambda self: [('message_type', '!=', 'user_notification')], auto_join=True)
95    message_unread = fields.Boolean(
96        'Unread Messages', compute='_get_message_unread',
97        help="If checked, new messages require your attention.")
98    message_unread_counter = fields.Integer(
99        'Unread Messages Counter', compute='_get_message_unread',
100        help="Number of unread messages")
101    message_needaction = fields.Boolean(
102        'Action Needed', compute='_get_message_needaction', search='_search_message_needaction',
103        help="If checked, new messages require your attention.")
104    message_needaction_counter = fields.Integer(
105        'Number of Actions', compute='_get_message_needaction',
106        help="Number of messages which requires an action")
107    message_has_error = fields.Boolean(
108        'Message Delivery error', compute='_compute_message_has_error', search='_search_message_has_error',
109        help="If checked, some messages have a delivery error.")
110    message_has_error_counter = fields.Integer(
111        'Number of errors', compute='_compute_message_has_error',
112        help="Number of messages with delivery error")
113    message_attachment_count = fields.Integer('Attachment Count', compute='_compute_message_attachment_count', groups="base.group_user")
114    message_main_attachment_id = fields.Many2one(string="Main Attachment", comodel_name='ir.attachment', index=True, copy=False)
115
116    @api.depends('message_follower_ids')
117    def _get_followers(self):
118        for thread in self:
119            thread.message_partner_ids = thread.message_follower_ids.mapped('partner_id')
120            thread.message_channel_ids = thread.message_follower_ids.mapped('channel_id')
121
122    @api.model
123    def _search_follower_partners(self, operator, operand):
124        """Search function for message_follower_ids
125
126        Do not use with operator 'not in'. Use instead message_is_followers
127        """
128        # TOFIX make it work with not in
129        assert operator != "not in", "Do not search message_follower_ids with 'not in'"
130        followers = self.env['mail.followers'].sudo().search([
131            ('res_model', '=', self._name),
132            ('partner_id', operator, operand)])
133        # using read() below is much faster than followers.mapped('res_id')
134        return [('id', 'in', [res['res_id'] for res in followers.read(['res_id'])])]
135
136    @api.model
137    def _search_follower_channels(self, operator, operand):
138        """Search function for message_follower_ids
139
140        Do not use with operator 'not in'. Use instead message_is_followers
141        """
142        # TOFIX make it work with not in
143        assert operator != "not in", "Do not search message_follower_ids with 'not in'"
144        followers = self.env['mail.followers'].sudo().search([
145            ('res_model', '=', self._name),
146            ('channel_id', operator, operand)])
147        # using read() below is much faster than followers.mapped('res_id')
148        return [('id', 'in', [res['res_id'] for res in followers.read(['res_id'])])]
149
150    @api.depends('message_follower_ids')
151    def _compute_is_follower(self):
152        followers = self.env['mail.followers'].sudo().search([
153            ('res_model', '=', self._name),
154            ('res_id', 'in', self.ids),
155            ('partner_id', '=', self.env.user.partner_id.id),
156            ])
157        # using read() below is much faster than followers.mapped('res_id')
158        following_ids = [res['res_id'] for res in followers.read(['res_id'])]
159        for record in self:
160            record.message_is_follower = record.id in following_ids
161
162    @api.model
163    def _search_is_follower(self, operator, operand):
164        followers = self.env['mail.followers'].sudo().search([
165            ('res_model', '=', self._name),
166            ('partner_id', '=', self.env.user.partner_id.id),
167            ])
168        # Cases ('message_is_follower', '=', True) or  ('message_is_follower', '!=', False)
169        if (operator == '=' and operand) or (operator == '!=' and not operand):
170            # using read() below is much faster than followers.mapped('res_id')
171            return [('id', 'in', [res['res_id'] for res in followers.read(['res_id'])])]
172        else:
173            # using read() below is much faster than followers.mapped('res_id')
174            return [('id', 'not in', [res['res_id'] for res in followers.read(['res_id'])])]
175
176    def _get_message_unread(self):
177        partner_id = self.env.user.partner_id.id
178        res = dict.fromkeys(self.ids, 0)
179        if self.ids:
180            # search for unread messages, directly in SQL to improve performances
181            self._cr.execute(""" SELECT msg.res_id FROM mail_message msg
182                                 RIGHT JOIN mail_message_mail_channel_rel rel
183                                 ON rel.mail_message_id = msg.id
184                                 RIGHT JOIN mail_channel_partner cp
185                                 ON (cp.channel_id = rel.mail_channel_id AND cp.partner_id = %s AND
186                                    (cp.seen_message_id IS NULL OR cp.seen_message_id < msg.id))
187                                 WHERE msg.model = %s AND msg.res_id = ANY(%s) AND
188                                        msg.message_type != 'user_notification' AND
189                                       (msg.author_id IS NULL OR msg.author_id != %s) AND
190                                       (msg.message_type not in ('notification', 'user_notification') OR msg.model != 'mail.channel')""",
191                             (partner_id, self._name, list(self.ids), partner_id,))
192            for result in self._cr.fetchall():
193                res[result[0]] += 1
194
195        for record in self:
196            record.message_unread_counter = res.get(record._origin.id, 0)
197            record.message_unread = bool(record.message_unread_counter)
198
199    def _get_message_needaction(self):
200        res = dict.fromkeys(self.ids, 0)
201        if self.ids:
202            # search for unread messages, directly in SQL to improve performances
203            self._cr.execute(""" SELECT msg.res_id FROM mail_message msg
204                                 RIGHT JOIN mail_message_res_partner_needaction_rel rel
205                                 ON rel.mail_message_id = msg.id AND rel.res_partner_id = %s AND (rel.is_read = false OR rel.is_read IS NULL)
206                                 WHERE msg.model = %s AND msg.res_id in %s AND msg.message_type != 'user_notification'""",
207                             (self.env.user.partner_id.id, self._name, tuple(self.ids),))
208            for result in self._cr.fetchall():
209                res[result[0]] += 1
210
211        for record in self:
212            record.message_needaction_counter = res.get(record._origin.id, 0)
213            record.message_needaction = bool(record.message_needaction_counter)
214
215    @api.model
216    def _search_message_needaction(self, operator, operand):
217        return [('message_ids.needaction', operator, operand)]
218
219    def _compute_message_has_error(self):
220        res = {}
221        if self.ids:
222            self._cr.execute(""" SELECT msg.res_id, COUNT(msg.res_id) FROM mail_message msg
223                                 RIGHT JOIN mail_message_res_partner_needaction_rel rel
224                                 ON rel.mail_message_id = msg.id AND rel.notification_status in ('exception','bounce')
225                                 WHERE msg.author_id = %s AND msg.model = %s AND msg.res_id in %s AND msg.message_type != 'user_notification'
226                                 GROUP BY msg.res_id""",
227                             (self.env.user.partner_id.id, self._name, tuple(self.ids),))
228            res.update(self._cr.fetchall())
229
230        for record in self:
231            record.message_has_error_counter = res.get(record._origin.id, 0)
232            record.message_has_error = bool(record.message_has_error_counter)
233
234    @api.model
235    def _search_message_has_error(self, operator, operand):
236        message_ids = self.env['mail.message']._search([('has_error', operator, operand), ('author_id', '=', self.env.user.partner_id.id)])
237        return [('message_ids', 'in', message_ids)]
238
239    def _compute_message_attachment_count(self):
240        read_group_var = self.env['ir.attachment'].read_group([('res_id', 'in', self.ids), ('res_model', '=', self._name)],
241                                                              fields=['res_id'],
242                                                              groupby=['res_id'])
243
244        attachment_count_dict = dict((d['res_id'], d['res_id_count']) for d in read_group_var)
245        for record in self:
246            record.message_attachment_count = attachment_count_dict.get(record.id, 0)
247
248    # ------------------------------------------------------------
249    # CRUD
250    # ------------------------------------------------------------
251
252    @api.model_create_multi
253    def create(self, vals_list):
254        """ Chatter override :
255            - subscribe uid
256            - subscribe followers of parent
257            - log a creation message
258        """
259        if self._context.get('tracking_disable'):
260            threads = super(MailThread, self).create(vals_list)
261            threads._discard_tracking()
262            return threads
263
264        threads = super(MailThread, self).create(vals_list)
265        # subscribe uid unless asked not to
266        if not self._context.get('mail_create_nosubscribe'):
267            for thread in threads:
268                self.env['mail.followers']._insert_followers(
269                    thread._name, thread.ids, self.env.user.partner_id.ids,
270                    None, None, None,
271                    customer_ids=[],
272                    check_existing=False
273                )
274
275        # auto_subscribe: take values and defaults into account
276        create_values_list = {}
277        for thread, values in zip(threads, vals_list):
278            create_values = dict(values)
279            for key, val in self._context.items():
280                if key.startswith('default_') and key[8:] not in create_values:
281                    create_values[key[8:]] = val
282            thread._message_auto_subscribe(create_values, followers_existing_policy='update')
283            create_values_list[thread.id] = create_values
284
285        # automatic logging unless asked not to (mainly for various testing purpose)
286        if not self._context.get('mail_create_nolog'):
287            threads_no_subtype = self.env[self._name]
288            for thread in threads:
289                subtype = thread._creation_subtype()
290                if subtype:  # if we have a subtype, post message to notify users from _message_auto_subscribe
291                    thread.sudo().message_post(subtype_id=subtype.id, author_id=self.env.user.partner_id.id)
292                else:
293                    threads_no_subtype += thread
294            if threads_no_subtype:
295                bodies = dict(
296                    (thread.id, thread._creation_message())
297                    for thread in threads_no_subtype)
298                threads_no_subtype._message_log_batch(bodies=bodies)
299
300        # post track template if a tracked field changed
301        threads._discard_tracking()
302        if not self._context.get('mail_notrack'):
303            fnames = self._get_tracked_fields()
304            for thread in threads:
305                create_values = create_values_list[thread.id]
306                changes = [fname for fname in fnames if create_values.get(fname)]
307                # based on tracked field to stay consistent with write
308                # we don't consider that a falsy field is a change, to stay consistent with previous implementation,
309                # but we may want to change that behaviour later.
310                thread._message_track_post_template(changes)
311
312        return threads
313
314    def write(self, values):
315        if self._context.get('tracking_disable'):
316            return super(MailThread, self).write(values)
317
318        if not self._context.get('mail_notrack'):
319            self._prepare_tracking(self._fields)
320
321        # Perform write
322        result = super(MailThread, self).write(values)
323
324        # update followers
325        self._message_auto_subscribe(values)
326
327        return result
328
329    def unlink(self):
330        """ Override unlink to delete messages and followers. This cannot be
331        cascaded, because link is done through (res_model, res_id). """
332        if not self:
333            return True
334        # discard pending tracking
335        self._discard_tracking()
336        self.env['mail.message'].search([('model', '=', self._name), ('res_id', 'in', self.ids)]).sudo().unlink()
337        res = super(MailThread, self).unlink()
338        self.env['mail.followers'].sudo().search(
339            [('res_model', '=', self._name), ('res_id', 'in', self.ids)]
340        ).unlink()
341        return res
342
343    def copy_data(self, default=None):
344        # avoid tracking multiple temporary changes during copy
345        return super(MailThread, self.with_context(mail_notrack=True)).copy_data(default=default)
346
347    @api.model
348    def get_empty_list_help(self, help):
349        """ Override of BaseModel.get_empty_list_help() to generate an help message
350        that adds alias information. """
351        model = self._context.get('empty_list_help_model')
352        res_id = self._context.get('empty_list_help_id')
353        catchall_domain = self.env['ir.config_parameter'].sudo().get_param("mail.catchall.domain")
354        document_name = self._context.get('empty_list_help_document_name', _('document'))
355        nothing_here = not help
356        alias = None
357
358        if catchall_domain and model and res_id:  # specific res_id -> find its alias (i.e. section_id specified)
359            record = self.env[model].sudo().browse(res_id)
360            # check that the alias effectively creates new records
361            if record.alias_id and record.alias_id.alias_name and \
362                    record.alias_id.alias_model_id and \
363                    record.alias_id.alias_model_id.model == self._name and \
364                    record.alias_id.alias_force_thread_id == 0:
365                alias = record.alias_id
366        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
367            Alias = self.env['mail.alias']
368            aliases = Alias.search([
369                ("alias_parent_model_id.model", "=", model),
370                ("alias_name", "!=", False),
371                ('alias_force_thread_id', '=', False),
372                ('alias_parent_thread_id', '=', False)], order='id ASC')
373            if aliases and len(aliases) == 1:
374                alias = aliases[0]
375
376        if alias:
377            email_link = "<a href='mailto:%(email)s'>%(email)s</a>" % {'email': alias.display_name}
378            if nothing_here:
379                return "<p class='o_view_nocontent_smiling_face'>%(dyn_help)s</p>" % {
380                    'dyn_help': _("Add a new %(document)s or send an email to %(email_link)s",
381                        document=document_name,
382                        email_link=email_link,
383                    )
384                }
385            # do not add alias two times if it was added previously
386            if "oe_view_nocontent_alias" not in help:
387                return "%(static_help)s<p class='oe_view_nocontent_alias'>%(dyn_help)s</p>" % {
388                    'static_help': help,
389                    'dyn_help': _("Create new %(document)s by sending an email to %(email_link)s",
390                        document=document_name,
391                        email_link=email_link,
392                    )
393                }
394
395        if nothing_here:
396            return "<p class='o_view_nocontent_smiling_face'>%(dyn_help)s</p>" % {
397                'dyn_help': _("Create new %(document)s", document=document_name),
398            }
399
400        return help
401
402    # ------------------------------------------------------
403    # MODELS / CRUD HELPERS
404    # ------------------------------------------------------
405
406    def _compute_field_value(self, field):
407        if not self._context.get('tracking_disable') and not self._context.get('mail_notrack'):
408            self._prepare_tracking(f.name for f in self.pool.field_computed[field] if f.store)
409
410        return super()._compute_field_value(field)
411
412    def _creation_subtype(self):
413        """ Give the subtypes triggered by the creation of a record
414
415        :returns: a subtype browse record (empty if no subtype is triggered)
416        """
417        return self.env['mail.message.subtype']
418
419    def _get_creation_message(self):
420        """ Deprecated, remove in 14+ """
421        return self._creation_message()
422
423    def _creation_message(self):
424        """ Get the creation message to log into the chatter at the record's creation.
425        :returns: The message's body to log.
426        """
427        self.ensure_one()
428        doc_name = self.env['ir.model']._get(self._name).name
429        return _('%s created', doc_name)
430
431    @api.model
432    def get_mail_message_access(self, res_ids, operation, model_name=None):
433        """ Deprecated, remove with v14+ """
434        return self._get_mail_message_access(res_ids, operation, model_name=model_name)
435
436    @api.model
437    def _get_mail_message_access(self, res_ids, operation, model_name=None):
438        """ mail.message check permission rules for related document. This method is
439            meant to be inherited in order to implement addons-specific behavior.
440            A common behavior would be to allow creating messages when having read
441            access rule on the document, for portal document such as issues. """
442
443        DocModel = self.env[model_name] if model_name else self
444        create_allow = getattr(DocModel, '_mail_post_access', 'write')
445
446        if operation in ['write', 'unlink']:
447            check_operation = 'write'
448        elif operation == 'create' and create_allow in ['create', 'read', 'write', 'unlink']:
449            check_operation = create_allow
450        elif operation == 'create':
451            check_operation = 'write'
452        else:
453            check_operation = operation
454        return check_operation
455
456    def _valid_field_parameter(self, field, name):
457        # allow tracking on models inheriting from 'mail.thread'
458        return name == 'tracking' or super()._valid_field_parameter(field, name)
459
460    def with_lang(self):
461        """ Deprecated, remove in 14+ """
462        return self._fallback_lang()
463
464    def _fallback_lang(self):
465        if not self._context.get("lang"):
466            return self.with_context(lang=self.env.user.lang)
467        return self
468
469    # ------------------------------------------------------
470    # WRAPPERS AND TOOLS
471    # ------------------------------------------------------
472
473    def message_change_thread(self, new_thread):
474        """
475        Transfer the list of the mail thread messages from an model to another
476
477        :param id : the old res_id of the mail.message
478        :param new_res_id : the new res_id of the mail.message
479        :param new_model : the name of the new model of the mail.message
480
481        Example :   my_lead.message_change_thread(my_project_task)
482                    will transfer the context of the thread of my_lead to my_project_task
483        """
484        self.ensure_one()
485        # get the subtype of the comment Message
486        subtype_comment = self.env['ir.model.data'].xmlid_to_res_id('mail.mt_comment')
487
488        # get the ids of the comment and not-comment of the thread
489        # TDE check: sudo on mail.message, to be sure all messages are moved ?
490        MailMessage = self.env['mail.message']
491        msg_comment = MailMessage.search([
492            ('model', '=', self._name),
493            ('res_id', '=', self.id),
494            ('message_type', '!=', 'user_notification'),
495            ('subtype_id', '=', subtype_comment)])
496        msg_not_comment = MailMessage.search([
497            ('model', '=', self._name),
498            ('res_id', '=', self.id),
499            ('message_type', '!=', 'user_notification'),
500            ('subtype_id', '!=', subtype_comment)])
501
502        # update the messages
503        msg_comment.write({"res_id": new_thread.id, "model": new_thread._name})
504        msg_not_comment.write({"res_id": new_thread.id, "model": new_thread._name, "subtype_id": None})
505        return True
506
507    # ------------------------------------------------------
508    # TRACKING / LOG
509    # ------------------------------------------------------
510
511    def _prepare_tracking(self, fields):
512        """ Prepare the tracking of ``fields`` for ``self``.
513
514        :param fields: iterable of fields names to potentially track
515        """
516        fnames = self._get_tracked_fields().intersection(fields)
517        if not fnames:
518            return
519        self.env.cr.precommit.add(self._finalize_tracking)
520        initial_values = self.env.cr.precommit.data.setdefault(f'mail.tracking.{self._name}', {})
521        for record in self:
522            if not record.id:
523                continue
524            values = initial_values.setdefault(record.id, {})
525            if values is not None:
526                for fname in fnames:
527                    values.setdefault(fname, record[fname])
528
529    def _discard_tracking(self):
530        """ Prevent any tracking of fields on ``self``. """
531        if not self._get_tracked_fields():
532            return
533        self.env.cr.precommit.add(self._finalize_tracking)
534        initial_values = self.env.cr.precommit.data.setdefault(f'mail.tracking.{self._name}', {})
535        # disable tracking by setting initial values to None
536        for id_ in self.ids:
537            initial_values[id_] = None
538
539    def _finalize_tracking(self):
540        """ Generate the tracking messages for the records that have been
541        prepared with ``_prepare_tracking``.
542        """
543        initial_values = self.env.cr.precommit.data.pop(f'mail.tracking.{self._name}', {})
544        ids = [id_ for id_, vals in initial_values.items() if vals]
545        if not ids:
546            return
547        records = self.browse(ids).sudo()
548        fnames = self._get_tracked_fields()
549        context = clean_context(self._context)
550        tracking = records.with_context(context).message_track(fnames, initial_values)
551        for record in records:
552            changes, tracking_value_ids = tracking.get(record.id, (None, None))
553            record._message_track_post_template(changes)
554        # this method is called after the main flush() and just before commit();
555        # we have to flush() again in case we triggered some recomputations
556        self.flush()
557
558    @tools.ormcache('self.env.uid', 'self.env.su')
559    def _get_tracked_fields(self):
560        """ Return the set of tracked fields names for the current model. """
561        fields = {
562            name
563            for name, field in self._fields.items()
564            if getattr(field, 'tracking', None) or getattr(field, 'track_visibility', None)
565        }
566
567        return fields and set(self.fields_get(fields))
568
569    def _message_track_post_template(self, changes):
570        if not changes:
571            return True
572        # Clean the context to get rid of residual default_* keys
573        # that could cause issues afterward during the mail.message
574        # generation. Example: 'default_parent_id' would refer to
575        # the parent_id of the current record that was used during
576        # its creation, but could refer to wrong parent message id,
577        # leading to a traceback in case the related message_id
578        # doesn't exist
579        self = self.with_context(clean_context(self._context))
580        templates = self._track_template(changes)
581        for field_name, (template, post_kwargs) in templates.items():
582            if not template:
583                continue
584            if isinstance(template, str):
585                self._fallback_lang().message_post_with_view(template, **post_kwargs)
586            else:
587                self._fallback_lang().message_post_with_template(template.id, **post_kwargs)
588        return True
589
590    def _track_template(self, changes):
591        return dict()
592
593    def message_track(self, tracked_fields, initial_values):
594        """ Track updated values. Comparing the initial and current values of
595        the fields given in tracked_fields, it generates a message containing
596        the updated values. This message can be linked to a mail.message.subtype
597        given by the ``_track_subtype`` method.
598
599        :param tracked_fields: iterable of field names to track
600        :param initial_values: mapping {record_id: {field_name: value}}
601        :return: mapping {record_id: (changed_field_names, tracking_value_ids)}
602            containing existing records only
603        """
604        if not tracked_fields:
605            return True
606
607        tracked_fields = self.fields_get(tracked_fields)
608        tracking = dict()
609        for record in self:
610            try:
611                tracking[record.id] = record._message_track(tracked_fields, initial_values[record.id])
612            except MissingError:
613                continue
614
615        for record in self:
616            changes, tracking_value_ids = tracking.get(record.id, (None, None))
617            if not changes:
618                continue
619
620            # find subtypes and post messages or log if no subtype found
621            subtype = False
622            # 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
623            if not self._context.get('mail_track_log_only'):
624                subtype = record._track_subtype(dict((col_name, initial_values[record.id][col_name]) for col_name in changes))
625            if subtype:
626                if not subtype.exists():
627                    _logger.debug('subtype "%s" not found' % subtype.name)
628                    continue
629                record.message_post(subtype_id=subtype.id, tracking_value_ids=tracking_value_ids)
630            elif tracking_value_ids:
631                record._message_log(tracking_value_ids=tracking_value_ids)
632
633        return tracking
634
635    def static_message_track(self, record, tracked_fields, initial):
636        """ Deprecated, remove in v14+ """
637        return record._mail_track(tracked_fields, initial)
638
639    def _message_track(self, tracked_fields, initial):
640        """ Moved to ``BaseModel._mail_track()`` """
641        return self._mail_track(tracked_fields, initial)
642
643    def _track_subtype(self, init_values):
644        """ Give the subtypes triggered by the changes on the record according
645        to values that have been updated.
646
647        :param init_values: the original values of the record; only modified fields
648                            are present in the dict
649        :type init_values: dict
650        :returns: a subtype browse record or False if no subtype is trigerred
651        """
652        return False
653
654    # ------------------------------------------------------
655    # MAIL GATEWAY
656    # ------------------------------------------------------
657
658    def _routing_warn(self, error_message, message_id, route, raise_exception=True):
659        """ Tools method used in _routing_check_route: whether to log a warning or raise an error """
660        short_message = _("Mailbox unavailable - %s", error_message)
661        full_message = ('Routing mail with Message-Id %s: route %s: %s' %
662                        (message_id, route, error_message))
663        _logger.info(full_message)
664        if raise_exception:
665            # sender should not see private diagnostics info, just the error
666            raise ValueError(short_message)
667
668    def _routing_create_bounce_email(self, email_from, body_html, message, **mail_values):
669        bounce_to = tools.decode_message_header(message, 'Return-Path') or email_from
670        bounce_mail_values = {
671            'author_id': False,
672            'body_html': body_html,
673            'subject': 'Re: %s' % message.get('subject'),
674            'email_to': bounce_to,
675            'auto_delete': True,
676        }
677        bounce_from = self.env['ir.mail_server']._get_default_bounce_address()
678        if bounce_from:
679            bounce_mail_values['email_from'] = tools.formataddr(('MAILER-DAEMON', bounce_from))
680        elif self.env['ir.config_parameter'].sudo().get_param("mail.catchall.alias") not in message['To']:
681            bounce_mail_values['email_from'] = tools.decode_message_header(message, 'To')
682        else:
683            bounce_mail_values['email_from'] = tools.formataddr(('MAILER-DAEMON', self.env.user.email_normalized))
684        bounce_mail_values.update(mail_values)
685        self.env['mail.mail'].sudo().create(bounce_mail_values).send()
686
687    @api.model
688    def _routing_handle_bounce(self, email_message, message_dict):
689        """ Handle bounce of incoming email. Based on values of the bounce (email
690        and related partner, send message and its messageID)
691
692          * find blacklist-enabled records with email_normalized = bounced email
693            and call ``_message_receive_bounce`` on each of them to propagate
694            bounce information through various records linked to same email;
695          * if not already done (i.e. if original record is not blacklist enabled
696            like a bounce on an applicant), find record linked to bounced message
697            and call ``_message_receive_bounce``;
698
699        :param email_message: incoming email;
700        :type email_message: email.message;
701        :param message_dict: dictionary holding already-parsed values and in
702            which bounce-related values will be added;
703        :type message_dict: dictionary;
704        """
705        bounced_record, bounced_record_done = False, False
706        bounced_email, bounced_partner = message_dict['bounced_email'], message_dict['bounced_partner']
707        bounced_msg_id, bounced_message = message_dict['bounced_msg_id'], message_dict['bounced_message']
708
709        if bounced_email:
710            bounced_model, bounced_res_id = bounced_message.model, bounced_message.res_id
711
712            if bounced_model and bounced_model in self.env and bounced_res_id:
713                bounced_record = self.env[bounced_model].sudo().browse(bounced_res_id).exists()
714
715            bl_models = self.env['ir.model'].sudo().search(['&', ('is_mail_blacklist', '=', True), ('model', '!=', 'mail.thread.blacklist')])
716            for model in [bl_model for bl_model in bl_models if bl_model.model in self.env]:  # transient test mode
717                rec_bounce_w_email = self.env[model.model].sudo().search([('email_normalized', '=', bounced_email)])
718                rec_bounce_w_email._message_receive_bounce(bounced_email, bounced_partner)
719                bounced_record_done = bounced_record_done or (bounced_record and model.model == bounced_model and bounced_record in rec_bounce_w_email)
720
721            # set record as bounced unless already done due to blacklist mixin
722            if bounced_record and not bounced_record_done and issubclass(type(bounced_record), self.pool['mail.thread']):
723                bounced_record._message_receive_bounce(bounced_email, bounced_partner)
724
725            if bounced_partner and bounced_message:
726                self.env['mail.notification'].sudo().search([
727                    ('mail_message_id', '=', bounced_message.id),
728                    ('res_partner_id', 'in', bounced_partner.ids)]
729                ).write({'notification_status': 'bounce'})
730
731        if bounced_record:
732            _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)',
733                         message_dict['email_from'], message_dict['to'], message_dict['message_id'], bounced_email, bounced_msg_id, bounced_model, bounced_res_id)
734        elif bounced_email:
735            _logger.info('Routing mail from %s to %s with Message-Id %s: not routing bounce email from %s replying to %s (no document found)',
736                         message_dict['email_from'], message_dict['to'], message_dict['message_id'], bounced_email, bounced_msg_id)
737        else:
738            _logger.info('Routing mail from %s to %s with Message-Id %s: not routing bounce email.',
739                         message_dict['email_from'], message_dict['to'], message_dict['message_id'])
740
741    @api.model
742    def _routing_check_route(self, message, message_dict, route, raise_exception=True):
743        """ Verify route validity. Check and rules:
744            1 - if thread_id -> check that document effectively exists; otherwise
745                fallback on a message_new by resetting thread_id
746            2 - check that message_update exists if thread_id is set; or at least
747                that message_new exist
748            3 - if there is an alias, check alias_contact:
749                'followers' and thread_id:
750                    check on target document that the author is in the followers
751                'followers' and alias_parent_thread_id:
752                    check on alias parent document that the author is in the
753                    followers
754                'partners': check that author_id id set
755
756        :param message: an email.message instance
757        :param message_dict: dictionary of values that will be given to
758                             mail_message.create()
759        :param route: route to check which is a tuple (model, thread_id,
760                      custom_values, uid, alias)
761        :param raise_exception: if an error occurs, tell whether to raise an error
762                                or just log a warning and try other processing or
763                                invalidate route
764        """
765
766        assert isinstance(route, (list, tuple)), 'A route should be a list or a tuple'
767        assert len(route) == 5, 'A route should contain 5 elements: model, thread_id, custom_values, uid, alias record'
768
769        message_id = message_dict['message_id']
770        email_from = message_dict['email_from']
771        author_id = message_dict.get('author_id')
772        model, thread_id, alias = route[0], route[1], route[4]
773        record_set = None
774
775        # Wrong model
776        if not model:
777            self._routing_warn(_('target model unspecified'), message_id, route, raise_exception)
778            return ()
779        elif model not in self.env:
780            self._routing_warn(_('unknown target model %s', model), message_id, route, raise_exception)
781            return ()
782        record_set = self.env[model].browse(thread_id) if thread_id else self.env[model]
783
784        # Existing Document: check if exists and model accepts the mailgateway; if not, fallback on create if allowed
785        if thread_id:
786            if not record_set.exists():
787                self._routing_warn(
788                    _('reply to missing document (%(model)s,%(thread)s), fall back on document creation', model=model, thread=thread_id),
789                    message_id,
790                    route,
791                    False
792                )
793                thread_id = None
794            elif not hasattr(record_set, 'message_update'):
795                self._routing_warn(_('reply to model %s that does not accept document update, fall back on document creation', model), message_id, route, False)
796                thread_id = None
797
798        # New Document: check model accepts the mailgateway
799        if not thread_id and model and not hasattr(record_set, 'message_new'):
800            self._routing_warn(_('model %s does not accept document creation', model), message_id, route, raise_exception)
801            return ()
802
803        # Update message author. We do it now because we need it for aliases (contact settings)
804        if not author_id:
805            if record_set:
806                authors = self._mail_find_partner_from_emails([email_from], records=record_set)
807            elif alias and alias.alias_parent_model_id and alias.alias_parent_thread_id:
808                records = self.env[alias.alias_parent_model_id.model].browse(alias.alias_parent_thread_id)
809                authors = self._mail_find_partner_from_emails([email_from], records=records)
810            else:
811                authors = self._mail_find_partner_from_emails([email_from], records=None)
812            if authors:
813                message_dict['author_id'] = authors[0].id
814
815        # Alias: check alias_contact settings
816        if alias:
817            if thread_id:
818                obj = record_set[0]
819            elif alias.alias_parent_model_id and alias.alias_parent_thread_id:
820                obj = self.env[alias.alias_parent_model_id.model].browse(alias.alias_parent_thread_id)
821            else:
822                obj = self.env[model]
823            error_message = obj._alias_get_error_message(message, message_dict, alias)
824            if error_message:
825                self._routing_warn(
826                    _('alias %(name)s: %(error)s', name=alias.alias_name, error=error_message or _('unknown error')),
827                    message_id,
828                    route,
829                    False
830                )
831                body = alias._get_alias_bounced_body(message_dict)
832                self._routing_create_bounce_email(email_from, body, message, references=message_id)
833                return False
834
835        return (model, thread_id, route[2], route[3], route[4])
836
837    @api.model
838    def _routing_reset_bounce(self, email_message, message_dict):
839        """Called by ``message_process`` when a new mail is received from an email address.
840        If the email is related to a partner, we consider that the number of message_bounce
841        is not relevant anymore as the email is valid - as we received an email from this
842        address. The model is here hardcoded because we cannot know with which model the
843        incomming mail match. We consider that if a mail arrives, we have to clear bounce for
844        each model having bounce count.
845
846        :param email_from: email address that sent the incoming email."""
847        valid_email = message_dict['email_from']
848        if valid_email:
849            bl_models = self.env['ir.model'].sudo().search(['&', ('is_mail_blacklist', '=', True), ('model', '!=', 'mail.thread.blacklist')])
850            for model in [bl_model for bl_model in bl_models if bl_model.model in self.env]:  # transient test mode
851                self.env[model.model].sudo().search([('message_bounce', '>', 0), ('email_normalized', '=', valid_email)])._message_reset_bounce(valid_email)
852
853    @api.model
854    def message_route(self, message, message_dict, model=None, thread_id=None, custom_values=None):
855        """ Attempt to figure out the correct target model, thread_id,
856        custom_values and user_id to use for an incoming message.
857        Multiple values may be returned, if a message had multiple
858        recipients matching existing mail.aliases, for example.
859
860        The following heuristics are used, in this order:
861
862         * if the message replies to an existing thread by having a Message-Id
863           that matches an existing mail_message.message_id, we take the original
864           message model/thread_id pair and ignore custom_value as no creation will
865           take place;
866         * look for a mail.alias entry matching the message recipients and use the
867           corresponding model, thread_id, custom_values and user_id. This could
868           lead to a thread update or creation depending on the alias;
869         * fallback on provided ``model``, ``thread_id`` and ``custom_values``;
870         * raise an exception as no route has been found
871
872        :param string message: an email.message instance
873        :param dict message_dict: dictionary holding parsed message variables
874        :param string model: the fallback model to use if the message does not match
875            any of the currently configured mail aliases (may be None if a matching
876            alias is supposed to be present)
877        :type dict custom_values: optional dictionary of default field values
878            to pass to ``message_new`` if a new record needs to be created.
879            Ignored if the thread record already exists, and also if a matching
880            mail.alias was found (aliases define their own defaults)
881        :param int thread_id: optional ID of the record/thread from ``model`` to
882            which this mail should be attached. Only used if the message does not
883            reply to an existing thread and does not match any mail alias.
884        :return: list of routes [(model, thread_id, custom_values, user_id, alias)]
885
886        :raises: ValueError, TypeError
887        """
888        if not isinstance(message, EmailMessage):
889            raise TypeError('message must be an email.message.EmailMessage at this point')
890        catchall_alias = self.env['ir.config_parameter'].sudo().get_param("mail.catchall.alias")
891        bounce_alias = self.env['ir.config_parameter'].sudo().get_param("mail.bounce.alias")
892        bounce_alias_static = tools.str2bool(self.env['ir.config_parameter'].sudo().get_param("mail.bounce.alias.static", "False"))
893        fallback_model = model
894
895        # get email.message.Message variables for future processing
896        local_hostname = socket.gethostname()
897        message_id = message_dict['message_id']
898
899        # compute references to find if message is a reply to an existing thread
900        thread_references = message_dict['references'] or message_dict['in_reply_to']
901        msg_references = [
902            re.sub(r'[\r\n\t ]+', r'', ref)  # "Unfold" buggy references
903            for ref in tools.mail_header_msgid_re.findall(thread_references)
904            if 'reply_to' not in ref
905        ]
906        mail_messages = self.env['mail.message'].sudo().search([('message_id', 'in', msg_references)], limit=1, order='id desc, message_id')
907        is_a_reply = bool(mail_messages)
908        reply_model, reply_thread_id = mail_messages.model, mail_messages.res_id
909
910        # author and recipients
911        email_from = message_dict['email_from']
912        email_from_localpart = (tools.email_split(email_from) or [''])[0].split('@', 1)[0].lower()
913        email_to = message_dict['to']
914        email_to_localparts = [
915            e.split('@', 1)[0].lower()
916            for e in (tools.email_split(email_to) or [''])
917        ]
918        # Delivered-To is a safe bet in most modern MTAs, but we have to fallback on To + Cc values
919        # for all the odd MTAs out there, as there is no standard header for the envelope's `rcpt_to` value.
920        rcpt_tos_localparts = [
921            e.split('@')[0].lower()
922            for e in tools.email_split(message_dict['recipients'])
923        ]
924        rcpt_tos_valid_localparts = [to for to in rcpt_tos_localparts]
925
926        # 0. Handle bounce: verify whether this is a bounced email and use it to collect bounce data and update notifications for customers
927        #    Bounce regex: typical form of bounce is bounce_alias+128-crm.lead-34@domain
928        #       group(1) = the mail ID; group(2) = the model (if any); group(3) = the record ID
929        #    Bounce message (not alias)
930        #       See http://datatracker.ietf.org/doc/rfc3462/?include_text=1
931        #        As all MTA does not respect this RFC (googlemail is one of them),
932        #       we also need to verify if the message come from "mailer-daemon"
933        #    If not a bounce: reset bounce information
934        if bounce_alias and any(email.startswith(bounce_alias) for email in email_to_localparts):
935            bounce_re = re.compile("%s\+(\d+)-?([\w.]+)?-?(\d+)?" % re.escape(bounce_alias), re.UNICODE)
936            bounce_match = bounce_re.search(email_to)
937            if bounce_match:
938                self._routing_handle_bounce(message, message_dict)
939                return []
940        if bounce_alias and bounce_alias_static and any(email == bounce_alias for email in email_to_localparts):
941            self._routing_handle_bounce(message, message_dict)
942            return []
943        if message.get_content_type() == 'multipart/report' or email_from_localpart == 'mailer-daemon':
944            self._routing_handle_bounce(message, message_dict)
945            return []
946        self._routing_reset_bounce(message, message_dict)
947
948        # 1. Handle reply
949        #    if destination = alias with different model -> consider it is a forward and not a reply
950        #    if destination = alias with same model -> check contact settings as they still apply
951        if reply_model and reply_thread_id:
952            other_model_aliases = self.env['mail.alias'].search([
953                '&', '&',
954                ('alias_name', '!=', False),
955                ('alias_name', 'in', email_to_localparts),
956                ('alias_model_id.model', '!=', reply_model),
957            ])
958            if other_model_aliases:
959                is_a_reply = False
960                rcpt_tos_valid_localparts = [to for to in rcpt_tos_valid_localparts if to in other_model_aliases.mapped('alias_name')]
961
962        if is_a_reply:
963            dest_aliases = self.env['mail.alias'].search([
964                ('alias_name', 'in', rcpt_tos_localparts),
965                ('alias_model_id.model', '=', reply_model)
966            ], limit=1)
967
968            user_id = self._mail_find_user_for_gateway(email_from, alias=dest_aliases).id or self._uid
969            route = self._routing_check_route(
970                message, message_dict,
971                (reply_model, reply_thread_id, custom_values, user_id, dest_aliases),
972                raise_exception=False)
973            if route:
974                _logger.info(
975                    'Routing mail from %s to %s with Message-Id %s: direct reply to msg: model: %s, thread_id: %s, custom_values: %s, uid: %s',
976                    email_from, email_to, message_id, reply_model, reply_thread_id, custom_values, self._uid)
977                return [route]
978            elif route is False:
979                return []
980
981        # 2. Handle new incoming email by checking aliases and applying their settings
982        if rcpt_tos_localparts:
983            # no route found for a matching reference (or reply), so parent is invalid
984            message_dict.pop('parent_id', None)
985
986            # check it does not directly contact catchall
987            if catchall_alias and email_to_localparts and all(email_localpart == catchall_alias for email_localpart in email_to_localparts):
988                _logger.info('Routing mail from %s to %s with Message-Id %s: direct write to catchall, bounce', email_from, email_to, message_id)
989                body = self.env.ref('mail.mail_bounce_catchall')._render({
990                    'message': message,
991                }, engine='ir.qweb')
992                self._routing_create_bounce_email(email_from, body, message, references=message_id, reply_to=self.env.company.email)
993                return []
994
995            dest_aliases = self.env['mail.alias'].search([('alias_name', 'in', rcpt_tos_valid_localparts)])
996            if dest_aliases:
997                routes = []
998                for alias in dest_aliases:
999                    user_id = self._mail_find_user_for_gateway(email_from, alias=alias).id or self._uid
1000                    route = (alias.alias_model_id.model, alias.alias_force_thread_id, ast.literal_eval(alias.alias_defaults), user_id, alias)
1001                    route = self._routing_check_route(message, message_dict, route, raise_exception=True)
1002                    if route:
1003                        _logger.info(
1004                            'Routing mail from %s to %s with Message-Id %s: direct alias match: %r',
1005                            email_from, email_to, message_id, route)
1006                        routes.append(route)
1007                return routes
1008
1009        # 3. Fallback to the provided parameters, if they work
1010        if fallback_model:
1011            # no route found for a matching reference (or reply), so parent is invalid
1012            message_dict.pop('parent_id', None)
1013            user_id = self._mail_find_user_for_gateway(email_from).id or self._uid
1014            route = self._routing_check_route(
1015                message, message_dict,
1016                (fallback_model, thread_id, custom_values, user_id, None),
1017                raise_exception=True)
1018            if route:
1019                _logger.info(
1020                    'Routing mail from %s to %s with Message-Id %s: fallback to model:%s, thread_id:%s, custom_values:%s, uid:%s',
1021                    email_from, email_to, message_id, fallback_model, thread_id, custom_values, user_id)
1022                return [route]
1023
1024        # ValueError if no routes found and if no bounce occured
1025        raise ValueError(
1026            'No possible route found for incoming message from %s to %s (Message-Id %s:). '
1027            'Create an appropriate mail.alias or force the destination model.' %
1028            (email_from, email_to, message_id)
1029        )
1030
1031    @api.model
1032    def _message_route_process(self, message, message_dict, routes):
1033        self = self.with_context(attachments_mime_plainxml=True) # import XML attachments as text
1034        # postpone setting message_dict.partner_ids after message_post, to avoid double notifications
1035        original_partner_ids = message_dict.pop('partner_ids', [])
1036        thread_id = False
1037        for model, thread_id, custom_values, user_id, alias in routes or ():
1038            subtype_id = False
1039            related_user = self.env['res.users'].browse(user_id)
1040            Model = self.env[model].with_context(mail_create_nosubscribe=True, mail_create_nolog=True)
1041            if not (thread_id and hasattr(Model, 'message_update') or hasattr(Model, 'message_new')):
1042                raise ValueError(
1043                    "Undeliverable mail with Message-Id %s, model %s does not accept incoming emails" %
1044                    (message_dict['message_id'], model)
1045                )
1046
1047            # disabled subscriptions during message_new/update to avoid having the system user running the
1048            # email gateway become a follower of all inbound messages
1049            ModelCtx = Model.with_user(related_user).sudo()
1050            if thread_id and hasattr(ModelCtx, 'message_update'):
1051                thread = ModelCtx.browse(thread_id)
1052                thread.message_update(message_dict)
1053            else:
1054                # if a new thread is created, parent is irrelevant
1055                message_dict.pop('parent_id', None)
1056                thread = ModelCtx.message_new(message_dict, custom_values)
1057                thread_id = thread.id
1058                subtype_id = thread._creation_subtype().id
1059
1060            # replies to internal message are considered as notes, but parent message
1061            # author is added in recipients to ensure he is notified of a private answer
1062            parent_message = False
1063            if message_dict.get('parent_id'):
1064                parent_message = self.env['mail.message'].sudo().browse(message_dict['parent_id'])
1065            partner_ids = []
1066            if not subtype_id:
1067                if message_dict.get('is_internal'):
1068                    subtype_id = self.env['ir.model.data'].xmlid_to_res_id('mail.mt_note')
1069                    if parent_message and parent_message.author_id:
1070                        partner_ids = [parent_message.author_id.id]
1071                else:
1072                    subtype_id = self.env['ir.model.data'].xmlid_to_res_id('mail.mt_comment')
1073
1074            post_params = dict(subtype_id=subtype_id, partner_ids=partner_ids, **message_dict)
1075            # remove computational values not stored on mail.message and avoid warnings when creating it
1076            for x in ('from', 'to', 'cc', 'recipients', 'references', 'in_reply_to', 'bounced_email', 'bounced_message', 'bounced_msg_id', 'bounced_partner'):
1077                post_params.pop(x, None)
1078            new_msg = False
1079            if thread._name == 'mail.thread':  # message with parent_id not linked to record
1080                new_msg = thread.message_notify(**post_params)
1081            else:
1082                # parsing should find an author independently of user running mail gateway, and ensure it is not odoobot
1083                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')
1084                thread = thread.with_context(mail_create_nosubscribe=not partner_from_found)
1085                new_msg = thread.message_post(**post_params)
1086
1087            if new_msg and original_partner_ids:
1088                # postponed after message_post, because this is an external message and we don't want to create
1089                # duplicate emails due to notifications
1090                new_msg.write({'partner_ids': original_partner_ids})
1091        return thread_id
1092
1093    @api.model
1094    def message_process(self, model, message, custom_values=None,
1095                        save_original=False, strip_attachments=False,
1096                        thread_id=None):
1097        """ Process an incoming RFC2822 email message, relying on
1098            ``mail.message.parse()`` for the parsing operation,
1099            and ``message_route()`` to figure out the target model.
1100
1101            Once the target model is known, its ``message_new`` method
1102            is called with the new message (if the thread record did not exist)
1103            or its ``message_update`` method (if it did).
1104
1105           :param string model: the fallback model to use if the message
1106               does not match any of the currently configured mail aliases
1107               (may be None if a matching alias is supposed to be present)
1108           :param message: source of the RFC2822 message
1109           :type message: string or xmlrpclib.Binary
1110           :type dict custom_values: optional dictionary of field values
1111                to pass to ``message_new`` if a new record needs to be created.
1112                Ignored if the thread record already exists, and also if a
1113                matching mail.alias was found (aliases define their own defaults)
1114           :param bool save_original: whether to keep a copy of the original
1115                email source attached to the message after it is imported.
1116           :param bool strip_attachments: whether to strip all attachments
1117                before processing the message, in order to save some space.
1118           :param int thread_id: optional ID of the record/thread from ``model``
1119               to which this mail should be attached. When provided, this
1120               overrides the automatic detection based on the message
1121               headers.
1122        """
1123        # extract message bytes - we are forced to pass the message as binary because
1124        # we don't know its encoding until we parse its headers and hence can't
1125        # convert it to utf-8 for transport between the mailgate script and here.
1126        if isinstance(message, xmlrpclib.Binary):
1127            message = bytes(message.data)
1128        if isinstance(message, str):
1129            message = message.encode('utf-8')
1130        message = email.message_from_bytes(message, policy=email.policy.SMTP)
1131
1132        # parse the message, verify we are not in a loop by checking message_id is not duplicated
1133        msg_dict = self.message_parse(message, save_original=save_original)
1134        if strip_attachments:
1135            msg_dict.pop('attachments', None)
1136
1137        existing_msg_ids = self.env['mail.message'].search([('message_id', '=', msg_dict['message_id'])], limit=1)
1138        if existing_msg_ids:
1139            _logger.info('Ignored mail from %s to %s with Message-Id %s: found duplicated Message-Id during processing',
1140                         msg_dict.get('email_from'), msg_dict.get('to'), msg_dict.get('message_id'))
1141            return False
1142
1143        # find possible routes for the message
1144        routes = self.message_route(message, msg_dict, model, thread_id, custom_values)
1145        thread_id = self._message_route_process(message, msg_dict, routes)
1146        return thread_id
1147
1148    @api.model
1149    def message_new(self, msg_dict, custom_values=None):
1150        """Called by ``message_process`` when a new message is received
1151           for a given thread model, if the message did not belong to
1152           an existing thread.
1153           The default behavior is to create a new record of the corresponding
1154           model (based on some very basic info extracted from the message).
1155           Additional behavior may be implemented by overriding this method.
1156
1157           :param dict msg_dict: a map containing the email details and
1158                                 attachments. See ``message_process`` and
1159                                ``mail.message.parse`` for details.
1160           :param dict custom_values: optional dictionary of additional
1161                                      field values to pass to create()
1162                                      when creating the new thread record.
1163                                      Be careful, these values may override
1164                                      any other values coming from the message.
1165           :rtype: int
1166           :return: the id of the newly created thread object
1167        """
1168        data = {}
1169        if isinstance(custom_values, dict):
1170            data = custom_values.copy()
1171        fields = self.fields_get()
1172        name_field = self._rec_name or 'name'
1173        if name_field in fields and not data.get('name'):
1174            data[name_field] = msg_dict.get('subject', '')
1175        return self.create(data)
1176
1177    def message_update(self, msg_dict, update_vals=None):
1178        """Called by ``message_process`` when a new message is received
1179           for an existing thread. The default behavior is to update the record
1180           with update_vals taken from the incoming email.
1181           Additional behavior may be implemented by overriding this
1182           method.
1183           :param dict msg_dict: a map containing the email details and
1184                               attachments. See ``message_process`` and
1185                               ``mail.message.parse()`` for details.
1186           :param dict update_vals: a dict containing values to update records
1187                              given their ids; if the dict is None or is
1188                              void, no write operation is performed.
1189        """
1190        if update_vals:
1191            self.write(update_vals)
1192        return True
1193
1194    def _message_receive_bounce(self, email, partner):
1195        """Called by ``message_process`` when a bounce email (such as Undelivered
1196        Mail Returned to Sender) is received for an existing thread. The default
1197        behavior is to do nothing. This method is meant to be overridden in various
1198        modules to add some specific behavior like blacklist management or mass
1199        mailing statistics update. check is an integer  ``message_bounce`` column exists.
1200        If it is the case, its content is incremented.
1201
1202        :param string email: email that caused the bounce;
1203        :param record partner: partner matching the bounced email address, if any;
1204        """
1205        pass
1206
1207    def _message_reset_bounce(self, email):
1208        """Called by ``message_process`` when an email is considered as not being
1209        a bounce. The default behavior is to do nothing. This method is meant to
1210        be overridden in various modules to add some specific behavior like
1211        blacklist management.
1212
1213        :param string email: email for which to reset bounce information
1214        """
1215        pass
1216
1217    def _message_parse_extract_payload_postprocess(self, message, payload_dict):
1218        """ Perform some cleaning / postprocess in the body and attachments
1219        extracted from the email. Note that this processing is specific to the
1220        mail module, and should not contain security or generic html cleaning.
1221        Indeed those aspects should be covered by the html_sanitize method
1222        located in tools. """
1223        body, attachments = payload_dict['body'], payload_dict['attachments']
1224        if not body:
1225            return payload_dict
1226        try:
1227            root = lxml.html.fromstring(body)
1228        except ValueError:
1229            # In case the email client sent XHTML, fromstring will fail because 'Unicode strings
1230            # with encoding declaration are not supported'.
1231            root = lxml.html.fromstring(body.encode('utf-8'))
1232
1233        postprocessed = False
1234        to_remove = []
1235        for node in root.iter():
1236            if 'o_mail_notification' in (node.get('class') or '') or 'o_mail_notification' in (node.get('summary') or ''):
1237                postprocessed = True
1238                if node.getparent() is not None:
1239                    to_remove.append(node)
1240            if node.tag == 'img' and node.get('src', '').startswith('cid:'):
1241                cid = node.get('src').split(':', 1)[1]
1242                related_attachment = [attach for attach in attachments if attach[2] and attach[2].get('cid') == cid]
1243                if related_attachment:
1244                    node.set('data-filename', related_attachment[0][0])
1245                    postprocessed = True
1246
1247        for node in to_remove:
1248            node.getparent().remove(node)
1249        if postprocessed:
1250            body = etree.tostring(root, pretty_print=False, encoding='unicode')
1251        return {'body': body, 'attachments': attachments}
1252
1253    def _message_parse_extract_payload(self, message, save_original=False):
1254        """Extract body as HTML and attachments from the mail message"""
1255        attachments = []
1256        body = u''
1257        if save_original:
1258            attachments.append(self._Attachment('original_email.eml', message.as_string(), {}))
1259
1260        # Be careful, content-type may contain tricky content like in the
1261        # following example so test the MIME type with startswith()
1262        #
1263        # Content-Type: multipart/related;
1264        #   boundary="_004_3f1e4da175f349248b8d43cdeb9866f1AMSPR06MB343eurprd06pro_";
1265        #   type="text/html"
1266        if message.get_content_maintype() == 'text':
1267            encoding = message.get_content_charset()
1268            body = message.get_content()
1269            body = tools.ustr(body, encoding, errors='replace')
1270            if message.get_content_type() == 'text/plain':
1271                # text/plain -> <pre/>
1272                body = tools.append_content_to_html(u'', body, preserve=True)
1273        else:
1274            alternative = False
1275            mixed = False
1276            html = u''
1277            for part in message.walk():
1278                if part.get_content_type() == 'multipart/alternative':
1279                    alternative = True
1280                if part.get_content_type() == 'multipart/mixed':
1281                    mixed = True
1282                if part.get_content_maintype() == 'multipart':
1283                    continue  # skip container
1284
1285                filename = part.get_filename()  # I may not properly handle all charsets
1286                encoding = part.get_content_charset()  # None if attachment
1287
1288                # 0) Inline Attachments -> attachments, with a third part in the tuple to match cid / attachment
1289                if filename and part.get('content-id'):
1290                    inner_cid = part.get('content-id').strip('><')
1291                    attachments.append(self._Attachment(filename, part.get_content(), {'cid': inner_cid}))
1292                    continue
1293                # 1) Explicit Attachments -> attachments
1294                if filename or part.get('content-disposition', '').strip().startswith('attachment'):
1295                    attachments.append(self._Attachment(filename or 'attachment', part.get_content(), {}))
1296                    continue
1297                # 2) text/plain -> <pre/>
1298                if part.get_content_type() == 'text/plain' and (not alternative or not body):
1299                    body = tools.append_content_to_html(body, tools.ustr(part.get_content(),
1300                                                                         encoding, errors='replace'), preserve=True)
1301                # 3) text/html -> raw
1302                elif part.get_content_type() == 'text/html':
1303                    # mutlipart/alternative have one text and a html part, keep only the second
1304                    # mixed allows several html parts, append html content
1305                    append_content = not alternative or (html and mixed)
1306                    html = tools.ustr(part.get_content(), encoding, errors='replace')
1307                    if not append_content:
1308                        body = html
1309                    else:
1310                        body = tools.append_content_to_html(body, html, plaintext=False)
1311                    # we only strip_classes here everything else will be done in by html field of mail.message
1312                    body = tools.html_sanitize(body, sanitize_tags=False, strip_classes=True)
1313                # 4) Anything else -> attachment
1314                else:
1315                    attachments.append(self._Attachment(filename or 'attachment', part.get_content(), {}))
1316
1317        return self._message_parse_extract_payload_postprocess(message, {'body': body, 'attachments': attachments})
1318
1319    def _message_parse_extract_bounce(self, email_message, message_dict):
1320        """ Parse email and extract bounce information to be used in future
1321        processing.
1322
1323        :param email_message: an email.message instance;
1324        :param message_dict: dictionary holding already-parsed values;
1325
1326        :return dict: bounce-related values will be added, containing
1327
1328          * bounced_email: email that bounced (normalized);
1329          * bounce_partner: res.partner recordset whose email_normalized =
1330            bounced_email;
1331          * bounced_msg_id: list of message_ID references (<...@myserver>) linked
1332            to the email that bounced;
1333          * bounced_message: if found, mail.message recordset matching bounced_msg_id;
1334        """
1335        if not isinstance(email_message, EmailMessage):
1336            raise TypeError('message must be an email.message.EmailMessage at this point')
1337
1338        email_part = next((part for part in email_message.walk() if part.get_content_type() in {'message/rfc822', 'text/rfc822-headers'}), None)
1339        dsn_part = next((part for part in email_message.walk() if part.get_content_type() == 'message/delivery-status'), None)
1340
1341        bounced_email = False
1342        bounced_partner = self.env['res.partner'].sudo()
1343        if dsn_part and len(dsn_part.get_payload()) > 1:
1344            dsn = dsn_part.get_payload()[1]
1345            final_recipient_data = tools.decode_message_header(dsn, 'Final-Recipient')
1346            bounced_email = tools.email_normalize(final_recipient_data.split(';', 1)[1].strip())
1347            if bounced_email:
1348                bounced_partner = self.env['res.partner'].sudo().search([('email_normalized', '=', bounced_email)])
1349
1350        bounced_msg_id = False
1351        bounced_message = self.env['mail.message'].sudo()
1352        if email_part:
1353            if email_part.get_content_type() == 'text/rfc822-headers':
1354                # Convert the message body into a message itself
1355                email_payload = message_from_string(email_part.get_payload(), policy=policy.SMTP)
1356            else:
1357                email_payload = email_part.get_payload()[0]
1358            bounced_msg_id = tools.mail_header_msgid_re.findall(tools.decode_message_header(email_payload, 'Message-Id'))
1359            if bounced_msg_id:
1360                bounced_message = self.env['mail.message'].sudo().search([('message_id', 'in', bounced_msg_id)])
1361
1362        return {
1363            'bounced_email': bounced_email,
1364            'bounced_partner': bounced_partner,
1365            'bounced_msg_id': bounced_msg_id,
1366            'bounced_message': bounced_message,
1367        }
1368
1369    @api.model
1370    def message_parse(self, message, save_original=False):
1371        """ Parses an email.message.Message representing an RFC-2822 email
1372        and returns a generic dict holding the message details.
1373
1374        :param message: email to parse
1375        :type message: email.message.Message
1376        :param bool save_original: whether the returned dict should include
1377            an ``original`` attachment containing the source of the message
1378        :rtype: dict
1379        :return: A dict with the following structure, where each field may not
1380            be present if missing in original message::
1381
1382            { 'message_id': msg_id,
1383              'subject': subject,
1384              'email_from': from,
1385              'to': to + delivered-to,
1386              'cc': cc,
1387              'recipients': delivered-to + to + cc + resent-to + resent-cc,
1388              'partner_ids': partners found based on recipients emails,
1389              'body': unified_body,
1390              'references': references,
1391              'in_reply_to': in-reply-to,
1392              'parent_id': parent mail.message based on in_reply_to or references,
1393              'is_internal': answer to an internal message (note),
1394              'date': date,
1395              'attachments': [('file1', 'bytes'),
1396                              ('file2', 'bytes')}
1397            }
1398        """
1399        if not isinstance(message, EmailMessage):
1400            raise ValueError(_('Message should be a valid EmailMessage instance'))
1401        msg_dict = {'message_type': 'email'}
1402
1403        message_id = message.get('Message-Id')
1404        if not message_id:
1405            # Very unusual situation, be we should be fault-tolerant here
1406            message_id = "<%s@localhost>" % time.time()
1407            _logger.debug('Parsing Message without message-id, generating a random one: %s', message_id)
1408        msg_dict['message_id'] = message_id.strip()
1409
1410        if message.get('Subject'):
1411            msg_dict['subject'] = tools.decode_message_header(message, 'Subject')
1412
1413        email_from = tools.decode_message_header(message, 'From')
1414        email_cc = tools.decode_message_header(message, 'cc')
1415        email_from_list = tools.email_split_and_format(email_from)
1416        email_cc_list = tools.email_split_and_format(email_cc)
1417        msg_dict['email_from'] = email_from_list[0] if email_from_list else email_from
1418        msg_dict['from'] = msg_dict['email_from']  # compatibility for message_new
1419        msg_dict['cc'] = ','.join(email_cc_list) if email_cc_list else email_cc
1420        # Delivered-To is a safe bet in most modern MTAs, but we have to fallback on To + Cc values
1421        # for all the odd MTAs out there, as there is no standard header for the envelope's `rcpt_to` value.
1422        msg_dict['recipients'] = ','.join(set(formatted_email
1423            for address in [
1424                tools.decode_message_header(message, 'Delivered-To'),
1425                tools.decode_message_header(message, 'To'),
1426                tools.decode_message_header(message, 'Cc'),
1427                tools.decode_message_header(message, 'Resent-To'),
1428                tools.decode_message_header(message, 'Resent-Cc')
1429            ] if address
1430            for formatted_email in tools.email_split_and_format(address))
1431        )
1432        msg_dict['to'] = ','.join(set(formatted_email
1433            for address in [
1434                tools.decode_message_header(message, 'Delivered-To'),
1435                tools.decode_message_header(message, 'To')
1436            ] if address
1437            for formatted_email in tools.email_split_and_format(address))
1438        )
1439        partner_ids = [x.id for x in self._mail_find_partner_from_emails(tools.email_split(msg_dict['recipients']), records=self) if x]
1440        msg_dict['partner_ids'] = partner_ids
1441        # compute references to find if email_message is a reply to an existing thread
1442        msg_dict['references'] = tools.decode_message_header(message, 'References')
1443        msg_dict['in_reply_to'] = tools.decode_message_header(message, 'In-Reply-To').strip()
1444
1445        if message.get('Date'):
1446            try:
1447                date_hdr = tools.decode_message_header(message, 'Date')
1448                parsed_date = dateutil.parser.parse(date_hdr, fuzzy=True)
1449                if parsed_date.utcoffset() is None:
1450                    # naive datetime, so we arbitrarily decide to make it
1451                    # UTC, there's no better choice. Should not happen,
1452                    # as RFC2822 requires timezone offset in Date headers.
1453                    stored_date = parsed_date.replace(tzinfo=pytz.utc)
1454                else:
1455                    stored_date = parsed_date.astimezone(tz=pytz.utc)
1456            except Exception:
1457                _logger.info('Failed to parse Date header %r in incoming mail '
1458                             'with message-id %r, assuming current date/time.',
1459                             message.get('Date'), message_id)
1460                stored_date = datetime.datetime.now()
1461            msg_dict['date'] = stored_date.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT)
1462
1463        parent_ids = False
1464        if msg_dict['in_reply_to']:
1465            parent_ids = self.env['mail.message'].search([('message_id', '=', msg_dict['in_reply_to'])], limit=1)
1466        if msg_dict['references'] and not parent_ids:
1467            references_msg_id_list = tools.mail_header_msgid_re.findall(msg_dict['references'])
1468            parent_ids = self.env['mail.message'].search([('message_id', 'in', [x.strip() for x in references_msg_id_list])], limit=1)
1469        if parent_ids:
1470            msg_dict['parent_id'] = parent_ids.id
1471            msg_dict['is_internal'] = parent_ids.subtype_id and parent_ids.subtype_id.internal or False
1472
1473        msg_dict.update(self._message_parse_extract_payload(message, save_original=save_original))
1474        msg_dict.update(self._message_parse_extract_bounce(message, msg_dict))
1475        return msg_dict
1476
1477    # ------------------------------------------------------
1478    # RECIPIENTS MANAGEMENT TOOLS
1479    # ------------------------------------------------------
1480
1481    @api.model
1482    def _message_get_default_recipients_on_records(self, records):
1483        """ Moved to ``BaseModel._message_get_default_recipients()`` """
1484        return records._message_get_default_recipients()
1485
1486    def _message_add_suggested_recipient(self, result, partner=None, email=None, reason=''):
1487        """ Called by _message_get_suggested_recipients, to add a suggested
1488            recipient in the result dictionary. The form is :
1489                partner_id, partner_name<partner_email> or partner_name, reason """
1490        self.ensure_one()
1491        if email and not partner:
1492            # get partner info from email
1493            partner_info = self._message_partner_info_from_emails([email])[0]
1494            if partner_info.get('partner_id'):
1495                partner = self.env['res.partner'].sudo().browse([partner_info['partner_id']])[0]
1496        if email and email in [val[1] for val in result[self.ids[0]]]:  # already existing email -> skip
1497            return result
1498        if partner and partner in self.message_partner_ids:  # recipient already in the followers -> skip
1499            return result
1500        if partner and partner.id in [val[0] for val in result[self.ids[0]]]:  # already existing partner ID -> skip
1501            return result
1502        if partner and partner.email:  # complete profile: id, name <email>
1503            result[self.ids[0]].append((partner.id, partner.email_formatted, reason))
1504        elif partner:  # incomplete profile: id, name
1505            result[self.ids[0]].append((partner.id, '%s' % (partner.name), reason))
1506        else:  # unknown partner, we are probably managing an email address
1507            result[self.ids[0]].append((False, email, reason))
1508        return result
1509
1510    def _message_get_suggested_recipients(self):
1511        """ Returns suggested recipients for ids. Those are a list of
1512        tuple (partner_id, partner_name, reason), to be managed by Chatter. """
1513        result = dict((res_id, []) for res_id in self.ids)
1514        if 'user_id' in self._fields:
1515            for obj in self.sudo():  # SUPERUSER because of a read on res.users that would crash otherwise
1516                if not obj.user_id or not obj.user_id.partner_id:
1517                    continue
1518                obj._message_add_suggested_recipient(result, partner=obj.user_id.partner_id, reason=self._fields['user_id'].string)
1519        return result
1520
1521    def _mail_search_on_user(self, normalized_emails, extra_domain=False):
1522        """ Find partners linked to users, given an email address that will
1523        be normalized. Search is done as sudo on res.users model to avoid domain
1524        on partner like ('user_ids', '!=', False) that would not be efficient. """
1525        domain = [('email_normalized', 'in', normalized_emails)]
1526        if extra_domain:
1527            domain = expression.AND([domain, extra_domain])
1528        partners = self.env['res.users'].sudo().search(domain, order='name ASC').mapped('partner_id')
1529        # return a search on partner to filter results current user should not see (multi company for example)
1530        return self.env['res.partner'].search([('id', 'in', partners.ids)])
1531
1532    def _mail_search_on_partner(self, normalized_emails, extra_domain=False):
1533        domain = [('email_normalized', 'in', normalized_emails)]
1534        if extra_domain:
1535            domain = expression.AND([domain, extra_domain])
1536        return self.env['res.partner'].search(domain)
1537
1538    def _mail_find_user_for_gateway(self, email, alias=None):
1539        """ Utility method to find user from email address that can create documents
1540        in the target model. Purpose is to link document creation to users whenever
1541        possible, for example when creating document through mailgateway.
1542
1543        Heuristic
1544
1545          * alias owner record: fetch in its followers for user with matching email;
1546          * find any user with matching emails;
1547          * try alias owner as fallback;
1548
1549        Note that standard search order is applied.
1550
1551        :param str email: will be sanitized and parsed to find email;
1552        :param mail.alias alias: optional alias. Used to fetch owner followers
1553          or fallback user (alias owner);
1554        :param fallback_model: if not alias, related model to check access rights;
1555
1556        :return res.user user: user matching email or void recordset if none found
1557        """
1558        # find normalized emails and exclude aliases (to avoid subscribing alias emails to records)
1559        normalized_email = tools.email_normalize(email)
1560        catchall_domain = self.env['ir.config_parameter'].sudo().get_param("mail.catchall.domain")
1561        if normalized_email and catchall_domain:
1562            left_part = normalized_email.split('@')[0] if normalized_email.split('@')[1] == catchall_domain.lower() else False
1563            if left_part:
1564                if self.env['mail.alias'].sudo().search_count([('alias_name', '=', left_part)]):
1565                    return self.env['res.users']
1566
1567        if alias and alias.alias_parent_model_id and alias.alias_parent_thread_id:
1568            followers = self.env['mail.followers'].search([
1569                ('res_model', '=', alias.alias_parent_model_id.model),
1570                ('res_id', '=', alias.alias_parent_thread_id)]
1571            ).mapped('partner_id')
1572        else:
1573            followers = self.env['res.partner']
1574
1575        follower_users = self.env['res.users'].search([
1576            ('partner_id', 'in', followers.ids), ('email_normalized', '=', normalized_email)
1577        ], limit=1) if followers else self.env['res.users']
1578        matching_user = follower_users[0] if follower_users else self.env['res.users']
1579        if matching_user:
1580            return matching_user
1581
1582        if not matching_user:
1583            std_users = self.env['res.users'].sudo().search([('email_normalized', '=', normalized_email)], limit=1, order='name ASC')
1584            matching_user = std_users[0] if std_users else self.env['res.users']
1585        if matching_user:
1586            return matching_user
1587
1588        if not matching_user and alias and alias.alias_user_id:
1589            matching_user = alias and alias.alias_user_id
1590        if matching_user:
1591            return matching_user
1592
1593        return matching_user
1594
1595    @api.model
1596    def _mail_find_partner_from_emails(self, emails, records=None, force_create=False):
1597        """ Utility method to find partners from email addresses. If no partner is
1598        found, create new partners if force_create is enabled. Search heuristics
1599
1600          * 1: check in records (record set) followers if records is mail.thread
1601               enabled and if check_followers parameter is enabled;
1602          * 2: search for partners with user;
1603          * 3: search for partners;
1604
1605        :param records: record set on which to check followers;
1606        :param list emails: list of email addresses for finding partner;
1607        :param boolean force_create: create a new partner if not found
1608
1609        :return list partners: a list of partner records ordered as given emails.
1610          If no partner has been found and/or created for a given emails its
1611          matching partner is an empty record.
1612        """
1613        if records and issubclass(type(records), self.pool['mail.thread']):
1614            followers = records.mapped('message_partner_ids')
1615        else:
1616            followers = self.env['res.partner']
1617        catchall_domain = self.env['ir.config_parameter'].sudo().get_param("mail.catchall.domain")
1618
1619        # first, build a normalized email list and remove those linked to aliases to avoid adding aliases as partners
1620        normalized_emails = [tools.email_normalize(contact) for contact in emails if tools.email_normalize(contact)]
1621        if catchall_domain:
1622            domain_left_parts = [email.split('@')[0] for email in normalized_emails if email and email.split('@')[1] == catchall_domain.lower()]
1623            if domain_left_parts:
1624                found_alias_names = self.env['mail.alias'].sudo().search([('alias_name', 'in', domain_left_parts)]).mapped('alias_name')
1625                normalized_emails = [email for email in normalized_emails if email.split('@')[0] not in found_alias_names]
1626
1627        done_partners = [follower for follower in followers if follower.email_normalized in normalized_emails]
1628        remaining = [email for email in normalized_emails if email not in [partner.email_normalized for partner in done_partners]]
1629
1630        user_partners = self._mail_search_on_user(remaining)
1631        done_partners += [user_partner for user_partner in user_partners]
1632        remaining = [email for email in normalized_emails if email not in [partner.email_normalized for partner in done_partners]]
1633
1634        partners = self._mail_search_on_partner(remaining)
1635        done_partners += [partner for partner in partners]
1636        remaining = [email for email in normalized_emails if email not in [partner.email_normalized for partner in done_partners]]
1637
1638        # iterate and keep ordering
1639        partners = []
1640        for contact in emails:
1641            normalized_email = tools.email_normalize(contact)
1642            partner = next((partner for partner in done_partners if partner.email_normalized == normalized_email), self.env['res.partner'])
1643            if not partner and force_create and normalized_email in normalized_emails:
1644                partner = self.env['res.partner'].browse(self.env['res.partner'].name_create(contact)[0])
1645            partners.append(partner)
1646        return partners
1647
1648    def _message_partner_info_from_emails(self, emails, link_mail=False):
1649        """ Convert a list of emails into a list partner_ids and a list
1650            new_partner_ids. The return value is non conventional because
1651            it is meant to be used by the mail widget.
1652
1653            :return dict: partner_ids and new_partner_ids """
1654        self.ensure_one()
1655        MailMessage = self.env['mail.message'].sudo()
1656        partners = self._mail_find_partner_from_emails(emails, records=self)
1657        result = list()
1658        for idx, contact in enumerate(emails):
1659            partner = partners[idx]
1660            partner_info = {'full_name': partner.email_formatted if partner else contact, 'partner_id': partner.id}
1661            result.append(partner_info)
1662            # link mail with this from mail to the new partner id
1663            if link_mail and partner:
1664                MailMessage.search([
1665                    ('email_from', '=ilike', partner.email_normalized),
1666                    ('author_id', '=', False)
1667                ]).write({'author_id': partner.id})
1668        return result
1669
1670    # ------------------------------------------------------
1671    # MESSAGE POST API
1672    # ------------------------------------------------------
1673
1674    def _message_post_process_attachments(self, attachments, attachment_ids, message_values):
1675        """ Preprocess attachments for mail_thread.message_post() or mail_mail.create().
1676
1677        :param list attachments: list of attachment tuples in the form ``(name,content)``, #todo xdo update that
1678                                 where content is NOT base64 encoded
1679        :param list attachment_ids: a list of attachment ids, not in tomany command form
1680        :param dict message_data: model: the model of the attachments parent record,
1681          res_id: the id of the attachments parent record
1682        """
1683        return_values = {}
1684        body = message_values.get('body')
1685        model = message_values['model']
1686        res_id = message_values['res_id']
1687
1688        m2m_attachment_ids = []
1689        if attachment_ids:
1690            # taking advantage of cache looks better in this case, to check
1691            filtered_attachment_ids = self.env['ir.attachment'].sudo().browse(attachment_ids).filtered(
1692                lambda a: a.res_model == 'mail.compose.message' and a.create_uid.id == self._uid)
1693            # update filtered (pending) attachments to link them to the proper record
1694            if filtered_attachment_ids:
1695                filtered_attachment_ids.write({'res_model': model, 'res_id': res_id})
1696            # prevent public and portal users from using attachments that are not theirs
1697            if not self.env.user.has_group('base.group_user'):
1698                attachment_ids = filtered_attachment_ids.ids
1699
1700            m2m_attachment_ids += [(4, id) for id in attachment_ids]
1701        # Handle attachments parameter, that is a dictionary of attachments
1702
1703        if attachments: # generate
1704            cids_in_body = set()
1705            names_in_body = set()
1706            cid_list = []
1707            name_list = []
1708
1709            if body:
1710                root = lxml.html.fromstring(tools.ustr(body))
1711                # first list all attachments that will be needed in body
1712                for node in root.iter('img'):
1713                    if node.get('src', '').startswith('cid:'):
1714                        cids_in_body.add(node.get('src').split('cid:')[1])
1715                    elif node.get('data-filename'):
1716                        names_in_body.add(node.get('data-filename'))
1717            attachement_values_list = []
1718
1719            # generate values
1720            for attachment in attachments:
1721                cid = False
1722                if len(attachment) == 2:
1723                    name, content = attachment
1724                elif len(attachment) == 3:
1725                    name, content, info = attachment
1726                    cid = info and info.get('cid')
1727                else:
1728                    continue
1729                if isinstance(content, str):
1730                    content = content.encode('utf-8')
1731                elif isinstance(content, EmailMessage):
1732                    content = content.as_bytes()
1733                elif content is None:
1734                    continue
1735                attachement_values= {
1736                    'name': name,
1737                    'datas': base64.b64encode(content),
1738                    'type': 'binary',
1739                    'description': name,
1740                    'res_model': model,
1741                    'res_id': res_id,
1742                }
1743                if body and (cid and cid in cids_in_body or name in names_in_body):
1744                    attachement_values['access_token'] = self.env['ir.attachment']._generate_access_token()
1745                attachement_values_list.append(attachement_values)
1746                # keep cid and name list synced with attachement_values_list length to match ids latter
1747                cid_list.append(cid)
1748                name_list.append(name)
1749            new_attachments = self.env['ir.attachment'].create(attachement_values_list)
1750            cid_mapping = {}
1751            name_mapping = {}
1752            for counter, new_attachment in enumerate(new_attachments):
1753                cid = cid_list[counter]
1754                if 'access_token' in attachement_values_list[counter]:
1755                    if cid:
1756                        cid_mapping[cid] = (new_attachment.id, attachement_values_list[counter]['access_token'])
1757                    name = name_list[counter]
1758                    name_mapping[name] = (new_attachment.id, attachement_values_list[counter]['access_token'])
1759                m2m_attachment_ids.append((4, new_attachment.id))
1760
1761            # note: right know we are only taking attachments and ignoring attachment_ids.
1762            if (cid_mapping or name_mapping) and body:
1763                postprocessed = False
1764                for node in root.iter('img'):
1765                    attachment_data = False
1766                    if node.get('src', '').startswith('cid:'):
1767                        cid = node.get('src').split('cid:')[1]
1768                        attachment_data = cid_mapping.get(cid)
1769                    if not attachment_data and node.get('data-filename'):
1770                        attachment_data = name_mapping.get(node.get('data-filename'), False)
1771                    if attachment_data:
1772                        node.set('src', '/web/image/%s?access_token=%s' % attachment_data)
1773                        postprocessed = True
1774                if postprocessed:
1775                    return_values['body'] = lxml.html.tostring(root, pretty_print=False, encoding='UTF-8')
1776        return_values['attachment_ids'] = m2m_attachment_ids
1777        return return_values
1778
1779    @api.returns('mail.message', lambda value: value.id)
1780    def message_post(self, *,
1781                     body='', subject=None, message_type='notification',
1782                     email_from=None, author_id=None, parent_id=False,
1783                     subtype_xmlid=None, subtype_id=False, partner_ids=None, channel_ids=None,
1784                     attachments=None, attachment_ids=None,
1785                     add_sign=True, record_name=False,
1786                     **kwargs):
1787        """ Post a new message in an existing thread, returning the new
1788            mail.message ID.
1789            :param str body: body of the message, usually raw HTML that will
1790                be sanitized
1791            :param str subject: subject of the message
1792            :param str message_type: see mail_message.message_type field. Can be anything but
1793                user_notification, reserved for message_notify
1794            :param int parent_id: handle thread formation
1795            :param int subtype_id: subtype_id of the message, mainly use fore
1796                followers mechanism
1797            :param list(int) partner_ids: partner_ids to notify
1798            :param list(int) channel_ids: channel_ids to notify
1799            :param list(tuple(str,str), tuple(str,str, dict) or int) attachments : list of attachment tuples in the form
1800                ``(name,content)`` or ``(name,content, info)``, where content is NOT base64 encoded
1801            :param list id attachment_ids: list of existing attachement to link to this message
1802                -Should only be setted by chatter
1803                -Attachement object attached to mail.compose.message(0) will be attached
1804                    to the related document.
1805            Extra keyword arguments will be used as default column values for the
1806            new mail.message record.
1807            :return int: ID of newly created mail.message
1808        """
1809        self.ensure_one()  # should always be posted on a record, use message_notify if no record
1810        # split message additional values from notify additional values
1811        msg_kwargs = dict((key, val) for key, val in kwargs.items() if key in self.env['mail.message']._fields)
1812        notif_kwargs = dict((key, val) for key, val in kwargs.items() if key not in msg_kwargs)
1813
1814        if self._name == 'mail.thread' or not self.id or message_type == 'user_notification':
1815            raise ValueError('message_post should only be call to post message on record. Use message_notify instead')
1816
1817        if 'model' in msg_kwargs or 'res_id' in msg_kwargs:
1818            raise ValueError("message_post doesn't support model and res_id parameters anymore. Please call message_post on record.")
1819        if 'subtype' in kwargs:
1820            raise ValueError("message_post doesn't support subtype parameter anymore. Please give a valid subtype_id or subtype_xmlid value instead.")
1821
1822        self = self._fallback_lang() # add lang to context imediatly since it will be usefull in various flows latter.
1823
1824        # Explicit access rights check, because display_name is computed as sudo.
1825        self.check_access_rights('read')
1826        self.check_access_rule('read')
1827        record_name = record_name or self.display_name
1828
1829        partner_ids = set(partner_ids or [])
1830        channel_ids = set(channel_ids or [])
1831
1832        if any(not isinstance(pc_id, int) for pc_id in partner_ids | channel_ids):
1833            raise ValueError('message_post partner_ids and channel_ids must be integer list, not commands')
1834
1835        # Find the message's author
1836        author_id, email_from = self._message_compute_author(author_id, email_from, raise_exception=True)
1837
1838        if subtype_xmlid:
1839            subtype_id = self.env['ir.model.data'].xmlid_to_res_id(subtype_xmlid)
1840        if not subtype_id:
1841            subtype_id = self.env['ir.model.data'].xmlid_to_res_id('mail.mt_note')
1842
1843        # automatically subscribe recipients if asked to
1844        if self._context.get('mail_post_autofollow') and partner_ids:
1845            self.message_subscribe(list(partner_ids))
1846
1847        MailMessage_sudo = self.env['mail.message'].sudo()
1848        if self._mail_flat_thread and not parent_id:
1849            parent_message = MailMessage_sudo.search([('res_id', '=', self.id), ('model', '=', self._name), ('message_type', '!=', 'user_notification')], order="id ASC", limit=1)
1850            # parent_message searched in sudo for performance, only used for id.
1851            # Note that with sudo we will match message with internal subtypes.
1852            parent_id = parent_message.id if parent_message else False
1853        elif parent_id:
1854            old_parent_id = parent_id
1855            parent_message = MailMessage_sudo.search([('id', '=', parent_id), ('parent_id', '!=', False)], limit=1)
1856            # avoid loops when finding ancestors
1857            processed_list = []
1858            if parent_message:
1859                new_parent_id = parent_message.parent_id and parent_message.parent_id.id
1860                while (new_parent_id and new_parent_id not in processed_list):
1861                    processed_list.append(new_parent_id)
1862                    parent_message = parent_message.parent_id
1863                parent_id = parent_message.id
1864
1865        values = dict(msg_kwargs)
1866        values.update({
1867            'author_id': author_id,
1868            'email_from': email_from,
1869            'model': self._name,
1870            'res_id': self.id,
1871            'body': body,
1872            'subject': subject or False,
1873            'message_type': message_type,
1874            'parent_id': parent_id,
1875            'subtype_id': subtype_id,
1876            'partner_ids': partner_ids,
1877            'channel_ids': channel_ids,
1878            'add_sign': add_sign,
1879            'record_name': record_name,
1880        })
1881        attachments = attachments or []
1882        attachment_ids = attachment_ids or []
1883        attachement_values = self._message_post_process_attachments(attachments, attachment_ids, values)
1884        values.update(attachement_values)  # attachement_ids, [body]
1885
1886        new_message = self._message_create(values)
1887
1888        # Set main attachment field if necessary
1889        self._message_set_main_attachment_id(values['attachment_ids'])
1890
1891        if values['author_id'] and values['message_type'] != 'notification' and not self._context.get('mail_create_nosubscribe'):
1892            if self.env['res.partner'].browse(values['author_id']).active:  # we dont want to add odoobot/inactive as a follower
1893                self._message_subscribe([values['author_id']])
1894
1895        self._message_post_after_hook(new_message, values)
1896        self._notify_thread(new_message, values, **notif_kwargs)
1897        return new_message
1898
1899    def _message_set_main_attachment_id(self, attachment_ids):  # todo move this out of mail.thread
1900        if not self._abstract and attachment_ids and not self.message_main_attachment_id:
1901            all_attachments = self.env['ir.attachment'].browse([attachment_tuple[1] for attachment_tuple in attachment_ids])
1902            prioritary_attachments = all_attachments.filtered(lambda x: x.mimetype.endswith('pdf')) \
1903                                     or all_attachments.filtered(lambda x: x.mimetype.startswith('image')) \
1904                                     or all_attachments
1905            self.sudo().with_context(tracking_disable=True).write({'message_main_attachment_id': prioritary_attachments[0].id})
1906
1907    def _message_post_after_hook(self, message, msg_vals):
1908        """ Hook to add custom behavior after having posted the message. Both
1909        message and computed value are given, to try to lessen query count by
1910        using already-computed values instead of having to rebrowse things. """
1911        pass
1912
1913    # ------------------------------------------------------
1914    # MESSAGE POST TOOLS
1915    # ------------------------------------------------------
1916
1917    def message_post_with_view(self, views_or_xmlid, **kwargs):
1918        """ Helper method to send a mail / post a message using a view_id to
1919        render using the ir.qweb engine. This method is stand alone, because
1920        there is nothing in template and composer that allows to handle
1921        views in batch. This method should probably disappear when templates
1922        handle ir ui views. """
1923        values = kwargs.pop('values', None) or dict()
1924        try:
1925            from odoo.addons.http_routing.models.ir_http import slug
1926            values['slug'] = slug
1927        except ImportError:
1928            values['slug'] = lambda self: self.id
1929        if isinstance(views_or_xmlid, str):
1930            views = self.env.ref(views_or_xmlid, raise_if_not_found=False)
1931        else:
1932            views = views_or_xmlid
1933        if not views:
1934            return
1935        for record in self:
1936            values['object'] = record
1937            rendered_template = views._render(values, engine='ir.qweb', minimal_qcontext=True)
1938            kwargs['body'] = rendered_template
1939            record.message_post_with_template(False, **kwargs)
1940
1941    def message_post_with_template(self, template_id, email_layout_xmlid=None, auto_commit=False, **kwargs):
1942        """ Helper method to send a mail with a template
1943            :param template_id : the id of the template to render to create the body of the message
1944            :param **kwargs : parameter to create a mail.compose.message woaerd (which inherit from mail.message)
1945        """
1946        # Get composition mode, or force it according to the number of record in self
1947        if not kwargs.get('composition_mode'):
1948            kwargs['composition_mode'] = 'comment' if len(self.ids) == 1 else 'mass_mail'
1949        if not kwargs.get('message_type'):
1950            kwargs['message_type'] = 'notification'
1951        res_id = kwargs.get('res_id', self.ids and self.ids[0] or 0)
1952        res_ids = kwargs.get('res_id') and [kwargs['res_id']] or self.ids
1953
1954        # Create the composer
1955        composer = self.env['mail.compose.message'].with_context(
1956            active_id=res_id,
1957            active_ids=res_ids,
1958            active_model=kwargs.get('model', self._name),
1959            default_composition_mode=kwargs['composition_mode'],
1960            default_model=kwargs.get('model', self._name),
1961            default_res_id=res_id,
1962            default_template_id=template_id,
1963            custom_layout=email_layout_xmlid,
1964        ).create(kwargs)
1965        # Simulate the onchange (like trigger in form the view) only
1966        # when having a template in single-email mode
1967        if template_id:
1968            update_values = composer.onchange_template_id(template_id, kwargs['composition_mode'], self._name, res_id)['value']
1969            composer.write(update_values)
1970        return composer.send_mail(auto_commit=auto_commit)
1971
1972    def message_notify(self, *,
1973                       partner_ids=False, parent_id=False, model=False, res_id=False,
1974                       author_id=None, email_from=None, body='', subject=False, **kwargs):
1975        """ Shortcut allowing to notify partners of messages that shouldn't be
1976        displayed on a document. It pushes notifications on inbox or by email depending
1977        on the user configuration, like other notifications. """
1978        if self:
1979            self.ensure_one()
1980        # split message additional values from notify additional values
1981        msg_kwargs = dict((key, val) for key, val in kwargs.items() if key in self.env['mail.message']._fields)
1982        notif_kwargs = dict((key, val) for key, val in kwargs.items() if key not in msg_kwargs)
1983
1984        author_id, email_from = self._message_compute_author(author_id, email_from, raise_exception=True)
1985
1986        if not partner_ids:
1987            _logger.warning('Message notify called without recipient_ids, skipping')
1988            return self.env['mail.message']
1989
1990        if not (model and res_id):  # both value should be set or none should be set (record)
1991            model = False
1992            res_id = False
1993
1994        MailThread = self.env['mail.thread']
1995        values = {
1996            'parent_id': parent_id,
1997            'model': self._name if self else model,
1998            'res_id': self.id if self else res_id,
1999            'message_type': 'user_notification',
2000            'subject': subject,
2001            'body': body,
2002            'author_id': author_id,
2003            'email_from': email_from,
2004            'partner_ids': partner_ids,
2005            'subtype_id': self.env['ir.model.data'].xmlid_to_res_id('mail.mt_note'),
2006            'is_internal': True,
2007            'record_name': False,
2008            'reply_to': MailThread._notify_get_reply_to(default=email_from, records=None)[False],
2009            'message_id': tools.generate_tracking_message_id('message-notify'),
2010        }
2011        values.update(msg_kwargs)
2012        new_message = MailThread._message_create(values)
2013        MailThread._notify_thread(new_message, values, **notif_kwargs)
2014        return new_message
2015
2016    def _message_log(self, *, body='', author_id=None, email_from=None, subject=False, message_type='notification', **kwargs):
2017        """ Shortcut allowing to post note on a document. It does not perform
2018        any notification and pre-computes some values to have a short code
2019        as optimized as possible. This method is private as it does not check
2020        access rights and perform the message creation as sudo to speedup
2021        the log process. This method should be called within methods where
2022        access rights are already granted to avoid privilege escalation. """
2023        self.ensure_one()
2024        author_id, email_from = self._message_compute_author(author_id, email_from, raise_exception=False)
2025
2026        message_values = {
2027            'subject': subject,
2028            'body': body,
2029            'author_id': author_id,
2030            'email_from': email_from,
2031            'message_type': message_type,
2032            'model': kwargs.get('model', self._name),
2033            'res_id': self.ids[0] if self.ids else False,
2034            'subtype_id': self.env['ir.model.data'].xmlid_to_res_id('mail.mt_note'),
2035            'is_internal': True,
2036            'record_name': False,
2037            'reply_to': self.env['mail.thread']._notify_get_reply_to(default=email_from, records=None)[False],
2038            'message_id': tools.generate_tracking_message_id('message-notify'),  # why? this is all but a notify
2039        }
2040        message_values.update(kwargs)
2041        return self.sudo()._message_create(message_values)
2042
2043    def _message_log_batch(self, bodies, author_id=None, email_from=None, subject=False, message_type='notification'):
2044        """ Shortcut allowing to post notes on a batch of documents. It achieve the
2045        same purpose as _message_log, done in batch to speedup quick note log.
2046
2047          :param bodies: dict {record_id: body}
2048        """
2049        author_id, email_from = self._message_compute_author(author_id, email_from, raise_exception=False)
2050
2051        base_message_values = {
2052            'subject': subject,
2053            'author_id': author_id,
2054            'email_from': email_from,
2055            'message_type': message_type,
2056            'model': self._name,
2057            'subtype_id': self.env['ir.model.data'].xmlid_to_res_id('mail.mt_note'),
2058            'is_internal': True,
2059            'record_name': False,
2060            'reply_to': self.env['mail.thread']._notify_get_reply_to(default=email_from, records=None)[False],
2061            'message_id': tools.generate_tracking_message_id('message-notify'),  # why? this is all but a notify
2062        }
2063        values_list = [dict(base_message_values,
2064                            res_id=record.id,
2065                            body=bodies.get(record.id, ''))
2066                       for record in self]
2067        return self.sudo()._message_create(values_list)
2068
2069    def _message_compute_author(self, author_id=None, email_from=None, raise_exception=True):
2070        """ Tool method computing author information for messages. Purpose is
2071        to ensure maximum coherence between author / current user / email_from
2072        when sending emails. """
2073        if author_id is None:
2074            if email_from:
2075                author = self._mail_find_partner_from_emails([email_from])[0]
2076            else:
2077                author = self.env.user.partner_id
2078                email_from = author.email_formatted
2079            author_id = author.id
2080
2081        if email_from is None:
2082            if author_id:
2083                author = self.env['res.partner'].browse(author_id)
2084                email_from = author.email_formatted
2085
2086        # superuser mode without author email -> probably public user; anyway we don't want to crash
2087        if not email_from and not self.env.su and raise_exception:
2088            raise exceptions.UserError(_("Unable to log message, please configure the sender's email address."))
2089
2090        return author_id, email_from
2091
2092    def _message_create(self, values_list):
2093        if not isinstance(values_list, (list)):
2094            values_list = [values_list]
2095        create_values_list = []
2096        for values in values_list:
2097            create_values = dict(values)
2098            # Avoid warnings about non-existing fields
2099            for x in ('from', 'to', 'cc', 'canned_response_ids'):
2100                create_values.pop(x, None)
2101            create_values['partner_ids'] = [(4, pid) for pid in create_values.get('partner_ids', [])]
2102            create_values['channel_ids'] = [(4, cid) for cid in create_values.get('channel_ids', [])]
2103            create_values_list.append(create_values)
2104        if 'default_child_ids' in self._context:
2105            ctx = {key: val for key, val in self._context.items() if key != 'default_child_ids'}
2106            self = self.with_context(ctx)
2107        return self.env['mail.message'].create(create_values_list)
2108
2109    # ------------------------------------------------------
2110    # NOTIFICATION API
2111    # ------------------------------------------------------
2112
2113    def _notify_thread(self, message, msg_vals=False, notify_by_email=True, **kwargs):
2114        """ Main notification method. This method basically does two things
2115
2116         * call ``_notify_compute_recipients`` that computes recipients to
2117           notify based on message record or message creation values if given
2118           (to optimize performance if we already have data computed);
2119         * performs the notification process by calling the various notification
2120           methods implemented;
2121
2122        This method cnn be overridden to intercept and postpone notification
2123        mechanism like mail.channel moderation.
2124
2125        :param message: mail.message record to notify;
2126        :param msg_vals: dictionary of values used to create the message. If given
2127          it is used instead of accessing ``self`` to lessen query count in some
2128          simple cases where no notification is actually required;
2129
2130        Kwargs allow to pass various parameters that are given to sub notification
2131        methods. See those methods for more details about the additional parameters.
2132        Parameters used for email-style notifications
2133        """
2134        msg_vals = msg_vals if msg_vals else {}
2135        rdata = self._notify_compute_recipients(message, msg_vals)
2136        if not rdata:
2137            return False
2138
2139        message_values = {}
2140        if rdata['channels']:
2141            message_values['channel_ids'] = [(6, 0, [r['id'] for r in rdata['channels']])]
2142
2143        self._notify_record_by_inbox(message, rdata, msg_vals=msg_vals, **kwargs)
2144        if notify_by_email:
2145            self._notify_record_by_email(message, rdata, msg_vals=msg_vals, **kwargs)
2146
2147        return rdata
2148
2149    def _notify_record_by_inbox(self, message, recipients_data, msg_vals=False, **kwargs):
2150        """ Notification method: inbox. Do two main things
2151
2152          * create an inbox notification for users;
2153          * create channel / message link (channel_ids field of mail.message);
2154          * send bus notifications;
2155
2156        TDE/XDO TODO: flag rdata directly, with for example r['notif'] = 'ocn_client' and r['needaction']=False
2157        and correctly override notify_recipients
2158        """
2159        channel_ids = [r['id'] for r in recipients_data['channels']]
2160        if channel_ids:
2161            message.write({'channel_ids': [(6, 0, channel_ids)]})
2162
2163        inbox_pids = [r['id'] for r in recipients_data['partners'] if r['notif'] == 'inbox']
2164        if inbox_pids:
2165            notif_create_values = [{
2166                'mail_message_id': message.id,
2167                'res_partner_id': pid,
2168                'notification_type': 'inbox',
2169                'notification_status': 'sent',
2170            } for pid in inbox_pids]
2171            self.env['mail.notification'].sudo().create(notif_create_values)
2172
2173        bus_notifications = []
2174        if inbox_pids or channel_ids:
2175            message_format_values = False
2176            if inbox_pids:
2177                message_format_values = message.message_format()[0]
2178                for partner_id in inbox_pids:
2179                    bus_notifications.append([(self._cr.dbname, 'ir.needaction', partner_id), dict(message_format_values)])
2180            if channel_ids:
2181                channels = self.env['mail.channel'].sudo().browse(channel_ids)
2182                bus_notifications += channels._channel_message_notifications(message, message_format_values)
2183
2184        if bus_notifications:
2185            self.env['bus.bus'].sudo().sendmany(bus_notifications)
2186
2187    def _notify_record_by_email(self, message, recipients_data, msg_vals=False,
2188                                model_description=False, mail_auto_delete=True, check_existing=False,
2189                                force_send=True, send_after_commit=True,
2190                                **kwargs):
2191        """ Method to send email linked to notified messages.
2192
2193        :param message: mail.message record to notify;
2194        :param recipients_data: see ``_notify_thread``;
2195        :param msg_vals: see ``_notify_thread``;
2196
2197        :param model_description: model description used in email notification process
2198          (computed if not given);
2199        :param mail_auto_delete: delete notification emails once sent;
2200        :param check_existing: check for existing notifications to update based on
2201          mailed recipient, otherwise create new notifications;
2202
2203        :param force_send: send emails directly instead of using queue;
2204        :param send_after_commit: if force_send, tells whether to send emails after
2205          the transaction has been committed using a post-commit hook;
2206        """
2207        partners_data = [r for r in recipients_data['partners'] if r['notif'] == 'email']
2208        if not partners_data:
2209            return True
2210
2211        model = msg_vals.get('model') if msg_vals else message.model
2212        model_name = model_description or (self._fallback_lang().env['ir.model']._get(model).display_name if model else False) # one query for display name
2213        recipients_groups_data = self._notify_classify_recipients(partners_data, model_name, msg_vals=msg_vals)
2214
2215        if not recipients_groups_data:
2216            return True
2217        force_send = self.env.context.get('mail_notify_force_send', force_send)
2218
2219        template_values = self._notify_prepare_template_context(message, msg_vals, model_description=model_description) # 10 queries
2220
2221        email_layout_xmlid = msg_vals.get('email_layout_xmlid') if msg_vals else message.email_layout_xmlid
2222        template_xmlid = email_layout_xmlid if email_layout_xmlid else 'mail.message_notification_email'
2223        try:
2224            base_template = self.env.ref(template_xmlid, raise_if_not_found=True).with_context(lang=template_values['lang']) # 1 query
2225        except ValueError:
2226            _logger.warning('QWeb template %s not found when sending notification emails. Sending without layouting.' % (template_xmlid))
2227            base_template = False
2228
2229        mail_subject = message.subject or (message.record_name and 'Re: %s' % message.record_name) # in cache, no queries
2230        # prepare notification mail values
2231        base_mail_values = {
2232            'mail_message_id': message.id,
2233            'mail_server_id': message.mail_server_id.id, # 2 query, check acces + read, may be useless, Falsy, when will it be used?
2234            'auto_delete': mail_auto_delete,
2235            # due to ir.rule, user have no right to access parent message if message is not published
2236            'references': message.parent_id.sudo().message_id if message.parent_id else False,
2237            'subject': mail_subject,
2238        }
2239        base_mail_values = self._notify_by_email_add_values(base_mail_values)
2240
2241        # Clean the context to get rid of residual default_* keys that could cause issues during
2242        # the mail.mail creation.
2243        # Example: 'default_state' would refer to the default state of a previously created record
2244        # from another model that in turns triggers an assignation notification that ends up here.
2245        # This will lead to a traceback when trying to create a mail.mail with this state value that
2246        # doesn't exist.
2247        SafeMail = self.env['mail.mail'].sudo().with_context(clean_context(self._context))
2248        SafeNotification = self.env['mail.notification'].sudo().with_context(clean_context(self._context))
2249        emails = self.env['mail.mail'].sudo()
2250
2251        # loop on groups (customer, portal, user,  ... + model specific like group_sale_salesman)
2252        notif_create_values = []
2253        recipients_max = 50
2254        for recipients_group_data in recipients_groups_data:
2255            # generate notification email content
2256            recipients_ids = recipients_group_data.pop('recipients')
2257            render_values = {**template_values, **recipients_group_data}
2258            # {company, is_discussion, lang, message, model_description, record, record_name, signature, subtype, tracking_values, website_url}
2259            # {actions, button_access, has_button_access, recipients}
2260
2261            if base_template:
2262                mail_body = base_template._render(render_values, engine='ir.qweb', minimal_qcontext=True)
2263            else:
2264                mail_body = message.body
2265            mail_body = self.env['mail.render.mixin']._replace_local_links(mail_body)
2266
2267            # create email
2268            for recipients_ids_chunk in split_every(recipients_max, recipients_ids):
2269                recipient_values = self._notify_email_recipient_values(recipients_ids_chunk)
2270                email_to = recipient_values['email_to']
2271                recipient_ids = recipient_values['recipient_ids']
2272
2273                create_values = {
2274                    'body_html': mail_body,
2275                    'subject': mail_subject,
2276                    'recipient_ids': [(4, pid) for pid in recipient_ids],
2277                }
2278                if email_to:
2279                    create_values['email_to'] = email_to
2280                create_values.update(base_mail_values)  # mail_message_id, mail_server_id, auto_delete, references, headers
2281                email = SafeMail.create(create_values)
2282
2283                if email and recipient_ids:
2284                    tocreate_recipient_ids = list(recipient_ids)
2285                    if check_existing:
2286                        existing_notifications = self.env['mail.notification'].sudo().search([
2287                            ('mail_message_id', '=', message.id),
2288                            ('notification_type', '=', 'email'),
2289                            ('res_partner_id', 'in', tocreate_recipient_ids)
2290                        ])
2291                        if existing_notifications:
2292                            tocreate_recipient_ids = [rid for rid in recipient_ids if rid not in existing_notifications.mapped('res_partner_id.id')]
2293                            existing_notifications.write({
2294                                'notification_status': 'ready',
2295                                'mail_id': email.id,
2296                            })
2297                    notif_create_values += [{
2298                        'mail_message_id': message.id,
2299                        'res_partner_id': recipient_id,
2300                        'notification_type': 'email',
2301                        'mail_id': email.id,
2302                        'is_read': True,  # discard Inbox notification
2303                        'notification_status': 'ready',
2304                    } for recipient_id in tocreate_recipient_ids]
2305                emails |= email
2306
2307        if notif_create_values:
2308            SafeNotification.create(notif_create_values)
2309
2310        # NOTE:
2311        #   1. for more than 50 followers, use the queue system
2312        #   2. do not send emails immediately if the registry is not loaded,
2313        #      to prevent sending email during a simple update of the database
2314        #      using the command-line.
2315        test_mode = getattr(threading.currentThread(), 'testing', False)
2316        if force_send and len(emails) < recipients_max and (not self.pool._init or test_mode):
2317            # unless asked specifically, send emails after the transaction to
2318            # avoid side effects due to emails being sent while the transaction fails
2319            if not test_mode and send_after_commit:
2320                email_ids = emails.ids
2321                dbname = self.env.cr.dbname
2322                _context = self._context
2323
2324                @self.env.cr.postcommit.add
2325                def send_notifications():
2326                    db_registry = registry(dbname)
2327                    with api.Environment.manage(), db_registry.cursor() as cr:
2328                        env = api.Environment(cr, SUPERUSER_ID, _context)
2329                        env['mail.mail'].browse(email_ids).send()
2330            else:
2331                emails.send()
2332
2333        return True
2334
2335    @api.model
2336    def _notify_prepare_template_context(self, message, msg_vals, model_description=False, mail_auto_delete=True):
2337        # compute send user and its related signature
2338        signature = ''
2339        user = self.env.user
2340        author = message.env['res.partner'].browse(msg_vals.get('author_id')) if msg_vals else message.author_id
2341        model = msg_vals.get('model') if msg_vals else message.model
2342        add_sign = msg_vals.get('add_sign') if msg_vals else message.add_sign
2343        subtype_id = msg_vals.get('subtype_id') if msg_vals else message.subtype_id.id
2344        message_id = message.id
2345        record_name = msg_vals.get('record_name') if msg_vals else message.record_name
2346        author_user = user if user.partner_id == author else author.user_ids[0] if author and author.user_ids else False
2347        # trying to use user (self.env.user) instead of browing user_ids if he is the author will give a sudo user,
2348        # improving access performances and cache usage.
2349        if author_user:
2350            user = author_user
2351            if add_sign:
2352                signature = user.signature
2353        else:
2354            if add_sign:
2355                signature = "<p>-- <br/>%s</p>" % author.name
2356
2357        # company value should fall back on env.company if:
2358        # - no company_id field on record
2359        # - company_id field available but not set
2360        company = self.company_id.sudo() if self and 'company_id' in self and self.company_id else self.env.company
2361        if company.website:
2362            website_url = 'http://%s' % company.website if not company.website.lower().startswith(('http:', 'https:')) else company.website
2363        else:
2364            website_url = False
2365
2366        # Retrieve the language in which the template was rendered, in order to render the custom
2367        # layout in the same language.
2368        # TDE FIXME: this whole brol should be cleaned !
2369        lang = self.env.context.get('lang')
2370        if {'default_template_id', 'default_model', 'default_res_id'} <= self.env.context.keys():
2371            template = self.env['mail.template'].browse(self.env.context['default_template_id'])
2372            if template and template.lang:
2373                lang = template._render_lang([self.env.context['default_res_id']])[self.env.context['default_res_id']]
2374
2375        if not model_description and model:
2376            model_description = self.env['ir.model'].with_context(lang=lang)._get(model).display_name
2377
2378        tracking = []
2379        if msg_vals.get('tracking_value_ids', True) if msg_vals else bool(self): # could be tracking
2380            for tracking_value in self.env['mail.tracking.value'].sudo().search([('mail_message_id', '=', message.id)]):
2381                groups = tracking_value.field_groups
2382                if not groups or self.env.is_superuser() or self.user_has_groups(groups):
2383                    tracking.append((tracking_value.field_desc,
2384                                    tracking_value.get_old_display_value()[0],
2385                                    tracking_value.get_new_display_value()[0]))
2386
2387        is_discussion = subtype_id == self.env['ir.model.data'].xmlid_to_res_id('mail.mt_comment')
2388
2389        return {
2390            'message': message,
2391            'signature': signature,
2392            'website_url': website_url,
2393            'company': company,
2394            'model_description': model_description,
2395            'record': self,
2396            'record_name': record_name,
2397            'tracking_values': tracking,
2398            'is_discussion': is_discussion,
2399            'subtype': message.subtype_id,
2400            'lang': lang,
2401        }
2402
2403    def _notify_by_email_add_values(self, base_mail_values):
2404        """ Add model-specific values to the dictionary used to create the
2405        notification email. Its base behavior is to compute model-specific
2406        headers.
2407
2408        :param dict base_mail_values: base mail.mail values, holding message
2409        to notify (mail_message_id and its fields), server, references, subject.
2410        """
2411        headers = self._notify_email_headers()
2412        if headers:
2413            base_mail_values['headers'] = headers
2414        return base_mail_values
2415
2416    def _notify_compute_recipients(self, message, msg_vals):
2417        """ Compute recipients to notify based on subtype and followers. This
2418        method returns data structured as expected for ``_notify_recipients``. """
2419        msg_sudo = message.sudo()
2420        # get values from msg_vals or from message if msg_vals doen't exists
2421        pids = msg_vals.get('partner_ids', []) if msg_vals else msg_sudo.partner_ids.ids
2422        cids = msg_vals.get('channel_ids', []) if msg_vals else msg_sudo.channel_ids.ids
2423        message_type = msg_vals.get('message_type') if msg_vals else msg_sudo.message_type
2424        subtype_id = msg_vals.get('subtype_id') if msg_vals else msg_sudo.subtype_id.id
2425        # is it possible to have record but no subtype_id ?
2426        recipient_data = {
2427            'partners': [],
2428            'channels': [],
2429        }
2430        res = self.env['mail.followers']._get_recipient_data(self, message_type, subtype_id, pids, cids)
2431        if not res:
2432            return recipient_data
2433
2434        author_id = msg_vals.get('author_id') or message.author_id.id
2435        for pid, cid, active, pshare, ctype, notif, groups in res:
2436            if pid and pid == author_id and not self.env.context.get('mail_notify_author'):  # do not notify the author of its own messages
2437                continue
2438            if pid:
2439                if active is False:
2440                    continue
2441                pdata = {'id': pid, 'active': active, 'share': pshare, 'groups': groups or []}
2442                if notif == 'inbox':
2443                    recipient_data['partners'].append(dict(pdata, notif=notif, type='user'))
2444                elif not pshare and notif:  # has an user and is not shared, is therefore user
2445                    recipient_data['partners'].append(dict(pdata, notif=notif, type='user'))
2446                elif pshare and notif:  # has an user but is shared, is therefore portal
2447                    recipient_data['partners'].append(dict(pdata, notif=notif, type='portal'))
2448                else:  # has no user, is therefore customer
2449                    recipient_data['partners'].append(dict(pdata, notif=notif if notif else 'email', type='customer'))
2450            elif cid:
2451                recipient_data['channels'].append({'id': cid, 'notif': notif, 'type': ctype})
2452
2453        # add partner ids in email channels
2454        email_cids = [r['id'] for r in recipient_data['channels'] if r['notif'] == 'email']
2455        if email_cids:
2456            # we are doing a similar search in ocn_client
2457            # Could be interesting to make everything in a single query.
2458            # ocn_client: (searching all partners linked to channels of type chat).
2459            # here      : (searching all partners linked to channels with notif email if email is not the author one)
2460            # TDE FIXME: use email_sanitized
2461            email_from = msg_vals.get('email_from') or message.email_from
2462            email_from = self.env['res.partner']._parse_partner_name(email_from)[1]
2463            exept_partner = [r['id'] for r in recipient_data['partners']]
2464            if author_id:
2465                exept_partner.append(author_id)
2466
2467            sql_query = """ select distinct on (p.id) p.id from res_partner p
2468                            left join mail_channel_partner mcp on p.id = mcp.partner_id
2469                            left join mail_channel c on c.id = mcp.channel_id
2470                            left join res_users u on p.id = u.partner_id
2471                                where (u.notification_type != 'inbox' or u.id is null)
2472                                and (p.email != ANY(%s) or p.email is null)
2473                                and c.id = ANY(%s)
2474                                and p.id != ANY(%s)"""
2475
2476            self.env.cr.execute(sql_query, (([email_from], ), (email_cids, ), (exept_partner, )))
2477            for partner_id in self._cr.fetchall():
2478                # ocn_client: will add partners to recipient recipient_data. more ocn notifications. We neeed to filter them maybe
2479                recipient_data['partners'].append({'id': partner_id[0], 'share': True, 'active': True, 'notif': 'email', 'type': 'channel_email', 'groups': []})
2480
2481        return recipient_data
2482
2483    @api.model
2484    def _notify_encode_link(self, base_link, params):
2485        secret = self.env['ir.config_parameter'].sudo().get_param('database.secret')
2486        token = '%s?%s' % (base_link, ' '.join('%s=%s' % (key, params[key]) for key in sorted(params)))
2487        hm = hmac.new(secret.encode('utf-8'), token.encode('utf-8'), hashlib.sha1).hexdigest()
2488        return hm
2489
2490    def _notify_get_action_link(self, link_type, **kwargs):
2491        """ Prepare link to an action: view document, follow document, ... """
2492        params = {
2493            'model': kwargs.get('model', self._name),
2494            'res_id': kwargs.get('res_id', self.ids and self.ids[0] or False),
2495        }
2496        # whitelist accepted parameters: action (deprecated), token (assign), access_token
2497        # (view), auth_signup_token and auth_login (for auth_signup support)
2498        params.update(dict(
2499            (key, value)
2500            for key, value in kwargs.items()
2501            if key in ('action', 'token', 'access_token', 'auth_signup_token', 'auth_login')
2502        ))
2503
2504        if link_type in ['view', 'assign', 'follow', 'unfollow']:
2505            base_link = '/mail/%s' % link_type
2506        elif link_type == 'controller':
2507            controller = kwargs.get('controller')
2508            params.pop('model')
2509            base_link = '%s' % controller
2510        else:
2511            return ''
2512
2513        if link_type not in ['view']:
2514            token = self._notify_encode_link(base_link, params)
2515            params['token'] = token
2516
2517        link = '%s?%s' % (base_link, urls.url_encode(params))
2518        if self:
2519            link = self[0].get_base_url() + link
2520
2521        return link
2522
2523    def _notify_get_groups(self, msg_vals=None):
2524        """ Return groups used to classify recipients of a notification email.
2525        Groups is a list of tuple containing of form (group_name, group_func,
2526        group_data) where
2527         * group_name is an identifier used only to be able to override and manipulate
2528           groups. Default groups are user (recipients linked to an employee user),
2529           portal (recipients linked to a portal user) and customer (recipients not
2530           linked to any user). An example of override use would be to add a group
2531           linked to a res.groups like Hr Officers to set specific action buttons to
2532           them.
2533         * group_func is a function pointer taking a partner record as parameter. This
2534           method will be applied on recipients to know whether they belong to a given
2535           group or not. Only first matching group is kept. Evaluation order is the
2536           list order.
2537         * group_data is a dict containing parameters for the notification email
2538          * has_button_access: whether to display Access <Document> in email. True
2539            by default for new groups, False for portal / customer.
2540          * button_access: dict with url and title of the button
2541          * actions: list of action buttons to display in the notification email.
2542            Each action is a dict containing url and title of the button.
2543        Groups has a default value that you can find in mail_thread
2544        ``_notify_classify_recipients`` method.
2545        """
2546        return [
2547            (
2548                'user',
2549                lambda pdata: pdata['type'] == 'user',
2550                {}
2551            ), (
2552                'portal',
2553                lambda pdata: pdata['type'] == 'portal',
2554                {'has_button_access': False}
2555            ), (
2556                'customer',
2557                lambda pdata: True,
2558                {'has_button_access': False}
2559            )
2560        ]
2561
2562    def _notify_classify_recipients(self, recipient_data, model_name, msg_vals=None):
2563        """ Classify recipients to be notified of a message in groups to have
2564        specific rendering depending on their group. For example users could
2565        have access to buttons customers should not have in their emails.
2566        Module-specific grouping should be done by overriding ``_notify_get_groups``
2567        method defined here-under.
2568        :param recipient_data:todo xdo UPDATE ME
2569        return example:
2570        [{
2571            'actions': [],
2572            'button_access': {'title': 'View Simple Chatter Model',
2573                                'url': '/mail/view?model=mail.test.simple&res_id=1497'},
2574            'has_button_access': False,
2575            'recipients': [11]
2576        },
2577        {
2578            'actions': [],
2579            'button_access': {'title': 'View Simple Chatter Model',
2580                            'url': '/mail/view?model=mail.test.simple&res_id=1497'},
2581            'has_button_access': False,
2582            'recipients': [4, 5, 6]
2583        },
2584        {
2585            'actions': [],
2586            'button_access': {'title': 'View Simple Chatter Model',
2587                                'url': '/mail/view?model=mail.test.simple&res_id=1497'},
2588            'has_button_access': True,
2589            'recipients': [10, 11, 12]
2590        }]
2591        only return groups with recipients
2592        """
2593        # keep a local copy of msg_vals as it may be modified to include more information about groups or links
2594        local_msg_vals = dict(msg_vals) if msg_vals else {}
2595        groups = self._notify_get_groups(msg_vals=local_msg_vals)
2596        access_link = self._notify_get_action_link('view', **local_msg_vals)
2597
2598        if model_name:
2599            view_title = _('View %s', model_name)
2600        else:
2601            view_title = _('View')
2602
2603        # fill group_data with default_values if they are not complete
2604        for group_name, group_func, group_data in groups:
2605            group_data.setdefault('notification_group_name', group_name)
2606            group_data.setdefault('notification_is_customer', False)
2607            group_data.setdefault('has_button_access', True)
2608            group_button_access = group_data.setdefault('button_access', {})
2609            group_button_access.setdefault('url', access_link)
2610            group_button_access.setdefault('title', view_title)
2611            group_data.setdefault('actions', list())
2612            group_data.setdefault('recipients', list())
2613
2614        # classify recipients in each group
2615        for recipient in recipient_data:
2616            for group_name, group_func, group_data in groups:
2617                if group_func(recipient):
2618                    group_data['recipients'].append(recipient['id'])
2619                    break
2620
2621        result = []
2622        for group_name, group_method, group_data in groups:
2623            if group_data['recipients']:
2624                result.append(group_data)
2625
2626        return result
2627
2628    @api.model
2629    def _notify_get_reply_to_on_records(self, default=None, records=None, company=None, doc_names=None):
2630        """ Moved to ``BaseModel._notify_get_reply_to()`` """
2631        records = records if records else self
2632        return records._notify_get_reply_to(default=default, company=company, doc_names=doc_names)
2633
2634    def _notify_email_recipient_values(self, recipient_ids):
2635        """ Format email notification recipient values to store on the notification
2636        mail.mail. Basic method just set the recipient partners as mail_mail
2637        recipients. Override to generate other mail values like email_to or
2638        email_cc.
2639        :param recipient_ids: res.partner recordset to notify
2640        """
2641        return {
2642            'email_to': False,
2643            'recipient_ids': recipient_ids,
2644        }
2645
2646    # ------------------------------------------------------
2647    # FOLLOWERS API
2648    # ------------------------------------------------------
2649
2650    def message_subscribe(self, partner_ids=None, channel_ids=None, subtype_ids=None):
2651        """ Main public API to add followers to a record set. Its main purpose is
2652        to perform access rights checks before calling ``_message_subscribe``. """
2653        if not self or (not partner_ids and not channel_ids):
2654            return True
2655
2656        partner_ids = partner_ids or []
2657        channel_ids = channel_ids or []
2658        adding_current = set(partner_ids) == set([self.env.user.partner_id.id])
2659        customer_ids = [] if adding_current else None
2660
2661        if not channel_ids and partner_ids and adding_current:
2662            try:
2663                self.check_access_rights('read')
2664                self.check_access_rule('read')
2665            except exceptions.AccessError:
2666                return False
2667        else:
2668            self.check_access_rights('write')
2669            self.check_access_rule('write')
2670
2671        # filter inactive and private addresses
2672        if partner_ids and not adding_current:
2673            partner_ids = self.env['res.partner'].sudo().search([('id', 'in', partner_ids), ('active', '=', True), ('type', '!=', 'private')]).ids
2674
2675        return self._message_subscribe(partner_ids, channel_ids, subtype_ids, customer_ids=customer_ids)
2676
2677    def _message_subscribe(self, partner_ids=None, channel_ids=None, subtype_ids=None, customer_ids=None):
2678        """ Main private API to add followers to a record set. This method adds
2679        partners and channels, given their IDs, as followers of all records
2680        contained in the record set.
2681
2682        If subtypes are given existing followers are erased with new subtypes.
2683        If default one have to be computed only missing followers will be added
2684        with default subtypes matching the record set model.
2685
2686        This private method does not specifically check for access right. Use
2687        ``message_subscribe`` public API when not sure about access rights.
2688
2689        :param customer_ids: see ``_insert_followers`` """
2690        if not self:
2691            return True
2692
2693        if not subtype_ids:
2694            self.env['mail.followers']._insert_followers(
2695                self._name, self.ids, partner_ids, None, channel_ids, None,
2696                customer_ids=customer_ids, check_existing=True, existing_policy='skip')
2697        else:
2698            self.env['mail.followers']._insert_followers(
2699                self._name, self.ids,
2700                partner_ids, dict((pid, subtype_ids) for pid in partner_ids),
2701                channel_ids, dict((cid, subtype_ids) for cid in channel_ids),
2702                customer_ids=customer_ids, check_existing=True, existing_policy='replace')
2703
2704        return True
2705
2706    def message_unsubscribe(self, partner_ids=None, channel_ids=None):
2707        """ Remove partners from the records followers. """
2708        # not necessary for computation, but saves an access right check
2709        if not partner_ids and not channel_ids:
2710            return True
2711        user_pid = self.env.user.partner_id.id
2712        if not channel_ids and set(partner_ids) == set([user_pid]):
2713            self.check_access_rights('read')
2714            self.check_access_rule('read')
2715        else:
2716            self.check_access_rights('write')
2717            self.check_access_rule('write')
2718        self.env['mail.followers'].sudo().search([
2719            ('res_model', '=', self._name),
2720            ('res_id', 'in', self.ids),
2721            '|',
2722            ('partner_id', 'in', partner_ids or []),
2723            ('channel_id', 'in', channel_ids or [])
2724        ]).unlink()
2725
2726    def _message_auto_subscribe_followers(self, updated_values, default_subtype_ids):
2727        """ Optional method to override in addons inheriting from mail.thread.
2728        Return a list tuples containing (
2729          partner ID,
2730          subtype IDs (or False if model-based default subtypes),
2731          QWeb template XML ID for notification (or False is no specific
2732            notification is required),
2733          ), aka partners and their subtype and possible notification to send
2734        using the auto subscription mechanism linked to updated values.
2735
2736        Default value of this method is to return the new responsible of
2737        documents. This is done using relational fields linking to res.users
2738        with track_visibility set. Since OpenERP v7 it is considered as being
2739        responsible for the document and therefore standard behavior is to
2740        subscribe the user and send him a notification.
2741
2742        Override this method to change that behavior and/or to add people to
2743        notify, using possible custom notification.
2744
2745        :param updated_values: see ``_message_auto_subscribe``
2746        :param default_subtype_ids: coming from ``_get_auto_subscription_subtypes``
2747        """
2748        fnames = []
2749        field = self._fields.get('user_id')
2750        user_id = updated_values.get('user_id')
2751        if field and user_id and field.comodel_name == 'res.users' and (getattr(field, 'track_visibility', False) or getattr(field, 'tracking', False)):
2752            user = self.env['res.users'].sudo().browse(user_id)
2753            try: # avoid to make an exists, lets be optimistic and try to read it.
2754                if user.active:
2755                    return [(user.partner_id.id, default_subtype_ids, 'mail.message_user_assigned' if user != self.env.user else False)]
2756            except:
2757                pass
2758        return []
2759
2760    def _message_auto_subscribe_notify(self, partner_ids, template):
2761        """ Notify new followers, using a template to render the content of the
2762        notification message. Notifications pushed are done using the standard
2763        notification mechanism in mail.thread. It is either inbox either email
2764        depending on the partner state: no user (email, customer), share user
2765        (email, customer) or classic user (notification_type)
2766
2767        :param partner_ids: IDs of partner to notify;
2768        :param template: XML ID of template used for the notification;
2769        """
2770        if not self or self.env.context.get('mail_auto_subscribe_no_notify'):
2771            return
2772        if not self.env.registry.ready:  # Don't send notification during install
2773            return
2774
2775        view = self.env['ir.ui.view'].browse(self.env['ir.model.data'].xmlid_to_res_id(template))
2776
2777        for record in self:
2778            model_description = self.env['ir.model']._get(record._name).display_name
2779            values = {
2780                'object': record,
2781                'model_description': model_description,
2782                'access_link': record._notify_get_action_link('view'),
2783            }
2784            assignation_msg = view._render(values, engine='ir.qweb', minimal_qcontext=True)
2785            assignation_msg = self.env['mail.render.mixin']._replace_local_links(assignation_msg)
2786            record.message_notify(
2787                subject=_('You have been assigned to %s', record.display_name),
2788                body=assignation_msg,
2789                partner_ids=partner_ids,
2790                record_name=record.display_name,
2791                email_layout_xmlid='mail.mail_notification_light',
2792                model_description=model_description,
2793            )
2794
2795    def _message_auto_subscribe(self, updated_values, followers_existing_policy='skip'):
2796        """ Handle auto subscription. Auto subscription is done based on two
2797        main mechanisms
2798
2799         * using subtypes parent relationship. For example following a parent record
2800           (i.e. project) with subtypes linked to child records (i.e. task). See
2801           mail.message.subtype ``_get_auto_subscription_subtypes``;
2802         * calling _message_auto_subscribe_notify that returns a list of partner
2803           to subscribe, as well as data about the subtypes and notification
2804           to send. Base behavior is to subscribe responsible and notify them;
2805
2806        Adding application-specific auto subscription should be done by overriding
2807        ``_message_auto_subscribe_followers``. It should return structured data
2808        for new partner to subscribe, with subtypes and eventual notification
2809        to perform. See that method for more details.
2810
2811        :param updated_values: values modifying the record trigerring auto subscription
2812        """
2813        if not self:
2814            return True
2815
2816        new_partners, new_channels = dict(), dict()
2817
2818        # return data related to auto subscription based on subtype matching (aka:
2819        # default task subtypes or subtypes from project triggering task subtypes)
2820        updated_relation = dict()
2821        child_ids, def_ids, all_int_ids, parent, relation = self.env['mail.message.subtype']._get_auto_subscription_subtypes(self._name)
2822
2823        # check effectively modified relation field
2824        for res_model, fnames in relation.items():
2825            for field in (fname for fname in fnames if updated_values.get(fname)):
2826                updated_relation.setdefault(res_model, set()).add(field)
2827        udpated_fields = [fname for fnames in updated_relation.values() for fname in fnames if updated_values.get(fname)]
2828
2829        if udpated_fields:
2830            # fetch "parent" subscription data (aka: subtypes on project to propagate on task)
2831            doc_data = [(model, [updated_values[fname] for fname in fnames]) for model, fnames in updated_relation.items()]
2832            res = self.env['mail.followers']._get_subscription_data(doc_data, None, None, include_pshare=True, include_active=True)
2833            for fid, rid, pid, cid, subtype_ids, pshare, active in res:
2834                # use project.task_new -> task.new link
2835                sids = [parent[sid] for sid in subtype_ids if parent.get(sid)]
2836                # add checked subtypes matching model_name
2837                sids += [sid for sid in subtype_ids if sid not in parent and sid in child_ids]
2838                if pid and active:  # auto subscribe only active partners
2839                    if pshare:  # remove internal subtypes for customers
2840                        new_partners[pid] = set(sids) - set(all_int_ids)
2841                    else:
2842                        new_partners[pid] = set(sids)
2843                if cid:  # never subscribe channels to internal subtypes
2844                    new_channels[cid] = set(sids) - set(all_int_ids)
2845
2846        notify_data = dict()
2847        res = self._message_auto_subscribe_followers(updated_values, def_ids)
2848        for pid, sids, template in res:
2849            new_partners.setdefault(pid, sids)
2850            if template:
2851                partner = self.env['res.partner'].browse(pid)
2852                lang = partner.lang if partner else None
2853                notify_data.setdefault((template, lang), list()).append(pid)
2854
2855        self.env['mail.followers']._insert_followers(
2856            self._name, self.ids,
2857            list(new_partners), new_partners,
2858            list(new_channels), new_channels,
2859            check_existing=True, existing_policy=followers_existing_policy)
2860
2861        # notify people from auto subscription, for example like assignation
2862        for (template, lang), pids in notify_data.items():
2863            self.with_context(lang=lang)._message_auto_subscribe_notify(pids, template)
2864
2865        return True
2866
2867    # ------------------------------------------------------
2868    # CONTROLLERS
2869    # ------------------------------------------------------
2870
2871    def _get_mail_redirect_suggested_company(self):
2872        """ Return the suggested company to be set on the context
2873        in case of a mail redirection to the record. To avoid multi
2874        company issues when clicking on a link sent by email, this
2875        could be called to try setting the most suited company on
2876        the allowed_company_ids in the context. This method can be
2877        overridden, for example on the hr.leave model, where the
2878        most suited company is the company of the leave type, as
2879        specified by the ir.rule.
2880        """
2881        if 'company_id' in self:
2882            return self.company_id
2883        return False
2884