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