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