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