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 ##############################################################################
27 import simplejson as json
30 from lxml import etree
37 from email.message import Message
39 from openerp import tools
40 from openerp import SUPERUSER_ID
41 from openerp.addons.mail.mail_message import decode
42 from openerp.osv import fields, osv, orm
43 from openerp.osv.orm import browse_record, browse_null
44 from openerp.tools.safe_eval import safe_eval as eval
45 from openerp.tools.translate import _
47 _logger = logging.getLogger(__name__)
50 mail_header_msgid_re = re.compile('<[^<>]+>')
52 def decode_header(message, header, separator=' '):
53 return separator.join(map(decode, filter(None, message.get_all(header, []))))
56 class mail_thread(osv.AbstractModel):
57 ''' mail_thread model is meant to be inherited by any model that needs to
58 act as a discussion topic on which messages can be attached. Public
59 methods are prefixed with ``message_`` in order to avoid name
60 collisions with methods of the models that will inherit from this class.
62 ``mail.thread`` defines fields used to handle and display the
63 communication history. ``mail.thread`` also manages followers of
64 inheriting classes. All features and expected behavior are managed
65 by mail.thread. Widgets has been designed for the 7.0 and following
68 Inheriting classes are not required to implement any method, as the
69 default implementation will work for any model. However it is common
70 to override at least the ``message_new`` and ``message_update``
71 methods (calling ``super``) to add model-specific behavior at
72 creation and update of a thread when processing incoming emails.
75 - _mail_flat_thread: if set to True, all messages without parent_id
76 are automatically attached to the first message posted on the
77 ressource. If set to False, the display of Chatter is done using
78 threads, and no parent_id is automatically set.
81 _description = 'Email Thread'
82 _mail_flat_thread = True
83 _mail_post_access = 'write'
85 # Automatic logging system if mail installed
88 # 'module.subtype_xml': lambda self, cr, uid, obj, context=None: obj[state] == done,
89 # 'module.subtype_xml2': lambda self, cr, uid, obj, context=None: obj[state] != done,
96 # :param string field: field name
97 # :param module.subtype_xml: xml_id of a mail.message.subtype (i.e. mail.mt_comment)
98 # :param obj: is a browse_record
99 # :param function lambda: returns whether the tracking should record using this subtype
102 def get_empty_list_help(self, cr, uid, help, context=None):
103 """ Override of BaseModel.get_empty_list_help() to generate an help message
104 that adds alias information. """
105 model = context.get('empty_list_help_model')
106 res_id = context.get('empty_list_help_id')
107 ir_config_parameter = self.pool.get("ir.config_parameter")
108 catchall_domain = ir_config_parameter.get_param(cr, uid, "mail.catchall.domain", context=context)
109 document_name = context.get('empty_list_help_document_name', _('document'))
112 if catchall_domain and model and res_id: # specific res_id -> find its alias (i.e. section_id specified)
113 object_id = self.pool.get(model).browse(cr, uid, res_id, context=context)
114 # check that the alias effectively creates new records
115 if object_id.alias_id and object_id.alias_id.alias_name and \
116 object_id.alias_id.alias_model_id and \
117 object_id.alias_id.alias_model_id.model == self._name and \
118 object_id.alias_id.alias_force_thread_id == 0:
119 alias = object_id.alias_id
120 elif catchall_domain and model: # no specific res_id given -> generic help message, take an example alias (i.e. alias of some section_id)
121 alias_obj = self.pool.get('mail.alias')
122 alias_ids = alias_obj.search(cr, uid, [("alias_parent_model_id.model", "=", model), ("alias_name", "!=", False), ('alias_force_thread_id', '=', False)], context=context, order='id ASC')
123 if alias_ids and len(alias_ids) == 1:
124 alias = alias_obj.browse(cr, uid, alias_ids[0], context=context)
127 alias_email = alias.name_get()[0][1]
128 return _("""<p class='oe_view_nocontent_create'>
129 Click here to add new %(document)s or send an email to: <a href='mailto:%(email)s'>%(email)s</a>
133 'document': document_name,
134 'email': alias_email,
135 'static_help': help or ''
138 if document_name != 'document' and help and help.find("oe_view_nocontent_create") == -1:
139 return _("<p class='oe_view_nocontent_create'>Click here to add new %(document)s</p>%(static_help)s") % {
140 'document': document_name,
141 'static_help': help or '',
146 def _get_message_data(self, cr, uid, ids, name, args, context=None):
148 - message_unread: has uid unread message for the document
149 - message_summary: html snippet summarizing the Chatter for kanban views """
150 res = dict((id, dict(message_unread=False, message_unread_count=0, message_summary=' ')) for id in ids)
151 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
153 # search for unread messages, directly in SQL to improve performances
154 cr.execute(""" SELECT m.res_id FROM mail_message m
155 RIGHT JOIN mail_notification n
156 ON (n.message_id = m.id AND n.partner_id = %s AND (n.read = False or n.read IS NULL))
157 WHERE m.model = %s AND m.res_id in %s""",
158 (user_pid, self._name, tuple(ids),))
159 for result in cr.fetchall():
160 res[result[0]]['message_unread'] = True
161 res[result[0]]['message_unread_count'] += 1
164 if res[id]['message_unread_count']:
165 title = res[id]['message_unread_count'] > 1 and _("You have %d unread messages") % res[id]['message_unread_count'] or _("You have one unread message")
166 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"))
167 res[id].pop('message_unread_count', None)
170 def read_followers_data(self, cr, uid, follower_ids, context=None):
172 technical_group = self.pool.get('ir.model.data').get_object(cr, uid, 'base', 'group_no_one', context=context)
173 for follower in self.pool.get('res.partner').browse(cr, uid, follower_ids, context=context):
174 is_editable = uid in map(lambda x: x.id, technical_group.users)
175 is_uid = uid in map(lambda x: x.id, follower.user_ids)
178 {'is_editable': is_editable, 'is_uid': is_uid},
183 def _get_subscription_data(self, cr, uid, ids, name, args, user_pid=None, context=None):
185 - message_subtype_data: data about document subtypes: which are
186 available, which are followed if any """
187 res = dict((id, dict(message_subtype_data='')) for id in ids)
189 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
191 # find current model subtypes, add them to a dictionary
192 subtype_obj = self.pool.get('mail.message.subtype')
193 subtype_ids = subtype_obj.search(cr, uid, ['|', ('res_model', '=', self._name), ('res_model', '=', False)], context=context)
194 subtype_dict = dict((subtype.name, dict(default=subtype.default, followed=False, id=subtype.id)) for subtype in subtype_obj.browse(cr, uid, subtype_ids, context=context))
196 res[id]['message_subtype_data'] = subtype_dict.copy()
198 # find the document followers, update the data
199 fol_obj = self.pool.get('mail.followers')
200 fol_ids = fol_obj.search(cr, uid, [
201 ('partner_id', '=', user_pid),
202 ('res_id', 'in', ids),
203 ('res_model', '=', self._name),
205 for fol in fol_obj.browse(cr, uid, fol_ids, context=context):
206 thread_subtype_dict = res[fol.res_id]['message_subtype_data']
207 for subtype in [st for st in fol.subtype_ids if st.name in thread_subtype_dict]:
208 thread_subtype_dict[subtype.name]['followed'] = True
209 res[fol.res_id]['message_subtype_data'] = thread_subtype_dict
213 def _search_message_unread(self, cr, uid, obj=None, name=None, domain=None, context=None):
214 return [('message_ids.to_read', '=', True)]
216 def _get_followers(self, cr, uid, ids, name, arg, context=None):
217 fol_obj = self.pool.get('mail.followers')
218 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids)])
219 res = dict((id, dict(message_follower_ids=[], message_is_follower=False)) for id in ids)
220 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
221 for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids):
222 res[fol.res_id]['message_follower_ids'].append(fol.partner_id.id)
223 if fol.partner_id.id == user_pid:
224 res[fol.res_id]['message_is_follower'] = True
227 def _set_followers(self, cr, uid, id, name, value, arg, context=None):
230 partner_obj = self.pool.get('res.partner')
231 fol_obj = self.pool.get('mail.followers')
233 # read the old set of followers, and determine the new set of followers
234 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', '=', id)])
235 old = set(fol.partner_id.id for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids))
238 for command in value or []:
239 if isinstance(command, (int, long)):
241 elif command[0] == 0:
242 new.add(partner_obj.create(cr, uid, command[2], context=context))
243 elif command[0] == 1:
244 partner_obj.write(cr, uid, [command[1]], command[2], context=context)
246 elif command[0] == 2:
247 partner_obj.unlink(cr, uid, [command[1]], context=context)
248 new.discard(command[1])
249 elif command[0] == 3:
250 new.discard(command[1])
251 elif command[0] == 4:
253 elif command[0] == 5:
255 elif command[0] == 6:
256 new = set(command[2])
258 # remove partners that are no longer followers
259 self.message_unsubscribe(cr, uid, [id], list(old-new), context=context)
261 self.message_subscribe(cr, uid, [id], list(new-old), context=context)
263 def _search_followers(self, cr, uid, obj, name, args, context):
264 """Search function for message_follower_ids
266 Do not use with operator 'not in'. Use instead message_is_followers
268 fol_obj = self.pool.get('mail.followers')
270 for field, operator, value in args:
272 # TOFIX make it work with not in
273 assert operator != "not in", "Do not search message_follower_ids with 'not in'"
274 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('partner_id', operator, value)])
275 res_ids = [fol.res_id for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids)]
276 res.append(('id', 'in', res_ids))
279 def _search_is_follower(self, cr, uid, obj, name, args, context):
280 """Search function for message_is_follower"""
282 for field, operator, value in args:
284 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
285 if (operator == '=' and value) or (operator == '!=' and not value): # is a follower
286 res_ids = self.search(cr, uid, [('message_follower_ids', 'in', [partner_id])], context=context)
287 else: # is not a follower or unknown domain
288 mail_ids = self.search(cr, uid, [('message_follower_ids', 'in', [partner_id])], context=context)
289 res_ids = self.search(cr, uid, [('id', 'not in', mail_ids)], context=context)
290 res.append(('id', 'in', res_ids))
294 'message_is_follower': fields.function(_get_followers, type='boolean',
295 fnct_search=_search_is_follower, string='Is a Follower', multi='_get_followers,'),
296 'message_follower_ids': fields.function(_get_followers, fnct_inv=_set_followers,
297 fnct_search=_search_followers, type='many2many', priority=-10,
298 obj='res.partner', string='Followers', multi='_get_followers'),
299 'message_ids': fields.one2many('mail.message', 'res_id',
300 domain=lambda self: [('model', '=', self._name)],
303 help="Messages and communication history"),
304 'message_unread': fields.function(_get_message_data,
305 fnct_search=_search_message_unread, multi="_get_message_data",
306 type='boolean', string='Unread Messages',
307 help="If checked new messages require your attention."),
308 'message_summary': fields.function(_get_message_data, method=True,
309 type='text', string='Summary', multi="_get_message_data",
310 help="Holds the Chatter summary (number of messages, ...). "\
311 "This summary is directly in html format in order to "\
312 "be inserted in kanban views."),
315 def _get_user_chatter_options(self, cr, uid, context=None):
317 'display_log_button': False
319 group_ids = self.pool.get('res.users').browse(cr, uid, uid, context=context).groups_id
320 group_user_id = self.pool.get("ir.model.data").get_object_reference(cr, uid, 'base', 'group_user')[1]
321 is_employee = group_user_id in [group.id for group in group_ids]
323 options['display_log_button'] = True
326 def fields_view_get(self, cr, uid, view_id=None, view_type='form', context=None, toolbar=False, submenu=False):
327 res = super(mail_thread, self).fields_view_get(cr, uid, view_id=view_id, view_type=view_type, context=context, toolbar=toolbar, submenu=submenu)
328 if view_type == 'form':
329 doc = etree.XML(res['arch'])
330 for node in doc.xpath("//field[@name='message_ids']"):
331 options = json.loads(node.get('options', '{}'))
332 options.update(self._get_user_chatter_options(cr, uid, context=context))
333 node.set('options', json.dumps(options))
334 res['arch'] = etree.tostring(doc)
337 #------------------------------------------------------
338 # CRUD overrides for automatic subscription and logging
339 #------------------------------------------------------
341 def create(self, cr, uid, values, context=None):
342 """ Chatter override :
344 - subscribe followers of parent
345 - log a creation message
350 # subscribe uid unless asked not to
351 if not context.get('mail_create_nosubscribe'):
352 pid = self.pool['res.users'].browse(cr, SUPERUSER_ID, uid).partner_id.id
353 message_follower_ids = values.get('message_follower_ids') or [] # webclient can send None or False
354 message_follower_ids.append([4, pid])
355 values['message_follower_ids'] = message_follower_ids
356 # add operation to ignore access rule checking for subscription
357 context_operation = dict(context, operation='create')
359 context_operation = context
360 thread_id = super(mail_thread, self).create(cr, uid, values, context=context_operation)
362 # automatic logging unless asked not to (mainly for various testing purpose)
363 if not context.get('mail_create_nolog'):
364 ir_model_pool = self.pool['ir.model']
365 ids = ir_model_pool.search(cr, uid, [('model', '=', self._name)], context=context)
366 name = ir_model_pool.read(cr, uid, ids, ['name'], context=context)[0]['name']
367 self.message_post(cr, uid, thread_id, body=_('%s created') % name, context=context)
369 # auto_subscribe: take values and defaults into account
370 create_values = dict(values)
371 for key, val in context.iteritems():
372 if key.startswith('default_'):
373 create_values[key[8:]] = val
374 self.message_auto_subscribe(cr, uid, [thread_id], create_values.keys(), context=context, values=create_values)
377 track_ctx = dict(context)
378 if 'lang' not in track_ctx:
379 track_ctx['lang'] = self.pool.get('res.users').browse(cr, uid, uid, context=context).lang
380 if not context.get('mail_notrack'):
381 tracked_fields = self._get_tracked_fields(cr, uid, values.keys(), context=track_ctx)
383 initial_values = {thread_id: dict((item, False) for item in tracked_fields)}
384 self.message_track(cr, uid, [thread_id], tracked_fields, initial_values, context=track_ctx)
387 def write(self, cr, uid, ids, values, context=None):
390 if isinstance(ids, (int, long)):
392 # Track initial values of tracked fields
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 tracked_fields = self._get_tracked_fields(cr, uid, values.keys(), context=track_ctx)
398 records = self.browse(cr, uid, ids, context=track_ctx)
399 initial_values = dict((this.id, dict((key, getattr(this, key)) for key in tracked_fields.keys())) for this in records)
401 # Perform write, update followers
402 result = super(mail_thread, self).write(cr, uid, ids, values, context=context)
403 self.message_auto_subscribe(cr, uid, ids, values.keys(), context=context, values=values)
405 if not context.get('mail_notrack'):
406 # Perform the tracking
407 tracked_fields = self._get_tracked_fields(cr, uid, values.keys(), context=context)
409 tracked_fields = None
411 self.message_track(cr, uid, ids, tracked_fields, initial_values, context=track_ctx)
414 def unlink(self, cr, uid, ids, context=None):
415 """ Override unlink to delete messages and followers. This cannot be
416 cascaded, because link is done through (res_model, res_id). """
417 msg_obj = self.pool.get('mail.message')
418 fol_obj = self.pool.get('mail.followers')
419 # delete messages and notifications
420 msg_ids = msg_obj.search(cr, uid, [('model', '=', self._name), ('res_id', 'in', ids)], context=context)
421 msg_obj.unlink(cr, uid, msg_ids, context=context)
423 res = super(mail_thread, self).unlink(cr, uid, ids, context=context)
425 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids)], context=context)
426 fol_obj.unlink(cr, SUPERUSER_ID, fol_ids, context=context)
429 def copy_data(self, cr, uid, id, default=None, context=None):
430 # avoid tracking multiple temporary changes during copy
431 context = dict(context or {}, mail_notrack=True)
433 default = default or {}
434 default['message_ids'] = []
435 default['message_follower_ids'] = []
436 return super(mail_thread, self).copy_data(cr, uid, id, default=default, context=context)
438 #------------------------------------------------------
439 # Automatically log tracked fields
440 #------------------------------------------------------
442 def _get_tracked_fields(self, cr, uid, updated_fields, context=None):
443 """ Return a structure of tracked fields for the current model.
444 :param list updated_fields: modified field names
445 :return list: a list of (field_name, column_info obj), containing
446 always tracked fields and modified on_change fields
449 for name, column_info in self._all_columns.items():
450 visibility = getattr(column_info.column, 'track_visibility', False)
451 if visibility == 'always' or (visibility == 'onchange' and name in updated_fields) or name in self._track:
455 return self.fields_get(cr, uid, lst, context=context)
457 def message_track(self, cr, uid, ids, tracked_fields, initial_values, context=None):
459 def convert_for_display(value, col_info):
460 if not value and col_info['type'] == 'boolean':
464 if col_info['type'] == 'many2one':
465 return value.name_get()[0][1]
466 if col_info['type'] == 'selection':
467 return dict(col_info['selection'])[value]
470 def format_message(message_description, tracked_values):
472 if message_description:
473 message = '<span>%s</span>' % message_description
474 for name, change in tracked_values.items():
475 message += '<div> • <b>%s</b>: ' % change.get('col_info')
476 if change.get('old_value'):
477 message += '%s → ' % change.get('old_value')
478 message += '%s</div>' % change.get('new_value')
481 if not tracked_fields:
484 for browse_record in self.browse(cr, uid, ids, context=context):
485 initial = initial_values[browse_record.id]
489 # generate tracked_values data structure: {'col_name': {col_info, new_value, old_value}}
490 for col_name, col_info in tracked_fields.items():
491 initial_value = initial[col_name]
492 record_value = getattr(browse_record, col_name)
494 if record_value == initial_value and getattr(self._all_columns[col_name].column, 'track_visibility', None) == 'always':
495 tracked_values[col_name] = dict(col_info=col_info['string'],
496 new_value=convert_for_display(record_value, col_info))
497 elif record_value != initial_value and (record_value or initial_value): # because browse null != False
498 if getattr(self._all_columns[col_name].column, 'track_visibility', None) in ['always', 'onchange']:
499 tracked_values[col_name] = dict(col_info=col_info['string'],
500 old_value=convert_for_display(initial_value, col_info),
501 new_value=convert_for_display(record_value, col_info))
502 if col_name in tracked_fields:
503 changes.add(col_name)
507 # find subtypes and post messages or log if no subtype found
509 for field, track_info in self._track.items():
510 if field not in changes:
512 for subtype, method in track_info.items():
513 if method(self, cr, uid, browse_record, context):
514 subtypes.append(subtype)
517 for subtype in subtypes:
518 subtype_rec = self.pool.get('ir.model.data').xmlid_to_object(cr, uid, subtype, context=context)
519 if not (subtype_rec and subtype_rec.exists()):
520 _logger.debug('subtype %s not found' % subtype)
522 message = format_message(subtype_rec.description if subtype_rec.description else subtype_rec.name, tracked_values)
523 self.message_post(cr, uid, browse_record.id, body=message, subtype=subtype, context=context)
526 message = format_message('', tracked_values)
527 self.message_post(cr, uid, browse_record.id, body=message, context=context)
530 #------------------------------------------------------
531 # mail.message wrappers and tools
532 #------------------------------------------------------
534 def _needaction_domain_get(self, cr, uid, context=None):
536 return [('message_unread', '=', True)]
539 def _garbage_collect_attachments(self, cr, uid, context=None):
540 """ Garbage collect lost mail attachments. Those are attachments
541 - linked to res_model 'mail.compose.message', the composer wizard
542 - with res_id 0, because they were created outside of an existing
543 wizard (typically user input through Chatter or reports
544 created on-the-fly by the templates)
545 - unused since at least one day (create_date and write_date)
547 limit_date = datetime.datetime.utcnow() - datetime.timedelta(days=1)
548 limit_date_str = datetime.datetime.strftime(limit_date, tools.DEFAULT_SERVER_DATETIME_FORMAT)
549 ir_attachment_obj = self.pool.get('ir.attachment')
550 attach_ids = ir_attachment_obj.search(cr, uid, [
551 ('res_model', '=', 'mail.compose.message'),
553 ('create_date', '<', limit_date_str),
554 ('write_date', '<', limit_date_str),
556 ir_attachment_obj.unlink(cr, uid, attach_ids, context=context)
559 def check_mail_message_access(self, cr, uid, mids, operation, model_obj=None, context=None):
560 """ mail.message check permission rules for related document. This method is
561 meant to be inherited in order to implement addons-specific behavior.
562 A common behavior would be to allow creating messages when having read
563 access rule on the document, for portal document such as issues. """
566 if hasattr(self, '_mail_post_access'):
567 create_allow = self._mail_post_access
569 create_allow = 'write'
571 if operation in ['write', 'unlink']:
572 check_operation = 'write'
573 elif operation == 'create' and create_allow in ['create', 'read', 'write', 'unlink']:
574 check_operation = create_allow
575 elif operation == 'create':
576 check_operation = 'write'
578 check_operation = operation
580 model_obj.check_access_rights(cr, uid, check_operation)
581 model_obj.check_access_rule(cr, uid, mids, check_operation, context=context)
583 def _get_formview_action(self, cr, uid, id, model=None, context=None):
584 """ Return an action to open the document. This method is meant to be
585 overridden in addons that want to give specific view ids for example.
587 :param int id: id of the document to open
588 :param string model: specific model that overrides self._name
591 'type': 'ir.actions.act_window',
592 'res_model': model or self._name,
595 'views': [(False, 'form')],
600 def _get_inbox_action_xml_id(self, cr, uid, context=None):
601 """ When redirecting towards the Inbox, choose which action xml_id has
602 to be fetched. This method is meant to be inherited, at least in portal
603 because portal users have a different Inbox action than classic users. """
604 return ('mail', 'action_mail_inbox_feeds')
606 def message_redirect_action(self, cr, uid, context=None):
607 """ For a given message, return an action that either
608 - opens the form view of the related document if model, res_id, and
609 read access to the document
610 - opens the Inbox with a default search on the conversation if model,
612 - opens the Inbox with context propagated
618 # default action is the Inbox action
619 self.pool.get('res.users').browse(cr, SUPERUSER_ID, uid, context=context)
620 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))
621 action = self.pool.get(act_model).read(cr, uid, act_id, [])
622 params = context.get('params')
623 msg_id = model = res_id = None
626 msg_id = params.get('message_id')
627 model = params.get('model')
628 res_id = params.get('res_id')
629 if not msg_id and not (model and res_id):
631 if msg_id and not (model and res_id):
632 msg = self.pool.get('mail.message').browse(cr, uid, msg_id, context=context)
634 model, res_id = msg.model, msg.res_id
636 # if model + res_id found: try to redirect to the document or fallback on the Inbox
638 model_obj = self.pool.get(model)
639 if model_obj.check_access_rights(cr, uid, 'read', raise_exception=False):
641 model_obj.check_access_rule(cr, uid, [res_id], 'read', context=context)
642 if not hasattr(model_obj, '_get_formview_action'):
643 action = self.pool.get('mail.thread')._get_formview_action(cr, uid, res_id, model=model, context=context)
645 action = model_obj._get_formview_action(cr, uid, res_id, context=context)
646 except (osv.except_osv, orm.except_orm):
650 'search_default_model': model,
651 'search_default_res_id': res_id,
656 #------------------------------------------------------
658 #------------------------------------------------------
660 def message_get_reply_to(self, cr, uid, ids, context=None):
661 """ Returns the preferred reply-to email address that is basically
662 the alias of the document, if it exists. """
663 if not self._inherits.get('mail.alias'):
664 return [False for id in ids]
665 return ["%s@%s" % (record['alias_name'], record['alias_domain'])
666 if record.get('alias_domain') and record.get('alias_name')
668 for record in self.read(cr, SUPERUSER_ID, ids, ['alias_name', 'alias_domain'], context=context)]
670 #------------------------------------------------------
672 #------------------------------------------------------
674 def message_capable_models(self, cr, uid, context=None):
675 """ Used by the plugin addon, based for plugin_outlook and others. """
677 for model_name in self.pool.obj_list():
678 model = self.pool[model_name]
679 if hasattr(model, "message_process") and hasattr(model, "message_post"):
680 ret_dict[model_name] = model._description
683 def _message_find_partners(self, cr, uid, message, header_fields=['From'], context=None):
684 """ Find partners related to some header fields of the message.
686 :param string message: an email.message instance """
687 s = ', '.join([decode(message.get(h)) for h in header_fields if message.get(h)])
688 return filter(lambda x: x, self._find_partner_from_emails(cr, uid, None, tools.email_split(s), context=context))
690 def message_route_verify(self, cr, uid, message, message_dict, route, update_author=True, assert_model=True, create_fallback=True, context=None):
691 """ Verify route validity. Check and rules:
692 1 - if thread_id -> check that document effectively exists; otherwise
693 fallback on a message_new by resetting thread_id
694 2 - check that message_update exists if thread_id is set; or at least
695 that message_new exist
696 [ - find author_id if udpate_author is set]
697 3 - if there is an alias, check alias_contact:
698 'followers' and thread_id:
699 check on target document that the author is in the followers
700 'followers' and alias_parent_thread_id:
701 check on alias parent document that the author is in the
703 'partners': check that author_id id set
706 assert isinstance(route, (list, tuple)), 'A route should be a list or a tuple'
707 assert len(route) == 5, 'A route should contain 5 elements: model, thread_id, custom_values, uid, alias record'
709 message_id = message.get('Message-Id')
710 email_from = decode_header(message, 'From')
711 author_id = message_dict.get('author_id')
712 model, thread_id, alias = route[0], route[1], route[4]
715 def _create_bounce_email():
716 mail_mail = self.pool.get('mail.mail')
717 mail_id = mail_mail.create(cr, uid, {
718 'body_html': '<div><p>Hello,</p>'
719 '<p>The following email sent to %s cannot be accepted because this is '
720 'a private email address. Only allowed people can contact us at this address.</p></div>'
721 '<blockquote>%s</blockquote>' % (message.get('to'), message_dict.get('body')),
722 'subject': 'Re: %s' % message.get('subject'),
723 'email_to': message.get('from'),
726 mail_mail.send(cr, uid, [mail_id], context=context)
729 _logger.warning('Routing mail with Message-Id %s: route %s: %s',
730 message_id, route, message)
733 if model and not model in self.pool:
735 assert model in self.pool, 'Routing: unknown target model %s' % model
736 _warn('unknown target model %s' % model)
739 model_pool = self.pool[model]
741 # Private message: should not contain any thread_id
742 if not model and thread_id:
745 raise ValueError('Routing: posting a message without model should be with a null res_id (private message), received %s.' % thread_id)
746 _warn('posting a message without model should be with a null res_id (private message), received %s resetting thread_id' % thread_id)
748 # Private message: should have a parent_id (only answers)
749 if not model and not message_dict.get('parent_id'):
751 if not message_dict.get('parent_id'):
752 raise ValueError('Routing: posting a message without model should be with a parent_id (private mesage).')
753 _warn('posting a message without model should be with a parent_id (private mesage), skipping')
756 # Existing Document: check if exists; if not, fallback on create if allowed
757 if thread_id and not model_pool.exists(cr, uid, thread_id):
759 _warn('reply to missing document (%s,%s), fall back on new document creation' % (model, thread_id))
762 assert model_pool.exists(cr, uid, thread_id), 'Routing: reply to missing document (%s,%s)' % (model, thread_id)
764 _warn('reply to missing document (%s,%s), skipping' % (model, thread_id))
767 # Existing Document: check model accepts the mailgateway
768 if thread_id and model and not hasattr(model_pool, 'message_update'):
770 _warn('model %s does not accept document update, fall back on document creation' % model)
773 assert hasattr(model_pool, 'message_update'), 'Routing: model %s does not accept document update, crashing' % model
775 _warn('model %s does not accept document update, skipping' % model)
778 # New Document: check model accepts the mailgateway
779 if not thread_id and model and not hasattr(model_pool, 'message_new'):
781 if not hasattr(model_pool, 'message_new'):
783 'Model %s does not accept document creation, crashing' % model
785 _warn('model %s does not accept document creation, skipping' % model)
788 # Update message author if asked
789 # We do it now because we need it for aliases (contact settings)
790 if not author_id and update_author:
791 author_ids = self._find_partner_from_emails(cr, uid, thread_id, [email_from], model=model, context=context)
793 author_id = author_ids[0]
794 message_dict['author_id'] = author_id
796 # Alias: check alias_contact settings
797 if alias and alias.alias_contact == 'followers' and (thread_id or alias.alias_parent_thread_id):
799 obj = self.pool[model].browse(cr, uid, thread_id, context=context)
801 obj = self.pool[alias.alias_parent_model_id.model].browse(cr, uid, alias.alias_parent_thread_id, context=context)
802 if not author_id or not author_id in [fol.id for fol in obj.message_follower_ids]:
803 _warn('alias %s restricted to internal followers, skipping' % alias.alias_name)
804 _create_bounce_email()
806 elif alias and alias.alias_contact == 'partners' and not author_id:
807 _warn('alias %s does not accept unknown author, skipping' % alias.alias_name)
808 _create_bounce_email()
811 return (model, thread_id, route[2], route[3], route[4])
813 def message_route(self, cr, uid, message, message_dict, model=None, thread_id=None,
814 custom_values=None, context=None):
815 """Attempt to figure out the correct target model, thread_id,
816 custom_values and user_id to use for an incoming message.
817 Multiple values may be returned, if a message had multiple
818 recipients matching existing mail.aliases, for example.
820 The following heuristics are used, in this order:
821 1. If the message replies to an existing thread_id, and
822 properly contains the thread model in the 'In-Reply-To'
823 header, use this model/thread_id pair, and ignore
824 custom_value (not needed as no creation will take place)
825 2. Look for a mail.alias entry matching the message
826 recipient, and use the corresponding model, thread_id,
827 custom_values and user_id.
828 3. Fallback to the ``model``, ``thread_id`` and ``custom_values``
830 4. If all the above fails, raise an exception.
832 :param string message: an email.message instance
833 :param dict message_dict: dictionary holding message variables
834 :param string model: the fallback model to use if the message
835 does not match any of the currently configured mail aliases
836 (may be None if a matching alias is supposed to be present)
837 :type dict custom_values: optional dictionary of default field values
838 to pass to ``message_new`` if a new record needs to be created.
839 Ignored if the thread record already exists, and also if a
840 matching mail.alias was found (aliases define their own defaults)
841 :param int thread_id: optional ID of the record/thread from ``model``
842 to which this mail should be attached. Only used if the message
843 does not reply to an existing thread and does not match any mail alias.
844 :return: list of [model, thread_id, custom_values, user_id, alias]
846 :raises: ValueError, TypeError
848 if not isinstance(message, Message):
849 raise TypeError('message must be an email.message.Message at this point')
850 mail_msg_obj = self.pool['mail.message']
851 fallback_model = model
853 # Get email.message.Message variables for future processing
854 message_id = message.get('Message-Id')
855 email_from = decode_header(message, 'From')
856 email_to = decode_header(message, 'To')
857 references = decode_header(message, 'References')
858 in_reply_to = decode_header(message, 'In-Reply-To')
859 thread_references = references or in_reply_to
861 # 1. message is a reply to an existing message (exact match of message_id)
862 msg_references = mail_header_msgid_re.findall(thread_references)
863 mail_message_ids = mail_msg_obj.search(cr, uid, [('message_id', 'in', msg_references)], context=context)
865 original_msg = mail_msg_obj.browse(cr, SUPERUSER_ID, mail_message_ids[0], context=context)
866 model, thread_id = original_msg.model, original_msg.res_id
868 'Routing mail from %s to %s with Message-Id %s: direct reply to msg: model: %s, thread_id: %s, custom_values: %s, uid: %s',
869 email_from, email_to, message_id, model, thread_id, custom_values, uid)
870 route = self.message_route_verify(
871 cr, uid, message, message_dict,
872 (model, thread_id, custom_values, uid, None),
873 update_author=True, assert_model=True, create_fallback=True, context=context)
874 return route and [route] or []
876 # 2. message is a reply to an existign thread (6.1 compatibility)
877 ref_match = thread_references and tools.reference_re.search(thread_references)
879 reply_thread_id = int(ref_match.group(1))
880 reply_model = ref_match.group(2) or fallback_model
881 reply_hostname = ref_match.group(3)
882 local_hostname = socket.gethostname()
883 # do not match forwarded emails from another OpenERP system (thread_id collision!)
884 if local_hostname == reply_hostname:
885 thread_id, model = reply_thread_id, reply_model
886 if thread_id and model in self.pool:
887 model_obj = self.pool[model]
888 compat_mail_msg_ids = mail_msg_obj.search(
890 ('message_id', '=', False),
891 ('model', '=', model),
892 ('res_id', '=', thread_id),
894 if compat_mail_msg_ids and model_obj.exists(cr, uid, thread_id) and hasattr(model_obj, 'message_update'):
896 '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',
897 email_from, email_to, message_id, model, thread_id, custom_values, uid)
898 route = self.message_route_verify(
899 cr, uid, message, message_dict,
900 (model, thread_id, custom_values, uid, None),
901 update_author=True, assert_model=True, create_fallback=True, context=context)
902 return route and [route] or []
904 # 2. Reply to a private message
906 mail_message_ids = mail_msg_obj.search(cr, uid, [
907 ('message_id', '=', in_reply_to),
908 '!', ('message_id', 'ilike', 'reply_to')
909 ], limit=1, context=context)
911 mail_message = mail_msg_obj.browse(cr, uid, mail_message_ids[0], context=context)
912 _logger.info('Routing mail from %s to %s with Message-Id %s: direct reply to a private message: %s, custom_values: %s, uid: %s',
913 email_from, email_to, message_id, mail_message.id, custom_values, uid)
914 route = self.message_route_verify(cr, uid, message, message_dict,
915 (mail_message.model, mail_message.res_id, custom_values, uid, None),
916 update_author=True, assert_model=True, create_fallback=True, context=context)
917 return route and [route] or []
919 # 3. Look for a matching mail.alias entry
920 # Delivered-To is a safe bet in most modern MTAs, but we have to fallback on To + Cc values
921 # for all the odd MTAs out there, as there is no standard header for the envelope's `rcpt_to` value.
923 ','.join([decode_header(message, 'Delivered-To'),
924 decode_header(message, 'To'),
925 decode_header(message, 'Cc'),
926 decode_header(message, 'Resent-To'),
927 decode_header(message, 'Resent-Cc')])
928 local_parts = [e.split('@')[0] for e in tools.email_split(rcpt_tos)]
930 mail_alias = self.pool.get('mail.alias')
931 alias_ids = mail_alias.search(cr, uid, [('alias_name', 'in', local_parts)])
934 for alias in mail_alias.browse(cr, uid, alias_ids, context=context):
935 user_id = alias.alias_user_id.id
937 # TDE note: this could cause crashes, because no clue that the user
938 # that send the email has the right to create or modify a new document
939 # Fallback on user_id = uid
940 # Note: recognized partners will be added as followers anyway
941 # user_id = self._message_find_user_id(cr, uid, message, context=context)
943 _logger.info('No matching user_id for the alias %s', alias.alias_name)
944 route = (alias.alias_model_id.model, alias.alias_force_thread_id, eval(alias.alias_defaults), user_id, alias)
945 _logger.info('Routing mail from %s to %s with Message-Id %s: direct alias match: %r',
946 email_from, email_to, message_id, route)
947 route = self.message_route_verify(cr, uid, message, message_dict, route,
948 update_author=True, assert_model=True, create_fallback=True, context=context)
953 # 4. Fallback to the provided parameters, if they work
955 # Legacy: fallback to matching [ID] in the Subject
956 match = tools.res_re.search(decode_header(message, 'Subject'))
957 thread_id = match and match.group(1)
958 # Convert into int (bug spotted in 7.0 because of str)
960 thread_id = int(thread_id)
963 _logger.info('Routing mail from %s to %s with Message-Id %s: fallback to model:%s, thread_id:%s, custom_values:%s, uid:%s',
964 email_from, email_to, message_id, fallback_model, thread_id, custom_values, uid)
965 route = self.message_route_verify(cr, uid, message, message_dict,
966 (fallback_model, thread_id, custom_values, uid, None),
967 update_author=True, assert_model=True, context=context)
971 # AssertionError if no routes found and if no bounce occured
973 'No possible route found for incoming message from %s to %s (Message-Id %s:). '
974 'Create an appropriate mail.alias or force the destination model.' %
975 (email_from, email_to, message_id)
978 def message_route_process(self, cr, uid, message, message_dict, routes, context=None):
979 # postpone setting message_dict.partner_ids after message_post, to avoid double notifications
980 partner_ids = message_dict.pop('partner_ids', [])
982 for model, thread_id, custom_values, user_id, alias in routes:
983 if self._name == 'mail.thread':
984 context.update({'thread_model': model})
986 model_pool = self.pool[model]
987 if not (thread_id and hasattr(model_pool, 'message_update') or hasattr(model_pool, 'message_new')):
989 "Undeliverable mail with Message-Id %s, model %s does not accept incoming emails" %
990 (message_dict['message_id'], model)
993 # disabled subscriptions during message_new/update to avoid having the system user running the
994 # email gateway become a follower of all inbound messages
995 nosub_ctx = dict(context, mail_create_nosubscribe=True, mail_create_nolog=True)
996 if thread_id and hasattr(model_pool, 'message_update'):
997 model_pool.message_update(cr, user_id, [thread_id], message_dict, context=nosub_ctx)
999 thread_id = model_pool.message_new(cr, user_id, message_dict, custom_values, context=nosub_ctx)
1002 raise ValueError("Posting a message without model should be with a null res_id, to create a private message.")
1003 model_pool = self.pool.get('mail.thread')
1004 if not hasattr(model_pool, 'message_post'):
1005 context['thread_model'] = model
1006 model_pool = self.pool['mail.thread']
1007 new_msg_id = model_pool.message_post(cr, uid, [thread_id], context=context, subtype='mail.mt_comment', **message_dict)
1010 # postponed after message_post, because this is an external message and we don't want to create
1011 # duplicate emails due to notifications
1012 self.pool.get('mail.message').write(cr, uid, [new_msg_id], {'partner_ids': partner_ids}, context=context)
1015 def message_process(self, cr, uid, model, message, custom_values=None,
1016 save_original=False, strip_attachments=False,
1017 thread_id=None, context=None):
1018 """ Process an incoming RFC2822 email message, relying on
1019 ``mail.message.parse()`` for the parsing operation,
1020 and ``message_route()`` to figure out the target model.
1022 Once the target model is known, its ``message_new`` method
1023 is called with the new message (if the thread record did not exist)
1024 or its ``message_update`` method (if it did).
1026 There is a special case where the target model is False: a reply
1027 to a private message. In this case, we skip the message_new /
1028 message_update step, to just post a new message using mail_thread
1031 :param string model: the fallback model to use if the message
1032 does not match any of the currently configured mail aliases
1033 (may be None if a matching alias is supposed to be present)
1034 :param message: source of the RFC2822 message
1035 :type message: string or xmlrpclib.Binary
1036 :type dict custom_values: optional dictionary of field values
1037 to pass to ``message_new`` if a new record needs to be created.
1038 Ignored if the thread record already exists, and also if a
1039 matching mail.alias was found (aliases define their own defaults)
1040 :param bool save_original: whether to keep a copy of the original
1041 email source attached to the message after it is imported.
1042 :param bool strip_attachments: whether to strip all attachments
1043 before processing the message, in order to save some space.
1044 :param int thread_id: optional ID of the record/thread from ``model``
1045 to which this mail should be attached. When provided, this
1046 overrides the automatic detection based on the message
1052 # extract message bytes - we are forced to pass the message as binary because
1053 # we don't know its encoding until we parse its headers and hence can't
1054 # convert it to utf-8 for transport between the mailgate script and here.
1055 if isinstance(message, xmlrpclib.Binary):
1056 message = str(message.data)
1057 # Warning: message_from_string doesn't always work correctly on unicode,
1058 # we must use utf-8 strings here :-(
1059 if isinstance(message, unicode):
1060 message = message.encode('utf-8')
1061 msg_txt = email.message_from_string(message)
1063 # parse the message, verify we are not in a loop by checking message_id is not duplicated
1064 msg = self.message_parse(cr, uid, msg_txt, save_original=save_original, context=context)
1065 if strip_attachments:
1066 msg.pop('attachments', None)
1068 if msg.get('message_id'): # should always be True as message_parse generate one if missing
1069 existing_msg_ids = self.pool.get('mail.message').search(cr, SUPERUSER_ID, [
1070 ('message_id', '=', msg.get('message_id')),
1072 if existing_msg_ids:
1073 _logger.info('Ignored mail from %s to %s with Message-Id %s: found duplicated Message-Id during processing',
1074 msg.get('from'), msg.get('to'), msg.get('message_id'))
1077 # find possible routes for the message
1078 routes = self.message_route(cr, uid, msg_txt, msg, model, thread_id, custom_values, context=context)
1079 thread_id = self.message_route_process(cr, uid, msg_txt, msg, routes, context=context)
1082 def message_new(self, cr, uid, msg_dict, custom_values=None, context=None):
1083 """Called by ``message_process`` when a new message is received
1084 for a given thread model, if the message did not belong to
1086 The default behavior is to create a new record of the corresponding
1087 model (based on some very basic info extracted from the message).
1088 Additional behavior may be implemented by overriding this method.
1090 :param dict msg_dict: a map containing the email details and
1091 attachments. See ``message_process`` and
1092 ``mail.message.parse`` for details.
1093 :param dict custom_values: optional dictionary of additional
1094 field values to pass to create()
1095 when creating the new thread record.
1096 Be careful, these values may override
1097 any other values coming from the message.
1098 :param dict context: if a ``thread_model`` value is present
1099 in the context, its value will be used
1100 to determine the model of the record
1101 to create (instead of the current model).
1103 :return: the id of the newly created thread object
1108 if isinstance(custom_values, dict):
1109 data = custom_values.copy()
1110 model = context.get('thread_model') or self._name
1111 model_pool = self.pool[model]
1112 fields = model_pool.fields_get(cr, uid, context=context)
1113 if 'name' in fields and not data.get('name'):
1114 data['name'] = msg_dict.get('subject', '')
1115 res_id = model_pool.create(cr, uid, data, context=context)
1118 def message_update(self, cr, uid, ids, msg_dict, update_vals=None, context=None):
1119 """Called by ``message_process`` when a new message is received
1120 for an existing thread. The default behavior is to update the record
1121 with update_vals taken from the incoming email.
1122 Additional behavior may be implemented by overriding this
1124 :param dict msg_dict: a map containing the email details and
1125 attachments. See ``message_process`` and
1126 ``mail.message.parse()`` for details.
1127 :param dict update_vals: a dict containing values to update records
1128 given their ids; if the dict is None or is
1129 void, no write operation is performed.
1132 self.write(cr, uid, ids, update_vals, context=context)
1135 def _message_extract_payload(self, message, save_original=False):
1136 """Extract body as HTML and attachments from the mail message"""
1140 attachments.append(('original_email.eml', message.as_string()))
1142 # Be careful, content-type may contain tricky content like in the
1143 # following example so test the MIME type with startswith()
1145 # Content-Type: multipart/related;
1146 # boundary="_004_3f1e4da175f349248b8d43cdeb9866f1AMSPR06MB343eurprd06pro_";
1148 if not message.is_multipart() or message.get('content-type', '').startswith("text/"):
1149 encoding = message.get_content_charset()
1150 body = message.get_payload(decode=True)
1151 body = tools.ustr(body, encoding, errors='replace')
1152 if message.get_content_type() == 'text/plain':
1153 # text/plain -> <pre/>
1154 body = tools.append_content_to_html(u'', body, preserve=True)
1157 for part in message.walk():
1158 if part.get_content_type() == 'multipart/alternative':
1160 if part.get_content_maintype() == 'multipart':
1161 continue # skip container
1162 # part.get_filename returns decoded value if able to decode, coded otherwise.
1163 # original get_filename is not able to decode iso-8859-1 (for instance).
1164 # therefore, iso encoded attachements are not able to be decoded properly with get_filename
1165 # code here partially copy the original get_filename method, but handle more encoding
1166 filename=part.get_param('filename', None, 'content-disposition')
1168 filename=part.get_param('name', None)
1170 if isinstance(filename, tuple):
1172 filename=email.utils.collapse_rfc2231_value(filename).strip()
1174 filename=decode(filename)
1175 encoding = part.get_content_charset() # None if attachment
1176 # 1) Explicit Attachments -> attachments
1177 if filename or part.get('content-disposition', '').strip().startswith('attachment'):
1178 attachments.append((filename or 'attachment', part.get_payload(decode=True)))
1180 # 2) text/plain -> <pre/>
1181 if part.get_content_type() == 'text/plain' and (not alternative or not body):
1182 body = tools.append_content_to_html(body, tools.ustr(part.get_payload(decode=True),
1183 encoding, errors='replace'), preserve=True)
1184 # 3) text/html -> raw
1185 elif part.get_content_type() == 'text/html':
1186 html = tools.ustr(part.get_payload(decode=True), encoding, errors='replace')
1190 body = tools.append_content_to_html(body, html, plaintext=False)
1191 # 4) Anything else -> attachment
1193 attachments.append((filename or 'attachment', part.get_payload(decode=True)))
1194 return body, attachments
1196 def message_parse(self, cr, uid, message, save_original=False, context=None):
1197 """Parses a string or email.message.Message representing an
1198 RFC-2822 email, and returns a generic dict holding the
1201 :param message: the message to parse
1202 :type message: email.message.Message | string | unicode
1203 :param bool save_original: whether the returned dict
1204 should include an ``original`` attachment containing
1205 the source of the message
1207 :return: A dict with the following structure, where each
1208 field may not be present if missing in original
1211 { 'message_id': msg_id,
1216 'body': unified_body,
1217 'attachments': [('file1', 'bytes'),
1224 if not isinstance(message, Message):
1225 if isinstance(message, unicode):
1226 # Warning: message_from_string doesn't always work correctly on unicode,
1227 # we must use utf-8 strings here :-(
1228 message = message.encode('utf-8')
1229 message = email.message_from_string(message)
1231 message_id = message['message-id']
1233 # Very unusual situation, be we should be fault-tolerant here
1234 message_id = "<%s@localhost>" % time.time()
1235 _logger.debug('Parsing Message without message-id, generating a random one: %s', message_id)
1236 msg_dict['message_id'] = message_id
1238 if message.get('Subject'):
1239 msg_dict['subject'] = decode(message.get('Subject'))
1241 # Envelope fields not stored in mail.message but made available for message_new()
1242 msg_dict['from'] = decode(message.get('from'))
1243 msg_dict['to'] = decode(message.get('to'))
1244 msg_dict['cc'] = decode(message.get('cc'))
1245 msg_dict['email_from'] = decode(message.get('from'))
1246 partner_ids = self._message_find_partners(cr, uid, message, ['To', 'Cc'], context=context)
1247 msg_dict['partner_ids'] = [(4, partner_id) for partner_id in partner_ids]
1249 if message.get('Date'):
1251 date_hdr = decode(message.get('Date'))
1252 parsed_date = dateutil.parser.parse(date_hdr, fuzzy=True)
1253 if parsed_date.utcoffset() is None:
1254 # naive datetime, so we arbitrarily decide to make it
1255 # UTC, there's no better choice. Should not happen,
1256 # as RFC2822 requires timezone offset in Date headers.
1257 stored_date = parsed_date.replace(tzinfo=pytz.utc)
1259 stored_date = parsed_date.astimezone(tz=pytz.utc)
1261 _logger.warning('Failed to parse Date header %r in incoming mail '
1262 'with message-id %r, assuming current date/time.',
1263 message.get('Date'), message_id)
1264 stored_date = datetime.datetime.now()
1265 msg_dict['date'] = stored_date.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT)
1267 if message.get('In-Reply-To'):
1268 parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', '=', decode(message['In-Reply-To'].strip()))])
1270 msg_dict['parent_id'] = parent_ids[0]
1272 if message.get('References') and 'parent_id' not in msg_dict:
1273 msg_list = mail_header_msgid_re.findall(decode(message['References']))
1274 parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', 'in', [x.strip() for x in msg_list])])
1276 msg_dict['parent_id'] = parent_ids[0]
1278 msg_dict['body'], msg_dict['attachments'] = self._message_extract_payload(message, save_original=save_original)
1281 #------------------------------------------------------
1283 #------------------------------------------------------
1285 def log(self, cr, uid, id, message, secondary=False, context=None):
1286 _logger.warning("log() is deprecated. As this module inherit from "\
1287 "mail.thread, the message will be managed by this "\
1288 "module instead of by the res.log mechanism. Please "\
1289 "use mail_thread.message_post() instead of the "\
1290 "now deprecated res.log.")
1291 self.message_post(cr, uid, [id], message, context=context)
1293 def _message_add_suggested_recipient(self, cr, uid, result, obj, partner=None, email=None, reason='', context=None):
1294 """ Called by message_get_suggested_recipients, to add a suggested
1295 recipient in the result dictionary. The form is :
1296 partner_id, partner_name<partner_email> or partner_name, reason """
1297 if email and not partner:
1298 # get partner info from email
1299 partner_info = self.message_partner_info_from_emails(cr, uid, obj.id, [email], context=context)[0]
1300 if partner_info.get('partner_id'):
1301 partner = self.pool.get('res.partner').browse(cr, SUPERUSER_ID, [partner_info['partner_id']], context=context)[0]
1302 if email and email in [val[1] for val in result[obj.id]]: # already existing email -> skip
1304 if partner and partner in obj.message_follower_ids: # recipient already in the followers -> skip
1306 if partner and partner.id in [val[0] for val in result[obj.id]]: # already existing partner ID -> skip
1308 if partner and partner.email: # complete profile: id, name <email>
1309 result[obj.id].append((partner.id, '%s<%s>' % (partner.name, partner.email), reason))
1310 elif partner: # incomplete profile: id, name
1311 result[obj.id].append((partner.id, '%s' % (partner.name), reason))
1312 else: # unknown partner, we are probably managing an email address
1313 result[obj.id].append((False, email, reason))
1316 def message_get_suggested_recipients(self, cr, uid, ids, context=None):
1317 """ Returns suggested recipients for ids. Those are a list of
1318 tuple (partner_id, partner_name, reason), to be managed by Chatter. """
1319 result = dict.fromkeys(ids, list())
1320 if self._all_columns.get('user_id'):
1321 for obj in self.browse(cr, SUPERUSER_ID, ids, context=context): # SUPERUSER because of a read on res.users that would crash otherwise
1322 if not obj.user_id or not obj.user_id.partner_id:
1324 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)
1327 def _find_partner_from_emails(self, cr, uid, id, emails, model=None, context=None, check_followers=True):
1328 """ Utility method to find partners from email addresses. The rules are :
1329 1 - check in document (model | self, id) followers
1330 2 - try to find a matching partner that is also an user
1331 3 - try to find a matching partner
1333 :param list emails: list of email addresses
1334 :param string model: model to fetch related record; by default self
1336 :param boolean check_followers: check in document followers
1338 partner_obj = self.pool['res.partner']
1341 if id and (model or self._name != 'mail.thread') and check_followers:
1343 obj = self.pool[model].browse(cr, uid, id, context=context)
1345 obj = self.browse(cr, uid, id, context=context)
1346 for contact in emails:
1348 email_address = tools.email_split(contact)
1349 if not email_address:
1350 partner_ids.append(partner_id)
1352 email_address = email_address[0]
1353 # first try: check in document's followers
1355 for follower in obj.message_follower_ids:
1356 if follower.email == email_address:
1357 partner_id = follower.id
1358 # second try: check in partners that are also users
1360 ids = partner_obj.search(cr, SUPERUSER_ID, [
1361 ('email', 'ilike', email_address),
1362 ('user_ids', '!=', False)
1363 ], limit=1, context=context)
1366 # third try: check in partners
1368 ids = partner_obj.search(cr, SUPERUSER_ID, [
1369 ('email', 'ilike', email_address)
1370 ], limit=1, context=context)
1373 partner_ids.append(partner_id)
1376 def message_partner_info_from_emails(self, cr, uid, id, emails, link_mail=False, context=None):
1377 """ Convert a list of emails into a list partner_ids and a list
1378 new_partner_ids. The return value is non conventional because
1379 it is meant to be used by the mail widget.
1381 :return dict: partner_ids and new_partner_ids """
1382 mail_message_obj = self.pool.get('mail.message')
1383 partner_ids = self._find_partner_from_emails(cr, uid, id, emails, context=context)
1385 for idx in range(len(emails)):
1386 email_address = emails[idx]
1387 partner_id = partner_ids[idx]
1388 partner_info = {'full_name': email_address, 'partner_id': partner_id}
1389 result.append(partner_info)
1391 # link mail with this from mail to the new partner id
1392 if link_mail and partner_info['partner_id']:
1393 message_ids = mail_message_obj.search(cr, SUPERUSER_ID, [
1395 ('email_from', '=', email_address),
1396 ('email_from', 'ilike', '<%s>' % email_address),
1397 ('author_id', '=', False)
1400 mail_message_obj.write(cr, SUPERUSER_ID, message_ids, {'author_id': partner_info['partner_id']}, context=context)
1403 def _message_preprocess_attachments(self, cr, uid, attachments, attachment_ids, attach_model, attach_res_id, context=None):
1404 """ Preprocess attachments for mail_thread.message_post() or mail_mail.create().
1406 :param list attachments: list of attachment tuples in the form ``(name,content)``,
1407 where content is NOT base64 encoded
1408 :param list attachment_ids: a list of attachment ids, not in tomany command form
1409 :param str attach_model: the model of the attachments parent record
1410 :param integer attach_res_id: the id of the attachments parent record
1412 Attachment = self.pool['ir.attachment']
1413 m2m_attachment_ids = []
1415 filtered_attachment_ids = Attachment.search(cr, SUPERUSER_ID, [
1416 ('res_model', '=', 'mail.compose.message'),
1417 ('create_uid', '=', uid),
1418 ('id', 'in', attachment_ids)], context=context)
1419 if filtered_attachment_ids:
1420 Attachment.write(cr, SUPERUSER_ID, filtered_attachment_ids, {'res_model': attach_model, 'res_id': attach_res_id}, context=context)
1421 m2m_attachment_ids += [(4, id) for id in attachment_ids]
1422 # Handle attachments parameter, that is a dictionary of attachments
1423 for name, content in attachments:
1424 if isinstance(content, unicode):
1425 content = content.encode('utf-8')
1428 'datas': base64.b64encode(str(content)),
1429 'datas_fname': name,
1430 'description': name,
1431 'res_model': attach_model,
1432 'res_id': attach_res_id,
1434 m2m_attachment_ids.append((0, 0, data_attach))
1435 return m2m_attachment_ids
1437 def message_post(self, cr, uid, thread_id, body='', subject=None, type='notification',
1438 subtype=None, parent_id=False, attachments=None, context=None,
1439 content_subtype='html', **kwargs):
1440 """ Post a new message in an existing thread, returning the new
1443 :param int thread_id: thread ID to post into, or list with one ID;
1444 if False/0, mail.message model will also be set as False
1445 :param str body: body of the message, usually raw HTML that will
1447 :param str type: see mail_message.type field
1448 :param str content_subtype:: if plaintext: convert body into html
1449 :param int parent_id: handle reply to a previous message by adding the
1450 parent partners to the message in case of private discussion
1451 :param tuple(str,str) attachments or list id: list of attachment tuples in the form
1452 ``(name,content)``, where content is NOT base64 encoded
1454 Extra keyword arguments will be used as default column values for the
1455 new mail.message record. Special cases:
1456 - attachment_ids: supposed not attached to any document; attach them
1457 to the related document. Should only be set by Chatter.
1458 :return int: ID of newly created mail.message
1462 if attachments is None:
1464 mail_message = self.pool.get('mail.message')
1465 ir_attachment = self.pool.get('ir.attachment')
1467 assert (not thread_id) or \
1468 isinstance(thread_id, (int, long)) or \
1469 (isinstance(thread_id, (list, tuple)) and len(thread_id) == 1), \
1470 "Invalid thread_id; should be 0, False, an ID or a list with one ID"
1471 if isinstance(thread_id, (list, tuple)):
1472 thread_id = thread_id[0]
1474 # if we're processing a message directly coming from the gateway, the destination model was
1475 # set in the context.
1478 model = context.get('thread_model', self._name) if self._name == 'mail.thread' else self._name
1479 if model != self._name and hasattr(self.pool[model], 'message_post'):
1480 del context['thread_model']
1481 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)
1483 #0: Find the message's author, because we need it for private discussion
1484 author_id = kwargs.get('author_id')
1485 if author_id is None: # keep False values
1486 author_id = self.pool.get('mail.message')._get_default_author(cr, uid, context=context)
1488 # 1: Handle content subtype: if plaintext, converto into HTML
1489 if content_subtype == 'plaintext':
1490 body = tools.plaintext2html(body)
1492 # 2: Private message: add recipients (recipients and author of parent message) - current author
1493 # + legacy-code management (! we manage only 4 and 6 commands)
1495 kwargs_partner_ids = kwargs.pop('partner_ids', [])
1496 for partner_id in kwargs_partner_ids:
1497 if isinstance(partner_id, (list, tuple)) and partner_id[0] == 4 and len(partner_id) == 2:
1498 partner_ids.add(partner_id[1])
1499 if isinstance(partner_id, (list, tuple)) and partner_id[0] == 6 and len(partner_id) == 3:
1500 partner_ids |= set(partner_id[2])
1501 elif isinstance(partner_id, (int, long)):
1502 partner_ids.add(partner_id)
1504 pass # we do not manage anything else
1505 if parent_id and not model:
1506 parent_message = mail_message.browse(cr, uid, parent_id, context=context)
1507 private_followers = set([partner.id for partner in parent_message.partner_ids])
1508 if parent_message.author_id:
1509 private_followers.add(parent_message.author_id.id)
1510 private_followers -= set([author_id])
1511 partner_ids |= private_followers
1514 # - HACK TDE FIXME: Chatter: attachments linked to the document (not done JS-side), load the message
1515 attachment_ids = self._message_preprocess_attachments(cr, uid, attachments, kwargs.pop('attachment_ids', []), model, thread_id, context)
1517 # 4: mail.message.subtype
1520 if '.' not in subtype:
1521 subtype = 'mail.%s' % subtype
1522 ref = self.pool.get('ir.model.data').get_object_reference(cr, uid, *subtype.split('.'))
1523 subtype_id = ref and ref[1] or False
1525 # automatically subscribe recipients if asked to
1526 if context.get('mail_post_autofollow') and thread_id and partner_ids:
1527 partner_to_subscribe = partner_ids
1528 if context.get('mail_post_autofollow_partner_ids'):
1529 partner_to_subscribe = filter(lambda item: item in context.get('mail_post_autofollow_partner_ids'), partner_ids)
1530 self.message_subscribe(cr, uid, [thread_id], list(partner_to_subscribe), context=context)
1532 # _mail_flat_thread: automatically set free messages to the first posted message
1533 if self._mail_flat_thread and not parent_id and thread_id:
1534 message_ids = mail_message.search(cr, uid, ['&', ('res_id', '=', thread_id), ('model', '=', model)], context=context, order="id ASC", limit=1)
1535 parent_id = message_ids and message_ids[0] or False
1536 # 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
1538 message_ids = mail_message.search(cr, SUPERUSER_ID, [('id', '=', parent_id), ('parent_id', '!=', False)], context=context)
1539 # avoid loops when finding ancestors
1542 message = mail_message.browse(cr, SUPERUSER_ID, message_ids[0], context=context)
1543 while (message.parent_id and message.parent_id.id not in processed_list):
1544 processed_list.append(message.parent_id.id)
1545 message = message.parent_id
1546 parent_id = message.id
1550 'author_id': author_id,
1552 'res_id': thread_id or False,
1554 'subject': subject or False,
1556 'parent_id': parent_id,
1557 'attachment_ids': attachment_ids,
1558 'subtype_id': subtype_id,
1559 'partner_ids': [(4, pid) for pid in partner_ids],
1562 # Avoid warnings about non-existing fields
1563 for x in ('from', 'to', 'cc'):
1566 # Create and auto subscribe the author
1567 msg_id = mail_message.create(cr, uid, values, context=context)
1568 message = mail_message.browse(cr, uid, msg_id, context=context)
1569 if message.author_id and thread_id and type != 'notification' and not context.get('mail_create_nosubscribe'):
1570 self.message_subscribe(cr, uid, [thread_id], [message.author_id.id], context=context)
1573 #------------------------------------------------------
1575 #------------------------------------------------------
1577 def message_get_subscription_data(self, cr, uid, ids, user_pid=None, context=None):
1578 """ Wrapper to get subtypes data. """
1579 return self._get_subscription_data(cr, uid, ids, None, None, user_pid=user_pid, context=context)
1581 def message_subscribe_users(self, cr, uid, ids, user_ids=None, subtype_ids=None, context=None):
1582 """ Wrapper on message_subscribe, using users. If user_ids is not
1583 provided, subscribe uid instead. """
1584 if user_ids is None:
1586 partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
1587 return self.message_subscribe(cr, uid, ids, partner_ids, subtype_ids=subtype_ids, context=context)
1589 def message_subscribe(self, cr, uid, ids, partner_ids, subtype_ids=None, context=None):
1590 """ Add partners to the records followers. """
1593 # not necessary for computation, but saves an access right check
1597 mail_followers_obj = self.pool.get('mail.followers')
1598 subtype_obj = self.pool.get('mail.message.subtype')
1600 user_pid = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1601 if set(partner_ids) == set([user_pid]):
1603 self.check_access_rights(cr, uid, 'read')
1604 if context.get('operation', '') == 'create':
1605 self.check_access_rule(cr, uid, ids, 'create')
1607 self.check_access_rule(cr, uid, ids, 'read')
1608 except (osv.except_osv, orm.except_orm):
1611 self.check_access_rights(cr, uid, 'write')
1612 self.check_access_rule(cr, uid, ids, 'write')
1614 existing_pids_dict = {}
1615 fol_ids = mail_followers_obj.search(cr, SUPERUSER_ID, ['&', '&', ('res_model', '=', self._name), ('res_id', 'in', ids), ('partner_id', 'in', partner_ids)])
1616 for fol in mail_followers_obj.browse(cr, SUPERUSER_ID, fol_ids, context=context):
1617 existing_pids_dict.setdefault(fol.res_id, set()).add(fol.partner_id.id)
1619 # subtype_ids specified: update already subscribed partners
1620 if subtype_ids and fol_ids:
1621 mail_followers_obj.write(cr, SUPERUSER_ID, fol_ids, {'subtype_ids': [(6, 0, subtype_ids)]}, context=context)
1622 # subtype_ids not specified: do not update already subscribed partner, fetch default subtypes for new partners
1623 if subtype_ids is None:
1624 subtype_ids = subtype_obj.search(
1626 ('default', '=', True), '|', ('res_model', '=', self._name), ('res_model', '=', False)], context=context)
1629 existing_pids = existing_pids_dict.get(id, set())
1630 new_pids = set(partner_ids) - existing_pids
1632 # subscribe new followers
1633 for new_pid in new_pids:
1634 mail_followers_obj.create(
1636 'res_model': self._name,
1638 'partner_id': new_pid,
1639 'subtype_ids': [(6, 0, subtype_ids)],
1644 def message_unsubscribe_users(self, cr, uid, ids, user_ids=None, context=None):
1645 """ Wrapper on message_subscribe, using users. If user_ids is not
1646 provided, unsubscribe uid instead. """
1647 if user_ids is None:
1649 partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
1650 return self.message_unsubscribe(cr, uid, ids, partner_ids, context=context)
1652 def message_unsubscribe(self, cr, uid, ids, partner_ids, context=None):
1653 """ Remove partners from the records followers. """
1654 # not necessary for computation, but saves an access right check
1657 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
1658 if set(partner_ids) == set([user_pid]):
1659 self.check_access_rights(cr, uid, 'read')
1660 self.check_access_rule(cr, uid, ids, 'read')
1662 self.check_access_rights(cr, uid, 'write')
1663 self.check_access_rule(cr, uid, ids, 'write')
1664 fol_obj = self.pool['mail.followers']
1665 fol_ids = fol_obj.search(
1667 ('res_model', '=', self._name),
1668 ('res_id', 'in', ids),
1669 ('partner_id', 'in', partner_ids)
1671 return fol_obj.unlink(cr, SUPERUSER_ID, fol_ids, context=context)
1673 def _message_get_auto_subscribe_fields(self, cr, uid, updated_fields, auto_follow_fields=['user_id'], context=None):
1674 """ Returns the list of relational fields linking to res.users that should
1675 trigger an auto subscribe. The default list checks for the fields
1677 - linking to res.users
1678 - with track_visibility set
1679 In OpenERP V7, this is sufficent for all major addon such as opportunity,
1680 project, issue, recruitment, sale.
1681 Override this method if a custom behavior is needed about fields
1682 that automatically subscribe users.
1685 for name, column_info in self._all_columns.items():
1686 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':
1687 user_field_lst.append(name)
1688 return user_field_lst
1690 def message_auto_subscribe(self, cr, uid, ids, updated_fields, context=None, values=None):
1691 """ Handle auto subscription. Two methods for auto subscription exist:
1693 - tracked res.users relational fields, such as user_id fields. Those fields
1694 must be relation fields toward a res.users record, and must have the
1695 track_visilibity attribute set.
1696 - using subtypes parent relationship: check if the current model being
1697 modified has an header record (such as a project for tasks) whose followers
1698 can be added as followers of the current records. Example of structure
1699 with project and task:
1701 - st_project_1.parent_id = st_task_1
1702 - st_project_1.res_model = 'project.project'
1703 - st_project_1.relation_field = 'project_id'
1704 - st_task_1.model = 'project.task'
1706 :param list updated_fields: list of updated fields to track
1707 :param dict values: updated values; if None, the first record will be browsed
1708 to get the values. Added after releasing 7.0, therefore
1709 not merged with updated_fields argumment.
1711 subtype_obj = self.pool.get('mail.message.subtype')
1712 follower_obj = self.pool.get('mail.followers')
1713 new_followers = dict()
1715 # fetch auto_follow_fields: res.users relation fields whose changes are tracked for subscription
1716 user_field_lst = self._message_get_auto_subscribe_fields(cr, uid, updated_fields, context=context)
1718 # fetch header subtypes
1719 header_subtype_ids = subtype_obj.search(cr, uid, ['|', ('res_model', '=', False), ('parent_id.res_model', '=', self._name)], context=context)
1720 subtypes = subtype_obj.browse(cr, uid, header_subtype_ids, context=context)
1722 # if no change in tracked field or no change in tracked relational field: quit
1723 relation_fields = set([subtype.relation_field for subtype in subtypes if subtype.relation_field is not False])
1724 if not any(relation in updated_fields for relation in relation_fields) and not user_field_lst:
1727 # legacy behavior: if values is not given, compute the values by browsing
1728 # @TDENOTE: remove me in 8.0
1730 record = self.browse(cr, uid, ids[0], context=context)
1731 for updated_field in updated_fields:
1732 field_value = getattr(record, updated_field)
1733 if isinstance(field_value, browse_record):
1734 field_value = field_value.id
1735 elif isinstance(field_value, browse_null):
1737 values[updated_field] = field_value
1739 # find followers of headers, update structure for new followers
1741 for subtype in subtypes:
1742 if subtype.relation_field and values.get(subtype.relation_field):
1743 headers.add((subtype.res_model, values.get(subtype.relation_field)))
1745 header_domain = ['|'] * (len(headers) - 1)
1746 for header in headers:
1747 header_domain += ['&', ('res_model', '=', header[0]), ('res_id', '=', header[1])]
1748 header_follower_ids = follower_obj.search(
1753 for header_follower in follower_obj.browse(cr, SUPERUSER_ID, header_follower_ids, context=context):
1754 for subtype in header_follower.subtype_ids:
1755 if subtype.parent_id and subtype.parent_id.res_model == self._name:
1756 new_followers.setdefault(header_follower.partner_id.id, set()).add(subtype.parent_id.id)
1757 elif subtype.res_model is False:
1758 new_followers.setdefault(header_follower.partner_id.id, set()).add(subtype.id)
1760 # add followers coming from res.users relational fields that are tracked
1761 user_ids = [values[name] for name in user_field_lst if values.get(name)]
1762 user_pids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, SUPERUSER_ID, user_ids, context=context)]
1763 for partner_id in user_pids:
1764 new_followers.setdefault(partner_id, None)
1766 for pid, subtypes in new_followers.items():
1767 subtypes = list(subtypes) if subtypes is not None else None
1768 self.message_subscribe(cr, uid, ids, [pid], subtypes, context=context)
1770 # find first email message, set it as unread for auto_subscribe fields for them to have a notification
1772 for record_id in ids:
1773 message_obj = self.pool.get('mail.message')
1774 msg_ids = message_obj.search(cr, SUPERUSER_ID, [
1775 ('model', '=', self._name),
1776 ('res_id', '=', record_id),
1777 ('type', '=', 'email')], limit=1, context=context)
1779 msg_ids = message_obj.search(cr, SUPERUSER_ID, [
1780 ('model', '=', self._name),
1781 ('res_id', '=', record_id)], limit=1, context=context)
1783 self.pool.get('mail.notification')._notify(cr, uid, msg_ids[0], partners_to_notify=user_pids, context=context)
1787 #------------------------------------------------------
1789 #------------------------------------------------------
1791 def message_mark_as_unread(self, cr, uid, ids, context=None):
1792 """ Set as unread. """
1793 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1795 UPDATE mail_notification SET
1798 message_id IN (SELECT id from mail_message where res_id=any(%s) and model=%s limit 1) and
1800 ''', (ids, self._name, partner_id))
1803 def message_mark_as_read(self, cr, uid, ids, context=None):
1804 """ Set as read. """
1805 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1807 UPDATE mail_notification SET
1810 message_id IN (SELECT id FROM mail_message WHERE res_id=ANY(%s) AND model=%s) AND
1812 ''', (ids, self._name, partner_id))
1815 #------------------------------------------------------
1817 #------------------------------------------------------
1819 def get_suggested_thread(self, cr, uid, removed_suggested_threads=None, context=None):
1820 """Return a list of suggested threads, sorted by the numbers of followers"""
1824 # TDE HACK: originally by MAT from portal/mail_mail.py but not working until the inheritance graph bug is not solved in trunk
1825 # TDE FIXME: relocate in portal when it won't be necessary to reload the hr.employee model in an additional bridge module
1826 if self.pool['res.groups']._all_columns.get('is_portal'):
1827 user = self.pool.get('res.users').browse(cr, SUPERUSER_ID, uid, context=context)
1828 if any(group.is_portal for group in user.groups_id):
1832 if removed_suggested_threads is None:
1833 removed_suggested_threads = []
1835 thread_ids = self.search(cr, uid, [('id', 'not in', removed_suggested_threads), ('message_is_follower', '=', False)], context=context)
1836 for thread in self.browse(cr, uid, thread_ids, context=context):
1839 'popularity': len(thread.message_follower_ids),
1840 'name': thread.name,
1841 'image_small': thread.image_small
1843 threads.append(data)
1844 return sorted(threads, key=lambda x: (x['popularity'], x['id']), reverse=True)[:3]