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