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