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