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