[FIX] message_route: when replying to a message, use its model and res_id, never...
[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         user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
78
79         # search for unread messages, directly in SQL to improve performances
80         cr.execute("""  SELECT m.res_id FROM mail_message m
81                         RIGHT JOIN mail_notification n
82                         ON (n.message_id = m.id AND n.partner_id = %s AND n.read = False)
83                         WHERE m.model = %s AND m.res_id in %s""",
84                     (user_pid, self._name, tuple(ids),))
85         msg_ids = [result[0] for result in cr.fetchall()]
86         for msg_id in msg_ids:
87             res[msg_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         references = decode_header(message, 'References')
323         in_reply_to = decode_header(message, 'In-Reply-To')
324
325         # 1. Verify if this is a reply to an existing thread
326         thread_references = references or in_reply_to
327         ref_match = thread_references and tools.reference_re.search(thread_references)
328         if ref_match:
329             thread_id = int(ref_match.group(1))
330             model = ref_match.group(2) or model
331             model_pool = self.pool.get(model)
332             if thread_id and model and model_pool and model_pool.exists(cr, uid, thread_id) \
333                 and hasattr(model_pool, 'message_update'):
334                 _logger.debug('Routing mail with Message-Id %s: direct reply to model: %s, thread_id: %s, custom_values: %s, uid: %s',
335                               message_id, model, thread_id, custom_values, uid)
336                 return [(model, thread_id, custom_values, uid)]
337
338         # Verify this is a reply to a private message
339         message_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', '=', in_reply_to)], limit=1, context=context)
340         if message_ids:
341             message = self.pool.get('mail.message').browse(cr, uid, message_ids[0], context=context)
342             _logger.debug('Routing mail with Message-Id %s: direct reply to a private message: %s, custom_values: %s, uid: %s',
343                             message_id, message.id, custom_values, uid)
344             return [(message.model, message.res_id, custom_values, uid)]
345
346         # 2. Look for a matching mail.alias entry
347         # Delivered-To is a safe bet in most modern MTAs, but we have to fallback on To + Cc values
348         # for all the odd MTAs out there, as there is no standard header for the envelope's `rcpt_to` value.
349         rcpt_tos = decode_header(message, 'Delivered-To') or \
350              ','.join([decode_header(message, 'To'),
351                        decode_header(message, 'Cc'),
352                        decode_header(message, 'Resent-To'),
353                        decode_header(message, 'Resent-Cc')])
354         local_parts = [e.split('@')[0] for e in tools.email_split(rcpt_tos)]
355         if local_parts:
356             mail_alias = self.pool.get('mail.alias')
357             alias_ids = mail_alias.search(cr, uid, [('alias_name', 'in', local_parts)])
358             if alias_ids:
359                 routes = []
360                 for alias in mail_alias.browse(cr, uid, alias_ids, context=context):
361                     user_id = alias.alias_user_id.id
362                     if not user_id:
363                         user_id = self._message_find_user_id(cr, uid, message, context=context)
364                     routes.append((alias.alias_model_id.model, alias.alias_force_thread_id, \
365                                    eval(alias.alias_defaults), user_id))
366                 _logger.debug('Routing mail with Message-Id %s: direct alias match: %r', message_id, routes)
367                 return routes
368
369         # 3. Fallback to the provided parameters, if they work
370         model_pool = self.pool.get(model)
371         if not thread_id:
372             # Legacy: fallback to matching [ID] in the Subject
373             match = tools.res_re.search(decode_header(message, 'Subject'))
374             thread_id = match and match.group(1)
375         assert thread_id and hasattr(model_pool, 'message_update') or hasattr(model_pool, 'message_new'), \
376             "No possible route found for incoming message with Message-Id %s. " \
377             "Create an appropriate mail.alias or force the destination model." % message_id
378         if thread_id and not model_pool.exists(cr, uid, thread_id):
379             _logger.warning('Received mail reply to missing document %s! Ignoring and creating new document instead for Message-Id %s',
380                             thread_id, message_id)
381             thread_id = None
382         _logger.debug('Routing mail with Message-Id %s: fallback to model:%s, thread_id:%s, custom_values:%s, uid:%s',
383                       message_id, model, thread_id, custom_values, uid)
384         return [(model, thread_id, custom_values, uid)]
385
386     def message_process(self, cr, uid, model, message, custom_values=None,
387                         save_original=False, strip_attachments=False,
388                         thread_id=None, context=None):
389         """ Process an incoming RFC2822 email message, relying on
390             ``mail.message.parse()`` for the parsing operation,
391             and ``message_route()`` to figure out the target model.
392
393             Once the target model is known, its ``message_new`` method
394             is called with the new message (if the thread record did not exist)
395             or its ``message_update`` method (if it did).
396
397             There is a special case where the target model is False: a reply
398             to a private message. In this case, we skip the message_new /
399             message_update step, to just post a new message using mail_thread
400             message_post.
401
402            :param string model: the fallback model to use if the message
403                does not match any of the currently configured mail aliases
404                (may be None if a matching alias is supposed to be present)
405            :param message: source of the RFC2822 message
406            :type message: string or xmlrpclib.Binary
407            :type dict custom_values: optional dictionary of field values
408                 to pass to ``message_new`` if a new record needs to be created.
409                 Ignored if the thread record already exists, and also if a
410                 matching mail.alias was found (aliases define their own defaults)
411            :param bool save_original: whether to keep a copy of the original
412                 email source attached to the message after it is imported.
413            :param bool strip_attachments: whether to strip all attachments
414                 before processing the message, in order to save some space.
415            :param int thread_id: optional ID of the record/thread from ``model``
416                to which this mail should be attached. When provided, this
417                overrides the automatic detection based on the message
418                headers.
419         """
420         if context is None:
421             context = {}
422
423         # extract message bytes - we are forced to pass the message as binary because
424         # we don't know its encoding until we parse its headers and hence can't
425         # convert it to utf-8 for transport between the mailgate script and here.
426         if isinstance(message, xmlrpclib.Binary):
427             message = str(message.data)
428         # Warning: message_from_string doesn't always work correctly on unicode,
429         # we must use utf-8 strings here :-(
430         if isinstance(message, unicode):
431             message = message.encode('utf-8')
432         msg_txt = email.message_from_string(message)
433         routes = self.message_route(cr, uid, msg_txt, model,
434                                     thread_id, custom_values,
435                                     context=context)
436         msg = self.message_parse(cr, uid, msg_txt, save_original=save_original, context=context)
437         if strip_attachments:
438             msg.pop('attachments', None)
439         thread_id = False
440         for model, thread_id, custom_values, user_id in routes:
441             if self._name != model:
442                 context.update({'thread_model': model})
443             if model:
444                 model_pool = self.pool.get(model)
445                 assert thread_id and hasattr(model_pool, 'message_update') or hasattr(model_pool, 'message_new'), \
446                     "Undeliverable mail with Message-Id %s, model %s does not accept incoming emails" % \
447                         (msg['message_id'], model)
448                 if thread_id and hasattr(model_pool, 'message_update'):
449                     model_pool.message_update(cr, user_id, [thread_id], msg, context=context)
450                 else:
451                     thread_id = model_pool.message_new(cr, user_id, msg, custom_values, context=context)
452             else:
453                 assert thread_id == 0, "Posting a message without model should be with a null res_id, to create a private message."
454                 model_pool = self.pool.get('mail.thread')
455             model_pool.message_post_user_api(cr, uid, [thread_id], context=context, content_subtype='html', **msg)
456         return thread_id
457
458     def message_new(self, cr, uid, msg_dict, custom_values=None, context=None):
459         """Called by ``message_process`` when a new message is received
460            for a given thread model, if the message did not belong to
461            an existing thread.
462            The default behavior is to create a new record of the corresponding
463            model (based on some very basic info extracted from the message).
464            Additional behavior may be implemented by overriding this method.
465
466            :param dict msg_dict: a map containing the email details and
467                                  attachments. See ``message_process`` and
468                                 ``mail.message.parse`` for details.
469            :param dict custom_values: optional dictionary of additional
470                                       field values to pass to create()
471                                       when creating the new thread record.
472                                       Be careful, these values may override
473                                       any other values coming from the message.
474            :param dict context: if a ``thread_model`` value is present
475                                 in the context, its value will be used
476                                 to determine the model of the record
477                                 to create (instead of the current model).
478            :rtype: int
479            :return: the id of the newly created thread object
480         """
481         if context is None:
482             context = {}
483         model = context.get('thread_model') or self._name
484         model_pool = self.pool.get(model)
485         fields = model_pool.fields_get(cr, uid, context=context)
486         data = model_pool.default_get(cr, uid, fields, context=context)
487         if 'name' in fields and not data.get('name'):
488             data['name'] = msg_dict.get('subject', '')
489         if custom_values and isinstance(custom_values, dict):
490             data.update(custom_values)
491         res_id = model_pool.create(cr, uid, data, context=context)
492         return res_id
493
494     def message_update(self, cr, uid, ids, msg_dict, update_vals=None, context=None):
495         """Called by ``message_process`` when a new message is received
496            for an existing thread. The default behavior is to update the record
497            with update_vals taken from the incoming email.
498            Additional behavior may be implemented by overriding this
499            method.
500            :param dict msg_dict: a map containing the email details and
501                                attachments. See ``message_process`` and
502                                ``mail.message.parse()`` for details.
503            :param dict update_vals: a dict containing values to update records
504                               given their ids; if the dict is None or is
505                               void, no write operation is performed.
506         """
507         if update_vals:
508             self.write(cr, uid, ids, update_vals, context=context)
509         return True
510
511     def _message_extract_payload(self, message, save_original=False):
512         """Extract body as HTML and attachments from the mail message"""
513         attachments = []
514         body = u''
515         if save_original:
516             attachments.append(('original_email.eml', message.as_string()))
517         if not message.is_multipart() or 'text/' in message.get('content-type', ''):
518             encoding = message.get_content_charset()
519             body = message.get_payload(decode=True)
520             body = tools.ustr(body, encoding, errors='replace')
521             if message.get_content_type() == 'text/plain':
522                 # text/plain -> <pre/>
523                 body = tools.append_content_to_html(u'', body, preserve=True)
524         else:
525             alternative = (message.get_content_type() == 'multipart/alternative')
526             for part in message.walk():
527                 if part.get_content_maintype() == 'multipart':
528                     continue # skip container
529                 filename = part.get_filename() # None if normal part
530                 encoding = part.get_content_charset() # None if attachment
531                 # 1) Explicit Attachments -> attachments
532                 if filename or part.get('content-disposition', '').strip().startswith('attachment'):
533                     attachments.append((filename or 'attachment', part.get_payload(decode=True)))
534                     continue
535                 # 2) text/plain -> <pre/>
536                 if part.get_content_type() == 'text/plain' and (not alternative or not body):
537                     body = tools.append_content_to_html(body, tools.ustr(part.get_payload(decode=True),
538                                                                          encoding, errors='replace'), preserve=True)
539                 # 3) text/html -> raw
540                 elif part.get_content_type() == 'text/html':
541                     html = tools.ustr(part.get_payload(decode=True), encoding, errors='replace')
542                     if alternative:
543                         body = html
544                     else:
545                         body = tools.append_content_to_html(body, html, plaintext=False)
546                 # 4) Anything else -> attachment
547                 else:
548                     attachments.append((filename or 'attachment', part.get_payload(decode=True)))
549         return body, attachments
550
551     def message_parse(self, cr, uid, message, save_original=False, context=None):
552         """Parses a string or email.message.Message representing an
553            RFC-2822 email, and returns a generic dict holding the
554            message details.
555
556            :param message: the message to parse
557            :type message: email.message.Message | string | unicode
558            :param bool save_original: whether the returned dict
559                should include an ``original`` attachment containing
560                the source of the message
561            :rtype: dict
562            :return: A dict with the following structure, where each
563                     field may not be present if missing in original
564                     message::
565
566                     { 'message_id': msg_id,
567                       'subject': subject,
568                       'from': from,
569                       'to': to,
570                       'cc': cc,
571                       'body': unified_body,
572                       'attachments': [('file1', 'bytes'),
573                                       ('file2', 'bytes')}
574                     }
575         """
576         msg_dict = {
577             'type': 'email',
578             'author_id': False,
579         }
580         if not isinstance(message, Message):
581             if isinstance(message, unicode):
582                 # Warning: message_from_string doesn't always work correctly on unicode,
583                 # we must use utf-8 strings here :-(
584                 message = message.encode('utf-8')
585             message = email.message_from_string(message)
586
587         message_id = message['message-id']
588         if not message_id:
589             # Very unusual situation, be we should be fault-tolerant here
590             message_id = "<%s@localhost>" % time.time()
591             _logger.debug('Parsing Message without message-id, generating a random one: %s', message_id)
592         msg_dict['message_id'] = message_id
593
594         if 'Subject' in message:
595             msg_dict['subject'] = decode(message.get('Subject'))
596
597         # Envelope fields not stored in mail.message but made available for message_new()
598         msg_dict['from'] = decode(message.get('from'))
599         msg_dict['to'] = decode(message.get('to'))
600         msg_dict['cc'] = decode(message.get('cc'))
601
602         if 'From' in message:
603             author_ids = self._message_find_partners(cr, uid, message, ['From'], context=context)
604             if author_ids:
605                 msg_dict['author_id'] = author_ids[0]
606             else:
607                 msg_dict['email_from'] = message.get('from')
608         partner_ids = self._message_find_partners(cr, uid, message, ['From', 'To', 'Cc'], context=context)
609         msg_dict['partner_ids'] = [(4, partner_id) for partner_id in partner_ids]
610
611         if 'Date' in message:
612             date_hdr = decode(message.get('Date'))
613             # convert from email timezone to server timezone
614             date_server_datetime = dateutil.parser.parse(date_hdr).astimezone(pytz.timezone(tools.get_server_timezone()))
615             date_server_datetime_str = date_server_datetime.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT)
616             msg_dict['date'] = date_server_datetime_str
617
618         if 'In-Reply-To' in message:
619             parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', '=', decode(message['In-Reply-To']))])
620             if parent_ids:
621                 msg_dict['parent_id'] = parent_ids[0]
622
623         if 'References' in message and 'parent_id' not in msg_dict:
624             parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', 'in',
625                                                                          [x.strip() for x in decode(message['References']).split()])])
626             if parent_ids:
627                 msg_dict['parent_id'] = parent_ids[0]
628
629         msg_dict['body'], msg_dict['attachments'] = self._message_extract_payload(message)
630         return msg_dict
631
632     #------------------------------------------------------
633     # Note specific
634     #------------------------------------------------------
635
636     def log(self, cr, uid, id, message, secondary=False, context=None):
637         _logger.warning("log() is deprecated. As this module inherit from "\
638                         "mail.thread, the message will be managed by this "\
639                         "module instead of by the res.log mechanism. Please "\
640                         "use mail_thread.message_post() instead of the "\
641                         "now deprecated res.log.")
642         self.message_post(cr, uid, [id], message, context=context)
643
644     def message_post(self, cr, uid, thread_id, body='', subject=None, type='notification',
645                         subtype=None, parent_id=False, attachments=None, context=None, **kwargs):
646         """ Post a new message in an existing thread, returning the new
647             mail.message ID. Extra keyword arguments will be used as default
648             column values for the new mail.message record.
649             Auto link messages for same id and object
650             :param int thread_id: thread ID to post into, or list with one ID;
651                 if False/0, mail.message model will also be set as False
652             :param str body: body of the message, usually raw HTML that will
653                 be sanitized
654             :param str subject: optional subject
655             :param str type: mail_message.type
656             :param int parent_id: optional ID of parent message in this thread
657             :param tuple(str,str) attachments or list id: list of attachment tuples in the form
658                 ``(name,content)``, where content is NOT base64 encoded
659             :return: ID of newly created mail.message
660         """
661         if context is None:
662             context = {}
663         if attachments is None:
664             attachments = {}
665
666         assert (not thread_id) or isinstance(thread_id, (int, long)) or \
667             (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"
668         if isinstance(thread_id, (list, tuple)):
669             thread_id = thread_id and thread_id[0]
670         mail_message = self.pool.get('mail.message')
671         model = context.get('thread_model', self._name) if thread_id else False
672
673         attachment_ids = []
674         for name, content in attachments:
675             if isinstance(content, unicode):
676                 content = content.encode('utf-8')
677             data_attach = {
678                 'name': name,
679                 'datas': base64.b64encode(str(content)),
680                 'datas_fname': name,
681                 'description': name,
682                 'res_model': context.get('thread_model') or self._name,
683                 'res_id': thread_id,
684             }
685             attachment_ids.append((0, 0, data_attach))
686
687         # fetch subtype
688         if subtype:
689             s_data = subtype.split('.')
690             if len(s_data) == 1:
691                 s_data = ('mail', s_data[0])
692             ref = self.pool.get('ir.model.data').get_object_reference(cr, uid, s_data[0], s_data[1])
693             subtype_id = ref and ref[1] or False
694         else:
695             subtype_id = False
696
697         # _mail_flat_thread: automatically set free messages to the first posted message
698         if self._mail_flat_thread and not parent_id and thread_id:
699             message_ids = mail_message.search(cr, uid, ['&', ('res_id', '=', thread_id), ('model', '=', model)], context=context, order="id ASC", limit=1)
700             parent_id = message_ids and message_ids[0] or False
701         # 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
702         elif parent_id:
703             message_ids = mail_message.search(cr, SUPERUSER_ID, [('id', '=', parent_id), ('parent_id', '!=', False)], context=context)
704             # avoid loops when finding ancestors
705             processed_list = []
706             if message_ids:
707                 message = mail_message.browse(cr, SUPERUSER_ID, message_ids[0], context=context)
708                 while (message.parent_id and message.parent_id.id not in processed_list):
709                     processed_list.append(message.parent_id.id)
710                     message = message.parent_id
711                 parent_id = message.id
712
713         values = kwargs
714         values.update({
715             'model': model,
716             'res_id': thread_id or False,
717             'body': body,
718             'subject': subject or False,
719             'type': type,
720             'parent_id': parent_id,
721             'attachment_ids': attachment_ids,
722             'subtype_id': subtype_id,
723         })
724
725         # Avoid warnings about non-existing fields
726         for x in ('from', 'to', 'cc'):
727             values.pop(x, None)
728
729         return mail_message.create(cr, uid, values, context=context)
730
731     def message_post_user_api(self, cr, uid, thread_id, body='', subject=False, parent_id=False,
732                                 attachment_ids=None, context=None, content_subtype='plaintext', **kwargs):
733         """ Wrapper on message_post, used for user input :
734             - mail gateway
735             - quick reply in Chatter (refer to mail.js), not
736                 the mail.compose.message wizard
737             The purpose is to perform some pre- and post-processing:
738             - if body is plaintext: convert it into html
739             - if parent_id: handle reply to a previous message by adding the
740                 parent partners to the message
741             - type and subtype: comment and mail.mt_comment by default
742             - attachment_ids: supposed not attached to any document; attach them
743                 to the related document. Should only be set by Chatter.
744         """
745         ir_attachment = self.pool.get('ir.attachment')
746         mail_message = self.pool.get('mail.message')
747
748         # 1. Pre-processing: body, partner_ids, type and subtype
749         if content_subtype == 'plaintext':
750             body = tools.plaintext2html(body)
751
752         partner_ids = kwargs.pop('partner_ids', [])
753         if parent_id:
754             parent_message = self.pool.get('mail.message').browse(cr, uid, parent_id, context=context)
755             partner_ids += [(4, partner.id) for partner in parent_message.partner_ids]
756             # TDE FIXME HACK: mail.thread -> private message
757             if self._name == 'mail.thread' and parent_message.author_id.id:
758                 partner_ids.append((4, parent_message.author_id.id))
759
760         message_type = kwargs.pop('type', 'comment')
761         message_subtype = kwargs.pop('subtype', 'mail.mt_comment')
762
763         # 2. Post message
764         new_message_id = self.message_post(cr, uid, thread_id=thread_id, body=body, subject=subject, type=message_type,
765                         subtype=message_subtype, parent_id=parent_id, context=context, partner_ids=partner_ids, **kwargs)
766
767         # 3. Post-processing
768         # HACK TDE FIXME: Chatter: attachments linked to the document (not done JS-side), load the message
769         if attachment_ids:
770             filtered_attachment_ids = ir_attachment.search(cr, SUPERUSER_ID, [
771                 ('res_model', '=', 'mail.compose.message'),
772                 ('res_id', '=', 0),
773                 ('create_uid', '=', uid),
774                 ('id', 'in', attachment_ids)], context=context)
775             if filtered_attachment_ids:
776                 ir_attachment.write(cr, SUPERUSER_ID, attachment_ids, {'res_model': self._name, 'res_id': thread_id}, context=context)
777                 mail_message.write(cr, SUPERUSER_ID, [new_message_id], {'attachment_ids': [(6, 0, [pid for pid in attachment_ids])]}, context=context)
778
779         return new_message_id
780
781     #------------------------------------------------------
782     # Followers API
783     #------------------------------------------------------
784
785     def message_get_subscription_data(self, cr, uid, ids, context=None):
786         """ Wrapper to get subtypes data. """
787         return self._get_subscription_data(cr, uid, ids, None, None, context=context)
788
789     def message_subscribe_users(self, cr, uid, ids, user_ids=None, subtype_ids=None, context=None):
790         """ Wrapper on message_subscribe, using users. If user_ids is not
791             provided, subscribe uid instead. """
792         if user_ids is None:
793             user_ids = [uid]
794         partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
795         return self.message_subscribe(cr, uid, ids, partner_ids, subtype_ids=subtype_ids, context=context)
796
797     def message_subscribe(self, cr, uid, ids, partner_ids, subtype_ids=None, context=None):
798         """ Add partners to the records followers. """
799         self.write(cr, uid, ids, {'message_follower_ids': [(4, pid) for pid in partner_ids]}, context=context)
800         # if subtypes are not specified (and not set to a void list), fetch default ones
801         if subtype_ids is None:
802             subtype_obj = self.pool.get('mail.message.subtype')
803             subtype_ids = subtype_obj.search(cr, uid, [('default', '=', True), '|', ('res_model', '=', self._name), ('res_model', '=', False)], context=context)
804         # update the subscriptions
805         fol_obj = self.pool.get('mail.followers')
806         fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids), ('partner_id', 'in', partner_ids)], context=context)
807         fol_obj.write(cr, SUPERUSER_ID, fol_ids, {'subtype_ids': [(6, 0, subtype_ids)]}, context=context)
808         return True
809
810     def message_unsubscribe_users(self, cr, uid, ids, user_ids=None, context=None):
811         """ Wrapper on message_subscribe, using users. If user_ids is not
812             provided, unsubscribe uid instead. """
813         if user_ids is None:
814             user_ids = [uid]
815         partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
816         return self.message_unsubscribe(cr, uid, ids, partner_ids, context=context)
817
818     def message_unsubscribe(self, cr, uid, ids, partner_ids, context=None):
819         """ Remove partners from the records followers. """
820         return self.write(cr, uid, ids, {'message_follower_ids': [(3, pid) for pid in partner_ids]}, context=context)
821
822     #------------------------------------------------------
823     # Thread state
824     #------------------------------------------------------
825
826     def message_mark_as_unread(self, cr, uid, ids, context=None):
827         """ Set as unread. """
828         partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
829         cr.execute('''
830             UPDATE mail_notification SET
831                 read=false
832             WHERE
833                 message_id IN (SELECT id from mail_message where res_id=any(%s) and model=%s limit 1) and
834                 partner_id = %s
835         ''', (ids, self._name, partner_id))
836         return True
837
838     def message_mark_as_read(self, cr, uid, ids, context=None):
839         """ Set as read. """
840         partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
841         cr.execute('''
842             UPDATE mail_notification SET
843                 read=true
844             WHERE
845                 message_id IN (SELECT id FROM mail_message WHERE res_id=ANY(%s) AND model=%s) AND
846                 partner_id = %s
847         ''', (ids, self._name, partner_id))
848         return True
849
850 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: