1 # -*- coding: utf-8 -*-
2 ##############################################################################
4 # OpenERP, Open Source Management Solution
5 # Copyright (C) 2009-today OpenERP SA (<http://www.openerp.com>)
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Affero General Public License as
9 # published by the Free Software Foundation, either version 3 of the
10 # License, or (at your option) any later version
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU Affero General Public License for more details
17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program. If not, see <http://www.gnu.org/licenses/>
20 ##############################################################################
23 from collections import OrderedDict
28 import simplejson as json
31 from lxml import etree
38 from email.message import Message
39 from urllib import urlencode
41 from openerp import tools
42 from openerp import SUPERUSER_ID
43 from openerp.addons.mail.mail_message import decode
44 from openerp.osv import fields, osv, orm
45 from openerp.osv.orm import browse_record, browse_null
46 from openerp.tools.safe_eval import safe_eval as eval
47 from openerp.tools.translate import _
49 _logger = logging.getLogger(__name__)
52 mail_header_msgid_re = re.compile('<[^<>]+>')
54 def decode_header(message, header, separator=' '):
55 return separator.join(map(decode, filter(None, message.get_all(header, []))))
58 class mail_thread(osv.AbstractModel):
59 ''' mail_thread model is meant to be inherited by any model that needs to
60 act as a discussion topic on which messages can be attached. Public
61 methods are prefixed with ``message_`` in order to avoid name
62 collisions with methods of the models that will inherit from this class.
64 ``mail.thread`` defines fields used to handle and display the
65 communication history. ``mail.thread`` also manages followers of
66 inheriting classes. All features and expected behavior are managed
67 by mail.thread. Widgets has been designed for the 7.0 and following
70 Inheriting classes are not required to implement any method, as the
71 default implementation will work for any model. However it is common
72 to override at least the ``message_new`` and ``message_update``
73 methods (calling ``super``) to add model-specific behavior at
74 creation and update of a thread when processing incoming emails.
77 - _mail_flat_thread: if set to True, all messages without parent_id
78 are automatically attached to the first message posted on the
79 ressource. If set to False, the display of Chatter is done using
80 threads, and no parent_id is automatically set.
83 _description = 'Email Thread'
84 _mail_flat_thread = True
85 _mail_post_access = 'write'
87 # Automatic logging system if mail installed
90 # 'module.subtype_xml': lambda self, cr, uid, obj, context=None: obj[state] == done,
91 # 'module.subtype_xml2': lambda self, cr, uid, obj, context=None: obj[state] != done,
98 # :param string field: field name
99 # :param module.subtype_xml: xml_id of a mail.message.subtype (i.e. mail.mt_comment)
100 # :param obj: is a browse_record
101 # :param function lambda: returns whether the tracking should record using this subtype
104 # Mass mailing feature
105 _mail_mass_mailing = False
107 def get_empty_list_help(self, cr, uid, help, context=None):
108 """ Override of BaseModel.get_empty_list_help() to generate an help message
109 that adds alias information. """
110 model = context.get('empty_list_help_model')
111 res_id = context.get('empty_list_help_id')
112 ir_config_parameter = self.pool.get("ir.config_parameter")
113 catchall_domain = ir_config_parameter.get_param(cr, uid, "mail.catchall.domain", context=context)
114 document_name = context.get('empty_list_help_document_name', _('document'))
117 if catchall_domain and model and res_id: # specific res_id -> find its alias (i.e. section_id specified)
118 object_id = self.pool.get(model).browse(cr, uid, res_id, context=context)
119 # check that the alias effectively creates new records
120 if object_id.alias_id and object_id.alias_id.alias_name and \
121 object_id.alias_id.alias_model_id and \
122 object_id.alias_id.alias_model_id.model == self._name and \
123 object_id.alias_id.alias_force_thread_id == 0:
124 alias = object_id.alias_id
125 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
126 alias_obj = self.pool.get('mail.alias')
127 alias_ids = alias_obj.search(cr, uid, [("alias_parent_model_id.model", "=", model), ("alias_name", "!=", False), ('alias_force_thread_id', '=', False), ('alias_parent_thread_id', '=', False)], context=context, order='id ASC')
128 if alias_ids and len(alias_ids) == 1:
129 alias = alias_obj.browse(cr, uid, alias_ids[0], context=context)
132 alias_email = alias.name_get()[0][1]
133 return _("""<p class='oe_view_nocontent_create'>
134 Click here to add new %(document)s or send an email to: <a href='mailto:%(email)s'>%(email)s</a>
138 'document': document_name,
139 'email': alias_email,
140 'static_help': help or ''
143 if document_name != 'document' and help and help.find("oe_view_nocontent_create") == -1:
144 return _("<p class='oe_view_nocontent_create'>Click here to add new %(document)s</p>%(static_help)s") % {
145 'document': document_name,
146 'static_help': help or '',
151 def _get_message_data(self, cr, uid, ids, name, args, context=None):
153 - message_unread: has uid unread message for the document
154 - message_summary: html snippet summarizing the Chatter for kanban views """
155 res = dict((id, dict(message_unread=False, message_unread_count=0, message_summary=' ')) for id in ids)
156 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
158 # search for unread messages, directly in SQL to improve performances
159 cr.execute(""" SELECT m.res_id FROM mail_message m
160 RIGHT JOIN mail_notification n
161 ON (n.message_id = m.id AND n.partner_id = %s AND (n.read = False or n.read IS NULL))
162 WHERE m.model = %s AND m.res_id in %s""",
163 (user_pid, self._name, tuple(ids),))
164 for result in cr.fetchall():
165 res[result[0]]['message_unread'] = True
166 res[result[0]]['message_unread_count'] += 1
169 if res[id]['message_unread_count']:
170 title = res[id]['message_unread_count'] > 1 and _("You have %d unread messages") % res[id]['message_unread_count'] or _("You have one unread message")
171 res[id]['message_summary'] = "<span class='oe_kanban_mail_new' title='%s'><span class='oe_e'>9</span> %d %s</span>" % (title, res[id].pop('message_unread_count'), _("New"))
172 res[id].pop('message_unread_count', None)
175 def read_followers_data(self, cr, uid, follower_ids, context=None):
177 technical_group = self.pool.get('ir.model.data').get_object(cr, uid, 'base', 'group_no_one', context=context)
178 for follower in self.pool.get('res.partner').browse(cr, uid, follower_ids, context=context):
179 is_editable = uid in map(lambda x: x.id, technical_group.users)
180 is_uid = uid in map(lambda x: x.id, follower.user_ids)
183 {'is_editable': is_editable, 'is_uid': is_uid},
188 def _get_subscription_data(self, cr, uid, ids, name, args, user_pid=None, context=None):
190 - message_subtype_data: data about document subtypes: which are
191 available, which are followed if any """
192 res = dict((id, dict(message_subtype_data='')) for id in ids)
194 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
196 # find current model subtypes, add them to a dictionary
197 subtype_obj = self.pool.get('mail.message.subtype')
198 subtype_ids = subtype_obj.search(
200 '&', ('hidden', '=', False), '|', ('res_model', '=', self._name), ('res_model', '=', False)
202 subtype_dict = OrderedDict(
204 'default': subtype.default,
206 'parent_model': subtype.parent_id and subtype.parent_id.res_model or self._name,
208 ) for subtype in subtype_obj.browse(cr, uid, subtype_ids, context=context))
210 res[id]['message_subtype_data'] = subtype_dict.copy()
212 # find the document followers, update the data
213 fol_obj = self.pool.get('mail.followers')
214 fol_ids = fol_obj.search(cr, uid, [
215 ('partner_id', '=', user_pid),
216 ('res_id', 'in', ids),
217 ('res_model', '=', self._name),
219 for fol in fol_obj.browse(cr, uid, fol_ids, context=context):
220 thread_subtype_dict = res[fol.res_id]['message_subtype_data']
221 for subtype in [st for st in fol.subtype_ids if st.name in thread_subtype_dict]:
222 thread_subtype_dict[subtype.name]['followed'] = True
223 res[fol.res_id]['message_subtype_data'] = thread_subtype_dict
227 def _search_message_unread(self, cr, uid, obj=None, name=None, domain=None, context=None):
228 return [('message_ids.to_read', '=', True)]
230 def _get_followers(self, cr, uid, ids, name, arg, context=None):
231 fol_obj = self.pool.get('mail.followers')
232 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids)])
233 res = dict((id, dict(message_follower_ids=[], message_is_follower=False)) for id in ids)
234 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
235 for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids):
236 res[fol.res_id]['message_follower_ids'].append(fol.partner_id.id)
237 if fol.partner_id.id == user_pid:
238 res[fol.res_id]['message_is_follower'] = True
241 def _set_followers(self, cr, uid, id, name, value, arg, context=None):
244 partner_obj = self.pool.get('res.partner')
245 fol_obj = self.pool.get('mail.followers')
247 # read the old set of followers, and determine the new set of followers
248 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', '=', id)])
249 old = set(fol.partner_id.id for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids))
252 for command in value or []:
253 if isinstance(command, (int, long)):
255 elif command[0] == 0:
256 new.add(partner_obj.create(cr, uid, command[2], context=context))
257 elif command[0] == 1:
258 partner_obj.write(cr, uid, [command[1]], command[2], context=context)
260 elif command[0] == 2:
261 partner_obj.unlink(cr, uid, [command[1]], context=context)
262 new.discard(command[1])
263 elif command[0] == 3:
264 new.discard(command[1])
265 elif command[0] == 4:
267 elif command[0] == 5:
269 elif command[0] == 6:
270 new = set(command[2])
272 # remove partners that are no longer followers
273 self.message_unsubscribe(cr, uid, [id], list(old-new), context=context)
275 self.message_subscribe(cr, uid, [id], list(new-old), context=context)
277 def _search_followers(self, cr, uid, obj, name, args, context):
278 """Search function for message_follower_ids
280 Do not use with operator 'not in'. Use instead message_is_followers
282 fol_obj = self.pool.get('mail.followers')
284 for field, operator, value in args:
286 # TOFIX make it work with not in
287 assert operator != "not in", "Do not search message_follower_ids with 'not in'"
288 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('partner_id', operator, value)])
289 res_ids = [fol.res_id for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids)]
290 res.append(('id', 'in', res_ids))
293 def _search_is_follower(self, cr, uid, obj, name, args, context):
294 """Search function for message_is_follower"""
296 for field, operator, value in args:
298 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
299 if (operator == '=' and value) or (operator == '!=' and not value): # is a follower
300 res_ids = self.search(cr, uid, [('message_follower_ids', 'in', [partner_id])], context=context)
301 else: # is not a follower or unknown domain
302 mail_ids = self.search(cr, uid, [('message_follower_ids', 'in', [partner_id])], context=context)
303 res_ids = self.search(cr, uid, [('id', 'not in', mail_ids)], context=context)
304 res.append(('id', 'in', res_ids))
308 'message_is_follower': fields.function(_get_followers, type='boolean',
309 fnct_search=_search_is_follower, string='Is a Follower', multi='_get_followers,'),
310 'message_follower_ids': fields.function(_get_followers, fnct_inv=_set_followers,
311 fnct_search=_search_followers, type='many2many', priority=-10,
312 obj='res.partner', string='Followers', multi='_get_followers'),
313 'message_ids': fields.one2many('mail.message', 'res_id',
314 domain=lambda self: [('model', '=', self._name)],
317 help="Messages and communication history"),
318 'message_last_post': fields.datetime('Last Message Date',
319 help='Date of the last message posted on the record.'),
320 'message_unread': fields.function(_get_message_data,
321 fnct_search=_search_message_unread, multi="_get_message_data",
322 type='boolean', string='Unread Messages',
323 help="If checked new messages require your attention."),
324 'message_summary': fields.function(_get_message_data, method=True,
325 type='text', string='Summary', multi="_get_message_data",
326 help="Holds the Chatter summary (number of messages, ...). "\
327 "This summary is directly in html format in order to "\
328 "be inserted in kanban views."),
331 def _get_user_chatter_options(self, cr, uid, context=None):
333 'display_log_button': False
335 group_ids = self.pool.get('res.users').browse(cr, uid, uid, context=context).groups_id
336 group_user_id = self.pool.get("ir.model.data").get_object_reference(cr, uid, 'base', 'group_user')[1]
337 is_employee = group_user_id in [group.id for group in group_ids]
339 options['display_log_button'] = True
342 def fields_view_get(self, cr, uid, view_id=None, view_type='form', context=None, toolbar=False, submenu=False):
343 res = super(mail_thread, self).fields_view_get(cr, uid, view_id=view_id, view_type=view_type, context=context, toolbar=toolbar, submenu=submenu)
344 if view_type == 'form':
345 doc = etree.XML(res['arch'])
346 for node in doc.xpath("//field[@name='message_ids']"):
347 options = json.loads(node.get('options', '{}'))
348 options.update(self._get_user_chatter_options(cr, uid, context=context))
349 node.set('options', json.dumps(options))
350 res['arch'] = etree.tostring(doc)
353 #------------------------------------------------------
354 # CRUD overrides for automatic subscription and logging
355 #------------------------------------------------------
357 def create(self, cr, uid, values, context=None):
358 """ Chatter override :
360 - subscribe followers of parent
361 - log a creation message
366 if context.get('tracking_disable'):
367 return super(mail_thread, self).create(
368 cr, uid, values, context=context)
370 # subscribe uid unless asked not to
371 if not context.get('mail_create_nosubscribe'):
372 pid = self.pool['res.users'].browse(cr, SUPERUSER_ID, uid).partner_id.id
373 message_follower_ids = values.get('message_follower_ids') or [] # webclient can send None or False
374 message_follower_ids.append([4, pid])
375 values['message_follower_ids'] = message_follower_ids
376 thread_id = super(mail_thread, self).create(cr, uid, values, context=context)
378 # automatic logging unless asked not to (mainly for various testing purpose)
379 if not context.get('mail_create_nolog'):
380 ir_model_pool = self.pool['ir.model']
381 ids = ir_model_pool.search(cr, uid, [('model', '=', self._name)], context=context)
382 name = ir_model_pool.read(cr, uid, ids, ['name'], context=context)[0]['name']
383 self.message_post(cr, uid, thread_id, body=_('%s created') % name, context=context)
385 # auto_subscribe: take values and defaults into account
386 create_values = dict(values)
387 for key, val in context.iteritems():
388 if key.startswith('default_'):
389 create_values[key[8:]] = val
390 self.message_auto_subscribe(cr, uid, [thread_id], create_values.keys(), context=context, values=create_values)
393 track_ctx = dict(context)
394 if 'lang' not in track_ctx:
395 track_ctx['lang'] = self.pool.get('res.users').browse(cr, uid, uid, context=context).lang
396 if not context.get('mail_notrack'):
397 tracked_fields = self._get_tracked_fields(cr, uid, values.keys(), context=track_ctx)
399 initial_values = {thread_id: dict.fromkeys(tracked_fields, False)}
400 self.message_track(cr, uid, [thread_id], tracked_fields, initial_values, context=track_ctx)
403 def write(self, cr, uid, ids, values, context=None):
406 if isinstance(ids, (int, long)):
408 if context.get('tracking_disable'):
409 return super(mail_thread, self).write(
410 cr, uid, ids, values, context=context)
411 # Track initial values of tracked fields
412 track_ctx = dict(context)
413 if 'lang' not in track_ctx:
414 track_ctx['lang'] = self.pool.get('res.users').browse(cr, uid, uid, context=context).lang
416 tracked_fields = None
417 if not context.get('mail_notrack'):
418 tracked_fields = self._get_tracked_fields(cr, uid, values.keys(), context=track_ctx)
421 records = self.browse(cr, uid, ids, context=track_ctx)
422 initial_values = dict((record.id, dict((key, getattr(record, key)) for key in tracked_fields))
423 for record in records)
425 # Perform write, update followers
426 result = super(mail_thread, self).write(cr, uid, ids, values, context=context)
427 self.message_auto_subscribe(cr, uid, ids, values.keys(), context=context, values=values)
429 # Perform the tracking
431 self.message_track(cr, uid, ids, tracked_fields, initial_values, context=track_ctx)
435 def unlink(self, cr, uid, ids, context=None):
436 """ Override unlink to delete messages and followers. This cannot be
437 cascaded, because link is done through (res_model, res_id). """
438 msg_obj = self.pool.get('mail.message')
439 fol_obj = self.pool.get('mail.followers')
440 # delete messages and notifications
441 msg_ids = msg_obj.search(cr, uid, [('model', '=', self._name), ('res_id', 'in', ids)], context=context)
442 msg_obj.unlink(cr, uid, msg_ids, context=context)
444 res = super(mail_thread, self).unlink(cr, uid, ids, context=context)
446 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids)], context=context)
447 fol_obj.unlink(cr, SUPERUSER_ID, fol_ids, context=context)
450 def copy_data(self, cr, uid, id, default=None, context=None):
451 # avoid tracking multiple temporary changes during copy
452 context = dict(context or {}, mail_notrack=True)
454 default = default or {}
455 default['message_ids'] = []
456 default['message_follower_ids'] = []
457 return super(mail_thread, self).copy_data(cr, uid, id, default=default, context=context)
459 #------------------------------------------------------
460 # Automatically log tracked fields
461 #------------------------------------------------------
463 def _get_tracked_fields(self, cr, uid, updated_fields, context=None):
464 """ Return a structure of tracked fields for the current model.
465 :param list updated_fields: modified field names
466 :return list: a list of (field_name, column_info obj), containing
467 always tracked fields and modified on_change fields
470 for name, column_info in self._all_columns.items():
471 visibility = getattr(column_info.column, 'track_visibility', False)
472 if visibility == 'always' or (visibility == 'onchange' and name in updated_fields) or name in self._track:
473 tracked_fields.append(name)
476 return self.fields_get(cr, uid, tracked_fields, context=context)
479 def message_track(self, cr, uid, ids, tracked_fields, initial_values, context=None):
481 def convert_for_display(value, col_info):
482 if not value and col_info['type'] == 'boolean':
486 if col_info['type'] == 'many2one':
487 return value.name_get()[0][1]
488 if col_info['type'] == 'selection':
489 return dict(col_info['selection'])[value]
492 def format_message(message_description, tracked_values):
494 if message_description:
495 message = '<span>%s</span>' % message_description
496 for name, change in tracked_values.items():
497 message += '<div> • <b>%s</b>: ' % change.get('col_info')
498 if change.get('old_value'):
499 message += '%s → ' % change.get('old_value')
500 message += '%s</div>' % change.get('new_value')
503 if not tracked_fields:
506 for browse_record in self.browse(cr, uid, ids, context=context):
507 initial = initial_values[browse_record.id]
511 # generate tracked_values data structure: {'col_name': {col_info, new_value, old_value}}
512 for col_name, col_info in tracked_fields.items():
513 initial_value = initial[col_name]
514 record_value = getattr(browse_record, col_name)
516 if record_value == initial_value and getattr(self._all_columns[col_name].column, 'track_visibility', None) == 'always':
517 tracked_values[col_name] = dict(col_info=col_info['string'],
518 new_value=convert_for_display(record_value, col_info))
519 elif record_value != initial_value and (record_value or initial_value): # because browse null != False
520 if getattr(self._all_columns[col_name].column, 'track_visibility', None) in ['always', 'onchange']:
521 tracked_values[col_name] = dict(col_info=col_info['string'],
522 old_value=convert_for_display(initial_value, col_info),
523 new_value=convert_for_display(record_value, col_info))
524 if col_name in tracked_fields:
525 changes.add(col_name)
529 # find subtypes and post messages or log if no subtype found
531 for field, track_info in self._track.items():
532 if field not in changes:
534 for subtype, method in track_info.items():
535 if method(self, cr, uid, browse_record, context):
536 subtypes.append(subtype)
539 for subtype in subtypes:
540 subtype_rec = self.pool.get('ir.model.data').xmlid_to_object(cr, uid, subtype, context=context)
541 if not (subtype_rec and subtype_rec.exists()):
542 _logger.debug('subtype %s not found' % subtype)
544 message = format_message(subtype_rec.description if subtype_rec.description else subtype_rec.name, tracked_values)
545 self.message_post(cr, uid, browse_record.id, body=message, subtype=subtype, context=context)
548 message = format_message('', tracked_values)
549 self.message_post(cr, uid, browse_record.id, body=message, context=context)
552 #------------------------------------------------------
553 # mail.message wrappers and tools
554 #------------------------------------------------------
556 def _needaction_domain_get(self, cr, uid, context=None):
558 return [('message_unread', '=', True)]
561 def _garbage_collect_attachments(self, cr, uid, context=None):
562 """ Garbage collect lost mail attachments. Those are attachments
563 - linked to res_model 'mail.compose.message', the composer wizard
564 - with res_id 0, because they were created outside of an existing
565 wizard (typically user input through Chatter or reports
566 created on-the-fly by the templates)
567 - unused since at least one day (create_date and write_date)
569 limit_date = datetime.datetime.utcnow() - datetime.timedelta(days=1)
570 limit_date_str = datetime.datetime.strftime(limit_date, tools.DEFAULT_SERVER_DATETIME_FORMAT)
571 ir_attachment_obj = self.pool.get('ir.attachment')
572 attach_ids = ir_attachment_obj.search(cr, uid, [
573 ('res_model', '=', 'mail.compose.message'),
575 ('create_date', '<', limit_date_str),
576 ('write_date', '<', limit_date_str),
578 ir_attachment_obj.unlink(cr, uid, attach_ids, context=context)
581 def check_mail_message_access(self, cr, uid, mids, operation, model_obj=None, context=None):
582 """ mail.message check permission rules for related document. This method is
583 meant to be inherited in order to implement addons-specific behavior.
584 A common behavior would be to allow creating messages when having read
585 access rule on the document, for portal document such as issues. """
588 if hasattr(self, '_mail_post_access'):
589 create_allow = self._mail_post_access
591 create_allow = 'write'
593 if operation in ['write', 'unlink']:
594 check_operation = 'write'
595 elif operation == 'create' and create_allow in ['create', 'read', 'write', 'unlink']:
596 check_operation = create_allow
597 elif operation == 'create':
598 check_operation = 'write'
600 check_operation = operation
602 model_obj.check_access_rights(cr, uid, check_operation)
603 model_obj.check_access_rule(cr, uid, mids, check_operation, context=context)
605 def _get_inbox_action_xml_id(self, cr, uid, context=None):
606 """ When redirecting towards the Inbox, choose which action xml_id has
607 to be fetched. This method is meant to be inherited, at least in portal
608 because portal users have a different Inbox action than classic users. """
609 return ('mail', 'action_mail_inbox_feeds')
611 def message_redirect_action(self, cr, uid, context=None):
612 """ For a given message, return an action that either
613 - opens the form view of the related document if model, res_id, and
614 read access to the document
615 - opens the Inbox with a default search on the conversation if model,
617 - opens the Inbox with context propagated
623 # default action is the Inbox action
624 self.pool.get('res.users').browse(cr, SUPERUSER_ID, uid, context=context)
625 act_model, act_id = self.pool.get('ir.model.data').get_object_reference(cr, uid, *self._get_inbox_action_xml_id(cr, uid, context=context))
626 action = self.pool.get(act_model).read(cr, uid, act_id, [])
627 params = context.get('params')
628 msg_id = model = res_id = None
631 msg_id = params.get('message_id')
632 model = params.get('model')
633 res_id = params.get('res_id')
634 if not msg_id and not (model and res_id):
636 if msg_id and not (model and res_id):
637 msg = self.pool.get('mail.message').browse(cr, uid, msg_id, context=context)
639 model, res_id = msg.model, msg.res_id
641 # if model + res_id found: try to redirect to the document or fallback on the Inbox
643 model_obj = self.pool.get(model)
644 if model_obj.check_access_rights(cr, uid, 'read', raise_exception=False):
646 model_obj.check_access_rule(cr, uid, [res_id], 'read', context=context)
647 action = model_obj.get_formview_action(cr, uid, res_id, context=context)
648 except (osv.except_osv, orm.except_orm):
652 'search_default_model': model,
653 'search_default_res_id': res_id,
658 def _get_access_link(self, cr, uid, mail, partner, context=None):
659 # the parameters to encode for the query and fragment part of url
660 query = {'db': cr.dbname}
662 'login': partner.user_ids[0].login,
663 'action': 'mail.action_mail_redirect',
665 if mail.notification:
666 fragment['message_id'] = mail.mail_message_id.id
667 elif mail.model and mail.res_id:
668 fragment.update(model=mail.model, res_id=mail.res_id)
670 return "/web?%s#%s" % (urlencode(query), urlencode(fragment))
672 #------------------------------------------------------
674 #------------------------------------------------------
676 def message_get_default_recipients(self, cr, uid, ids, context=None):
677 if context and context.get('thread_model') and context['thread_model'] in self.pool and context['thread_model'] != self._name:
678 if hasattr(self.pool[context['thread_model']], 'message_get_default_recipients'):
679 sub_ctx = dict(context)
680 sub_ctx.pop('thread_model')
681 return self.pool[context['thread_model']].message_get_default_recipients(cr, uid, ids, context=sub_ctx)
683 for record in self.browse(cr, SUPERUSER_ID, ids, context=context):
684 recipient_ids, email_to, email_cc = set(), False, False
685 if 'partner_id' in self._all_columns and record.partner_id:
686 recipient_ids.add(record.partner_id.id)
687 elif 'email_from' in self._all_columns and record.email_from:
688 email_to = record.email_from
689 elif 'email' in self._all_columns:
690 email_to = record.email
691 res[record.id] = {'partner_ids': list(recipient_ids), 'email_to': email_to, 'email_cc': email_cc}
694 def message_get_reply_to(self, cr, uid, ids, context=None):
695 """ Returns the preferred reply-to email address that is basically
696 the alias of the document, if it exists. """
697 if not self._inherits.get('mail.alias'):
698 return [False for id in ids]
699 return ["%s@%s" % (record.alias_name, record.alias_domain)
700 if record.alias_domain and record.alias_name else False
701 for record in self.browse(cr, SUPERUSER_ID, ids, context=context)]
703 def message_get_email_values(self, cr, uid, id, notif_mail=None, context=None):
704 """ Temporary method to create custom notification email values for a given
705 model and document. This should be better to have a headers field on
706 the mail.mail model, computed when creating the notification email, but
707 this cannot be done in a stable version.
709 TDE FIXME: rethink this ulgy thing. """
713 #------------------------------------------------------
715 #------------------------------------------------------
717 def message_capable_models(self, cr, uid, context=None):
718 """ Used by the plugin addon, based for plugin_outlook and others. """
720 for model_name in self.pool.obj_list():
721 model = self.pool[model_name]
722 if hasattr(model, "message_process") and hasattr(model, "message_post"):
723 ret_dict[model_name] = model._description
726 def _message_find_partners(self, cr, uid, message, header_fields=['From'], context=None):
727 """ Find partners related to some header fields of the message.
729 :param string message: an email.message instance """
730 s = ', '.join([decode(message.get(h)) for h in header_fields if message.get(h)])
731 return filter(lambda x: x, self._find_partner_from_emails(cr, uid, None, tools.email_split(s), context=context))
733 def message_route_verify(self, cr, uid, message, message_dict, route, update_author=True, assert_model=True, create_fallback=True, allow_private=False, context=None):
734 """ Verify route validity. Check and rules:
735 1 - if thread_id -> check that document effectively exists; otherwise
736 fallback on a message_new by resetting thread_id
737 2 - check that message_update exists if thread_id is set; or at least
738 that message_new exist
739 [ - find author_id if udpate_author is set]
740 3 - if there is an alias, check alias_contact:
741 'followers' and thread_id:
742 check on target document that the author is in the followers
743 'followers' and alias_parent_thread_id:
744 check on alias parent document that the author is in the
746 'partners': check that author_id id set
749 assert isinstance(route, (list, tuple)), 'A route should be a list or a tuple'
750 assert len(route) == 5, 'A route should contain 5 elements: model, thread_id, custom_values, uid, alias record'
752 message_id = message.get('Message-Id')
753 email_from = decode_header(message, 'From')
754 author_id = message_dict.get('author_id')
755 model, thread_id, alias = route[0], route[1], route[4]
758 def _create_bounce_email():
759 mail_mail = self.pool.get('mail.mail')
760 mail_id = mail_mail.create(cr, uid, {
761 'body_html': '<div><p>Hello,</p>'
762 '<p>The following email sent to %s cannot be accepted because this is '
763 'a private email address. Only allowed people can contact us at this address.</p></div>'
764 '<blockquote>%s</blockquote>' % (message.get('to'), message_dict.get('body')),
765 'subject': 'Re: %s' % message.get('subject'),
766 'email_to': message.get('from'),
769 mail_mail.send(cr, uid, [mail_id], context=context)
772 _logger.warning('Routing mail with Message-Id %s: route %s: %s',
773 message_id, route, message)
776 if model and not model in self.pool:
778 assert model in self.pool, 'Routing: unknown target model %s' % model
779 _warn('unknown target model %s' % model)
782 model_pool = self.pool[model]
784 # Private message: should not contain any thread_id
785 if not model and thread_id:
788 raise ValueError('Routing: posting a message without model should be with a null res_id (private message), received %s.' % thread_id)
789 _warn('posting a message without model should be with a null res_id (private message), received %s resetting thread_id' % thread_id)
791 # Private message: should have a parent_id (only answers)
792 if not model and not message_dict.get('parent_id'):
794 if not message_dict.get('parent_id'):
795 raise ValueError('Routing: posting a message without model should be with a parent_id (private mesage).')
796 _warn('posting a message without model should be with a parent_id (private mesage), skipping')
799 # Existing Document: check if exists; if not, fallback on create if allowed
800 if thread_id and not model_pool.exists(cr, uid, thread_id):
802 _warn('reply to missing document (%s,%s), fall back on new document creation' % (model, thread_id))
805 assert model_pool.exists(cr, uid, thread_id), 'Routing: reply to missing document (%s,%s)' % (model, thread_id)
807 _warn('reply to missing document (%s,%s), skipping' % (model, thread_id))
810 # Existing Document: check model accepts the mailgateway
811 if thread_id and model and not hasattr(model_pool, 'message_update'):
813 _warn('model %s does not accept document update, fall back on document creation' % model)
816 assert hasattr(model_pool, 'message_update'), 'Routing: model %s does not accept document update, crashing' % model
818 _warn('model %s does not accept document update, skipping' % model)
821 # New Document: check model accepts the mailgateway
822 if not thread_id and model and not hasattr(model_pool, 'message_new'):
824 if not hasattr(model_pool, 'message_new'):
826 'Model %s does not accept document creation, crashing' % model
828 _warn('model %s does not accept document creation, skipping' % model)
831 # Update message author if asked
832 # We do it now because we need it for aliases (contact settings)
833 if not author_id and update_author:
834 author_ids = self._find_partner_from_emails(cr, uid, thread_id, [email_from], model=model, context=context)
836 author_id = author_ids[0]
837 message_dict['author_id'] = author_id
839 # Alias: check alias_contact settings
840 if alias and alias.alias_contact == 'followers' and (thread_id or alias.alias_parent_thread_id):
842 obj = self.pool[model].browse(cr, uid, thread_id, context=context)
844 obj = self.pool[alias.alias_parent_model_id.model].browse(cr, uid, alias.alias_parent_thread_id, context=context)
845 if not author_id or not author_id in [fol.id for fol in obj.message_follower_ids]:
846 _warn('alias %s restricted to internal followers, skipping' % alias.alias_name)
847 _create_bounce_email()
849 elif alias and alias.alias_contact == 'partners' and not author_id:
850 _warn('alias %s does not accept unknown author, skipping' % alias.alias_name)
851 _create_bounce_email()
854 if not model and not thread_id and not alias and not allow_private:
857 return (model, thread_id, route[2], route[3], route[4])
859 def message_route(self, cr, uid, message, message_dict, model=None, thread_id=None,
860 custom_values=None, context=None):
861 """Attempt to figure out the correct target model, thread_id,
862 custom_values and user_id to use for an incoming message.
863 Multiple values may be returned, if a message had multiple
864 recipients matching existing mail.aliases, for example.
866 The following heuristics are used, in this order:
867 1. If the message replies to an existing thread_id, and
868 properly contains the thread model in the 'In-Reply-To'
869 header, use this model/thread_id pair, and ignore
870 custom_value (not needed as no creation will take place)
871 2. Look for a mail.alias entry matching the message
872 recipient, and use the corresponding model, thread_id,
873 custom_values and user_id.
874 3. Fallback to the ``model``, ``thread_id`` and ``custom_values``
876 4. If all the above fails, raise an exception.
878 :param string message: an email.message instance
879 :param dict message_dict: dictionary holding message variables
880 :param string model: the fallback model to use if the message
881 does not match any of the currently configured mail aliases
882 (may be None if a matching alias is supposed to be present)
883 :type dict custom_values: optional dictionary of default field values
884 to pass to ``message_new`` if a new record needs to be created.
885 Ignored if the thread record already exists, and also if a
886 matching mail.alias was found (aliases define their own defaults)
887 :param int thread_id: optional ID of the record/thread from ``model``
888 to which this mail should be attached. Only used if the message
889 does not reply to an existing thread and does not match any mail alias.
890 :return: list of [model, thread_id, custom_values, user_id, alias]
892 :raises: ValueError, TypeError
894 if not isinstance(message, Message):
895 raise TypeError('message must be an email.message.Message at this point')
896 mail_msg_obj = self.pool['mail.message']
897 fallback_model = model
899 # Get email.message.Message variables for future processing
900 message_id = message.get('Message-Id')
901 email_from = decode_header(message, 'From')
902 email_to = decode_header(message, 'To')
903 references = decode_header(message, 'References')
904 in_reply_to = decode_header(message, 'In-Reply-To')
905 thread_references = references or in_reply_to
907 # 1. message is a reply to an existing message (exact match of message_id)
908 ref_match = thread_references and tools.reference_re.search(thread_references)
909 msg_references = mail_header_msgid_re.findall(thread_references)
910 mail_message_ids = mail_msg_obj.search(cr, uid, [('message_id', 'in', msg_references)], context=context)
911 if ref_match and mail_message_ids:
912 original_msg = mail_msg_obj.browse(cr, SUPERUSER_ID, mail_message_ids[0], context=context)
913 model, thread_id = original_msg.model, original_msg.res_id
914 route = self.message_route_verify(
915 cr, uid, message, message_dict,
916 (model, thread_id, custom_values, uid, None),
917 update_author=True, assert_model=False, create_fallback=True, context=context)
920 'Routing mail from %s to %s with Message-Id %s: direct reply to msg: model: %s, thread_id: %s, custom_values: %s, uid: %s',
921 email_from, email_to, message_id, model, thread_id, custom_values, uid)
924 # 2. message is a reply to an existign thread (6.1 compatibility)
926 reply_thread_id = int(ref_match.group(1))
927 reply_model = ref_match.group(2) or fallback_model
928 reply_hostname = ref_match.group(3)
929 local_hostname = socket.gethostname()
930 # do not match forwarded emails from another OpenERP system (thread_id collision!)
931 if local_hostname == reply_hostname:
932 thread_id, model = reply_thread_id, reply_model
933 if thread_id and model in self.pool:
934 model_obj = self.pool[model]
935 compat_mail_msg_ids = mail_msg_obj.search(
937 ('message_id', '=', False),
938 ('model', '=', model),
939 ('res_id', '=', thread_id),
941 if compat_mail_msg_ids and model_obj.exists(cr, uid, thread_id) and hasattr(model_obj, 'message_update'):
942 route = self.message_route_verify(
943 cr, uid, message, message_dict,
944 (model, thread_id, custom_values, uid, None),
945 update_author=True, assert_model=True, create_fallback=True, context=context)
948 'Routing mail from %s to %s with Message-Id %s: direct thread reply (compat-mode) to model: %s, thread_id: %s, custom_values: %s, uid: %s',
949 email_from, email_to, message_id, model, thread_id, custom_values, uid)
952 # 3. Reply to a private message
954 mail_message_ids = mail_msg_obj.search(cr, uid, [
955 ('message_id', '=', in_reply_to),
956 '!', ('message_id', 'ilike', 'reply_to')
957 ], limit=1, context=context)
959 mail_message = mail_msg_obj.browse(cr, uid, mail_message_ids[0], context=context)
960 route = self.message_route_verify(cr, uid, message, message_dict,
961 (mail_message.model, mail_message.res_id, custom_values, uid, None),
962 update_author=True, assert_model=True, create_fallback=True, allow_private=True, context=context)
965 'Routing mail from %s to %s with Message-Id %s: direct reply to a private message: %s, custom_values: %s, uid: %s',
966 email_from, email_to, message_id, mail_message.id, custom_values, uid)
969 # 4. Look for a matching mail.alias entry
970 # Delivered-To is a safe bet in most modern MTAs, but we have to fallback on To + Cc values
971 # for all the odd MTAs out there, as there is no standard header for the envelope's `rcpt_to` value.
973 ','.join([decode_header(message, 'Delivered-To'),
974 decode_header(message, 'To'),
975 decode_header(message, 'Cc'),
976 decode_header(message, 'Resent-To'),
977 decode_header(message, 'Resent-Cc')])
978 local_parts = [e.split('@')[0] for e in tools.email_split(rcpt_tos)]
980 mail_alias = self.pool.get('mail.alias')
981 alias_ids = mail_alias.search(cr, uid, [('alias_name', 'in', local_parts)])
984 for alias in mail_alias.browse(cr, uid, alias_ids, context=context):
985 user_id = alias.alias_user_id.id
987 # TDE note: this could cause crashes, because no clue that the user
988 # that send the email has the right to create or modify a new document
989 # Fallback on user_id = uid
990 # Note: recognized partners will be added as followers anyway
991 # user_id = self._message_find_user_id(cr, uid, message, context=context)
993 _logger.info('No matching user_id for the alias %s', alias.alias_name)
994 route = (alias.alias_model_id.model, alias.alias_force_thread_id, eval(alias.alias_defaults), user_id, alias)
995 route = self.message_route_verify(cr, uid, message, message_dict, route,
996 update_author=True, assert_model=True, create_fallback=True, context=context)
999 'Routing mail from %s to %s with Message-Id %s: direct alias match: %r',
1000 email_from, email_to, message_id, route)
1001 routes.append(route)
1004 # 5. Fallback to the provided parameters, if they work
1006 # Legacy: fallback to matching [ID] in the Subject
1007 match = tools.res_re.search(decode_header(message, 'Subject'))
1008 thread_id = match and match.group(1)
1009 # Convert into int (bug spotted in 7.0 because of str)
1011 thread_id = int(thread_id)
1014 route = self.message_route_verify(cr, uid, message, message_dict,
1015 (fallback_model, thread_id, custom_values, uid, None),
1016 update_author=True, assert_model=True, context=context)
1019 'Routing mail from %s to %s with Message-Id %s: fallback to model:%s, thread_id:%s, custom_values:%s, uid:%s',
1020 email_from, email_to, message_id, fallback_model, thread_id, custom_values, uid)
1023 # ValueError if no routes found and if no bounce occured
1025 'No possible route found for incoming message from %s to %s (Message-Id %s:). '
1026 'Create an appropriate mail.alias or force the destination model.' %
1027 (email_from, email_to, message_id)
1030 def message_route_process(self, cr, uid, message, message_dict, routes, context=None):
1031 # postpone setting message_dict.partner_ids after message_post, to avoid double notifications
1032 partner_ids = message_dict.pop('partner_ids', [])
1034 for model, thread_id, custom_values, user_id, alias in routes:
1035 if self._name == 'mail.thread':
1036 context.update({'thread_model': model})
1038 model_pool = self.pool[model]
1039 if not (thread_id and hasattr(model_pool, 'message_update') or hasattr(model_pool, 'message_new')):
1041 "Undeliverable mail with Message-Id %s, model %s does not accept incoming emails" %
1042 (message_dict['message_id'], model)
1045 # disabled subscriptions during message_new/update to avoid having the system user running the
1046 # email gateway become a follower of all inbound messages
1047 nosub_ctx = dict(context, mail_create_nosubscribe=True, mail_create_nolog=True)
1048 if thread_id and hasattr(model_pool, 'message_update'):
1049 model_pool.message_update(cr, user_id, [thread_id], message_dict, context=nosub_ctx)
1051 thread_id = model_pool.message_new(cr, user_id, message_dict, custom_values, context=nosub_ctx)
1054 raise ValueError("Posting a message without model should be with a null res_id, to create a private message.")
1055 model_pool = self.pool.get('mail.thread')
1056 if not hasattr(model_pool, 'message_post'):
1057 context['thread_model'] = model
1058 model_pool = self.pool['mail.thread']
1059 new_msg_id = model_pool.message_post(cr, uid, [thread_id], context=context, subtype='mail.mt_comment', **message_dict)
1062 # postponed after message_post, because this is an external message and we don't want to create
1063 # duplicate emails due to notifications
1064 self.pool.get('mail.message').write(cr, uid, [new_msg_id], {'partner_ids': partner_ids}, context=context)
1067 def message_process(self, cr, uid, model, message, custom_values=None,
1068 save_original=False, strip_attachments=False,
1069 thread_id=None, context=None):
1070 """ Process an incoming RFC2822 email message, relying on
1071 ``mail.message.parse()`` for the parsing operation,
1072 and ``message_route()`` to figure out the target model.
1074 Once the target model is known, its ``message_new`` method
1075 is called with the new message (if the thread record did not exist)
1076 or its ``message_update`` method (if it did).
1078 There is a special case where the target model is False: a reply
1079 to a private message. In this case, we skip the message_new /
1080 message_update step, to just post a new message using mail_thread
1083 :param string model: the fallback model to use if the message
1084 does not match any of the currently configured mail aliases
1085 (may be None if a matching alias is supposed to be present)
1086 :param message: source of the RFC2822 message
1087 :type message: string or xmlrpclib.Binary
1088 :type dict custom_values: optional dictionary of field values
1089 to pass to ``message_new`` if a new record needs to be created.
1090 Ignored if the thread record already exists, and also if a
1091 matching mail.alias was found (aliases define their own defaults)
1092 :param bool save_original: whether to keep a copy of the original
1093 email source attached to the message after it is imported.
1094 :param bool strip_attachments: whether to strip all attachments
1095 before processing the message, in order to save some space.
1096 :param int thread_id: optional ID of the record/thread from ``model``
1097 to which this mail should be attached. When provided, this
1098 overrides the automatic detection based on the message
1104 # extract message bytes - we are forced to pass the message as binary because
1105 # we don't know its encoding until we parse its headers and hence can't
1106 # convert it to utf-8 for transport between the mailgate script and here.
1107 if isinstance(message, xmlrpclib.Binary):
1108 message = str(message.data)
1109 # Warning: message_from_string doesn't always work correctly on unicode,
1110 # we must use utf-8 strings here :-(
1111 if isinstance(message, unicode):
1112 message = message.encode('utf-8')
1113 msg_txt = email.message_from_string(message)
1115 # parse the message, verify we are not in a loop by checking message_id is not duplicated
1116 msg = self.message_parse(cr, uid, msg_txt, save_original=save_original, context=context)
1117 if strip_attachments:
1118 msg.pop('attachments', None)
1120 if msg.get('message_id'): # should always be True as message_parse generate one if missing
1121 existing_msg_ids = self.pool.get('mail.message').search(cr, SUPERUSER_ID, [
1122 ('message_id', '=', msg.get('message_id')),
1124 if existing_msg_ids:
1125 _logger.info('Ignored mail from %s to %s with Message-Id %s: found duplicated Message-Id during processing',
1126 msg.get('from'), msg.get('to'), msg.get('message_id'))
1129 # find possible routes for the message
1130 routes = self.message_route(cr, uid, msg_txt, msg, model, thread_id, custom_values, context=context)
1131 thread_id = self.message_route_process(cr, uid, msg_txt, msg, routes, context=context)
1134 def message_new(self, cr, uid, msg_dict, custom_values=None, context=None):
1135 """Called by ``message_process`` when a new message is received
1136 for a given thread model, if the message did not belong to
1138 The default behavior is to create a new record of the corresponding
1139 model (based on some very basic info extracted from the message).
1140 Additional behavior may be implemented by overriding this method.
1142 :param dict msg_dict: a map containing the email details and
1143 attachments. See ``message_process`` and
1144 ``mail.message.parse`` for details.
1145 :param dict custom_values: optional dictionary of additional
1146 field values to pass to create()
1147 when creating the new thread record.
1148 Be careful, these values may override
1149 any other values coming from the message.
1150 :param dict context: if a ``thread_model`` value is present
1151 in the context, its value will be used
1152 to determine the model of the record
1153 to create (instead of the current model).
1155 :return: the id of the newly created thread object
1160 if isinstance(custom_values, dict):
1161 data = custom_values.copy()
1162 model = context.get('thread_model') or self._name
1163 model_pool = self.pool[model]
1164 fields = model_pool.fields_get(cr, uid, context=context)
1165 if 'name' in fields and not data.get('name'):
1166 data['name'] = msg_dict.get('subject', '')
1167 res_id = model_pool.create(cr, uid, data, context=context)
1170 def message_update(self, cr, uid, ids, msg_dict, update_vals=None, context=None):
1171 """Called by ``message_process`` when a new message is received
1172 for an existing thread. The default behavior is to update the record
1173 with update_vals taken from the incoming email.
1174 Additional behavior may be implemented by overriding this
1176 :param dict msg_dict: a map containing the email details and
1177 attachments. See ``message_process`` and
1178 ``mail.message.parse()`` for details.
1179 :param dict update_vals: a dict containing values to update records
1180 given their ids; if the dict is None or is
1181 void, no write operation is performed.
1184 self.write(cr, uid, ids, update_vals, context=context)
1187 def _message_extract_payload(self, message, save_original=False):
1188 """Extract body as HTML and attachments from the mail message"""
1192 attachments.append(('original_email.eml', message.as_string()))
1194 # Be careful, content-type may contain tricky content like in the
1195 # following example so test the MIME type with startswith()
1197 # Content-Type: multipart/related;
1198 # boundary="_004_3f1e4da175f349248b8d43cdeb9866f1AMSPR06MB343eurprd06pro_";
1200 if not message.is_multipart() or message.get('content-type', '').startswith("text/"):
1201 encoding = message.get_content_charset()
1202 body = message.get_payload(decode=True)
1203 body = tools.ustr(body, encoding, errors='replace')
1204 if message.get_content_type() == 'text/plain':
1205 # text/plain -> <pre/>
1206 body = tools.append_content_to_html(u'', body, preserve=True)
1209 for part in message.walk():
1210 if part.get_content_type() == 'multipart/alternative':
1212 if part.get_content_maintype() == 'multipart':
1213 continue # skip container
1214 # part.get_filename returns decoded value if able to decode, coded otherwise.
1215 # original get_filename is not able to decode iso-8859-1 (for instance).
1216 # therefore, iso encoded attachements are not able to be decoded properly with get_filename
1217 # code here partially copy the original get_filename method, but handle more encoding
1218 filename=part.get_param('filename', None, 'content-disposition')
1220 filename=part.get_param('name', None)
1222 if isinstance(filename, tuple):
1224 filename=email.utils.collapse_rfc2231_value(filename).strip()
1226 filename=decode(filename)
1227 encoding = part.get_content_charset() # None if attachment
1228 # 1) Explicit Attachments -> attachments
1229 if filename or part.get('content-disposition', '').strip().startswith('attachment'):
1230 attachments.append((filename or 'attachment', part.get_payload(decode=True)))
1232 # 2) text/plain -> <pre/>
1233 if part.get_content_type() == 'text/plain' and (not alternative or not body):
1234 body = tools.append_content_to_html(body, tools.ustr(part.get_payload(decode=True),
1235 encoding, errors='replace'), preserve=True)
1236 # 3) text/html -> raw
1237 elif part.get_content_type() == 'text/html':
1238 html = tools.ustr(part.get_payload(decode=True), encoding, errors='replace')
1242 body = tools.append_content_to_html(body, html, plaintext=False)
1243 # 4) Anything else -> attachment
1245 attachments.append((filename or 'attachment', part.get_payload(decode=True)))
1246 return body, attachments
1248 def message_parse(self, cr, uid, message, save_original=False, context=None):
1249 """Parses a string or email.message.Message representing an
1250 RFC-2822 email, and returns a generic dict holding the
1253 :param message: the message to parse
1254 :type message: email.message.Message | string | unicode
1255 :param bool save_original: whether the returned dict
1256 should include an ``original`` attachment containing
1257 the source of the message
1259 :return: A dict with the following structure, where each
1260 field may not be present if missing in original
1263 { 'message_id': msg_id,
1268 'body': unified_body,
1269 'attachments': [('file1', 'bytes'),
1276 if not isinstance(message, Message):
1277 if isinstance(message, unicode):
1278 # Warning: message_from_string doesn't always work correctly on unicode,
1279 # we must use utf-8 strings here :-(
1280 message = message.encode('utf-8')
1281 message = email.message_from_string(message)
1283 message_id = message['message-id']
1285 # Very unusual situation, be we should be fault-tolerant here
1286 message_id = "<%s@localhost>" % time.time()
1287 _logger.debug('Parsing Message without message-id, generating a random one: %s', message_id)
1288 msg_dict['message_id'] = message_id
1290 if message.get('Subject'):
1291 msg_dict['subject'] = decode(message.get('Subject'))
1293 # Envelope fields not stored in mail.message but made available for message_new()
1294 msg_dict['from'] = decode(message.get('from'))
1295 msg_dict['to'] = decode(message.get('to'))
1296 msg_dict['cc'] = decode(message.get('cc'))
1297 msg_dict['email_from'] = decode(message.get('from'))
1298 partner_ids = self._message_find_partners(cr, uid, message, ['To', 'Cc'], context=context)
1299 msg_dict['partner_ids'] = [(4, partner_id) for partner_id in partner_ids]
1301 if message.get('Date'):
1303 date_hdr = decode(message.get('Date'))
1304 parsed_date = dateutil.parser.parse(date_hdr, fuzzy=True)
1305 if parsed_date.utcoffset() is None:
1306 # naive datetime, so we arbitrarily decide to make it
1307 # UTC, there's no better choice. Should not happen,
1308 # as RFC2822 requires timezone offset in Date headers.
1309 stored_date = parsed_date.replace(tzinfo=pytz.utc)
1311 stored_date = parsed_date.astimezone(tz=pytz.utc)
1313 _logger.warning('Failed to parse Date header %r in incoming mail '
1314 'with message-id %r, assuming current date/time.',
1315 message.get('Date'), message_id)
1316 stored_date = datetime.datetime.now()
1317 msg_dict['date'] = stored_date.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT)
1319 if message.get('In-Reply-To'):
1320 parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', '=', decode(message['In-Reply-To'].strip()))])
1322 msg_dict['parent_id'] = parent_ids[0]
1324 if message.get('References') and 'parent_id' not in msg_dict:
1325 msg_list = mail_header_msgid_re.findall(decode(message['References']))
1326 parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', 'in', [x.strip() for x in msg_list])])
1328 msg_dict['parent_id'] = parent_ids[0]
1330 msg_dict['body'], msg_dict['attachments'] = self._message_extract_payload(message, save_original=save_original)
1333 #------------------------------------------------------
1335 #------------------------------------------------------
1337 def log(self, cr, uid, id, message, secondary=False, context=None):
1338 _logger.warning("log() is deprecated. As this module inherit from "\
1339 "mail.thread, the message will be managed by this "\
1340 "module instead of by the res.log mechanism. Please "\
1341 "use mail_thread.message_post() instead of the "\
1342 "now deprecated res.log.")
1343 self.message_post(cr, uid, [id], message, context=context)
1345 def _message_add_suggested_recipient(self, cr, uid, result, obj, partner=None, email=None, reason='', context=None):
1346 """ Called by message_get_suggested_recipients, to add a suggested
1347 recipient in the result dictionary. The form is :
1348 partner_id, partner_name<partner_email> or partner_name, reason """
1349 if email and not partner:
1350 # get partner info from email
1351 partner_info = self.message_partner_info_from_emails(cr, uid, obj.id, [email], context=context)[0]
1352 if partner_info.get('partner_id'):
1353 partner = self.pool.get('res.partner').browse(cr, SUPERUSER_ID, [partner_info['partner_id']], context=context)[0]
1354 if email and email in [val[1] for val in result[obj.id]]: # already existing email -> skip
1356 if partner and partner in obj.message_follower_ids: # recipient already in the followers -> skip
1358 if partner and partner.id in [val[0] for val in result[obj.id]]: # already existing partner ID -> skip
1360 if partner and partner.email: # complete profile: id, name <email>
1361 result[obj.id].append((partner.id, '%s<%s>' % (partner.name, partner.email), reason))
1362 elif partner: # incomplete profile: id, name
1363 result[obj.id].append((partner.id, '%s' % (partner.name), reason))
1364 else: # unknown partner, we are probably managing an email address
1365 result[obj.id].append((False, email, reason))
1368 def message_get_suggested_recipients(self, cr, uid, ids, context=None):
1369 """ Returns suggested recipients for ids. Those are a list of
1370 tuple (partner_id, partner_name, reason), to be managed by Chatter. """
1371 result = dict.fromkeys(ids, list())
1372 if self._all_columns.get('user_id'):
1373 for obj in self.browse(cr, SUPERUSER_ID, ids, context=context): # SUPERUSER because of a read on res.users that would crash otherwise
1374 if not obj.user_id or not obj.user_id.partner_id:
1376 self._message_add_suggested_recipient(cr, uid, result, obj, partner=obj.user_id.partner_id, reason=self._all_columns['user_id'].column.string, context=context)
1379 def _find_partner_from_emails(self, cr, uid, id, emails, model=None, context=None, check_followers=True):
1380 """ Utility method to find partners from email addresses. The rules are :
1381 1 - check in document (model | self, id) followers
1382 2 - try to find a matching partner that is also an user
1383 3 - try to find a matching partner
1385 :param list emails: list of email addresses
1386 :param string model: model to fetch related record; by default self
1388 :param boolean check_followers: check in document followers
1390 partner_obj = self.pool['res.partner']
1393 if id and (model or self._name != 'mail.thread') and check_followers:
1395 obj = self.pool[model].browse(cr, uid, id, context=context)
1397 obj = self.browse(cr, uid, id, context=context)
1398 for contact in emails:
1400 email_address = tools.email_split(contact)
1401 if not email_address:
1402 partner_ids.append(partner_id)
1404 email_address = email_address[0]
1405 # first try: check in document's followers
1407 for follower in obj.message_follower_ids:
1408 if follower.email == email_address:
1409 partner_id = follower.id
1410 # second try: check in partners that are also users
1412 ids = partner_obj.search(cr, SUPERUSER_ID, [
1413 ('email', 'ilike', email_address),
1414 ('user_ids', '!=', False)
1415 ], limit=1, context=context)
1418 # third try: check in partners
1420 ids = partner_obj.search(cr, SUPERUSER_ID, [
1421 ('email', 'ilike', email_address)
1422 ], limit=1, context=context)
1425 partner_ids.append(partner_id)
1428 def message_partner_info_from_emails(self, cr, uid, id, emails, link_mail=False, context=None):
1429 """ Convert a list of emails into a list partner_ids and a list
1430 new_partner_ids. The return value is non conventional because
1431 it is meant to be used by the mail widget.
1433 :return dict: partner_ids and new_partner_ids """
1434 mail_message_obj = self.pool.get('mail.message')
1435 partner_ids = self._find_partner_from_emails(cr, uid, id, emails, context=context)
1437 for idx in range(len(emails)):
1438 email_address = emails[idx]
1439 partner_id = partner_ids[idx]
1440 partner_info = {'full_name': email_address, 'partner_id': partner_id}
1441 result.append(partner_info)
1443 # link mail with this from mail to the new partner id
1444 if link_mail and partner_info['partner_id']:
1445 message_ids = mail_message_obj.search(cr, SUPERUSER_ID, [
1447 ('email_from', '=', email_address),
1448 ('email_from', 'ilike', '<%s>' % email_address),
1449 ('author_id', '=', False)
1452 mail_message_obj.write(cr, SUPERUSER_ID, message_ids, {'author_id': partner_info['partner_id']}, context=context)
1455 def _message_preprocess_attachments(self, cr, uid, attachments, attachment_ids, attach_model, attach_res_id, context=None):
1456 """ Preprocess attachments for mail_thread.message_post() or mail_mail.create().
1458 :param list attachments: list of attachment tuples in the form ``(name,content)``,
1459 where content is NOT base64 encoded
1460 :param list attachment_ids: a list of attachment ids, not in tomany command form
1461 :param str attach_model: the model of the attachments parent record
1462 :param integer attach_res_id: the id of the attachments parent record
1464 Attachment = self.pool['ir.attachment']
1465 m2m_attachment_ids = []
1467 filtered_attachment_ids = Attachment.search(cr, SUPERUSER_ID, [
1468 ('res_model', '=', 'mail.compose.message'),
1469 ('create_uid', '=', uid),
1470 ('id', 'in', attachment_ids)], context=context)
1471 if filtered_attachment_ids:
1472 Attachment.write(cr, SUPERUSER_ID, filtered_attachment_ids, {'res_model': attach_model, 'res_id': attach_res_id}, context=context)
1473 m2m_attachment_ids += [(4, id) for id in attachment_ids]
1474 # Handle attachments parameter, that is a dictionary of attachments
1475 for name, content in attachments:
1476 if isinstance(content, unicode):
1477 content = content.encode('utf-8')
1480 'datas': base64.b64encode(str(content)),
1481 'datas_fname': name,
1482 'description': name,
1483 'res_model': attach_model,
1484 'res_id': attach_res_id,
1486 m2m_attachment_ids.append((0, 0, data_attach))
1487 return m2m_attachment_ids
1489 def message_post(self, cr, uid, thread_id, body='', subject=None, type='notification',
1490 subtype=None, parent_id=False, attachments=None, context=None,
1491 content_subtype='html', **kwargs):
1492 """ Post a new message in an existing thread, returning the new
1495 :param int thread_id: thread ID to post into, or list with one ID;
1496 if False/0, mail.message model will also be set as False
1497 :param str body: body of the message, usually raw HTML that will
1499 :param str type: see mail_message.type field
1500 :param str content_subtype:: if plaintext: convert body into html
1501 :param int parent_id: handle reply to a previous message by adding the
1502 parent partners to the message in case of private discussion
1503 :param tuple(str,str) attachments or list id: list of attachment tuples in the form
1504 ``(name,content)``, where content is NOT base64 encoded
1506 Extra keyword arguments will be used as default column values for the
1507 new mail.message record. Special cases:
1508 - attachment_ids: supposed not attached to any document; attach them
1509 to the related document. Should only be set by Chatter.
1510 :return int: ID of newly created mail.message
1514 if attachments is None:
1516 mail_message = self.pool.get('mail.message')
1517 ir_attachment = self.pool.get('ir.attachment')
1519 assert (not thread_id) or \
1520 isinstance(thread_id, (int, long)) or \
1521 (isinstance(thread_id, (list, tuple)) and len(thread_id) == 1), \
1522 "Invalid thread_id; should be 0, False, an ID or a list with one ID"
1523 if isinstance(thread_id, (list, tuple)):
1524 thread_id = thread_id[0]
1526 # if we're processing a message directly coming from the gateway, the destination model was
1527 # set in the context.
1530 model = context.get('thread_model', self._name) if self._name == 'mail.thread' else self._name
1531 if model != self._name and hasattr(self.pool[model], 'message_post'):
1532 del context['thread_model']
1533 return self.pool[model].message_post(cr, uid, thread_id, body=body, subject=subject, type=type, subtype=subtype, parent_id=parent_id, attachments=attachments, context=context, content_subtype=content_subtype, **kwargs)
1535 #0: Find the message's author, because we need it for private discussion
1536 author_id = kwargs.get('author_id')
1537 if author_id is None: # keep False values
1538 author_id = self.pool.get('mail.message')._get_default_author(cr, uid, context=context)
1540 # 1: Handle content subtype: if plaintext, converto into HTML
1541 if content_subtype == 'plaintext':
1542 body = tools.plaintext2html(body)
1544 # 2: Private message: add recipients (recipients and author of parent message) - current author
1545 # + legacy-code management (! we manage only 4 and 6 commands)
1547 kwargs_partner_ids = kwargs.pop('partner_ids', [])
1548 for partner_id in kwargs_partner_ids:
1549 if isinstance(partner_id, (list, tuple)) and partner_id[0] == 4 and len(partner_id) == 2:
1550 partner_ids.add(partner_id[1])
1551 if isinstance(partner_id, (list, tuple)) and partner_id[0] == 6 and len(partner_id) == 3:
1552 partner_ids |= set(partner_id[2])
1553 elif isinstance(partner_id, (int, long)):
1554 partner_ids.add(partner_id)
1556 pass # we do not manage anything else
1557 if parent_id and not model:
1558 parent_message = mail_message.browse(cr, uid, parent_id, context=context)
1559 private_followers = set([partner.id for partner in parent_message.partner_ids])
1560 if parent_message.author_id:
1561 private_followers.add(parent_message.author_id.id)
1562 private_followers -= set([author_id])
1563 partner_ids |= private_followers
1566 # - HACK TDE FIXME: Chatter: attachments linked to the document (not done JS-side), load the message
1567 attachment_ids = self._message_preprocess_attachments(cr, uid, attachments, kwargs.pop('attachment_ids', []), model, thread_id, context)
1569 # 4: mail.message.subtype
1572 if '.' not in subtype:
1573 subtype = 'mail.%s' % subtype
1574 subtype_id = self.pool.get('ir.model.data').xmlid_to_res_id(cr, uid, subtype)
1576 # automatically subscribe recipients if asked to
1577 if context.get('mail_post_autofollow') and thread_id and partner_ids:
1578 partner_to_subscribe = partner_ids
1579 if context.get('mail_post_autofollow_partner_ids'):
1580 partner_to_subscribe = filter(lambda item: item in context.get('mail_post_autofollow_partner_ids'), partner_ids)
1581 self.message_subscribe(cr, uid, [thread_id], list(partner_to_subscribe), context=context)
1583 # _mail_flat_thread: automatically set free messages to the first posted message
1584 if self._mail_flat_thread and not parent_id and thread_id:
1585 message_ids = mail_message.search(cr, uid, ['&', ('res_id', '=', thread_id), ('model', '=', model)], context=context, order="id ASC", limit=1)
1586 parent_id = message_ids and message_ids[0] or False
1587 # we want to set a parent: force to set the parent_id to the oldest ancestor, to avoid having more than 1 level of thread
1589 message_ids = mail_message.search(cr, SUPERUSER_ID, [('id', '=', parent_id), ('parent_id', '!=', False)], context=context)
1590 # avoid loops when finding ancestors
1593 message = mail_message.browse(cr, SUPERUSER_ID, message_ids[0], context=context)
1594 while (message.parent_id and message.parent_id.id not in processed_list):
1595 processed_list.append(message.parent_id.id)
1596 message = message.parent_id
1597 parent_id = message.id
1601 'author_id': author_id,
1603 'res_id': thread_id or False,
1605 'subject': subject or False,
1607 'parent_id': parent_id,
1608 'attachment_ids': attachment_ids,
1609 'subtype_id': subtype_id,
1610 'partner_ids': [(4, pid) for pid in partner_ids],
1613 # Avoid warnings about non-existing fields
1614 for x in ('from', 'to', 'cc'):
1618 msg_id = mail_message.create(cr, uid, values, context=context)
1620 # Post-process: subscribe author, update message_last_post
1621 if model and model != 'mail.thread' and thread_id and subtype_id:
1622 # done with SUPERUSER_ID, because on some models users can post only with read access, not necessarily write access
1623 self.write(cr, SUPERUSER_ID, [thread_id], {'message_last_post': fields.datetime.now()}, context=context)
1624 message = mail_message.browse(cr, uid, msg_id, context=context)
1625 if message.author_id and thread_id and type != 'notification' and not context.get('mail_create_nosubscribe'):
1626 self.message_subscribe(cr, uid, [thread_id], [message.author_id.id], context=context)
1629 #------------------------------------------------------
1631 #------------------------------------------------------
1633 def message_get_subscription_data(self, cr, uid, ids, user_pid=None, context=None):
1634 """ Wrapper to get subtypes data. """
1635 return self._get_subscription_data(cr, uid, ids, None, None, user_pid=user_pid, context=context)
1637 def message_subscribe_users(self, cr, uid, ids, user_ids=None, subtype_ids=None, context=None):
1638 """ Wrapper on message_subscribe, using users. If user_ids is not
1639 provided, subscribe uid instead. """
1640 if user_ids is None:
1642 partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
1643 return self.message_subscribe(cr, uid, ids, partner_ids, subtype_ids=subtype_ids, context=context)
1645 def message_subscribe(self, cr, uid, ids, partner_ids, subtype_ids=None, context=None):
1646 """ Add partners to the records followers. """
1649 # not necessary for computation, but saves an access right check
1653 mail_followers_obj = self.pool.get('mail.followers')
1654 subtype_obj = self.pool.get('mail.message.subtype')
1656 user_pid = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1657 if set(partner_ids) == set([user_pid]):
1659 self.check_access_rights(cr, uid, 'read')
1660 self.check_access_rule(cr, uid, ids, 'read')
1661 except (osv.except_osv, orm.except_orm):
1664 self.check_access_rights(cr, uid, 'write')
1665 self.check_access_rule(cr, uid, ids, 'write')
1667 existing_pids_dict = {}
1668 fol_ids = mail_followers_obj.search(cr, SUPERUSER_ID, ['&', '&', ('res_model', '=', self._name), ('res_id', 'in', ids), ('partner_id', 'in', partner_ids)])
1669 for fol in mail_followers_obj.browse(cr, SUPERUSER_ID, fol_ids, context=context):
1670 existing_pids_dict.setdefault(fol.res_id, set()).add(fol.partner_id.id)
1672 # subtype_ids specified: update already subscribed partners
1673 if subtype_ids and fol_ids:
1674 mail_followers_obj.write(cr, SUPERUSER_ID, fol_ids, {'subtype_ids': [(6, 0, subtype_ids)]}, context=context)
1675 # subtype_ids not specified: do not update already subscribed partner, fetch default subtypes for new partners
1676 if subtype_ids is None:
1677 subtype_ids = subtype_obj.search(
1679 ('default', '=', True), '|', ('res_model', '=', self._name), ('res_model', '=', False)], context=context)
1682 existing_pids = existing_pids_dict.get(id, set())
1683 new_pids = set(partner_ids) - existing_pids
1685 # subscribe new followers
1686 for new_pid in new_pids:
1687 mail_followers_obj.create(
1689 'res_model': self._name,
1691 'partner_id': new_pid,
1692 'subtype_ids': [(6, 0, subtype_ids)],
1697 def message_unsubscribe_users(self, cr, uid, ids, user_ids=None, context=None):
1698 """ Wrapper on message_subscribe, using users. If user_ids is not
1699 provided, unsubscribe uid instead. """
1700 if user_ids is None:
1702 partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
1703 return self.message_unsubscribe(cr, uid, ids, partner_ids, context=context)
1705 def message_unsubscribe(self, cr, uid, ids, partner_ids, context=None):
1706 """ Remove partners from the records followers. """
1707 # not necessary for computation, but saves an access right check
1710 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
1711 if set(partner_ids) == set([user_pid]):
1712 self.check_access_rights(cr, uid, 'read')
1713 self.check_access_rule(cr, uid, ids, 'read')
1715 self.check_access_rights(cr, uid, 'write')
1716 self.check_access_rule(cr, uid, ids, 'write')
1717 fol_obj = self.pool['mail.followers']
1718 fol_ids = fol_obj.search(
1720 ('res_model', '=', self._name),
1721 ('res_id', 'in', ids),
1722 ('partner_id', 'in', partner_ids)
1724 return fol_obj.unlink(cr, SUPERUSER_ID, fol_ids, context=context)
1726 def _message_get_auto_subscribe_fields(self, cr, uid, updated_fields, auto_follow_fields=['user_id'], context=None):
1727 """ Returns the list of relational fields linking to res.users that should
1728 trigger an auto subscribe. The default list checks for the fields
1730 - linking to res.users
1731 - with track_visibility set
1732 In OpenERP V7, this is sufficent for all major addon such as opportunity,
1733 project, issue, recruitment, sale.
1734 Override this method if a custom behavior is needed about fields
1735 that automatically subscribe users.
1738 for name, column_info in self._all_columns.items():
1739 if name in auto_follow_fields and name in updated_fields and getattr(column_info.column, 'track_visibility', False) and column_info.column._obj == 'res.users':
1740 user_field_lst.append(name)
1741 return user_field_lst
1743 def message_auto_subscribe(self, cr, uid, ids, updated_fields, context=None, values=None):
1744 """ Handle auto subscription. Two methods for auto subscription exist:
1746 - tracked res.users relational fields, such as user_id fields. Those fields
1747 must be relation fields toward a res.users record, and must have the
1748 track_visilibity attribute set.
1749 - using subtypes parent relationship: check if the current model being
1750 modified has an header record (such as a project for tasks) whose followers
1751 can be added as followers of the current records. Example of structure
1752 with project and task:
1754 - st_project_1.parent_id = st_task_1
1755 - st_project_1.res_model = 'project.project'
1756 - st_project_1.relation_field = 'project_id'
1757 - st_task_1.model = 'project.task'
1759 :param list updated_fields: list of updated fields to track
1760 :param dict values: updated values; if None, the first record will be browsed
1761 to get the values. Added after releasing 7.0, therefore
1762 not merged with updated_fields argumment.
1764 subtype_obj = self.pool.get('mail.message.subtype')
1765 follower_obj = self.pool.get('mail.followers')
1766 new_followers = dict()
1768 # fetch auto_follow_fields: res.users relation fields whose changes are tracked for subscription
1769 user_field_lst = self._message_get_auto_subscribe_fields(cr, uid, updated_fields, context=context)
1771 # fetch header subtypes
1772 header_subtype_ids = subtype_obj.search(cr, uid, ['|', ('res_model', '=', False), ('parent_id.res_model', '=', self._name)], context=context)
1773 subtypes = subtype_obj.browse(cr, uid, header_subtype_ids, context=context)
1775 # if no change in tracked field or no change in tracked relational field: quit
1776 relation_fields = set([subtype.relation_field for subtype in subtypes if subtype.relation_field is not False])
1777 if not any(relation in updated_fields for relation in relation_fields) and not user_field_lst:
1780 # legacy behavior: if values is not given, compute the values by browsing
1781 # @TDENOTE: remove me in 8.0
1783 record = self.browse(cr, uid, ids[0], context=context)
1784 for updated_field in updated_fields:
1785 field_value = getattr(record, updated_field)
1786 if isinstance(field_value, browse_record):
1787 field_value = field_value.id
1788 elif isinstance(field_value, browse_null):
1790 values[updated_field] = field_value
1792 # find followers of headers, update structure for new followers
1794 for subtype in subtypes:
1795 if subtype.relation_field and values.get(subtype.relation_field):
1796 headers.add((subtype.res_model, values.get(subtype.relation_field)))
1798 header_domain = ['|'] * (len(headers) - 1)
1799 for header in headers:
1800 header_domain += ['&', ('res_model', '=', header[0]), ('res_id', '=', header[1])]
1801 header_follower_ids = follower_obj.search(
1806 for header_follower in follower_obj.browse(cr, SUPERUSER_ID, header_follower_ids, context=context):
1807 for subtype in header_follower.subtype_ids:
1808 if subtype.parent_id and subtype.parent_id.res_model == self._name:
1809 new_followers.setdefault(header_follower.partner_id.id, set()).add(subtype.parent_id.id)
1810 elif subtype.res_model is False:
1811 new_followers.setdefault(header_follower.partner_id.id, set()).add(subtype.id)
1813 # add followers coming from res.users relational fields that are tracked
1814 user_ids = [values[name] for name in user_field_lst if values.get(name)]
1815 user_pids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, SUPERUSER_ID, user_ids, context=context)]
1816 for partner_id in user_pids:
1817 new_followers.setdefault(partner_id, None)
1819 for pid, subtypes in new_followers.items():
1820 subtypes = list(subtypes) if subtypes is not None else None
1821 self.message_subscribe(cr, uid, ids, [pid], subtypes, context=context)
1823 # find first email message, set it as unread for auto_subscribe fields for them to have a notification
1825 for record_id in ids:
1826 message_obj = self.pool.get('mail.message')
1827 msg_ids = message_obj.search(cr, SUPERUSER_ID, [
1828 ('model', '=', self._name),
1829 ('res_id', '=', record_id),
1830 ('type', '=', 'email')], limit=1, context=context)
1832 msg_ids = message_obj.search(cr, SUPERUSER_ID, [
1833 ('model', '=', self._name),
1834 ('res_id', '=', record_id)], limit=1, context=context)
1836 self.pool.get('mail.notification')._notify(cr, uid, msg_ids[0], partners_to_notify=user_pids, context=context)
1840 #------------------------------------------------------
1842 #------------------------------------------------------
1844 def message_mark_as_unread(self, cr, uid, ids, context=None):
1845 """ Set as unread. """
1846 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1848 UPDATE mail_notification SET
1851 message_id IN (SELECT id from mail_message where res_id=any(%s) and model=%s limit 1) and
1853 ''', (ids, self._name, partner_id))
1856 def message_mark_as_read(self, cr, uid, ids, context=None):
1857 """ Set as read. """
1858 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1860 UPDATE mail_notification SET
1863 message_id IN (SELECT id FROM mail_message WHERE res_id=ANY(%s) AND model=%s) AND
1865 ''', (ids, self._name, partner_id))
1868 #------------------------------------------------------
1870 #------------------------------------------------------
1872 def get_suggested_thread(self, cr, uid, removed_suggested_threads=None, context=None):
1873 """Return a list of suggested threads, sorted by the numbers of followers"""
1877 # TDE HACK: originally by MAT from portal/mail_mail.py but not working until the inheritance graph bug is not solved in trunk
1878 # TDE FIXME: relocate in portal when it won't be necessary to reload the hr.employee model in an additional bridge module
1879 if self.pool['res.groups']._all_columns.get('is_portal'):
1880 user = self.pool.get('res.users').browse(cr, SUPERUSER_ID, uid, context=context)
1881 if any(group.is_portal for group in user.groups_id):
1885 if removed_suggested_threads is None:
1886 removed_suggested_threads = []
1888 thread_ids = self.search(cr, uid, [('id', 'not in', removed_suggested_threads), ('message_is_follower', '=', False)], context=context)
1889 for thread in self.browse(cr, uid, thread_ids, context=context):
1892 'popularity': len(thread.message_follower_ids),
1893 'name': thread.name,
1894 'image_small': thread.image_small
1896 threads.append(data)
1897 return sorted(threads, key=lambda x: (x['popularity'], x['id']), reverse=True)[:3]
1899 def message_change_thread(self, cr, uid, id, new_res_id, new_model, context=None):
1901 Transfert the list of the mail thread messages from an model to another
1903 :param id : the old res_id of the mail.message
1904 :param new_res_id : the new res_id of the mail.message
1905 :param new_model : the name of the new model of the mail.message
1907 Example : self.pool.get("crm.lead").message_change_thread(self, cr, uid, 2, 4, "project.issue", context)
1908 will transfert thread of the lead (id=2) to the issue (id=4)
1911 # get the sbtype id of the comment Message
1912 subtype_res_id = self.pool.get('ir.model.data').xmlid_to_res_id(cr, uid, 'mail.mt_comment', raise_if_not_found=True)
1914 # get the ids of the comment and none-comment of the thread
1915 message_obj = self.pool.get('mail.message')
1916 msg_ids_comment = message_obj.search(cr, uid, [
1917 ('model', '=', self._name),
1918 ('res_id', '=', id),
1919 ('subtype_id', '=', subtype_res_id)], context=context)
1920 msg_ids_not_comment = message_obj.search(cr, uid, [
1921 ('model', '=', self._name),
1922 ('res_id', '=', id),
1923 ('subtype_id', '!=', subtype_res_id)], context=context)
1925 # update the messages
1926 message_obj.write(cr, uid, msg_ids_comment, {"res_id" : new_res_id, "model" : new_model}, context=context)
1927 message_obj.write(cr, uid, msg_ids_not_comment, {"res_id" : new_res_id, "model" : new_model, "subtype_id" : None}, context=context)