[CLEAN] Misc cleaning before merging (some code cleaning, added comments).
[odoo/odoo.git] / addons / mail / mail_thread.py
1 # -*- coding: utf-8 -*-
2 ##############################################################################
3 #
4 #    OpenERP, Open Source Management Solution
5 #    Copyright (C) 2009-today OpenERP SA (<http://www.openerp.com>)
6 #
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
11 #
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
16 #
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/>
19 #
20 ##############################################################################
21
22 import base64
23 import datetime
24 import dateutil
25 import email
26 import logging
27 import pytz
28 import time
29 import xmlrpclib
30 from email.message import Message
31
32 from openerp import tools
33 from openerp import SUPERUSER_ID
34 from openerp.addons.mail.mail_message import decode
35 from openerp.osv import fields, osv, orm
36 from openerp.tools.safe_eval import safe_eval as eval
37 from openerp.tools.translate import _
38
39 _logger = logging.getLogger(__name__)
40
41
42 def decode_header(message, header, separator=' '):
43     return separator.join(map(decode, filter(None, message.get_all(header, []))))
44
45
46 class mail_thread(osv.AbstractModel):
47     ''' mail_thread model is meant to be inherited by any model that needs to
48         act as a discussion topic on which messages can be attached. Public
49         methods are prefixed with ``message_`` in order to avoid name
50         collisions with methods of the models that will inherit from this class.
51
52         ``mail.thread`` defines fields used to handle and display the
53         communication history. ``mail.thread`` also manages followers of
54         inheriting classes. All features and expected behavior are managed
55         by mail.thread. Widgets has been designed for the 7.0 and following
56         versions of OpenERP.
57
58         Inheriting classes are not required to implement any method, as the
59         default implementation will work for any model. However it is common
60         to override at least the ``message_new`` and ``message_update``
61         methods (calling ``super``) to add model-specific behavior at
62         creation and update of a thread when processing incoming emails.
63
64         Options:
65             - _mail_flat_thread: if set to True, all messages without parent_id
66                 are automatically attached to the first message posted on the
67                 ressource. If set to False, the display of Chatter is done using
68                 threads, and no parent_id is automatically set.
69     '''
70     _name = 'mail.thread'
71     _description = 'Email Thread'
72     _mail_flat_thread = True
73
74     # Automatic logging system if mail installed
75     # _track = {
76     #   'field': {
77     #       'module.subtype_xml': lambda self, cr, uid, obj, context=None: obj[state] == done,
78     #       'module.subtype_xml2': lambda self, cr, uid, obj, context=None: obj[state] != done,
79     #   },
80     #   'field2': {
81     #       ...
82     #   },
83     # }
84     # where
85     #   :param string field: field name
86     #   :param module.subtype_xml: xml_id of a mail.message.subtype (i.e. mail.mt_comment)
87     #   :param obj: is a browse_record
88     #   :param function lambda: returns whether the tracking should record using this subtype
89     _track = {}
90
91     def get_empty_list_help(self, cr, uid, help, context=None):
92         """ Override of BaseModel.get_empty_list_help() to generate an help message
93             that adds alias information. """
94         model = context.get('empty_list_help_model')
95         res_id = context.get('empty_list_help_id')
96         ir_config_parameter = self.pool.get("ir.config_parameter")
97         catchall_domain = ir_config_parameter.get_param(cr, uid, "mail.catchall.domain", context=context)
98         document_name = context.get('empty_list_help_document_name', _('document'))
99         alias = None
100
101         if catchall_domain and model and res_id:  # specific res_id -> find its alias (i.e. section_id specified)
102             object_id = self.pool.get(model).browse(cr, uid, res_id, context=context)
103             # check that the alias effectively creates new records
104             if object_id.alias_id and object_id.alias_id.alias_name and \
105                     object_id.alias_id.alias_model_id and \
106                     object_id.alias_id.alias_model_id.model == self._name and \
107                     object_id.alias_id.alias_force_thread_id == 0:
108                 alias = object_id.alias_id
109         elif catchall_domain and model:  # no specific res_id given -> generic help message, take an example alias (i.e. alias of some section_id)
110             model_id = self.pool.get('ir.model').search(cr, uid, [("model", "=", self._name)], context=context)[0]
111             alias_obj = self.pool.get('mail.alias')
112             alias_ids = alias_obj.search(cr, uid, [("alias_model_id", "=", model_id), ("alias_name", "!=", False), ('alias_force_thread_id', '=', 0)], context=context, order='id ASC')
113             if alias_ids and len(alias_ids) == 1:  # if several aliases -> incoherent to propose one guessed from nowhere, therefore avoid if several aliases
114                 alias = alias_obj.browse(cr, uid, alias_ids[0], context=context)
115
116         if alias:
117             alias_email = alias.name_get()[0][1]
118             return _("""<p class='oe_view_nocontent_create'>
119                             Click here to add new %(document)s or send an email to: <a href='mailto:%(email)s'>%(email)s</a>
120                         </p>
121                         %(static_help)s"""
122                     ) % {
123                         'document': document_name,
124                         'email': alias_email,
125                         'static_help': help or ''
126                     }
127
128         if document_name != 'document' and help and help.find("oe_view_nocontent_create") == -1:
129             return _("<p class='oe_view_nocontent_create'>Click here to add new %(document)s</p>%(static_help)s") % {
130                         'document': document_name,
131                         'static_help': help or '',
132                     }
133
134         return help
135
136     def _get_message_data(self, cr, uid, ids, name, args, context=None):
137         """ Computes:
138             - message_unread: has uid unread message for the document
139             - message_summary: html snippet summarizing the Chatter for kanban views """
140         res = dict((id, dict(message_unread=False, message_unread_count=0, message_summary=' ')) for id in ids)
141         user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
142
143         # search for unread messages, directly in SQL to improve performances
144         cr.execute("""  SELECT m.res_id FROM mail_message m
145                         RIGHT JOIN mail_notification n
146                         ON (n.message_id = m.id AND n.partner_id = %s AND (n.read = False or n.read IS NULL))
147                         WHERE m.model = %s AND m.res_id in %s""",
148                     (user_pid, self._name, tuple(ids),))
149         for result in cr.fetchall():
150             res[result[0]]['message_unread'] = True
151             res[result[0]]['message_unread_count'] += 1
152
153         for id in ids:
154             if res[id]['message_unread_count']:
155                 title = res[id]['message_unread_count'] > 1 and _("You have %d unread messages") % res[id]['message_unread_count'] or _("You have one unread message")
156                 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"))
157         return res
158
159     def _get_subscription_data(self, cr, uid, ids, name, args, context=None):
160         """ Computes:
161             - message_subtype_data: data about document subtypes: which are
162                 available, which are followed if any """
163         res = dict((id, dict(message_subtype_data='')) for id in ids)
164         user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
165
166         # find current model subtypes, add them to a dictionary
167         subtype_obj = self.pool.get('mail.message.subtype')
168         subtype_ids = subtype_obj.search(cr, uid, ['|', ('res_model', '=', self._name), ('res_model', '=', False)], context=context)
169         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))
170         for id in ids:
171             res[id]['message_subtype_data'] = subtype_dict.copy()
172
173         # find the document followers, update the data
174         fol_obj = self.pool.get('mail.followers')
175         fol_ids = fol_obj.search(cr, uid, [
176             ('partner_id', '=', user_pid),
177             ('res_id', 'in', ids),
178             ('res_model', '=', self._name),
179         ], context=context)
180         for fol in fol_obj.browse(cr, uid, fol_ids, context=context):
181             thread_subtype_dict = res[fol.res_id]['message_subtype_data']
182             for subtype in fol.subtype_ids:
183                 thread_subtype_dict[subtype.name]['followed'] = True
184             res[fol.res_id]['message_subtype_data'] = thread_subtype_dict
185
186         return res
187
188     def _search_message_unread(self, cr, uid, obj=None, name=None, domain=None, context=None):
189         return [('message_ids.to_read', '=', True)]
190
191     def _get_followers(self, cr, uid, ids, name, arg, context=None):
192         fol_obj = self.pool.get('mail.followers')
193         fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids)])
194         res = dict((id, dict(message_follower_ids=[], message_is_follower=False)) for id in ids)
195         user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
196         for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids):
197             res[fol.res_id]['message_follower_ids'].append(fol.partner_id.id)
198             if fol.partner_id.id == user_pid:
199                 res[fol.res_id]['message_is_follower'] = True
200         return res
201
202     def _set_followers(self, cr, uid, id, name, value, arg, context=None):
203         if not value:
204             return
205         partner_obj = self.pool.get('res.partner')
206         fol_obj = self.pool.get('mail.followers')
207
208         # read the old set of followers, and determine the new set of followers
209         fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', '=', id)])
210         old = set(fol.partner_id.id for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids))
211         new = set(old)
212
213         for command in value or []:
214             if isinstance(command, (int, long)):
215                 new.add(command)
216             elif command[0] == 0:
217                 new.add(partner_obj.create(cr, uid, command[2], context=context))
218             elif command[0] == 1:
219                 partner_obj.write(cr, uid, [command[1]], command[2], context=context)
220                 new.add(command[1])
221             elif command[0] == 2:
222                 partner_obj.unlink(cr, uid, [command[1]], context=context)
223                 new.discard(command[1])
224             elif command[0] == 3:
225                 new.discard(command[1])
226             elif command[0] == 4:
227                 new.add(command[1])
228             elif command[0] == 5:
229                 new.clear()
230             elif command[0] == 6:
231                 new = set(command[2])
232
233         # remove partners that are no longer followers
234         fol_ids = fol_obj.search(cr, SUPERUSER_ID,
235             [('res_model', '=', self._name), ('res_id', '=', id), ('partner_id', 'not in', list(new))])
236         fol_obj.unlink(cr, SUPERUSER_ID, fol_ids)
237
238         # add new followers
239         for partner_id in new - old:
240             fol_obj.create(cr, SUPERUSER_ID, {'res_model': self._name, 'res_id': id, 'partner_id': partner_id})
241
242     def _search_followers(self, cr, uid, obj, name, args, context):
243         """Search function for message_follower_ids
244
245         Do not use with operator 'not in'. Use instead message_is_followers
246         """
247         fol_obj = self.pool.get('mail.followers')
248         res = []
249         for field, operator, value in args:
250             assert field == name
251             # TOFIX make it work with not in
252             assert operator != "not in", "Do not search message_follower_ids with 'not in'"
253             fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('partner_id', operator, value)])
254             res_ids = [fol.res_id for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids)]
255             res.append(('id', 'in', res_ids))
256         return res
257
258     def _search_is_follower(self, cr, uid, obj, name, args, context):
259         """Search function for message_is_follower"""
260         res = []
261         for field, operator, value in args:
262             assert field == name
263             partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
264             if (operator == '=' and value) or (operator == '!=' and not value):  # is a follower
265                 res_ids = self.search(cr, uid, [('message_follower_ids', 'in', [partner_id])], context=context)
266             else:  # is not a follower or unknown domain
267                 mail_ids = self.search(cr, uid, [('message_follower_ids', 'in', [partner_id])], context=context)
268                 res_ids = self.search(cr, uid, [('id', 'not in', mail_ids)], context=context)
269             res.append(('id', 'in', res_ids))
270         return res
271
272     _columns = {
273         'message_is_follower': fields.function(_get_followers, type='boolean',
274             fnct_search=_search_is_follower, string='Is a Follower', multi='_get_followers,'),
275         'message_follower_ids': fields.function(_get_followers, fnct_inv=_set_followers,
276             fnct_search=_search_followers, type='many2many',
277             obj='res.partner', string='Followers', multi='_get_followers'),
278         'message_ids': fields.one2many('mail.message', 'res_id',
279             domain=lambda self: [('model', '=', self._name)],
280             auto_join=True,
281             string='Messages',
282             help="Messages and communication history"),
283         'message_unread': fields.function(_get_message_data,
284             fnct_search=_search_message_unread, multi="_get_message_data",
285             type='boolean', string='Unread Messages',
286             help="If checked new messages require your attention."),
287         'message_summary': fields.function(_get_message_data, method=True,
288             type='text', string='Summary', multi="_get_message_data",
289             help="Holds the Chatter summary (number of messages, ...). "\
290                  "This summary is directly in html format in order to "\
291                  "be inserted in kanban views."),
292     }
293
294     #------------------------------------------------------
295     # CRUD overrides for automatic subscription and logging
296     #------------------------------------------------------
297
298     def create(self, cr, uid, values, context=None):
299         """ Chatter override :
300             - subscribe uid
301             - subscribe followers of parent
302             - log a creation message
303         """
304         if context is None:
305             context = {}
306         thread_id = super(mail_thread, self).create(cr, uid, values, context=context)
307
308         # automatic logging unless asked not to (mainly for various testing purpose)
309         if not context.get('mail_create_nolog'):
310             self.message_post(cr, uid, thread_id, body=_('%s created') % (self._description), context=context)
311
312         # subscribe uid unless asked not to
313         if not context.get('mail_create_nosubscribe'):
314             self.message_subscribe_users(cr, uid, [thread_id], [uid], context=context)
315         self.message_auto_subscribe(cr, uid, [thread_id], values.keys(), context=context)
316
317         # track values
318         tracked_fields = self._get_tracked_fields(cr, uid, values.keys(), context=context)
319         if tracked_fields:
320             initial_values = {thread_id: dict((item, False) for item in tracked_fields)}
321             self.message_track(cr, uid, [thread_id], tracked_fields, initial_values, context=context)
322
323         return thread_id
324
325     def write(self, cr, uid, ids, values, context=None):
326         if isinstance(ids, (int, long)):
327             ids = [ids]
328         # Track initial values of tracked fields
329         tracked_fields = self._get_tracked_fields(cr, uid, values.keys(), context=context)
330         if tracked_fields:
331             initial = self.read(cr, uid, ids, tracked_fields.keys(), context=context)
332             initial_values = dict((item['id'], item) for item in initial)
333
334         # Perform write, update followers
335         result = super(mail_thread, self).write(cr, uid, ids, values, context=context)
336         self.message_auto_subscribe(cr, uid, ids, values.keys(), context=context)
337
338         # Perform the tracking
339         if tracked_fields:
340             self.message_track(cr, uid, ids, tracked_fields, initial_values, context=context)
341         return result
342
343     def unlink(self, cr, uid, ids, context=None):
344         """ Override unlink to delete messages and followers. This cannot be
345             cascaded, because link is done through (res_model, res_id). """
346         msg_obj = self.pool.get('mail.message')
347         fol_obj = self.pool.get('mail.followers')
348         # delete messages and notifications
349         msg_ids = msg_obj.search(cr, uid, [('model', '=', self._name), ('res_id', 'in', ids)], context=context)
350         msg_obj.unlink(cr, uid, msg_ids, context=context)
351         # delete
352         res = super(mail_thread, self).unlink(cr, uid, ids, context=context)
353         # delete followers
354         fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids)], context=context)
355         fol_obj.unlink(cr, SUPERUSER_ID, fol_ids, context=context)
356         return res
357
358     def copy(self, cr, uid, id, default=None, context=None):
359         default = default or {}
360         default['message_ids'] = []
361         default['message_follower_ids'] = []
362         return super(mail_thread, self).copy(cr, uid, id, default=default, context=context)
363
364     #------------------------------------------------------
365     # Automatically log tracked fields
366     #------------------------------------------------------
367
368     def _get_tracked_fields(self, cr, uid, updated_fields, context=None):
369         """ Return a structure of tracked fields for the current model.
370             :param list updated_fields: modified field names
371             :return list: a list of (field_name, column_info obj), containing
372                 always tracked fields and modified on_change fields
373         """
374         lst = []
375         for name, column_info in self._all_columns.items():
376             visibility = getattr(column_info.column, 'track_visibility', False)
377             if visibility == 'always' or (visibility == 'onchange' and name in updated_fields) or name in self._track:
378                 lst.append(name)
379         if not lst:
380             return lst
381         return self.fields_get(cr, uid, lst, context=context)
382
383     def message_track(self, cr, uid, ids, tracked_fields, initial_values, context=None):
384
385         def convert_for_display(value, col_info):
386             if not value and col_info['type'] == 'boolean':
387                 return 'False'
388             if not value:
389                 return ''
390             if col_info['type'] == 'many2one':
391                 return value[1]
392             if col_info['type'] == 'selection':
393                 return dict(col_info['selection'])[value]
394             return value
395
396         def format_message(message_description, tracked_values):
397             message = ''
398             if message_description:
399                 message = '<span>%s</span>' % message_description
400             for name, change in tracked_values.items():
401                 message += '<div> &nbsp; &nbsp; &bull; <b>%s</b>: ' % change.get('col_info')
402                 if change.get('old_value'):
403                     message += '%s &rarr; ' % change.get('old_value')
404                 message += '%s</div>' % change.get('new_value')
405             return message
406
407         if not tracked_fields:
408             return True
409
410         for record in self.read(cr, uid, ids, tracked_fields.keys(), context=context):
411             initial = initial_values[record['id']]
412             changes = []
413             tracked_values = {}
414
415             # generate tracked_values data structure: {'col_name': {col_info, new_value, old_value}}
416             for col_name, col_info in tracked_fields.items():
417                 if record[col_name] == initial[col_name] and getattr(self._all_columns[col_name].column, 'track_visibility', None) == 'always':
418                     tracked_values[col_name] = dict(col_info=col_info['string'],
419                                                         new_value=convert_for_display(record[col_name], col_info))
420                 elif record[col_name] != initial[col_name]:
421                     if getattr(self._all_columns[col_name].column, 'track_visibility', None) in ['always', 'onchange']:
422                         tracked_values[col_name] = dict(col_info=col_info['string'],
423                                                             old_value=convert_for_display(initial[col_name], col_info),
424                                                             new_value=convert_for_display(record[col_name], col_info))
425                     if col_name in tracked_fields:
426                         changes.append(col_name)
427             if not changes:
428                 continue
429
430             # find subtypes and post messages or log if no subtype found
431             subtypes = []
432             for field, track_info in self._track.items():
433                 if field not in changes:
434                     continue
435                 for subtype, method in track_info.items():
436                     if method(self, cr, uid, record, context):
437                         subtypes.append(subtype)
438
439             posted = False
440             for subtype in subtypes:
441                 try:
442                     subtype_rec = self.pool.get('ir.model.data').get_object(cr, uid, subtype.split('.')[0], subtype.split('.')[1], context=context)
443                 except ValueError, e:
444                     _logger.debug('subtype %s not found, giving error "%s"' % (subtype, e))
445                     continue
446                 message = format_message(subtype_rec.description if subtype_rec.description else subtype_rec.name, tracked_values)
447                 self.message_post(cr, uid, record['id'], body=message, subtype=subtype, context=context)
448                 posted = True
449             if not posted:
450                 message = format_message('', tracked_values)
451                 self.message_post(cr, uid, record['id'], body=message, context=context)
452         return True
453
454     #------------------------------------------------------
455     # mail.message wrappers and tools
456     #------------------------------------------------------
457
458     def _needaction_domain_get(self, cr, uid, context=None):
459         if self._needaction:
460             return [('message_unread', '=', True)]
461         return []
462
463     def _garbage_collect_attachments(self, cr, uid, context=None):
464         """ Garbage collect lost mail attachments. Those are attachments
465             - linked to res_model 'mail.compose.message', the composer wizard
466             - with res_id 0, because they were created outside of an existing
467                 wizard (typically user input through Chatter or reports
468                 created on-the-fly by the templates)
469             - unused since at least one day (create_date and write_date)
470         """
471         limit_date = datetime.datetime.utcnow() - datetime.timedelta(days=1)
472         limit_date_str = datetime.datetime.strftime(limit_date, tools.DEFAULT_SERVER_DATETIME_FORMAT)
473         ir_attachment_obj = self.pool.get('ir.attachment')
474         attach_ids = ir_attachment_obj.search(cr, uid, [
475                             ('res_model', '=', 'mail.compose.message'),
476                             ('res_id', '=', 0),
477                             ('create_date', '<', limit_date_str),
478                             ('write_date', '<', limit_date_str),
479                             ], context=context)
480         ir_attachment_obj.unlink(cr, uid, attach_ids, context=context)
481         return True
482
483     def check_mail_message_access(self, cr, uid, mids, operation, model_obj=None, context=None):
484         """ mail.message check permission rules for related document. This method is
485             meant to be inherited in order to implement addons-specific behavior.
486             A common behavior would be to allow creating messages when having read
487             access rule on the document, for portal document such as issues. """
488         if not model_obj:
489             model_obj = self
490         if operation in ['create', 'write', 'unlink']:
491             model_obj.check_access_rights(cr, uid, 'write')
492             model_obj.check_access_rule(cr, uid, mids, 'write', context=context)
493         else:
494             model_obj.check_access_rights(cr, uid, operation)
495             model_obj.check_access_rule(cr, uid, mids, operation, context=context)
496
497     def _get_formview_action(self, cr, uid, id, model=None, context=None):
498         """ Return an action to open the document. This method is meant to be
499             overridden in addons that want to give specific view ids for example.
500
501             :param int id: id of the document to open
502             :param string model: specific model that overrides self._name
503         """
504         return {
505                 'type': 'ir.actions.act_window',
506                 'res_model': model or self._name,
507                 'view_type': 'form',
508                 'view_mode': 'form',
509                 'views': [(False, 'form')],
510                 'target': 'current',
511                 'res_id': id,
512             }
513
514     def _get_inbox_action_xml_id(self, cr, uid, context=None):
515         """ When redirecting towards the Inbox, choose which action xml_id has
516             to be fetched. This method is meant to be inherited, at least in portal
517             because portal users have a different Inbox action than classic users. """
518         return ('mail', 'action_mail_inbox_feeds')
519
520     def message_redirect_action(self, cr, uid, context=None):
521         """ For a given message, return an action that either
522             - opens the form view of the related document if model, res_id, and
523               read access to the document
524             - opens the Inbox with a default search on the conversation if model,
525               res_id
526             - opens the Inbox with context propagated
527
528         """
529         if context is None:
530             context = {}
531
532         # default action is the Inbox action
533         self.pool.get('res.users').browse(cr, SUPERUSER_ID, uid, context=context)
534         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))
535         action = self.pool.get(act_model).read(cr, uid, act_id, [])
536
537         # if msg_id specified: try to redirect to the document or fallback on the Inbox
538         msg_id = context.get('params', {}).get('message_id')
539         if not msg_id:
540             return action
541         msg = self.pool.get('mail.message').browse(cr, uid, msg_id, context=context)
542         if msg.model and msg.res_id:
543             action.update({
544                 'context': {
545                     'search_default_model': msg.model,
546                     'search_default_res_id': msg.res_id,
547                 }
548             })
549             if self.pool.get(msg.model).check_access_rights(cr, uid, 'read', raise_exception=False):
550                 try:
551                     model_obj = self.pool.get(msg.model)
552                     model_obj.check_access_rule(cr, uid, [msg.res_id], 'read', context=context)
553                     if not hasattr(model_obj, '_get_formview_action'):
554                         action = self.pool.get('mail.thread')._get_formview_action(cr, uid, msg.res_id, model=msg.model, context=context)
555                     else:
556                         action = model_obj._get_formview_action(cr, uid, msg.res_id, context=context)
557                 except (osv.except_osv, orm.except_orm):
558                     pass
559         return action
560
561     #------------------------------------------------------
562     # Email specific
563     #------------------------------------------------------
564
565     def message_get_reply_to(self, cr, uid, ids, context=None):
566         """ Returns the preferred reply-to email address that is basically
567             the alias of the document, if it exists. """
568         if not self._inherits.get('mail.alias'):
569             return [False for id in ids]
570         return ["%s@%s" % (record['alias_name'], record['alias_domain'])
571                     if record.get('alias_domain') and record.get('alias_name')
572                     else False
573                     for record in self.read(cr, SUPERUSER_ID, ids, ['alias_name', 'alias_domain'], context=context)]
574
575     #------------------------------------------------------
576     # Mail gateway
577     #------------------------------------------------------
578
579     def message_capable_models(self, cr, uid, context=None):
580         """ Used by the plugin addon, based for plugin_outlook and others. """
581         ret_dict = {}
582         for model_name in self.pool.obj_list():
583             model = self.pool[model_name]
584             if hasattr(model, "message_process") and hasattr(model, "message_post"):
585                 ret_dict[model_name] = model._description
586         return ret_dict
587
588     def _message_find_partners(self, cr, uid, message, header_fields=['From'], context=None):
589         """ Find partners related to some header fields of the message.
590
591             :param string message: an email.message instance """
592         s = ', '.join([decode(message.get(h)) for h in header_fields if message.get(h)])
593         return filter(lambda x: x, self._find_partner_from_emails(cr, uid, None, tools.email_split(s), context=context))
594
595     def message_route_verify(self, cr, uid, message, message_dict, route, update_author=True, assert_model=True, create_fallback=True, context=None):
596         """ Verify route validity. Check and rules:
597             1 - if thread_id -> check that document effectively exists; otherwise
598                 fallback on a message_new by resetting thread_id
599             2 - check that message_update exists if thread_id is set; or at least
600                 that message_new exist
601             [ - find author_id if udpate_author is set]
602             3 - if there is an alias, check alias_contact:
603                 'followers' and thread_id:
604                     check on target document that the author is in the followers
605                 'followers' and alias_parent_thread_id:
606                     check on alias parent document that the author is in the
607                     followers
608                 'partners': check that author_id id set
609         """
610
611         assert isinstance(route, (list, tuple)), 'A route should be a list or a tuple'
612         assert len(route) == 5, 'A route should contain 5 elements: model, thread_id, custom_values, uid, alias record'
613
614         message_id = message.get('Message-Id')
615         email_from = decode_header(message, 'From')
616         author_id = message_dict.get('author_id')
617         model, thread_id, alias = route[0], route[1], route[4]
618         model_pool = None
619
620         def _create_bounce_email():
621             mail_mail = self.pool.get('mail.mail')
622             mail_id = mail_mail.create(cr, uid, {
623                             'body_html': '<div><p>Hello,</p>'
624                                 '<p>The following email sent to %s cannot be accepted because this is '
625                                 'a private email address. Only allowed people can contact us at this address.</p></div>'
626                                 '<blockquote>%s</blockquote>' % (message.get('to'), message_dict.get('body')),
627                             'subject': 'Re: %s' % message.get('subject'),
628                             'email_to': message.get('from'),
629                             'auto_delete': True,
630                         }, context=context)
631             mail_mail.send(cr, uid, [mail_id], context=context)
632
633         def _warn(message):
634             _logger.warning('Routing mail with Message-Id %s: route %s: %s',
635                                 message_id, route, message)
636
637         # Wrong model
638         if model and not model in self.pool:
639             if assert_model:
640                 assert model in self.pool, 'Routing: unknown target model %s' % model
641             _warn('unknown target model %s' % model)
642             return ()
643         elif model:
644             model_pool = self.pool[model]
645
646         # Private message: should not contain any thread_id
647         if not model and thread_id:
648             if assert_model:
649                 assert thread_id == 0, 'Routing: posting a message without model should be with a null res_id (private message).'
650             _warn('posting a message without model should be with a null res_id (private message), resetting thread_id')
651             thread_id = 0
652
653         # Existing Document: check if exists; if not, fallback on create if allowed
654         if thread_id and not model_pool.exists(cr, uid, thread_id):
655             if create_fallback:
656                 _warn('reply to missing document (%s,%s), fall back on new document creation' % (model, thread_id))
657                 thread_id = None
658             elif assert_model:
659                 assert model_pool.exists(cr, uid, thread_id), 'Routing: reply to missing document (%s,%s)' % (model, thread_id)
660             else:
661                 _warn('reply to missing document (%s,%s), skipping' % (model, thread_id))
662                 return ()
663
664         # Existing Document: check model accepts the mailgateway
665         if thread_id and not hasattr(model_pool, 'message_update'):
666             if create_fallback:
667                 _warn('model %s does not accept document update, fall back on document creation' % model)
668                 thread_id = None
669             elif assert_model:
670                 assert hasattr(model_pool, 'message_update'), 'Routing: model %s does not accept document update, crashing' % model
671             else:
672                 _warn('model %s does not accept document update, skipping' % model)
673                 return ()
674
675         # New Document: check model accepts the mailgateway
676         if not thread_id and not hasattr(model_pool, 'message_new'):
677             if assert_model:
678                 assert hasattr(model_pool, 'message_new'), 'Model %s does not accept document creation, crashing' % model
679             _warn('model %s does not accept document creation, skipping' % model)
680             return ()
681
682         # Update message author if asked
683         # We do it now because we need it for aliases (contact settings)
684         if not author_id and update_author:
685             author_ids = self._find_partner_from_emails(cr, uid, thread_id, [email_from], model=model, context=context)
686             if author_ids:
687                 author_id = author_ids[0]
688                 message_dict['author_id'] = author_id
689
690         # Alias: check alias_contact settings
691         if alias and alias.alias_contact == 'followers' and (thread_id or alias.alias_parent_thread_id):
692             if thread_id:
693                 obj = self.pool[model].browse(cr, uid, thread_id, context=context)
694             else:
695                 obj = self.pool[alias.alias_parent_model_id.model].browse(cr, uid, alias.alias_parent_thread_id, context=context)
696             if not author_id or not author_id in [fol.id for fol in obj.message_follower_ids]:
697                 _warn('alias %s restricted to internal followers, skipping' % alias.alias_name)
698                 _create_bounce_email()
699                 return ()
700         elif alias and alias.alias_contact == 'partners' and not author_id:
701             _warn('alias %s does not accept unknown author, skipping' % alias.alias_name)
702             _create_bounce_email()
703             return ()
704
705         return (model, thread_id, route[2], route[3], route[4])
706
707     def message_route(self, cr, uid, message, message_dict, model=None, thread_id=None,
708                       custom_values=None, context=None):
709         """Attempt to figure out the correct target model, thread_id,
710         custom_values and user_id to use for an incoming message.
711         Multiple values may be returned, if a message had multiple
712         recipients matching existing mail.aliases, for example.
713
714         The following heuristics are used, in this order:
715              1. If the message replies to an existing thread_id, and
716                 properly contains the thread model in the 'In-Reply-To'
717                 header, use this model/thread_id pair, and ignore
718                 custom_value (not needed as no creation will take place)
719              2. Look for a mail.alias entry matching the message
720                 recipient, and use the corresponding model, thread_id,
721                 custom_values and user_id.
722              3. Fallback to the ``model``, ``thread_id`` and ``custom_values``
723                 provided.
724              4. If all the above fails, raise an exception.
725
726            :param string message: an email.message instance
727            :param dict message_dict: dictionary holding message variables
728            :param string model: the fallback model to use if the message
729                does not match any of the currently configured mail aliases
730                (may be None if a matching alias is supposed to be present)
731            :type dict custom_values: optional dictionary of default field values
732                 to pass to ``message_new`` if a new record needs to be created.
733                 Ignored if the thread record already exists, and also if a
734                 matching mail.alias was found (aliases define their own defaults)
735            :param int thread_id: optional ID of the record/thread from ``model``
736                to which this mail should be attached. Only used if the message
737                does not reply to an existing thread and does not match any mail alias.
738            :return: list of [model, thread_id, custom_values, user_id, alias]
739         """
740         assert isinstance(message, Message), 'message must be an email.message.Message at this point'
741         fallback_model = model
742
743         # Get email.message.Message variables for future processing
744         message_id = message.get('Message-Id')
745         email_from = decode_header(message, 'From')
746         email_to = decode_header(message, 'To')
747         references = decode_header(message, 'References')
748         in_reply_to = decode_header(message, 'In-Reply-To')
749
750         # 1. Verify if this is a reply to an existing thread
751         thread_references = references or in_reply_to
752         ref_match = thread_references and tools.reference_re.search(thread_references)
753         if ref_match:
754             thread_id = int(ref_match.group(1))
755             model = ref_match.group(2) or fallback_model
756             if thread_id and model in self.pool:
757                 model_obj = self.pool[model]
758                 if model_obj.exists(cr, uid, thread_id) and hasattr(model_obj, 'message_update'):
759                     _logger.info('Routing mail from %s to %s with Message-Id %s: direct reply to model: %s, thread_id: %s, custom_values: %s, uid: %s',
760                                     email_from, email_to, message_id, model, thread_id, custom_values, uid)
761                     route = self.message_route_verify(cr, uid, message, message_dict,
762                                     (model, thread_id, custom_values, uid, None),
763                                     update_author=True, assert_model=True, create_fallback=True, context=context)
764                     return route and [route] or []
765
766         # 2. Reply to a private message
767         if in_reply_to:
768             message_ids = self.pool.get('mail.message').search(cr, uid, [
769                                 ('message_id', '=', in_reply_to),
770                                 '!', ('message_id', 'ilike', 'reply_to')
771                             ], limit=1, context=context)
772             if message_ids:
773                 message = self.pool.get('mail.message').browse(cr, uid, message_ids[0], context=context)
774                 _logger.info('Routing mail from %s to %s with Message-Id %s: direct reply to a private message: %s, custom_values: %s, uid: %s',
775                                 email_from, email_to, message_id, message.id, custom_values, uid)
776                 route = self.message_route_verify(cr, uid, message, message_dict,
777                                 (message.model, message.res_id, custom_values, uid, None),
778                                 update_author=True, assert_model=True, create_fallback=True, context=context)
779                 return route and [route] or []
780
781         # 3. Look for a matching mail.alias entry
782         # Delivered-To is a safe bet in most modern MTAs, but we have to fallback on To + Cc values
783         # for all the odd MTAs out there, as there is no standard header for the envelope's `rcpt_to` value.
784         rcpt_tos = \
785              ','.join([decode_header(message, 'Delivered-To'),
786                        decode_header(message, 'To'),
787                        decode_header(message, 'Cc'),
788                        decode_header(message, 'Resent-To'),
789                        decode_header(message, 'Resent-Cc')])
790         local_parts = [e.split('@')[0] for e in tools.email_split(rcpt_tos)]
791         if local_parts:
792             mail_alias = self.pool.get('mail.alias')
793             alias_ids = mail_alias.search(cr, uid, [('alias_name', 'in', local_parts)])
794             if alias_ids:
795                 routes = []
796                 for alias in mail_alias.browse(cr, uid, alias_ids, context=context):
797                     user_id = alias.alias_user_id.id
798                     if not user_id:
799                         # TDE note: this could cause crashes, because no clue that the user
800                         # that send the email has the right to create or modify a new document
801                         # Fallback on user_id = uid
802                         # Note: recognized partners will be added as followers anyway
803                         # user_id = self._message_find_user_id(cr, uid, message, context=context)
804                         user_id = uid
805                         _logger.info('No matching user_id for the alias %s', alias.alias_name)
806                     route = (alias.alias_model_id.model, alias.alias_force_thread_id, eval(alias.alias_defaults), user_id, alias)
807                     _logger.info('Routing mail from %s to %s with Message-Id %s: direct alias match: %r',
808                                 email_from, email_to, message_id, route)
809                     route = self.message_route_verify(cr, uid, message, message_dict, route,
810                                 update_author=True, assert_model=True, create_fallback=True, context=context)
811                     if route:
812                         routes.append(route)
813                 return routes
814
815         # 4. Fallback to the provided parameters, if they work
816         if not thread_id:
817             # Legacy: fallback to matching [ID] in the Subject
818             match = tools.res_re.search(decode_header(message, 'Subject'))
819             thread_id = match and match.group(1)
820             # Convert into int (bug spotted in 7.0 because of str)
821             try:
822                 thread_id = int(thread_id)
823             except:
824                 thread_id = False
825         _logger.info('Routing mail from %s to %s with Message-Id %s: fallback to model:%s, thread_id:%s, custom_values:%s, uid:%s',
826                     email_from, email_to, message_id, fallback_model, thread_id, custom_values, uid)
827         route = self.message_route_verify(cr, uid, message, message_dict,
828                         (fallback_model, thread_id, custom_values, uid, None),
829                         update_author=True, assert_model=True, context=context)
830         if route:
831             return [route]
832
833         # AssertionError if no routes found and if no bounce occured
834         assert False, \
835             "No possible route found for incoming message from %s to %s (Message-Id %s:)." \
836             "Create an appropriate mail.alias or force the destination model." % (email_from, email_to, message_id)
837
838     def message_process(self, cr, uid, model, message, custom_values=None,
839                         save_original=False, strip_attachments=False,
840                         thread_id=None, context=None):
841         """ Process an incoming RFC2822 email message, relying on
842             ``mail.message.parse()`` for the parsing operation,
843             and ``message_route()`` to figure out the target model.
844
845             Once the target model is known, its ``message_new`` method
846             is called with the new message (if the thread record did not exist)
847             or its ``message_update`` method (if it did).
848
849             There is a special case where the target model is False: a reply
850             to a private message. In this case, we skip the message_new /
851             message_update step, to just post a new message using mail_thread
852             message_post.
853
854            :param string model: the fallback model to use if the message
855                does not match any of the currently configured mail aliases
856                (may be None if a matching alias is supposed to be present)
857            :param message: source of the RFC2822 message
858            :type message: string or xmlrpclib.Binary
859            :type dict custom_values: optional dictionary of field values
860                 to pass to ``message_new`` if a new record needs to be created.
861                 Ignored if the thread record already exists, and also if a
862                 matching mail.alias was found (aliases define their own defaults)
863            :param bool save_original: whether to keep a copy of the original
864                 email source attached to the message after it is imported.
865            :param bool strip_attachments: whether to strip all attachments
866                 before processing the message, in order to save some space.
867            :param int thread_id: optional ID of the record/thread from ``model``
868                to which this mail should be attached. When provided, this
869                overrides the automatic detection based on the message
870                headers.
871         """
872         if context is None:
873             context = {}
874
875         # extract message bytes - we are forced to pass the message as binary because
876         # we don't know its encoding until we parse its headers and hence can't
877         # convert it to utf-8 for transport between the mailgate script and here.
878         if isinstance(message, xmlrpclib.Binary):
879             message = str(message.data)
880         # Warning: message_from_string doesn't always work correctly on unicode,
881         # we must use utf-8 strings here :-(
882         if isinstance(message, unicode):
883             message = message.encode('utf-8')
884         msg_txt = email.message_from_string(message)
885
886         # parse the message, verify we are not in a loop by checking message_id is not duplicated
887         msg = self.message_parse(cr, uid, msg_txt, save_original=save_original, context=context)
888         if strip_attachments:
889             msg.pop('attachments', None)
890         # postpone setting msg.partner_ids after message_post, to avoid double notifications
891         partner_ids = msg.pop('partner_ids', [])
892         if msg.get('message_id'):   # should always be True as message_parse generate one if missing
893             existing_msg_ids = self.pool.get('mail.message').search(cr, SUPERUSER_ID, [
894                                                                 ('message_id', '=', msg.get('message_id')),
895                                                                 ], context=context)
896             if existing_msg_ids:
897                 _logger.info('Ignored mail from %s to %s with Message-Id %s: found duplicated Message-Id during processing',
898                                 msg.get('from'), msg.get('to'), msg.get('message_id'))
899                 return False
900
901         # find possible routes for the message
902         routes = self.message_route(cr, uid, msg_txt, msg, model, thread_id, custom_values, context=context)
903         thread_id = False
904         for model, thread_id, custom_values, user_id, alias in routes:
905             if self._name == 'mail.thread':
906                 context.update({'thread_model': model})
907             if model:
908                 model_pool = self.pool[model]
909                 assert thread_id and hasattr(model_pool, 'message_update') or hasattr(model_pool, 'message_new'), \
910                     "Undeliverable mail with Message-Id %s, model %s does not accept incoming emails" % \
911                         (msg['message_id'], model)
912
913                 # disabled subscriptions during message_new/update to avoid having the system user running the
914                 # email gateway become a follower of all inbound messages
915                 nosub_ctx = dict(context, mail_create_nosubscribe=True, mail_create_nolog=True)
916                 if thread_id and hasattr(model_pool, 'message_update'):
917                     model_pool.message_update(cr, user_id, [thread_id], msg, context=nosub_ctx)
918                 else:
919                     thread_id = model_pool.message_new(cr, user_id, msg, custom_values, context=nosub_ctx)
920             else:
921                 assert thread_id == 0, "Posting a message without model should be with a null res_id, to create a private message."
922                 model_pool = self.pool.get('mail.thread')
923             if not hasattr(model_pool, 'message_post'):
924                 context['thread_model'] = model
925                 model_pool = self.pool['mail.thread']
926             new_msg_id = model_pool.message_post(cr, uid, [thread_id], context=context, subtype='mail.mt_comment', **msg)
927
928             if partner_ids:
929                 # postponed after message_post, because this is an external message and we don't want to create
930                 # duplicate emails due to notifications
931                 self.pool.get('mail.message').write(cr, uid, [new_msg_id], {'partner_ids': partner_ids}, context=context)
932
933         return thread_id
934
935     def message_new(self, cr, uid, msg_dict, custom_values=None, context=None):
936         """Called by ``message_process`` when a new message is received
937            for a given thread model, if the message did not belong to
938            an existing thread.
939            The default behavior is to create a new record of the corresponding
940            model (based on some very basic info extracted from the message).
941            Additional behavior may be implemented by overriding this method.
942
943            :param dict msg_dict: a map containing the email details and
944                                  attachments. See ``message_process`` and
945                                 ``mail.message.parse`` for details.
946            :param dict custom_values: optional dictionary of additional
947                                       field values to pass to create()
948                                       when creating the new thread record.
949                                       Be careful, these values may override
950                                       any other values coming from the message.
951            :param dict context: if a ``thread_model`` value is present
952                                 in the context, its value will be used
953                                 to determine the model of the record
954                                 to create (instead of the current model).
955            :rtype: int
956            :return: the id of the newly created thread object
957         """
958         if context is None:
959             context = {}
960         data = {}
961         if isinstance(custom_values, dict):
962             data = custom_values.copy()
963         model = context.get('thread_model') or self._name
964         model_pool = self.pool[model]
965         fields = model_pool.fields_get(cr, uid, context=context)
966         if 'name' in fields and not data.get('name'):
967             data['name'] = msg_dict.get('subject', '')
968         res_id = model_pool.create(cr, uid, data, context=context)
969         return res_id
970
971     def message_update(self, cr, uid, ids, msg_dict, update_vals=None, context=None):
972         """Called by ``message_process`` when a new message is received
973            for an existing thread. The default behavior is to update the record
974            with update_vals taken from the incoming email.
975            Additional behavior may be implemented by overriding this
976            method.
977            :param dict msg_dict: a map containing the email details and
978                                attachments. See ``message_process`` and
979                                ``mail.message.parse()`` for details.
980            :param dict update_vals: a dict containing values to update records
981                               given their ids; if the dict is None or is
982                               void, no write operation is performed.
983         """
984         if update_vals:
985             self.write(cr, uid, ids, update_vals, context=context)
986         return True
987
988     def _message_extract_payload(self, message, save_original=False):
989         """Extract body as HTML and attachments from the mail message"""
990         attachments = []
991         body = u''
992         if save_original:
993             attachments.append(('original_email.eml', message.as_string()))
994         if not message.is_multipart() or 'text/' in message.get('content-type', ''):
995             encoding = message.get_content_charset()
996             body = message.get_payload(decode=True)
997             body = tools.ustr(body, encoding, errors='replace')
998             if message.get_content_type() == 'text/plain':
999                 # text/plain -> <pre/>
1000                 body = tools.append_content_to_html(u'', body, preserve=True)
1001         else:
1002             alternative = (message.get_content_type() == 'multipart/alternative')
1003             for part in message.walk():
1004                 if part.get_content_maintype() == 'multipart':
1005                     continue  # skip container
1006                 filename = part.get_filename()  # None if normal part
1007                 encoding = part.get_content_charset()  # None if attachment
1008                 # 1) Explicit Attachments -> attachments
1009                 if filename or part.get('content-disposition', '').strip().startswith('attachment'):
1010                     attachments.append((filename or 'attachment', part.get_payload(decode=True)))
1011                     continue
1012                 # 2) text/plain -> <pre/>
1013                 if part.get_content_type() == 'text/plain' and (not alternative or not body):
1014                     body = tools.append_content_to_html(body, tools.ustr(part.get_payload(decode=True),
1015                                                                          encoding, errors='replace'), preserve=True)
1016                 # 3) text/html -> raw
1017                 elif part.get_content_type() == 'text/html':
1018                     html = tools.ustr(part.get_payload(decode=True), encoding, errors='replace')
1019                     if alternative:
1020                         body = html
1021                     else:
1022                         body = tools.append_content_to_html(body, html, plaintext=False)
1023                 # 4) Anything else -> attachment
1024                 else:
1025                     attachments.append((filename or 'attachment', part.get_payload(decode=True)))
1026         return body, attachments
1027
1028     def message_parse(self, cr, uid, message, save_original=False, context=None):
1029         """Parses a string or email.message.Message representing an
1030            RFC-2822 email, and returns a generic dict holding the
1031            message details.
1032
1033            :param message: the message to parse
1034            :type message: email.message.Message | string | unicode
1035            :param bool save_original: whether the returned dict
1036                should include an ``original`` attachment containing
1037                the source of the message
1038            :rtype: dict
1039            :return: A dict with the following structure, where each
1040                     field may not be present if missing in original
1041                     message::
1042
1043                     { 'message_id': msg_id,
1044                       'subject': subject,
1045                       'from': from,
1046                       'to': to,
1047                       'cc': cc,
1048                       'body': unified_body,
1049                       'attachments': [('file1', 'bytes'),
1050                                       ('file2', 'bytes')}
1051                     }
1052         """
1053         msg_dict = {
1054             'type': 'email',
1055         }
1056         if not isinstance(message, Message):
1057             if isinstance(message, unicode):
1058                 # Warning: message_from_string doesn't always work correctly on unicode,
1059                 # we must use utf-8 strings here :-(
1060                 message = message.encode('utf-8')
1061             message = email.message_from_string(message)
1062
1063         message_id = message['message-id']
1064         if not message_id:
1065             # Very unusual situation, be we should be fault-tolerant here
1066             message_id = "<%s@localhost>" % time.time()
1067             _logger.debug('Parsing Message without message-id, generating a random one: %s', message_id)
1068         msg_dict['message_id'] = message_id
1069
1070         if message.get('Subject'):
1071             msg_dict['subject'] = decode(message.get('Subject'))
1072
1073         # Envelope fields not stored in mail.message but made available for message_new()
1074         msg_dict['from'] = decode(message.get('from'))
1075         msg_dict['to'] = decode(message.get('to'))
1076         msg_dict['cc'] = decode(message.get('cc'))
1077         msg_dict['email_from'] = decode(message.get('from'))
1078         partner_ids = self._message_find_partners(cr, uid, message, ['To', 'Cc'], context=context)
1079         msg_dict['partner_ids'] = [(4, partner_id) for partner_id in partner_ids]
1080
1081         if message.get('Date'):
1082             try:
1083                 date_hdr = decode(message.get('Date'))
1084                 parsed_date = dateutil.parser.parse(date_hdr, fuzzy=True)
1085                 if parsed_date.utcoffset() is None:
1086                     # naive datetime, so we arbitrarily decide to make it
1087                     # UTC, there's no better choice. Should not happen,
1088                     # as RFC2822 requires timezone offset in Date headers.
1089                     stored_date = parsed_date.replace(tzinfo=pytz.utc)
1090                 else:
1091                     stored_date = parsed_date.astimezone(tz=pytz.utc)
1092             except Exception:
1093                 _logger.warning('Failed to parse Date header %r in incoming mail '
1094                                 'with message-id %r, assuming current date/time.',
1095                                 message.get('Date'), message_id)
1096                 stored_date = datetime.datetime.now()
1097             msg_dict['date'] = stored_date.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT)
1098
1099         if message.get('In-Reply-To'):
1100             parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', '=', decode(message['In-Reply-To']))])
1101             if parent_ids:
1102                 msg_dict['parent_id'] = parent_ids[0]
1103
1104         if message.get('References') and 'parent_id' not in msg_dict:
1105             parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', 'in',
1106                                                                          [x.strip() for x in decode(message['References']).split()])])
1107             if parent_ids:
1108                 msg_dict['parent_id'] = parent_ids[0]
1109
1110         msg_dict['body'], msg_dict['attachments'] = self._message_extract_payload(message, save_original=save_original)
1111         return msg_dict
1112
1113     #------------------------------------------------------
1114     # Note specific
1115     #------------------------------------------------------
1116
1117     def log(self, cr, uid, id, message, secondary=False, context=None):
1118         _logger.warning("log() is deprecated. As this module inherit from "\
1119                         "mail.thread, the message will be managed by this "\
1120                         "module instead of by the res.log mechanism. Please "\
1121                         "use mail_thread.message_post() instead of the "\
1122                         "now deprecated res.log.")
1123         self.message_post(cr, uid, [id], message, context=context)
1124
1125     def _message_add_suggested_recipient(self, cr, uid, result, obj, partner=None, email=None, reason='', context=None):
1126         """ Called by message_get_suggested_recipients, to add a suggested
1127             recipient in the result dictionary. The form is :
1128                 partner_id, partner_name<partner_email> or partner_name, reason """
1129         if email and not partner:
1130             # get partner info from email
1131             partner_info = self.message_partner_info_from_emails(cr, uid, obj.id, [email], context=context)[0]
1132             if partner_info.get('partner_id'):
1133                 partner = self.pool.get('res.partner').browse(cr, SUPERUSER_ID, [partner_info.get('partner_id')], context=context)[0]
1134         if email and email in [val[1] for val in result[obj.id]]:  # already existing email -> skip
1135             return result
1136         if partner and partner in obj.message_follower_ids:  # recipient already in the followers -> skip
1137             return result
1138         if partner and partner in [val[0] for val in result[obj.id]]:  # already existing partner ID -> skip
1139             return result
1140         if partner and partner.email:  # complete profile: id, name <email>
1141             result[obj.id].append((partner.id, '%s<%s>' % (partner.name, partner.email), reason))
1142         elif partner:  # incomplete profile: id, name
1143             result[obj.id].append((partner.id, '%s' % (partner.name), reason))
1144         else:  # unknown partner, we are probably managing an email address
1145             result[obj.id].append((False, email, reason))
1146         return result
1147
1148     def message_get_suggested_recipients(self, cr, uid, ids, context=None):
1149         """ Returns suggested recipients for ids. Those are a list of
1150             tuple (partner_id, partner_name, reason), to be managed by Chatter. """
1151         result = dict.fromkeys(ids, list())
1152         if self._all_columns.get('user_id'):
1153             for obj in self.browse(cr, SUPERUSER_ID, ids, context=context):  # SUPERUSER because of a read on res.users that would crash otherwise
1154                 if not obj.user_id or not obj.user_id.partner_id:
1155                     continue
1156                 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)
1157         return result
1158
1159     def _find_partner_from_emails(self, cr, uid, id, emails, model=None, context=None, check_followers=True):
1160         """ Utility method to find partners from email addresses. The rules are :
1161             1 - check in document (model | self, id) followers
1162             2 - try to find a matching partner that is also an user
1163             3 - try to find a matching partner
1164
1165             :param list emails: list of email addresses
1166             :param string model: model to fetch related record; by default self
1167                 is used.
1168             :param boolean check_followers: check in document followers
1169         """
1170         partner_obj = self.pool['res.partner']
1171         partner_ids = []
1172         obj = None
1173         if id and (model or self._name != 'mail.thread') and check_followers:
1174             if model:
1175                 obj = self.pool[model].browse(cr, uid, id, context=context)
1176             else:
1177                 obj = self.browse(cr, uid, id, context=context)
1178         for contact in emails:
1179             partner_id = False
1180             email_address = tools.email_split(contact)
1181             if not email_address:
1182                 partner_ids.append(partner_id)
1183                 continue
1184             email_address = email_address[0]
1185             # first try: check in document's followers
1186             if obj:
1187                 for follower in obj.message_follower_ids:
1188                     if follower.email == email_address:
1189                         partner_id = follower.id
1190             # second try: check in partners that are also users
1191             if not partner_id:
1192                 ids = partner_obj.search(cr, SUPERUSER_ID, [
1193                                                 ('email', 'ilike', email_address),
1194                                                 ('user_ids', '!=', False)
1195                                             ], limit=1, context=context)
1196                 if ids:
1197                     partner_id = ids[0]
1198             # third try: check in partners
1199             if not partner_id:
1200                 ids = partner_obj.search(cr, SUPERUSER_ID, [
1201                                                 ('email', 'ilike', email_address)
1202                                             ], limit=1, context=context)
1203                 if ids:
1204                     partner_id = ids[0]
1205             partner_ids.append(partner_id)
1206         return partner_ids
1207
1208     def message_partner_info_from_emails(self, cr, uid, id, emails, link_mail=False, context=None):
1209         """ Convert a list of emails into a list partner_ids and a list
1210             new_partner_ids. The return value is non conventional because
1211             it is meant to be used by the mail widget.
1212
1213             :return dict: partner_ids and new_partner_ids """
1214         mail_message_obj = self.pool.get('mail.message')
1215         partner_ids = self._find_partner_from_emails(cr, uid, id, emails, context=context)
1216         result = list()
1217         for idx in range(len(emails)):
1218             email_address = emails[idx]
1219             partner_id = partner_ids[idx]
1220             partner_info = {'full_name': email_address, 'partner_id': partner_id}
1221             result.append(partner_info)
1222
1223             # link mail with this from mail to the new partner id
1224             if link_mail and partner_info['partner_id']:
1225                 message_ids = mail_message_obj.search(cr, SUPERUSER_ID, [
1226                                     '|',
1227                                     ('email_from', '=', email_address),
1228                                     ('email_from', 'ilike', '<%s>' % email_address),
1229                                     ('author_id', '=', False)
1230                                 ], context=context)
1231                 if message_ids:
1232                     mail_message_obj.write(cr, SUPERUSER_ID, message_ids, {'author_id': partner_info['partner_id']}, context=context)
1233         return result
1234
1235     def message_post(self, cr, uid, thread_id, body='', subject=None, type='notification',
1236                         subtype=None, parent_id=False, attachments=None, context=None,
1237                         content_subtype='html', **kwargs):
1238         """ Post a new message in an existing thread, returning the new
1239             mail.message ID.
1240
1241             :param int thread_id: thread ID to post into, or list with one ID;
1242                 if False/0, mail.message model will also be set as False
1243             :param str body: body of the message, usually raw HTML that will
1244                 be sanitized
1245             :param str type: see mail_message.type field
1246             :param str content_subtype:: if plaintext: convert body into html
1247             :param int parent_id: handle reply to a previous message by adding the
1248                 parent partners to the message in case of private discussion
1249             :param tuple(str,str) attachments or list id: list of attachment tuples in the form
1250                 ``(name,content)``, where content is NOT base64 encoded
1251
1252             Extra keyword arguments will be used as default column values for the
1253             new mail.message record. Special cases:
1254                 - attachment_ids: supposed not attached to any document; attach them
1255                     to the related document. Should only be set by Chatter.
1256             :return int: ID of newly created mail.message
1257         """
1258         if context is None:
1259             context = {}
1260         if attachments is None:
1261             attachments = {}
1262         mail_message = self.pool.get('mail.message')
1263         ir_attachment = self.pool.get('ir.attachment')
1264
1265         assert (not thread_id) or \
1266                 isinstance(thread_id, (int, long)) or \
1267                 (isinstance(thread_id, (list, tuple)) and len(thread_id) == 1), \
1268                 "Invalid thread_id; should be 0, False, an ID or a list with one ID"
1269         if isinstance(thread_id, (list, tuple)):
1270             thread_id = thread_id[0]
1271
1272         # if we're processing a message directly coming from the gateway, the destination model was
1273         # set in the context.
1274         model = False
1275         if thread_id:
1276             model = context.get('thread_model', self._name) if self._name == 'mail.thread' else self._name
1277             if model != self._name and hasattr(self.pool[model], 'message_post'):
1278                 del context['thread_model']
1279                 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)
1280
1281         #0: Find the message's author, because we need it for private discussion
1282         author_id = kwargs.get('author_id')
1283         if author_id is None:  # keep False values
1284             author_id = self.pool.get('mail.message')._get_default_author(cr, uid, context=context)
1285
1286         # 1: Handle content subtype: if plaintext, converto into HTML
1287         if content_subtype == 'plaintext':
1288             body = tools.plaintext2html(body)
1289
1290         # 2: Private message: add recipients (recipients and author of parent message) - current author
1291         #   + legacy-code management (! we manage only 4 and 6 commands)
1292         partner_ids = set()
1293         kwargs_partner_ids = kwargs.pop('partner_ids', [])
1294         for partner_id in kwargs_partner_ids:
1295             if isinstance(partner_id, (list, tuple)) and partner_id[0] == 4 and len(partner_id) == 2:
1296                 partner_ids.add(partner_id[1])
1297             if isinstance(partner_id, (list, tuple)) and partner_id[0] == 6 and len(partner_id) == 3:
1298                 partner_ids |= set(partner_id[2])
1299             elif isinstance(partner_id, (int, long)):
1300                 partner_ids.add(partner_id)
1301             else:
1302                 pass  # we do not manage anything else
1303         if parent_id and not model:
1304             parent_message = mail_message.browse(cr, uid, parent_id, context=context)
1305             private_followers = set([partner.id for partner in parent_message.partner_ids])
1306             if parent_message.author_id:
1307                 private_followers.add(parent_message.author_id.id)
1308             private_followers -= set([author_id])
1309             partner_ids |= private_followers
1310
1311         # 3. Attachments
1312         #   - HACK TDE FIXME: Chatter: attachments linked to the document (not done JS-side), load the message
1313         attachment_ids = kwargs.pop('attachment_ids', []) or []  # because we could receive None (some old code sends None)
1314         if attachment_ids:
1315             filtered_attachment_ids = ir_attachment.search(cr, SUPERUSER_ID, [
1316                 ('res_model', '=', 'mail.compose.message'),
1317                 ('create_uid', '=', uid),
1318                 ('id', 'in', attachment_ids)], context=context)
1319             if filtered_attachment_ids:
1320                 ir_attachment.write(cr, SUPERUSER_ID, filtered_attachment_ids, {'res_model': model, 'res_id': thread_id}, context=context)
1321         attachment_ids = [(4, id) for id in attachment_ids]
1322         # Handle attachments parameter, that is a dictionary of attachments
1323         for name, content in attachments:
1324             if isinstance(content, unicode):
1325                 content = content.encode('utf-8')
1326             data_attach = {
1327                 'name': name,
1328                 'datas': base64.b64encode(str(content)),
1329                 'datas_fname': name,
1330                 'description': name,
1331                 'res_model': model,
1332                 'res_id': thread_id,
1333             }
1334             attachment_ids.append((0, 0, data_attach))
1335
1336         # 4: mail.message.subtype
1337         subtype_id = False
1338         if subtype:
1339             if '.' not in subtype:
1340                 subtype = 'mail.%s' % subtype
1341             ref = self.pool.get('ir.model.data').get_object_reference(cr, uid, *subtype.split('.'))
1342             subtype_id = ref and ref[1] or False
1343
1344         # automatically subscribe recipients if asked to
1345         if context.get('mail_post_autofollow') and thread_id and partner_ids:
1346             partner_to_subscribe = partner_ids
1347             if context.get('mail_post_autofollow_partner_ids'):
1348                 partner_to_subscribe = filter(lambda item: item in context.get('mail_post_autofollow_partner_ids'), partner_ids)
1349             self.message_subscribe(cr, uid, [thread_id], list(partner_to_subscribe), context=context)
1350
1351         # _mail_flat_thread: automatically set free messages to the first posted message
1352         if self._mail_flat_thread and not parent_id and thread_id:
1353             message_ids = mail_message.search(cr, uid, ['&', ('res_id', '=', thread_id), ('model', '=', model)], context=context, order="id ASC", limit=1)
1354             parent_id = message_ids and message_ids[0] or False
1355         # 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
1356         elif parent_id:
1357             message_ids = mail_message.search(cr, SUPERUSER_ID, [('id', '=', parent_id), ('parent_id', '!=', False)], context=context)
1358             # avoid loops when finding ancestors
1359             processed_list = []
1360             if message_ids:
1361                 message = mail_message.browse(cr, SUPERUSER_ID, message_ids[0], context=context)
1362                 while (message.parent_id and message.parent_id.id not in processed_list):
1363                     processed_list.append(message.parent_id.id)
1364                     message = message.parent_id
1365                 parent_id = message.id
1366
1367         values = kwargs
1368         values.update({
1369             'author_id': author_id,
1370             'model': model,
1371             'res_id': thread_id or False,
1372             'body': body,
1373             'subject': subject or False,
1374             'type': type,
1375             'parent_id': parent_id,
1376             'attachment_ids': attachment_ids,
1377             'subtype_id': subtype_id,
1378             'partner_ids': [(4, pid) for pid in partner_ids],
1379         })
1380
1381         # Avoid warnings about non-existing fields
1382         for x in ('from', 'to', 'cc'):
1383             values.pop(x, None)
1384
1385         # Create and auto subscribe the author
1386         msg_id = mail_message.create(cr, uid, values, context=context)
1387         message = mail_message.browse(cr, uid, msg_id, context=context)
1388         if message.author_id and thread_id and type != 'notification' and not context.get('mail_create_nosubscribe'):
1389             self.message_subscribe(cr, uid, [thread_id], [message.author_id.id], context=context)
1390         return msg_id
1391
1392     #------------------------------------------------------
1393     # Followers API
1394     #------------------------------------------------------
1395
1396     def message_get_subscription_data(self, cr, uid, ids, context=None):
1397         """ Wrapper to get subtypes data. """
1398         return self._get_subscription_data(cr, uid, ids, None, None, context=context)
1399
1400     def message_subscribe_users(self, cr, uid, ids, user_ids=None, subtype_ids=None, context=None):
1401         """ Wrapper on message_subscribe, using users. If user_ids is not
1402             provided, subscribe uid instead. """
1403         if user_ids is None:
1404             user_ids = [uid]
1405         partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
1406         return self.message_subscribe(cr, uid, ids, partner_ids, subtype_ids=subtype_ids, context=context)
1407
1408     def message_subscribe(self, cr, uid, ids, partner_ids, subtype_ids=None, context=None):
1409         """ Add partners to the records followers. """
1410         mail_followers_obj = self.pool.get('mail.followers')
1411         subtype_obj = self.pool.get('mail.message.subtype')
1412
1413         user_pid = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1414         if set(partner_ids) == set([user_pid]):
1415             try:
1416                 self.check_access_rights(cr, uid, 'read')
1417             except (osv.except_osv, orm.except_orm):
1418                 return
1419         else:
1420             self.check_access_rights(cr, uid, 'write')
1421
1422         for record in self.browse(cr, SUPERUSER_ID, ids, context=context):
1423             existing_pids = set([f.id for f in record.message_follower_ids
1424                                             if f.id in partner_ids])
1425             new_pids = set(partner_ids) - existing_pids
1426
1427             # subtype_ids specified: update already subscribed partners
1428             if subtype_ids and existing_pids:
1429                 fol_ids = mail_followers_obj.search(cr, SUPERUSER_ID, [
1430                                                         ('res_model', '=', self._name),
1431                                                         ('res_id', '=', record.id),
1432                                                         ('partner_id', 'in', list(existing_pids)),
1433                                                     ], context=context)
1434                 mail_followers_obj.write(cr, SUPERUSER_ID, fol_ids, {'subtype_ids': [(6, 0, subtype_ids)]}, context=context)
1435             # subtype_ids not specified: do not update already subscribed partner, fetch default subtypes for new partners
1436             else:
1437                 subtype_ids = subtype_obj.search(cr, uid, [
1438                                                         ('default', '=', True),
1439                                                         '|',
1440                                                         ('res_model', '=', self._name),
1441                                                         ('res_model', '=', False)
1442                                                     ], context=context)
1443             # subscribe new followers
1444             for new_pid in new_pids:
1445                 mail_followers_obj.create(cr, SUPERUSER_ID, {
1446                                                 'res_model': self._name,
1447                                                 'res_id': record.id,
1448                                                 'partner_id': new_pid,
1449                                                 'subtype_ids': [(6, 0, subtype_ids)],
1450                                             }, context=context)
1451
1452         return True
1453
1454     def message_unsubscribe_users(self, cr, uid, ids, user_ids=None, context=None):
1455         """ Wrapper on message_subscribe, using users. If user_ids is not
1456             provided, unsubscribe uid instead. """
1457         if user_ids is None:
1458             user_ids = [uid]
1459         partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
1460         return self.message_unsubscribe(cr, uid, ids, partner_ids, context=context)
1461
1462     def message_unsubscribe(self, cr, uid, ids, partner_ids, context=None):
1463         """ Remove partners from the records followers. """
1464         user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
1465         if set(partner_ids) == set([user_pid]):
1466             self.check_access_rights(cr, uid, 'read')
1467         else:
1468             self.check_access_rights(cr, uid, 'write')
1469         return self.write(cr, SUPERUSER_ID, ids, {'message_follower_ids': [(3, pid) for pid in partner_ids]}, context=context)
1470
1471     def _message_get_auto_subscribe_fields(self, cr, uid, updated_fields, auto_follow_fields=['user_id'], context=None):
1472         """ Returns the list of relational fields linking to res.users that should
1473             trigger an auto subscribe. The default list checks for the fields
1474             - called 'user_id'
1475             - linking to res.users
1476             - with track_visibility set
1477             In OpenERP V7, this is sufficent for all major addon such as opportunity,
1478             project, issue, recruitment, sale.
1479             Override this method if a custom behavior is needed about fields
1480             that automatically subscribe users.
1481         """
1482         user_field_lst = []
1483         for name, column_info in self._all_columns.items():
1484             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':
1485                 user_field_lst.append(name)
1486         return user_field_lst
1487
1488     def message_auto_subscribe(self, cr, uid, ids, updated_fields, context=None):
1489         """
1490             1. fetch project subtype related to task (parent_id.res_model = 'project.task')
1491             2. for each project subtype: subscribe the follower to the task
1492         """
1493         subtype_obj = self.pool.get('mail.message.subtype')
1494         follower_obj = self.pool.get('mail.followers')
1495
1496         # fetch auto_follow_fields
1497         user_field_lst = self._message_get_auto_subscribe_fields(cr, uid, updated_fields, context=context)
1498
1499         # fetch related record subtypes
1500         related_subtype_ids = subtype_obj.search(cr, uid, ['|', ('res_model', '=', False), ('parent_id.res_model', '=', self._name)], context=context)
1501         subtypes = subtype_obj.browse(cr, uid, related_subtype_ids, context=context)
1502         default_subtypes = [subtype for subtype in subtypes if subtype.res_model == False]
1503         related_subtypes = [subtype for subtype in subtypes if subtype.res_model != False]
1504         relation_fields = set([subtype.relation_field for subtype in subtypes if subtype.relation_field != False])
1505         if (not related_subtypes or not any(relation in updated_fields for relation in relation_fields)) and not user_field_lst:
1506             return True
1507
1508         for record in self.browse(cr, uid, ids, context=context):
1509             new_followers = dict()
1510             parent_res_id = False
1511             parent_model = False
1512             for subtype in related_subtypes:
1513                 if not subtype.relation_field or not subtype.parent_id:
1514                     continue
1515                 if not subtype.relation_field in self._columns or not getattr(record, subtype.relation_field, False):
1516                     continue
1517                 parent_res_id = getattr(record, subtype.relation_field).id
1518                 parent_model = subtype.res_model
1519                 follower_ids = follower_obj.search(cr, SUPERUSER_ID, [
1520                     ('res_model', '=', parent_model),
1521                     ('res_id', '=', parent_res_id),
1522                     ('subtype_ids', 'in', [subtype.id])
1523                     ], context=context)
1524                 for follower in follower_obj.browse(cr, SUPERUSER_ID, follower_ids, context=context):
1525                     new_followers.setdefault(follower.partner_id.id, set()).add(subtype.parent_id.id)
1526
1527             if parent_res_id and parent_model:
1528                 for subtype in default_subtypes:
1529                     follower_ids = follower_obj.search(cr, SUPERUSER_ID, [
1530                         ('res_model', '=', parent_model),
1531                         ('res_id', '=', parent_res_id),
1532                         ('subtype_ids', 'in', [subtype.id])
1533                         ], context=context)
1534                     for follower in follower_obj.browse(cr, SUPERUSER_ID, follower_ids, context=context):
1535                         new_followers.setdefault(follower.partner_id.id, set()).add(subtype.id)
1536
1537             # add followers coming from res.users relational fields that are tracked
1538             user_ids = [getattr(record, name).id for name in user_field_lst if getattr(record, name)]
1539             user_id_partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, SUPERUSER_ID, user_ids, context=context)]
1540             for partner_id in user_id_partner_ids:
1541                 new_followers.setdefault(partner_id, None)
1542
1543             for pid, subtypes in new_followers.items():
1544                 subtypes = list(subtypes) if subtypes is not None else None
1545                 self.message_subscribe(cr, uid, [record.id], [pid], subtypes, context=context)
1546
1547             # find first email message, set it as unread for auto_subscribe fields for them to have a notification
1548             if user_id_partner_ids:
1549                 msg_ids = self.pool.get('mail.message').search(cr, uid, [
1550                                 ('model', '=', self._name),
1551                                 ('res_id', '=', record.id),
1552                                 ('type', '=', 'email')], limit=1, context=context)
1553                 if not msg_ids and record.message_ids:
1554                     msg_ids = [record.message_ids[-1].id]
1555                 if msg_ids:
1556                     self.pool.get('mail.notification')._notify(cr, uid, msg_ids[0], partners_to_notify=user_id_partner_ids, context=context)
1557
1558         return True
1559
1560     #------------------------------------------------------
1561     # Thread state
1562     #------------------------------------------------------
1563
1564     def message_mark_as_unread(self, cr, uid, ids, context=None):
1565         """ Set as unread. """
1566         partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1567         cr.execute('''
1568             UPDATE mail_notification SET
1569                 read=false
1570             WHERE
1571                 message_id IN (SELECT id from mail_message where res_id=any(%s) and model=%s limit 1) and
1572                 partner_id = %s
1573         ''', (ids, self._name, partner_id))
1574         return True
1575
1576     def message_mark_as_read(self, cr, uid, ids, context=None):
1577         """ Set as read. """
1578         partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1579         cr.execute('''
1580             UPDATE mail_notification SET
1581                 read=true
1582             WHERE
1583                 message_id IN (SELECT id FROM mail_message WHERE res_id=ANY(%s) AND model=%s) AND
1584                 partner_id = %s
1585         ''', (ids, self._name, partner_id))
1586         return True
1587
1588     #------------------------------------------------------
1589     # Thread suggestion
1590     #------------------------------------------------------
1591
1592     def get_suggested_thread(self, cr, uid, removed_suggested_threads=None, context=None):
1593         """Return a list of suggested threads, sorted by the numbers of followers"""
1594         if context is None:
1595             context = {}
1596
1597         # TDE HACK: originally by MAT from portal/mail_mail.py but not working until the inheritance graph bug is not solved in trunk
1598         # TDE FIXME: relocate in portal when it won't be necessary to reload the hr.employee model in an additional bridge module
1599         if self.pool['res.groups']._all_columns.get('is_portal'):
1600             user = self.pool.get('res.users').browse(cr, SUPERUSER_ID, uid, context=context)
1601             if any(group.is_portal for group in user.groups_id):
1602                 return []
1603
1604         threads = []
1605         if removed_suggested_threads is None:
1606             removed_suggested_threads = []
1607
1608         thread_ids = self.search(cr, uid, [('id', 'not in', removed_suggested_threads), ('message_is_follower', '=', False)], context=context)
1609         for thread in self.browse(cr, uid, thread_ids, context=context):
1610             data = {
1611                 'id': thread.id,
1612                 'popularity': len(thread.message_follower_ids),
1613                 'name': thread.name,
1614                 'image_small': thread.image_small
1615             }
1616             threads.append(data)
1617         return sorted(threads, key=lambda x: (x['popularity'], x['id']), reverse=True)[:3]