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