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 ##############################################################################
31 from email.message import Message
33 from openerp import tools
34 from openerp import SUPERUSER_ID
35 from openerp.addons.mail.mail_message import decode
36 from openerp.osv import fields, osv
37 from openerp.tools.safe_eval import safe_eval as eval
38 from openerp.tools.translate import _
40 _logger = logging.getLogger(__name__)
43 def decode_header(message, header, separator=' '):
44 return separator.join(map(decode, filter(None, message.get_all(header, []))))
47 class mail_thread(osv.AbstractModel):
48 ''' mail_thread model is meant to be inherited by any model that needs to
49 act as a discussion topic on which messages can be attached. Public
50 methods are prefixed with ``message_`` in order to avoid name
51 collisions with methods of the models that will inherit from this class.
53 ``mail.thread`` defines fields used to handle and display the
54 communication history. ``mail.thread`` also manages followers of
55 inheriting classes. All features and expected behavior are managed
56 by mail.thread. Widgets has been designed for the 7.0 and following
59 Inheriting classes are not required to implement any method, as the
60 default implementation will work for any model. However it is common
61 to override at least the ``message_new`` and ``message_update``
62 methods (calling ``super``) to add model-specific behavior at
63 creation and update of a thread when processing incoming emails.
66 - _mail_flat_thread: if set to True, all messages without parent_id
67 are automatically attached to the first message posted on the
68 ressource. If set to False, the display of Chatter is done using
69 threads, and no parent_id is automatically set.
72 _description = 'Email Thread'
73 _mail_flat_thread = True
75 # Automatic logging system if mail installed
78 # 'module.subtype_xml': lambda self, cr, uid, obj, context=None: obj[state] == done,
79 # 'module.subtype_xml2': lambda self, cr, uid, obj, context=None: obj[state] != done,
86 # :param string field: field name
87 # :param module.subtype_xml: xml_id of a mail.message.subtype (i.e. mail.mt_comment)
88 # :param obj: is a browse_record
89 # :param function lambda: returns whether the tracking should record using this subtype
92 def _get_message_data(self, cr, uid, ids, name, args, context=None):
94 - message_unread: has uid unread message for the document
95 - message_summary: html snippet summarizing the Chatter for kanban views """
96 res = dict((id, dict(message_unread=False, message_summary='')) for id in ids)
97 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
99 # search for unread messages, directly in SQL to improve performances
100 cr.execute(""" SELECT m.res_id FROM mail_message m
101 RIGHT JOIN mail_notification n
102 ON (n.message_id = m.id AND n.partner_id = %s AND (n.read = False or n.read IS NULL))
103 WHERE m.model = %s AND m.res_id in %s""",
104 (user_pid, self._name, tuple(ids),))
105 msg_ids = [result[0] for result in cr.fetchall()]
106 for msg_id in msg_ids:
107 res[msg_id]['message_unread'] = True
109 for thread in self.browse(cr, uid, ids, context=context):
110 cls = res[thread.id]['message_unread'] and ' class="oe_kanban_mail_new"' or ''
111 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))
115 def _get_subscription_data(self, cr, uid, ids, name, args, context=None):
117 - message_subtype_data: data about document subtypes: which are
118 available, which are followed if any """
119 res = dict((id, dict(message_subtype_data='')) for id in ids)
120 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
122 # find current model subtypes, add them to a dictionary
123 subtype_obj = self.pool.get('mail.message.subtype')
124 subtype_ids = subtype_obj.search(cr, uid, ['|', ('res_model', '=', self._name), ('res_model', '=', False)], context=context)
125 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))
127 res[id]['message_subtype_data'] = subtype_dict.copy()
129 # find the document followers, update the data
130 fol_obj = self.pool.get('mail.followers')
131 fol_ids = fol_obj.search(cr, uid, [
132 ('partner_id', '=', user_pid),
133 ('res_id', 'in', ids),
134 ('res_model', '=', self._name),
136 for fol in fol_obj.browse(cr, uid, fol_ids, context=context):
137 thread_subtype_dict = res[fol.res_id]['message_subtype_data']
138 for subtype in fol.subtype_ids:
139 thread_subtype_dict[subtype.name]['followed'] = True
140 res[fol.res_id]['message_subtype_data'] = thread_subtype_dict
144 def _search_message_unread(self, cr, uid, obj=None, name=None, domain=None, context=None):
145 return [('message_ids.to_read', '=', True)]
147 def _get_followers(self, cr, uid, ids, name, arg, context=None):
148 fol_obj = self.pool.get('mail.followers')
149 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids)])
150 res = dict((id, dict(message_follower_ids=[], message_is_follower=False)) for id in ids)
151 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
152 for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids):
153 res[fol.res_id]['message_follower_ids'].append(fol.partner_id.id)
154 if fol.partner_id.id == user_pid:
155 res[fol.res_id]['message_is_follower'] = True
158 def _set_followers(self, cr, uid, id, name, value, arg, context=None):
161 partner_obj = self.pool.get('res.partner')
162 fol_obj = self.pool.get('mail.followers')
164 # read the old set of followers, and determine the new set of followers
165 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', '=', id)])
166 old = set(fol.partner_id.id for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids))
169 for command in value or []:
170 if isinstance(command, (int, long)):
172 elif command[0] == 0:
173 new.add(partner_obj.create(cr, uid, command[2], context=context))
174 elif command[0] == 1:
175 partner_obj.write(cr, uid, [command[1]], command[2], context=context)
177 elif command[0] == 2:
178 partner_obj.unlink(cr, uid, [command[1]], context=context)
179 new.discard(command[1])
180 elif command[0] == 3:
181 new.discard(command[1])
182 elif command[0] == 4:
184 elif command[0] == 5:
186 elif command[0] == 6:
187 new = set(command[2])
189 # remove partners that are no longer followers
190 fol_ids = fol_obj.search(cr, SUPERUSER_ID,
191 [('res_model', '=', self._name), ('res_id', '=', id), ('partner_id', 'not in', list(new))])
192 fol_obj.unlink(cr, SUPERUSER_ID, fol_ids)
195 for partner_id in new - old:
196 fol_obj.create(cr, SUPERUSER_ID, {'res_model': self._name, 'res_id': id, 'partner_id': partner_id})
198 def _search_followers(self, cr, uid, obj, name, args, context):
199 fol_obj = self.pool.get('mail.followers')
201 for field, operator, value in args:
203 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('partner_id', operator, value)])
204 res_ids = [fol.res_id for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids)]
205 res.append(('id', 'in', res_ids))
209 'message_is_follower': fields.function(_get_followers,
210 type='boolean', string='Is a Follower', multi='_get_followers,'),
211 'message_follower_ids': fields.function(_get_followers, fnct_inv=_set_followers,
212 fnct_search=_search_followers, type='many2many',
213 obj='res.partner', string='Followers', multi='_get_followers'),
214 'message_ids': fields.one2many('mail.message', 'res_id',
215 domain=lambda self: [('model', '=', self._name)],
218 help="Messages and communication history"),
219 'message_unread': fields.function(_get_message_data,
220 fnct_search=_search_message_unread, multi="_get_message_data",
221 type='boolean', string='Unread Messages',
222 help="If checked new messages require your attention."),
223 'message_summary': fields.function(_get_message_data, method=True,
224 type='text', string='Summary', multi="_get_message_data",
225 help="Holds the Chatter summary (number of messages, ...). "\
226 "This summary is directly in html format in order to "\
227 "be inserted in kanban views."),
230 #------------------------------------------------------
231 # CRUD overrides for automatic subscription and logging
232 #------------------------------------------------------
234 def create(self, cr, uid, values, context=None):
235 """ Chatter override :
237 - subscribe followers of parent
238 - log a creation message
242 thread_id = super(mail_thread, self).create(cr, uid, values, context=context)
244 # subscribe uid unless asked not to
245 if not context.get('mail_create_nosubscribe'):
246 self.message_subscribe_users(cr, uid, [thread_id], [uid], context=context)
247 self.message_auto_subscribe(cr, uid, [thread_id], values.keys(), context=context)
249 # automatic logging unless asked not to (mainly for various testing purpose)
250 if not context.get('mail_create_nolog'):
251 self.message_post(cr, uid, thread_id, body=_('%s created') % (self._description), context=context)
254 def write(self, cr, uid, ids, values, context=None):
255 if isinstance(ids, (int, long)):
257 # Track initial values of tracked fields
258 tracked_fields = self._get_tracked_fields(cr, uid, values.keys(), context=context)
260 initial = self.read(cr, uid, ids, tracked_fields.keys(), context=context)
261 initial_values = dict((item['id'], item) for item in initial)
263 # Perform write, update followers
264 result = super(mail_thread, self).write(cr, uid, ids, values, context=context)
265 self.message_auto_subscribe(cr, uid, ids, values.keys(), context=context)
267 # Perform the tracking
269 self.message_track(cr, uid, ids, tracked_fields, initial_values, context=context)
272 def unlink(self, cr, uid, ids, context=None):
273 """ Override unlink to delete messages and followers. This cannot be
274 cascaded, because link is done through (res_model, res_id). """
275 msg_obj = self.pool.get('mail.message')
276 fol_obj = self.pool.get('mail.followers')
277 # delete messages and notifications
278 msg_ids = msg_obj.search(cr, uid, [('model', '=', self._name), ('res_id', 'in', ids)], context=context)
279 msg_obj.unlink(cr, uid, msg_ids, context=context)
281 res = super(mail_thread, self).unlink(cr, uid, ids, context=context)
283 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids)], context=context)
284 fol_obj.unlink(cr, SUPERUSER_ID, fol_ids, context=context)
287 def copy(self, cr, uid, id, default=None, context=None):
288 default = default or {}
289 default['message_ids'] = []
290 default['message_follower_ids'] = []
291 return super(mail_thread, self).copy(cr, uid, id, default=default, context=context)
293 #------------------------------------------------------
294 # Automatically log tracked fields
295 #------------------------------------------------------
297 def _get_tracked_fields(self, cr, uid, updated_fields, context=None):
298 """ Return a structure of tracked fields for the current model.
299 :param list updated_fields: modified field names
300 :return list: a list of (field_name, column_info obj), containing
301 always tracked fields and modified on_change fields
304 for name, column_info in self._all_columns.items():
305 visibility = getattr(column_info.column, 'track_visibility', False)
306 if visibility == 'always' or (visibility == 'onchange' and name in updated_fields) or name in self._track:
310 return self.fields_get(cr, uid, lst, context=context)
312 def message_track(self, cr, uid, ids, tracked_fields, initial_values, context=None):
314 def convert_for_display(value, col_info):
315 if not value and col_info['type'] == 'boolean':
319 if col_info['type'] == 'many2one':
321 if col_info['type'] == 'selection':
322 return dict(col_info['selection'])[value]
325 def format_message(message_description, tracked_values):
327 if message_description:
328 message = '<span>%s</span>' % message_description
329 for name, change in tracked_values.items():
330 message += '<div> • <b>%s</b>: ' % change.get('col_info')
331 if change.get('old_value'):
332 message += '%s → ' % change.get('old_value')
333 message += '%s</div>' % change.get('new_value')
336 if not tracked_fields:
339 for record in self.read(cr, uid, ids, tracked_fields.keys(), context=context):
340 initial = initial_values[record['id']]
344 # generate tracked_values data structure: {'col_name': {col_info, new_value, old_value}}
345 for col_name, col_info in tracked_fields.items():
346 if record[col_name] == initial[col_name] and getattr(self._all_columns[col_name].column, 'track_visibility', None) == 'always':
347 tracked_values[col_name] = dict(col_info=col_info['string'],
348 new_value=convert_for_display(record[col_name], col_info))
349 elif record[col_name] != initial[col_name]:
350 if getattr(self._all_columns[col_name].column, 'track_visibility', None) in ['always', 'onchange']:
351 tracked_values[col_name] = dict(col_info=col_info['string'],
352 old_value=convert_for_display(initial[col_name], col_info),
353 new_value=convert_for_display(record[col_name], col_info))
354 if col_name in tracked_fields:
355 changes.append(col_name)
359 # find subtypes and post messages or log if no subtype found
361 for field, track_info in self._track.items():
362 if field not in changes:
364 for subtype, method in track_info.items():
365 if method(self, cr, uid, record, context):
366 subtypes.append(subtype)
369 for subtype in subtypes:
371 subtype_rec = self.pool.get('ir.model.data').get_object(cr, uid, subtype.split('.')[0], subtype.split('.')[1])
372 except ValueError, e:
373 _logger.debug('subtype %s not found, giving error "%s"' % (subtype, e))
375 message = format_message(subtype_rec.description if subtype_rec.description else subtype_rec.name, tracked_values)
376 self.message_post(cr, uid, record['id'], body=message, subtype=subtype, context=context)
379 message = format_message('', tracked_values)
380 self.message_post(cr, uid, record['id'], body=message, context=context)
383 #------------------------------------------------------
384 # mail.message wrappers and tools
385 #------------------------------------------------------
387 def _needaction_domain_get(self, cr, uid, context=None):
389 return [('message_unread', '=', True)]
392 #------------------------------------------------------
394 #------------------------------------------------------
396 def message_get_reply_to(self, cr, uid, ids, context=None):
397 if not self._inherits.get('mail.alias'):
398 return [False for id in ids]
399 return ["%s@%s" % (record['alias_name'], record['alias_domain'])
400 if record.get('alias_domain') and record.get('alias_name')
402 for record in self.read(cr, uid, ids, ['alias_name', 'alias_domain'], context=context)]
404 #------------------------------------------------------
406 #------------------------------------------------------
408 def message_capable_models(self, cr, uid, context=None):
409 """ Used by the plugin addon, based for plugin_outlook and others. """
411 for model_name in self.pool.obj_list():
412 model = self.pool.get(model_name)
413 if 'mail.thread' in getattr(model, '_inherit', []):
414 ret_dict[model_name] = model._description
417 def _message_find_partners(self, cr, uid, message, header_fields=['From'], context=None):
418 """ Find partners related to some header fields of the message. """
419 s = ', '.join([decode(message.get(h)) for h in header_fields if message.get(h)])
420 return [partner_id for email in tools.email_split(s)
421 for partner_id in self.pool.get('res.partner').search(cr, uid, [('email', 'ilike', email)], limit=1, context=context)]
423 def _message_find_user_id(self, cr, uid, message, context=None):
424 from_local_part = tools.email_split(decode(message.get('From')))[0]
425 # FP Note: canonification required, the minimu: .lower()
426 user_ids = self.pool.get('res.users').search(cr, uid, ['|',
427 ('login', '=', from_local_part),
428 ('email', '=', from_local_part)], context=context)
429 return user_ids[0] if user_ids else uid
431 def message_route(self, cr, uid, message, model=None, thread_id=None,
432 custom_values=None, context=None):
433 """Attempt to figure out the correct target model, thread_id,
434 custom_values and user_id to use for an incoming message.
435 Multiple values may be returned, if a message had multiple
436 recipients matching existing mail.aliases, for example.
438 The following heuristics are used, in this order:
439 1. If the message replies to an existing thread_id, and
440 properly contains the thread model in the 'In-Reply-To'
441 header, use this model/thread_id pair, and ignore
442 custom_value (not needed as no creation will take place)
443 2. Look for a mail.alias entry matching the message
444 recipient, and use the corresponding model, thread_id,
445 custom_values and user_id.
446 3. Fallback to the ``model``, ``thread_id`` and ``custom_values``
448 4. If all the above fails, raise an exception.
450 :param string message: an email.message instance
451 :param string model: the fallback model to use if the message
452 does not match any of the currently configured mail aliases
453 (may be None if a matching alias is supposed to be present)
454 :type dict custom_values: optional dictionary of default field values
455 to pass to ``message_new`` if a new record needs to be created.
456 Ignored if the thread record already exists, and also if a
457 matching mail.alias was found (aliases define their own defaults)
458 :param int thread_id: optional ID of the record/thread from ``model``
459 to which this mail should be attached. Only used if the message
460 does not reply to an existing thread and does not match any mail alias.
461 :return: list of [model, thread_id, custom_values, user_id]
463 assert isinstance(message, Message), 'message must be an email.message.Message at this point'
464 message_id = message.get('Message-Id')
465 references = decode_header(message, 'References')
466 in_reply_to = decode_header(message, 'In-Reply-To')
468 # 1. Verify if this is a reply to an existing thread
469 thread_references = references or in_reply_to
470 ref_match = thread_references and tools.reference_re.search(thread_references)
472 thread_id = int(ref_match.group(1))
473 model = ref_match.group(2) or model
474 model_pool = self.pool.get(model)
475 if thread_id and model and model_pool and model_pool.exists(cr, uid, thread_id) \
476 and hasattr(model_pool, 'message_update'):
477 _logger.debug('Routing mail with Message-Id %s: direct reply to model: %s, thread_id: %s, custom_values: %s, uid: %s',
478 message_id, model, thread_id, custom_values, uid)
479 return [(model, thread_id, custom_values, uid)]
481 # Verify whether this is a reply to a private message
483 message_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', '=', in_reply_to)], limit=1, context=context)
485 message = self.pool.get('mail.message').browse(cr, uid, message_ids[0], context=context)
486 _logger.debug('Routing mail with Message-Id %s: direct reply to a private message: %s, custom_values: %s, uid: %s',
487 message_id, message.id, custom_values, uid)
488 return [(message.model, message.res_id, custom_values, uid)]
490 # 2. Look for a matching mail.alias entry
491 # Delivered-To is a safe bet in most modern MTAs, but we have to fallback on To + Cc values
492 # for all the odd MTAs out there, as there is no standard header for the envelope's `rcpt_to` value.
494 ','.join([decode_header(message, 'Delivered-To'),
495 decode_header(message, 'To'),
496 decode_header(message, 'Cc'),
497 decode_header(message, 'Resent-To'),
498 decode_header(message, 'Resent-Cc')])
499 local_parts = [e.split('@')[0] for e in tools.email_split(rcpt_tos)]
501 mail_alias = self.pool.get('mail.alias')
502 alias_ids = mail_alias.search(cr, uid, [('alias_name', 'in', local_parts)])
505 for alias in mail_alias.browse(cr, uid, alias_ids, context=context):
506 user_id = alias.alias_user_id.id
508 # TDE note: this could cause crashes, because no clue that the user
509 # that send the email has the right to create or modify a new document
510 # Fallback on user_id = uid
511 # Note: recognized partners will be added as followers anyway
512 # user_id = self._message_find_user_id(cr, uid, message, context=context)
514 _logger.debug('No matching user_id for the alias %s', alias.alias_name)
515 routes.append((alias.alias_model_id.model, alias.alias_force_thread_id, \
516 eval(alias.alias_defaults), user_id))
517 _logger.debug('Routing mail with Message-Id %s: direct alias match: %r', message_id, routes)
520 # 3. Fallback to the provided parameters, if they work
521 model_pool = self.pool.get(model)
523 # Legacy: fallback to matching [ID] in the Subject
524 match = tools.res_re.search(decode_header(message, 'Subject'))
525 thread_id = match and match.group(1)
526 assert thread_id and hasattr(model_pool, 'message_update') or hasattr(model_pool, 'message_new'), \
527 "No possible route found for incoming message with Message-Id %s. " \
528 "Create an appropriate mail.alias or force the destination model." % message_id
529 if thread_id and not model_pool.exists(cr, uid, thread_id):
530 _logger.warning('Received mail reply to missing document %s! Ignoring and creating new document instead for Message-Id %s',
531 thread_id, message_id)
533 _logger.debug('Routing mail with Message-Id %s: fallback to model:%s, thread_id:%s, custom_values:%s, uid:%s',
534 message_id, model, thread_id, custom_values, uid)
535 return [(model, thread_id, custom_values, uid)]
537 def message_process(self, cr, uid, model, message, custom_values=None,
538 save_original=False, strip_attachments=False,
539 thread_id=None, context=None):
540 """ Process an incoming RFC2822 email message, relying on
541 ``mail.message.parse()`` for the parsing operation,
542 and ``message_route()`` to figure out the target model.
544 Once the target model is known, its ``message_new`` method
545 is called with the new message (if the thread record did not exist)
546 or its ``message_update`` method (if it did).
548 There is a special case where the target model is False: a reply
549 to a private message. In this case, we skip the message_new /
550 message_update step, to just post a new message using mail_thread
553 :param string model: the fallback model to use if the message
554 does not match any of the currently configured mail aliases
555 (may be None if a matching alias is supposed to be present)
556 :param message: source of the RFC2822 message
557 :type message: string or xmlrpclib.Binary
558 :type dict custom_values: optional dictionary of field values
559 to pass to ``message_new`` if a new record needs to be created.
560 Ignored if the thread record already exists, and also if a
561 matching mail.alias was found (aliases define their own defaults)
562 :param bool save_original: whether to keep a copy of the original
563 email source attached to the message after it is imported.
564 :param bool strip_attachments: whether to strip all attachments
565 before processing the message, in order to save some space.
566 :param int thread_id: optional ID of the record/thread from ``model``
567 to which this mail should be attached. When provided, this
568 overrides the automatic detection based on the message
574 # extract message bytes - we are forced to pass the message as binary because
575 # we don't know its encoding until we parse its headers and hence can't
576 # convert it to utf-8 for transport between the mailgate script and here.
577 if isinstance(message, xmlrpclib.Binary):
578 message = str(message.data)
579 # Warning: message_from_string doesn't always work correctly on unicode,
580 # we must use utf-8 strings here :-(
581 if isinstance(message, unicode):
582 message = message.encode('utf-8')
583 msg_txt = email.message_from_string(message)
584 routes = self.message_route(cr, uid, msg_txt, model,
585 thread_id, custom_values,
587 msg = self.message_parse(cr, uid, msg_txt, save_original=save_original, context=context)
588 if strip_attachments:
589 msg.pop('attachments', None)
591 # postpone setting msg.partner_ids after message_post, to avoid double notifications
592 partner_ids = msg.pop('partner_ids', [])
595 for model, thread_id, custom_values, user_id in routes:
596 if self._name == 'mail.thread':
597 context.update({'thread_model': model})
599 model_pool = self.pool.get(model)
600 assert thread_id and hasattr(model_pool, 'message_update') or hasattr(model_pool, 'message_new'), \
601 "Undeliverable mail with Message-Id %s, model %s does not accept incoming emails" % \
602 (msg['message_id'], model)
604 # disabled subscriptions during message_new/update to avoid having the system user running the
605 # email gateway become a follower of all inbound messages
606 nosub_ctx = dict(context, mail_create_nosubscribe=True)
607 if thread_id and hasattr(model_pool, 'message_update'):
608 model_pool.message_update(cr, user_id, [thread_id], msg, context=nosub_ctx)
610 thread_id = model_pool.message_new(cr, user_id, msg, custom_values, context=nosub_ctx)
612 assert thread_id == 0, "Posting a message without model should be with a null res_id, to create a private message."
613 model_pool = self.pool.get('mail.thread')
614 new_msg_id = model_pool.message_post_user_api(cr, uid, [thread_id], context=context, content_subtype='html', **msg)
616 # when posting an incoming email to a document: subscribe the author, if a partner, as follower
617 if model and thread_id and msg.get('author_id'):
618 model_pool.message_subscribe(cr, uid, [thread_id], [msg.get('author_id')], context=context)
621 # postponed after message_post, because this is an external message and we don't want to create
622 # duplicate emails due to notifications
623 self.pool.get('mail.message').write(cr, uid, [new_msg_id], {'partner_ids': partner_ids}, context=context)
627 def message_new(self, cr, uid, msg_dict, custom_values=None, context=None):
628 """Called by ``message_process`` when a new message is received
629 for a given thread model, if the message did not belong to
631 The default behavior is to create a new record of the corresponding
632 model (based on some very basic info extracted from the message).
633 Additional behavior may be implemented by overriding this method.
635 :param dict msg_dict: a map containing the email details and
636 attachments. See ``message_process`` and
637 ``mail.message.parse`` for details.
638 :param dict custom_values: optional dictionary of additional
639 field values to pass to create()
640 when creating the new thread record.
641 Be careful, these values may override
642 any other values coming from the message.
643 :param dict context: if a ``thread_model`` value is present
644 in the context, its value will be used
645 to determine the model of the record
646 to create (instead of the current model).
648 :return: the id of the newly created thread object
653 if isinstance(custom_values, dict):
654 data = custom_values.copy()
655 model = context.get('thread_model') or self._name
656 model_pool = self.pool.get(model)
657 fields = model_pool.fields_get(cr, uid, context=context)
658 if 'name' in fields and not data.get('name'):
659 data['name'] = msg_dict.get('subject', '')
660 res_id = model_pool.create(cr, uid, data, context=context)
663 def message_update(self, cr, uid, ids, msg_dict, update_vals=None, context=None):
664 """Called by ``message_process`` when a new message is received
665 for an existing thread. The default behavior is to update the record
666 with update_vals taken from the incoming email.
667 Additional behavior may be implemented by overriding this
669 :param dict msg_dict: a map containing the email details and
670 attachments. See ``message_process`` and
671 ``mail.message.parse()`` for details.
672 :param dict update_vals: a dict containing values to update records
673 given their ids; if the dict is None or is
674 void, no write operation is performed.
677 self.write(cr, uid, ids, update_vals, context=context)
680 def _message_extract_payload(self, message, save_original=False):
681 """Extract body as HTML and attachments from the mail message"""
685 attachments.append(('original_email.eml', message.as_string()))
686 if not message.is_multipart() or 'text/' in message.get('content-type', ''):
687 encoding = message.get_content_charset()
688 body = message.get_payload(decode=True)
689 body = tools.ustr(body, encoding, errors='replace')
690 if message.get_content_type() == 'text/plain':
691 # text/plain -> <pre/>
692 body = tools.append_content_to_html(u'', body, preserve=True)
694 alternative = (message.get_content_type() == 'multipart/alternative')
695 for part in message.walk():
696 if part.get_content_maintype() == 'multipart':
697 continue # skip container
698 filename = part.get_filename() # None if normal part
699 encoding = part.get_content_charset() # None if attachment
700 # 1) Explicit Attachments -> attachments
701 if filename or part.get('content-disposition', '').strip().startswith('attachment'):
702 attachments.append((filename or 'attachment', part.get_payload(decode=True)))
704 # 2) text/plain -> <pre/>
705 if part.get_content_type() == 'text/plain' and (not alternative or not body):
706 body = tools.append_content_to_html(body, tools.ustr(part.get_payload(decode=True),
707 encoding, errors='replace'), preserve=True)
708 # 3) text/html -> raw
709 elif part.get_content_type() == 'text/html':
710 html = tools.ustr(part.get_payload(decode=True), encoding, errors='replace')
714 body = tools.append_content_to_html(body, html, plaintext=False)
715 # 4) Anything else -> attachment
717 attachments.append((filename or 'attachment', part.get_payload(decode=True)))
718 return body, attachments
720 def message_parse(self, cr, uid, message, save_original=False, context=None):
721 """Parses a string or email.message.Message representing an
722 RFC-2822 email, and returns a generic dict holding the
725 :param message: the message to parse
726 :type message: email.message.Message | string | unicode
727 :param bool save_original: whether the returned dict
728 should include an ``original`` attachment containing
729 the source of the message
731 :return: A dict with the following structure, where each
732 field may not be present if missing in original
735 { 'message_id': msg_id,
740 'body': unified_body,
741 'attachments': [('file1', 'bytes'),
749 if not isinstance(message, Message):
750 if isinstance(message, unicode):
751 # Warning: message_from_string doesn't always work correctly on unicode,
752 # we must use utf-8 strings here :-(
753 message = message.encode('utf-8')
754 message = email.message_from_string(message)
756 message_id = message['message-id']
758 # Very unusual situation, be we should be fault-tolerant here
759 message_id = "<%s@localhost>" % time.time()
760 _logger.debug('Parsing Message without message-id, generating a random one: %s', message_id)
761 msg_dict['message_id'] = message_id
763 if 'Subject' in message:
764 msg_dict['subject'] = decode(message.get('Subject'))
766 # Envelope fields not stored in mail.message but made available for message_new()
767 msg_dict['from'] = decode(message.get('from'))
768 msg_dict['to'] = decode(message.get('to'))
769 msg_dict['cc'] = decode(message.get('cc'))
771 if 'From' in message:
772 author_ids = self._message_find_partners(cr, uid, message, ['From'], context=context)
774 msg_dict['author_id'] = author_ids[0]
776 msg_dict['email_from'] = decode(message.get('from'))
777 partner_ids = self._message_find_partners(cr, uid, message, ['To', 'Cc'], context=context)
778 msg_dict['partner_ids'] = [(4, partner_id) for partner_id in partner_ids]
780 if 'Date' in message:
782 date_hdr = decode(message.get('Date'))
783 parsed_date = dateutil.parser.parse(date_hdr, fuzzy=True)
784 if parsed_date.utcoffset() is None:
785 # naive datetime, so we arbitrarily decide to make it
786 # UTC, there's no better choice. Should not happen,
787 # as RFC2822 requires timezone offset in Date headers.
788 stored_date = parsed_date.replace(tzinfo=pytz.utc)
790 stored_date = parsed_date.astimezone(pytz.utc)
792 _logger.warning('Failed to parse Date header %r in incoming mail '
793 'with message-id %r, assuming current date/time.',
794 message.get('Date'), message_id)
795 stored_date = datetime.datetime.now()
796 msg_dict['date'] = stored_date.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT)
798 if 'In-Reply-To' in message:
799 parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', '=', decode(message['In-Reply-To']))])
801 msg_dict['parent_id'] = parent_ids[0]
803 if 'References' in message and 'parent_id' not in msg_dict:
804 parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', 'in',
805 [x.strip() for x in decode(message['References']).split()])])
807 msg_dict['parent_id'] = parent_ids[0]
809 msg_dict['body'], msg_dict['attachments'] = self._message_extract_payload(message)
812 #------------------------------------------------------
814 #------------------------------------------------------
816 def log(self, cr, uid, id, message, secondary=False, context=None):
817 _logger.warning("log() is deprecated. As this module inherit from "\
818 "mail.thread, the message will be managed by this "\
819 "module instead of by the res.log mechanism. Please "\
820 "use mail_thread.message_post() instead of the "\
821 "now deprecated res.log.")
822 self.message_post(cr, uid, [id], message, context=context)
824 def message_create_partners_from_emails(self, cr, uid, emails, context=None):
825 """ Convert a list of emails into a list partner_ids and a list
826 new_partner_ids. The return value is non conventional because
827 it is meant to be used by the mail widget.
829 :return dict: partner_ids and new_partner_ids
831 partner_obj = self.pool.get('res.partner')
832 mail_message_obj = self.pool.get('mail.message')
837 m = re.search(r"((.+?)\s*<)?([^<>]+@[^<>]+)>?", email, re.IGNORECASE | re.DOTALL)
838 name = m.group(2) or m.group(0)
840 ids = partner_obj.search(cr, SUPERUSER_ID, [('email', '=', email)], context=context)
842 partner_ids.append(ids[0])
845 partner_id = partner_obj.create(cr, uid, {
846 'name': name or email,
849 new_partner_ids.append(partner_id)
851 # link mail with this from mail to the new partner id
852 message_ids = mail_message_obj.search(cr, SUPERUSER_ID, ['|', ('email_from', '=', email), ('email_from', 'ilike', '<%s>' % email), ('author_id', '=', False)], context=context)
854 mail_message_obj.write(cr, SUPERUSER_ID, message_ids, {'email_from': None, 'author_id': partner_id}, context=context)
856 'partner_ids': partner_ids,
857 'new_partner_ids': new_partner_ids,
860 def message_post(self, cr, uid, thread_id, body='', subject=None, type='notification',
861 subtype=None, parent_id=False, attachments=None, context=None, **kwargs):
862 """ Post a new message in an existing thread, returning the new
863 mail.message ID. Extra keyword arguments will be used as default
864 column values for the new mail.message record.
865 Auto link messages for same id and object
866 :param int thread_id: thread ID to post into, or list with one ID;
867 if False/0, mail.message model will also be set as False
868 :param str body: body of the message, usually raw HTML that will
870 :param str subject: optional subject
871 :param str type: mail_message.type
872 :param int parent_id: optional ID of parent message in this thread
873 :param tuple(str,str) attachments or list id: list of attachment tuples in the form
874 ``(name,content)``, where content is NOT base64 encoded
875 :return: ID of newly created mail.message
879 if attachments is None:
882 assert (not thread_id) or isinstance(thread_id, (int, long)) or \
883 (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"
884 if isinstance(thread_id, (list, tuple)):
885 thread_id = thread_id and thread_id[0]
886 mail_message = self.pool.get('mail.message')
888 # if we're processing a message directly coming from the gateway, the destination model was
889 # set in the context.
892 model = context.get('thread_model', self._name) if self._name == 'mail.thread' else self._name
894 attachment_ids = kwargs.pop('attachment_ids', [])
895 for name, content in attachments:
896 if isinstance(content, unicode):
897 content = content.encode('utf-8')
900 'datas': base64.b64encode(str(content)),
903 'res_model': context.get('thread_model') or self._name,
906 attachment_ids.append((0, 0, data_attach))
910 s_data = subtype.split('.')
912 s_data = ('mail', s_data[0])
913 ref = self.pool.get('ir.model.data').get_object_reference(cr, uid, s_data[0], s_data[1])
914 subtype_id = ref and ref[1] or False
918 # _mail_flat_thread: automatically set free messages to the first posted message
919 if self._mail_flat_thread and not parent_id and thread_id:
920 message_ids = mail_message.search(cr, uid, ['&', ('res_id', '=', thread_id), ('model', '=', model)], context=context, order="id ASC", limit=1)
921 parent_id = message_ids and message_ids[0] or False
922 # 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
924 message_ids = mail_message.search(cr, SUPERUSER_ID, [('id', '=', parent_id), ('parent_id', '!=', False)], context=context)
925 # avoid loops when finding ancestors
928 message = mail_message.browse(cr, SUPERUSER_ID, message_ids[0], context=context)
929 while (message.parent_id and message.parent_id.id not in processed_list):
930 processed_list.append(message.parent_id.id)
931 message = message.parent_id
932 parent_id = message.id
937 'res_id': thread_id or False,
939 'subject': subject or False,
941 'parent_id': parent_id,
942 'attachment_ids': attachment_ids,
943 'subtype_id': subtype_id,
946 # Avoid warnings about non-existing fields
947 for x in ('from', 'to', 'cc'):
950 return mail_message.create(cr, uid, values, context=context)
952 def message_post_user_api(self, cr, uid, thread_id, body='', parent_id=False,
953 attachment_ids=None, content_subtype='plaintext',
954 context=None, **kwargs):
955 """ Wrapper on message_post, used for user input :
957 - quick reply in Chatter (refer to mail.js), not
958 the mail.compose.message wizard
959 The purpose is to perform some pre- and post-processing:
960 - if body is plaintext: convert it into html
961 - if parent_id: handle reply to a previous message by adding the
962 parent partners to the message
963 - type and subtype: comment and mail.mt_comment by default
964 - attachment_ids: supposed not attached to any document; attach them
965 to the related document. Should only be set by Chatter.
967 mail_message_obj = self.pool.get('mail.message')
968 ir_attachment = self.pool.get('ir.attachment')
970 # 1.A.1: add recipients of parent message (# TDE FIXME HACK: mail.thread -> private message)
971 partner_ids = set([])
972 if parent_id and self._name == 'mail.thread':
973 parent_message = mail_message_obj.browse(cr, uid, parent_id, context=context)
974 partner_ids |= set([(4, partner.id) for partner in parent_message.partner_ids])
975 if parent_message.author_id.id:
976 partner_ids.add((4, parent_message.author_id.id))
978 # 1.A.2: add specified recipients
979 param_partner_ids = set()
980 for item in kwargs.pop('partner_ids', []):
981 if isinstance(item, (list)):
982 param_partner_ids.add((item[0], item[1]))
983 elif isinstance(item, (int, long)):
984 param_partner_ids.add((4, item))
986 param_partner_ids.add(item)
987 partner_ids |= param_partner_ids
989 # 1.A.3: add parameters recipients as follower
990 # TDE FIXME in 7.1: should check whether this comes from email_list or partner_ids
991 if param_partner_ids and self._name != 'mail.thread':
992 self.message_subscribe(cr, uid, [thread_id], [pid[1] for pid in param_partner_ids], context=context)
994 # 1.B: handle body, message_type and message_subtype
995 if content_subtype == 'plaintext':
996 body = tools.plaintext2html(body)
997 msg_type = kwargs.pop('type', 'comment')
998 msg_subtype = kwargs.pop('subtype', 'mail.mt_comment')
1000 # 2. Pre-processing: attachments
1001 # HACK TDE FIXME: Chatter: attachments linked to the document (not done JS-side), load the message
1003 # TDE FIXME (?): when posting a private message, we use mail.thread as a model
1004 # However, attaching doc to mail.thread is not possible, mail.thread does not have any table
1006 if model == 'mail.thread':
1008 filtered_attachment_ids = ir_attachment.search(cr, SUPERUSER_ID, [
1009 ('res_model', '=', 'mail.compose.message'),
1011 ('create_uid', '=', uid),
1012 ('id', 'in', attachment_ids)], context=context)
1013 if filtered_attachment_ids:
1014 if thread_id and model:
1015 ir_attachment.write(cr, SUPERUSER_ID, attachment_ids, {'res_model': model, 'res_id': thread_id}, context=context)
1018 attachment_ids = [(4, id) for id in attachment_ids]
1021 return self.message_post(cr, uid, thread_id=thread_id, body=body,
1022 type=msg_type, subtype=msg_subtype, parent_id=parent_id,
1023 attachment_ids=attachment_ids, partner_ids=list(partner_ids), context=context, **kwargs)
1025 #------------------------------------------------------
1027 #------------------------------------------------------
1029 def message_get_subscription_data(self, cr, uid, ids, context=None):
1030 """ Wrapper to get subtypes data. """
1031 return self._get_subscription_data(cr, uid, ids, None, None, context=context)
1033 def message_subscribe_users(self, cr, uid, ids, user_ids=None, subtype_ids=None, context=None):
1034 """ Wrapper on message_subscribe, using users. If user_ids is not
1035 provided, subscribe uid instead. """
1036 if user_ids is None:
1038 partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
1039 return self.message_subscribe(cr, uid, ids, partner_ids, subtype_ids=subtype_ids, context=context)
1041 def message_subscribe(self, cr, uid, ids, partner_ids, subtype_ids=None, context=None):
1042 """ Add partners to the records followers. """
1043 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
1044 if set(partner_ids) == set([user_pid]):
1045 self.check_access_rights(cr, uid, 'read')
1047 self.check_access_rights(cr, uid, 'write')
1049 self.write(cr, SUPERUSER_ID, ids, {'message_follower_ids': [(4, pid) for pid in partner_ids]}, context=context)
1050 # if subtypes are not specified (and not set to a void list), fetch default ones
1051 if subtype_ids is None:
1052 subtype_obj = self.pool.get('mail.message.subtype')
1053 subtype_ids = subtype_obj.search(cr, uid, [('default', '=', True), '|', ('res_model', '=', self._name), ('res_model', '=', False)], context=context)
1054 # update the subscriptions
1055 fol_obj = self.pool.get('mail.followers')
1056 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids), ('partner_id', 'in', partner_ids)], context=context)
1057 fol_obj.write(cr, SUPERUSER_ID, fol_ids, {'subtype_ids': [(6, 0, subtype_ids)]}, context=context)
1060 def message_unsubscribe_users(self, cr, uid, ids, user_ids=None, context=None):
1061 """ Wrapper on message_subscribe, using users. If user_ids is not
1062 provided, unsubscribe uid instead. """
1063 if user_ids is None:
1065 partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
1066 return self.message_unsubscribe(cr, uid, ids, partner_ids, context=context)
1068 def message_unsubscribe(self, cr, uid, ids, partner_ids, context=None):
1069 """ Remove partners from the records followers. """
1070 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
1071 if set(partner_ids) == set([user_pid]):
1072 self.check_access_rights(cr, uid, 'read')
1074 self.check_access_rights(cr, uid, 'write')
1075 return self.write(cr, SUPERUSER_ID, ids, {'message_follower_ids': [(3, pid) for pid in partner_ids]}, context=context)
1077 def _message_get_auto_subscribe_fields(self, cr, uid, updated_fields, auto_follow_fields=['user_id'], context=None):
1078 """ Returns the list of relational fields linking to res.users that should
1079 trigger an auto subscribe. The default list checks for the fields
1081 - linking to res.users
1082 - with track_visibility set
1083 In OpenERP V7, this is sufficent for all major addon such as opportunity,
1084 project, issue, recruitment, sale.
1085 Override this method if a custom behavior is needed about fields
1086 that automatically subscribe users.
1089 for name, column_info in self._all_columns.items():
1090 if name in auto_follow_fields and name in updated_fields and getattr(column_info.column, 'track_visibility', False) and column_info.column._obj == 'res.users':
1091 user_field_lst.append(name)
1092 return user_field_lst
1094 def message_auto_subscribe(self, cr, uid, ids, updated_fields, context=None):
1096 1. fetch project subtype related to task (parent_id.res_model = 'project.task')
1097 2. for each project subtype: subscribe the follower to the task
1099 subtype_obj = self.pool.get('mail.message.subtype')
1100 follower_obj = self.pool.get('mail.followers')
1102 # fetch auto_follow_fields
1103 user_field_lst = self._message_get_auto_subscribe_fields(cr, uid, updated_fields, context=context)
1105 # fetch related record subtypes
1106 related_subtype_ids = subtype_obj.search(cr, uid, ['|', ('res_model', '=', False), ('parent_id.res_model', '=', self._name)], context=context)
1107 subtypes = subtype_obj.browse(cr, uid, related_subtype_ids, context=context)
1108 default_subtypes = [subtype for subtype in subtypes if subtype.res_model == False]
1109 related_subtypes = [subtype for subtype in subtypes if subtype.res_model != False]
1110 relation_fields = set([subtype.relation_field for subtype in subtypes if subtype.relation_field != False])
1111 if (not related_subtypes or not any(relation in updated_fields for relation in relation_fields)) and not user_field_lst:
1114 for record in self.browse(cr, uid, ids, context=context):
1115 new_followers = dict()
1116 parent_res_id = False
1117 parent_model = False
1118 for subtype in related_subtypes:
1119 if not subtype.relation_field or not subtype.parent_id:
1121 if not subtype.relation_field in self._columns or not getattr(record, subtype.relation_field, False):
1123 parent_res_id = getattr(record, subtype.relation_field).id
1124 parent_model = subtype.res_model
1125 follower_ids = follower_obj.search(cr, SUPERUSER_ID, [
1126 ('res_model', '=', parent_model),
1127 ('res_id', '=', parent_res_id),
1128 ('subtype_ids', 'in', [subtype.id])
1130 for follower in follower_obj.browse(cr, SUPERUSER_ID, follower_ids, context=context):
1131 new_followers.setdefault(follower.partner_id.id, set()).add(subtype.parent_id.id)
1133 if parent_res_id and parent_model:
1134 for subtype in default_subtypes:
1135 follower_ids = follower_obj.search(cr, SUPERUSER_ID, [
1136 ('res_model', '=', parent_model),
1137 ('res_id', '=', parent_res_id),
1138 ('subtype_ids', 'in', [subtype.id])
1140 for follower in follower_obj.browse(cr, SUPERUSER_ID, follower_ids, context=context):
1141 new_followers.setdefault(follower.partner_id.id, set()).add(subtype.id)
1143 # add followers coming from res.users relational fields that are tracked
1144 user_ids = [getattr(record, name).id for name in user_field_lst if getattr(record, name)]
1145 for partner_id in [user.partner_id.id for user in self.pool.get('res.users').browse(cr, SUPERUSER_ID, user_ids, context=context)]:
1146 new_followers.setdefault(partner_id, None)
1148 for pid, subtypes in new_followers.items():
1149 subtypes = list(subtypes) if subtypes is not None else None
1150 self.message_subscribe(cr, uid, [record.id], [pid], subtypes, context=context)
1153 #------------------------------------------------------
1155 #------------------------------------------------------
1157 def message_mark_as_unread(self, cr, uid, ids, context=None):
1158 """ Set as unread. """
1159 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1161 UPDATE mail_notification SET
1164 message_id IN (SELECT id from mail_message where res_id=any(%s) and model=%s limit 1) and
1166 ''', (ids, self._name, partner_id))
1169 def message_mark_as_read(self, cr, uid, ids, context=None):
1170 """ Set as read. """
1171 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1173 UPDATE mail_notification SET
1176 message_id IN (SELECT id FROM mail_message WHERE res_id=ANY(%s) AND model=%s) AND
1178 ''', (ids, self._name, partner_id))
1181 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: