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