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