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