[CLEAN] Removed unused variables.
[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 dateutil
24 import email
25 import logging
26 import pytz
27 import time
28 import tools
29 import xmlrpclib
30
31 from email.message import Message
32 from mail_message import decode
33 from openerp import SUPERUSER_ID
34 from osv import osv, fields
35 from tools.safe_eval import safe_eval as eval
36
37 _logger = logging.getLogger(__name__)
38
39
40 def decode_header(message, header, separator=' '):
41     return separator.join(map(decode, message.get_all(header, [])))
42
43
44 class mail_thread(osv.AbstractModel):
45     ''' mail_thread model is meant to be inherited by any model that needs to
46         act as a discussion topic on which messages can be attached. Public
47         methods are prefixed with ``message_`` in order to avoid name
48         collisions with methods of the models that will inherit from this class.
49
50         ``mail.thread`` defines fields used to handle and display the
51         communication history. ``mail.thread`` also manages followers of
52         inheriting classes. All features and expected behavior are managed
53         by mail.thread. Widgets has been designed for the 7.0 and following
54         versions of OpenERP.
55
56         Inheriting classes are not required to implement any method, as the
57         default implementation will work for any model. However it is common
58         to override at least the ``message_new`` and ``message_update``
59         methods (calling ``super``) to add model-specific behavior at
60         creation and update of a thread when processing incoming emails.
61
62         Options:
63             - _mail_flat_thread: if set to True, all messages without parent_id
64                 are automatically attached to the first message posted on the
65                 ressource. If set to False, the display of Chatter is done using
66                 threads, and no parent_id is automatically set.
67     '''
68     _name = 'mail.thread'
69     _description = 'Email Thread'
70     _mail_flat_thread = True
71
72     def _get_message_data(self, cr, uid, ids, name, args, context=None):
73         """ Computes:
74             - message_unread: has uid unread message for the document
75             - message_summary: html snippet summarizing the Chatter for kanban views """
76         res = dict((id, dict(message_unread=False, message_summary='')) for id in ids)
77
78         # search for unread messages, by reading directly mail.notification, as SUPERUSER
79         notif_obj = self.pool.get('mail.notification')
80         notif_ids = notif_obj.search(cr, SUPERUSER_ID, [
81             ('partner_id.user_ids', 'in', [uid]),
82             ('message_id.res_id', 'in', ids),
83             ('message_id.model', '=', self._name),
84             ('read', '=', False)
85         ], context=context)
86         for notif in notif_obj.browse(cr, SUPERUSER_ID, notif_ids, context=context):
87             res[notif.message_id.res_id]['message_unread'] = True
88
89         for thread in self.browse(cr, uid, ids, context=context):
90             cls = res[thread.id]['message_unread'] and ' class="oe_kanban_mail_new"' or ''
91             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_comment_ids), len(thread.message_follower_ids))
92
93         return res
94
95     def _get_subscription_data(self, cr, uid, ids, name, args, context=None):
96         """ Computes:
97             - message_subtype_data: data about document subtypes: which are
98                 available, which are followed if any """
99         res = dict((id, dict(message_subtype_data='')) for id in ids)
100         user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
101
102         # find current model subtypes, add them to a dictionary
103         subtype_obj = self.pool.get('mail.message.subtype')
104         subtype_ids = subtype_obj.search(cr, uid, ['|', ('res_model', '=', self._name), ('res_model', '=', False)], context=context)
105         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))
106         for id in ids:
107             res[id]['message_subtype_data'] = subtype_dict.copy()
108
109         # find the document followers, update the data
110         fol_obj = self.pool.get('mail.followers')
111         fol_ids = fol_obj.search(cr, uid, [
112             ('partner_id', '=', user_pid),
113             ('res_id', 'in', ids),
114             ('res_model', '=', self._name),
115         ], context=context)
116         for fol in fol_obj.browse(cr, uid, fol_ids, context=context):
117             thread_subtype_dict = res[fol.res_id]['message_subtype_data']
118             for subtype in fol.subtype_ids:
119                 thread_subtype_dict[subtype.name]['followed'] = True
120             res[fol.res_id]['message_subtype_data'] = thread_subtype_dict
121
122         return res
123
124     def _search_unread(self, cr, uid, obj=None, name=None, domain=None, context=None):
125         partner_id = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
126         res = {}
127         notif_obj = self.pool.get('mail.notification')
128         notif_ids = notif_obj.search(cr, uid, [
129             ('partner_id', '=', partner_id),
130             ('message_id.model', '=', self._name),
131             ('read', '=', False)
132         ], context=context)
133         for notif in notif_obj.browse(cr, uid, notif_ids, context=context):
134             res[notif.message_id.res_id] = True
135         return [('id', 'in', res.keys())]
136
137     def _get_followers(self, cr, uid, ids, name, arg, context=None):
138         fol_obj = self.pool.get('mail.followers')
139         fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids)])
140         res = dict((id, dict(message_follower_ids=[], message_is_follower=False)) for id in ids)
141         user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
142         for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids):
143             res[fol.res_id]['message_follower_ids'].append(fol.partner_id.id)
144             if fol.partner_id.id == user_pid:
145                 res[fol.res_id]['message_is_follower'] = True
146         return res
147
148     def _set_followers(self, cr, uid, id, name, value, arg, context=None):
149         if not value:
150             return
151         partner_obj = self.pool.get('res.partner')
152         fol_obj = self.pool.get('mail.followers')
153
154         # read the old set of followers, and determine the new set of followers
155         fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', '=', id)])
156         old = set(fol.partner_id.id for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids))
157         new = set(old)
158
159         for command in value or []:
160             if isinstance(command, (int, long)):
161                 new.add(command)
162             elif command[0] == 0:
163                 new.add(partner_obj.create(cr, uid, command[2], context=context))
164             elif command[0] == 1:
165                 partner_obj.write(cr, uid, [command[1]], command[2], context=context)
166                 new.add(command[1])
167             elif command[0] == 2:
168                 partner_obj.unlink(cr, uid, [command[1]], context=context)
169                 new.discard(command[1])
170             elif command[0] == 3:
171                 new.discard(command[1])
172             elif command[0] == 4:
173                 new.add(command[1])
174             elif command[0] == 5:
175                 new.clear()
176             elif command[0] == 6:
177                 new = set(command[2])
178
179         # remove partners that are no longer followers
180         fol_ids = fol_obj.search(cr, SUPERUSER_ID,
181             [('res_model', '=', self._name), ('res_id', '=', id), ('partner_id', 'not in', list(new))])
182         fol_obj.unlink(cr, SUPERUSER_ID, fol_ids)
183
184         # add new followers
185         for partner_id in new - old:
186             fol_obj.create(cr, SUPERUSER_ID, {'res_model': self._name, 'res_id': id, 'partner_id': partner_id})
187
188     def _search_followers(self, cr, uid, obj, name, args, context):
189         fol_obj = self.pool.get('mail.followers')
190         res = []
191         for field, operator, value in args:
192             assert field == name
193             fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('partner_id', operator, value)])
194             res_ids = [fol.res_id for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids)]
195             res.append(('id', 'in', res_ids))
196         return res
197
198     _columns = {
199         'message_is_follower': fields.function(_get_followers,
200             type='boolean', string='Is a Follower', multi='_get_followers,'),
201         'message_follower_ids': fields.function(_get_followers, fnct_inv=_set_followers,
202                 fnct_search=_search_followers, type='many2many',
203                 obj='res.partner', string='Followers', multi='_get_followers'),
204         'message_comment_ids': fields.one2many('mail.message', 'res_id',
205             domain=lambda self: [('model', '=', self._name), ('type', 'in', ('comment', 'email'))],
206             string='Comments and emails',
207             help="Comments and emails"),
208         'message_ids': fields.one2many('mail.message', 'res_id',
209             domain=lambda self: [('model', '=', self._name)],
210             string='Messages',
211             help="Messages and communication history"),
212         'message_unread': fields.function(_get_message_data, fnct_search=_search_unread,
213             type='boolean', string='Unread Messages', multi="_get_message_data",
214             help="If checked new messages require your attention."),
215         'message_summary': fields.function(_get_message_data, method=True,
216             type='text', string='Summary', multi="_get_message_data",
217             help="Holds the Chatter summary (number of messages, ...). "\
218                  "This summary is directly in html format in order to "\
219                  "be inserted in kanban views."),
220     }
221
222     #------------------------------------------------------
223     # Automatic subscription when creating
224     #------------------------------------------------------
225
226     def create(self, cr, uid, vals, context=None):
227         """ Override to subscribe the current user. """
228         thread_id = super(mail_thread, self).create(cr, uid, vals, context=context)
229         self.message_subscribe_users(cr, uid, [thread_id], [uid], context=context)
230         return thread_id
231
232     def unlink(self, cr, uid, ids, context=None):
233         """ Override unlink to delete messages and followers. This cannot be
234             cascaded, because link is done through (res_model, res_id). """
235         msg_obj = self.pool.get('mail.message')
236         fol_obj = self.pool.get('mail.followers')
237         # delete messages and notifications
238         msg_ids = msg_obj.search(cr, uid, [('model', '=', self._name), ('res_id', 'in', ids)], context=context)
239         msg_obj.unlink(cr, uid, msg_ids, context=context)
240         # delete followers
241         fol_ids = fol_obj.search(cr, uid, [('res_model', '=', self._name), ('res_id', 'in', ids)], context=context)
242         fol_obj.unlink(cr, uid, fol_ids, context=context)
243         return super(mail_thread, self).unlink(cr, uid, ids, context=context)
244
245     def copy(self, cr, uid, id, default=None, context=None):
246         default = default or {}
247         default['message_ids'] = []
248         default['message_comment_ids'] = []
249         default['message_follower_ids'] = []
250         return super(mail_thread, self).copy(cr, uid, id, default=default, context=context)
251
252     #------------------------------------------------------
253     # mail.message wrappers and tools
254     #------------------------------------------------------
255
256     def _needaction_domain_get(self, cr, uid, context=None):
257         if self._needaction:
258             return [('message_unread', '=', True)]
259         return []
260
261     #------------------------------------------------------
262     # Mail gateway
263     #------------------------------------------------------
264
265     def message_capable_models(self, cr, uid, context=None):
266         """ Used by the plugin addon, based for plugin_outlook and others. """
267         ret_dict = {}
268         for model_name in self.pool.obj_list():
269             model = self.pool.get(model_name)
270             if 'mail.thread' in getattr(model, '_inherit', []):
271                 ret_dict[model_name] = model._description
272         return ret_dict
273
274     def _message_find_partners(self, cr, uid, message, header_fields=['From'], context=None):
275         """ Find partners related to some header fields of the message. """
276         s = ', '.join([decode(message.get(h)) for h in header_fields if message.get(h)])
277         return [partner_id for email in tools.email_split(s)
278                 for partner_id in self.pool.get('res.partner').search(cr, uid, [('email', 'ilike', email)], context=context)]
279
280     def _message_find_user_id(self, cr, uid, message, context=None):
281         from_local_part = tools.email_split(decode(message.get('From')))[0]
282         # FP Note: canonification required, the minimu: .lower()
283         user_ids = self.pool.get('res.users').search(cr, uid, ['|',
284             ('login', '=', from_local_part),
285             ('email', '=', from_local_part)], context=context)
286         return user_ids[0] if user_ids else uid
287
288     def message_route(self, cr, uid, message, model=None, thread_id=None,
289                       custom_values=None, context=None):
290         """Attempt to figure out the correct target model, thread_id,
291         custom_values and user_id to use for an incoming message.
292         Multiple values may be returned, if a message had multiple
293         recipients matching existing mail.aliases, for example.
294
295         The following heuristics are used, in this order:
296              1. If the message replies to an existing thread_id, and
297                 properly contains the thread model in the 'In-Reply-To'
298                 header, use this model/thread_id pair, and ignore
299                 custom_value (not needed as no creation will take place)
300              2. Look for a mail.alias entry matching the message
301                 recipient, and use the corresponding model, thread_id,
302                 custom_values and user_id.
303              3. Fallback to the ``model``, ``thread_id`` and ``custom_values``
304                 provided.
305              4. If all the above fails, raise an exception.
306
307            :param string message: an email.message instance
308            :param string model: the fallback model to use if the message
309                does not match any of the currently configured mail aliases
310                (may be None if a matching alias is supposed to be present)
311            :type dict custom_values: optional dictionary of default field values
312                 to pass to ``message_new`` if a new record needs to be created.
313                 Ignored if the thread record already exists, and also if a
314                 matching mail.alias was found (aliases define their own defaults)
315            :param int thread_id: optional ID of the record/thread from ``model``
316                to which this mail should be attached. Only used if the message
317                does not reply to an existing thread and does not match any mail alias.
318            :return: list of [model, thread_id, custom_values, user_id]
319         """
320         assert isinstance(message, Message), 'message must be an email.message.Message at this point'
321         message_id = message.get('Message-Id')
322
323         # 1. Verify if this is a reply to an existing thread
324         references = decode_header(message, 'References') or decode_header(message, 'In-Reply-To')
325         ref_match = references and tools.reference_re.search(references)
326         if ref_match:
327             thread_id = int(ref_match.group(1))
328             model = ref_match.group(2) or model
329             model_pool = self.pool.get(model)
330             if thread_id and model and model_pool and model_pool.exists(cr, uid, thread_id) \
331                 and hasattr(model_pool, 'message_update'):
332                 _logger.debug('Routing mail with Message-Id %s: direct reply to model: %s, thread_id: %s, custom_values: %s, uid: %s',
333                               message_id, model, thread_id, custom_values, uid)
334                 return [(model, thread_id, custom_values, uid)]
335
336         # 2. Look for a matching mail.alias entry
337         # Delivered-To is a safe bet in most modern MTAs, but we have to fallback on To + Cc values
338         # for all the odd MTAs out there, as there is no standard header for the envelope's `rcpt_to` value.
339         rcpt_tos = decode_header(message, 'Delivered-To') or \
340              ','.join([decode_header(message, 'To'),
341                        decode_header(message, 'Cc'),
342                        decode_header(message, 'Resent-To'),
343                        decode_header(message, 'Resent-Cc')])
344         local_parts = [e.split('@')[0] for e in tools.email_split(rcpt_tos)]
345         if local_parts:
346             mail_alias = self.pool.get('mail.alias')
347             alias_ids = mail_alias.search(cr, uid, [('alias_name', 'in', local_parts)])
348             if alias_ids:
349                 routes = []
350                 for alias in mail_alias.browse(cr, uid, alias_ids, context=context):
351                     user_id = alias.alias_user_id.id
352                     if not user_id:
353                         user_id = self._message_find_user_id(cr, uid, message, context=context)
354                     routes.append((alias.alias_model_id.model, alias.alias_force_thread_id, \
355                                    eval(alias.alias_defaults), user_id))
356                 _logger.debug('Routing mail with Message-Id %s: direct alias match: %r', message_id, routes)
357                 return routes
358
359         # 3. Fallback to the provided parameters, if they work
360         model_pool = self.pool.get(model)
361         if not thread_id:
362             # Legacy: fallback to matching [ID] in the Subject
363             match = tools.res_re.search(decode_header(message, 'Subject'))
364             thread_id = match and match.group(1)
365         assert thread_id and hasattr(model_pool, 'message_update') or hasattr(model_pool, 'message_new'), \
366             "No possible route found for incoming message with Message-Id %s. " \
367             "Create an appropriate mail.alias or force the destination model." % message_id
368         if thread_id and not model_pool.exists(cr, uid, thread_id):
369             _logger.warning('Received mail reply to missing document %s! Ignoring and creating new document instead for Message-Id %s',
370                             thread_id, message_id)
371             thread_id = None
372         _logger.debug('Routing mail with Message-Id %s: fallback to model:%s, thread_id:%s, custom_values:%s, uid:%s',
373                       message_id, model, thread_id, custom_values, uid)
374         return [(model, thread_id, custom_values, uid)]
375
376     def message_process(self, cr, uid, model, message, custom_values=None,
377                         save_original=False, strip_attachments=False,
378                         thread_id=None, context=None):
379         """Process an incoming RFC2822 email message, relying on
380            ``mail.message.parse()`` for the parsing operation,
381            and ``message_route()`` to figure out the target model.
382
383            Once the target model is known, its ``message_new`` method
384            is called with the new message (if the thread record did not exist)
385             or its ``message_update`` method (if it did).
386
387            :param string model: the fallback model to use if the message
388                does not match any of the currently configured mail aliases
389                (may be None if a matching alias is supposed to be present)
390            :param message: source of the RFC2822 message
391            :type message: string or xmlrpclib.Binary
392            :type dict custom_values: optional dictionary of field values
393                 to pass to ``message_new`` if a new record needs to be created.
394                 Ignored if the thread record already exists, and also if a
395                 matching mail.alias was found (aliases define their own defaults)
396            :param bool save_original: whether to keep a copy of the original
397                 email source attached to the message after it is imported.
398            :param bool strip_attachments: whether to strip all attachments
399                 before processing the message, in order to save some space.
400            :param int thread_id: optional ID of the record/thread from ``model``
401                to which this mail should be attached. When provided, this
402                overrides the automatic detection based on the message
403                headers.
404         """
405         if context is None:
406             context = {}
407
408         # extract message bytes - we are forced to pass the message as binary because
409         # we don't know its encoding until we parse its headers and hence can't
410         # convert it to utf-8 for transport between the mailgate script and here.
411         if isinstance(message, xmlrpclib.Binary):
412             message = str(message.data)
413         # Warning: message_from_string doesn't always work correctly on unicode,
414         # we must use utf-8 strings here :-(
415         if isinstance(message, unicode):
416             message = message.encode('utf-8')
417         msg_txt = email.message_from_string(message)
418         routes = self.message_route(cr, uid, msg_txt, model,
419                                     thread_id, custom_values,
420                                     context=context)
421         msg = self.message_parse(cr, uid, msg_txt, save_original=save_original, context=context)
422         if strip_attachments:
423             msg.pop('attachments', None)
424         thread_id = False
425         for model, thread_id, custom_values, user_id in routes:
426             if self._name != model:
427                 context.update({'thread_model': model})
428             model_pool = self.pool.get(model)
429             assert thread_id and hasattr(model_pool, 'message_update') or hasattr(model_pool, 'message_new'), \
430                 "Undeliverable mail with Message-Id %s, model %s does not accept incoming emails" % \
431                     (msg['message_id'], model)
432             if thread_id and hasattr(model_pool, 'message_update'):
433                 model_pool.message_update(cr, user_id, [thread_id], msg, context=context)
434             else:
435                 thread_id = model_pool.message_new(cr, user_id, msg, custom_values, context=context)
436             model_pool.message_post(cr, uid, [thread_id], context=context, **msg)
437         return thread_id
438
439     def message_new(self, cr, uid, msg_dict, custom_values=None, context=None):
440         """Called by ``message_process`` when a new message is received
441            for a given thread model, if the message did not belong to
442            an existing thread.
443            The default behavior is to create a new record of the corresponding
444            model (based on some very basic info extracted from the message).
445            Additional behavior may be implemented by overriding this method.
446
447            :param dict msg_dict: a map containing the email details and
448                                  attachments. See ``message_process`` and
449                                 ``mail.message.parse`` for details.
450            :param dict custom_values: optional dictionary of additional
451                                       field values to pass to create()
452                                       when creating the new thread record.
453                                       Be careful, these values may override
454                                       any other values coming from the message.
455            :param dict context: if a ``thread_model`` value is present
456                                 in the context, its value will be used
457                                 to determine the model of the record
458                                 to create (instead of the current model).
459            :rtype: int
460            :return: the id of the newly created thread object
461         """
462         if context is None:
463             context = {}
464         model = context.get('thread_model') or self._name
465         model_pool = self.pool.get(model)
466         fields = model_pool.fields_get(cr, uid, context=context)
467         data = model_pool.default_get(cr, uid, fields, context=context)
468         if 'name' in fields and not data.get('name'):
469             data['name'] = msg_dict.get('subject', '')
470         if custom_values and isinstance(custom_values, dict):
471             data.update(custom_values)
472         res_id = model_pool.create(cr, uid, data, context=context)
473         return res_id
474
475     def message_update(self, cr, uid, ids, msg_dict, update_vals=None, context=None):
476         """Called by ``message_process`` when a new message is received
477            for an existing thread. The default behavior is to update the record
478            with update_vals taken from the incoming email.
479            Additional behavior may be implemented by overriding this
480            method.
481            :param dict msg_dict: a map containing the email details and
482                                attachments. See ``message_process`` and
483                                ``mail.message.parse()`` for details.
484            :param dict update_vals: a dict containing values to update records
485                               given their ids; if the dict is None or is
486                               void, no write operation is performed.
487         """
488         if update_vals:
489             self.write(cr, uid, ids, update_vals, context=context)
490         return True
491
492     def _message_extract_payload(self, message, save_original=False):
493         """Extract body as HTML and attachments from the mail message"""
494         attachments = []
495         body = u''
496         if save_original:
497             attachments.append(('original_email.eml', message.as_string()))
498         if not message.is_multipart() or 'text/' in message.get('content-type', ''):
499             encoding = message.get_content_charset()
500             body = message.get_payload(decode=True)
501             body = tools.ustr(body, encoding, errors='replace')
502             if message.get_content_type() == 'text/plain':
503                 # text/plain -> <pre/>
504                 body = tools.append_content_to_html(u'', body)
505         else:
506             alternative = (message.get_content_type() == 'multipart/alternative')
507             for part in message.walk():
508                 if part.get_content_maintype() == 'multipart':
509                     continue # skip container
510                 filename = part.get_filename() # None if normal part
511                 encoding = part.get_content_charset() # None if attachment
512                 # 1) Explicit Attachments -> attachments
513                 if filename or part.get('content-disposition', '').strip().startswith('attachment'):
514                     attachments.append((filename or 'attachment', part.get_payload(decode=True)))
515                     continue
516                 # 2) text/plain -> <pre/>
517                 if part.get_content_type() == 'text/plain' and (not alternative or not body):
518                     body = tools.append_content_to_html(body, tools.ustr(part.get_payload(decode=True),
519                                                                          encoding, errors='replace'))
520                 # 3) text/html -> raw
521                 elif part.get_content_type() == 'text/html':
522                     html = tools.ustr(part.get_payload(decode=True), encoding, errors='replace')
523                     if alternative:
524                         body = html
525                     else:
526                         body = tools.append_content_to_html(body, html, plaintext=False)
527                 # 4) Anything else -> attachment
528                 else:
529                     attachments.append((filename or 'attachment', part.get_payload(decode=True)))
530         return body, attachments
531
532     def message_parse(self, cr, uid, message, save_original=False, context=None):
533         """Parses a string or email.message.Message representing an
534            RFC-2822 email, and returns a generic dict holding the
535            message details.
536
537            :param message: the message to parse
538            :type message: email.message.Message | string | unicode
539            :param bool save_original: whether the returned dict
540                should include an ``original`` attachment containing
541                the source of the message
542            :rtype: dict
543            :return: A dict with the following structure, where each
544                     field may not be present if missing in original
545                     message::
546
547                     { 'message_id': msg_id,
548                       'subject': subject,
549                       'from': from,
550                       'to': to,
551                       'cc': cc,
552                       'body': unified_body,
553                       'attachments': [('file1', 'bytes'),
554                                       ('file2', 'bytes')}
555                     }
556         """
557         msg_dict = {
558             'type': 'email',
559             'subtype': 'mail.mt_comment',
560             'author_id': False,
561         }
562         if not isinstance(message, Message):
563             if isinstance(message, unicode):
564                 # Warning: message_from_string doesn't always work correctly on unicode,
565                 # we must use utf-8 strings here :-(
566                 message = message.encode('utf-8')
567             message = email.message_from_string(message)
568
569         message_id = message['message-id']
570         if not message_id:
571             # Very unusual situation, be we should be fault-tolerant here
572             message_id = "<%s@localhost>" % time.time()
573             _logger.debug('Parsing Message without message-id, generating a random one: %s', message_id)
574         msg_dict['message_id'] = message_id
575
576         if 'Subject' in message:
577             msg_dict['subject'] = decode(message.get('Subject'))
578
579         # Envelope fields not stored in mail.message but made available for message_new()
580         msg_dict['from'] = decode(message.get('from'))
581         msg_dict['to'] = decode(message.get('to'))
582         msg_dict['cc'] = decode(message.get('cc'))
583
584         if 'From' in message:
585             author_ids = self._message_find_partners(cr, uid, message, ['From'], context=context)
586             if author_ids:
587                 msg_dict['author_id'] = author_ids[0]
588             else:
589                 msg_dict['email_from'] = message.get('from')
590         partner_ids = self._message_find_partners(cr, uid, message, ['From', 'To', 'Cc'], context=context)
591         msg_dict['partner_ids'] = partner_ids
592
593         if 'Date' in message:
594             date_hdr = decode(message.get('Date'))
595             # convert from email timezone to server timezone
596             date_server_datetime = dateutil.parser.parse(date_hdr).astimezone(pytz.timezone(tools.get_server_timezone()))
597             date_server_datetime_str = date_server_datetime.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT)
598             msg_dict['date'] = date_server_datetime_str
599
600         if 'In-Reply-To' in message:
601             parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', '=', decode(message['In-Reply-To']))])
602             if parent_ids:
603                 msg_dict['parent_id'] = parent_ids[0]
604
605         if 'References' in message and 'parent_id' not in msg_dict:
606             parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', 'in',
607                                                                          [x.strip() for x in decode(message['References']).split()])])
608             if parent_ids:
609                 msg_dict['parent_id'] = parent_ids[0]
610
611         msg_dict['body'], msg_dict['attachments'] = self._message_extract_payload(message)
612         return msg_dict
613
614     #------------------------------------------------------
615     # Note specific
616     #------------------------------------------------------
617
618     def log(self, cr, uid, id, message, secondary=False, context=None):
619         _logger.warning("log() is deprecated. As this module inherit from "\
620                         "mail.thread, the message will be managed by this "\
621                         "module instead of by the res.log mechanism. Please "\
622                         "use mail_thread.message_post() instead of the "\
623                         "now deprecated res.log.")
624         self.message_post(cr, uid, [id], message, context=context)
625
626     def message_post(self, cr, uid, thread_id, body='', subject=None, type='notification',
627                         subtype=None, parent_id=False, attachments=None, context=None, **kwargs):
628         """ Post a new message in an existing thread, returning the new
629             mail.message ID. Extra keyword arguments will be used as default
630             column values for the new mail.message record.
631             Auto link messages for same id and object
632             :param int thread_id: thread ID to post into, or list with one ID
633             :param str body: body of the message, usually raw HTML that will
634                 be sanitized
635             :param str subject: optional subject
636             :param str type: mail_message.type
637             :param int parent_id: optional ID of parent message in this thread
638             :param tuple(str,str) attachments or list id: list of attachment tuples in the form
639                 ``(name,content)``, where content is NOT base64 encoded
640             :return: ID of newly created mail.message
641         """
642         context = context or {}
643         attachments = attachments or []
644         assert (not thread_id) or isinstance(thread_id, (int, long)) or \
645             (isinstance(thread_id, (list, tuple)) and len(thread_id) == 1), "Invalid thread_id"
646         if isinstance(thread_id, (list, tuple)):
647             thread_id = thread_id and thread_id[0]
648         mail_message = self.pool.get('mail.message')
649         model = context.get('thread_model', self._name) if thread_id else False
650
651         attachment_ids = []
652         for name, content in attachments:
653             if isinstance(content, unicode):
654                 content = content.encode('utf-8')
655             data_attach = {
656                 'name': name,
657                 'datas': base64.b64encode(str(content)),
658                 'datas_fname': name,
659                 'description': name,
660                 'res_model': context.get('thread_model') or self._name,
661                 'res_id': thread_id,
662             }
663             attachment_ids.append((0, 0, data_attach))
664
665         # fetch subtype
666         if subtype:
667             s_data = subtype.split('.')
668             if len(s_data) == 1:
669                 s_data = ('mail', s_data[0])
670             ref = self.pool.get('ir.model.data').get_object_reference(cr, uid, s_data[0], s_data[1])
671             subtype_id = ref and ref[1] or False
672         else:
673             subtype_id = False
674
675         # _mail_flat_thread: automatically set free messages to the first posted message
676         if self._mail_flat_thread and not parent_id and thread_id:
677             message_ids = mail_message.search(cr, uid, ['&', ('res_id', '=', thread_id), ('model', '=', model)], context=context, order="id ASC", limit=1)
678             parent_id = message_ids and message_ids[0] or False
679         # 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
680         elif parent_id:
681             message_ids = mail_message.search(cr, SUPERUSER_ID, [('id', '=', parent_id), ('parent_id', '!=', False)], context=context)
682             # avoid loops when finding ancestors
683             processed_list = []
684             if message_ids:
685                 message = mail_message.browse(cr, SUPERUSER_ID, message_ids[0], context=context)
686                 while (message.parent_id and message.parent_id.id not in processed_list):
687                     processed_list.append(message.parent_id.id)
688                     message = message.parent_id
689                 parent_id = message.id
690
691         values = kwargs
692         values.update({
693             'model': model,
694             'res_id': thread_id or False,
695             'body': body,
696             'subject': subject or False,
697             'type': type,
698             'parent_id': parent_id,
699             'attachment_ids': attachment_ids,
700             'subtype_id': subtype_id,
701         })
702
703         # Avoid warnings about non-existing fields
704         for x in ('from', 'to', 'cc'):
705             values.pop(x, None)
706
707         return mail_message.create(cr, uid, values, context=context)
708
709     def message_post_api(self, cr, uid, thread_id, body='', subject=False, parent_id=False, attachment_ids=None, context=None):
710         """ Wrapper on message_post, used only in Chatter (JS). The purpose is
711             to handle attachments.
712             # TDE FIXME: body is plaintext: convert it into html
713         """
714         new_message_id = self.message_post(cr, uid, thread_id=thread_id, body=body, subject=subject, type='comment',
715                         subtype='mail.mt_comment', parent_id=parent_id, context=context)
716
717         # HACK FIXME: Chatter: attachments linked to the document (not done JS-side), load the message
718         if attachment_ids:
719             ir_attachment = self.pool.get('ir.attachment')
720             mail_message = self.pool.get('mail.message')
721             filtered_attachment_ids = ir_attachment.search(cr, SUPERUSER_ID, [
722                 ('res_model', '=', 'mail.compose.message'),
723                 ('res_id', '=', 0),
724                 ('create_uid', '=', uid),
725                 ('id', 'in', attachment_ids)], context=context)
726             if filtered_attachment_ids:
727                 ir_attachment.write(cr, SUPERUSER_ID, attachment_ids, {'res_model': self._name, 'res_id': thread_id}, context=context)
728                 mail_message.write(cr, SUPERUSER_ID, [new_message_id], {'attachment_ids': [(6, 0, [pid for pid in attachment_ids])]}, context=context)
729
730         return new_message_id
731
732     #------------------------------------------------------
733     # Followers API
734     #------------------------------------------------------
735
736     def message_get_subscription_data(self, cr, uid, ids, context=None):
737         """ Wrapper to get subtypes data. """
738         return self._get_subscription_data(cr, uid, ids, None, None, context=context)
739
740     def message_subscribe_users(self, cr, uid, ids, user_ids=None, subtype_ids=None, context=None):
741         """ Wrapper on message_subscribe, using users. If user_ids is not
742             provided, subscribe uid instead. """
743         if user_ids is None:
744             user_ids = [uid]
745         partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
746         return self.message_subscribe(cr, uid, ids, partner_ids, subtype_ids=subtype_ids, context=context)
747
748     def message_subscribe(self, cr, uid, ids, partner_ids, subtype_ids=None, context=None):
749         """ Add partners to the records followers. """
750         self.write(cr, uid, ids, {'message_follower_ids': [(4, pid) for pid in partner_ids]}, context=context)
751         # if subtypes are not specified (and not set to a void list), fetch default ones
752         if subtype_ids is None:
753             subtype_obj = self.pool.get('mail.message.subtype')
754             subtype_ids = subtype_obj.search(cr, uid, [('default', '=', True), '|', ('res_model', '=', self._name), ('res_model', '=', False)], context=context)
755         # update the subscriptions
756         fol_obj = self.pool.get('mail.followers')
757         fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids), ('partner_id', 'in', partner_ids)], context=context)
758         fol_obj.write(cr, SUPERUSER_ID, fol_ids, {'subtype_ids': [(6, 0, subtype_ids)]}, context=context)
759         return True
760
761     def message_unsubscribe_users(self, cr, uid, ids, user_ids=None, context=None):
762         """ Wrapper on message_subscribe, using users. If user_ids is not
763             provided, unsubscribe uid instead. """
764         if user_ids is None:
765             user_ids = [uid]
766         partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
767         return self.message_unsubscribe(cr, uid, ids, partner_ids, context=context)
768
769     def message_unsubscribe(self, cr, uid, ids, partner_ids, context=None):
770         """ Remove partners from the records followers. """
771         return self.write(cr, uid, ids, {'message_follower_ids': [(3, pid) for pid in partner_ids]}, context=context)
772
773     #------------------------------------------------------
774     # Thread state
775     #------------------------------------------------------
776
777     def message_mark_as_unread(self, cr, uid, ids, context=None):
778         """ Set as unread. """
779         partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
780         cr.execute('''
781             UPDATE mail_notification SET
782                 read=false
783             WHERE
784                 message_id IN (SELECT id from mail_message where res_id=any(%s) and model=%s limit 1) and
785                 partner_id = %s
786         ''', (ids, self._name, partner_id))
787         return True
788
789     def message_mark_as_read(self, cr, uid, ids, context=None):
790         """ Set as read. """
791         partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
792         cr.execute('''
793             UPDATE mail_notification SET
794                 read=true
795             WHERE
796                 message_id IN (SELECT id FROM mail_message WHERE res_id=ANY(%s) AND model=%s) AND
797                 partner_id = %s
798         ''', (ids, self._name, partner_id))
799         return True
800
801 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: