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