294a3bdaf30119387027ba2c5dde4f851452a64d
[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 email
24 import logging
25 import re
26 import time
27 import xmlrpclib
28 from email.utils import parsedate
29 from email.message import Message
30
31 from osv import osv, fields
32 from mail_message import decode, to_email
33 import tools
34 from tools.translate import _
35 from tools.safe_eval import safe_eval as eval
36
37 _logger = logging.getLogger(__name__)
38
39
40 def decode_header(message, header, separator=' '):
41     return separator.join(map(decode,message.get_all(header, [])))
42
43
44 class mail_thread(osv.Model):
45     '''Mixin model, meant to be inherited by any model that needs to
46        act as a discussion topic on which messages can be attached.
47        Public methods are prefixed with ``message_`` in order to avoid
48        name collisions with methods of the models that will inherit
49        from this mixin.
50
51        ``mail.thread`` is designed to work without adding any field
52        to the extended models. All functionalities and expected behavior
53        are managed by mail.thread, using model name and record ids.
54        A widget has been designed for the 6.1 and following version of OpenERP
55        web-client. However, due to technical limitations, ``mail.thread``
56        adds a simulated one2many field, to display the web widget by
57        overriding the default field displayed. Using this field
58        is not recommanded has it will disappeear in future version
59        of OpenERP, leading to a pure mixin class.
60
61        Inheriting classes are not required to implement any method, as the
62        default implementation will work for any model. However it is common
63        to override at least the ``message_new`` and ``message_update``
64        methods (calling ``super``) to add model-specific behavior at
65        creation and update of a thread; and ``message_get_subscribers``
66        to manage more precisely the social aspect of the thread through
67        the followers.
68     '''
69     _name = 'mail.thread'
70     _description = 'Email Thread'
71
72     def _get_message_ids(self, cr, uid, ids, name, args, context=None):
73         res = {}
74         for id in ids:
75             message_ids = self.message_search(cr, uid, [id], context=context)
76             subscriber_ids = self.message_get_subscribers(cr, uid, [id], context=context)
77             res[id] = {
78                 'message_ids': message_ids,
79                 'message_summary': "<span><span class='oe_e'>9</span> %d</span> <span><span class='oe_e'>+</span> %d</span>" % (len(message_ids), len(subscriber_ids)),
80             }
81         return res
82
83     def _search_message_ids(self, cr, uid, obj, name, args, context=None):
84         msg_obj = self.pool.get('mail.message')
85         msg_ids = msg_obj.search(cr, uid, ['&', ('res_id', 'in', args[0][2]), ('model', '=', self._name)], context=context)
86         return [('id', 'in', msg_ids)]
87
88     _columns = {
89         'message_ids': fields.function(_get_message_ids,
90                         fnct_search=_search_message_ids,
91             type='one2many', obj='mail.message', _fields_id = 'res_id',
92             string='Temp messages', multi="_get_message_ids",
93             help="Functional field holding messages related to the current document."),
94         'message_state': fields.boolean('Read',
95             help="When checked, new messages require your attention."),
96         'message_summary': fields.function(_get_message_ids, method=True,
97             type='text', string='Summary', multi="_get_message_ids",
98             help="Holds the Chatter summary (number of messages, ...). "\
99                  "This summary is directly in html format in order to "\
100                  "be inserted in kanban views."),
101     }
102     
103     _defaults = {
104         'message_state': True,
105     }
106
107     #------------------------------------------------------
108     # Automatic subscription when creating/reading
109     #------------------------------------------------------
110
111     def create(self, cr, uid, vals, context=None):
112         """Automatically subscribe the creator """
113         thread_id = super(mail_thread, self).create(cr, uid, vals, context=context)
114         if thread_id:
115             self.message_subscribe(cr, uid, [thread_id], [uid], context=context)
116         return thread_id
117
118     def write(self, cr, uid, ids, vals, context=None):
119         """Automatically subscribe the writer"""
120         if isinstance(ids, (int, long)):
121             ids = [ids]
122         write_res = super(mail_thread, self).write(cr, uid, ids, vals, context=context);
123         if write_res:
124             self.message_subscribe(cr, uid, ids, [uid], context=context)
125         return write_res;
126
127     def unlink(self, cr, uid, ids, context=None):
128         """Override unlink, to automatically delete
129            - subscriptions
130            - messages
131            that are linked with res_model and res_id, not through
132            a foreign key with a 'cascade' ondelete attribute.
133            Notifications will be deleted with messages
134         """
135         subscr_obj = self.pool.get('mail.subscription')
136         msg_obj = self.pool.get('mail.message')
137         # delete subscriptions
138         subscr_to_del_ids = subscr_obj.search(cr, uid, [('res_model', '=', self._name), ('res_id', 'in', ids)], context=context)
139         subscr_obj.unlink(cr, uid, subscr_to_del_ids, context=context)
140         # delete messages and notifications
141         msg_to_del_ids = msg_obj.search(cr, uid, [('model', '=', self._name), ('res_id', 'in', ids)], context=context)
142         msg_obj.unlink(cr, uid, msg_to_del_ids, context=context)
143
144         return super(mail_thread, self).unlink(cr, uid, ids, context=context)
145
146     #------------------------------------------------------
147     # mail.message wrappers and tools
148     #------------------------------------------------------
149
150     def message_create(self, cr, uid, thread_id, vals, context=None):
151         """ OpenChatter: wrapper of mail.message create method
152            - creates the mail.message
153            - automatically subscribe the message writer
154            - push the message to subscribed users
155         """
156         if context is None:
157             context = {}
158         
159         message_obj = self.pool.get('mail.message')
160         notification_obj = self.pool.get('mail.notification')
161         
162         # automatically subscribe the writer of the message
163         if vals.get('user_id'):
164             self.message_subscribe(cr, uid, [thread_id], [vals['user_id']], context=context)
165         
166         # create message
167         msg_id = message_obj.create(cr, uid, vals, context=context)
168         
169         # Set as unread if writer is not the document responsible
170         self.message_create_set_unread(cr, uid, [thread_id], context=context)
171         
172         # special: if install mode, do not push demo data
173         if context.get('install_mode', False):
174             return msg_id
175         
176         # get users that will get a notification pushed
177         user_to_push_ids = self.message_get_user_ids_to_notify(cr, uid, [thread_id], vals, context=context)
178         for id in user_to_push_ids:
179             notification_obj.create(cr, uid, {'user_id': id, 'message_id': msg_id}, context=context)
180         
181         # create the email to send
182         self.message_create_notify_by_email(cr, uid, vals, user_to_push_ids, context=context)
183         
184         return msg_id
185     
186     def message_get_user_ids_to_notify(self, cr, uid, thread_ids, new_msg_vals, context=None):
187         # get body
188         body = new_msg_vals.get('body_html', '') if new_msg_vals.get('content_subtype') == 'html' else new_msg_vals.get('body_text', '')
189         
190         # get subscribers
191         notif_user_ids = self.message_get_subscribers(cr, uid, thread_ids, context=context)
192         
193         # add users requested via parsing message (@login)
194         notif_user_ids += self.message_parse_users(cr, uid, body, context=context)
195         
196         # add users requested to perform an action (need_action mechanism)
197         if hasattr(self, 'get_needaction_user_ids'):
198             user_ids_dict = self.get_needaction_user_ids(cr, uid, thread_ids, context=context)
199             for id, user_ids in user_ids_dict.iteritems():
200                 notif_user_ids += user_ids
201         
202         # add users notified of the parent messages (because: if parent message contains @login, login must receive the replies)
203         if new_msg_vals.get('parent_id'):
204             notif_obj = self.pool.get('mail.notification')
205             parent_notif_ids = notif_obj.search(cr, uid, [('message_id', '=', new_msg_vals.get('parent_id'))], context=context)
206             parent_notifs = notif_obj.read(cr, uid, parent_notif_ids, context=context)
207             notif_user_ids += [parent_notif['user_id'][0] for parent_notif in parent_notifs]
208
209         # remove duplicate entries
210         notif_user_ids = list(set(notif_user_ids))
211         return notif_user_ids
212
213     def message_parse_users(self, cr, uid, string, context=None):
214         """Parse message content
215            - if find @login -(^|\s)@((\w|@|\.)*)-: returns the related ids
216              this supports login that are emails (such as @raoul@grobedon.net)
217         """
218         regex = re.compile('(^|\s)@((\w|@|\.)*)')
219         login_lst = [item[1] for item in regex.findall(string)]
220         if not login_lst: return []
221         user_ids = self.pool.get('res.users').search(cr, uid, [('login', 'in', login_lst)], context=context)
222         return user_ids
223
224     #------------------------------------------------------
225     # Generic message api
226     #------------------------------------------------------
227
228     def message_capable_models(self, cr, uid, context=None):
229         ret_dict = {}
230         for model_name in self.pool.obj_list():
231             model = self.pool.get(model_name)
232             if 'mail.thread' in getattr(model, '_inherit', []):
233                 ret_dict[model_name] = model._description
234         return ret_dict
235
236     def message_append(self, cr, uid, threads, subject, body_text=None, body_html=None,
237                         type='email', email_date=None, parent_id=False,
238                         content_subtype='plain', state=None,
239                         partner_ids=None, email_from=False, email_to=False,
240                         email_cc=None, email_bcc=None, reply_to=None,
241                         headers=None, message_id=False, references=None,
242                         attachments=None, original=None, context=None):
243         """ Creates a new mail.message through message_create. The new message
244             is attached to the current mail.thread, containing all the details 
245             passed as parameters. All attachments will be attached to the 
246             thread record as well as to the actual message.
247            
248             This method calls message_create that will handle management of
249             subscription and notifications, and effectively create the message.
250            
251             If ``email_from`` is not set or ``type`` not set as 'email',
252             a note message is created (comment or system notification), 
253             without the usual envelope attributes (sender, recipients, etc.).
254
255             :param threads: list of thread ids, or list of browse_records
256                 representing threads to which a new message should be attached
257             :param subject: subject of the message, or description of the event;
258                 this is totally optional as subjects are not important except
259                 for specific messages (blog post, job offers) or for emails
260             :param body_text: plaintext contents of the mail or log message
261             :param body_html: html contents of the mail or log message
262             :param type: type of message: 'email', 'comment', 'notification';
263                 email by default
264             :param email_date: email date string if different from now, in
265                 server timezone
266             :param parent_id: id of the parent message (threaded messaging model)
267             :param content_subtype: optional content_subtype of message: 'plain'
268                 or 'html', corresponding to the main body contents (body_text or
269                 body_html).
270             :param state: state of message
271             :param partner_ids: destination partners of the message, in addition
272                 to the now fully optional email_to; this method is supposed to
273                 received a list of ids is not None. The specific many2many
274                 instruction will be generated by this method.
275             :param email_from: Email From / Sender address if any
276             :param email_to: Email-To / Recipient address
277             :param email_cc: Comma-Separated list of Carbon Copy Emails To
278                 addresses if any
279             :param email_bcc: Comma-Separated list of Blind Carbon Copy Emails To
280                 addresses if any
281             :param reply_to: reply_to header
282             :param headers: mail headers to store
283             :param message_id: optional email identifier
284             :param references: optional email references
285             :param dict attachments: map of attachment filenames to binary
286                 contents, if any.
287             :param str original: optional full source of the RFC2822 email, for
288                 reference
289             :param dict context: if a ``thread_model`` value is present in the
290                 context, its value will be used to determine the model of the
291                 thread to update (instead of the current model).
292         """
293         if context is None:
294             context = {}
295         if attachments is None:
296             attachments = {}
297
298         if email_date:
299             edate = parsedate(email_date)
300             if edate is not None:
301                 email_date = time.strftime('%Y-%m-%d %H:%M:%S', edate)
302
303         if all(isinstance(thread_id, (int, long)) for thread_id in threads):
304             model = context.get('thread_model') or self._name
305             model_pool = self.pool.get(model)
306             threads = model_pool.browse(cr, uid, threads, context=context)
307
308         ir_attachment = self.pool.get('ir.attachment')
309
310         new_msg_ids = []
311         for thread in threads:
312             to_attach = []
313             for attachment in attachments:
314                 fname, fcontent = attachment
315                 if isinstance(fcontent, unicode):
316                     fcontent = fcontent.encode('utf-8')
317                 data_attach = {
318                     'name': fname,
319                     'datas': base64.b64encode(str(fcontent)),
320                     'datas_fname': fname,
321                     'description': _('Mail attachment'),
322                     'res_model': thread._name,
323                     'res_id': thread.id,
324                 }
325                 to_attach.append(ir_attachment.create(cr, uid, data_attach, context=context))
326             # find related partner: partner_id column in thread object, or self is res.partner model
327             partner_id = ('partner_id' in thread._columns.keys()) and (thread.partner_id and thread.partner_id.id or False) or False
328             if not partner_id and thread._name == 'res.partner':
329                 partner_id = thread.id
330             # destination partners
331             if partner_ids is None:
332                 partner_ids = []
333             mail_partner_ids = [(6, 0, partner_ids)]
334
335             data = {
336                 'subject': subject,
337                 'body_text': body_text or (hasattr(thread, 'description') and thread.description or ''),
338                 'body_html': body_html or '',
339                 'parent_id': parent_id,
340                 'date': email_date or fields.datetime.now(),
341                 'type': type,
342                 'content_subtype': content_subtype,
343                 'state': state,
344                 'message_id': message_id,
345                 'partner_ids': mail_partner_ids,
346                 'attachment_ids': [(6, 0, to_attach)],
347                 'user_id': uid,
348                 'model' : thread._name,
349                 'res_id': thread.id,
350                 'partner_id': partner_id,
351             }
352
353             if email_from or type == 'email':
354                 for param in (email_to, email_cc, email_bcc):
355                     if isinstance(param, list):
356                         param = ", ".join(param)
357                 data.update({
358                     'email_to': email_to,
359                     'email_from': email_from or \
360                         (hasattr(thread, 'user_id') and thread.user_id and thread.user_id.user_email),
361                     'email_cc': email_cc,
362                     'email_bcc': email_bcc,
363                     'references': references,
364                     'headers': headers,
365                     'reply_to': reply_to,
366                     'original': original, })
367
368             new_msg_ids.append(self.message_create(cr, uid, thread.id, data, context=context))
369         return new_msg_ids
370
371     def message_append_dict(self, cr, uid, ids, msg_dict, context=None):
372         """Creates a new mail.message attached to the given threads (``ids``),
373            with the contents of ``msg_dict``, by calling ``message_append``
374            with the mail details. All attachments in msg_dict will be
375            attached to the object record as well as to the actual
376            mail message.
377
378            :param dict msg_dict: a map containing the email details and
379                                  attachments. See ``message_process()`` and
380                                 ``mail.message.parse()`` for details on
381                                 the dict structure.
382            :param dict context: if a ``thread_model`` value is present
383                                 in the context, its value will be used
384                                 to determine the model of the thread to
385                                 update (instead of the current model).
386         """
387         return self.message_append(cr, uid, ids,
388                             subject = msg_dict.get('subject'),
389                             body_text = msg_dict.get('body_text'),
390                             body_html= msg_dict.get('body_html'),
391                             parent_id = msg_dict.get('parent_id', False),
392                             type = msg_dict.get('type', 'email'),
393                             content_subtype = msg_dict.get('content_subtype'),
394                             state = msg_dict.get('state'),
395                             partner_ids = msg_dict.get('partner_ids'),
396                             email_from = msg_dict.get('from', msg_dict.get('email_from')),
397                             email_to = msg_dict.get('to', msg_dict.get('email_to')),
398                             email_cc = msg_dict.get('cc', msg_dict.get('email_cc')),
399                             email_bcc = msg_dict.get('bcc', msg_dict.get('email_bcc')),
400                             reply_to = msg_dict.get('reply', msg_dict.get('reply_to')),
401                             email_date = msg_dict.get('date'),
402                             message_id = msg_dict.get('message-id', msg_dict.get('message_id')),
403                             references = msg_dict.get('references')\
404                                       or msg_dict.get('in-reply-to'),
405                             attachments = msg_dict.get('attachments'),
406                             headers = msg_dict.get('headers'),
407                             original = msg_dict.get('original'),
408                             context = context)
409
410     #------------------------------------------------------
411     # Message loading
412     #------------------------------------------------------
413
414     def _message_search_ancestor_ids(self, cr, uid, ids, child_ids, ancestor_ids, context=None):
415         """ Given message child_ids ids, find their ancestors until ancestor_ids
416             using their parent_id relationship.
417
418             :param child_ids: the first nodes of the search
419             :param ancestor_ids: list of ancestors. When the search reach an
420                                  ancestor, it stops.
421         """
422         def _get_parent_ids(message_list, ancestor_ids, child_ids):
423             """ Tool function: return the list of parent_ids of messages
424                 contained in message_list. Parents that are in ancestor_ids
425                 or in child_ids are not returned. """
426             return [message['parent_id'][0] for message in message_list
427                         if message['parent_id']
428                         and message['parent_id'][0] not in ancestor_ids
429                         and message['parent_id'][0] not in child_ids
430                     ]
431
432         message_obj = self.pool.get('mail.message')
433         messages_temp = message_obj.read(cr, uid, child_ids, ['id', 'parent_id'], context=context)
434         parent_ids = _get_parent_ids(messages_temp, ancestor_ids, child_ids)
435         child_ids += parent_ids
436         cur_iter = 0; max_iter = 100; # avoid infinite loop
437         while (parent_ids and (cur_iter < max_iter)):
438             cur_iter += 1
439             messages_temp = message_obj.read(cr, uid, parent_ids, ['id', 'parent_id'], context=context)
440             parent_ids = _get_parent_ids(messages_temp, ancestor_ids, child_ids)
441             child_ids += parent_ids
442         if (cur_iter > max_iter):
443             _logger.warning("Possible infinite loop in _message_search_ancestor_ids. "\
444                 "Note that this algorithm is intended to check for cycle in "\
445                 "message graph, leading to a curious error. Have fun.")
446         return child_ids
447
448     def message_search_get_domain(self, cr, uid, ids, context=None):
449         """ OpenChatter feature: get the domain to search the messages related
450             to a document. mail.thread defines the default behavior as
451             being messages with model = self._name, id in ids.
452             This method should be overridden if a model has to implement a
453             particular behavior.
454         """
455         return ['&', ('res_id', 'in', ids), ('model', '=', self._name)]
456
457     def message_search(self, cr, uid, ids, fetch_ancestors=False, ancestor_ids=None, 
458                         limit=100, offset=0, domain=None, count=False, context=None):
459         """ OpenChatter feature: return thread messages ids according to the
460             search domain given by ``message_search_get_domain``.
461             
462             It is possible to add in the search the parent of messages by
463             setting the fetch_ancestors flag to True. In that case, using
464             the parent_id relationship, the method returns the id list according
465             to the search domain, but then calls ``_message_search_ancestor_ids``
466             that will add to the list the ancestors ids. The search is limited
467             to parent messages having an id in ancestor_ids or having
468             parent_id set to False.
469             
470             If ``count==True``, the number of ids is returned instead of the
471             id list. The count is done by hand instead of passing it as an 
472             argument to the search call because we might want to perform
473             a research including parent messages until some ancestor_ids.
474             
475             :param fetch_ancestors: performs an ascended search; will add 
476                                     to fetched msgs all their parents until
477                                     ancestor_ids
478             :param ancestor_ids: used when fetching ancestors
479             :param domain: domain to add to the search; especially child_of
480                            is interesting when dealing with threaded display.
481                            Note that the added domain is anded with the 
482                            default domain.
483             :param limit, offset, count, context: as usual
484         """
485         search_domain = self.message_search_get_domain(cr, uid, ids, context=context)
486         if domain:
487             search_domain += domain
488         message_obj = self.pool.get('mail.message')
489         message_res = message_obj.search(cr, uid, search_domain, limit=limit, offset=offset, count=count, context=context)
490         if not count and fetch_ancestors:
491             message_res += self._message_search_ancestor_ids(cr, uid, ids, message_res, ancestor_ids, context=context) 
492         return message_res
493
494     def message_read(self, cr, uid, ids, fetch_ancestors=False, ancestor_ids=None, 
495                         limit=100, offset=0, domain=None, context=None):
496         """ OpenChatter feature: read the messages related to some threads.
497             This method is used mainly the Chatter widget, to directly have
498             read result instead of searching then reading.
499
500             Please see message_search for more information about the parameters.
501         """
502         message_ids = self.message_search(cr, uid, ids, fetch_ancestors, ancestor_ids,
503             limit, offset, domain, context=context)
504         messages = self.pool.get('mail.message').read(cr, uid, message_ids, context=context)
505
506         """ Retrieve all attachments names """
507         map_id_to_name = dict((attachment_id, '') for message in messages for attachment_id in message['attachment_ids'])
508
509         ids = map_id_to_name.keys()
510         names = self.pool.get('ir.attachment').name_get(cr, uid, ids, context=context)
511         
512         # convert the list of tuples into a dictionnary
513         for name in names: 
514             map_id_to_name[name[0]] = name[1]
515         
516         # give corresponding ids and names to each message
517         for msg in messages:
518             msg["attachments"] = []
519             
520             for attach_id in msg["attachment_ids"]:
521                 msg["attachments"].append({'id': attach_id, 'name': map_id_to_name[attach_id]})
522         
523         # Set the threads as read
524         self.message_check_and_set_read(cr, uid, ids, context=context)
525         # Sort and return the messages
526         messages = sorted(messages, key=lambda d: (-d['id']))
527         return messages
528
529     def message_get_pushed_messages(self, cr, uid, ids, fetch_ancestors=False, ancestor_ids=None,
530                             limit=100, offset=0, msg_search_domain=[], context=None):
531         """ OpenChatter: wall: get the pushed notifications and used them
532             to fetch messages to display on the wall.
533             
534             :param fetch_ancestors: performs an ascended search; will add
535                                     to fetched msgs all their parents until
536                                     ancestor_ids
537             :param ancestor_ids: used when fetching ancestors
538             :param domain: domain to add to the search; especially child_of
539                            is interesting when dealing with threaded display
540             :param ascent: performs an ascended search; will add to fetched msgs
541                            all their parents until root_ids
542             :param root_ids: for ascent search
543             :return: list of mail.messages sorted by date
544         """
545         notification_obj = self.pool.get('mail.notification')
546         msg_obj = self.pool.get('mail.message')
547         # update message search
548         for arg in msg_search_domain:
549             if isinstance(arg, (tuple, list)):
550                 arg[0] = 'message_id.' + arg[0]
551         # compose final domain
552         domain = [('user_id', '=', uid)] + msg_search_domain
553         # get notifications
554         notification_ids = notification_obj.search(cr, uid, domain, limit=limit, offset=offset, context=context)
555         notifications = notification_obj.browse(cr, uid, notification_ids, context=context)
556         msg_ids = [notification.message_id.id for notification in notifications]
557         # get messages
558         msg_ids = msg_obj.search(cr, uid, [('id', 'in', msg_ids)], context=context)
559         if (fetch_ancestors): msg_ids = self._message_search_ancestor_ids(cr, uid, ids, msg_ids, ancestor_ids, context=context)
560         msgs = msg_obj.read(cr, uid, msg_ids, context=context)
561         return msgs
562
563     def _message_find_user_id(self, cr, uid, message, context=None):
564         from_local_part = to_email(decode(message.get('From')))[0]
565         user_ids = self.pool.get('res.users').search(cr, uid, [('login', '=', from_local_part)], context=context)
566         return user_ids[0] if user_ids else uid
567
568     #------------------------------------------------------
569     # Mail gateway
570     #------------------------------------------------------
571     # message_process will call either message_new or message_update.
572
573     def message_route(self, cr, uid, message, model=None, thread_id=None,
574                       custom_values=None, context=None):
575         """Attempt to figure out the correct target model, thread_id,
576         custom_values and user_id to use for an incoming message.
577         Multiple values may be returned, if a message had multiple
578         recipients matching existing mail.aliases, for example.
579
580         The following heuristics are used, in this order: 
581              1. If the message replies to an existing thread_id, and
582                 properly contains the thread model in the 'In-Reply-To'
583                 header, use this model/thread_id pair, and ignore
584                 custom_value (not needed as no creation will take place) 
585              2. Look for a mail.alias entry matching the message
586                 recipient, and use the corresponding model, thread_id,
587                 custom_values and user_id.
588              3. Fallback to the ``model``, ``thread_id`` and ``custom_values``
589                 provided.
590              4. If all the above fails, raise an exception.
591
592            :param string message: an email.message instance
593            :param string model: the fallback model to use if the message
594                does not match any of the currently configured mail aliases
595                (may be None if a matching alias is supposed to be present)
596            :type dict custom_values: optional dictionary of default field values
597                 to pass to ``message_new`` if a new record needs to be created.
598                 Ignored if the thread record already exists, and also if a
599                 matching mail.alias was found (aliases define their own defaults)
600            :param int thread_id: optional ID of the record/thread from ``model``
601                to which this mail should be attached. Only used if the message
602                does not reply to an existing thread and does not match any mail alias.
603            :return: list of [model, thread_id, custom_values, user_id]
604         """
605         assert isinstance(message, Message), 'message must be an email.message.Message at this point'
606         message_id = message.get('Message-Id')
607
608         # 1. Verify if this is a reply to an existing thread
609         references = decode_header(message, 'References') or decode_header(message, 'In-Reply-To')
610         ref_match = references and tools.reference_re.search(references)
611         if ref_match:
612             thread_id = int(ref_match.group(1))
613             model = ref_match.group(2) or model
614             model_pool = self.pool.get(model)
615             if thread_id and model and model_pool and model_pool.exists(cr, uid, thread_id) \
616                 and hasattr(model_pool, 'message_update'):
617                 _logger.debug('Routing mail with Message-Id %s: direct reply to model: %s, thread_id: %s, custom_values: %s, uid: %s',
618                               message_id, model, thread_id, custom_values, uid)
619                 return [(model, thread_id, custom_values, uid)]
620         
621         # 2. Look for a matching mail.alias entry
622         # Delivered-To is a safe bet in most modern MTAs, but we have to fallback on To + Cc values
623         # for all the odd MTAs out there, as there is no standard header for the envelope's `rcpt_to` value.
624         rcpt_tos = decode_header(message, 'Delivered-To') or \
625              ','.join([decode_header(message, 'To'),
626                        decode_header(message, 'Cc'),
627                        decode_header(message, 'Resent-To'),
628                        decode_header(message, 'Resent-Cc')])
629         local_parts = [e.split('@')[0] for e in to_email(rcpt_tos)]
630         if local_parts:
631             mail_alias = self.pool.get('mail.alias')
632             alias_ids = mail_alias.search(cr, uid, [('alias_name', 'in', local_parts)])
633             if alias_ids:
634                 routes = []
635                 for alias in mail_alias.browse(cr, uid, alias_ids, context=context):
636                     user_id = alias.alias_user_id.id
637                     if not user_id:
638                         user_id = self._message_find_user_id(cr, uid, message, context=context)
639                     routes.append((alias.alias_model_id.model, alias.alias_force_thread_id, \
640                                    eval(alias.alias_defaults), user_id))
641                 _logger.debug('Routing mail with Message-Id %s: direct alias match: %r', message_id, routes)
642                 return routes
643         
644         # 3. Fallback to the provided parameters, if they work
645         model_pool = self.pool.get(model)
646         if not thread_id:
647             # Legacy: fallback to matching [ID] in the Subject
648             match = tools.res_re.search(decode_header(message, 'Subject'))
649             thread_id = match and match.group(1)
650         assert thread_id and hasattr(model_pool, 'message_update') or hasattr(model_pool, 'message_new'), \
651             "No possible route found for incoming message with Message-Id %s. " \
652             "Create an appropriate mail.alias or force the destination model." % message_id
653         if thread_id and not model_pool.exists(cr, uid, thread_id):
654             _logger.warning('Received mail reply to missing document %s! Ignoring and creating new document instead for Message-Id %s',
655                             thread_id, message_id)
656             thread_id = None
657         _logger.debug('Routing mail with Message-Id %s: fallback to model:%s, thread_id:%s, custom_values:%s, uid:%s',
658                       message_id, model, thread_id, custom_values, uid)
659         return [(model, thread_id, custom_values, uid)]
660
661
662     def message_process(self, cr, uid, model, message, custom_values=None,
663                         save_original=False, strip_attachments=False,
664                         thread_id=None, context=None):
665         """Process an incoming RFC2822 email message, relying on
666            ``mail.message.parse()`` for the parsing operation,
667            and ``message_route()`` to figure out the target model. 
668            
669            Once the target model is known, its ``message_new`` method
670            is called with the new message (if the thread record did not exist)
671             or its ``message_update`` method (if it did). Finally,
672            ``message_forward`` is called to automatically notify other
673            people that should receive this message.
674
675            :param string model: the fallback model to use if the message
676                does not match any of the currently configured mail aliases
677                (may be None if a matching alias is supposed to be present)
678            :param message: source of the RFC2822 message
679            :type message: string or xmlrpclib.Binary
680            :type dict custom_values: optional dictionary of field values
681                 to pass to ``message_new`` if a new record needs to be created.
682                 Ignored if the thread record already exists, and also if a
683                 matching mail.alias was found (aliases define their own defaults)
684            :param bool save_original: whether to keep a copy of the original
685                 email source attached to the message after it is imported.
686            :param bool strip_attachments: whether to strip all attachments
687                 before processing the message, in order to save some space.
688            :param int thread_id: optional ID of the record/thread from ``model``
689                to which this mail should be attached. When provided, this
690                overrides the automatic detection based on the message
691                headers.
692         """
693         if context is None: context = {}
694
695         # extract message bytes - we are forced to pass the message as binary because
696         # we don't know its encoding until we parse its headers and hence can't
697         # convert it to utf-8 for transport between the mailgate script and here.
698         if isinstance(message, xmlrpclib.Binary):
699             message = str(message.data)
700         # Warning: message_from_string doesn't always work correctly on unicode,
701         # we must use utf-8 strings here :-(
702         if isinstance(message, unicode):
703             message = message.encode('utf-8')
704         msg_txt = email.message_from_string(message)
705         routes = self.message_route(cr, uid, msg_txt, model,
706                                     thread_id, custom_values,
707                                     context=context)
708         msg = self.pool.get('mail.message').parse_message(msg_txt, save_original=save_original, context=context)
709         msg['state'] = 'received'     
710         if strip_attachments and 'attachments' in msg:
711             del msg['attachments']
712         for model, thread_id, custom_values, user_id in routes:   
713             if self._name != model:
714                 context.update({'thread_model': model})
715             model_pool = self.pool.get(model)
716             assert thread_id and hasattr(model_pool, 'message_update') or hasattr(model_pool, 'message_new'), \
717                 "Undeliverable mail with Message-Id %s, model %s does not accept incoming emails" % \
718                     (msg['message-id'], model)
719             if thread_id and hasattr(model_pool, 'message_update'):
720                 model_pool.message_update(cr, user_id, [thread_id], msg, context=context)
721             else:
722                 thread_id = model_pool.message_new(cr, user_id, msg, custom_values, context=context)
723     
724             # Forward the email to other followers
725             self.message_forward(cr, uid, model, [thread_id], msg_txt, context=context)
726             model_pool.message_mark_as_unread(cr, uid, [thread_id], context=context)
727         return True
728
729     def message_new(self, cr, uid, msg_dict, custom_values=None, context=None):
730         """Called by ``message_process`` when a new message is received
731            for a given thread model, if the message did not belong to
732            an existing thread.
733            The default behavior is to create a new record of the corresponding
734            model (based on some very basic info extracted from the message),
735            then attach the message to the newly created record
736            (by calling ``message_append_dict``).
737            Additional behavior may be implemented by overriding this method.
738
739            :param dict msg_dict: a map containing the email details and
740                                  attachments. See ``message_process`` and
741                                 ``mail.message.parse`` for details.
742            :param dict custom_values: optional dictionary of additional
743                                       field values to pass to create()
744                                       when creating the new thread record.
745                                       Be careful, these values may override
746                                       any other values coming from the message.
747            :param dict context: if a ``thread_model`` value is present
748                                 in the context, its value will be used
749                                 to determine the model of the record
750                                 to create (instead of the current model).
751            :rtype: int
752            :return: the id of the newly created thread object
753         """
754         if context is None:
755             context = {}
756         model = context.get('thread_model') or self._name
757         model_pool = self.pool.get(model)
758         fields = model_pool.fields_get(cr, uid, context=context)
759         data = model_pool.default_get(cr, uid, fields, context=context)
760         if 'name' in fields and not data.get('name'):
761             data['name'] = msg_dict.get('subject', '')
762         if custom_values and isinstance(custom_values, dict):
763             data.update(custom_values)
764         res_id = model_pool.create(cr, uid, data, context=context)
765         self.message_append_dict(cr, uid, [res_id], msg_dict, context=context)
766         return res_id
767
768     def message_update(self, cr, uid, ids, msg_dict, update_vals=None, context=None):
769         """Called by ``message_process`` when a new message is received
770            for an existing thread. The default behavior is to create a
771            new mail.message in the given thread (by calling
772            ``message_append_dict``)
773            Additional behavior may be implemented by overriding this
774            method.
775            :param dict msg_dict: a map containing the email details and
776                                attachments. See ``message_process`` and
777                                ``mail.message.parse()`` for details.
778            :param dict update_vals: a dict containing values to update records
779                               given their ids; if the dict is None or is
780                               void, no write operation is performed.
781         """
782         if update_vals:
783             self.write(cr, uid, ids, update_vals, context=context)
784         return self.message_append_dict(cr, uid, ids, msg_dict, context=context)
785
786     def message_thread_followers(self, cr, uid, ids, context=None):
787         """ Returns a list of email addresses of the people following
788             this thread, including the sender of each mail, and the
789             people who were in CC of the messages, if any.
790         """
791         res = {}
792         if isinstance(ids, (str, int, long)):
793             ids = [long(ids)]
794         for thread in self.browse(cr, uid, ids, context=context):
795             l = set()
796             for message in thread.message_ids:
797                 l.add((message.user_id and message.user_id.user_email) or '')
798                 l.add(message.email_from or '')
799                 l.add(message.email_cc or '')
800             res[thread.id] = filter(None, l)
801         return res
802
803     def message_forward(self, cr, uid, model, thread_ids, msg, email_error=False, context=None):
804         """Sends an email to all people following the given threads.
805            The emails are forwarded immediately, not queued for sending,
806            and not archived.
807
808         :param str model: thread model
809         :param list thread_ids: ids of the thread records
810         :param msg: email.message.Message object to forward
811         :param email_error: optional email address to notify in case
812                             of any delivery error during the forward.
813         :return: True
814         """
815         model_pool = self.pool.get(model)
816         smtp_server_obj = self.pool.get('ir.mail_server')
817         for res in model_pool.browse(cr, uid, thread_ids, context=context):
818             if hasattr(model_pool, 'message_thread_followers'):
819                 followers = model_pool.message_thread_followers(cr, uid, [res.id])[res.id]
820             else:
821                 followers = self.message_thread_followers(cr, uid, [res.id])[res.id]
822             message_followers_emails = to_email(','.join(filter(None, followers)))
823             message_recipients = to_email(','.join(filter(None,
824                                                                        [decode(msg['from']),
825                                                                         decode(msg['to']),
826                                                                         decode(msg['cc'])])))
827             forward_to = [i for i in message_followers_emails if (i and (i not in message_recipients))]
828             if forward_to:
829                 # TODO: we need an interface for this for all types of objects, not just leads
830                 if hasattr(res, 'section_id'):
831                     del msg['reply-to']
832                     msg['reply-to'] = res.section_id.reply_to
833
834                 smtp_from, = to_email(msg['from'])
835                 msg['from'] = smtp_from
836                 msg['to'] =  ", ".join(forward_to)
837                 msg['message-id'] = tools.generate_tracking_message_id(res.id)
838                 if not smtp_server_obj.send_email(cr, uid, msg) and email_error:
839                     subj = msg['subject']
840                     del msg['subject'], msg['to'], msg['cc'], msg['bcc']
841                     msg['subject'] = _('[OpenERP-Forward-Failed] %s') % subj
842                     msg['to'] = email_error
843                     smtp_server_obj.send_email(cr, uid, msg)
844         return True
845
846     def message_partner_by_email(self, cr, uid, email, context=None):
847         """Attempts to return the id of a partner address matching
848            the given ``email``, and the corresponding partner id.
849            Can be used by classes using the ``mail.thread`` mixin
850            to lookup the partner and use it in their implementation
851            of ``message_new`` to link the new record with a
852            corresponding partner.
853            The keys used in the returned dict are meant to map
854            to usual names for relationships towards a partner
855            and one of its addresses.
856
857            :param email: email address for which a partner
858                          should be searched for.
859            :rtype: dict
860            :return: a map of the following form::
861
862                       { 'partner_address_id': id or False,
863                         'partner_id': pid or False }
864         """
865         partner_pool = self.pool.get('res.partner')
866         res = {'partner_id': False}
867         if email:
868             email = to_email(email)[0]
869             contact_ids = partner_pool.search(cr, uid, [('email', '=', email)])
870             if contact_ids:
871                 contact = partner_pool.browse(cr, uid, contact_ids[0])
872                 res['partner_id'] = contact.id
873         return res
874
875     # for backwards-compatibility with old scripts
876     process_email = message_process
877
878     #------------------------------------------------------
879     # Note specific
880     #------------------------------------------------------
881
882     def log(self, cr, uid, id, message, secondary=False, context=None):
883         _logger.warning("log() is deprecated. As this module inherit from \
884                         mail.thread, the message will be managed by this \
885                         module instead of by the res.log mechanism. Please \
886                         use the mail.thread OpenChatter API instead of the \
887                         now deprecated res.log.")
888         self.message_append_note(cr, uid, [id], 'res.log', message, context=context)
889
890     def message_append_note(self, cr, uid, ids, subject=None, body=None, parent_id=False,
891                             type='notification', content_subtype='html', context=None):
892         if content_subtype == 'html':
893             body_html = body
894             body_text = body
895         else:
896             body_html = body
897             body_text = body
898         return self.message_append(cr, uid, ids, subject, body_html, body_text,
899                                     type, parent_id=parent_id,
900                                     content_subtype=content_subtype, context=context)
901
902     #------------------------------------------------------
903     # Subscription mechanism
904     #------------------------------------------------------
905
906     def message_get_subscribers(self, cr, uid, ids, context=None):
907         """ Returns the current document followers. Basically this method
908             checks in mail.subscription for entries with matching res_model,
909             res_id.
910             This method can be overriden to add implicit subscribers, such
911             as project managers, by adding their user_id to the list of
912             ids returned by this method.
913         """
914         subscr_obj = self.pool.get('mail.subscription')
915         subscr_ids = subscr_obj.search(cr, uid, ['&', ('res_model', '=', self._name), ('res_id', 'in', ids)], context=context)
916         return [sub['user_id'][0] for sub in subscr_obj.read(cr, uid, subscr_ids, ['user_id'], context=context)]
917
918     def message_read_subscribers(self, cr, uid, ids, fields=['id', 'name', 'image_small'], context=None):
919         """ Returns the current document followers as a read result. Used
920             mainly for Chatter having only one method to call to have
921             details about users.
922         """
923         user_ids = self.message_get_subscribers(cr, uid, ids, context=context)
924         return self.pool.get('res.users').read(cr, uid, user_ids, fields=fields, context=context)
925
926     def message_is_subscriber(self, cr, uid, ids, user_id = None, context=None):
927         """ Check if uid or user_id (if set) is a subscriber to the current
928             document.
929             
930             :param user_id: if set, check is done on user_id; if not set
931                             check is done on uid
932         """
933         sub_user_id = uid if user_id is None else user_id
934         if sub_user_id in self.message_get_subscribers(cr, uid, ids, context=context):
935             return True
936         return False
937
938     def message_subscribe(self, cr, uid, ids, user_ids = None, context=None):
939         """ Subscribe the user (or user_ids) to the current document.
940             
941             :param user_ids: a list of user_ids; if not set, subscribe
942                              uid instead
943         """
944         subscription_obj = self.pool.get('mail.subscription')
945         to_subscribe_uids = [uid] if user_ids is None else user_ids
946         create_ids = []
947         for id in ids:
948             already_subscribed_user_ids = self.message_get_subscribers(cr, uid, [id], context=context)
949             for user_id in to_subscribe_uids:
950                 if user_id in already_subscribed_user_ids: continue
951                 create_ids.append(subscription_obj.create(cr, uid, {'res_model': self._name, 'res_id': id, 'user_id': user_id}, context=context))
952         return create_ids
953
954     def message_unsubscribe(self, cr, uid, ids, user_ids = None, context=None):
955         """ Unsubscribe the user (or user_ids) from the current document.
956             
957             :param user_ids: a list of user_ids; if not set, subscribe
958                              uid instead
959         """
960         # Trying to unsubscribe somebody not in subscribers: returns False
961         # if special management is needed; allows to know that an automatically
962         # subscribed user tries to unsubscribe and allows to warn him
963         to_unsubscribe_uids = [uid] if user_ids is None else user_ids
964         subscription_obj = self.pool.get('mail.subscription')
965         to_delete_sub_ids = subscription_obj.search(cr, uid,
966                         ['&', '&', ('res_model', '=', self._name), ('res_id', 'in', ids), ('user_id', 'in', to_unsubscribe_uids)], context=context)
967         if not to_delete_sub_ids:
968             return False
969         return subscription_obj.unlink(cr, uid, to_delete_sub_ids, context=context)
970
971     #------------------------------------------------------
972     # Notification API
973     #------------------------------------------------------
974
975     def message_create_notify_by_email(self, cr, uid, new_msg_values, user_to_notify_ids, context=None):
976         """ When creating a new message and pushing notifications, emails
977             must be send if users have chosen to receive notifications
978             by email via the notification_email_pref field.
979             
980             ``notification_email_pref`` can have 3 values :
981             - all: receive all notification by email (for example for shared
982               users)
983             - to_me: messages send directly to me (@login, messages on res.users)
984             - never: never receive notifications
985             Note that an user should never receive notifications for messages
986             he has created.
987             
988             :param new_msg_values: dictionary of message values, those that
989                                    are given to the create method
990             :param user_to_notify_ids: list of user_ids, user that will
991                                        receive a notification on their Wall
992         """
993         message_obj = self.pool.get('mail.message')
994         res_users_obj = self.pool.get('res.users')
995         body = new_msg_values.get('body_html', '') if new_msg_values.get('content_subtype') == 'html' else new_msg_values.get('body_text', '')
996         
997         # remove message writer
998         if user_to_notify_ids.count(new_msg_values.get('user_id')) > 0:
999             user_to_notify_ids.remove(new_msg_values.get('user_id'))
1000         
1001         # get user_ids directly asked
1002         user_to_push_from_parse_ids = self.message_parse_users(cr, uid, body, context=context)
1003         
1004         # try to find an email_to
1005         email_to = ''
1006         for user in res_users_obj.browse(cr, uid, user_to_notify_ids, context=context):
1007             if not user.notification_email_pref == 'all' and \
1008                 not (user.notification_email_pref == 'to_me' and user.id in user_to_push_from_parse_ids):
1009                 continue
1010             if not user.user_email:
1011                 continue
1012             email_to = '%s, %s' % (email_to, user.user_email)
1013             email_to = email_to.lstrip(', ')
1014         
1015         # did not find any email address: not necessary to create an email
1016         if not email_to:
1017             return
1018         
1019         # try to find an email_from
1020         current_user = res_users_obj.browse(cr, uid, [uid], context=context)[0]
1021         email_from = new_msg_values.get('email_from')
1022         if not email_from:
1023             email_from = current_user.user_email
1024         
1025         # get email content, create it (with mail_message.create)
1026         email_values = self.message_create_notify_get_email_dict(cr, uid, new_msg_values, email_from, email_to, context)
1027         email_id = message_obj.create(cr, uid, email_values, context=context)
1028         return email_id
1029     
1030     def message_create_notify_get_email_dict(self, cr, uid, new_msg_values, email_from, email_to, context=None):
1031         values = dict(new_msg_values)
1032         
1033         body_html = new_msg_values.get('body_html', '')
1034         if body_html:
1035             body_html += '\n\n----------\nThis email was send automatically by OpenERP, because you have subscribed to a document.'
1036         body_text = new_msg_values.get('body_text', '')
1037         if body_text:
1038             body_text += '\n\n----------\nThis email was send automatically by OpenERP, because you have subscribed to a document.'
1039         values.update({
1040             'type': 'email',
1041             'state': 'outgoing',
1042             'email_from': email_from,
1043             'email_to': email_to,
1044             'subject': 'New message',
1045             'content_subtype': new_msg_values.get('content_subtype', 'plain'),
1046             'body_html': body_html,
1047             'body_text': body_text,
1048             'auto_delete': True,
1049             'res_model': '',
1050             'res_id': False,
1051         })
1052         return values
1053
1054     def message_remove_pushed_notifications(self, cr, uid, ids, msg_ids, remove_childs=True, context=None):
1055         notif_obj = self.pool.get('mail.notification')
1056         msg_obj = self.pool.get('mail.message')
1057         if remove_childs:
1058             notif_msg_ids = msg_obj.search(cr, uid, [('id', 'child_of', msg_ids)], context=context)
1059         else:
1060             notif_msg_ids = msg_ids
1061         to_del_notif_ids = notif_obj.search(cr, uid, ['&', ('user_id', '=', uid), ('message_id', 'in', notif_msg_ids)], context=context)
1062         return notif_obj.unlink(cr, uid, to_del_notif_ids, context=context)
1063
1064     #------------------------------------------------------
1065     # Thread_state
1066     #------------------------------------------------------
1067
1068     def message_create_set_unread(self, cr, uid, ids, context=None):
1069         """ When creating a new message, set as unread if uid is not the
1070             object responsible. """
1071         for obj in self.browse(cr, uid, ids, context=context):
1072             if obj.message_state and hasattr(obj, 'user_id') and (not obj.user_id or obj.user_id.id != uid):
1073                 self.message_mark_as_unread(cr, uid, [obj.id], context=context)
1074
1075     def message_check_and_set_unread(self, cr, uid, ids, context=None):
1076         """ Set unread if uid is the object responsible or if the object has
1077             no responsible. """
1078         for obj in self.browse(cr, uid, ids, context=context):
1079             if obj.message_state and hasattr(obj, 'user_id') and (not obj.user_id or obj.user_id.id == uid):
1080                 self.message_mark_as_unread(cr, uid, [obj.id], context=context)
1081
1082     def message_mark_as_unread(self, cr, uid, ids, context=None):
1083         """ Set as unread. """
1084         return self.write(cr, uid, ids, {'message_state': False}, context=context)
1085
1086     def message_check_and_set_read(self, cr, uid, ids, context=None):
1087         """ Set read if uid is the object responsible. """
1088         for obj in self.browse(cr, uid, ids, context=context):
1089             if not obj.message_state and hasattr(obj, 'user_id') and obj.user_id and obj.user_id.id == uid:
1090                 self.message_mark_as_read(cr, uid, [obj.id], context=context)
1091     
1092     def message_mark_as_read(self, cr, uid, ids, context=None):
1093         """ Set as read. """
1094         return self.write(cr, uid, ids, {'message_state': True}, context=context)
1095
1096
1097 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: