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