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