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