[MERGE] Sync with trunk.
[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 time
29 import xmlrpclib
30 from email.message import Message
31
32 from openerp import tools
33 from openerp import SUPERUSER_ID
34 from openerp.addons.mail.mail_message import decode
35 from openerp.osv import fields, osv
36 from openerp.tools.safe_eval import safe_eval as eval
37
38 _logger = logging.getLogger(__name__)
39
40
41 def decode_header(message, header, separator=' '):
42     return separator.join(map(decode, message.get_all(header, [])))
43
44
45 class mail_thread(osv.AbstractModel):
46     ''' mail_thread model is meant to be inherited by any model that needs to
47         act as a discussion topic on which messages can be attached. Public
48         methods are prefixed with ``message_`` in order to avoid name
49         collisions with methods of the models that will inherit from this class.
50
51         ``mail.thread`` defines fields used to handle and display the
52         communication history. ``mail.thread`` also manages followers of
53         inheriting classes. All features and expected behavior are managed
54         by mail.thread. Widgets has been designed for the 7.0 and following
55         versions of OpenERP.
56
57         Inheriting classes are not required to implement any method, as the
58         default implementation will work for any model. However it is common
59         to override at least the ``message_new`` and ``message_update``
60         methods (calling ``super``) to add model-specific behavior at
61         creation and update of a thread when processing incoming emails.
62
63         Options:
64             - _mail_flat_thread: if set to True, all messages without parent_id
65                 are automatically attached to the first message posted on the
66                 ressource. If set to False, the display of Chatter is done using
67                 threads, and no parent_id is automatically set.
68     '''
69     _name = 'mail.thread'
70     _description = 'Email Thread'
71     _mail_flat_thread = True
72
73     # Automatic logging system if mail installed
74     # _track = {
75     #   'field': {
76     #       'module.subtype_xml': lambda self, cr, uid, obj, context=None: obj[state] == done,
77     #       'module.subtype_xml2': lambda self, cr, uid, obj, context=None: obj[state] != done,
78     #   },
79     #   'field2': {
80     #       ...
81     #   },
82     # }
83     # where
84     #   :param string field: field name
85     #   :param module.subtype_xml: xml_id of a mail.message.subtype (i.e. mail.mt_comment)
86     #   :param obj: is a browse_record
87     #   :param function lambda: returns whether the tracking should record using this subtype
88     _track = {}
89
90     def _get_message_data(self, cr, uid, ids, name, args, context=None):
91         """ Computes:
92             - message_unread: has uid unread message for the document
93             - message_summary: html snippet summarizing the Chatter for kanban views """
94         res = dict((id, dict(message_unread=False, message_summary='')) for id in ids)
95         user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
96
97         # search for unread messages, directly in SQL to improve performances
98         cr.execute("""  SELECT m.res_id FROM mail_message m
99                         RIGHT JOIN mail_notification n
100                         ON (n.message_id = m.id AND n.partner_id = %s AND (n.read = False or n.read IS NULL))
101                         WHERE m.model = %s AND m.res_id in %s""",
102                     (user_pid, self._name, tuple(ids),))
103         msg_ids = [result[0] for result in cr.fetchall()]
104         for msg_id in msg_ids:
105             res[msg_id]['message_unread'] = True
106
107         for thread in self.browse(cr, uid, ids, context=context):
108             cls = res[thread.id]['message_unread'] and ' class="oe_kanban_mail_new"' or ''
109             res[thread.id]['message_summary'] = "<span%s><span class='oe_e'>9</span> %d</span> <span><span class='oe_e'>+</span> %d</span>" % (cls, len(thread.message_ids), len(thread.message_follower_ids))
110
111         return res
112
113     def _get_subscription_data(self, cr, uid, ids, name, args, context=None):
114         """ Computes:
115             - message_subtype_data: data about document subtypes: which are
116                 available, which are followed if any """
117         res = dict((id, dict(message_subtype_data='')) for id in ids)
118         user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
119
120         # find current model subtypes, add them to a dictionary
121         subtype_obj = self.pool.get('mail.message.subtype')
122         subtype_ids = subtype_obj.search(cr, uid, ['|', ('res_model', '=', self._name), ('res_model', '=', False)], context=context)
123         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))
124         for id in ids:
125             res[id]['message_subtype_data'] = subtype_dict.copy()
126
127         # find the document followers, update the data
128         fol_obj = self.pool.get('mail.followers')
129         fol_ids = fol_obj.search(cr, uid, [
130             ('partner_id', '=', user_pid),
131             ('res_id', 'in', ids),
132             ('res_model', '=', self._name),
133         ], context=context)
134         for fol in fol_obj.browse(cr, uid, fol_ids, context=context):
135             thread_subtype_dict = res[fol.res_id]['message_subtype_data']
136             for subtype in fol.subtype_ids:
137                 thread_subtype_dict[subtype.name]['followed'] = True
138             res[fol.res_id]['message_subtype_data'] = thread_subtype_dict
139
140         return res
141
142     def _search_message_unread(self, cr, uid, obj=None, name=None, domain=None, context=None):
143         return [('message_ids.to_read', '=', True)]
144
145     def _get_followers(self, cr, uid, ids, name, arg, context=None):
146         fol_obj = self.pool.get('mail.followers')
147         fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids)])
148         res = dict((id, dict(message_follower_ids=[], message_is_follower=False)) for id in ids)
149         user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
150         for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids):
151             res[fol.res_id]['message_follower_ids'].append(fol.partner_id.id)
152             if fol.partner_id.id == user_pid:
153                 res[fol.res_id]['message_is_follower'] = True
154         return res
155
156     def _set_followers(self, cr, uid, id, name, value, arg, context=None):
157         if not value:
158             return
159         partner_obj = self.pool.get('res.partner')
160         fol_obj = self.pool.get('mail.followers')
161
162         # read the old set of followers, and determine the new set of followers
163         fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', '=', id)])
164         old = set(fol.partner_id.id for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids))
165         new = set(old)
166
167         for command in value or []:
168             if isinstance(command, (int, long)):
169                 new.add(command)
170             elif command[0] == 0:
171                 new.add(partner_obj.create(cr, uid, command[2], context=context))
172             elif command[0] == 1:
173                 partner_obj.write(cr, uid, [command[1]], command[2], context=context)
174                 new.add(command[1])
175             elif command[0] == 2:
176                 partner_obj.unlink(cr, uid, [command[1]], context=context)
177                 new.discard(command[1])
178             elif command[0] == 3:
179                 new.discard(command[1])
180             elif command[0] == 4:
181                 new.add(command[1])
182             elif command[0] == 5:
183                 new.clear()
184             elif command[0] == 6:
185                 new = set(command[2])
186
187         # remove partners that are no longer followers
188         fol_ids = fol_obj.search(cr, SUPERUSER_ID,
189             [('res_model', '=', self._name), ('res_id', '=', id), ('partner_id', 'not in', list(new))])
190         fol_obj.unlink(cr, SUPERUSER_ID, fol_ids)
191
192         # add new followers
193         for partner_id in new - old:
194             fol_obj.create(cr, SUPERUSER_ID, {'res_model': self._name, 'res_id': id, 'partner_id': partner_id})
195
196     def _search_followers(self, cr, uid, obj, name, args, context):
197         fol_obj = self.pool.get('mail.followers')
198         res = []
199         for field, operator, value in args:
200             assert field == name
201             fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('partner_id', operator, value)])
202             res_ids = [fol.res_id for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids)]
203             res.append(('id', 'in', res_ids))
204         return res
205
206     _columns = {
207         'message_is_follower': fields.function(_get_followers,
208             type='boolean', string='Is a Follower', multi='_get_followers,'),
209         'message_follower_ids': fields.function(_get_followers, fnct_inv=_set_followers,
210                 fnct_search=_search_followers, type='many2many',
211                 obj='res.partner', string='Followers', multi='_get_followers'),
212         'message_ids': fields.one2many('mail.message', 'res_id',
213             domain=lambda self: [('model', '=', self._name)],
214             auto_join=True,
215             string='Messages',
216             help="Messages and communication history"),
217         'message_unread': fields.function(_get_message_data,
218             fnct_search=_search_message_unread, multi="_get_message_data",
219             type='boolean', string='Unread Messages',
220             help="If checked new messages require your attention."),
221         'message_summary': fields.function(_get_message_data, method=True,
222             type='text', string='Summary', multi="_get_message_data",
223             help="Holds the Chatter summary (number of messages, ...). "\
224                  "This summary is directly in html format in order to "\
225                  "be inserted in kanban views."),
226     }
227
228     #------------------------------------------------------
229     # CRUD overrides for automatic subscription and logging
230     #------------------------------------------------------
231
232     def create(self, cr, uid, values, context=None):
233         """ Chatter override :
234             - subscribe uid
235             - subscribe followers of parent
236             - log a creation message
237         """
238         if context is None:
239             context = {}
240         thread_id = super(mail_thread, self).create(cr, uid, values, context=context)
241
242         # subscribe uid unless asked not to
243         if not context.get('mail_create_nosubscribe'):
244             self.message_subscribe_users(cr, uid, [thread_id], [uid], context=context)
245             self.message_subscribe_from_parent(cr, uid, [thread_id], values.keys(), context=context)
246
247         # automatic logging unless asked not to (mainly for various testing purpose)
248         if not context.get('mail_create_nolog'):
249             self.message_post(cr, uid, thread_id, body='Document created', context=context)
250         return thread_id
251
252     def write(self, cr, uid, ids, values, context=None):
253         if isinstance(ids, (int, long)):
254             ids = [ids]
255         # Track initial values of tracked fields
256         tracked_fields = self._get_tracked_fields(cr, uid, values.keys(), context=context)
257         if tracked_fields:
258             initial = self.read(cr, uid, ids, tracked_fields.keys(), context=context)
259             initial_values = dict((item['id'], item) for item in initial)
260
261         # Perform write, update followers
262         result = super(mail_thread, self).write(cr, uid, ids, values, context=context)
263         self.message_subscribe_from_parent(cr, uid, ids, values.keys(), context=context)
264
265         # Perform the tracking
266         if tracked_fields:
267             self.message_track(cr, uid, ids, tracked_fields, initial_values, context=context)
268         return result
269
270     def unlink(self, cr, uid, ids, context=None):
271         """ Override unlink to delete messages and followers. This cannot be
272             cascaded, because link is done through (res_model, res_id). """
273         msg_obj = self.pool.get('mail.message')
274         fol_obj = self.pool.get('mail.followers')
275         # delete messages and notifications
276         msg_ids = msg_obj.search(cr, uid, [('model', '=', self._name), ('res_id', 'in', ids)], context=context)
277         msg_obj.unlink(cr, uid, msg_ids, context=context)
278         # delete
279         res = super(mail_thread, self).unlink(cr, uid, ids, context=context)
280         # delete followers
281         fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids)], context=context)
282         fol_obj.unlink(cr, SUPERUSER_ID, fol_ids, context=context)
283         return res
284
285     def copy(self, cr, uid, id, default=None, context=None):
286         default = default or {}
287         default['message_ids'] = []
288         default['message_follower_ids'] = []
289         return super(mail_thread, self).copy(cr, uid, id, default=default, context=context)
290
291     #------------------------------------------------------
292     # Automatically log tracked fields
293     #------------------------------------------------------
294
295     def _get_tracked_fields(self, cr, uid, updated_fields, context=None):
296         """ Return a structure of tracked fields for the current model.
297             :param list updated_fields: modified field names
298             :return list: a list of (field_name, column_info obj), containing
299                 always tracked fields and modified on_change fields
300         """
301         lst = []
302         for name, column_info in self._all_columns.items():
303             visibility = getattr(column_info.column, 'track_visibility', False)
304             if visibility == 'always' or (visibility == 'onchange' and name in updated_fields) or name in self._track:
305                 lst.append(name)
306         if not lst:
307             return lst
308         return self.fields_get(cr, uid, lst, context=context)
309
310     def message_track(self, cr, uid, ids, tracked_fields, initial_values, context=None):
311
312         def convert_for_display(value, field_obj):
313             if not value:
314                 return ''
315             if field_obj['type'] == 'many2one':
316                 return value[1]
317             if field_obj['type'] == 'selection':
318                 return dict(field_obj['selection'])[value]
319             return value
320
321         def format_message(message_description, tracked_values):
322             message = ''
323             if message_description:
324                 message = '<span>%s</span>' % message_description
325             for name, change in tracked_values.items():
326                 message += '<div> &nbsp; &nbsp; &bull; <b>%s</b>: ' % change.get('col_info')
327                 if change.get('old_value'):
328                     message += '%s &rarr; ' % change.get('old_value')
329                 message += '%s</div>' % change.get('new_value')
330             return message
331
332         if not tracked_fields:
333             return True
334
335         for record in self.read(cr, uid, ids, tracked_fields.keys(), context=context):
336             initial = initial_values[record['id']]
337             changes = []
338             tracked_values = {}
339
340             # generate tracked_values data structure: {'col_name': {col_info, new_value, old_value}}
341             for col_name, col_info in tracked_fields.items():
342                 if record[col_name] == initial[col_name] and getattr(self._all_columns[col_name].column, 'track_visibility', None) == 'always':
343                     tracked_values[col_name] = dict(col_info=col_info['string'],
344                                                         new_value=convert_for_display(record[col_name], col_info))
345                 elif record[col_name] != initial[col_name]:
346                     if getattr(self._all_columns[col_name].column, 'track_visibility', None) in ['always', 'onchange']:
347                         tracked_values[col_name] = dict(col_info=col_info['string'],
348                                                             old_value=convert_for_display(initial[col_name], col_info),
349                                                             new_value=convert_for_display(record[col_name], col_info))
350                     if col_name in tracked_fields:
351                         changes.append(col_name)
352             if not changes:
353                 continue
354
355             # find subtypes and post messages or log if no subtype found
356             subtypes = []
357             for field, track_info in self._track.items():
358                 if field not in changes:
359                     continue
360                 for subtype, method in track_info.items():
361                     if method(self, cr, uid, record, context):
362                         subtypes.append(subtype)
363
364             posted = False
365             for subtype in subtypes:
366                 try:
367                     subtype_rec = self.pool.get('ir.model.data').get_object(cr, uid, subtype.split('.')[0], subtype.split('.')[1])
368                 except ValueError, e:
369                     _logger.debug('subtype %s not found, giving error "%s"' % (subtype, e))
370                     continue
371                 message = format_message(subtype_rec.description if subtype_rec.description else subtype_rec.name, tracked_values)
372                 self.message_post(cr, uid, record['id'], body=message, subtype=subtype, context=context)
373                 posted = True
374             if not posted:
375                 message = format_message('', tracked_values)
376                 self.message_post(cr, uid, record['id'], body=message, context=context)
377         return True
378
379     #------------------------------------------------------
380     # mail.message wrappers and tools
381     #------------------------------------------------------
382
383     def _needaction_domain_get(self, cr, uid, context=None):
384         if self._needaction:
385             return [('message_unread', '=', True)]
386         return []
387
388     #------------------------------------------------------
389     # Mail gateway
390     #------------------------------------------------------
391
392     def message_capable_models(self, cr, uid, context=None):
393         """ Used by the plugin addon, based for plugin_outlook and others. """
394         ret_dict = {}
395         for model_name in self.pool.obj_list():
396             model = self.pool.get(model_name)
397             if 'mail.thread' in getattr(model, '_inherit', []):
398                 ret_dict[model_name] = model._description
399         return ret_dict
400
401     def _message_find_partners(self, cr, uid, message, header_fields=['From'], context=None):
402         """ Find partners related to some header fields of the message. """
403         s = ', '.join([decode(message.get(h)) for h in header_fields if message.get(h)])
404         return [partner_id for email in tools.email_split(s)
405                 for partner_id in self.pool.get('res.partner').search(cr, uid, [('email', 'ilike', email)], limit=1, context=context)]
406
407     def _message_find_user_id(self, cr, uid, message, context=None):
408         from_local_part = tools.email_split(decode(message.get('From')))[0]
409         # FP Note: canonification required, the minimu: .lower()
410         user_ids = self.pool.get('res.users').search(cr, uid, ['|',
411             ('login', '=', from_local_part),
412             ('email', '=', from_local_part)], context=context)
413         return user_ids[0] if user_ids else uid
414
415     def message_route(self, cr, uid, message, model=None, thread_id=None,
416                       custom_values=None, context=None):
417         """Attempt to figure out the correct target model, thread_id,
418         custom_values and user_id to use for an incoming message.
419         Multiple values may be returned, if a message had multiple
420         recipients matching existing mail.aliases, for example.
421
422         The following heuristics are used, in this order:
423              1. If the message replies to an existing thread_id, and
424                 properly contains the thread model in the 'In-Reply-To'
425                 header, use this model/thread_id pair, and ignore
426                 custom_value (not needed as no creation will take place)
427              2. Look for a mail.alias entry matching the message
428                 recipient, and use the corresponding model, thread_id,
429                 custom_values and user_id.
430              3. Fallback to the ``model``, ``thread_id`` and ``custom_values``
431                 provided.
432              4. If all the above fails, raise an exception.
433
434            :param string message: an email.message instance
435            :param string model: the fallback model to use if the message
436                does not match any of the currently configured mail aliases
437                (may be None if a matching alias is supposed to be present)
438            :type dict custom_values: optional dictionary of default field values
439                 to pass to ``message_new`` if a new record needs to be created.
440                 Ignored if the thread record already exists, and also if a
441                 matching mail.alias was found (aliases define their own defaults)
442            :param int thread_id: optional ID of the record/thread from ``model``
443                to which this mail should be attached. Only used if the message
444                does not reply to an existing thread and does not match any mail alias.
445            :return: list of [model, thread_id, custom_values, user_id]
446         """
447         assert isinstance(message, Message), 'message must be an email.message.Message at this point'
448         message_id = message.get('Message-Id')
449         references = decode_header(message, 'References')
450         in_reply_to = decode_header(message, 'In-Reply-To')
451
452         # 1. Verify if this is a reply to an existing thread
453         thread_references = references or in_reply_to
454         ref_match = thread_references and tools.reference_re.search(thread_references)
455         if ref_match:
456             thread_id = int(ref_match.group(1))
457             model = ref_match.group(2) or model
458             model_pool = self.pool.get(model)
459             if thread_id and model and model_pool and model_pool.exists(cr, uid, thread_id) \
460                 and hasattr(model_pool, 'message_update'):
461                 _logger.debug('Routing mail with Message-Id %s: direct reply to model: %s, thread_id: %s, custom_values: %s, uid: %s',
462                               message_id, model, thread_id, custom_values, uid)
463                 return [(model, thread_id, custom_values, uid)]
464
465         # Verify whether this is a reply to a private message
466         if in_reply_to:
467             message_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', '=', in_reply_to)], limit=1, context=context)
468             if message_ids:
469                 message = self.pool.get('mail.message').browse(cr, uid, message_ids[0], context=context)
470                 _logger.debug('Routing mail with Message-Id %s: direct reply to a private message: %s, custom_values: %s, uid: %s',
471                                 message_id, message.id, custom_values, uid)
472                 return [(message.model, message.res_id, custom_values, uid)]
473
474         # 2. Look for a matching mail.alias entry
475         # Delivered-To is a safe bet in most modern MTAs, but we have to fallback on To + Cc values
476         # for all the odd MTAs out there, as there is no standard header for the envelope's `rcpt_to` value.
477         rcpt_tos = \
478              ','.join([decode_header(message, 'Delivered-To'),
479                        decode_header(message, 'To'),
480                        decode_header(message, 'Cc'),
481                        decode_header(message, 'Resent-To'),
482                        decode_header(message, 'Resent-Cc')])
483         local_parts = [e.split('@')[0] for e in tools.email_split(rcpt_tos)]
484         if local_parts:
485             mail_alias = self.pool.get('mail.alias')
486             alias_ids = mail_alias.search(cr, uid, [('alias_name', 'in', local_parts)])
487             if alias_ids:
488                 routes = []
489                 for alias in mail_alias.browse(cr, uid, alias_ids, context=context):
490                     user_id = alias.alias_user_id.id
491                     if not user_id:
492                         user_id = self._message_find_user_id(cr, uid, message, context=context)
493                     routes.append((alias.alias_model_id.model, alias.alias_force_thread_id, \
494                                    eval(alias.alias_defaults), user_id))
495                 _logger.debug('Routing mail with Message-Id %s: direct alias match: %r', message_id, routes)
496                 return routes
497
498         # 3. Fallback to the provided parameters, if they work
499         model_pool = self.pool.get(model)
500         if not thread_id:
501             # Legacy: fallback to matching [ID] in the Subject
502             match = tools.res_re.search(decode_header(message, 'Subject'))
503             thread_id = match and match.group(1)
504         assert thread_id and hasattr(model_pool, 'message_update') or hasattr(model_pool, 'message_new'), \
505             "No possible route found for incoming message with Message-Id %s. " \
506             "Create an appropriate mail.alias or force the destination model." % message_id
507         if thread_id and not model_pool.exists(cr, uid, thread_id):
508             _logger.warning('Received mail reply to missing document %s! Ignoring and creating new document instead for Message-Id %s',
509                             thread_id, message_id)
510             thread_id = None
511         _logger.debug('Routing mail with Message-Id %s: fallback to model:%s, thread_id:%s, custom_values:%s, uid:%s',
512                       message_id, model, thread_id, custom_values, uid)
513         return [(model, thread_id, custom_values, uid)]
514
515     def message_process(self, cr, uid, model, message, custom_values=None,
516                         save_original=False, strip_attachments=False,
517                         thread_id=None, context=None):
518         """ Process an incoming RFC2822 email message, relying on
519             ``mail.message.parse()`` for the parsing operation,
520             and ``message_route()`` to figure out the target model.
521
522             Once the target model is known, its ``message_new`` method
523             is called with the new message (if the thread record did not exist)
524             or its ``message_update`` method (if it did).
525
526             There is a special case where the target model is False: a reply
527             to a private message. In this case, we skip the message_new /
528             message_update step, to just post a new message using mail_thread
529             message_post.
530
531            :param string model: the fallback model to use if the message
532                does not match any of the currently configured mail aliases
533                (may be None if a matching alias is supposed to be present)
534            :param message: source of the RFC2822 message
535            :type message: string or xmlrpclib.Binary
536            :type dict custom_values: optional dictionary of field values
537                 to pass to ``message_new`` if a new record needs to be created.
538                 Ignored if the thread record already exists, and also if a
539                 matching mail.alias was found (aliases define their own defaults)
540            :param bool save_original: whether to keep a copy of the original
541                 email source attached to the message after it is imported.
542            :param bool strip_attachments: whether to strip all attachments
543                 before processing the message, in order to save some space.
544            :param int thread_id: optional ID of the record/thread from ``model``
545                to which this mail should be attached. When provided, this
546                overrides the automatic detection based on the message
547                headers.
548         """
549         if context is None:
550             context = {}
551
552         # extract message bytes - we are forced to pass the message as binary because
553         # we don't know its encoding until we parse its headers and hence can't
554         # convert it to utf-8 for transport between the mailgate script and here.
555         if isinstance(message, xmlrpclib.Binary):
556             message = str(message.data)
557         # Warning: message_from_string doesn't always work correctly on unicode,
558         # we must use utf-8 strings here :-(
559         if isinstance(message, unicode):
560             message = message.encode('utf-8')
561         msg_txt = email.message_from_string(message)
562         routes = self.message_route(cr, uid, msg_txt, model,
563                                     thread_id, custom_values,
564                                     context=context)
565         msg = self.message_parse(cr, uid, msg_txt, save_original=save_original, context=context)
566         if strip_attachments:
567             msg.pop('attachments', None)
568
569         # postpone setting msg.partner_ids after message_post, to avoid double notifications
570         partner_ids = msg.pop('partner_ids', [])
571
572         thread_id = False
573         for model, thread_id, custom_values, user_id in routes:
574             if self._name != model:
575                 context.update({'thread_model': model})
576             if model:
577                 model_pool = self.pool.get(model)
578                 assert thread_id and hasattr(model_pool, 'message_update') or hasattr(model_pool, 'message_new'), \
579                     "Undeliverable mail with Message-Id %s, model %s does not accept incoming emails" % \
580                         (msg['message_id'], model)
581
582                 # disabled subscriptions during message_new/update to avoid having the system user running the
583                 # email gateway become a follower of all inbound messages
584                 nosub_ctx = dict(context, mail_create_nosubscribe=True)
585                 if thread_id and hasattr(model_pool, 'message_update'):
586                     model_pool.message_update(cr, user_id, [thread_id], msg, context=nosub_ctx)
587                 else:
588                     thread_id = model_pool.message_new(cr, user_id, msg, custom_values, context=nosub_ctx)
589             else:
590                 assert thread_id == 0, "Posting a message without model should be with a null res_id, to create a private message."
591                 model_pool = self.pool.get('mail.thread')
592             new_msg_id = model_pool.message_post_user_api(cr, uid, [thread_id], context=context, content_subtype='html', **msg)
593
594             # when posting an incoming email to a document: subscribe the author, if a partner, as follower
595             if model and thread_id and msg.get('author_id'):
596                 model_pool.message_subscribe(cr, uid, [thread_id], [msg.get('author_id')], context=context)
597
598             if partner_ids:
599                 # postponed after message_post, because this is an external message and we don't want to create
600                 # duplicate emails due to notifications
601                 self.pool.get('mail.message').write(cr, uid, [new_msg_id], {'partner_ids': partner_ids}, context=context)
602
603         return thread_id
604
605     def message_new(self, cr, uid, msg_dict, custom_values=None, context=None):
606         """Called by ``message_process`` when a new message is received
607            for a given thread model, if the message did not belong to
608            an existing thread.
609            The default behavior is to create a new record of the corresponding
610            model (based on some very basic info extracted from the message).
611            Additional behavior may be implemented by overriding this method.
612
613            :param dict msg_dict: a map containing the email details and
614                                  attachments. See ``message_process`` and
615                                 ``mail.message.parse`` for details.
616            :param dict custom_values: optional dictionary of additional
617                                       field values to pass to create()
618                                       when creating the new thread record.
619                                       Be careful, these values may override
620                                       any other values coming from the message.
621            :param dict context: if a ``thread_model`` value is present
622                                 in the context, its value will be used
623                                 to determine the model of the record
624                                 to create (instead of the current model).
625            :rtype: int
626            :return: the id of the newly created thread object
627         """
628         if context is None:
629             context = {}
630         model = context.get('thread_model') or self._name
631         model_pool = self.pool.get(model)
632         fields = model_pool.fields_get(cr, uid, context=context)
633         data = model_pool.default_get(cr, uid, fields, context=context)
634         if 'name' in fields and not data.get('name'):
635             data['name'] = msg_dict.get('subject', '')
636         if custom_values and isinstance(custom_values, dict):
637             data.update(custom_values)
638         res_id = model_pool.create(cr, uid, data, context=context)
639         return res_id
640
641     def message_update(self, cr, uid, ids, msg_dict, update_vals=None, context=None):
642         """Called by ``message_process`` when a new message is received
643            for an existing thread. The default behavior is to update the record
644            with update_vals taken from the incoming email.
645            Additional behavior may be implemented by overriding this
646            method.
647            :param dict msg_dict: a map containing the email details and
648                                attachments. See ``message_process`` and
649                                ``mail.message.parse()`` for details.
650            :param dict update_vals: a dict containing values to update records
651                               given their ids; if the dict is None or is
652                               void, no write operation is performed.
653         """
654         if update_vals:
655             self.write(cr, uid, ids, update_vals, context=context)
656         return True
657
658     def _message_extract_payload(self, message, save_original=False):
659         """Extract body as HTML and attachments from the mail message"""
660         attachments = []
661         body = u''
662         if save_original:
663             attachments.append(('original_email.eml', message.as_string()))
664         if not message.is_multipart() or 'text/' in message.get('content-type', ''):
665             encoding = message.get_content_charset()
666             body = message.get_payload(decode=True)
667             body = tools.ustr(body, encoding, errors='replace')
668             if message.get_content_type() == 'text/plain':
669                 # text/plain -> <pre/>
670                 body = tools.append_content_to_html(u'', body, preserve=True)
671         else:
672             alternative = (message.get_content_type() == 'multipart/alternative')
673             for part in message.walk():
674                 if part.get_content_maintype() == 'multipart':
675                     continue  # skip container
676                 filename = part.get_filename()  # None if normal part
677                 encoding = part.get_content_charset()  # None if attachment
678                 # 1) Explicit Attachments -> attachments
679                 if filename or part.get('content-disposition', '').strip().startswith('attachment'):
680                     attachments.append((filename or 'attachment', part.get_payload(decode=True)))
681                     continue
682                 # 2) text/plain -> <pre/>
683                 if part.get_content_type() == 'text/plain' and (not alternative or not body):
684                     body = tools.append_content_to_html(body, tools.ustr(part.get_payload(decode=True),
685                                                                          encoding, errors='replace'), preserve=True)
686                 # 3) text/html -> raw
687                 elif part.get_content_type() == 'text/html':
688                     html = tools.ustr(part.get_payload(decode=True), encoding, errors='replace')
689                     if alternative:
690                         body = html
691                     else:
692                         body = tools.append_content_to_html(body, html, plaintext=False)
693                 # 4) Anything else -> attachment
694                 else:
695                     attachments.append((filename or 'attachment', part.get_payload(decode=True)))
696         return body, attachments
697
698     def message_parse(self, cr, uid, message, save_original=False, context=None):
699         """Parses a string or email.message.Message representing an
700            RFC-2822 email, and returns a generic dict holding the
701            message details.
702
703            :param message: the message to parse
704            :type message: email.message.Message | string | unicode
705            :param bool save_original: whether the returned dict
706                should include an ``original`` attachment containing
707                the source of the message
708            :rtype: dict
709            :return: A dict with the following structure, where each
710                     field may not be present if missing in original
711                     message::
712
713                     { 'message_id': msg_id,
714                       'subject': subject,
715                       'from': from,
716                       'to': to,
717                       'cc': cc,
718                       'body': unified_body,
719                       'attachments': [('file1', 'bytes'),
720                                       ('file2', 'bytes')}
721                     }
722         """
723         msg_dict = {
724             'type': 'email',
725             'author_id': False,
726         }
727         if not isinstance(message, Message):
728             if isinstance(message, unicode):
729                 # Warning: message_from_string doesn't always work correctly on unicode,
730                 # we must use utf-8 strings here :-(
731                 message = message.encode('utf-8')
732             message = email.message_from_string(message)
733
734         message_id = message['message-id']
735         if not message_id:
736             # Very unusual situation, be we should be fault-tolerant here
737             message_id = "<%s@localhost>" % time.time()
738             _logger.debug('Parsing Message without message-id, generating a random one: %s', message_id)
739         msg_dict['message_id'] = message_id
740
741         if 'Subject' in message:
742             msg_dict['subject'] = decode(message.get('Subject'))
743
744         # Envelope fields not stored in mail.message but made available for message_new()
745         msg_dict['from'] = decode(message.get('from'))
746         msg_dict['to'] = decode(message.get('to'))
747         msg_dict['cc'] = decode(message.get('cc'))
748
749         if 'From' in message:
750             author_ids = self._message_find_partners(cr, uid, message, ['From'], context=context)
751             if author_ids:
752                 msg_dict['author_id'] = author_ids[0]
753             else:
754                 msg_dict['email_from'] = message.get('from')
755         partner_ids = self._message_find_partners(cr, uid, message, ['From', 'To', 'Cc'], context=context)
756         msg_dict['partner_ids'] = [(4, partner_id) for partner_id in partner_ids]
757
758         if 'Date' in message:
759             try:
760                 date_hdr = decode(message.get('Date'))
761                 parsed_date = dateutil.parser.parse(date_hdr, fuzzy=True)
762                 if parsed_date.utcoffset() is None:
763                     # naive datetime, so we arbitrarily decide to make it
764                     # UTC, there's no better choice. Should not happen,
765                     # as RFC2822 requires timezone offset in Date headers.
766                     stored_date = parsed_date.replace(tzinfo=pytz.utc)
767                 else:
768                     stored_date = parsed_date.astimezone(pytz.utc)
769             except Exception:
770                 _logger.warning('Failed to parse Date header %r in incoming mail '
771                                 'with message-id %r, assuming current date/time.',
772                                 message.get('Date'), message_id)
773                 stored_date = datetime.datetime.now()
774             msg_dict['date'] = stored_date.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT)
775
776         if 'In-Reply-To' in message:
777             parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', '=', decode(message['In-Reply-To']))])
778             if parent_ids:
779                 msg_dict['parent_id'] = parent_ids[0]
780
781         if 'References' in message and 'parent_id' not in msg_dict:
782             parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', 'in',
783                                                                          [x.strip() for x in decode(message['References']).split()])])
784             if parent_ids:
785                 msg_dict['parent_id'] = parent_ids[0]
786
787         msg_dict['body'], msg_dict['attachments'] = self._message_extract_payload(message)
788         return msg_dict
789
790     #------------------------------------------------------
791     # Note specific
792     #------------------------------------------------------
793
794     def log(self, cr, uid, id, message, secondary=False, context=None):
795         _logger.warning("log() is deprecated. As this module inherit from "\
796                         "mail.thread, the message will be managed by this "\
797                         "module instead of by the res.log mechanism. Please "\
798                         "use mail_thread.message_post() instead of the "\
799                         "now deprecated res.log.")
800         self.message_post(cr, uid, [id], message, context=context)
801
802     def message_post(self, cr, uid, thread_id, body='', subject=None, type='notification',
803                         subtype=None, parent_id=False, attachments=None, context=None, **kwargs):
804         """ Post a new message in an existing thread, returning the new
805             mail.message ID. Extra keyword arguments will be used as default
806             column values for the new mail.message record.
807             Auto link messages for same id and object
808             :param int thread_id: thread ID to post into, or list with one ID;
809                 if False/0, mail.message model will also be set as False
810             :param str body: body of the message, usually raw HTML that will
811                 be sanitized
812             :param str subject: optional subject
813             :param str type: mail_message.type
814             :param int parent_id: optional ID of parent message in this thread
815             :param tuple(str,str) attachments or list id: list of attachment tuples in the form
816                 ``(name,content)``, where content is NOT base64 encoded
817             :return: ID of newly created mail.message
818         """
819         if context is None:
820             context = {}
821         if attachments is None:
822             attachments = {}
823
824         assert (not thread_id) or isinstance(thread_id, (int, long)) or \
825             (isinstance(thread_id, (list, tuple)) and len(thread_id) == 1), "Invalid thread_id; should be 0, False, an ID or a list with one ID"
826         if isinstance(thread_id, (list, tuple)):
827             thread_id = thread_id and thread_id[0]
828         mail_message = self.pool.get('mail.message')
829         model = context.get('thread_model', self._name) if thread_id else False
830
831         attachment_ids = []
832         for name, content in attachments:
833             if isinstance(content, unicode):
834                 content = content.encode('utf-8')
835             data_attach = {
836                 'name': name,
837                 'datas': base64.b64encode(str(content)),
838                 'datas_fname': name,
839                 'description': name,
840                 'res_model': context.get('thread_model') or self._name,
841                 'res_id': thread_id,
842             }
843             attachment_ids.append((0, 0, data_attach))
844
845         # fetch subtype
846         if subtype:
847             s_data = subtype.split('.')
848             if len(s_data) == 1:
849                 s_data = ('mail', s_data[0])
850             ref = self.pool.get('ir.model.data').get_object_reference(cr, uid, s_data[0], s_data[1])
851             subtype_id = ref and ref[1] or False
852         else:
853             subtype_id = False
854
855         # _mail_flat_thread: automatically set free messages to the first posted message
856         if self._mail_flat_thread and not parent_id and thread_id:
857             message_ids = mail_message.search(cr, uid, ['&', ('res_id', '=', thread_id), ('model', '=', model)], context=context, order="id ASC", limit=1)
858             parent_id = message_ids and message_ids[0] or False
859         # 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
860         elif parent_id:
861             message_ids = mail_message.search(cr, SUPERUSER_ID, [('id', '=', parent_id), ('parent_id', '!=', False)], context=context)
862             # avoid loops when finding ancestors
863             processed_list = []
864             if message_ids:
865                 message = mail_message.browse(cr, SUPERUSER_ID, message_ids[0], context=context)
866                 while (message.parent_id and message.parent_id.id not in processed_list):
867                     processed_list.append(message.parent_id.id)
868                     message = message.parent_id
869                 parent_id = message.id
870
871         values = kwargs
872         values.update({
873             'model': model,
874             'res_id': thread_id or False,
875             'body': body,
876             'subject': subject or False,
877             'type': type,
878             'parent_id': parent_id,
879             'attachment_ids': attachment_ids,
880             'subtype_id': subtype_id,
881         })
882
883         # Avoid warnings about non-existing fields
884         for x in ('from', 'to', 'cc'):
885             values.pop(x, None)
886
887         return mail_message.create(cr, uid, values, context=context)
888
889     def message_post_user_api(self, cr, uid, thread_id, body='', subject=False, parent_id=False,
890                                 attachment_ids=None, context=None, content_subtype='plaintext',
891                                 extra_email=[], **kwargs):
892         """ Wrapper on message_post, used for user input :
893             - mail gateway
894             - quick reply in Chatter (refer to mail.js), not
895                 the mail.compose.message wizard
896             The purpose is to perform some pre- and post-processing:
897             - if body is plaintext: convert it into html
898             - if parent_id: handle reply to a previous message by adding the
899                 parent partners to the message
900             - type and subtype: comment and mail.mt_comment by default
901             - attachment_ids: supposed not attached to any document; attach them
902                 to the related document. Should only be set by Chatter.
903             - extra_email: [ 'Fabien <fpi@openerp.com>', 'al@openerp.com' ]
904         """
905         ir_attachment = self.pool.get('ir.attachment')
906         mail_message = self.pool.get('mail.message')
907
908         # 1. Pre-processing: body, partner_ids, type and subtype
909         if content_subtype == 'plaintext':
910             body = tools.plaintext2html(body)
911
912         for partner in extra_email:
913             part_ids = self.pool.get('res.partner').search(cr, uid, [('email', '=', partner)], context=context)
914             if not part_ids:
915                 part_ids = [self.pool.get('res.partner').name_create(cr, uid, partner, context=context)[0]]
916             self.message_subscribe(cr, uid, [thread_id], part_ids, context=context)
917
918         partner_ids = kwargs.pop('partner_ids', [])
919         if parent_id:
920             parent_message = self.pool.get('mail.message').browse(cr, uid, parent_id, context=context)
921             partner_ids += [(4, partner.id) for partner in parent_message.partner_ids]
922             # TDE FIXME HACK: mail.thread -> private message
923             if self._name == 'mail.thread' and parent_message.author_id.id:
924                 partner_ids.append((4, parent_message.author_id.id))
925
926         message_type = kwargs.pop('type', 'comment')
927         message_subtype = kwargs.pop('subtype', 'mail.mt_comment')
928
929         # 2. Post message
930         new_message_id = self.message_post(cr, uid, thread_id=thread_id, body=body, subject=subject, type=message_type,
931                         subtype=message_subtype, parent_id=parent_id, context=context, partner_ids=partner_ids, **kwargs)
932
933         # 3. Post-processing
934         # HACK TDE FIXME: Chatter: attachments linked to the document (not done JS-side), load the message
935         if attachment_ids:
936             # TDE FIXME (?): when posting a private message, we use mail.thread as a model
937             # However, attaching doc to mail.thread is not possible, mail.thread does not have any table
938             model = self._name
939             if model == 'mail.thread':
940                 model = False
941             filtered_attachment_ids = ir_attachment.search(cr, SUPERUSER_ID, [
942                 ('res_model', '=', 'mail.compose.message'),
943                 ('res_id', '=', 0),
944                 ('create_uid', '=', uid),
945                 ('id', 'in', attachment_ids)], context=context)
946             if filtered_attachment_ids:
947                 if thread_id and model:
948                     ir_attachment.write(cr, SUPERUSER_ID, attachment_ids, {'res_model': model, 'res_id': thread_id}, context=context)
949                 mail_message.write(cr, SUPERUSER_ID, [new_message_id], {'attachment_ids': [(6, 0, [pid for pid in attachment_ids])]}, context=context)
950
951         return new_message_id
952
953     #------------------------------------------------------
954     # Followers API
955     #------------------------------------------------------
956
957     def message_get_subscription_data(self, cr, uid, ids, context=None):
958         """ Wrapper to get subtypes data. """
959         return self._get_subscription_data(cr, uid, ids, None, None, context=context)
960
961     def message_subscribe_users(self, cr, uid, ids, user_ids=None, subtype_ids=None, context=None):
962         """ Wrapper on message_subscribe, using users. If user_ids is not
963             provided, subscribe uid instead. """
964         if user_ids is None:
965             user_ids = [uid]
966         partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
967         return self.message_subscribe(cr, uid, ids, partner_ids, subtype_ids=subtype_ids, context=context)
968
969     def message_subscribe(self, cr, uid, ids, partner_ids, subtype_ids=None, context=None):
970         """ Add partners to the records followers. """
971         self.check_access_rights(cr, uid, 'read')
972         self.write(cr, SUPERUSER_ID, ids, {'message_follower_ids': [(4, pid) for pid in partner_ids]}, context=context)
973         # if subtypes are not specified (and not set to a void list), fetch default ones
974         if subtype_ids is None:
975             subtype_obj = self.pool.get('mail.message.subtype')
976             subtype_ids = subtype_obj.search(cr, uid, [('default', '=', True), '|', ('res_model', '=', self._name), ('res_model', '=', False)], context=context)
977         # update the subscriptions
978         fol_obj = self.pool.get('mail.followers')
979         fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids), ('partner_id', 'in', partner_ids)], context=context)
980         fol_obj.write(cr, SUPERUSER_ID, fol_ids, {'subtype_ids': [(6, 0, subtype_ids)]}, context=context)
981         return True
982
983     def message_unsubscribe_users(self, cr, uid, ids, user_ids=None, context=None):
984         """ Wrapper on message_subscribe, using users. If user_ids is not
985             provided, unsubscribe uid instead. """
986         if user_ids is None:
987             user_ids = [uid]
988         partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
989         return self.message_unsubscribe(cr, uid, ids, partner_ids, context=context)
990
991     def message_unsubscribe(self, cr, uid, ids, partner_ids, context=None):
992         """ Remove partners from the records followers. """
993         self.check_access_rights(cr, uid, 'read')
994         return self.write(cr, SUPERUSER_ID, ids, {'message_follower_ids': [(3, pid) for pid in partner_ids]}, context=context)
995
996     def message_subscribe_from_parent(self, cr, uid, ids, updated_fields, context=None):
997         """
998             1. fetch project subtype related to task (parent_id.res_model = 'project.task')
999             2. for each project subtype: subscribe the follower to the task
1000         """
1001         subtype_obj = self.pool.get('mail.message.subtype')
1002         follower_obj = self.pool.get('mail.followers')
1003
1004         # fetch related record subtypes
1005         related_subtype_ids = subtype_obj.search(cr, uid, ['|', ('res_model', '=', False), ('parent_id.res_model', '=', self._name)], context=context)
1006         subtypes = subtype_obj.browse(cr, uid, related_subtype_ids, context=context)
1007         default_subtypes = [subtype for subtype in subtypes if subtype.res_model == False]
1008         related_subtypes = [subtype for subtype in subtypes if subtype.res_model != False]
1009         relation_fields = set([subtype.relation_field for subtype in subtypes if subtype.relation_field != False])
1010         if not related_subtypes or not any(relation in updated_fields for relation in relation_fields):
1011             return True
1012
1013         for record in self.browse(cr, uid, ids, context=context):
1014             new_followers = dict()
1015             parent_res_id = False
1016             parent_model = False
1017             for subtype in related_subtypes:
1018                 if not subtype.relation_field or not subtype.parent_id:
1019                     continue
1020                 if not subtype.relation_field in self._columns or not getattr(record, subtype.relation_field, False):
1021                     continue
1022                 parent_res_id = getattr(record, subtype.relation_field).id
1023                 parent_model = subtype.res_model
1024                 follower_ids = follower_obj.search(cr, SUPERUSER_ID, [
1025                     ('res_model', '=', parent_model),
1026                     ('res_id', '=', parent_res_id),
1027                     ('subtype_ids', 'in', [subtype.id])
1028                     ], context=context)
1029                 for follower in follower_obj.browse(cr, SUPERUSER_ID, follower_ids, context=context):
1030                     new_followers.setdefault(follower.partner_id.id, set()).add(subtype.parent_id.id)
1031
1032             if not parent_res_id or not parent_model:
1033                 continue
1034
1035             for subtype in default_subtypes:
1036                 follower_ids = follower_obj.search(cr, SUPERUSER_ID, [
1037                     ('res_model', '=', parent_model),
1038                     ('res_id', '=', parent_res_id),
1039                     ('subtype_ids', 'in', [subtype.id])
1040                     ], context=context)
1041                 for follower in follower_obj.browse(cr, SUPERUSER_ID, follower_ids, context=context):
1042                     new_followers.setdefault(follower.partner_id.id, set()).add(subtype.id)
1043
1044             for pid, subtypes in new_followers.items():
1045                 self.message_subscribe(cr, uid, [record.id], [pid], list(subtypes), context=context)
1046         return True
1047
1048     #------------------------------------------------------
1049     # Thread state
1050     #------------------------------------------------------
1051
1052     def message_mark_as_unread(self, cr, uid, ids, context=None):
1053         """ Set as unread. """
1054         partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1055         cr.execute('''
1056             UPDATE mail_notification SET
1057                 read=false
1058             WHERE
1059                 message_id IN (SELECT id from mail_message where res_id=any(%s) and model=%s limit 1) and
1060                 partner_id = %s
1061         ''', (ids, self._name, partner_id))
1062         return True
1063
1064     def message_mark_as_read(self, cr, uid, ids, context=None):
1065         """ Set as read. """
1066         partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1067         cr.execute('''
1068             UPDATE mail_notification SET
1069                 read=true
1070             WHERE
1071                 message_id IN (SELECT id FROM mail_message WHERE res_id=ANY(%s) AND model=%s) AND
1072                 partner_id = %s
1073         ''', (ids, self._name, partner_id))
1074         return True
1075
1076 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: