[DOC] cmdline: database-related parameters
[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 list: a list of (field_name, column_info obj), containing
464                 always tracked fields and modified on_change fields
465         """
466         tracked_fields = []
467         for name, column_info in self._all_columns.items():
468             visibility = getattr(column_info.column, '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                 initial_value = initial[col_name]
511                 record_value = getattr(browse_record, col_name)
512
513                 if record_value == initial_value and getattr(self._all_columns[col_name].column, 'track_visibility', None) == 'always':
514                     tracked_values[col_name] = dict(col_info=col_info['string'],
515                                                         new_value=convert_for_display(record_value, col_info))
516                 elif record_value != initial_value and (record_value or initial_value):  # because browse null != False
517                     if getattr(self._all_columns[col_name].column, 'track_visibility', None) in ['always', 'onchange']:
518                         tracked_values[col_name] = dict(col_info=col_info['string'],
519                                                             old_value=convert_for_display(initial_value, col_info),
520                                                             new_value=convert_for_display(record_value, col_info))
521                     if col_name in tracked_fields:
522                         changes.add(col_name)
523             if not changes:
524                 continue
525
526             # find subtypes and post messages or log if no subtype found
527             subtypes = []
528             # 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
529             if not context.get('mail_track_log_only'):
530                 for field, track_info in self._track.items():
531                     if field not in changes:
532                         continue
533                     for subtype, method in track_info.items():
534                         if method(self, cr, uid, browse_record, context):
535                             subtypes.append(subtype)
536
537             posted = False
538             for subtype in subtypes:
539                 subtype_rec = self.pool.get('ir.model.data').xmlid_to_object(cr, uid, subtype, context=context)
540                 if not (subtype_rec and subtype_rec.exists()):
541                     _logger.debug('subtype %s not found' % subtype)
542                     continue
543                 message = format_message(subtype_rec.description if subtype_rec.description else subtype_rec.name, tracked_values)
544                 self.message_post(cr, uid, browse_record.id, body=message, subtype=subtype, context=context)
545                 posted = True
546             if not posted:
547                 message = format_message('', tracked_values)
548                 self.message_post(cr, uid, browse_record.id, body=message, context=context)
549         return True
550
551     #------------------------------------------------------
552     # mail.message wrappers and tools
553     #------------------------------------------------------
554
555     def _needaction_domain_get(self, cr, uid, context=None):
556         if self._needaction:
557             return [('message_unread', '=', True)]
558         return []
559
560     def _garbage_collect_attachments(self, cr, uid, context=None):
561         """ Garbage collect lost mail attachments. Those are attachments
562             - linked to res_model 'mail.compose.message', the composer wizard
563             - with res_id 0, because they were created outside of an existing
564                 wizard (typically user input through Chatter or reports
565                 created on-the-fly by the templates)
566             - unused since at least one day (create_date and write_date)
567         """
568         limit_date = datetime.datetime.utcnow() - datetime.timedelta(days=1)
569         limit_date_str = datetime.datetime.strftime(limit_date, tools.DEFAULT_SERVER_DATETIME_FORMAT)
570         ir_attachment_obj = self.pool.get('ir.attachment')
571         attach_ids = ir_attachment_obj.search(cr, uid, [
572                             ('res_model', '=', 'mail.compose.message'),
573                             ('res_id', '=', 0),
574                             ('create_date', '<', limit_date_str),
575                             ('write_date', '<', limit_date_str),
576                             ], context=context)
577         ir_attachment_obj.unlink(cr, uid, attach_ids, context=context)
578         return True
579
580     def check_mail_message_access(self, cr, uid, mids, operation, model_obj=None, context=None):
581         """ mail.message check permission rules for related document. This method is
582             meant to be inherited in order to implement addons-specific behavior.
583             A common behavior would be to allow creating messages when having read
584             access rule on the document, for portal document such as issues. """
585         if not model_obj:
586             model_obj = self
587         if hasattr(self, '_mail_post_access'):
588             create_allow = self._mail_post_access
589         else:
590             create_allow = 'write'
591
592         if operation in ['write', 'unlink']:
593             check_operation = 'write'
594         elif operation == 'create' and create_allow in ['create', 'read', 'write', 'unlink']:
595             check_operation = create_allow
596         elif operation == 'create':
597             check_operation = 'write'
598         else:
599             check_operation = operation
600
601         model_obj.check_access_rights(cr, uid, check_operation)
602         model_obj.check_access_rule(cr, uid, mids, check_operation, context=context)
603
604     def _get_inbox_action_xml_id(self, cr, uid, context=None):
605         """ When redirecting towards the Inbox, choose which action xml_id has
606             to be fetched. This method is meant to be inherited, at least in portal
607             because portal users have a different Inbox action than classic users. """
608         return ('mail', 'action_mail_inbox_feeds')
609
610     def message_redirect_action(self, cr, uid, context=None):
611         """ For a given message, return an action that either
612             - opens the form view of the related document if model, res_id, and
613               read access to the document
614             - opens the Inbox with a default search on the conversation if model,
615               res_id
616             - opens the Inbox with context propagated
617
618         """
619         if context is None:
620             context = {}
621
622         # default action is the Inbox action
623         self.pool.get('res.users').browse(cr, SUPERUSER_ID, uid, context=context)
624         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))
625         action = self.pool.get(act_model).read(cr, uid, [act_id], [])[0]
626         params = context.get('params')
627         msg_id = model = res_id = None
628
629         if params:
630             msg_id = params.get('message_id')
631             model = params.get('model')
632             res_id = params.get('res_id', params.get('id'))  # signup automatically generated id instead of res_id
633         if not msg_id and not (model and res_id):
634             return action
635         if msg_id and not (model and res_id):
636             msg = self.pool.get('mail.message').browse(cr, uid, msg_id, context=context)
637             if msg.exists():
638                 model, res_id = msg.model, msg.res_id
639
640         # if model + res_id found: try to redirect to the document or fallback on the Inbox
641         if model and res_id:
642             model_obj = self.pool.get(model)
643             if model_obj.check_access_rights(cr, uid, 'read', raise_exception=False):
644                 try:
645                     model_obj.check_access_rule(cr, uid, [res_id], 'read', context=context)
646                     action = model_obj.get_access_action(cr, uid, res_id, context=context)
647                 except (osv.except_osv, orm.except_orm):
648                     pass
649             action.update({
650                 'context': {
651                     'search_default_model': model,
652                     'search_default_res_id': res_id,
653                 }
654             })
655         return action
656
657     def _get_access_link(self, cr, uid, mail, partner, context=None):
658         # the parameters to encode for the query and fragment part of url
659         query = {'db': cr.dbname}
660         fragment = {
661             'login': partner.user_ids[0].login,
662             'action': 'mail.action_mail_redirect',
663         }
664         if mail.notification:
665             fragment['message_id'] = mail.mail_message_id.id
666         elif mail.model and mail.res_id:
667             fragment.update(model=mail.model, res_id=mail.res_id)
668
669         return "/web?%s#%s" % (urlencode(query), urlencode(fragment))
670
671     #------------------------------------------------------
672     # Email specific
673     #------------------------------------------------------
674
675     def message_get_default_recipients(self, cr, uid, ids, context=None):
676         if context and context.get('thread_model') and context['thread_model'] in self.pool and context['thread_model'] != self._name:
677             if hasattr(self.pool[context['thread_model']], 'message_get_default_recipients'):
678                 sub_ctx = dict(context)
679                 sub_ctx.pop('thread_model')
680                 return self.pool[context['thread_model']].message_get_default_recipients(cr, uid, ids, context=sub_ctx)
681         res = {}
682         for record in self.browse(cr, SUPERUSER_ID, ids, context=context):
683             recipient_ids, email_to, email_cc = set(), False, False
684             if 'partner_id' in self._all_columns and record.partner_id:
685                 recipient_ids.add(record.partner_id.id)
686             elif 'email_from' in self._all_columns and record.email_from:
687                 email_to = record.email_from
688             elif 'email' in self._all_columns:
689                 email_to = record.email
690             res[record.id] = {'partner_ids': list(recipient_ids), 'email_to': email_to, 'email_cc': email_cc}
691         return res
692
693     def message_get_reply_to(self, cr, uid, ids, default=None, context=None):
694         """ Returns the preferred reply-to email address that is basically
695             the alias of the document, if it exists. """
696         if context is None:
697             context = {}
698         model_name = context.get('thread_model') or self._name
699         alias_domain = self.pool['ir.config_parameter'].get_param(cr, uid, "mail.catchall.domain", context=context)
700         res = dict.fromkeys(ids, False)
701
702         # alias domain: check for aliases and catchall
703         aliases = {}
704         doc_names = {}
705         if alias_domain:
706             if model_name and model_name != 'mail.thread':
707                 alias_ids = self.pool['mail.alias'].search(
708                     cr, SUPERUSER_ID, [
709                         ('alias_parent_model_id.model', '=', model_name),
710                         ('alias_parent_thread_id', 'in', ids),
711                         ('alias_name', '!=', False)
712                     ], context=context)
713                 aliases.update(
714                     dict((alias.alias_parent_thread_id, '%s@%s' % (alias.alias_name, alias_domain))
715                          for alias in self.pool['mail.alias'].browse(cr, SUPERUSER_ID, alias_ids, context=context)))
716                 doc_names.update(
717                     dict((ng_res[0], ng_res[1])
718                          for ng_res in self.pool[model_name].name_get(cr, SUPERUSER_ID, aliases.keys(), context=context)))
719             # left ids: use catchall
720             left_ids = set(ids).difference(set(aliases.keys()))
721             if left_ids:
722                 catchall_alias = self.pool['ir.config_parameter'].get_param(cr, uid, "mail.catchall.alias", context=context)
723                 if catchall_alias:
724                     aliases.update(dict((res_id, '%s@%s' % (catchall_alias, alias_domain)) for res_id in left_ids))
725             # compute name of reply-to
726             company_name = self.pool['res.users'].browse(cr, SUPERUSER_ID, uid, context=context).company_id.name
727             for res_id in aliases.keys():
728                 email_name = '%s%s' % (company_name, doc_names.get(res_id) and (' ' + doc_names[res_id]) or '')
729                 email_addr = aliases[res_id]
730                 res[res_id] = formataddr((email_name, email_addr))
731         left_ids = set(ids).difference(set(aliases.keys()))
732         if left_ids and default:
733             res.update(dict((res_id, default) for res_id in left_ids))
734         return res
735
736     def message_get_email_values(self, cr, uid, id, notif_mail=None, context=None):
737         """ Get specific notification email values to store on the notification
738         mail_mail. Void method, inherit it to add custom values. """
739         res = dict()
740         return res
741
742     #------------------------------------------------------
743     # Mail gateway
744     #------------------------------------------------------
745
746     def message_capable_models(self, cr, uid, context=None):
747         """ Used by the plugin addon, based for plugin_outlook and others. """
748         ret_dict = {}
749         for model_name in self.pool.obj_list():
750             model = self.pool[model_name]
751             if hasattr(model, "message_process") and hasattr(model, "message_post"):
752                 ret_dict[model_name] = model._description
753         return ret_dict
754
755     def _message_find_partners(self, cr, uid, message, header_fields=['From'], context=None):
756         """ Find partners related to some header fields of the message.
757
758             :param string message: an email.message instance """
759         s = ', '.join([decode(message.get(h)) for h in header_fields if message.get(h)])
760         return filter(lambda x: x, self._find_partner_from_emails(cr, uid, None, tools.email_split(s), context=context))
761
762     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):
763         """ Verify route validity. Check and rules:
764             1 - if thread_id -> check that document effectively exists; otherwise
765                 fallback on a message_new by resetting thread_id
766             2 - check that message_update exists if thread_id is set; or at least
767                 that message_new exist
768             [ - find author_id if udpate_author is set]
769             3 - if there is an alias, check alias_contact:
770                 'followers' and thread_id:
771                     check on target document that the author is in the followers
772                 'followers' and alias_parent_thread_id:
773                     check on alias parent document that the author is in the
774                     followers
775                 'partners': check that author_id id set
776         """
777
778         assert isinstance(route, (list, tuple)), 'A route should be a list or a tuple'
779         assert len(route) == 5, 'A route should contain 5 elements: model, thread_id, custom_values, uid, alias record'
780
781         message_id = message.get('Message-Id')
782         email_from = decode_header(message, 'From')
783         author_id = message_dict.get('author_id')
784         model, thread_id, alias = route[0], route[1], route[4]
785         model_pool = None
786
787         def _create_bounce_email():
788             mail_mail = self.pool.get('mail.mail')
789             mail_id = mail_mail.create(cr, uid, {
790                             'body_html': '<div><p>Hello,</p>'
791                                 '<p>The following email sent to %s cannot be accepted because this is '
792                                 'a private email address. Only allowed people can contact us at this address.</p></div>'
793                                 '<blockquote>%s</blockquote>' % (message.get('to'), message_dict.get('body')),
794                             'subject': 'Re: %s' % message.get('subject'),
795                             'email_to': message.get('from'),
796                             'auto_delete': True,
797                         }, context=context)
798             mail_mail.send(cr, uid, [mail_id], context=context)
799
800         def _warn(message):
801             _logger.warning('Routing mail with Message-Id %s: route %s: %s',
802                                 message_id, route, message)
803
804         # Wrong model
805         if model and not model in self.pool:
806             if assert_model:
807                 assert model in self.pool, 'Routing: unknown target model %s' % model
808             _warn('unknown target model %s' % model)
809             return ()
810         elif model:
811             model_pool = self.pool[model]
812
813         # Private message: should not contain any thread_id
814         if not model and thread_id:
815             if assert_model:
816                 if thread_id: 
817                     raise ValueError('Routing: posting a message without model should be with a null res_id (private message), received %s.' % thread_id)
818             _warn('posting a message without model should be with a null res_id (private message), received %s resetting thread_id' % thread_id)
819             thread_id = 0
820         # Private message: should have a parent_id (only answers)
821         if not model and not message_dict.get('parent_id'):
822             if assert_model:
823                 if not message_dict.get('parent_id'):
824                     raise ValueError('Routing: posting a message without model should be with a parent_id (private mesage).')
825             _warn('posting a message without model should be with a parent_id (private mesage), skipping')
826             return ()
827
828         # Existing Document: check if exists; if not, fallback on create if allowed
829         if thread_id and not model_pool.exists(cr, uid, thread_id):
830             if create_fallback:
831                 _warn('reply to missing document (%s,%s), fall back on new document creation' % (model, thread_id))
832                 thread_id = None
833             elif assert_model:
834                 assert model_pool.exists(cr, uid, thread_id), 'Routing: reply to missing document (%s,%s)' % (model, thread_id)
835             else:
836                 _warn('reply to missing document (%s,%s), skipping' % (model, thread_id))
837                 return ()
838
839         # Existing Document: check model accepts the mailgateway
840         if thread_id and model and not hasattr(model_pool, 'message_update'):
841             if create_fallback:
842                 _warn('model %s does not accept document update, fall back on document creation' % model)
843                 thread_id = None
844             elif assert_model:
845                 assert hasattr(model_pool, 'message_update'), 'Routing: model %s does not accept document update, crashing' % model
846             else:
847                 _warn('model %s does not accept document update, skipping' % model)
848                 return ()
849
850         # New Document: check model accepts the mailgateway
851         if not thread_id and model and not hasattr(model_pool, 'message_new'):
852             if assert_model:
853                 if not hasattr(model_pool, 'message_new'):
854                     raise ValueError(
855                         'Model %s does not accept document creation, crashing' % model
856                     )
857             _warn('model %s does not accept document creation, skipping' % model)
858             return ()
859
860         # Update message author if asked
861         # We do it now because we need it for aliases (contact settings)
862         if not author_id and update_author:
863             author_ids = self._find_partner_from_emails(cr, uid, thread_id, [email_from], model=model, context=context)
864             if author_ids:
865                 author_id = author_ids[0]
866                 message_dict['author_id'] = author_id
867
868         # Alias: check alias_contact settings
869         if alias and alias.alias_contact == 'followers' and (thread_id or alias.alias_parent_thread_id):
870             if thread_id:
871                 obj = self.pool[model].browse(cr, uid, thread_id, context=context)
872             else:
873                 obj = self.pool[alias.alias_parent_model_id.model].browse(cr, uid, alias.alias_parent_thread_id, context=context)
874             if not author_id or not author_id in [fol.id for fol in obj.message_follower_ids]:
875                 _warn('alias %s restricted to internal followers, skipping' % alias.alias_name)
876                 _create_bounce_email()
877                 return ()
878         elif alias and alias.alias_contact == 'partners' and not author_id:
879             _warn('alias %s does not accept unknown author, skipping' % alias.alias_name)
880             _create_bounce_email()
881             return ()
882
883         if not model and not thread_id and not alias and not allow_private:
884             return ()
885
886         return (model, thread_id, route[2], route[3], route[4])
887
888     def message_route(self, cr, uid, message, message_dict, model=None, thread_id=None,
889                       custom_values=None, context=None):
890         """Attempt to figure out the correct target model, thread_id,
891         custom_values and user_id to use for an incoming message.
892         Multiple values may be returned, if a message had multiple
893         recipients matching existing mail.aliases, for example.
894
895         The following heuristics are used, in this order:
896              1. If the message replies to an existing thread_id, and
897                 properly contains the thread model in the 'In-Reply-To'
898                 header, use this model/thread_id pair, and ignore
899                 custom_value (not needed as no creation will take place)
900              2. Look for a mail.alias entry matching the message
901                 recipient, and use the corresponding model, thread_id,
902                 custom_values and user_id.
903              3. Fallback to the ``model``, ``thread_id`` and ``custom_values``
904                 provided.
905              4. If all the above fails, raise an exception.
906
907            :param string message: an email.message instance
908            :param dict message_dict: dictionary holding message variables
909            :param string model: the fallback model to use if the message
910                does not match any of the currently configured mail aliases
911                (may be None if a matching alias is supposed to be present)
912            :type dict custom_values: optional dictionary of default field values
913                 to pass to ``message_new`` if a new record needs to be created.
914                 Ignored if the thread record already exists, and also if a
915                 matching mail.alias was found (aliases define their own defaults)
916            :param int thread_id: optional ID of the record/thread from ``model``
917                to which this mail should be attached. Only used if the message
918                does not reply to an existing thread and does not match any mail alias.
919            :return: list of [model, thread_id, custom_values, user_id, alias]
920
921         :raises: ValueError, TypeError
922         """
923         if not isinstance(message, Message):
924             raise TypeError('message must be an email.message.Message at this point')
925         mail_msg_obj = self.pool['mail.message']
926         fallback_model = model
927
928         # Get email.message.Message variables for future processing
929         message_id = message.get('Message-Id')
930         email_from = decode_header(message, 'From')
931         email_to = decode_header(message, 'To')
932         references = decode_header(message, 'References')
933         in_reply_to = decode_header(message, 'In-Reply-To')
934         thread_references = references or in_reply_to
935
936         # 1. message is a reply to an existing message (exact match of message_id)
937         ref_match = thread_references and tools.reference_re.search(thread_references)
938         msg_references = mail_header_msgid_re.findall(thread_references)
939         mail_message_ids = mail_msg_obj.search(cr, uid, [('message_id', 'in', msg_references)], context=context)
940         if ref_match and mail_message_ids:
941             original_msg = mail_msg_obj.browse(cr, SUPERUSER_ID, mail_message_ids[0], context=context)
942             model, thread_id = original_msg.model, original_msg.res_id
943             route = self.message_route_verify(
944                 cr, uid, message, message_dict,
945                 (model, thread_id, custom_values, uid, None),
946                 update_author=True, assert_model=False, create_fallback=True, context=context)
947             if route:
948                 _logger.info(
949                     'Routing mail from %s to %s with Message-Id %s: direct reply to msg: model: %s, thread_id: %s, custom_values: %s, uid: %s',
950                     email_from, email_to, message_id, model, thread_id, custom_values, uid)
951                 return [route]
952
953         # 2. message is a reply to an existign thread (6.1 compatibility)
954         if ref_match:
955             reply_thread_id = int(ref_match.group(1))
956             reply_model = ref_match.group(2) or fallback_model
957             reply_hostname = ref_match.group(3)
958             local_hostname = socket.gethostname()
959             # do not match forwarded emails from another OpenERP system (thread_id collision!)
960             if local_hostname == reply_hostname:
961                 thread_id, model = reply_thread_id, reply_model
962                 if thread_id and model in self.pool:
963                     model_obj = self.pool[model]
964                     compat_mail_msg_ids = mail_msg_obj.search(
965                         cr, uid, [
966                             ('message_id', '=', False),
967                             ('model', '=', model),
968                             ('res_id', '=', thread_id),
969                         ], context=context)
970                     if compat_mail_msg_ids and model_obj.exists(cr, uid, thread_id) and hasattr(model_obj, 'message_update'):
971                         route = self.message_route_verify(
972                             cr, uid, message, message_dict,
973                             (model, thread_id, custom_values, uid, None),
974                             update_author=True, assert_model=True, create_fallback=True, context=context)
975                         if route:
976                             _logger.info(
977                                 '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',
978                                 email_from, email_to, message_id, model, thread_id, custom_values, uid)
979                             return [route]
980
981         # 3. Reply to a private message
982         if in_reply_to:
983             mail_message_ids = mail_msg_obj.search(cr, uid, [
984                                 ('message_id', '=', in_reply_to),
985                                 '!', ('message_id', 'ilike', 'reply_to')
986                             ], limit=1, context=context)
987             if mail_message_ids:
988                 mail_message = mail_msg_obj.browse(cr, uid, mail_message_ids[0], context=context)
989                 route = self.message_route_verify(cr, uid, message, message_dict,
990                                 (mail_message.model, mail_message.res_id, custom_values, uid, None),
991                                 update_author=True, assert_model=True, create_fallback=True, allow_private=True, context=context)
992                 if route:
993                     _logger.info(
994                         'Routing mail from %s to %s with Message-Id %s: direct reply to a private message: %s, custom_values: %s, uid: %s',
995                         email_from, email_to, message_id, mail_message.id, custom_values, uid)
996                     return [route]
997
998         # 4. Look for a matching mail.alias entry
999         # Delivered-To is a safe bet in most modern MTAs, but we have to fallback on To + Cc values
1000         # for all the odd MTAs out there, as there is no standard header for the envelope's `rcpt_to` value.
1001         rcpt_tos = \
1002              ','.join([decode_header(message, 'Delivered-To'),
1003                        decode_header(message, 'To'),
1004                        decode_header(message, 'Cc'),
1005                        decode_header(message, 'Resent-To'),
1006                        decode_header(message, 'Resent-Cc')])
1007         local_parts = [e.split('@')[0] for e in tools.email_split(rcpt_tos)]
1008         if local_parts:
1009             mail_alias = self.pool.get('mail.alias')
1010             alias_ids = mail_alias.search(cr, uid, [('alias_name', 'in', local_parts)])
1011             if alias_ids:
1012                 routes = []
1013                 for alias in mail_alias.browse(cr, uid, alias_ids, context=context):
1014                     user_id = alias.alias_user_id.id
1015                     if not user_id:
1016                         # TDE note: this could cause crashes, because no clue that the user
1017                         # that send the email has the right to create or modify a new document
1018                         # Fallback on user_id = uid
1019                         # Note: recognized partners will be added as followers anyway
1020                         # user_id = self._message_find_user_id(cr, uid, message, context=context)
1021                         user_id = uid
1022                         _logger.info('No matching user_id for the alias %s', alias.alias_name)
1023                     route = (alias.alias_model_id.model, alias.alias_force_thread_id, eval(alias.alias_defaults), user_id, alias)
1024                     route = self.message_route_verify(cr, uid, message, message_dict, route,
1025                                 update_author=True, assert_model=True, create_fallback=True, context=context)
1026                     if route:
1027                         _logger.info(
1028                             'Routing mail from %s to %s with Message-Id %s: direct alias match: %r',
1029                             email_from, email_to, message_id, route)
1030                         routes.append(route)
1031                 return routes
1032
1033         # 5. Fallback to the provided parameters, if they work
1034         if not thread_id:
1035             # Legacy: fallback to matching [ID] in the Subject
1036             match = tools.res_re.search(decode_header(message, 'Subject'))
1037             thread_id = match and match.group(1)
1038             # Convert into int (bug spotted in 7.0 because of str)
1039             try:
1040                 thread_id = int(thread_id)
1041             except:
1042                 thread_id = False
1043         route = self.message_route_verify(cr, uid, message, message_dict,
1044                         (fallback_model, thread_id, custom_values, uid, None),
1045                         update_author=True, assert_model=True, context=context)
1046         if route:
1047             _logger.info(
1048                 'Routing mail from %s to %s with Message-Id %s: fallback to model:%s, thread_id:%s, custom_values:%s, uid:%s',
1049                 email_from, email_to, message_id, fallback_model, thread_id, custom_values, uid)
1050             return [route]
1051
1052         # ValueError if no routes found and if no bounce occured
1053         raise ValueError(
1054                 'No possible route found for incoming message from %s to %s (Message-Id %s:). '
1055                 'Create an appropriate mail.alias or force the destination model.' %
1056                 (email_from, email_to, message_id)
1057             )
1058
1059     def message_route_process(self, cr, uid, message, message_dict, routes, context=None):
1060         # postpone setting message_dict.partner_ids after message_post, to avoid double notifications
1061         context = dict(context or {})
1062         partner_ids = message_dict.pop('partner_ids', [])
1063         thread_id = False
1064         for model, thread_id, custom_values, user_id, alias in routes:
1065             if self._name == 'mail.thread':
1066                 context['thread_model'] = model
1067             if model:
1068                 model_pool = self.pool[model]
1069                 if not (thread_id and hasattr(model_pool, 'message_update') or hasattr(model_pool, 'message_new')):
1070                     raise ValueError(
1071                         "Undeliverable mail with Message-Id %s, model %s does not accept incoming emails" %
1072                         (message_dict['message_id'], model)
1073                     )
1074
1075                 # disabled subscriptions during message_new/update to avoid having the system user running the
1076                 # email gateway become a follower of all inbound messages
1077                 nosub_ctx = dict(context, mail_create_nosubscribe=True, mail_create_nolog=True)
1078                 if thread_id and hasattr(model_pool, 'message_update'):
1079                     model_pool.message_update(cr, user_id, [thread_id], message_dict, context=nosub_ctx)
1080                 else:
1081                     thread_id = model_pool.message_new(cr, user_id, message_dict, custom_values, context=nosub_ctx)
1082             else:
1083                 if thread_id:
1084                     raise ValueError("Posting a message without model should be with a null res_id, to create a private message.")
1085                 model_pool = self.pool.get('mail.thread')
1086             if not hasattr(model_pool, 'message_post'):
1087                 context['thread_model'] = model
1088                 model_pool = self.pool['mail.thread']
1089             new_msg_id = model_pool.message_post(cr, uid, [thread_id], context=context, subtype='mail.mt_comment', **message_dict)
1090
1091             if partner_ids:
1092                 # postponed after message_post, because this is an external message and we don't want to create
1093                 # duplicate emails due to notifications
1094                 self.pool.get('mail.message').write(cr, uid, [new_msg_id], {'partner_ids': partner_ids}, context=context)
1095         return thread_id
1096
1097     def message_process(self, cr, uid, model, message, custom_values=None,
1098                         save_original=False, strip_attachments=False,
1099                         thread_id=None, context=None):
1100         """ Process an incoming RFC2822 email message, relying on
1101             ``mail.message.parse()`` for the parsing operation,
1102             and ``message_route()`` to figure out the target model.
1103
1104             Once the target model is known, its ``message_new`` method
1105             is called with the new message (if the thread record did not exist)
1106             or its ``message_update`` method (if it did).
1107
1108             There is a special case where the target model is False: a reply
1109             to a private message. In this case, we skip the message_new /
1110             message_update step, to just post a new message using mail_thread
1111             message_post.
1112
1113            :param string model: the fallback model to use if the message
1114                does not match any of the currently configured mail aliases
1115                (may be None if a matching alias is supposed to be present)
1116            :param message: source of the RFC2822 message
1117            :type message: string or xmlrpclib.Binary
1118            :type dict custom_values: optional dictionary of field values
1119                 to pass to ``message_new`` if a new record needs to be created.
1120                 Ignored if the thread record already exists, and also if a
1121                 matching mail.alias was found (aliases define their own defaults)
1122            :param bool save_original: whether to keep a copy of the original
1123                 email source attached to the message after it is imported.
1124            :param bool strip_attachments: whether to strip all attachments
1125                 before processing the message, in order to save some space.
1126            :param int thread_id: optional ID of the record/thread from ``model``
1127                to which this mail should be attached. When provided, this
1128                overrides the automatic detection based on the message
1129                headers.
1130         """
1131         if context is None:
1132             context = {}
1133
1134         # extract message bytes - we are forced to pass the message as binary because
1135         # we don't know its encoding until we parse its headers and hence can't
1136         # convert it to utf-8 for transport between the mailgate script and here.
1137         if isinstance(message, xmlrpclib.Binary):
1138             message = str(message.data)
1139         # Warning: message_from_string doesn't always work correctly on unicode,
1140         # we must use utf-8 strings here :-(
1141         if isinstance(message, unicode):
1142             message = message.encode('utf-8')
1143         msg_txt = email.message_from_string(message)
1144
1145         # parse the message, verify we are not in a loop by checking message_id is not duplicated
1146         msg = self.message_parse(cr, uid, msg_txt, save_original=save_original, context=context)
1147         if strip_attachments:
1148             msg.pop('attachments', None)
1149
1150         if msg.get('message_id'):   # should always be True as message_parse generate one if missing
1151             existing_msg_ids = self.pool.get('mail.message').search(cr, SUPERUSER_ID, [
1152                                                                 ('message_id', '=', msg.get('message_id')),
1153                                                                 ], context=context)
1154             if existing_msg_ids:
1155                 _logger.info('Ignored mail from %s to %s with Message-Id %s: found duplicated Message-Id during processing',
1156                                 msg.get('from'), msg.get('to'), msg.get('message_id'))
1157                 return False
1158
1159         # find possible routes for the message
1160         routes = self.message_route(cr, uid, msg_txt, msg, model, thread_id, custom_values, context=context)
1161         thread_id = self.message_route_process(cr, uid, msg_txt, msg, routes, context=context)
1162         return thread_id
1163
1164     def message_new(self, cr, uid, msg_dict, custom_values=None, context=None):
1165         """Called by ``message_process`` when a new message is received
1166            for a given thread model, if the message did not belong to
1167            an existing thread.
1168            The default behavior is to create a new record of the corresponding
1169            model (based on some very basic info extracted from the message).
1170            Additional behavior may be implemented by overriding this method.
1171
1172            :param dict msg_dict: a map containing the email details and
1173                                  attachments. See ``message_process`` and
1174                                 ``mail.message.parse`` for details.
1175            :param dict custom_values: optional dictionary of additional
1176                                       field values to pass to create()
1177                                       when creating the new thread record.
1178                                       Be careful, these values may override
1179                                       any other values coming from the message.
1180            :param dict context: if a ``thread_model`` value is present
1181                                 in the context, its value will be used
1182                                 to determine the model of the record
1183                                 to create (instead of the current model).
1184            :rtype: int
1185            :return: the id of the newly created thread object
1186         """
1187         if context is None:
1188             context = {}
1189         data = {}
1190         if isinstance(custom_values, dict):
1191             data = custom_values.copy()
1192         model = context.get('thread_model') or self._name
1193         model_pool = self.pool[model]
1194         fields = model_pool.fields_get(cr, uid, context=context)
1195         if 'name' in fields and not data.get('name'):
1196             data['name'] = msg_dict.get('subject', '')
1197         res_id = model_pool.create(cr, uid, data, context=context)
1198         return res_id
1199
1200     def message_update(self, cr, uid, ids, msg_dict, update_vals=None, context=None):
1201         """Called by ``message_process`` when a new message is received
1202            for an existing thread. The default behavior is to update the record
1203            with update_vals taken from the incoming email.
1204            Additional behavior may be implemented by overriding this
1205            method.
1206            :param dict msg_dict: a map containing the email details and
1207                                attachments. See ``message_process`` and
1208                                ``mail.message.parse()`` for details.
1209            :param dict update_vals: a dict containing values to update records
1210                               given their ids; if the dict is None or is
1211                               void, no write operation is performed.
1212         """
1213         if update_vals:
1214             self.write(cr, uid, ids, update_vals, context=context)
1215         return True
1216
1217     def _message_extract_payload(self, message, save_original=False):
1218         """Extract body as HTML and attachments from the mail message"""
1219         attachments = []
1220         body = u''
1221         if save_original:
1222             attachments.append(('original_email.eml', message.as_string()))
1223
1224         # Be careful, content-type may contain tricky content like in the
1225         # following example so test the MIME type with startswith()
1226         #
1227         # Content-Type: multipart/related;
1228         #   boundary="_004_3f1e4da175f349248b8d43cdeb9866f1AMSPR06MB343eurprd06pro_";
1229         #   type="text/html"
1230         if not message.is_multipart() or message.get('content-type', '').startswith("text/"):
1231             encoding = message.get_content_charset()
1232             body = message.get_payload(decode=True)
1233             body = tools.ustr(body, encoding, errors='replace')
1234             if message.get_content_type() == 'text/plain':
1235                 # text/plain -> <pre/>
1236                 body = tools.append_content_to_html(u'', body, preserve=True)
1237         else:
1238             alternative = False
1239             mixed = False
1240             html = u''
1241             for part in message.walk():
1242                 if part.get_content_type() == 'multipart/alternative':
1243                     alternative = True
1244                 if part.get_content_type() == 'multipart/mixed':
1245                     mixed = True
1246                 if part.get_content_maintype() == 'multipart':
1247                     continue  # skip container
1248                 # part.get_filename returns decoded value if able to decode, coded otherwise.
1249                 # original get_filename is not able to decode iso-8859-1 (for instance).
1250                 # therefore, iso encoded attachements are not able to be decoded properly with get_filename
1251                 # code here partially copy the original get_filename method, but handle more encoding
1252                 filename=part.get_param('filename', None, 'content-disposition')
1253                 if not filename:
1254                     filename=part.get_param('name', None)
1255                 if filename:
1256                     if isinstance(filename, tuple):
1257                         # RFC2231
1258                         filename=email.utils.collapse_rfc2231_value(filename).strip()
1259                     else:
1260                         filename=decode(filename)
1261                 encoding = part.get_content_charset()  # None if attachment
1262                 # 1) Explicit Attachments -> attachments
1263                 if filename or part.get('content-disposition', '').strip().startswith('attachment'):
1264                     attachments.append((filename or 'attachment', part.get_payload(decode=True)))
1265                     continue
1266                 # 2) text/plain -> <pre/>
1267                 if part.get_content_type() == 'text/plain' and (not alternative or not body):
1268                     body = tools.append_content_to_html(body, tools.ustr(part.get_payload(decode=True),
1269                                                                          encoding, errors='replace'), preserve=True)
1270                 # 3) text/html -> raw
1271                 elif part.get_content_type() == 'text/html':
1272                     # mutlipart/alternative have one text and a html part, keep only the second
1273                     # mixed allows several html parts, append html content
1274                     append_content = not alternative or (html and mixed)
1275                     html = tools.ustr(part.get_payload(decode=True), encoding, errors='replace')
1276                     if not append_content:
1277                         body = html
1278                     else:
1279                         body = tools.append_content_to_html(body, html, plaintext=False)
1280                 # 4) Anything else -> attachment
1281                 else:
1282                     attachments.append((filename or 'attachment', part.get_payload(decode=True)))
1283         return body, attachments
1284
1285     def message_parse(self, cr, uid, message, save_original=False, context=None):
1286         """Parses a string or email.message.Message representing an
1287            RFC-2822 email, and returns a generic dict holding the
1288            message details.
1289
1290            :param message: the message to parse
1291            :type message: email.message.Message | string | unicode
1292            :param bool save_original: whether the returned dict
1293                should include an ``original`` attachment containing
1294                the source of the message
1295            :rtype: dict
1296            :return: A dict with the following structure, where each
1297                     field may not be present if missing in original
1298                     message::
1299
1300                     { 'message_id': msg_id,
1301                       'subject': subject,
1302                       'from': from,
1303                       'to': to,
1304                       'cc': cc,
1305                       'body': unified_body,
1306                       'attachments': [('file1', 'bytes'),
1307                                       ('file2', 'bytes')}
1308                     }
1309         """
1310         msg_dict = {
1311             'type': 'email',
1312         }
1313         if not isinstance(message, Message):
1314             if isinstance(message, unicode):
1315                 # Warning: message_from_string doesn't always work correctly on unicode,
1316                 # we must use utf-8 strings here :-(
1317                 message = message.encode('utf-8')
1318             message = email.message_from_string(message)
1319
1320         message_id = message['message-id']
1321         if not message_id:
1322             # Very unusual situation, be we should be fault-tolerant here
1323             message_id = "<%s@localhost>" % time.time()
1324             _logger.debug('Parsing Message without message-id, generating a random one: %s', message_id)
1325         msg_dict['message_id'] = message_id
1326
1327         if message.get('Subject'):
1328             msg_dict['subject'] = decode(message.get('Subject'))
1329
1330         # Envelope fields not stored in mail.message but made available for message_new()
1331         msg_dict['from'] = decode(message.get('from'))
1332         msg_dict['to'] = decode(message.get('to'))
1333         msg_dict['cc'] = decode(message.get('cc'))
1334         msg_dict['email_from'] = decode(message.get('from'))
1335         partner_ids = self._message_find_partners(cr, uid, message, ['To', 'Cc'], context=context)
1336         msg_dict['partner_ids'] = [(4, partner_id) for partner_id in partner_ids]
1337
1338         if message.get('Date'):
1339             try:
1340                 date_hdr = decode(message.get('Date'))
1341                 parsed_date = dateutil.parser.parse(date_hdr, fuzzy=True)
1342                 if parsed_date.utcoffset() is None:
1343                     # naive datetime, so we arbitrarily decide to make it
1344                     # UTC, there's no better choice. Should not happen,
1345                     # as RFC2822 requires timezone offset in Date headers.
1346                     stored_date = parsed_date.replace(tzinfo=pytz.utc)
1347                 else:
1348                     stored_date = parsed_date.astimezone(tz=pytz.utc)
1349             except Exception:
1350                 _logger.warning('Failed to parse Date header %r in incoming mail '
1351                                 'with message-id %r, assuming current date/time.',
1352                                 message.get('Date'), message_id)
1353                 stored_date = datetime.datetime.now()
1354             msg_dict['date'] = stored_date.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT)
1355
1356         if message.get('In-Reply-To'):
1357             parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', '=', decode(message['In-Reply-To'].strip()))])
1358             if parent_ids:
1359                 msg_dict['parent_id'] = parent_ids[0]
1360
1361         if message.get('References') and 'parent_id' not in msg_dict:
1362             msg_list =  mail_header_msgid_re.findall(decode(message['References']))
1363             parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', 'in', [x.strip() for x in msg_list])])
1364             if parent_ids:
1365                 msg_dict['parent_id'] = parent_ids[0]
1366
1367         msg_dict['body'], msg_dict['attachments'] = self._message_extract_payload(message, save_original=save_original)
1368         return msg_dict
1369
1370     #------------------------------------------------------
1371     # Note specific
1372     #------------------------------------------------------
1373
1374     def _message_add_suggested_recipient(self, cr, uid, result, obj, partner=None, email=None, reason='', context=None):
1375         """ Called by message_get_suggested_recipients, to add a suggested
1376             recipient in the result dictionary. The form is :
1377                 partner_id, partner_name<partner_email> or partner_name, reason """
1378         if email and not partner:
1379             # get partner info from email
1380             partner_info = self.message_partner_info_from_emails(cr, uid, obj.id, [email], context=context)[0]
1381             if partner_info.get('partner_id'):
1382                 partner = self.pool.get('res.partner').browse(cr, SUPERUSER_ID, [partner_info['partner_id']], context=context)[0]
1383         if email and email in [val[1] for val in result[obj.id]]:  # already existing email -> skip
1384             return result
1385         if partner and partner in obj.message_follower_ids:  # recipient already in the followers -> skip
1386             return result
1387         if partner and partner.id in [val[0] for val in result[obj.id]]:  # already existing partner ID -> skip
1388             return result
1389         if partner and partner.email:  # complete profile: id, name <email>
1390             result[obj.id].append((partner.id, '%s<%s>' % (partner.name, partner.email), reason))
1391         elif partner:  # incomplete profile: id, name
1392             result[obj.id].append((partner.id, '%s' % (partner.name), reason))
1393         else:  # unknown partner, we are probably managing an email address
1394             result[obj.id].append((False, email, reason))
1395         return result
1396
1397     def message_get_suggested_recipients(self, cr, uid, ids, context=None):
1398         """ Returns suggested recipients for ids. Those are a list of
1399             tuple (partner_id, partner_name, reason), to be managed by Chatter. """
1400         result = dict((res_id, []) for res_id in ids)
1401         if self._all_columns.get('user_id'):
1402             for obj in self.browse(cr, SUPERUSER_ID, ids, context=context):  # SUPERUSER because of a read on res.users that would crash otherwise
1403                 if not obj.user_id or not obj.user_id.partner_id:
1404                     continue
1405                 self._message_add_suggested_recipient(cr, uid, result, obj, partner=obj.user_id.partner_id, reason=self._all_columns['user_id'].column.string, context=context)
1406         return result
1407
1408     def _find_partner_from_emails(self, cr, uid, id, emails, model=None, context=None, check_followers=True):
1409         """ Utility method to find partners from email addresses. The rules are :
1410             1 - check in document (model | self, id) followers
1411             2 - try to find a matching partner that is also an user
1412             3 - try to find a matching partner
1413
1414             :param list emails: list of email addresses
1415             :param string model: model to fetch related record; by default self
1416                 is used.
1417             :param boolean check_followers: check in document followers
1418         """
1419         partner_obj = self.pool['res.partner']
1420         partner_ids = []
1421         obj = None
1422         if id and (model or self._name != 'mail.thread') and check_followers:
1423             if model:
1424                 obj = self.pool[model].browse(cr, uid, id, context=context)
1425             else:
1426                 obj = self.browse(cr, uid, id, context=context)
1427         for contact in emails:
1428             partner_id = False
1429             email_address = tools.email_split(contact)
1430             if not email_address:
1431                 partner_ids.append(partner_id)
1432                 continue
1433             email_address = email_address[0]
1434             # first try: check in document's followers
1435             if obj:
1436                 for follower in obj.message_follower_ids:
1437                     if follower.email == email_address:
1438                         partner_id = follower.id
1439             # second try: check in partners that are also users
1440             if not partner_id:
1441                 ids = partner_obj.search(cr, SUPERUSER_ID, [
1442                                                 ('email', 'ilike', email_address),
1443                                                 ('user_ids', '!=', False)
1444                                             ], limit=1, context=context)
1445                 if ids:
1446                     partner_id = ids[0]
1447             # third try: check in partners
1448             if not partner_id:
1449                 ids = partner_obj.search(cr, SUPERUSER_ID, [
1450                                                 ('email', 'ilike', email_address)
1451                                             ], limit=1, context=context)
1452                 if ids:
1453                     partner_id = ids[0]
1454             partner_ids.append(partner_id)
1455         return partner_ids
1456
1457     def message_partner_info_from_emails(self, cr, uid, id, emails, link_mail=False, context=None):
1458         """ Convert a list of emails into a list partner_ids and a list
1459             new_partner_ids. The return value is non conventional because
1460             it is meant to be used by the mail widget.
1461
1462             :return dict: partner_ids and new_partner_ids """
1463         mail_message_obj = self.pool.get('mail.message')
1464         partner_ids = self._find_partner_from_emails(cr, uid, id, emails, context=context)
1465         result = list()
1466         for idx in range(len(emails)):
1467             email_address = emails[idx]
1468             partner_id = partner_ids[idx]
1469             partner_info = {'full_name': email_address, 'partner_id': partner_id}
1470             result.append(partner_info)
1471
1472             # link mail with this from mail to the new partner id
1473             if link_mail and partner_info['partner_id']:
1474                 message_ids = mail_message_obj.search(cr, SUPERUSER_ID, [
1475                                     '|',
1476                                     ('email_from', '=', email_address),
1477                                     ('email_from', 'ilike', '<%s>' % email_address),
1478                                     ('author_id', '=', False)
1479                                 ], context=context)
1480                 if message_ids:
1481                     mail_message_obj.write(cr, SUPERUSER_ID, message_ids, {'author_id': partner_info['partner_id']}, context=context)
1482         return result
1483
1484     def _message_preprocess_attachments(self, cr, uid, attachments, attachment_ids, attach_model, attach_res_id, context=None):
1485         """ Preprocess attachments for mail_thread.message_post() or mail_mail.create().
1486
1487         :param list attachments: list of attachment tuples in the form ``(name,content)``,
1488                                  where content is NOT base64 encoded
1489         :param list attachment_ids: a list of attachment ids, not in tomany command form
1490         :param str attach_model: the model of the attachments parent record
1491         :param integer attach_res_id: the id of the attachments parent record
1492         """
1493         Attachment = self.pool['ir.attachment']
1494         m2m_attachment_ids = []
1495         if attachment_ids:
1496             filtered_attachment_ids = Attachment.search(cr, SUPERUSER_ID, [
1497                 ('res_model', '=', 'mail.compose.message'),
1498                 ('create_uid', '=', uid),
1499                 ('id', 'in', attachment_ids)], context=context)
1500             if filtered_attachment_ids:
1501                 Attachment.write(cr, SUPERUSER_ID, filtered_attachment_ids, {'res_model': attach_model, 'res_id': attach_res_id}, context=context)
1502             m2m_attachment_ids += [(4, id) for id in attachment_ids]
1503         # Handle attachments parameter, that is a dictionary of attachments
1504         for name, content in attachments:
1505             if isinstance(content, unicode):
1506                 content = content.encode('utf-8')
1507             data_attach = {
1508                 'name': name,
1509                 'datas': base64.b64encode(str(content)),
1510                 'datas_fname': name,
1511                 'description': name,
1512                 'res_model': attach_model,
1513                 'res_id': attach_res_id,
1514             }
1515             m2m_attachment_ids.append((0, 0, data_attach))
1516         return m2m_attachment_ids
1517
1518     @api.cr_uid_ids_context
1519     def message_post(self, cr, uid, thread_id, body='', subject=None, type='notification',
1520                      subtype=None, parent_id=False, attachments=None, context=None,
1521                      content_subtype='html', **kwargs):
1522         """ Post a new message in an existing thread, returning the new
1523             mail.message ID.
1524
1525             :param int thread_id: thread ID to post into, or list with one ID;
1526                 if False/0, mail.message model will also be set as False
1527             :param str body: body of the message, usually raw HTML that will
1528                 be sanitized
1529             :param str type: see mail_message.type field
1530             :param str content_subtype:: if plaintext: convert body into html
1531             :param int parent_id: handle reply to a previous message by adding the
1532                 parent partners to the message in case of private discussion
1533             :param tuple(str,str) attachments or list id: list of attachment tuples in the form
1534                 ``(name,content)``, where content is NOT base64 encoded
1535
1536             Extra keyword arguments will be used as default column values for the
1537             new mail.message record. Special cases:
1538                 - attachment_ids: supposed not attached to any document; attach them
1539                     to the related document. Should only be set by Chatter.
1540             :return int: ID of newly created mail.message
1541         """
1542         if context is None:
1543             context = {}
1544         if attachments is None:
1545             attachments = {}
1546         mail_message = self.pool.get('mail.message')
1547         ir_attachment = self.pool.get('ir.attachment')
1548
1549         assert (not thread_id) or \
1550                 isinstance(thread_id, (int, long)) or \
1551                 (isinstance(thread_id, (list, tuple)) and len(thread_id) == 1), \
1552                 "Invalid thread_id; should be 0, False, an ID or a list with one ID"
1553         if isinstance(thread_id, (list, tuple)):
1554             thread_id = thread_id[0]
1555
1556         # if we're processing a message directly coming from the gateway, the destination model was
1557         # set in the context.
1558         model = False
1559         if thread_id:
1560             model = context.get('thread_model', False) if self._name == 'mail.thread' else self._name
1561             if model and model != self._name and hasattr(self.pool[model], 'message_post'):
1562                 del context['thread_model']
1563                 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)
1564
1565         #0: Find the message's author, because we need it for private discussion
1566         author_id = kwargs.get('author_id')
1567         if author_id is None:  # keep False values
1568             author_id = self.pool.get('mail.message')._get_default_author(cr, uid, context=context)
1569
1570         # 1: Handle content subtype: if plaintext, converto into HTML
1571         if content_subtype == 'plaintext':
1572             body = tools.plaintext2html(body)
1573
1574         # 2: Private message: add recipients (recipients and author of parent message) - current author
1575         #   + legacy-code management (! we manage only 4 and 6 commands)
1576         partner_ids = set()
1577         kwargs_partner_ids = kwargs.pop('partner_ids', [])
1578         for partner_id in kwargs_partner_ids:
1579             if isinstance(partner_id, (list, tuple)) and partner_id[0] == 4 and len(partner_id) == 2:
1580                 partner_ids.add(partner_id[1])
1581             if isinstance(partner_id, (list, tuple)) and partner_id[0] == 6 and len(partner_id) == 3:
1582                 partner_ids |= set(partner_id[2])
1583             elif isinstance(partner_id, (int, long)):
1584                 partner_ids.add(partner_id)
1585             else:
1586                 pass  # we do not manage anything else
1587         if parent_id and not model:
1588             parent_message = mail_message.browse(cr, uid, parent_id, context=context)
1589             private_followers = set([partner.id for partner in parent_message.partner_ids])
1590             if parent_message.author_id:
1591                 private_followers.add(parent_message.author_id.id)
1592             private_followers -= set([author_id])
1593             partner_ids |= private_followers
1594
1595         # 3. Attachments
1596         #   - HACK TDE FIXME: Chatter: attachments linked to the document (not done JS-side), load the message
1597         attachment_ids = self._message_preprocess_attachments(cr, uid, attachments, kwargs.pop('attachment_ids', []), model, thread_id, context)
1598
1599         # 4: mail.message.subtype
1600         subtype_id = False
1601         if subtype:
1602             if '.' not in subtype:
1603                 subtype = 'mail.%s' % subtype
1604             subtype_id = self.pool.get('ir.model.data').xmlid_to_res_id(cr, uid, subtype)
1605
1606         # automatically subscribe recipients if asked to
1607         if context.get('mail_post_autofollow') and thread_id and partner_ids:
1608             partner_to_subscribe = partner_ids
1609             if context.get('mail_post_autofollow_partner_ids'):
1610                 partner_to_subscribe = filter(lambda item: item in context.get('mail_post_autofollow_partner_ids'), partner_ids)
1611             self.message_subscribe(cr, uid, [thread_id], list(partner_to_subscribe), context=context)
1612
1613         # _mail_flat_thread: automatically set free messages to the first posted message
1614         if self._mail_flat_thread and model and not parent_id and thread_id:
1615             message_ids = mail_message.search(cr, uid, ['&', ('res_id', '=', thread_id), ('model', '=', model)], context=context, order="id ASC", limit=1)
1616             parent_id = message_ids and message_ids[0] or False
1617         # 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
1618         elif parent_id:
1619             message_ids = mail_message.search(cr, SUPERUSER_ID, [('id', '=', parent_id), ('parent_id', '!=', False)], context=context)
1620             # avoid loops when finding ancestors
1621             processed_list = []
1622             if message_ids:
1623                 message = mail_message.browse(cr, SUPERUSER_ID, message_ids[0], context=context)
1624                 while (message.parent_id and message.parent_id.id not in processed_list):
1625                     processed_list.append(message.parent_id.id)
1626                     message = message.parent_id
1627                 parent_id = message.id
1628
1629         values = kwargs
1630         values.update({
1631             'author_id': author_id,
1632             'model': model,
1633             'res_id': model and thread_id or False,
1634             'body': body,
1635             'subject': subject or False,
1636             'type': type,
1637             'parent_id': parent_id,
1638             'attachment_ids': attachment_ids,
1639             'subtype_id': subtype_id,
1640             'partner_ids': [(4, pid) for pid in partner_ids],
1641         })
1642
1643         # Avoid warnings about non-existing fields
1644         for x in ('from', 'to', 'cc'):
1645             values.pop(x, None)
1646
1647         # Post the message
1648         msg_id = mail_message.create(cr, uid, values, context=context)
1649
1650         # Post-process: subscribe author, update message_last_post
1651         if model and model != 'mail.thread' and thread_id and subtype_id:
1652             # done with SUPERUSER_ID, because on some models users can post only with read access, not necessarily write access
1653             self.write(cr, SUPERUSER_ID, [thread_id], {'message_last_post': fields.datetime.now()}, context=context)
1654         message = mail_message.browse(cr, uid, msg_id, context=context)
1655         if message.author_id and model and thread_id and type != 'notification' and not context.get('mail_create_nosubscribe'):
1656             self.message_subscribe(cr, uid, [thread_id], [message.author_id.id], context=context)
1657         return msg_id
1658
1659     #------------------------------------------------------
1660     # Followers API
1661     #------------------------------------------------------
1662
1663     def message_get_subscription_data(self, cr, uid, ids, user_pid=None, context=None):
1664         """ Wrapper to get subtypes data. """
1665         return self._get_subscription_data(cr, uid, ids, None, None, user_pid=user_pid, context=context)
1666
1667     def message_subscribe_users(self, cr, uid, ids, user_ids=None, subtype_ids=None, context=None):
1668         """ Wrapper on message_subscribe, using users. If user_ids is not
1669             provided, subscribe uid instead. """
1670         if user_ids is None:
1671             user_ids = [uid]
1672         partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
1673         result = self.message_subscribe(cr, uid, ids, partner_ids, subtype_ids=subtype_ids, context=context)
1674         if partner_ids and result:
1675             self.pool['ir.ui.menu'].clear_cache()
1676         return result
1677
1678     def message_subscribe(self, cr, uid, ids, partner_ids, subtype_ids=None, context=None):
1679         """ Add partners to the records followers. """
1680         if context is None:
1681             context = {}
1682         # not necessary for computation, but saves an access right check
1683         if not partner_ids:
1684             return True
1685
1686         mail_followers_obj = self.pool.get('mail.followers')
1687         subtype_obj = self.pool.get('mail.message.subtype')
1688
1689         user_pid = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1690         if set(partner_ids) == set([user_pid]):
1691             try:
1692                 self.check_access_rights(cr, uid, 'read')
1693                 self.check_access_rule(cr, uid, ids, 'read')
1694             except (osv.except_osv, orm.except_orm):
1695                 return False
1696         else:
1697             self.check_access_rights(cr, uid, 'write')
1698             self.check_access_rule(cr, uid, ids, 'write')
1699
1700         existing_pids_dict = {}
1701         fol_ids = mail_followers_obj.search(cr, SUPERUSER_ID, ['&', '&', ('res_model', '=', self._name), ('res_id', 'in', ids), ('partner_id', 'in', partner_ids)])
1702         for fol in mail_followers_obj.browse(cr, SUPERUSER_ID, fol_ids, context=context):
1703             existing_pids_dict.setdefault(fol.res_id, set()).add(fol.partner_id.id)
1704
1705         # subtype_ids specified: update already subscribed partners
1706         if subtype_ids and fol_ids:
1707             mail_followers_obj.write(cr, SUPERUSER_ID, fol_ids, {'subtype_ids': [(6, 0, subtype_ids)]}, context=context)
1708         # subtype_ids not specified: do not update already subscribed partner, fetch default subtypes for new partners
1709         if subtype_ids is None:
1710             subtype_ids = subtype_obj.search(
1711                 cr, uid, [
1712                     ('default', '=', True), '|', ('res_model', '=', self._name), ('res_model', '=', False)], context=context)
1713
1714         for id in ids:
1715             existing_pids = existing_pids_dict.get(id, set())
1716             new_pids = set(partner_ids) - existing_pids
1717
1718             # subscribe new followers
1719             for new_pid in new_pids:
1720                 mail_followers_obj.create(
1721                     cr, SUPERUSER_ID, {
1722                         'res_model': self._name,
1723                         'res_id': id,
1724                         'partner_id': new_pid,
1725                         'subtype_ids': [(6, 0, subtype_ids)],
1726                     }, context=context)
1727
1728         return True
1729
1730     def message_unsubscribe_users(self, cr, uid, ids, user_ids=None, context=None):
1731         """ Wrapper on message_subscribe, using users. If user_ids is not
1732             provided, unsubscribe uid instead. """
1733         if user_ids is None:
1734             user_ids = [uid]
1735         partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
1736         result = self.message_unsubscribe(cr, uid, ids, partner_ids, context=context)
1737         if partner_ids and result:
1738             self.pool['ir.ui.menu'].clear_cache()
1739         return result
1740
1741     def message_unsubscribe(self, cr, uid, ids, partner_ids, context=None):
1742         """ Remove partners from the records followers. """
1743         # not necessary for computation, but saves an access right check
1744         if not partner_ids:
1745             return True
1746         user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
1747         if set(partner_ids) == set([user_pid]):
1748             self.check_access_rights(cr, uid, 'read')
1749             self.check_access_rule(cr, uid, ids, 'read')
1750         else:
1751             self.check_access_rights(cr, uid, 'write')
1752             self.check_access_rule(cr, uid, ids, 'write')
1753         fol_obj = self.pool['mail.followers']
1754         fol_ids = fol_obj.search(
1755             cr, SUPERUSER_ID, [
1756                 ('res_model', '=', self._name),
1757                 ('res_id', 'in', ids),
1758                 ('partner_id', 'in', partner_ids)
1759             ], context=context)
1760         return fol_obj.unlink(cr, SUPERUSER_ID, fol_ids, context=context)
1761
1762     def _message_get_auto_subscribe_fields(self, cr, uid, updated_fields, auto_follow_fields=None, context=None):
1763         """ Returns the list of relational fields linking to res.users that should
1764             trigger an auto subscribe. The default list checks for the fields
1765             - called 'user_id'
1766             - linking to res.users
1767             - with track_visibility set
1768             In OpenERP V7, this is sufficent for all major addon such as opportunity,
1769             project, issue, recruitment, sale.
1770             Override this method if a custom behavior is needed about fields
1771             that automatically subscribe users.
1772         """
1773         if auto_follow_fields is None:
1774             auto_follow_fields = ['user_id']
1775         user_field_lst = []
1776         for name, column_info in self._all_columns.items():
1777             if name in auto_follow_fields and name in updated_fields and getattr(column_info.column, 'track_visibility', False) and column_info.column._obj == 'res.users':
1778                 user_field_lst.append(name)
1779         return user_field_lst
1780
1781     def message_auto_subscribe(self, cr, uid, ids, updated_fields, context=None, values=None):
1782         """ Handle auto subscription. Two methods for auto subscription exist:
1783
1784          - tracked res.users relational fields, such as user_id fields. Those fields
1785            must be relation fields toward a res.users record, and must have the
1786            track_visilibity attribute set.
1787          - using subtypes parent relationship: check if the current model being
1788            modified has an header record (such as a project for tasks) whose followers
1789            can be added as followers of the current records. Example of structure
1790            with project and task:
1791
1792           - st_project_1.parent_id = st_task_1
1793           - st_project_1.res_model = 'project.project'
1794           - st_project_1.relation_field = 'project_id'
1795           - st_task_1.model = 'project.task'
1796
1797         :param list updated_fields: list of updated fields to track
1798         :param dict values: updated values; if None, the first record will be browsed
1799                             to get the values. Added after releasing 7.0, therefore
1800                             not merged with updated_fields argumment.
1801         """
1802         subtype_obj = self.pool.get('mail.message.subtype')
1803         follower_obj = self.pool.get('mail.followers')
1804         new_followers = dict()
1805
1806         # fetch auto_follow_fields: res.users relation fields whose changes are tracked for subscription
1807         user_field_lst = self._message_get_auto_subscribe_fields(cr, uid, updated_fields, context=context)
1808
1809         # fetch header subtypes
1810         header_subtype_ids = subtype_obj.search(cr, uid, ['|', ('res_model', '=', False), ('parent_id.res_model', '=', self._name)], context=context)
1811         subtypes = subtype_obj.browse(cr, uid, header_subtype_ids, context=context)
1812
1813         # if no change in tracked field or no change in tracked relational field: quit
1814         relation_fields = set([subtype.relation_field for subtype in subtypes if subtype.relation_field is not False])
1815         if not any(relation in updated_fields for relation in relation_fields) and not user_field_lst:
1816             return True
1817
1818         # legacy behavior: if values is not given, compute the values by browsing
1819         # @TDENOTE: remove me in 8.0
1820         if values is None:
1821             record = self.browse(cr, uid, ids[0], context=context)
1822             for updated_field in updated_fields:
1823                 field_value = getattr(record, updated_field)
1824                 if isinstance(field_value, BaseModel):
1825                     field_value = field_value.id
1826                 values[updated_field] = field_value
1827
1828         # find followers of headers, update structure for new followers
1829         headers = set()
1830         for subtype in subtypes:
1831             if subtype.relation_field and values.get(subtype.relation_field):
1832                 headers.add((subtype.res_model, values.get(subtype.relation_field)))
1833         if headers:
1834             header_domain = ['|'] * (len(headers) - 1)
1835             for header in headers:
1836                 header_domain += ['&', ('res_model', '=', header[0]), ('res_id', '=', header[1])]
1837             header_follower_ids = follower_obj.search(
1838                 cr, SUPERUSER_ID,
1839                 header_domain,
1840                 context=context
1841             )
1842             for header_follower in follower_obj.browse(cr, SUPERUSER_ID, header_follower_ids, context=context):
1843                 for subtype in header_follower.subtype_ids:
1844                     if subtype.parent_id and subtype.parent_id.res_model == self._name:
1845                         new_followers.setdefault(header_follower.partner_id.id, set()).add(subtype.parent_id.id)
1846                     elif subtype.res_model is False:
1847                         new_followers.setdefault(header_follower.partner_id.id, set()).add(subtype.id)
1848
1849         # add followers coming from res.users relational fields that are tracked
1850         user_ids = [values[name] for name in user_field_lst if values.get(name)]
1851         user_pids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, SUPERUSER_ID, user_ids, context=context)]
1852         for partner_id in user_pids:
1853             new_followers.setdefault(partner_id, None)
1854
1855         for pid, subtypes in new_followers.items():
1856             subtypes = list(subtypes) if subtypes is not None else None
1857             self.message_subscribe(cr, uid, ids, [pid], subtypes, context=context)
1858
1859         # find first email message, set it as unread for auto_subscribe fields for them to have a notification
1860         if user_pids:
1861             for record_id in ids:
1862                 message_obj = self.pool.get('mail.message')
1863                 msg_ids = message_obj.search(cr, SUPERUSER_ID, [
1864                     ('model', '=', self._name),
1865                     ('res_id', '=', record_id),
1866                     ('type', '=', 'email')], limit=1, context=context)
1867                 if not msg_ids:
1868                     msg_ids = message_obj.search(cr, SUPERUSER_ID, [
1869                         ('model', '=', self._name),
1870                         ('res_id', '=', record_id)], limit=1, context=context)
1871                 if msg_ids:
1872                     self.pool.get('mail.notification')._notify(cr, uid, msg_ids[0], partners_to_notify=user_pids, context=context)
1873
1874         return True
1875
1876     #------------------------------------------------------
1877     # Thread state
1878     #------------------------------------------------------
1879
1880     def message_mark_as_unread(self, cr, uid, ids, context=None):
1881         """ Set as unread. """
1882         partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1883         cr.execute('''
1884             UPDATE mail_notification SET
1885                 is_read=false
1886             WHERE
1887                 message_id IN (SELECT id from mail_message where res_id=any(%s) and model=%s limit 1) and
1888                 partner_id = %s
1889         ''', (ids, self._name, partner_id))
1890         self.pool.get('mail.notification').invalidate_cache(cr, uid, ['is_read'], context=context)
1891         return True
1892
1893     def message_mark_as_read(self, cr, uid, ids, context=None):
1894         """ Set as read. """
1895         partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1896         cr.execute('''
1897             UPDATE mail_notification SET
1898                 is_read=true
1899             WHERE
1900                 message_id IN (SELECT id FROM mail_message WHERE res_id=ANY(%s) AND model=%s) AND
1901                 partner_id = %s
1902         ''', (ids, self._name, partner_id))
1903         self.pool.get('mail.notification').invalidate_cache(cr, uid, ['is_read'], context=context)
1904         return True
1905
1906     #------------------------------------------------------
1907     # Thread suggestion
1908     #------------------------------------------------------
1909
1910     def get_suggested_thread(self, cr, uid, removed_suggested_threads=None, context=None):
1911         """Return a list of suggested threads, sorted by the numbers of followers"""
1912         if context is None:
1913             context = {}
1914
1915         # TDE HACK: originally by MAT from portal/mail_mail.py but not working until the inheritance graph bug is not solved in trunk
1916         # TDE FIXME: relocate in portal when it won't be necessary to reload the hr.employee model in an additional bridge module
1917         if self.pool['res.groups']._all_columns.get('is_portal'):
1918             user = self.pool.get('res.users').browse(cr, SUPERUSER_ID, uid, context=context)
1919             if any(group.is_portal for group in user.groups_id):
1920                 return []
1921
1922         threads = []
1923         if removed_suggested_threads is None:
1924             removed_suggested_threads = []
1925
1926         thread_ids = self.search(cr, uid, [('id', 'not in', removed_suggested_threads), ('message_is_follower', '=', False)], context=context)
1927         for thread in self.browse(cr, uid, thread_ids, context=context):
1928             data = {
1929                 'id': thread.id,
1930                 'popularity': len(thread.message_follower_ids),
1931                 'name': thread.name,
1932                 'image_small': thread.image_small
1933             }
1934             threads.append(data)
1935         return sorted(threads, key=lambda x: (x['popularity'], x['id']), reverse=True)[:3]
1936
1937     def message_change_thread(self, cr, uid, id, new_res_id, new_model, context=None):
1938         """
1939         Transfert the list of the mail thread messages from an model to another
1940
1941         :param id : the old res_id of the mail.message
1942         :param new_res_id : the new res_id of the mail.message
1943         :param new_model : the name of the new model of the mail.message
1944
1945         Example :   self.pool.get("crm.lead").message_change_thread(self, cr, uid, 2, 4, "project.issue", context) 
1946                     will transfert thread of the lead (id=2) to the issue (id=4)
1947         """
1948
1949         # get the sbtype id of the comment Message
1950         subtype_res_id = self.pool.get('ir.model.data').xmlid_to_res_id(cr, uid, 'mail.mt_comment', raise_if_not_found=True)
1951         
1952         # get the ids of the comment and none-comment of the thread
1953         message_obj = self.pool.get('mail.message')
1954         msg_ids_comment = message_obj.search(cr, uid, [
1955                     ('model', '=', self._name),
1956                     ('res_id', '=', id),
1957                     ('subtype_id', '=', subtype_res_id)], context=context)
1958         msg_ids_not_comment = message_obj.search(cr, uid, [
1959                     ('model', '=', self._name),
1960                     ('res_id', '=', id),
1961                     ('subtype_id', '!=', subtype_res_id)], context=context)
1962         
1963         # update the messages
1964         message_obj.write(cr, uid, msg_ids_comment, {"res_id" : new_res_id, "model" : new_model}, context=context)
1965         message_obj.write(cr, uid, msg_ids_not_comment, {"res_id" : new_res_id, "model" : new_model, "subtype_id" : None}, context=context)
1966         
1967         return True