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