[FIX] mail: set the parent_id to the first message of type email
[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 import datetime
24 import dateutil
25 import email
26 import logging
27 import pytz
28 import re
29 import socket
30 import time
31 import xmlrpclib
32 from email.message import Message
33
34 from openerp import tools
35 from openerp import SUPERUSER_ID
36 from openerp.addons.mail.mail_message import decode
37 from openerp.osv import fields, osv, orm
38 from openerp.osv.orm import browse_record, browse_null
39 from openerp.tools.safe_eval import safe_eval as eval
40 from openerp.tools.translate import _
41
42 _logger = logging.getLogger(__name__)
43
44
45 mail_header_msgid_re = re.compile('<[^<>]+>')
46
47 def decode_header(message, header, separator=' '):
48     return separator.join(map(decode, filter(None, message.get_all(header, []))))
49
50
51 class mail_thread(osv.AbstractModel):
52     ''' mail_thread model is meant to be inherited by any model that needs to
53         act as a discussion topic on which messages can be attached. Public
54         methods are prefixed with ``message_`` in order to avoid name
55         collisions with methods of the models that will inherit from this class.
56
57         ``mail.thread`` defines fields used to handle and display the
58         communication history. ``mail.thread`` also manages followers of
59         inheriting classes. All features and expected behavior are managed
60         by mail.thread. Widgets has been designed for the 7.0 and following
61         versions of OpenERP.
62
63         Inheriting classes are not required to implement any method, as the
64         default implementation will work for any model. However it is common
65         to override at least the ``message_new`` and ``message_update``
66         methods (calling ``super``) to add model-specific behavior at
67         creation and update of a thread when processing incoming emails.
68
69         Options:
70             - _mail_flat_thread: if set to True, all messages without parent_id
71                 are automatically attached to the first message posted on the
72                 ressource. If set to False, the display of Chatter is done using
73                 threads, and no parent_id is automatically set.
74     '''
75     _name = 'mail.thread'
76     _description = 'Email Thread'
77     _mail_flat_thread = True
78
79     # Automatic logging system if mail installed
80     # _track = {
81     #   'field': {
82     #       'module.subtype_xml': lambda self, cr, uid, obj, context=None: obj[state] == done,
83     #       'module.subtype_xml2': lambda self, cr, uid, obj, context=None: obj[state] != done,
84     #   },
85     #   'field2': {
86     #       ...
87     #   },
88     # }
89     # where
90     #   :param string field: field name
91     #   :param module.subtype_xml: xml_id of a mail.message.subtype (i.e. mail.mt_comment)
92     #   :param obj: is a browse_record
93     #   :param function lambda: returns whether the tracking should record using this subtype
94     _track = {}
95
96     def _get_message_data(self, cr, uid, ids, name, args, context=None):
97         """ Computes:
98             - message_unread: has uid unread message for the document
99             - message_summary: html snippet summarizing the Chatter for kanban views """
100         res = dict((id, dict(message_unread=False, message_unread_count=0, message_summary=' ')) for id in ids)
101         user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
102
103         # search for unread messages, directly in SQL to improve performances
104         cr.execute("""  SELECT m.res_id FROM mail_message m
105                         RIGHT JOIN mail_notification n
106                         ON (n.message_id = m.id AND n.partner_id = %s AND (n.read = False or n.read IS NULL))
107                         WHERE m.model = %s AND m.res_id in %s""",
108                     (user_pid, self._name, tuple(ids),))
109         for result in cr.fetchall():
110             res[result[0]]['message_unread'] = True
111             res[result[0]]['message_unread_count'] += 1
112
113         for id in ids:
114             if res[id]['message_unread_count']:
115                 title = res[id]['message_unread_count'] > 1 and _("You have %d unread messages") % res[id]['message_unread_count'] or _("You have one unread message")
116                 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"))
117             res[id].pop('message_unread_count', None)
118         return res
119
120     def _get_subscription_data(self, cr, uid, ids, name, args, context=None):
121         """ Computes:
122             - message_subtype_data: data about document subtypes: which are
123                 available, which are followed if any """
124         res = dict((id, dict(message_subtype_data='')) for id in ids)
125         user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
126
127         # find current model subtypes, add them to a dictionary
128         subtype_obj = self.pool.get('mail.message.subtype')
129         subtype_ids = subtype_obj.search(cr, uid, ['|', ('res_model', '=', self._name), ('res_model', '=', False)], context=context)
130         subtype_dict = dict((subtype.name, dict(default=subtype.default, followed=False, id=subtype.id)) for subtype in subtype_obj.browse(cr, uid, subtype_ids, context=context))
131         for id in ids:
132             res[id]['message_subtype_data'] = subtype_dict.copy()
133
134         # find the document followers, update the data
135         fol_obj = self.pool.get('mail.followers')
136         fol_ids = fol_obj.search(cr, uid, [
137             ('partner_id', '=', user_pid),
138             ('res_id', 'in', ids),
139             ('res_model', '=', self._name),
140         ], context=context)
141         for fol in fol_obj.browse(cr, uid, fol_ids, context=context):
142             thread_subtype_dict = res[fol.res_id]['message_subtype_data']
143             for subtype in fol.subtype_ids:
144                 thread_subtype_dict[subtype.name]['followed'] = True
145             res[fol.res_id]['message_subtype_data'] = thread_subtype_dict
146
147         return res
148
149     def _search_message_unread(self, cr, uid, obj=None, name=None, domain=None, context=None):
150         return [('message_ids.to_read', '=', True)]
151
152     def _get_followers(self, cr, uid, ids, name, arg, context=None):
153         fol_obj = self.pool.get('mail.followers')
154         fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids)])
155         res = dict((id, dict(message_follower_ids=[], message_is_follower=False)) for id in ids)
156         user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
157         for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids):
158             res[fol.res_id]['message_follower_ids'].append(fol.partner_id.id)
159             if fol.partner_id.id == user_pid:
160                 res[fol.res_id]['message_is_follower'] = True
161         return res
162
163     def _set_followers(self, cr, uid, id, name, value, arg, context=None):
164         if not value:
165             return
166         partner_obj = self.pool.get('res.partner')
167         fol_obj = self.pool.get('mail.followers')
168
169         # read the old set of followers, and determine the new set of followers
170         fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', '=', id)])
171         old = set(fol.partner_id.id for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids))
172         new = set(old)
173
174         for command in value or []:
175             if isinstance(command, (int, long)):
176                 new.add(command)
177             elif command[0] == 0:
178                 new.add(partner_obj.create(cr, uid, command[2], context=context))
179             elif command[0] == 1:
180                 partner_obj.write(cr, uid, [command[1]], command[2], context=context)
181                 new.add(command[1])
182             elif command[0] == 2:
183                 partner_obj.unlink(cr, uid, [command[1]], context=context)
184                 new.discard(command[1])
185             elif command[0] == 3:
186                 new.discard(command[1])
187             elif command[0] == 4:
188                 new.add(command[1])
189             elif command[0] == 5:
190                 new.clear()
191             elif command[0] == 6:
192                 new = set(command[2])
193
194         # remove partners that are no longer followers
195         self.message_unsubscribe(cr, uid, [id], list(old-new), context=context)
196         # add new followers
197         self.message_subscribe(cr, uid, [id], list(new-old), context=context)
198
199     def _search_followers(self, cr, uid, obj, name, args, context):
200         fol_obj = self.pool.get('mail.followers')
201         res = []
202         for field, operator, value in args:
203             assert field == name
204             fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('partner_id', operator, value)])
205             res_ids = [fol.res_id for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids)]
206             res.append(('id', 'in', res_ids))
207         return res
208
209     _columns = {
210         'message_is_follower': fields.function(_get_followers,
211             type='boolean', string='Is a Follower', multi='_get_followers,'),
212         'message_follower_ids': fields.function(_get_followers, fnct_inv=_set_followers,
213                 fnct_search=_search_followers, type='many2many', priority=-10,
214                 obj='res.partner', string='Followers', multi='_get_followers'),
215         'message_ids': fields.one2many('mail.message', 'res_id',
216             domain=lambda self: [('model', '=', self._name)],
217             auto_join=True,
218             string='Messages',
219             help="Messages and communication history"),
220         'message_unread': fields.function(_get_message_data,
221             fnct_search=_search_message_unread, multi="_get_message_data",
222             type='boolean', string='Unread Messages',
223             help="If checked new messages require your attention."),
224         'message_summary': fields.function(_get_message_data, method=True,
225             type='text', string='Summary', multi="_get_message_data",
226             help="Holds the Chatter summary (number of messages, ...). "\
227                  "This summary is directly in html format in order to "\
228                  "be inserted in kanban views."),
229     }
230
231     #------------------------------------------------------
232     # CRUD overrides for automatic subscription and logging
233     #------------------------------------------------------
234
235     def create(self, cr, uid, values, context=None):
236         """ Chatter override :
237             - subscribe uid
238             - subscribe followers of parent
239             - log a creation message
240         """
241         if context is None:
242             context = {}
243
244         # subscribe uid unless asked not to
245         if not context.get('mail_create_nosubscribe'):
246             pid = self.pool['res.users'].browse(cr, SUPERUSER_ID, uid).partner_id.id
247             message_follower_ids = values.get('message_follower_ids') or []  # webclient can send None or False
248             message_follower_ids.append([4, pid])
249             values['message_follower_ids'] = message_follower_ids
250             # add operation to ignore access rule checking for subscription
251             context_operation = dict(context, operation='create')
252         else:
253             context_operation = context
254         thread_id = super(mail_thread, self).create(cr, uid, values, context=context_operation)
255
256         # automatic logging unless asked not to (mainly for various testing purpose)
257         if not context.get('mail_create_nolog'):
258             ir_model_pool = self.pool['ir.model']
259             ids = ir_model_pool.search(cr, uid, [('model', '=', self._name)], context=context)
260             name = ir_model_pool.read(cr, uid, ids, ['name'], context=context)[0]['name']
261             self.message_post(cr, uid, thread_id, body=_('%s created') % name, context=context)
262
263         # auto_subscribe: take values and defaults into account
264         create_values = dict(values)
265         for key, val in context.iteritems():
266             if key.startswith('default_'):
267                 create_values[key[8:]] = val
268         self.message_auto_subscribe(cr, uid, [thread_id], create_values.keys(), context=context, values=create_values)
269
270         # track values
271         track_ctx = dict(context)
272         if 'lang' not in track_ctx:
273             track_ctx['lang'] = self.pool.get('res.users').browse(cr, uid, uid, context=context).lang
274         if not context.get('mail_notrack'):
275             tracked_fields = self._get_tracked_fields(cr, uid, values.keys(), context=track_ctx)
276             if tracked_fields:
277                 initial_values = {thread_id: dict((item, False) for item in tracked_fields)}
278                 self.message_track(cr, uid, [thread_id], tracked_fields, initial_values, context=track_ctx)
279         return thread_id
280
281     def write(self, cr, uid, ids, values, context=None):
282         if context is None:
283             context = {}
284         if isinstance(ids, (int, long)):
285             ids = [ids]
286
287         # Track initial values of tracked fields
288         track_ctx = dict(context)
289         if 'lang' not in track_ctx:
290             track_ctx['lang'] = self.pool.get('res.users').browse(cr, uid, uid, context=context).lang
291         tracked_fields = self._get_tracked_fields(cr, uid, values.keys(), context=track_ctx)
292         if tracked_fields:
293             initial = self.read(cr, uid, ids, tracked_fields.keys(), context=track_ctx)
294             initial_values = dict((item['id'], item) for item in initial)
295
296         # Perform write, update followers
297         result = super(mail_thread, self).write(cr, uid, ids, values, context=context)
298         self.message_auto_subscribe(cr, uid, ids, values.keys(), context=context, values=values)
299
300         if not context.get('mail_notrack'):
301             # Perform the tracking
302             tracked_fields = self._get_tracked_fields(cr, uid, values.keys(), context=track_ctx)
303         else:
304             tracked_fields = None
305         if tracked_fields:
306             self.message_track(cr, uid, ids, tracked_fields, initial_values, context=track_ctx)
307         return result
308
309     def unlink(self, cr, uid, ids, context=None):
310         """ Override unlink to delete messages and followers. This cannot be
311             cascaded, because link is done through (res_model, res_id). """
312         msg_obj = self.pool.get('mail.message')
313         fol_obj = self.pool.get('mail.followers')
314         # delete messages and notifications
315         msg_ids = msg_obj.search(cr, uid, [('model', '=', self._name), ('res_id', 'in', ids)], context=context)
316         msg_obj.unlink(cr, uid, msg_ids, context=context)
317         # delete
318         res = super(mail_thread, self).unlink(cr, uid, ids, context=context)
319         # delete followers
320         fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids)], context=context)
321         fol_obj.unlink(cr, SUPERUSER_ID, fol_ids, context=context)
322         return res
323
324     def copy_data(self, cr, uid, id, default=None, context=None):
325         # avoid tracking multiple temporary changes during copy
326         context = dict(context or {}, mail_notrack=True)
327
328         default = default or {}
329         default['message_ids'] = []
330         default['message_follower_ids'] = []
331         return super(mail_thread, self).copy_data(cr, uid, id, default=default, context=context)
332
333     #------------------------------------------------------
334     # Automatically log tracked fields
335     #------------------------------------------------------
336
337     def _get_tracked_fields(self, cr, uid, updated_fields, context=None):
338         """ Return a structure of tracked fields for the current model.
339             :param list updated_fields: modified field names
340             :return list: a list of (field_name, column_info obj), containing
341                 always tracked fields and modified on_change fields
342         """
343         lst = []
344         for name, column_info in self._all_columns.items():
345             visibility = getattr(column_info.column, 'track_visibility', False)
346             if visibility == 'always' or (visibility == 'onchange' and name in updated_fields) or name in self._track:
347                 lst.append(name)
348         if not lst:
349             return lst
350         return self.fields_get(cr, uid, lst, context=context)
351
352     def message_track(self, cr, uid, ids, tracked_fields, initial_values, context=None):
353
354         def convert_for_display(value, col_info):
355             if not value and col_info['type'] == 'boolean':
356                 return 'False'
357             if not value:
358                 return ''
359             if col_info['type'] == 'many2one':
360                 return value[1]
361             if col_info['type'] == 'selection':
362                 return dict(col_info['selection'])[value]
363             return value
364
365         def format_message(message_description, tracked_values):
366             message = ''
367             if message_description:
368                 message = '<span>%s</span>' % message_description
369             for name, change in tracked_values.items():
370                 message += '<div> &nbsp; &nbsp; &bull; <b>%s</b>: ' % change.get('col_info')
371                 if change.get('old_value'):
372                     message += '%s &rarr; ' % change.get('old_value')
373                 message += '%s</div>' % change.get('new_value')
374             return message
375
376         if not tracked_fields:
377             return True
378
379         for record in self.read(cr, uid, ids, tracked_fields.keys(), context=context):
380             initial = initial_values[record['id']]
381             changes = []
382             tracked_values = {}
383
384             # generate tracked_values data structure: {'col_name': {col_info, new_value, old_value}}
385             for col_name, col_info in tracked_fields.items():
386                 if record[col_name] == initial[col_name] and getattr(self._all_columns[col_name].column, 'track_visibility', None) == 'always':
387                     tracked_values[col_name] = dict(col_info=col_info['string'],
388                                                         new_value=convert_for_display(record[col_name], col_info))
389                 elif record[col_name] != initial[col_name]:
390                     if getattr(self._all_columns[col_name].column, 'track_visibility', None) in ['always', 'onchange']:
391                         tracked_values[col_name] = dict(col_info=col_info['string'],
392                                                             old_value=convert_for_display(initial[col_name], col_info),
393                                                             new_value=convert_for_display(record[col_name], col_info))
394                     if col_name in tracked_fields:
395                         changes.append(col_name)
396             if not changes:
397                 continue
398
399             # find subtypes and post messages or log if no subtype found
400             subtypes = []
401             for field, track_info in self._track.items():
402                 if field not in changes:
403                     continue
404                 for subtype, method in track_info.items():
405                     if method(self, cr, uid, record, context):
406                         subtypes.append(subtype)
407
408             posted = False
409             for subtype in subtypes:
410                 try:
411                     subtype_rec = self.pool.get('ir.model.data').get_object(cr, uid, subtype.split('.')[0], subtype.split('.')[1], context=context)
412                 except ValueError, e:
413                     _logger.debug('subtype %s not found, giving error "%s"' % (subtype, e))
414                     continue
415                 message = format_message(subtype_rec.description if subtype_rec.description else subtype_rec.name, tracked_values)
416                 self.message_post(cr, uid, record['id'], body=message, subtype=subtype, context=context)
417                 posted = True
418             if not posted:
419                 message = format_message('', tracked_values)
420                 self.message_post(cr, uid, record['id'], body=message, context=context)
421         return True
422
423     #------------------------------------------------------
424     # mail.message wrappers and tools
425     #------------------------------------------------------
426
427     def _needaction_domain_get(self, cr, uid, context=None):
428         if self._needaction:
429             return [('message_unread', '=', True)]
430         return []
431
432     def _garbage_collect_attachments(self, cr, uid, context=None):
433         """ Garbage collect lost mail attachments. Those are attachments
434             - linked to res_model 'mail.compose.message', the composer wizard
435             - with res_id 0, because they were created outside of an existing
436                 wizard (typically user input through Chatter or reports
437                 created on-the-fly by the templates)
438             - unused since at least one day (create_date and write_date)
439         """
440         limit_date = datetime.datetime.utcnow() - datetime.timedelta(days=1)
441         limit_date_str = datetime.datetime.strftime(limit_date, tools.DEFAULT_SERVER_DATETIME_FORMAT)
442         ir_attachment_obj = self.pool.get('ir.attachment')
443         attach_ids = ir_attachment_obj.search(cr, uid, [
444                             ('res_model', '=', 'mail.compose.message'),
445                             ('res_id', '=', 0),
446                             ('create_date', '<', limit_date_str),
447                             ('write_date', '<', limit_date_str),
448                             ], context=context)
449         ir_attachment_obj.unlink(cr, uid, attach_ids, context=context)
450         return True
451
452     #------------------------------------------------------
453     # Email specific
454     #------------------------------------------------------
455
456     def message_get_reply_to(self, cr, uid, ids, context=None):
457         if not self._inherits.get('mail.alias'):
458             return [False for id in ids]
459         return ["%s@%s" % (record['alias_name'], record['alias_domain'])
460                     if record.get('alias_domain') and record.get('alias_name')
461                     else False
462                     for record in self.read(cr, SUPERUSER_ID, ids, ['alias_name', 'alias_domain'], context=context)]
463
464     #------------------------------------------------------
465     # Mail gateway
466     #------------------------------------------------------
467
468     def message_capable_models(self, cr, uid, context=None):
469         """ Used by the plugin addon, based for plugin_outlook and others. """
470         ret_dict = {}
471         for model_name in self.pool.obj_list():
472             model = self.pool.get(model_name)
473             if hasattr(model, "message_process") and hasattr(model, "message_post"):
474                 ret_dict[model_name] = model._description
475         return ret_dict
476
477     def _message_find_partners(self, cr, uid, message, header_fields=['From'], context=None):
478         """ Find partners related to some header fields of the message.
479
480             TDE TODO: merge me with other partner finding methods in 8.0 """
481         partner_obj = self.pool.get('res.partner')
482         partner_ids = []
483         s = ', '.join([decode(message.get(h)) for h in header_fields if message.get(h)])
484         for email_address in tools.email_split(s):
485             related_partners = partner_obj.search(cr, uid, [('email', 'ilike', email_address), ('user_ids', '!=', False)], limit=1, context=context)
486             if not related_partners:
487                 related_partners = partner_obj.search(cr, uid, [('email', 'ilike', email_address)], limit=1, context=context)
488             partner_ids += related_partners
489         return partner_ids
490
491     def _message_find_user_id(self, cr, uid, message, context=None):
492         """ TDE TODO: check and maybe merge me with other user finding methods in 8.0 """
493         from_local_part = tools.email_split(decode(message.get('From')))[0]
494         # FP Note: canonification required, the minimu: .lower()
495         user_ids = self.pool.get('res.users').search(cr, uid, ['|',
496             ('login', '=', from_local_part),
497             ('email', '=', from_local_part)], context=context)
498         return user_ids[0] if user_ids else uid
499
500     def message_route(self, cr, uid, message, model=None, thread_id=None,
501                       custom_values=None, context=None):
502         """Attempt to figure out the correct target model, thread_id,
503         custom_values and user_id to use for an incoming message.
504         Multiple values may be returned, if a message had multiple
505         recipients matching existing mail.aliases, for example.
506
507         The following heuristics are used, in this order:
508              1. If the message replies to an existing thread_id, and
509                 properly contains the thread model in the 'In-Reply-To'
510                 header, use this model/thread_id pair, and ignore
511                 custom_value (not needed as no creation will take place)
512              2. Look for a mail.alias entry matching the message
513                 recipient, and use the corresponding model, thread_id,
514                 custom_values and user_id.
515              3. Fallback to the ``model``, ``thread_id`` and ``custom_values``
516                 provided.
517              4. If all the above fails, raise an exception.
518
519            :param string message: an email.message instance
520            :param string model: the fallback model to use if the message
521                does not match any of the currently configured mail aliases
522                (may be None if a matching alias is supposed to be present)
523            :type dict custom_values: optional dictionary of default field values
524                 to pass to ``message_new`` if a new record needs to be created.
525                 Ignored if the thread record already exists, and also if a
526                 matching mail.alias was found (aliases define their own defaults)
527            :param int thread_id: optional ID of the record/thread from ``model``
528                to which this mail should be attached. Only used if the message
529                does not reply to an existing thread and does not match any mail alias.
530            :return: list of [model, thread_id, custom_values, user_id]
531
532         :raises: ValueError, TypeError
533         """
534         if not isinstance(message, Message):
535             raise TypeError('message must be an email.message.Message at this point')
536         message_id = message.get('Message-Id')
537         email_from = decode_header(message, 'From')
538         email_to = decode_header(message, 'To')
539         references = decode_header(message, 'References')
540         in_reply_to = decode_header(message, 'In-Reply-To')
541
542         # 1. Verify if this is a reply to an existing thread
543         thread_references = references or in_reply_to
544         ref_match = thread_references and tools.reference_re.search(thread_references)
545         if ref_match:
546             reply_thread_id = int(ref_match.group(1))
547             reply_model = ref_match.group(2) or model
548             reply_hostname = ref_match.group(3)
549             local_hostname = socket.gethostname()
550             # do not match forwarded emails from another OpenERP system (thread_id collision!)
551             if local_hostname == reply_hostname:
552                 thread_id, model = reply_thread_id, reply_model
553                 model_pool = self.pool.get(model)
554                 if thread_id and model and model_pool and model_pool.exists(cr, uid, thread_id) \
555                     and hasattr(model_pool, 'message_update'):
556                     _logger.info('Routing mail from %s to %s with Message-Id %s: direct reply to model: %s, thread_id: %s, custom_values: %s, uid: %s',
557                                     email_from, email_to, message_id, model, thread_id, custom_values, uid)
558                     return [(model, thread_id, custom_values, uid)]
559
560         # Verify whether this is a reply to a private message
561         if in_reply_to:
562             message_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', '=', in_reply_to)], limit=1, context=context)
563             if message_ids:
564                 message = self.pool.get('mail.message').browse(cr, uid, message_ids[0], context=context)
565                 _logger.info('Routing mail from %s to %s with Message-Id %s: direct reply to a private message: %s, custom_values: %s, uid: %s',
566                                 email_from, email_to, message_id, message.id, custom_values, uid)
567                 return [(message.model, message.res_id, custom_values, uid)]
568
569         # 2. Look for a matching mail.alias entry
570         # Delivered-To is a safe bet in most modern MTAs, but we have to fallback on To + Cc values
571         # for all the odd MTAs out there, as there is no standard header for the envelope's `rcpt_to` value.
572         rcpt_tos = \
573              ','.join([decode_header(message, 'Delivered-To'),
574                        decode_header(message, 'To'),
575                        decode_header(message, 'Cc'),
576                        decode_header(message, 'Resent-To'),
577                        decode_header(message, 'Resent-Cc')])
578         local_parts = [e.split('@')[0] for e in tools.email_split(rcpt_tos)]
579         if local_parts:
580             mail_alias = self.pool.get('mail.alias')
581             alias_ids = mail_alias.search(cr, uid, [('alias_name', 'in', local_parts)])
582             if alias_ids:
583                 routes = []
584                 for alias in mail_alias.browse(cr, uid, alias_ids, context=context):
585                     user_id = alias.alias_user_id.id
586                     if not user_id:
587                         # TDE note: this could cause crashes, because no clue that the user
588                         # that send the email has the right to create or modify a new document
589                         # Fallback on user_id = uid
590                         # Note: recognized partners will be added as followers anyway
591                         # user_id = self._message_find_user_id(cr, uid, message, context=context)
592                         user_id = uid
593                         _logger.info('No matching user_id for the alias %s', alias.alias_name)
594                     routes.append((alias.alias_model_id.model, alias.alias_force_thread_id, \
595                                    eval(alias.alias_defaults), user_id))
596                 _logger.info('Routing mail from %s to %s with Message-Id %s: direct alias match: %r',
597                                 email_from, email_to, message_id, routes)
598                 return routes
599
600         # 3. Fallback to the provided parameters, if they work
601         model_pool = self.pool.get(model)
602         if not thread_id:
603             # Legacy: fallback to matching [ID] in the Subject
604             match = tools.res_re.search(decode_header(message, 'Subject'))
605             thread_id = match and match.group(1)
606             # Convert into int (bug spotted in 7.0 because of str)
607             try:
608                 thread_id = int(thread_id)
609             except:
610                 thread_id = False
611         if not (thread_id and hasattr(model_pool, 'message_update') or hasattr(model_pool, 'message_new')):
612             raise ValueError(
613                 'No possible route found for incoming message from %s to %s (Message-Id %s:). '
614                 'Create an appropriate mail.alias or force the destination model.' %
615                 (email_from, email_to, message_id)
616             )
617         if thread_id and not model_pool.exists(cr, uid, thread_id):
618             _logger.warning('Received mail reply to missing document %s! Ignoring and creating new document instead for Message-Id %s',
619                                 thread_id, message_id)
620             thread_id = None
621         _logger.info('Routing mail from %s to %s with Message-Id %s: fallback to model:%s, thread_id:%s, custom_values:%s, uid:%s',
622                         email_from, email_to, message_id, model, thread_id, custom_values, uid)
623         return [(model, thread_id, custom_values, uid)]
624
625     def message_process(self, cr, uid, model, message, custom_values=None,
626                         save_original=False, strip_attachments=False,
627                         thread_id=None, context=None):
628         """ Process an incoming RFC2822 email message, relying on
629             ``mail.message.parse()`` for the parsing operation,
630             and ``message_route()`` to figure out the target model.
631
632             Once the target model is known, its ``message_new`` method
633             is called with the new message (if the thread record did not exist)
634             or its ``message_update`` method (if it did).
635
636             There is a special case where the target model is False: a reply
637             to a private message. In this case, we skip the message_new /
638             message_update step, to just post a new message using mail_thread
639             message_post.
640
641            :param string model: the fallback model to use if the message
642                does not match any of the currently configured mail aliases
643                (may be None if a matching alias is supposed to be present)
644            :param message: source of the RFC2822 message
645            :type message: string or xmlrpclib.Binary
646            :type dict custom_values: optional dictionary of field values
647                 to pass to ``message_new`` if a new record needs to be created.
648                 Ignored if the thread record already exists, and also if a
649                 matching mail.alias was found (aliases define their own defaults)
650            :param bool save_original: whether to keep a copy of the original
651                 email source attached to the message after it is imported.
652            :param bool strip_attachments: whether to strip all attachments
653                 before processing the message, in order to save some space.
654            :param int thread_id: optional ID of the record/thread from ``model``
655                to which this mail should be attached. When provided, this
656                overrides the automatic detection based on the message
657                headers.
658
659         :raises: ValueError, TypeError
660         """
661         if context is None:
662             context = {}
663
664         # extract message bytes - we are forced to pass the message as binary because
665         # we don't know its encoding until we parse its headers and hence can't
666         # convert it to utf-8 for transport between the mailgate script and here.
667         if isinstance(message, xmlrpclib.Binary):
668             message = str(message.data)
669         # Warning: message_from_string doesn't always work correctly on unicode,
670         # we must use utf-8 strings here :-(
671         if isinstance(message, unicode):
672             message = message.encode('utf-8')
673         msg_txt = email.message_from_string(message)
674
675         # parse the message, verify we are not in a loop by checking message_id is not duplicated
676         msg = self.message_parse(cr, uid, msg_txt, save_original=save_original, context=context)
677         if strip_attachments:
678             msg.pop('attachments', None)
679         if msg.get('message_id'):   # should always be True as message_parse generate one if missing
680             existing_msg_ids = self.pool.get('mail.message').search(cr, SUPERUSER_ID, [
681                                                                 ('message_id', '=', msg.get('message_id')),
682                                                                 ], context=context)
683             if existing_msg_ids:
684                 _logger.info('Ignored mail from %s to %s with Message-Id %s:: found duplicated Message-Id during processing',
685                                 msg.get('from'), msg.get('to'), msg.get('message_id'))
686                 return False
687
688         # find possible routes for the message
689         routes = self.message_route(cr, uid, msg_txt, model,
690                                     thread_id, custom_values,
691                                     context=context)
692
693         # postpone setting msg.partner_ids after message_post, to avoid double notifications
694         partner_ids = msg.pop('partner_ids', [])
695
696         thread_id = False
697         for model, thread_id, custom_values, user_id in routes:
698             if self._name == 'mail.thread':
699                 context.update({'thread_model': model})
700             if model:
701                 model_pool = self.pool.get(model)
702                 if not (thread_id and hasattr(model_pool, 'message_update') or hasattr(model_pool, 'message_new')):
703                     raise ValueError(
704                         "Undeliverable mail with Message-Id %s, model %s does not accept incoming emails" %
705                         (msg['message_id'], model)
706                     )
707
708                 # disabled subscriptions during message_new/update to avoid having the system user running the
709                 # email gateway become a follower of all inbound messages
710                 nosub_ctx = dict(context, mail_create_nosubscribe=True)
711                 if thread_id and hasattr(model_pool, 'message_update'):
712                     model_pool.message_update(cr, user_id, [thread_id], msg, context=nosub_ctx)
713                 else:
714                     nosub_ctx = dict(nosub_ctx, mail_create_nolog=True)
715                     thread_id = model_pool.message_new(cr, user_id, msg, custom_values, context=nosub_ctx)
716             else:
717                 if thread_id:
718                     raise ValueError("Posting a message without model should be with a null res_id, to create a private message.")
719                 model_pool = self.pool.get('mail.thread')
720             new_msg_id = model_pool.message_post(cr, uid, [thread_id], context=context, subtype='mail.mt_comment', **msg)
721
722             if partner_ids:
723                 # postponed after message_post, because this is an external message and we don't want to create
724                 # duplicate emails due to notifications
725                 self.pool.get('mail.message').write(cr, uid, [new_msg_id], {'partner_ids': partner_ids}, context=context)
726
727         return thread_id
728
729     def message_new(self, cr, uid, msg_dict, custom_values=None, context=None):
730         """Called by ``message_process`` when a new message is received
731            for a given thread model, if the message did not belong to
732            an existing thread.
733            The default behavior is to create a new record of the corresponding
734            model (based on some very basic info extracted from the message).
735            Additional behavior may be implemented by overriding this method.
736
737            :param dict msg_dict: a map containing the email details and
738                                  attachments. See ``message_process`` and
739                                 ``mail.message.parse`` for details.
740            :param dict custom_values: optional dictionary of additional
741                                       field values to pass to create()
742                                       when creating the new thread record.
743                                       Be careful, these values may override
744                                       any other values coming from the message.
745            :param dict context: if a ``thread_model`` value is present
746                                 in the context, its value will be used
747                                 to determine the model of the record
748                                 to create (instead of the current model).
749            :rtype: int
750            :return: the id of the newly created thread object
751         """
752         if context is None:
753             context = {}
754         data = {}
755         if isinstance(custom_values, dict):
756             data = custom_values.copy()
757         model = context.get('thread_model') or self._name
758         model_pool = self.pool.get(model)
759         fields = model_pool.fields_get(cr, uid, context=context)
760         if 'name' in fields and not data.get('name'):
761             data['name'] = msg_dict.get('subject', '')
762         res_id = model_pool.create(cr, uid, data, context=context)
763         return res_id
764
765     def message_update(self, cr, uid, ids, msg_dict, update_vals=None, context=None):
766         """Called by ``message_process`` when a new message is received
767            for an existing thread. The default behavior is to update the record
768            with update_vals taken from the incoming email.
769            Additional behavior may be implemented by overriding this
770            method.
771            :param dict msg_dict: a map containing the email details and
772                                attachments. See ``message_process`` and
773                                ``mail.message.parse()`` for details.
774            :param dict update_vals: a dict containing values to update records
775                               given their ids; if the dict is None or is
776                               void, no write operation is performed.
777         """
778         if update_vals:
779             self.write(cr, uid, ids, update_vals, context=context)
780         return True
781
782     def _message_extract_payload(self, message, save_original=False):
783         """Extract body as HTML and attachments from the mail message"""
784         attachments = []
785         body = u''
786         if save_original:
787             attachments.append(('original_email.eml', message.as_string()))
788
789         # Be careful, content-type may contain tricky content like in the
790         # following example so test the MIME type with startswith()
791         #
792         # Content-Type: multipart/related;
793         #   boundary="_004_3f1e4da175f349248b8d43cdeb9866f1AMSPR06MB343eurprd06pro_";
794         #   type="text/html"
795         if not message.is_multipart() or message.get('content-type', '').startswith("text/"):
796             encoding = message.get_content_charset()
797             body = message.get_payload(decode=True)
798             body = tools.ustr(body, encoding, errors='replace')
799             if message.get_content_type() == 'text/plain':
800                 # text/plain -> <pre/>
801                 body = tools.append_content_to_html(u'', body, preserve=True)
802         else:
803             alternative = False
804             mixed = False
805             html = u''
806             for part in message.walk():
807                 if part.get_content_type() == 'multipart/alternative':
808                     alternative = True
809                 if part.get_content_type() == 'multipart/mixed':
810                     mixed = True
811                 if part.get_content_maintype() == 'multipart':
812                     continue  # skip container
813                 # part.get_filename returns decoded value if able to decode, coded otherwise.
814                 # original get_filename is not able to decode iso-8859-1 (for instance).
815                 # therefore, iso encoded attachements are not able to be decoded properly with get_filename
816                 # code here partially copy the original get_filename method, but handle more encoding
817                 filename=part.get_param('filename', None, 'content-disposition')
818                 if not filename:
819                     filename=part.get_param('name', None)
820                 if filename:
821                     if isinstance(filename, tuple):
822                         # RFC2231
823                         filename=email.utils.collapse_rfc2231_value(filename).strip()
824                     else:
825                         filename=decode(filename)
826                 encoding = part.get_content_charset()  # None if attachment
827                 # 1) Explicit Attachments -> attachments
828                 if filename or part.get('content-disposition', '').strip().startswith('attachment'):
829                     attachments.append((filename or 'attachment', part.get_payload(decode=True)))
830                     continue
831                 # 2) text/plain -> <pre/>
832                 if part.get_content_type() == 'text/plain' and (not alternative or not body):
833                     body = tools.append_content_to_html(body, tools.ustr(part.get_payload(decode=True),
834                                                                          encoding, errors='replace'), preserve=True)
835                 # 3) text/html -> raw
836                 elif part.get_content_type() == 'text/html':
837                     # mutlipart/alternative have one text and a html part, keep only the second
838                     # mixed allows several html parts, append html content
839                     append_content = not alternative or (html and mixed)
840                     html = tools.ustr(part.get_payload(decode=True), encoding, errors='replace')
841                     if not append_content:
842                         body = html
843                     else:
844                         body = tools.append_content_to_html(body, html, plaintext=False)
845                 # 4) Anything else -> attachment
846                 else:
847                     attachments.append((filename or 'attachment', part.get_payload(decode=True)))
848         return body, attachments
849
850     def message_parse(self, cr, uid, message, save_original=False, context=None):
851         """Parses a string or email.message.Message representing an
852            RFC-2822 email, and returns a generic dict holding the
853            message details.
854
855            :param message: the message to parse
856            :type message: email.message.Message | string | unicode
857            :param bool save_original: whether the returned dict
858                should include an ``original`` attachment containing
859                the source of the message
860            :rtype: dict
861            :return: A dict with the following structure, where each
862                     field may not be present if missing in original
863                     message::
864
865                     { 'message_id': msg_id,
866                       'subject': subject,
867                       'from': from,
868                       'to': to,
869                       'cc': cc,
870                       'body': unified_body,
871                       'attachments': [('file1', 'bytes'),
872                                       ('file2', 'bytes')}
873                     }
874         """
875         msg_dict = {
876             'type': 'email',
877             'author_id': False,
878         }
879         if not isinstance(message, Message):
880             if isinstance(message, unicode):
881                 # Warning: message_from_string doesn't always work correctly on unicode,
882                 # we must use utf-8 strings here :-(
883                 message = message.encode('utf-8')
884             message = email.message_from_string(message)
885
886         message_id = message['message-id']
887         if not message_id:
888             # Very unusual situation, be we should be fault-tolerant here
889             message_id = "<%s@localhost>" % time.time()
890             _logger.debug('Parsing Message without message-id, generating a random one: %s', message_id)
891         msg_dict['message_id'] = message_id
892
893         if message.get('Subject'):
894             msg_dict['subject'] = decode(message.get('Subject'))
895
896         # Envelope fields not stored in mail.message but made available for message_new()
897         msg_dict['from'] = decode(message.get('from'))
898         msg_dict['to'] = decode(message.get('to'))
899         msg_dict['cc'] = decode(message.get('cc'))
900
901         if message.get('From'):
902             author_ids = self._message_find_partners(cr, uid, message, ['From'], context=context)
903             if author_ids:
904                 msg_dict['author_id'] = author_ids[0]
905             msg_dict['email_from'] = decode(message.get('from'))
906         partner_ids = self._message_find_partners(cr, uid, message, ['To', 'Cc'], context=context)
907         msg_dict['partner_ids'] = [(4, partner_id) for partner_id in partner_ids]
908
909         if message.get('Date'):
910             try:
911                 date_hdr = decode(message.get('Date'))
912                 parsed_date = dateutil.parser.parse(date_hdr, fuzzy=True)
913                 if parsed_date.utcoffset() is None:
914                     # naive datetime, so we arbitrarily decide to make it
915                     # UTC, there's no better choice. Should not happen,
916                     # as RFC2822 requires timezone offset in Date headers.
917                     stored_date = parsed_date.replace(tzinfo=pytz.utc)
918                 else:
919                     stored_date = parsed_date.astimezone(tz=pytz.utc)
920             except Exception:
921                 _logger.warning('Failed to parse Date header %r in incoming mail '
922                                 'with message-id %r, assuming current date/time.',
923                                 message.get('Date'), message_id)
924                 stored_date = datetime.datetime.now()
925             msg_dict['date'] = stored_date.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT)
926
927         if message.get('In-Reply-To'):
928             parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', '=', decode(message['In-Reply-To'].strip()))])
929             if parent_ids:
930                 msg_dict['parent_id'] = parent_ids[0]
931
932         if message.get('References') and 'parent_id' not in msg_dict:
933             msg_list =  mail_header_msgid_re.findall(decode(message['References']))
934             parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', 'in', [x.strip() for x in msg_list])])
935             if parent_ids:
936                 msg_dict['parent_id'] = parent_ids[0]
937
938         msg_dict['body'], msg_dict['attachments'] = self._message_extract_payload(message, save_original=save_original)
939         return msg_dict
940
941     #------------------------------------------------------
942     # Note specific
943     #------------------------------------------------------
944
945     def log(self, cr, uid, id, message, secondary=False, context=None):
946         _logger.warning("log() is deprecated. As this module inherit from "\
947                         "mail.thread, the message will be managed by this "\
948                         "module instead of by the res.log mechanism. Please "\
949                         "use mail_thread.message_post() instead of the "\
950                         "now deprecated res.log.")
951         self.message_post(cr, uid, [id], message, context=context)
952
953     def _message_add_suggested_recipient(self, cr, uid, result, obj, partner=None, email=None, reason='', context=None):
954         """ Called by message_get_suggested_recipients, to add a suggested
955             recipient in the result dictionary. The form is :
956                 partner_id, partner_name<partner_email> or partner_name, reason """
957         if email and not partner:
958             # get partner info from email
959             partner_info = self.message_get_partner_info_from_emails(cr, uid, [email], context=context, res_id=obj.id)
960             if partner_info and partner_info[0].get('partner_id'):
961                 partner = self.pool.get('res.partner').browse(cr, SUPERUSER_ID, [partner_info[0]['partner_id']], context=context)[0]
962         if email and email in [val[1] for val in result[obj.id]]:  # already existing email -> skip
963             return result
964         if partner and partner in obj.message_follower_ids:  # recipient already in the followers -> skip
965             return result
966         if partner and partner.id in [val[0] for val in result[obj.id]]:  # already existing partner ID -> skip
967             return result
968         if partner and partner.email:  # complete profile: id, name <email>
969             result[obj.id].append((partner.id, '%s<%s>' % (partner.name, partner.email), reason))
970         elif partner:  # incomplete profile: id, name
971             result[obj.id].append((partner.id, '%s' % (partner.name), reason))
972         else:  # unknown partner, we are probably managing an email address
973             result[obj.id].append((False, email, reason))
974         return result
975
976     def message_get_suggested_recipients(self, cr, uid, ids, context=None):
977         """ Returns suggested recipients for ids. Those are a list of
978             tuple (partner_id, partner_name, reason), to be managed by Chatter. """
979         result = dict.fromkeys(ids, list())
980         if self._all_columns.get('user_id'):
981             for obj in self.browse(cr, SUPERUSER_ID, ids, context=context):  # SUPERUSER because of a read on res.users that would crash otherwise
982                 if not obj.user_id or not obj.user_id.partner_id:
983                     continue
984                 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)
985         return result
986
987     def message_get_partner_info_from_emails(self, cr, uid, emails, link_mail=False, context=None, res_id=None):
988         """ Wrapper with weird order parameter because of 7.0 fix.
989
990             TDE TODO: remove me in 8.0 """
991         return self.message_find_partner_from_emails(cr, uid, res_id, emails, link_mail=link_mail, context=context)
992
993     def message_find_partner_from_emails(self, cr, uid, id, emails, link_mail=False, context=None):
994         """ Convert a list of emails into a list partner_ids and a list
995             new_partner_ids. The return value is non conventional because
996             it is meant to be used by the mail widget.
997
998             :return dict: partner_ids and new_partner_ids
999
1000             TDE TODO: merge me with other partner finding methods in 8.0 """
1001         mail_message_obj = self.pool.get('mail.message')
1002         partner_obj = self.pool.get('res.partner')
1003         result = list()
1004         if id and self._name != 'mail.thread':
1005             obj = self.browse(cr, SUPERUSER_ID, id, context=context)
1006         else:
1007             obj = None
1008         for email in emails:
1009             partner_info = {'full_name': email, 'partner_id': False}
1010             m = re.search(r"((.+?)\s*<)?([^<>]+@[^<>]+)>?", email, re.IGNORECASE | re.DOTALL)
1011             if not m:
1012                 continue
1013             email_address = m.group(3)
1014             # first try: check in document's followers
1015             if obj:
1016                 for follower in obj.message_follower_ids:
1017                     if follower.email == email_address:
1018                         partner_info['partner_id'] = follower.id
1019             # second try: check in partners
1020             if not partner_info.get('partner_id'):
1021                 ids = partner_obj.search(cr, SUPERUSER_ID, [('email', 'ilike', email_address), ('user_ids', '!=', False)], limit=1, context=context)
1022                 if not ids:
1023                     ids = partner_obj.search(cr, SUPERUSER_ID, [('email', 'ilike', email_address)], limit=1, context=context)
1024                 if ids:
1025                     partner_info['partner_id'] = ids[0]
1026             result.append(partner_info)
1027
1028             # link mail with this from mail to the new partner id
1029             if link_mail and partner_info['partner_id']:
1030                 message_ids = mail_message_obj.search(cr, SUPERUSER_ID, [
1031                                     '|',
1032                                     ('email_from', '=', email),
1033                                     ('email_from', 'ilike', '<%s>' % email),
1034                                     ('author_id', '=', False)
1035                                 ], context=context)
1036                 if message_ids:
1037                     mail_message_obj.write(cr, SUPERUSER_ID, message_ids, {'author_id': partner_info['partner_id']}, context=context)
1038         return result
1039
1040     def message_post(self, cr, uid, thread_id, body='', subject=None, type='notification',
1041                         subtype=None, parent_id=False, attachments=None, context=None,
1042                         content_subtype='html', **kwargs):
1043         """ Post a new message in an existing thread, returning the new
1044             mail.message ID.
1045
1046             :param int thread_id: thread ID to post into, or list with one ID;
1047                 if False/0, mail.message model will also be set as False
1048             :param str body: body of the message, usually raw HTML that will
1049                 be sanitized
1050             :param str type: see mail_message.type field
1051             :param str content_subtype:: if plaintext: convert body into html
1052             :param int parent_id: handle reply to a previous message by adding the
1053                 parent partners to the message in case of private discussion
1054             :param tuple(str,str) attachments or list id: list of attachment tuples in the form
1055                 ``(name,content)``, where content is NOT base64 encoded
1056
1057             Extra keyword arguments will be used as default column values for the
1058             new mail.message record. Special cases:
1059                 - attachment_ids: supposed not attached to any document; attach them
1060                     to the related document. Should only be set by Chatter.
1061             :return int: ID of newly created mail.message
1062         """
1063         if context is None:
1064             context = {}
1065         if attachments is None:
1066             attachments = {}
1067         mail_message = self.pool.get('mail.message')
1068         ir_attachment = self.pool.get('ir.attachment')
1069
1070         assert (not thread_id) or \
1071                 isinstance(thread_id, (int, long)) or \
1072                 (isinstance(thread_id, (list, tuple)) and len(thread_id) == 1), \
1073                 "Invalid thread_id; should be 0, False, an ID or a list with one ID"
1074         if isinstance(thread_id, (list, tuple)):
1075             thread_id = thread_id[0]
1076
1077         # if we're processing a message directly coming from the gateway, the destination model was
1078         # set in the context.
1079         model = False
1080         if thread_id:
1081             model = context.get('thread_model', self._name) if self._name == 'mail.thread' else self._name
1082             if model != self._name:
1083                 del context['thread_model']
1084                 return self.pool.get(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)
1085
1086         # 0: Parse email-from, try to find a better author_id based on document's followers for incoming emails
1087         email_from = kwargs.get('email_from')
1088         if email_from and thread_id and type == 'email' and kwargs.get('author_id'):
1089             email_list = tools.email_split(email_from)
1090             doc = self.browse(cr, uid, thread_id, context=context)
1091             if email_list and doc:
1092                 author_ids = self.pool.get('res.partner').search(cr, uid, [
1093                                         ('email', 'ilike', email_list[0]),
1094                                         ('id', 'in', [f.id for f in doc.message_follower_ids])
1095                                     ], limit=1, context=context)
1096                 if author_ids:
1097                     kwargs['author_id'] = author_ids[0]
1098         author_id = kwargs.get('author_id')
1099         if author_id is None:  # keep False values
1100             author_id = self.pool.get('mail.message')._get_default_author(cr, uid, context=context)
1101
1102         # 1: Handle content subtype: if plaintext, converto into HTML
1103         if content_subtype == 'plaintext':
1104             body = tools.plaintext2html(body)
1105
1106         # 2: Private message: add recipients (recipients and author of parent message) - current author
1107         #   + legacy-code management (! we manage only 4 and 6 commands)
1108         partner_ids = set()
1109         kwargs_partner_ids = kwargs.pop('partner_ids', [])
1110         for partner_id in kwargs_partner_ids:
1111             if isinstance(partner_id, (list, tuple)) and partner_id[0] == 4 and len(partner_id) == 2:
1112                 partner_ids.add(partner_id[1])
1113             if isinstance(partner_id, (list, tuple)) and partner_id[0] == 6 and len(partner_id) == 3:
1114                 partner_ids |= set(partner_id[2])
1115             elif isinstance(partner_id, (int, long)):
1116                 partner_ids.add(partner_id)
1117             else:
1118                 pass  # we do not manage anything else
1119         if parent_id and not model:
1120             parent_message = mail_message.browse(cr, uid, parent_id, context=context)
1121             private_followers = set([partner.id for partner in parent_message.partner_ids])
1122             if parent_message.author_id:
1123                 private_followers.add(parent_message.author_id.id)
1124             private_followers -= set([author_id])
1125             partner_ids |= private_followers
1126
1127         # 3. Attachments
1128         #   - HACK TDE FIXME: Chatter: attachments linked to the document (not done JS-side), load the message
1129         attachment_ids = kwargs.pop('attachment_ids', []) or []  # because we could receive None (some old code sends None)
1130         if attachment_ids:
1131             filtered_attachment_ids = ir_attachment.search(cr, SUPERUSER_ID, [
1132                 ('res_model', '=', 'mail.compose.message'),
1133                 ('create_uid', '=', uid),
1134                 ('id', 'in', attachment_ids)], context=context)
1135             if filtered_attachment_ids:
1136                 ir_attachment.write(cr, SUPERUSER_ID, filtered_attachment_ids, {'res_model': model, 'res_id': thread_id}, context=context)
1137         attachment_ids = [(4, id) for id in attachment_ids]
1138         # Handle attachments parameter, that is a dictionary of attachments
1139         for name, content in attachments:
1140             if isinstance(content, unicode):
1141                 content = content.encode('utf-8')
1142             data_attach = {
1143                 'name': name,
1144                 'datas': base64.b64encode(str(content)),
1145                 'datas_fname': name,
1146                 'description': name,
1147                 'res_model': model,
1148                 'res_id': thread_id,
1149             }
1150             attachment_ids.append((0, 0, data_attach))
1151
1152         # 4: mail.message.subtype
1153         subtype_id = False
1154         if subtype:
1155             if '.' not in subtype:
1156                 subtype = 'mail.%s' % subtype
1157             ref = self.pool.get('ir.model.data').get_object_reference(cr, uid, *subtype.split('.'))
1158             subtype_id = ref and ref[1] or False
1159
1160         # automatically subscribe recipients if asked to
1161         if context.get('mail_post_autofollow') and thread_id and partner_ids:
1162             partner_to_subscribe = partner_ids
1163             if context.get('mail_post_autofollow_partner_ids'):
1164                 partner_to_subscribe = filter(lambda item: item in context.get('mail_post_autofollow_partner_ids'), partner_ids)
1165             self.message_subscribe(cr, uid, [thread_id], list(partner_to_subscribe), context=context)
1166
1167         # _mail_flat_thread: automatically set free messages to the first posted message
1168         if self._mail_flat_thread and not parent_id and thread_id:
1169             message_ids = mail_message.search(cr, uid, ['&', ('res_id', '=', thread_id), ('model', '=', model), ('type', '=', 'email')], context=context, order="id ASC", limit=1)
1170             if not message_ids:
1171                 message_ids = message_ids = mail_message.search(cr, uid, ['&', ('res_id', '=', thread_id), ('model', '=', model)], context=context, order="id ASC", limit=1)
1172             parent_id = message_ids and message_ids[0] or False
1173         # 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
1174         elif parent_id:
1175             message_ids = mail_message.search(cr, SUPERUSER_ID, [('id', '=', parent_id), ('parent_id', '!=', False)], context=context)
1176             # avoid loops when finding ancestors
1177             processed_list = []
1178             if message_ids:
1179                 message = mail_message.browse(cr, SUPERUSER_ID, message_ids[0], context=context)
1180                 while (message.parent_id and message.parent_id.id not in processed_list):
1181                     processed_list.append(message.parent_id.id)
1182                     message = message.parent_id
1183                 parent_id = message.id
1184
1185         values = kwargs
1186         values.update({
1187             'author_id': author_id,
1188             'model': model,
1189             'res_id': thread_id or False,
1190             'body': body,
1191             'subject': subject or False,
1192             'type': type,
1193             'parent_id': parent_id,
1194             'attachment_ids': attachment_ids,
1195             'subtype_id': subtype_id,
1196             'partner_ids': [(4, pid) for pid in partner_ids],
1197         })
1198
1199         # Avoid warnings about non-existing fields
1200         for x in ('from', 'to', 'cc'):
1201             values.pop(x, None)
1202
1203         # Create and auto subscribe the author
1204         msg_id = mail_message.create(cr, uid, values, context=context)
1205         message = mail_message.browse(cr, uid, msg_id, context=context)
1206         if message.author_id and thread_id and type != 'notification' and not context.get('mail_create_nosubscribe'):
1207             self.message_subscribe(cr, uid, [thread_id], [message.author_id.id], context=context)
1208         return msg_id
1209
1210     #------------------------------------------------------
1211     # Compatibility methods: do not use
1212     # TDE TODO: remove me in 8.0
1213     #------------------------------------------------------
1214
1215     def message_create_partners_from_emails(self, cr, uid, emails, context=None):
1216         return {'partner_ids': [], 'new_partner_ids': []}
1217
1218     def message_post_user_api(self, cr, uid, thread_id, body='', parent_id=False,
1219                                 attachment_ids=None, content_subtype='plaintext',
1220                                 context=None, **kwargs):
1221         return self.message_post(cr, uid, thread_id, body=body, parent_id=parent_id,
1222                                     attachment_ids=attachment_ids, content_subtype=content_subtype,
1223                                     context=context, **kwargs)
1224
1225     #------------------------------------------------------
1226     # Followers API
1227     #------------------------------------------------------
1228
1229     def message_get_subscription_data(self, cr, uid, ids, context=None):
1230         """ Wrapper to get subtypes data. """
1231         return self._get_subscription_data(cr, uid, ids, None, None, context=context)
1232
1233     def message_subscribe_users(self, cr, uid, ids, user_ids=None, subtype_ids=None, context=None):
1234         """ Wrapper on message_subscribe, using users. If user_ids is not
1235             provided, subscribe uid instead. """
1236         if user_ids is None:
1237             user_ids = [uid]
1238         partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
1239         return self.message_subscribe(cr, uid, ids, partner_ids, subtype_ids=subtype_ids, context=context)
1240
1241     def message_subscribe(self, cr, uid, ids, partner_ids, subtype_ids=None, context=None):
1242         """ Add partners to the records followers. """
1243         if context is None:
1244             context = {}
1245         # not necessary for computation, but saves an access right check
1246         if not partner_ids:
1247             return True
1248
1249         mail_followers_obj = self.pool.get('mail.followers')
1250         subtype_obj = self.pool.get('mail.message.subtype')
1251
1252         user_pid = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1253         if set(partner_ids) == set([user_pid]):
1254             try:
1255                 self.check_access_rights(cr, uid, 'read')
1256                 if context.get('operation', '') == 'create':
1257                     self.check_access_rule(cr, uid, ids, 'create')
1258                 else:
1259                     self.check_access_rule(cr, uid, ids, 'read')
1260             except (osv.except_osv, orm.except_orm):
1261                 return False
1262         else:
1263             self.check_access_rights(cr, uid, 'write')
1264             self.check_access_rule(cr, uid, ids, 'write')
1265
1266         existing_pids_dict = {}
1267         fol_ids = mail_followers_obj.search(cr, SUPERUSER_ID, ['&', '&', ('res_model', '=', self._name), ('res_id', 'in', ids), ('partner_id', 'in', partner_ids)])
1268         for fol in mail_followers_obj.browse(cr, SUPERUSER_ID, fol_ids, context=context):
1269             existing_pids_dict.setdefault(fol.res_id, set()).add(fol.partner_id.id)
1270
1271         # subtype_ids specified: update already subscribed partners
1272         if subtype_ids and fol_ids:
1273             mail_followers_obj.write(cr, SUPERUSER_ID, fol_ids, {'subtype_ids': [(6, 0, subtype_ids)]}, context=context)
1274         # subtype_ids not specified: do not update already subscribed partner, fetch default subtypes for new partners
1275         if subtype_ids is None:
1276             subtype_ids = subtype_obj.search(
1277                 cr, uid, [
1278                     ('default', '=', True), '|', ('res_model', '=', self._name), ('res_model', '=', False)], context=context)
1279
1280         for id in ids:
1281             existing_pids = existing_pids_dict.get(id, set())
1282             new_pids = set(partner_ids) - existing_pids
1283
1284             # subscribe new followers
1285             for new_pid in new_pids:
1286                 mail_followers_obj.create(
1287                     cr, SUPERUSER_ID, {
1288                         'res_model': self._name,
1289                         'res_id': id,
1290                         'partner_id': new_pid,
1291                         'subtype_ids': [(6, 0, subtype_ids)],
1292                     }, context=context)
1293
1294         return True
1295
1296     def message_unsubscribe_users(self, cr, uid, ids, user_ids=None, context=None):
1297         """ Wrapper on message_subscribe, using users. If user_ids is not
1298             provided, unsubscribe uid instead. """
1299         if user_ids is None:
1300             user_ids = [uid]
1301         partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
1302         return self.message_unsubscribe(cr, uid, ids, partner_ids, context=context)
1303
1304     def message_unsubscribe(self, cr, uid, ids, partner_ids, context=None):
1305         """ Remove partners from the records followers. """
1306         # not necessary for computation, but saves an access right check
1307         if not partner_ids:
1308             return True
1309         user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
1310         if set(partner_ids) == set([user_pid]):
1311             self.check_access_rights(cr, uid, 'read')
1312             self.check_access_rule(cr, uid, ids, 'read')
1313         else:
1314             self.check_access_rights(cr, uid, 'write')
1315             self.check_access_rule(cr, uid, ids, 'write')
1316         fol_obj = self.pool['mail.followers']
1317         fol_ids = fol_obj.search(
1318             cr, SUPERUSER_ID, [
1319                 ('res_model', '=', self._name),
1320                 ('res_id', 'in', ids),
1321                 ('partner_id', 'in', partner_ids)
1322             ], context=context)
1323         return fol_obj.unlink(cr, SUPERUSER_ID, fol_ids, context=context)
1324
1325     def _message_get_auto_subscribe_fields(self, cr, uid, updated_fields, auto_follow_fields=['user_id'], context=None):
1326         """ Returns the list of relational fields linking to res.users that should
1327             trigger an auto subscribe. The default list checks for the fields
1328             - called 'user_id'
1329             - linking to res.users
1330             - with track_visibility set
1331             In OpenERP V7, this is sufficent for all major addon such as opportunity,
1332             project, issue, recruitment, sale.
1333             Override this method if a custom behavior is needed about fields
1334             that automatically subscribe users.
1335         """
1336         user_field_lst = []
1337         for name, column_info in self._all_columns.items():
1338             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':
1339                 user_field_lst.append(name)
1340         return user_field_lst
1341
1342     def message_auto_subscribe(self, cr, uid, ids, updated_fields, context=None, values=None):
1343         """ Handle auto subscription. Two methods for auto subscription exist:
1344
1345          - tracked res.users relational fields, such as user_id fields. Those fields
1346            must be relation fields toward a res.users record, and must have the
1347            track_visilibity attribute set.
1348          - using subtypes parent relationship: check if the current model being
1349            modified has an header record (such as a project for tasks) whose followers
1350            can be added as followers of the current records. Example of structure
1351            with project and task:
1352
1353           - st_project_1.parent_id = st_task_1
1354           - st_project_1.res_model = 'project.project'
1355           - st_project_1.relation_field = 'project_id'
1356           - st_task_1.model = 'project.task'
1357
1358         :param list updated_fields: list of updated fields to track
1359         :param dict values: updated values; if None, the first record will be browsed
1360                             to get the values. Added after releasing 7.0, therefore
1361                             not merged with updated_fields argumment.
1362         """
1363         subtype_obj = self.pool.get('mail.message.subtype')
1364         follower_obj = self.pool.get('mail.followers')
1365         new_followers = dict()
1366
1367         # fetch auto_follow_fields: res.users relation fields whose changes are tracked for subscription
1368         user_field_lst = self._message_get_auto_subscribe_fields(cr, uid, updated_fields, context=context)
1369
1370         # fetch header subtypes
1371         header_subtype_ids = subtype_obj.search(cr, uid, ['|', ('res_model', '=', False), ('parent_id.res_model', '=', self._name)], context=context)
1372         subtypes = subtype_obj.browse(cr, uid, header_subtype_ids, context=context)
1373
1374         # if no change in tracked field or no change in tracked relational field: quit
1375         relation_fields = set([subtype.relation_field for subtype in subtypes if subtype.relation_field is not False])
1376         if not any(relation in updated_fields for relation in relation_fields) and not user_field_lst:
1377             return True
1378
1379         # legacy behavior: if values is not given, compute the values by browsing
1380         # @TDENOTE: remove me in 8.0
1381         if values is None:
1382             record = self.browse(cr, uid, ids[0], context=context)
1383             for updated_field in updated_fields:
1384                 field_value = getattr(record, updated_field)
1385                 if isinstance(field_value, browse_record):
1386                     field_value = field_value.id
1387                 elif isinstance(field_value, browse_null):
1388                     field_value = False
1389                 values[updated_field] = field_value
1390
1391         # find followers of headers, update structure for new followers
1392         headers = set()
1393         for subtype in subtypes:
1394             if subtype.relation_field and values.get(subtype.relation_field):
1395                 headers.add((subtype.res_model, values.get(subtype.relation_field)))
1396         if headers:
1397             header_domain = ['|'] * (len(headers) - 1)
1398             for header in headers:
1399                 header_domain += ['&', ('res_model', '=', header[0]), ('res_id', '=', header[1])]
1400             header_follower_ids = follower_obj.search(
1401                 cr, SUPERUSER_ID,
1402                 header_domain,
1403                 context=context
1404             )
1405             for header_follower in follower_obj.browse(cr, SUPERUSER_ID, header_follower_ids, context=context):
1406                 for subtype in header_follower.subtype_ids:
1407                     if subtype.parent_id and subtype.parent_id.res_model == self._name:
1408                         new_followers.setdefault(header_follower.partner_id.id, set()).add(subtype.parent_id.id)
1409                     elif subtype.res_model is False:
1410                         new_followers.setdefault(header_follower.partner_id.id, set()).add(subtype.id)
1411
1412         # add followers coming from res.users relational fields that are tracked
1413         user_ids = [values[name] for name in user_field_lst if values.get(name)]
1414         user_pids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, SUPERUSER_ID, user_ids, context=context)]
1415         for partner_id in user_pids:
1416             new_followers.setdefault(partner_id, None)
1417
1418         for pid, subtypes in new_followers.items():
1419             subtypes = list(subtypes) if subtypes is not None else None
1420             self.message_subscribe(cr, uid, ids, [pid], subtypes, context=context)
1421
1422         # find first email message, set it as unread for auto_subscribe fields for them to have a notification
1423         if user_pids:
1424             for record_id in ids:
1425                 message_obj = self.pool.get('mail.message')
1426                 msg_ids = message_obj.search(cr, SUPERUSER_ID, [
1427                     ('model', '=', self._name),
1428                     ('res_id', '=', record_id),
1429                     ('type', '=', 'email')], limit=1, context=context)
1430                 if not msg_ids:
1431                     msg_ids = message_obj.search(cr, SUPERUSER_ID, [
1432                         ('model', '=', self._name),
1433                         ('res_id', '=', record_id)], limit=1, context=context)
1434                 if msg_ids:
1435                     self.pool.get('mail.notification')._notify(cr, uid, msg_ids[0], partners_to_notify=user_pids, context=context)
1436
1437         return True
1438
1439     #------------------------------------------------------
1440     # Thread state
1441     #------------------------------------------------------
1442
1443     def message_mark_as_unread(self, cr, uid, ids, context=None):
1444         """ Set as unread. """
1445         partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1446         cr.execute('''
1447             UPDATE mail_notification SET
1448                 read=false
1449             WHERE
1450                 message_id IN (SELECT id from mail_message where res_id=any(%s) and model=%s limit 1) and
1451                 partner_id = %s
1452         ''', (ids, self._name, partner_id))
1453         return True
1454
1455     def message_mark_as_read(self, cr, uid, ids, context=None):
1456         """ Set as read. """
1457         partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1458         cr.execute('''
1459             UPDATE mail_notification SET
1460                 read=true
1461             WHERE
1462                 message_id IN (SELECT id FROM mail_message WHERE res_id=ANY(%s) AND model=%s) AND
1463                 partner_id = %s
1464         ''', (ids, self._name, partner_id))
1465         return True
1466
1467 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: