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