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