1 # -*- coding: utf-8 -*-
2 ##############################################################################
4 # OpenERP, Open Source Management Solution
5 # Copyright (C) 2009-today OpenERP SA (<http://www.openerp.com>)
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
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
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/>
20 ##############################################################################
29 from openerp import tools
32 from mako.template import Template as MakoTemplate
34 from email.message import Message
35 from mail_message import decode
36 from openerp import SUPERUSER_ID
37 from openerp.osv import fields, osv
38 from openerp.osv.orm import browse_record
39 from openerp.tools.safe_eval import safe_eval as eval
40 from tools.translate import _
42 _logger = logging.getLogger(__name__)
45 def decode_header(message, header, separator=' '):
46 return separator.join(map(decode, message.get_all(header, [])))
49 class mail_thread(osv.AbstractModel):
50 ''' mail_thread model is meant to be inherited by any model that needs to
51 act as a discussion topic on which messages can be attached. Public
52 methods are prefixed with ``message_`` in order to avoid name
53 collisions with methods of the models that will inherit from this class.
55 ``mail.thread`` defines fields used to handle and display the
56 communication history. ``mail.thread`` also manages followers of
57 inheriting classes. All features and expected behavior are managed
58 by mail.thread. Widgets has been designed for the 7.0 and following
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 when processing incoming emails.
68 - _mail_flat_thread: if set to True, all messages without parent_id
69 are automatically attached to the first message posted on the
70 ressource. If set to False, the display of Chatter is done using
71 threads, and no parent_id is automatically set.
74 _description = 'Email Thread'
75 _mail_flat_thread = True
77 # Automatic logging system if mail installed
80 # 'module.subtype_xml': lambda self, cr, uid, obj, context=None: obj.state == done,
81 # 'module.subtype_xml2': lambda self, cr, uid, obj, context=None: obj.state != done,
88 # :param string field: field name
89 # :param module.subtype_xml: xml_id of a mail.message.subtype (i.e. mail.mt_comment)
90 # :param obj: is a browse_record
91 # :param function lambda: returns whether the tracking should record using this subtype
94 %if message_description:
95 <span>${message_description}</span>
97 %for name, change in tracked_values.items():
99 • <b>${change.get('col_info')}</b>:
100 %if change.get('old_value'):
101 ${change.get('old_value')} →
103 ${change.get('new_value')}
108 def _get_message_data(self, cr, uid, ids, name, args, context=None):
110 - message_unread: has uid unread message for the document
111 - message_summary: html snippet summarizing the Chatter for kanban views """
112 res = dict((id, dict(message_unread=False, message_summary='')) for id in ids)
113 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
115 # search for unread messages, directly in SQL to improve performances
116 cr.execute(""" SELECT m.res_id FROM mail_message m
117 RIGHT JOIN mail_notification n
118 ON (n.message_id = m.id AND n.partner_id = %s AND (n.read = False or n.read IS NULL))
119 WHERE m.model = %s AND m.res_id in %s""",
120 (user_pid, self._name, tuple(ids),))
121 msg_ids = [result[0] for result in cr.fetchall()]
122 for msg_id in msg_ids:
123 res[msg_id]['message_unread'] = True
125 for thread in self.browse(cr, uid, ids, context=context):
126 cls = res[thread.id]['message_unread'] and ' class="oe_kanban_mail_new"' or ''
127 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))
131 def _get_subscription_data(self, cr, uid, ids, name, args, context=None):
133 - message_subtype_data: data about document subtypes: which are
134 available, which are followed if any """
135 res = dict((id, dict(message_subtype_data='')) for id in ids)
136 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
138 # find current model subtypes, add them to a dictionary
139 subtype_obj = self.pool.get('mail.message.subtype')
140 subtype_ids = subtype_obj.search(cr, uid, ['|', ('res_model', '=', self._name), ('res_model', '=', False)], context=context)
141 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))
143 res[id]['message_subtype_data'] = subtype_dict.copy()
145 # find the document followers, update the data
146 fol_obj = self.pool.get('mail.followers')
147 fol_ids = fol_obj.search(cr, uid, [
148 ('partner_id', '=', user_pid),
149 ('res_id', 'in', ids),
150 ('res_model', '=', self._name),
152 for fol in fol_obj.browse(cr, uid, fol_ids, context=context):
153 thread_subtype_dict = res[fol.res_id]['message_subtype_data']
154 for subtype in fol.subtype_ids:
155 thread_subtype_dict[subtype.name]['followed'] = True
156 res[fol.res_id]['message_subtype_data'] = thread_subtype_dict
160 def _search_message_unread(self, cr, uid, obj=None, name=None, domain=None, context=None):
161 return [('message_ids.to_read', '=', True)]
163 def _get_followers(self, cr, uid, ids, name, arg, context=None):
164 fol_obj = self.pool.get('mail.followers')
165 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids)])
166 res = dict((id, dict(message_follower_ids=[], message_is_follower=False)) for id in ids)
167 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
168 for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids):
169 res[fol.res_id]['message_follower_ids'].append(fol.partner_id.id)
170 if fol.partner_id.id == user_pid:
171 res[fol.res_id]['message_is_follower'] = True
174 def _set_followers(self, cr, uid, id, name, value, arg, context=None):
177 partner_obj = self.pool.get('res.partner')
178 fol_obj = self.pool.get('mail.followers')
180 # read the old set of followers, and determine the new set of followers
181 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', '=', id)])
182 old = set(fol.partner_id.id for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids))
185 for command in value or []:
186 if isinstance(command, (int, long)):
188 elif command[0] == 0:
189 new.add(partner_obj.create(cr, uid, command[2], context=context))
190 elif command[0] == 1:
191 partner_obj.write(cr, uid, [command[1]], command[2], context=context)
193 elif command[0] == 2:
194 partner_obj.unlink(cr, uid, [command[1]], context=context)
195 new.discard(command[1])
196 elif command[0] == 3:
197 new.discard(command[1])
198 elif command[0] == 4:
200 elif command[0] == 5:
202 elif command[0] == 6:
203 new = set(command[2])
205 # remove partners that are no longer followers
206 fol_ids = fol_obj.search(cr, SUPERUSER_ID,
207 [('res_model', '=', self._name), ('res_id', '=', id), ('partner_id', 'not in', list(new))])
208 fol_obj.unlink(cr, SUPERUSER_ID, fol_ids)
211 for partner_id in new - old:
212 fol_obj.create(cr, SUPERUSER_ID, {'res_model': self._name, 'res_id': id, 'partner_id': partner_id})
214 def _search_followers(self, cr, uid, obj, name, args, context):
215 fol_obj = self.pool.get('mail.followers')
217 for field, operator, value in args:
219 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('partner_id', operator, value)])
220 res_ids = [fol.res_id for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids)]
221 res.append(('id', 'in', res_ids))
225 'message_is_follower': fields.function(_get_followers,
226 type='boolean', string='Is a Follower', multi='_get_followers,'),
227 'message_follower_ids': fields.function(_get_followers, fnct_inv=_set_followers,
228 fnct_search=_search_followers, type='many2many',
229 obj='res.partner', string='Followers', multi='_get_followers'),
230 'message_ids': fields.one2many('mail.message', 'res_id',
231 domain=lambda self: [('model', '=', self._name)],
234 help="Messages and communication history"),
235 'message_unread': fields.function(_get_message_data,
236 fnct_search=_search_message_unread, multi="_get_message_data",
237 type='boolean', string='Unread Messages',
238 help="If checked new messages require your attention."),
239 'message_summary': fields.function(_get_message_data, method=True,
240 type='text', string='Summary', multi="_get_message_data",
241 help="Holds the Chatter summary (number of messages, ...). "\
242 "This summary is directly in html format in order to "\
243 "be inserted in kanban views."),
246 #------------------------------------------------------
247 # CRUD overrides for automatic subscription and logging
248 #------------------------------------------------------
250 def create(self, cr, uid, values, context=None):
251 """ Chatter override :
253 - subscribe followers of parent
254 - log a creation message
258 thread_id = super(mail_thread, self).create(cr, uid, values, context=context)
260 # subscribe uid unless asked not to
261 if not context.get('mail_nosubscribe'):
262 self.message_subscribe_users(cr, uid, [thread_id], [uid], context=context)
263 self.message_subscribe_from_parent(cr, uid, [thread_id], context=context)
265 # automatic logging unless asked not to (mainly for various testing purpose)
266 if not context.get('mail_nolog'):
267 self.message_post(cr, uid, thread_id, body='Document <b>created</b>.', context=context)
270 def write(self, cr, uid, ids, values, context=None):
271 tracked_fields = self._get_tracked_fields(cr, uid, values.keys(), context=context)
273 initial = self.read(cr, uid, ids, tracked_fields.keys(), context=context)
274 initial_values = dict((item['id'], item) for item in initial)
275 result = super(mail_thread, self).write(cr, uid, ids, values, context=context)
277 self.message_track(cr, uid, ids, tracked_fields, initial_values, context=context)
280 def unlink(self, cr, uid, ids, context=None):
281 """ Override unlink to delete messages and followers. This cannot be
282 cascaded, because link is done through (res_model, res_id). """
283 msg_obj = self.pool.get('mail.message')
284 fol_obj = self.pool.get('mail.followers')
285 # delete messages and notifications
286 msg_ids = msg_obj.search(cr, uid, [('model', '=', self._name), ('res_id', 'in', ids)], context=context)
287 msg_obj.unlink(cr, uid, msg_ids, context=context)
289 res = super(mail_thread, self).unlink(cr, uid, ids, context=context)
291 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids)], context=context)
292 fol_obj.unlink(cr, SUPERUSER_ID, fol_ids, context=context)
295 def copy(self, cr, uid, id, default=None, context=None):
296 default = default or {}
297 default['message_ids'] = []
298 default['message_follower_ids'] = []
299 return super(mail_thread, self).copy(cr, uid, id, default=default, context=context)
301 #------------------------------------------------------
302 # Automatically log tracked fields
303 #------------------------------------------------------
305 def _get_tracked_fields(self, cr, uid, updated_fields, context=None):
306 """ Return a structure of tracked fields for the current model.
307 :param list updated_fields: modified field names
308 :return list: a list of (field_name, column_info obj), containing
309 always tracked fields and modified on_change fields
312 for name, column_info in self._all_columns.items():
313 visibility = getattr(column_info.column, 'track_visibility', False)
314 if visibility == 2 or (visibility == 1 and name in updated_fields) or name in self._track:
318 return self.fields_get(cr, uid, lst, context=context)
320 def message_track(self, cr, uid, ids, tracked_fields, initial_values, context=None):
322 def convert_for_display(value, field_obj):
325 if field_obj['type'] == 'many2one':
327 if field_obj['type'] == 'selection':
328 return dict(field_obj['selection'])[value]
331 if not tracked_fields:
334 for record in self.read(cr, uid, ids, tracked_fields.keys(), context=context):
335 initial = initial_values[record['id']]
339 # generate tracked_values data structure: {'col_name': {col_info, new_value, old_value}}
340 for col_name, col_info in tracked_fields.items():
341 if record[col_name] == initial[col_name] and getattr(self._all_columns[col_name].column, 'track_visibility', 0) == 2:
342 tracked_values[col_name] = dict(col_info=col_info['string'],
343 new_value=convert_for_display(record[col_name], col_info))
344 elif record[col_name] != initial[col_name]:
345 if getattr(self._all_columns[col_name].column, 'track_visibility', 0) in [1, 2]:
346 tracked_values[col_name] = dict(col_info=col_info['string'],
347 old_value=convert_for_display(initial[col_name], col_info),
348 new_value=convert_for_display(record[col_name], col_info))
349 if col_name in tracked_fields:
350 changes.append(col_name)
354 # find subtypes and post messages or log if no subtype found
356 for field, track_info in self._track.items():
357 if field not in changes:
359 for subtype, method in track_info.items():
360 if method(self, cr, uid, record, context):
361 subtypes.append(subtype)
364 for subtype in subtypes:
366 subtype_rec = self.pool.get('ir.model.data').get_object(cr, uid, subtype.split('.')[0], subtype.split('.')[1])
369 message = MakoTemplate(self._TRACK_TEMPLATE).render_unicode(message_description=subtype_rec.description, tracked_values=tracked_values)
370 self.message_post(cr, uid, record['id'], body=message, subtype=subtype, context=context)
373 message = MakoTemplate(self._TRACK_TEMPLATE).render_unicode(message_description='', tracked_values=tracked_values)
374 self.message_post(cr, uid, record['id'], body=message, context=context)
377 #------------------------------------------------------
378 # mail.message wrappers and tools
379 #------------------------------------------------------
381 def _needaction_domain_get(self, cr, uid, context=None):
383 return [('message_unread', '=', True)]
386 #------------------------------------------------------
388 #------------------------------------------------------
390 def message_capable_models(self, cr, uid, context=None):
391 """ Used by the plugin addon, based for plugin_outlook and others. """
393 for model_name in self.pool.obj_list():
394 model = self.pool.get(model_name)
395 if 'mail.thread' in getattr(model, '_inherit', []):
396 ret_dict[model_name] = model._description
399 def _message_find_partners(self, cr, uid, message, header_fields=['From'], context=None):
400 """ Find partners related to some header fields of the message. """
401 s = ', '.join([decode(message.get(h)) for h in header_fields if message.get(h)])
402 return [partner_id for email in tools.email_split(s)
403 for partner_id in self.pool.get('res.partner').search(cr, uid, [('email', 'ilike', email)], context=context)]
405 def _message_find_user_id(self, cr, uid, message, context=None):
406 from_local_part = tools.email_split(decode(message.get('From')))[0]
407 # FP Note: canonification required, the minimu: .lower()
408 user_ids = self.pool.get('res.users').search(cr, uid, ['|',
409 ('login', '=', from_local_part),
410 ('email', '=', from_local_part)], context=context)
411 return user_ids[0] if user_ids else uid
413 def message_route(self, cr, uid, message, model=None, thread_id=None,
414 custom_values=None, context=None):
415 """Attempt to figure out the correct target model, thread_id,
416 custom_values and user_id to use for an incoming message.
417 Multiple values may be returned, if a message had multiple
418 recipients matching existing mail.aliases, for example.
420 The following heuristics are used, in this order:
421 1. If the message replies to an existing thread_id, and
422 properly contains the thread model in the 'In-Reply-To'
423 header, use this model/thread_id pair, and ignore
424 custom_value (not needed as no creation will take place)
425 2. Look for a mail.alias entry matching the message
426 recipient, and use the corresponding model, thread_id,
427 custom_values and user_id.
428 3. Fallback to the ``model``, ``thread_id`` and ``custom_values``
430 4. If all the above fails, raise an exception.
432 :param string message: an email.message instance
433 :param string model: the fallback model to use if the message
434 does not match any of the currently configured mail aliases
435 (may be None if a matching alias is supposed to be present)
436 :type dict custom_values: optional dictionary of default field values
437 to pass to ``message_new`` if a new record needs to be created.
438 Ignored if the thread record already exists, and also if a
439 matching mail.alias was found (aliases define their own defaults)
440 :param int thread_id: optional ID of the record/thread from ``model``
441 to which this mail should be attached. Only used if the message
442 does not reply to an existing thread and does not match any mail alias.
443 :return: list of [model, thread_id, custom_values, user_id]
445 assert isinstance(message, Message), 'message must be an email.message.Message at this point'
446 message_id = message.get('Message-Id')
447 references = decode_header(message, 'References')
448 in_reply_to = decode_header(message, 'In-Reply-To')
450 # 1. Verify if this is a reply to an existing thread
451 thread_references = references or in_reply_to
452 ref_match = thread_references and tools.reference_re.search(thread_references)
454 thread_id = int(ref_match.group(1))
455 model = ref_match.group(2) or model
456 model_pool = self.pool.get(model)
457 if thread_id and model and model_pool and model_pool.exists(cr, uid, thread_id) \
458 and hasattr(model_pool, 'message_update'):
459 _logger.debug('Routing mail with Message-Id %s: direct reply to model: %s, thread_id: %s, custom_values: %s, uid: %s',
460 message_id, model, thread_id, custom_values, uid)
461 return [(model, thread_id, custom_values, uid)]
463 # Verify whether this is a reply to a private message
465 message_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', '=', in_reply_to)], limit=1, context=context)
467 message = self.pool.get('mail.message').browse(cr, uid, message_ids[0], context=context)
468 _logger.debug('Routing mail with Message-Id %s: direct reply to a private message: %s, custom_values: %s, uid: %s',
469 message_id, message.id, custom_values, uid)
470 return [(message.model, message.res_id, custom_values, uid)]
472 # 2. Look for a matching mail.alias entry
473 # Delivered-To is a safe bet in most modern MTAs, but we have to fallback on To + Cc values
474 # for all the odd MTAs out there, as there is no standard header for the envelope's `rcpt_to` value.
476 ','.join([decode_header(message, 'Delivered-To'),
477 decode_header(message, 'To'),
478 decode_header(message, 'Cc'),
479 decode_header(message, 'Resent-To'),
480 decode_header(message, 'Resent-Cc')])
481 local_parts = [e.split('@')[0] for e in tools.email_split(rcpt_tos)]
483 mail_alias = self.pool.get('mail.alias')
484 alias_ids = mail_alias.search(cr, uid, [('alias_name', 'in', local_parts)])
487 for alias in mail_alias.browse(cr, uid, alias_ids, context=context):
488 user_id = alias.alias_user_id.id
490 user_id = self._message_find_user_id(cr, uid, message, context=context)
491 routes.append((alias.alias_model_id.model, alias.alias_force_thread_id, \
492 eval(alias.alias_defaults), user_id))
493 _logger.debug('Routing mail with Message-Id %s: direct alias match: %r', message_id, routes)
496 # 3. Fallback to the provided parameters, if they work
497 model_pool = self.pool.get(model)
499 # Legacy: fallback to matching [ID] in the Subject
500 match = tools.res_re.search(decode_header(message, 'Subject'))
501 thread_id = match and match.group(1)
502 assert thread_id and hasattr(model_pool, 'message_update') or hasattr(model_pool, 'message_new'), \
503 "No possible route found for incoming message with Message-Id %s. " \
504 "Create an appropriate mail.alias or force the destination model." % message_id
505 if thread_id and not model_pool.exists(cr, uid, thread_id):
506 _logger.warning('Received mail reply to missing document %s! Ignoring and creating new document instead for Message-Id %s',
507 thread_id, message_id)
509 _logger.debug('Routing mail with Message-Id %s: fallback to model:%s, thread_id:%s, custom_values:%s, uid:%s',
510 message_id, model, thread_id, custom_values, uid)
511 return [(model, thread_id, custom_values, uid)]
513 def message_process(self, cr, uid, model, message, custom_values=None,
514 save_original=False, strip_attachments=False,
515 thread_id=None, context=None):
516 """ Process an incoming RFC2822 email message, relying on
517 ``mail.message.parse()`` for the parsing operation,
518 and ``message_route()`` to figure out the target model.
520 Once the target model is known, its ``message_new`` method
521 is called with the new message (if the thread record did not exist)
522 or its ``message_update`` method (if it did).
524 There is a special case where the target model is False: a reply
525 to a private message. In this case, we skip the message_new /
526 message_update step, to just post a new message using mail_thread
529 :param string model: the fallback model to use if the message
530 does not match any of the currently configured mail aliases
531 (may be None if a matching alias is supposed to be present)
532 :param message: source of the RFC2822 message
533 :type message: string or xmlrpclib.Binary
534 :type dict custom_values: optional dictionary of field values
535 to pass to ``message_new`` if a new record needs to be created.
536 Ignored if the thread record already exists, and also if a
537 matching mail.alias was found (aliases define their own defaults)
538 :param bool save_original: whether to keep a copy of the original
539 email source attached to the message after it is imported.
540 :param bool strip_attachments: whether to strip all attachments
541 before processing the message, in order to save some space.
542 :param int thread_id: optional ID of the record/thread from ``model``
543 to which this mail should be attached. When provided, this
544 overrides the automatic detection based on the message
550 # extract message bytes - we are forced to pass the message as binary because
551 # we don't know its encoding until we parse its headers and hence can't
552 # convert it to utf-8 for transport between the mailgate script and here.
553 if isinstance(message, xmlrpclib.Binary):
554 message = str(message.data)
555 # Warning: message_from_string doesn't always work correctly on unicode,
556 # we must use utf-8 strings here :-(
557 if isinstance(message, unicode):
558 message = message.encode('utf-8')
559 msg_txt = email.message_from_string(message)
560 routes = self.message_route(cr, uid, msg_txt, model,
561 thread_id, custom_values,
563 msg = self.message_parse(cr, uid, msg_txt, save_original=save_original, context=context)
564 if strip_attachments:
565 msg.pop('attachments', None)
567 for model, thread_id, custom_values, user_id in routes:
568 if self._name != model:
569 context.update({'thread_model': model})
571 model_pool = self.pool.get(model)
572 assert thread_id and hasattr(model_pool, 'message_update') or hasattr(model_pool, 'message_new'), \
573 "Undeliverable mail with Message-Id %s, model %s does not accept incoming emails" % \
574 (msg['message_id'], model)
575 if thread_id and hasattr(model_pool, 'message_update'):
576 model_pool.message_update(cr, user_id, [thread_id], msg, context=context)
578 thread_id = model_pool.message_new(cr, user_id, msg, custom_values, context=context)
580 assert thread_id == 0, "Posting a message without model should be with a null res_id, to create a private message."
581 model_pool = self.pool.get('mail.thread')
582 model_pool.message_post_user_api(cr, uid, [thread_id], context=context, content_subtype='html', **msg)
585 def message_new(self, cr, uid, msg_dict, custom_values=None, context=None):
586 """Called by ``message_process`` when a new message is received
587 for a given thread model, if the message did not belong to
589 The default behavior is to create a new record of the corresponding
590 model (based on some very basic info extracted from the message).
591 Additional behavior may be implemented by overriding this method.
593 :param dict msg_dict: a map containing the email details and
594 attachments. See ``message_process`` and
595 ``mail.message.parse`` for details.
596 :param dict custom_values: optional dictionary of additional
597 field values to pass to create()
598 when creating the new thread record.
599 Be careful, these values may override
600 any other values coming from the message.
601 :param dict context: if a ``thread_model`` value is present
602 in the context, its value will be used
603 to determine the model of the record
604 to create (instead of the current model).
606 :return: the id of the newly created thread object
610 model = context.get('thread_model') or self._name
611 model_pool = self.pool.get(model)
612 fields = model_pool.fields_get(cr, uid, context=context)
613 data = model_pool.default_get(cr, uid, fields, context=context)
614 if 'name' in fields and not data.get('name'):
615 data['name'] = msg_dict.get('subject', '')
616 if custom_values and isinstance(custom_values, dict):
617 data.update(custom_values)
618 res_id = model_pool.create(cr, uid, data, context=context)
621 def message_update(self, cr, uid, ids, msg_dict, update_vals=None, context=None):
622 """Called by ``message_process`` when a new message is received
623 for an existing thread. The default behavior is to update the record
624 with update_vals taken from the incoming email.
625 Additional behavior may be implemented by overriding this
627 :param dict msg_dict: a map containing the email details and
628 attachments. See ``message_process`` and
629 ``mail.message.parse()`` for details.
630 :param dict update_vals: a dict containing values to update records
631 given their ids; if the dict is None or is
632 void, no write operation is performed.
635 self.write(cr, uid, ids, update_vals, context=context)
638 def _message_extract_payload(self, message, save_original=False):
639 """Extract body as HTML and attachments from the mail message"""
643 attachments.append(('original_email.eml', message.as_string()))
644 if not message.is_multipart() or 'text/' in message.get('content-type', ''):
645 encoding = message.get_content_charset()
646 body = message.get_payload(decode=True)
647 body = tools.ustr(body, encoding, errors='replace')
648 if message.get_content_type() == 'text/plain':
649 # text/plain -> <pre/>
650 body = tools.append_content_to_html(u'', body, preserve=True)
652 alternative = (message.get_content_type() == 'multipart/alternative')
653 for part in message.walk():
654 if part.get_content_maintype() == 'multipart':
655 continue # skip container
656 filename = part.get_filename() # None if normal part
657 encoding = part.get_content_charset() # None if attachment
658 # 1) Explicit Attachments -> attachments
659 if filename or part.get('content-disposition', '').strip().startswith('attachment'):
660 attachments.append((filename or 'attachment', part.get_payload(decode=True)))
662 # 2) text/plain -> <pre/>
663 if part.get_content_type() == 'text/plain' and (not alternative or not body):
664 body = tools.append_content_to_html(body, tools.ustr(part.get_payload(decode=True),
665 encoding, errors='replace'), preserve=True)
666 # 3) text/html -> raw
667 elif part.get_content_type() == 'text/html':
668 html = tools.ustr(part.get_payload(decode=True), encoding, errors='replace')
672 body = tools.append_content_to_html(body, html, plaintext=False)
673 # 4) Anything else -> attachment
675 attachments.append((filename or 'attachment', part.get_payload(decode=True)))
676 return body, attachments
678 def message_parse(self, cr, uid, message, save_original=False, context=None):
679 """Parses a string or email.message.Message representing an
680 RFC-2822 email, and returns a generic dict holding the
683 :param message: the message to parse
684 :type message: email.message.Message | string | unicode
685 :param bool save_original: whether the returned dict
686 should include an ``original`` attachment containing
687 the source of the message
689 :return: A dict with the following structure, where each
690 field may not be present if missing in original
693 { 'message_id': msg_id,
698 'body': unified_body,
699 'attachments': [('file1', 'bytes'),
707 if not isinstance(message, Message):
708 if isinstance(message, unicode):
709 # Warning: message_from_string doesn't always work correctly on unicode,
710 # we must use utf-8 strings here :-(
711 message = message.encode('utf-8')
712 message = email.message_from_string(message)
714 message_id = message['message-id']
716 # Very unusual situation, be we should be fault-tolerant here
717 message_id = "<%s@localhost>" % time.time()
718 _logger.debug('Parsing Message without message-id, generating a random one: %s', message_id)
719 msg_dict['message_id'] = message_id
721 if 'Subject' in message:
722 msg_dict['subject'] = decode(message.get('Subject'))
724 # Envelope fields not stored in mail.message but made available for message_new()
725 msg_dict['from'] = decode(message.get('from'))
726 msg_dict['to'] = decode(message.get('to'))
727 msg_dict['cc'] = decode(message.get('cc'))
729 if 'From' in message:
730 author_ids = self._message_find_partners(cr, uid, message, ['From'], context=context)
732 msg_dict['author_id'] = author_ids[0]
734 msg_dict['email_from'] = message.get('from')
735 partner_ids = self._message_find_partners(cr, uid, message, ['From', 'To', 'Cc'], context=context)
736 msg_dict['partner_ids'] = [(4, partner_id) for partner_id in partner_ids]
738 if 'Date' in message:
740 date_hdr = decode(message.get('Date'))
741 parsed_date = dateutil.parser.parse(date_hdr, fuzzy=True)
742 if parsed_date.utcoffset() is None:
743 # naive datetime, so we arbitrarily decide to make it
744 # UTC, there's no better choice. Should not happen,
745 # as RFC2822 requires timezone offset in Date headers.
746 stored_date = parsed_date.replace(tzinfo=pytz.utc)
748 stored_date = parsed_date.astimezone(pytz.utc)
750 _logger.warning('Failed to parse Date header %r in incoming mail '
751 'with message-id %r, assuming current date/time.',
752 message.get('Date'), message_id)
753 stored_date = datetime.datetime.now()
754 msg_dict['date'] = stored_date.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT)
756 if 'In-Reply-To' in message:
757 parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', '=', decode(message['In-Reply-To']))])
759 msg_dict['parent_id'] = parent_ids[0]
761 if 'References' in message and 'parent_id' not in msg_dict:
762 parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', 'in',
763 [x.strip() for x in decode(message['References']).split()])])
765 msg_dict['parent_id'] = parent_ids[0]
767 msg_dict['body'], msg_dict['attachments'] = self._message_extract_payload(message)
770 #------------------------------------------------------
772 #------------------------------------------------------
774 def log(self, cr, uid, id, message, secondary=False, context=None):
775 _logger.warning("log() is deprecated. As this module inherit from "\
776 "mail.thread, the message will be managed by this "\
777 "module instead of by the res.log mechanism. Please "\
778 "use mail_thread.message_post() instead of the "\
779 "now deprecated res.log.")
780 self.message_post(cr, uid, [id], message, context=context)
782 def message_post(self, cr, uid, thread_id, body='', subject=None, type='notification',
783 subtype=None, parent_id=False, attachments=None, context=None, **kwargs):
784 """ Post a new message in an existing thread, returning the new
785 mail.message ID. Extra keyword arguments will be used as default
786 column values for the new mail.message record.
787 Auto link messages for same id and object
788 :param int thread_id: thread ID to post into, or list with one ID;
789 if False/0, mail.message model will also be set as False
790 :param str body: body of the message, usually raw HTML that will
792 :param str subject: optional subject
793 :param str type: mail_message.type
794 :param int parent_id: optional ID of parent message in this thread
795 :param tuple(str,str) attachments or list id: list of attachment tuples in the form
796 ``(name,content)``, where content is NOT base64 encoded
797 :return: ID of newly created mail.message
801 if attachments is None:
804 assert (not thread_id) or isinstance(thread_id, (int, long)) or \
805 (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"
806 if isinstance(thread_id, (list, tuple)):
807 thread_id = thread_id and thread_id[0]
808 mail_message = self.pool.get('mail.message')
809 model = context.get('thread_model', self._name) if thread_id else False
812 for name, content in attachments:
813 if isinstance(content, unicode):
814 content = content.encode('utf-8')
817 'datas': base64.b64encode(str(content)),
820 'res_model': context.get('thread_model') or self._name,
823 attachment_ids.append((0, 0, data_attach))
827 s_data = subtype.split('.')
829 s_data = ('mail', s_data[0])
830 ref = self.pool.get('ir.model.data').get_object_reference(cr, uid, s_data[0], s_data[1])
831 subtype_id = ref and ref[1] or False
835 # _mail_flat_thread: automatically set free messages to the first posted message
836 if self._mail_flat_thread and not parent_id and thread_id:
837 message_ids = mail_message.search(cr, uid, ['&', ('res_id', '=', thread_id), ('model', '=', model)], context=context, order="id ASC", limit=1)
838 parent_id = message_ids and message_ids[0] or False
839 # 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
841 message_ids = mail_message.search(cr, SUPERUSER_ID, [('id', '=', parent_id), ('parent_id', '!=', False)], context=context)
842 # avoid loops when finding ancestors
845 message = mail_message.browse(cr, SUPERUSER_ID, message_ids[0], context=context)
846 while (message.parent_id and message.parent_id.id not in processed_list):
847 processed_list.append(message.parent_id.id)
848 message = message.parent_id
849 parent_id = message.id
854 'res_id': thread_id or False,
856 'subject': subject or False,
858 'parent_id': parent_id,
859 'attachment_ids': attachment_ids,
860 'subtype_id': subtype_id,
863 # Avoid warnings about non-existing fields
864 for x in ('from', 'to', 'cc'):
867 return mail_message.create(cr, uid, values, context=context)
869 def message_post_user_api(self, cr, uid, thread_id, body='', subject=False, parent_id=False,
870 attachment_ids=None, context=None, content_subtype='plaintext', **kwargs):
871 """ Wrapper on message_post, used for user input :
873 - quick reply in Chatter (refer to mail.js), not
874 the mail.compose.message wizard
875 The purpose is to perform some pre- and post-processing:
876 - if body is plaintext: convert it into html
877 - if parent_id: handle reply to a previous message by adding the
878 parent partners to the message
879 - type and subtype: comment and mail.mt_comment by default
880 - attachment_ids: supposed not attached to any document; attach them
881 to the related document. Should only be set by Chatter.
883 ir_attachment = self.pool.get('ir.attachment')
884 mail_message = self.pool.get('mail.message')
886 # 1. Pre-processing: body, partner_ids, type and subtype
887 if content_subtype == 'plaintext':
888 body = tools.plaintext2html(body)
890 partner_ids = kwargs.pop('partner_ids', [])
892 parent_message = self.pool.get('mail.message').browse(cr, uid, parent_id, context=context)
893 partner_ids += [(4, partner.id) for partner in parent_message.partner_ids]
894 # TDE FIXME HACK: mail.thread -> private message
895 if self._name == 'mail.thread' and parent_message.author_id.id:
896 partner_ids.append((4, parent_message.author_id.id))
898 message_type = kwargs.pop('type', 'comment')
899 message_subtype = kwargs.pop('subtype', 'mail.mt_comment')
902 new_message_id = self.message_post(cr, uid, thread_id=thread_id, body=body, subject=subject, type=message_type,
903 subtype=message_subtype, parent_id=parent_id, context=context, partner_ids=partner_ids, **kwargs)
906 # HACK TDE FIXME: Chatter: attachments linked to the document (not done JS-side), load the message
908 # TDE FIXME (?): when posting a private message, we use mail.thread as a model
909 # However, attaching doc to mail.thread is not possible, mail.thread does not have any table
911 if model == 'mail.thread':
913 filtered_attachment_ids = ir_attachment.search(cr, SUPERUSER_ID, [
914 ('res_model', '=', 'mail.compose.message'),
916 ('create_uid', '=', uid),
917 ('id', 'in', attachment_ids)], context=context)
918 if filtered_attachment_ids:
919 if thread_id and model:
920 ir_attachment.write(cr, SUPERUSER_ID, attachment_ids, {'res_model': model, 'res_id': thread_id}, context=context)
921 mail_message.write(cr, SUPERUSER_ID, [new_message_id], {'attachment_ids': [(6, 0, [pid for pid in attachment_ids])]}, context=context)
923 return new_message_id
925 #------------------------------------------------------
927 #------------------------------------------------------
929 def message_get_subscription_data(self, cr, uid, ids, context=None):
930 """ Wrapper to get subtypes data. """
931 return self._get_subscription_data(cr, uid, ids, None, None, context=context)
933 def message_subscribe_users(self, cr, uid, ids, user_ids=None, subtype_ids=None, context=None):
934 """ Wrapper on message_subscribe, using users. If user_ids is not
935 provided, subscribe uid instead. """
938 partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
939 return self.message_subscribe(cr, uid, ids, partner_ids, subtype_ids=subtype_ids, context=context)
941 def message_subscribe(self, cr, uid, ids, partner_ids, subtype_ids=None, context=None):
942 """ Add partners to the records followers. """
943 self.check_access_rights(cr, uid, 'read')
944 self.write(cr, SUPERUSER_ID, ids, {'message_follower_ids': [(4, pid) for pid in partner_ids]}, context=context)
945 # if subtypes are not specified (and not set to a void list), fetch default ones
946 if subtype_ids is None:
947 subtype_obj = self.pool.get('mail.message.subtype')
948 subtype_ids = subtype_obj.search(cr, uid, [('default', '=', True), '|', ('res_model', '=', self._name), ('res_model', '=', False)], context=context)
949 # update the subscriptions
950 fol_obj = self.pool.get('mail.followers')
951 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids), ('partner_id', 'in', partner_ids)], context=context)
952 fol_obj.write(cr, SUPERUSER_ID, fol_ids, {'subtype_ids': [(6, 0, subtype_ids)]}, context=context)
955 def message_unsubscribe_users(self, cr, uid, ids, user_ids=None, context=None):
956 """ Wrapper on message_subscribe, using users. If user_ids is not
957 provided, unsubscribe uid instead. """
960 partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
961 return self.message_unsubscribe(cr, uid, ids, partner_ids, context=context)
963 def message_unsubscribe(self, cr, uid, ids, partner_ids, context=None):
964 """ Remove partners from the records followers. """
965 self.check_access_rights(cr, uid, 'read')
966 return self.write(cr, SUPERUSER_ID, ids, {'message_follower_ids': [(3, pid) for pid in partner_ids]}, context=context)
968 def message_subscribe_from_parent(self, cr, uid, ids, context=None):
970 subtype_obj = self.pool.get('mail.message.subtype')
971 follower_obj = self.pool.get('mail.followers')
973 # fetch record subtypes
974 subtype_ids = subtype_obj.search(cr, uid, ['|', ('parent_id.res_model', '=', False), ('parent_id.res_model', '=', self._name)], context=context)
977 subtypes = subtype_obj.browse(cr, uid, subtype_ids, context=context)
979 for record in self.browse(cr, uid, ids, context=context):
980 new_followers = dict()
981 for subtype in subtypes:
982 if subtype.parent_field and subtype.parent_id:
983 if subtype.parent_field in self._columns and getattr(record, subtype.parent_field):
984 parent_res_id = getattr(record, subtype.parent_field).id
985 parent_model = subtype.res_model
986 follower_ids = follower_obj.search(cr, SUPERUSER_ID, [('res_model', '=', parent_model), ('res_id', '=', parent_res_id), ('subtype_ids', 'in', [subtype.id])], context=context)
987 for follower in follower_obj.browse(cr, SUPERUSER_ID, follower_ids, context=context):
988 new_followers.setdefault(follower.partner_id.id, set()).add(subtype.parent_id.id)
990 for pid, subtypes in new_followers.items():
991 self.message_subscribe(cr, uid, [record.id], [pid], list(subtypes), context=context)
993 def _subscribe_followers_subtype(self, cr, uid, ids, res_id, model, context=None):
994 """ TDE note: not the best way to do this, we could override _get_followers
995 of task, and perform a better mapping of subtypes than a mapping
997 However we will keep this implementation, maybe to be refactored
998 in 7.1 of future versions. """
999 subtype_obj = self.pool.get('mail.message.subtype')
1000 follower_obj = self.pool.get('mail.followers')
1002 subtype_ids = subtype_obj.search(cr, uid, ['|', ('res_model', '=', False), ('res_model', '=', self._name)], context=context)
1003 subtypes = subtype_obj.browse(cr, uid, subtype_ids, context=context)
1004 # fetch subscriptions
1005 follower_ids = follower_obj.search(cr, uid, [('res_model', '=', model), ('res_id', '=', res_id)], context=context)
1007 for follower in follower_obj.browse(cr, uid, follower_ids, context=context):
1008 if not follower.subtype_ids:
1010 subtype_names = [follower_subtype.name for follower_subtype in follower.subtype_ids]
1011 subtype_ids = [subtype.id for subtype in subtypes if subtype.name in subtype_names]
1012 self.message_subscribe(cr, uid, ids, [follower.partner_id.id],
1013 subtype_ids=subtype_ids, context=context)
1015 #------------------------------------------------------
1017 #------------------------------------------------------
1019 def message_mark_as_unread(self, cr, uid, ids, context=None):
1020 """ Set as unread. """
1021 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1023 UPDATE mail_notification SET
1026 message_id IN (SELECT id from mail_message where res_id=any(%s) and model=%s limit 1) and
1028 ''', (ids, self._name, partner_id))
1031 def message_mark_as_read(self, cr, uid, ids, context=None):
1032 """ Set as read. """
1033 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1035 UPDATE mail_notification SET
1038 message_id IN (SELECT id FROM mail_message WHERE res_id=ANY(%s) AND model=%s) AND
1040 ''', (ids, self._name, partner_id))
1043 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: