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