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 ##############################################################################
22 from collections import OrderedDict
28 import simplejson as json
31 from lxml import etree
36 from email.message import Message
38 from openerp import tools
39 from openerp import SUPERUSER_ID
40 from openerp.addons.mail.mail_message import decode
41 from openerp.osv import fields, osv, orm
42 from openerp.osv.orm import browse_record, browse_null
43 from openerp.tools.safe_eval import safe_eval as eval
44 from openerp.tools.translate import _
46 _logger = logging.getLogger(__name__)
49 def decode_header(message, header, separator=' '):
50 return separator.join(map(decode, filter(None, message.get_all(header, []))))
53 class mail_thread(osv.AbstractModel):
54 ''' mail_thread model is meant to be inherited by any model that needs to
55 act as a discussion topic on which messages can be attached. Public
56 methods are prefixed with ``message_`` in order to avoid name
57 collisions with methods of the models that will inherit from this class.
59 ``mail.thread`` defines fields used to handle and display the
60 communication history. ``mail.thread`` also manages followers of
61 inheriting classes. All features and expected behavior are managed
62 by mail.thread. Widgets has been designed for the 7.0 and following
65 Inheriting classes are not required to implement any method, as the
66 default implementation will work for any model. However it is common
67 to override at least the ``message_new`` and ``message_update``
68 methods (calling ``super``) to add model-specific behavior at
69 creation and update of a thread when processing incoming emails.
72 - _mail_flat_thread: if set to True, all messages without parent_id
73 are automatically attached to the first message posted on the
74 ressource. If set to False, the display of Chatter is done using
75 threads, and no parent_id is automatically set.
78 _description = 'Email Thread'
79 _mail_flat_thread = True
80 _mail_post_access = 'write'
82 # Automatic logging system if mail installed
85 # 'module.subtype_xml': lambda self, cr, uid, obj, context=None: obj[state] == done,
86 # 'module.subtype_xml2': lambda self, cr, uid, obj, context=None: obj[state] != done,
93 # :param string field: field name
94 # :param module.subtype_xml: xml_id of a mail.message.subtype (i.e. mail.mt_comment)
95 # :param obj: is a browse_record
96 # :param function lambda: returns whether the tracking should record using this subtype
99 def get_empty_list_help(self, cr, uid, help, context=None):
100 """ Override of BaseModel.get_empty_list_help() to generate an help message
101 that adds alias information. """
102 model = context.get('empty_list_help_model')
103 res_id = context.get('empty_list_help_id')
104 ir_config_parameter = self.pool.get("ir.config_parameter")
105 catchall_domain = ir_config_parameter.get_param(cr, uid, "mail.catchall.domain", context=context)
106 document_name = context.get('empty_list_help_document_name', _('document'))
109 if catchall_domain and model and res_id: # specific res_id -> find its alias (i.e. section_id specified)
110 object_id = self.pool.get(model).browse(cr, uid, res_id, context=context)
111 # check that the alias effectively creates new records
112 if object_id.alias_id and object_id.alias_id.alias_name and \
113 object_id.alias_id.alias_model_id and \
114 object_id.alias_id.alias_model_id.model == self._name and \
115 object_id.alias_id.alias_force_thread_id == 0:
116 alias = object_id.alias_id
117 if not alias and catchall_domain and model: # no res_id or res_id not linked to an alias -> generic help message, take a generic alias of the model
118 alias_obj = self.pool.get('mail.alias')
119 alias_ids = alias_obj.search(cr, uid, [("alias_parent_model_id.model", "=", model), ("alias_name", "!=", False), ('alias_force_thread_id', '=', False), ('alias_parent_thread_id', '=', False)], context=context, order='id ASC')
120 if alias_ids and len(alias_ids) == 1:
121 alias = alias_obj.browse(cr, uid, alias_ids[0], context=context)
124 alias_email = alias.name_get()[0][1]
125 return _("""<p class='oe_view_nocontent_create'>
126 Click here to add new %(document)s or send an email to: <a href='mailto:%(email)s'>%(email)s</a>
130 'document': document_name,
131 'email': alias_email,
132 'static_help': help or ''
135 if document_name != 'document' and help and help.find("oe_view_nocontent_create") == -1:
136 return _("<p class='oe_view_nocontent_create'>Click here to add new %(document)s</p>%(static_help)s") % {
137 'document': document_name,
138 'static_help': help or '',
143 def _get_message_data(self, cr, uid, ids, name, args, context=None):
145 - message_unread: has uid unread message for the document
146 - message_summary: html snippet summarizing the Chatter for kanban views """
147 res = dict((id, dict(message_unread=False, message_unread_count=0, message_summary=' ')) for id in ids)
148 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
150 # search for unread messages, directly in SQL to improve performances
151 cr.execute(""" SELECT m.res_id FROM mail_message m
152 RIGHT JOIN mail_notification n
153 ON (n.message_id = m.id AND n.partner_id = %s AND (n.read = False or n.read IS NULL))
154 WHERE m.model = %s AND m.res_id in %s""",
155 (user_pid, self._name, tuple(ids),))
156 for result in cr.fetchall():
157 res[result[0]]['message_unread'] = True
158 res[result[0]]['message_unread_count'] += 1
161 if res[id]['message_unread_count']:
162 title = res[id]['message_unread_count'] > 1 and _("You have %d unread messages") % res[id]['message_unread_count'] or _("You have one unread message")
163 res[id]['message_summary'] = "<span class='oe_kanban_mail_new' title='%s'><span class='oe_e'>9</span> %d %s</span>" % (title, res[id].pop('message_unread_count'), _("New"))
166 def read_followers_data(self, cr, uid, follower_ids, context=None):
168 technical_group = self.pool.get('ir.model.data').get_object(cr, uid, 'base', 'group_no_one', context=context)
169 for follower in self.pool.get('res.partner').browse(cr, uid, follower_ids, context=context):
170 is_editable = uid in map(lambda x: x.id, technical_group.users)
171 is_uid = uid in map(lambda x: x.id, follower.user_ids)
174 {'is_editable': is_editable, 'is_uid': is_uid},
179 def _get_subscription_data(self, cr, uid, ids, name, args, user_pid=None, context=None):
181 - message_subtype_data: data about document subtypes: which are
182 available, which are followed if any """
183 res = dict((id, dict(message_subtype_data='')) for id in ids)
185 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
187 # find current model subtypes, add them to a dictionary
188 subtype_obj = self.pool.get('mail.message.subtype')
189 subtype_ids = subtype_obj.search(
191 '&', ('hidden', '=', False), '|', ('res_model', '=', self._name), ('res_model', '=', False)
193 subtype_dict = OrderedDict(
195 'default': subtype.default,
197 'parent_model': subtype.parent_id and subtype.parent_id.res_model or self._name,
199 ) for subtype in subtype_obj.browse(cr, uid, subtype_ids, context=context))
201 res[id]['message_subtype_data'] = subtype_dict.copy()
203 # find the document followers, update the data
204 fol_obj = self.pool.get('mail.followers')
205 fol_ids = fol_obj.search(cr, uid, [
206 ('partner_id', '=', user_pid),
207 ('res_id', 'in', ids),
208 ('res_model', '=', self._name),
210 for fol in fol_obj.browse(cr, uid, fol_ids, context=context):
211 thread_subtype_dict = res[fol.res_id]['message_subtype_data']
212 for subtype in fol.subtype_ids:
213 thread_subtype_dict[subtype.name]['followed'] = True
214 res[fol.res_id]['message_subtype_data'] = thread_subtype_dict
218 def _search_message_unread(self, cr, uid, obj=None, name=None, domain=None, context=None):
219 return [('message_ids.to_read', '=', True)]
221 def _get_followers(self, cr, uid, ids, name, arg, context=None):
222 fol_obj = self.pool.get('mail.followers')
223 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids)])
224 res = dict((id, dict(message_follower_ids=[], message_is_follower=False)) for id in ids)
225 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
226 for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids):
227 res[fol.res_id]['message_follower_ids'].append(fol.partner_id.id)
228 if fol.partner_id.id == user_pid:
229 res[fol.res_id]['message_is_follower'] = True
232 def _set_followers(self, cr, uid, id, name, value, arg, context=None):
235 partner_obj = self.pool.get('res.partner')
236 fol_obj = self.pool.get('mail.followers')
238 # read the old set of followers, and determine the new set of followers
239 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', '=', id)])
240 old = set(fol.partner_id.id for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids))
243 for command in value or []:
244 if isinstance(command, (int, long)):
246 elif command[0] == 0:
247 new.add(partner_obj.create(cr, uid, command[2], context=context))
248 elif command[0] == 1:
249 partner_obj.write(cr, uid, [command[1]], command[2], context=context)
251 elif command[0] == 2:
252 partner_obj.unlink(cr, uid, [command[1]], context=context)
253 new.discard(command[1])
254 elif command[0] == 3:
255 new.discard(command[1])
256 elif command[0] == 4:
258 elif command[0] == 5:
260 elif command[0] == 6:
261 new = set(command[2])
263 # remove partners that are no longer followers
264 self.message_unsubscribe(cr, uid, [id], list(old-new), context=context)
266 self.message_subscribe(cr, uid, [id], list(new-old), context=context)
268 def _search_followers(self, cr, uid, obj, name, args, context):
269 """Search function for message_follower_ids
271 Do not use with operator 'not in'. Use instead message_is_followers
273 fol_obj = self.pool.get('mail.followers')
275 for field, operator, value in args:
277 # TOFIX make it work with not in
278 assert operator != "not in", "Do not search message_follower_ids with 'not in'"
279 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('partner_id', operator, value)])
280 res_ids = [fol.res_id for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids)]
281 res.append(('id', 'in', res_ids))
284 def _search_is_follower(self, cr, uid, obj, name, args, context):
285 """Search function for message_is_follower"""
287 for field, operator, value in args:
289 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
290 if (operator == '=' and value) or (operator == '!=' and not value): # is a follower
291 res_ids = self.search(cr, uid, [('message_follower_ids', 'in', [partner_id])], context=context)
292 else: # is not a follower or unknown domain
293 mail_ids = self.search(cr, uid, [('message_follower_ids', 'in', [partner_id])], context=context)
294 res_ids = self.search(cr, uid, [('id', 'not in', mail_ids)], context=context)
295 res.append(('id', 'in', res_ids))
299 'message_is_follower': fields.function(_get_followers, type='boolean',
300 fnct_search=_search_is_follower, string='Is a Follower', multi='_get_followers,'),
301 'message_follower_ids': fields.function(_get_followers, fnct_inv=_set_followers,
302 fnct_search=_search_followers, type='many2many', priority=-10,
303 obj='res.partner', string='Followers', multi='_get_followers'),
304 'message_ids': fields.one2many('mail.message', 'res_id',
305 domain=lambda self: [('model', '=', self._name)],
308 help="Messages and communication history"),
309 'message_unread': fields.function(_get_message_data,
310 fnct_search=_search_message_unread, multi="_get_message_data",
311 type='boolean', string='Unread Messages',
312 help="If checked new messages require your attention."),
313 'message_summary': fields.function(_get_message_data, method=True,
314 type='text', string='Summary', multi="_get_message_data",
315 help="Holds the Chatter summary (number of messages, ...). "\
316 "This summary is directly in html format in order to "\
317 "be inserted in kanban views."),
320 def _get_user_chatter_options(self, cr, uid, context=None):
322 'display_log_button': False
324 group_ids = self.pool.get('res.users').browse(cr, uid, uid, context=context).groups_id
325 group_user_id = self.pool.get("ir.model.data").get_object_reference(cr, uid, 'base', 'group_user')[1]
326 is_employee = group_user_id in [group.id for group in group_ids]
328 options['display_log_button'] = True
331 def fields_view_get(self, cr, uid, view_id=None, view_type='form', context=None, toolbar=False, submenu=False):
332 res = super(mail_thread, self).fields_view_get(cr, uid, view_id=view_id, view_type=view_type, context=context, toolbar=toolbar, submenu=submenu)
333 if view_type == 'form':
334 doc = etree.XML(res['arch'])
335 for node in doc.xpath("//field[@name='message_ids']"):
336 options = json.loads(node.get('options', '{}'))
337 options.update(self._get_user_chatter_options(cr, uid, context=context))
338 node.set('options', json.dumps(options))
339 res['arch'] = etree.tostring(doc)
342 #------------------------------------------------------
343 # CRUD overrides for automatic subscription and logging
344 #------------------------------------------------------
346 def create(self, cr, uid, values, context=None):
347 """ Chatter override :
349 - subscribe followers of parent
350 - log a creation message
355 # subscribe uid unless asked not to
356 if not context.get('mail_create_nosubscribe'):
357 pid = self.pool['res.users'].browse(cr, SUPERUSER_ID, uid).partner_id.id
358 message_follower_ids = values.get('message_follower_ids') or [] # webclient can send None or False
359 message_follower_ids.append([4, pid])
360 values['message_follower_ids'] = message_follower_ids
361 thread_id = super(mail_thread, self).create(cr, uid, values, context=context)
363 # automatic logging unless asked not to (mainly for various testing purpose)
364 if not context.get('mail_create_nolog'):
365 self.message_post(cr, uid, thread_id, body=_('%s created') % (self._description), context=context)
367 # auto_subscribe: take values and defaults into account
368 create_values = dict(values)
369 for key, val in context.iteritems():
370 if key.startswith('default_'):
371 create_values[key[8:]] = val
372 self.message_auto_subscribe(cr, uid, [thread_id], create_values.keys(), context=context, values=create_values)
375 track_ctx = dict(context)
376 if 'lang' not in track_ctx:
377 track_ctx['lang'] = self.pool.get('res.users').browse(cr, uid, uid, context=context).lang
378 if not context.get('mail_notrack'):
379 tracked_fields = self._get_tracked_fields(cr, uid, values.keys(), context=track_ctx)
381 initial_values = {thread_id: dict((item, False) for item in tracked_fields)}
382 self.message_track(cr, uid, [thread_id], tracked_fields, initial_values, context=track_ctx)
385 def write(self, cr, uid, ids, values, context=None):
388 if isinstance(ids, (int, long)):
390 # Track initial values of tracked fields
391 track_ctx = dict(context)
392 if 'lang' not in track_ctx:
393 track_ctx['lang'] = self.pool.get('res.users').browse(cr, uid, uid, context=context).lang
394 if not context.get('mail_notrack'):
395 tracked_fields = self._get_tracked_fields(cr, uid, values.keys(), context=track_ctx)
399 records = self.browse(cr, uid, ids, context=track_ctx)
400 initial_values = dict((this.id, dict((key, getattr(this, key)) for key in tracked_fields.keys())) for this in records)
402 # Perform write, update followers
403 result = super(mail_thread, self).write(cr, uid, ids, values, context=context)
404 self.message_auto_subscribe(cr, uid, ids, values.keys(), context=context, values=values)
406 if not context.get('mail_notrack'):
407 # Perform the tracking
408 tracked_fields = self._get_tracked_fields(cr, uid, values.keys(), context=context)
410 tracked_fields = None
412 self.message_track(cr, uid, ids, tracked_fields, initial_values, context=track_ctx)
415 def unlink(self, cr, uid, ids, context=None):
416 """ Override unlink to delete messages and followers. This cannot be
417 cascaded, because link is done through (res_model, res_id). """
418 msg_obj = self.pool.get('mail.message')
419 fol_obj = self.pool.get('mail.followers')
420 # delete messages and notifications
421 msg_ids = msg_obj.search(cr, uid, [('model', '=', self._name), ('res_id', 'in', ids)], context=context)
422 msg_obj.unlink(cr, uid, msg_ids, context=context)
424 res = super(mail_thread, self).unlink(cr, uid, ids, context=context)
426 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids)], context=context)
427 fol_obj.unlink(cr, SUPERUSER_ID, fol_ids, context=context)
430 def copy(self, cr, uid, id, default=None, context=None):
431 # avoid tracking multiple temporary changes during copy
432 context = dict(context or {}, mail_notrack=True)
434 default = default or {}
435 default['message_ids'] = []
436 default['message_follower_ids'] = []
437 return super(mail_thread, self).copy(cr, uid, id, default=default, context=context)
439 #------------------------------------------------------
440 # Automatically log tracked fields
441 #------------------------------------------------------
443 def _get_tracked_fields(self, cr, uid, updated_fields, context=None):
444 """ Return a structure of tracked fields for the current model.
445 :param list updated_fields: modified field names
446 :return list: a list of (field_name, column_info obj), containing
447 always tracked fields and modified on_change fields
450 for name, column_info in self._all_columns.items():
451 visibility = getattr(column_info.column, 'track_visibility', False)
452 if visibility == 'always' or (visibility == 'onchange' and name in updated_fields) or name in self._track:
456 return self.fields_get(cr, uid, lst, context=context)
458 def message_track(self, cr, uid, ids, tracked_fields, initial_values, context=None):
460 def convert_for_display(value, col_info):
461 if not value and col_info['type'] == 'boolean':
465 if col_info['type'] == 'many2one':
466 return value.name_get()[0][1]
467 if col_info['type'] == 'selection':
468 return dict(col_info['selection'])[value]
471 def format_message(message_description, tracked_values):
473 if message_description:
474 message = '<span>%s</span>' % message_description
475 for name, change in tracked_values.items():
476 message += '<div> • <b>%s</b>: ' % change.get('col_info')
477 if change.get('old_value'):
478 message += '%s → ' % change.get('old_value')
479 message += '%s</div>' % change.get('new_value')
482 if not tracked_fields:
485 for browse_record in self.browse(cr, uid, ids, context=context):
486 initial = initial_values[browse_record.id]
490 # generate tracked_values data structure: {'col_name': {col_info, new_value, old_value}}
491 for col_name, col_info in tracked_fields.items():
492 initial_value = initial[col_name]
493 record_value = getattr(browse_record, col_name)
495 if record_value == initial_value and getattr(self._all_columns[col_name].column, 'track_visibility', None) == 'always':
496 tracked_values[col_name] = dict(col_info=col_info['string'],
497 new_value=convert_for_display(record_value, col_info))
498 elif record_value != initial_value and (record_value or initial_value): # because browse null != False
499 if getattr(self._all_columns[col_name].column, 'track_visibility', None) in ['always', 'onchange']:
500 tracked_values[col_name] = dict(col_info=col_info['string'],
501 old_value=convert_for_display(initial_value, col_info),
502 new_value=convert_for_display(record_value, col_info))
503 if col_name in tracked_fields:
504 changes.add(col_name)
508 # find subtypes and post messages or log if no subtype found
510 for field, track_info in self._track.items():
511 if field not in changes:
513 for subtype, method in track_info.items():
514 if method(self, cr, uid, browse_record, context):
515 subtypes.append(subtype)
518 for subtype in subtypes:
519 subtype_rec = self.pool.get('ir.model.data').xmlid_to_object(cr, uid, subtype, context=context)
520 if not (subtype_rec and subtype_rec.exists()):
521 _logger.debug('subtype %s not found' % subtype)
523 message = format_message(subtype_rec.description if subtype_rec.description else subtype_rec.name, tracked_values)
524 self.message_post(cr, uid, browse_record.id, body=message, subtype=subtype, context=context)
527 message = format_message('', tracked_values)
528 self.message_post(cr, uid, browse_record.id, body=message, context=context)
531 #------------------------------------------------------
532 # mail.message wrappers and tools
533 #------------------------------------------------------
535 def _needaction_domain_get(self, cr, uid, context=None):
537 return [('message_unread', '=', True)]
540 def _garbage_collect_attachments(self, cr, uid, context=None):
541 """ Garbage collect lost mail attachments. Those are attachments
542 - linked to res_model 'mail.compose.message', the composer wizard
543 - with res_id 0, because they were created outside of an existing
544 wizard (typically user input through Chatter or reports
545 created on-the-fly by the templates)
546 - unused since at least one day (create_date and write_date)
548 limit_date = datetime.datetime.utcnow() - datetime.timedelta(days=1)
549 limit_date_str = datetime.datetime.strftime(limit_date, tools.DEFAULT_SERVER_DATETIME_FORMAT)
550 ir_attachment_obj = self.pool.get('ir.attachment')
551 attach_ids = ir_attachment_obj.search(cr, uid, [
552 ('res_model', '=', 'mail.compose.message'),
554 ('create_date', '<', limit_date_str),
555 ('write_date', '<', limit_date_str),
557 ir_attachment_obj.unlink(cr, uid, attach_ids, context=context)
560 def check_mail_message_access(self, cr, uid, mids, operation, model_obj=None, context=None):
561 """ mail.message check permission rules for related document. This method is
562 meant to be inherited in order to implement addons-specific behavior.
563 A common behavior would be to allow creating messages when having read
564 access rule on the document, for portal document such as issues. """
567 if hasattr(self, '_mail_post_access'):
568 create_allow = self._mail_post_access
570 create_allow = 'write'
572 if operation in ['write', 'unlink']:
573 check_operation = 'write'
574 elif operation == 'create' and create_allow in ['create', 'read', 'write', 'unlink']:
575 check_operation = create_allow
576 elif operation == 'create':
577 check_operation = 'write'
579 check_operation = operation
581 model_obj.check_access_rights(cr, uid, check_operation)
582 model_obj.check_access_rule(cr, uid, mids, check_operation, context=context)
584 def _get_formview_action(self, cr, uid, id, model=None, context=None):
585 """ Return an action to open the document. This method is meant to be
586 overridden in addons that want to give specific view ids for example.
588 :param int id: id of the document to open
589 :param string model: specific model that overrides self._name
592 'type': 'ir.actions.act_window',
593 'res_model': model or self._name,
596 'views': [(False, 'form')],
601 def _get_inbox_action_xml_id(self, cr, uid, context=None):
602 """ When redirecting towards the Inbox, choose which action xml_id has
603 to be fetched. This method is meant to be inherited, at least in portal
604 because portal users have a different Inbox action than classic users. """
605 return ('mail', 'action_mail_inbox_feeds')
607 def message_redirect_action(self, cr, uid, context=None):
608 """ For a given message, return an action that either
609 - opens the form view of the related document if model, res_id, and
610 read access to the document
611 - opens the Inbox with a default search on the conversation if model,
613 - opens the Inbox with context propagated
619 # default action is the Inbox action
620 self.pool.get('res.users').browse(cr, SUPERUSER_ID, uid, context=context)
621 act_model, act_id = self.pool.get('ir.model.data').get_object_reference(cr, uid, *self._get_inbox_action_xml_id(cr, uid, context=context))
622 action = self.pool.get(act_model).read(cr, uid, act_id, [])
623 params = context.get('params')
624 msg_id = model = res_id = None
627 msg_id = params.get('message_id')
628 model = params.get('model')
629 res_id = params.get('res_id')
630 if not msg_id and not (model and res_id):
632 if msg_id and not (model and res_id):
633 msg = self.pool.get('mail.message').browse(cr, uid, msg_id, context=context)
635 model, res_id = msg.model, msg.res_id
637 # if model + res_id found: try to redirect to the document or fallback on the Inbox
639 model_obj = self.pool.get(model)
640 if model_obj.check_access_rights(cr, uid, 'read', raise_exception=False):
642 model_obj.check_access_rule(cr, uid, [res_id], 'read', context=context)
643 if not hasattr(model_obj, '_get_formview_action'):
644 action = self.pool.get('mail.thread')._get_formview_action(cr, uid, res_id, model=model, context=context)
646 action = model_obj._get_formview_action(cr, uid, res_id, context=context)
647 except (osv.except_osv, orm.except_orm):
651 'search_default_model': model,
652 'search_default_res_id': res_id,
657 #------------------------------------------------------
659 #------------------------------------------------------
661 def message_get_reply_to(self, cr, uid, ids, context=None):
662 """ Returns the preferred reply-to email address that is basically
663 the alias of the document, if it exists. """
664 if not self._inherits.get('mail.alias'):
665 return [False for id in ids]
666 return ["%s@%s" % (record['alias_name'], record['alias_domain'])
667 if record.get('alias_domain') and record.get('alias_name')
669 for record in self.read(cr, SUPERUSER_ID, ids, ['alias_name', 'alias_domain'], context=context)]
671 #------------------------------------------------------
673 #------------------------------------------------------
675 def message_capable_models(self, cr, uid, context=None):
676 """ Used by the plugin addon, based for plugin_outlook and others. """
678 for model_name in self.pool.obj_list():
679 model = self.pool[model_name]
680 if hasattr(model, "message_process") and hasattr(model, "message_post"):
681 ret_dict[model_name] = model._description
684 def _message_find_partners(self, cr, uid, message, header_fields=['From'], context=None):
685 """ Find partners related to some header fields of the message.
687 :param string message: an email.message instance """
688 s = ', '.join([decode(message.get(h)) for h in header_fields if message.get(h)])
689 return filter(lambda x: x, self._find_partner_from_emails(cr, uid, None, tools.email_split(s), context=context))
691 def message_route_verify(self, cr, uid, message, message_dict, route, update_author=True, assert_model=True, create_fallback=True, context=None):
692 """ Verify route validity. Check and rules:
693 1 - if thread_id -> check that document effectively exists; otherwise
694 fallback on a message_new by resetting thread_id
695 2 - check that message_update exists if thread_id is set; or at least
696 that message_new exist
697 [ - find author_id if udpate_author is set]
698 3 - if there is an alias, check alias_contact:
699 'followers' and thread_id:
700 check on target document that the author is in the followers
701 'followers' and alias_parent_thread_id:
702 check on alias parent document that the author is in the
704 'partners': check that author_id id set
707 assert isinstance(route, (list, tuple)), 'A route should be a list or a tuple'
708 assert len(route) == 5, 'A route should contain 5 elements: model, thread_id, custom_values, uid, alias record'
710 message_id = message.get('Message-Id')
711 email_from = decode_header(message, 'From')
712 author_id = message_dict.get('author_id')
713 model, thread_id, alias = route[0], route[1], route[4]
716 def _create_bounce_email():
717 mail_mail = self.pool.get('mail.mail')
718 mail_id = mail_mail.create(cr, uid, {
719 'body_html': '<div><p>Hello,</p>'
720 '<p>The following email sent to %s cannot be accepted because this is '
721 'a private email address. Only allowed people can contact us at this address.</p></div>'
722 '<blockquote>%s</blockquote>' % (message.get('to'), message_dict.get('body')),
723 'subject': 'Re: %s' % message.get('subject'),
724 'email_to': message.get('from'),
727 mail_mail.send(cr, uid, [mail_id], context=context)
730 _logger.warning('Routing mail with Message-Id %s: route %s: %s',
731 message_id, route, message)
734 if model and not model in self.pool:
736 assert model in self.pool, 'Routing: unknown target model %s' % model
737 _warn('unknown target model %s' % model)
740 model_pool = self.pool[model]
742 # Private message: should not contain any thread_id
743 if not model and thread_id:
746 raise ValueError('Routing: posting a message without model should be with a null res_id (private message), received %s.' % thread_id)
747 _warn('posting a message without model should be with a null res_id (private message), received %s resetting thread_id' % thread_id)
749 # Private message: should have a parent_id (only answers)
750 if not model and not message_dict.get('parent_id'):
752 if not message_dict.get('parent_id'):
753 raise ValueError('Routing: posting a message without model should be with a parent_id (private mesage).')
754 _warn('posting a message without model should be with a parent_id (private mesage), skipping')
757 # Existing Document: check if exists; if not, fallback on create if allowed
758 if thread_id and not model_pool.exists(cr, uid, thread_id):
760 _warn('reply to missing document (%s,%s), fall back on new document creation' % (model, thread_id))
763 assert model_pool.exists(cr, uid, thread_id), 'Routing: reply to missing document (%s,%s)' % (model, thread_id)
765 _warn('reply to missing document (%s,%s), skipping' % (model, thread_id))
768 # Existing Document: check model accepts the mailgateway
769 if thread_id and model and not hasattr(model_pool, 'message_update'):
771 _warn('model %s does not accept document update, fall back on document creation' % model)
774 assert hasattr(model_pool, 'message_update'), 'Routing: model %s does not accept document update, crashing' % model
776 _warn('model %s does not accept document update, skipping' % model)
779 # New Document: check model accepts the mailgateway
780 if not thread_id and model and not hasattr(model_pool, 'message_new'):
782 if not hasattr(model_pool, 'message_new'):
784 'Model %s does not accept document creation, crashing' % model
786 _warn('model %s does not accept document creation, skipping' % model)
789 # Update message author if asked
790 # We do it now because we need it for aliases (contact settings)
791 if not author_id and update_author:
792 author_ids = self._find_partner_from_emails(cr, uid, thread_id, [email_from], model=model, context=context)
794 author_id = author_ids[0]
795 message_dict['author_id'] = author_id
797 # Alias: check alias_contact settings
798 if alias and alias.alias_contact == 'followers' and (thread_id or alias.alias_parent_thread_id):
800 obj = self.pool[model].browse(cr, uid, thread_id, context=context)
802 obj = self.pool[alias.alias_parent_model_id.model].browse(cr, uid, alias.alias_parent_thread_id, context=context)
803 if not author_id or not author_id in [fol.id for fol in obj.message_follower_ids]:
804 _warn('alias %s restricted to internal followers, skipping' % alias.alias_name)
805 _create_bounce_email()
807 elif alias and alias.alias_contact == 'partners' and not author_id:
808 _warn('alias %s does not accept unknown author, skipping' % alias.alias_name)
809 _create_bounce_email()
812 return (model, thread_id, route[2], route[3], route[4])
814 def message_route(self, cr, uid, message, message_dict, model=None, thread_id=None,
815 custom_values=None, context=None):
816 """Attempt to figure out the correct target model, thread_id,
817 custom_values and user_id to use for an incoming message.
818 Multiple values may be returned, if a message had multiple
819 recipients matching existing mail.aliases, for example.
821 The following heuristics are used, in this order:
822 1. If the message replies to an existing thread_id, and
823 properly contains the thread model in the 'In-Reply-To'
824 header, use this model/thread_id pair, and ignore
825 custom_value (not needed as no creation will take place)
826 2. Look for a mail.alias entry matching the message
827 recipient, and use the corresponding model, thread_id,
828 custom_values and user_id.
829 3. Fallback to the ``model``, ``thread_id`` and ``custom_values``
831 4. If all the above fails, raise an exception.
833 :param string message: an email.message instance
834 :param dict message_dict: dictionary holding message variables
835 :param string model: the fallback model to use if the message
836 does not match any of the currently configured mail aliases
837 (may be None if a matching alias is supposed to be present)
838 :type dict custom_values: optional dictionary of default field values
839 to pass to ``message_new`` if a new record needs to be created.
840 Ignored if the thread record already exists, and also if a
841 matching mail.alias was found (aliases define their own defaults)
842 :param int thread_id: optional ID of the record/thread from ``model``
843 to which this mail should be attached. Only used if the message
844 does not reply to an existing thread and does not match any mail alias.
845 :return: list of [model, thread_id, custom_values, user_id, alias]
847 :raises: ValueError, TypeError
849 if not isinstance(message, Message):
850 raise TypeError('message must be an email.message.Message at this point')
851 mail_msg_obj = self.pool['mail.message']
852 fallback_model = model
854 # Get email.message.Message variables for future processing
855 message_id = message.get('Message-Id')
856 email_from = decode_header(message, 'From')
857 email_to = decode_header(message, 'To')
858 references = decode_header(message, 'References')
859 in_reply_to = decode_header(message, 'In-Reply-To')
860 thread_references = references or in_reply_to
862 # 1. message is a reply to an existing message (exact match of message_id)
863 msg_references = thread_references.split()
864 mail_message_ids = mail_msg_obj.search(cr, uid, [('message_id', 'in', msg_references)], context=context)
866 original_msg = mail_msg_obj.browse(cr, SUPERUSER_ID, mail_message_ids[0], context=context)
867 model, thread_id = original_msg.model, original_msg.res_id
869 'Routing mail from %s to %s with Message-Id %s: direct reply to msg: model: %s, thread_id: %s, custom_values: %s, uid: %s',
870 email_from, email_to, message_id, model, thread_id, custom_values, uid)
871 route = self.message_route_verify(
872 cr, uid, message, message_dict,
873 (model, thread_id, custom_values, uid, None),
874 update_author=True, assert_model=True, create_fallback=True, context=context)
875 return route and [route] or []
877 # 2. message is a reply to an existign thread (6.1 compatibility)
878 ref_match = thread_references and tools.reference_re.search(thread_references)
880 thread_id = int(ref_match.group(1))
881 model = ref_match.group(2) or fallback_model
882 if thread_id and model in self.pool:
883 model_obj = self.pool[model]
884 compat_mail_msg_ids = mail_msg_obj.search(
886 ('message_id', '=', False),
887 ('model', '=', model),
888 ('res_id', '=', thread_id),
890 if compat_mail_msg_ids and model_obj.exists(cr, uid, thread_id) and hasattr(model_obj, 'message_update'):
892 'Routing mail from %s to %s with Message-Id %s: direct thread reply (compat-mode) to model: %s, thread_id: %s, custom_values: %s, uid: %s',
893 email_from, email_to, message_id, model, thread_id, custom_values, uid)
894 route = self.message_route_verify(
895 cr, uid, message, message_dict,
896 (model, thread_id, custom_values, uid, None),
897 update_author=True, assert_model=True, create_fallback=True, context=context)
898 return route and [route] or []
900 # 2. Reply to a private message
902 mail_message_ids = mail_msg_obj.search(cr, uid, [
903 ('message_id', '=', in_reply_to),
904 '!', ('message_id', 'ilike', 'reply_to')
905 ], limit=1, context=context)
907 mail_message = mail_msg_obj.browse(cr, uid, mail_message_ids[0], context=context)
908 _logger.info('Routing mail from %s to %s with Message-Id %s: direct reply to a private message: %s, custom_values: %s, uid: %s',
909 email_from, email_to, message_id, mail_message.id, custom_values, uid)
910 route = self.message_route_verify(cr, uid, message, message_dict,
911 (mail_message.model, mail_message.res_id, custom_values, uid, None),
912 update_author=True, assert_model=True, create_fallback=True, context=context)
913 return route and [route] or []
915 # 3. Look for a matching mail.alias entry
916 # Delivered-To is a safe bet in most modern MTAs, but we have to fallback on To + Cc values
917 # for all the odd MTAs out there, as there is no standard header for the envelope's `rcpt_to` value.
919 ','.join([decode_header(message, 'Delivered-To'),
920 decode_header(message, 'To'),
921 decode_header(message, 'Cc'),
922 decode_header(message, 'Resent-To'),
923 decode_header(message, 'Resent-Cc')])
924 local_parts = [e.split('@')[0] for e in tools.email_split(rcpt_tos)]
926 mail_alias = self.pool.get('mail.alias')
927 alias_ids = mail_alias.search(cr, uid, [('alias_name', 'in', local_parts)])
930 for alias in mail_alias.browse(cr, uid, alias_ids, context=context):
931 user_id = alias.alias_user_id.id
933 # TDE note: this could cause crashes, because no clue that the user
934 # that send the email has the right to create or modify a new document
935 # Fallback on user_id = uid
936 # Note: recognized partners will be added as followers anyway
937 # user_id = self._message_find_user_id(cr, uid, message, context=context)
939 _logger.info('No matching user_id for the alias %s', alias.alias_name)
940 route = (alias.alias_model_id.model, alias.alias_force_thread_id, eval(alias.alias_defaults), user_id, alias)
941 _logger.info('Routing mail from %s to %s with Message-Id %s: direct alias match: %r',
942 email_from, email_to, message_id, route)
943 route = self.message_route_verify(cr, uid, message, message_dict, route,
944 update_author=True, assert_model=True, create_fallback=True, context=context)
949 # 4. Fallback to the provided parameters, if they work
951 # Legacy: fallback to matching [ID] in the Subject
952 match = tools.res_re.search(decode_header(message, 'Subject'))
953 thread_id = match and match.group(1)
954 # Convert into int (bug spotted in 7.0 because of str)
956 thread_id = int(thread_id)
959 _logger.info('Routing mail from %s to %s with Message-Id %s: fallback to model:%s, thread_id:%s, custom_values:%s, uid:%s',
960 email_from, email_to, message_id, fallback_model, thread_id, custom_values, uid)
961 route = self.message_route_verify(cr, uid, message, message_dict,
962 (fallback_model, thread_id, custom_values, uid, None),
963 update_author=True, assert_model=True, context=context)
967 # AssertionError if no routes found and if no bounce occured
969 'No possible route found for incoming message from %s to %s (Message-Id %s:). '
970 'Create an appropriate mail.alias or force the destination model.' %
971 (email_from, email_to, message_id)
974 def message_route_process(self, cr, uid, message, message_dict, routes, context=None):
975 # postpone setting message_dict.partner_ids after message_post, to avoid double notifications
976 partner_ids = message_dict.pop('partner_ids', [])
978 for model, thread_id, custom_values, user_id, alias in routes:
979 if self._name == 'mail.thread':
980 context.update({'thread_model': model})
982 model_pool = self.pool[model]
983 if not (thread_id and hasattr(model_pool, 'message_update') or hasattr(model_pool, 'message_new')):
985 "Undeliverable mail with Message-Id %s, model %s does not accept incoming emails" %
986 (message_dict['message_id'], model)
989 # disabled subscriptions during message_new/update to avoid having the system user running the
990 # email gateway become a follower of all inbound messages
991 nosub_ctx = dict(context, mail_create_nosubscribe=True, mail_create_nolog=True)
992 if thread_id and hasattr(model_pool, 'message_update'):
993 model_pool.message_update(cr, user_id, [thread_id], message_dict, context=nosub_ctx)
995 thread_id = model_pool.message_new(cr, user_id, message_dict, custom_values, context=nosub_ctx)
998 raise ValueError("Posting a message without model should be with a null res_id, to create a private message.")
999 model_pool = self.pool.get('mail.thread')
1000 if not hasattr(model_pool, 'message_post'):
1001 context['thread_model'] = model
1002 model_pool = self.pool['mail.thread']
1003 new_msg_id = model_pool.message_post(cr, uid, [thread_id], context=context, subtype='mail.mt_comment', **message_dict)
1006 # postponed after message_post, because this is an external message and we don't want to create
1007 # duplicate emails due to notifications
1008 self.pool.get('mail.message').write(cr, uid, [new_msg_id], {'partner_ids': partner_ids}, context=context)
1011 def message_process(self, cr, uid, model, message, custom_values=None,
1012 save_original=False, strip_attachments=False,
1013 thread_id=None, context=None):
1014 """ Process an incoming RFC2822 email message, relying on
1015 ``mail.message.parse()`` for the parsing operation,
1016 and ``message_route()`` to figure out the target model.
1018 Once the target model is known, its ``message_new`` method
1019 is called with the new message (if the thread record did not exist)
1020 or its ``message_update`` method (if it did).
1022 There is a special case where the target model is False: a reply
1023 to a private message. In this case, we skip the message_new /
1024 message_update step, to just post a new message using mail_thread
1027 :param string model: the fallback model to use if the message
1028 does not match any of the currently configured mail aliases
1029 (may be None if a matching alias is supposed to be present)
1030 :param message: source of the RFC2822 message
1031 :type message: string or xmlrpclib.Binary
1032 :type dict custom_values: optional dictionary of field values
1033 to pass to ``message_new`` if a new record needs to be created.
1034 Ignored if the thread record already exists, and also if a
1035 matching mail.alias was found (aliases define their own defaults)
1036 :param bool save_original: whether to keep a copy of the original
1037 email source attached to the message after it is imported.
1038 :param bool strip_attachments: whether to strip all attachments
1039 before processing the message, in order to save some space.
1040 :param int thread_id: optional ID of the record/thread from ``model``
1041 to which this mail should be attached. When provided, this
1042 overrides the automatic detection based on the message
1048 # extract message bytes - we are forced to pass the message as binary because
1049 # we don't know its encoding until we parse its headers and hence can't
1050 # convert it to utf-8 for transport between the mailgate script and here.
1051 if isinstance(message, xmlrpclib.Binary):
1052 message = str(message.data)
1053 # Warning: message_from_string doesn't always work correctly on unicode,
1054 # we must use utf-8 strings here :-(
1055 if isinstance(message, unicode):
1056 message = message.encode('utf-8')
1057 msg_txt = email.message_from_string(message)
1059 # parse the message, verify we are not in a loop by checking message_id is not duplicated
1060 msg = self.message_parse(cr, uid, msg_txt, save_original=save_original, context=context)
1061 if strip_attachments:
1062 msg.pop('attachments', None)
1064 if msg.get('message_id'): # should always be True as message_parse generate one if missing
1065 existing_msg_ids = self.pool.get('mail.message').search(cr, SUPERUSER_ID, [
1066 ('message_id', '=', msg.get('message_id')),
1068 if existing_msg_ids:
1069 _logger.info('Ignored mail from %s to %s with Message-Id %s: found duplicated Message-Id during processing',
1070 msg.get('from'), msg.get('to'), msg.get('message_id'))
1073 # find possible routes for the message
1074 routes = self.message_route(cr, uid, msg_txt, msg, model, thread_id, custom_values, context=context)
1075 thread_id = self.message_route_process(cr, uid, msg_txt, msg, routes, context=context)
1078 def message_new(self, cr, uid, msg_dict, custom_values=None, context=None):
1079 """Called by ``message_process`` when a new message is received
1080 for a given thread model, if the message did not belong to
1082 The default behavior is to create a new record of the corresponding
1083 model (based on some very basic info extracted from the message).
1084 Additional behavior may be implemented by overriding this method.
1086 :param dict msg_dict: a map containing the email details and
1087 attachments. See ``message_process`` and
1088 ``mail.message.parse`` for details.
1089 :param dict custom_values: optional dictionary of additional
1090 field values to pass to create()
1091 when creating the new thread record.
1092 Be careful, these values may override
1093 any other values coming from the message.
1094 :param dict context: if a ``thread_model`` value is present
1095 in the context, its value will be used
1096 to determine the model of the record
1097 to create (instead of the current model).
1099 :return: the id of the newly created thread object
1104 if isinstance(custom_values, dict):
1105 data = custom_values.copy()
1106 model = context.get('thread_model') or self._name
1107 model_pool = self.pool[model]
1108 fields = model_pool.fields_get(cr, uid, context=context)
1109 if 'name' in fields and not data.get('name'):
1110 data['name'] = msg_dict.get('subject', '')
1111 res_id = model_pool.create(cr, uid, data, context=context)
1114 def message_update(self, cr, uid, ids, msg_dict, update_vals=None, context=None):
1115 """Called by ``message_process`` when a new message is received
1116 for an existing thread. The default behavior is to update the record
1117 with update_vals taken from the incoming email.
1118 Additional behavior may be implemented by overriding this
1120 :param dict msg_dict: a map containing the email details and
1121 attachments. See ``message_process`` and
1122 ``mail.message.parse()`` for details.
1123 :param dict update_vals: a dict containing values to update records
1124 given their ids; if the dict is None or is
1125 void, no write operation is performed.
1128 self.write(cr, uid, ids, update_vals, context=context)
1131 def _message_extract_payload(self, message, save_original=False):
1132 """Extract body as HTML and attachments from the mail message"""
1136 attachments.append(('original_email.eml', message.as_string()))
1137 if not message.is_multipart() or 'text/' in message.get('content-type', ''):
1138 encoding = message.get_content_charset()
1139 body = message.get_payload(decode=True)
1140 body = tools.ustr(body, encoding, errors='replace')
1141 if message.get_content_type() == 'text/plain':
1142 # text/plain -> <pre/>
1143 body = tools.append_content_to_html(u'', body, preserve=True)
1146 for part in message.walk():
1147 if part.get_content_type() == 'multipart/alternative':
1149 if part.get_content_maintype() == 'multipart':
1150 continue # skip container
1151 # part.get_filename returns decoded value if able to decode, coded otherwise.
1152 # original get_filename is not able to decode iso-8859-1 (for instance).
1153 # therefore, iso encoded attachements are not able to be decoded properly with get_filename
1154 # code here partially copy the original get_filename method, but handle more encoding
1155 filename=part.get_param('filename', None, 'content-disposition')
1157 filename=part.get_param('name', None)
1159 if isinstance(filename, tuple):
1161 filename=email.utils.collapse_rfc2231_value(filename).strip()
1163 filename=decode(filename)
1164 encoding = part.get_content_charset() # None if attachment
1165 # 1) Explicit Attachments -> attachments
1166 if filename or part.get('content-disposition', '').strip().startswith('attachment'):
1167 attachments.append((filename or 'attachment', part.get_payload(decode=True)))
1169 # 2) text/plain -> <pre/>
1170 if part.get_content_type() == 'text/plain' and (not alternative or not body):
1171 body = tools.append_content_to_html(body, tools.ustr(part.get_payload(decode=True),
1172 encoding, errors='replace'), preserve=True)
1173 # 3) text/html -> raw
1174 elif part.get_content_type() == 'text/html':
1175 html = tools.ustr(part.get_payload(decode=True), encoding, errors='replace')
1179 body = tools.append_content_to_html(body, html, plaintext=False)
1180 # 4) Anything else -> attachment
1182 attachments.append((filename or 'attachment', part.get_payload(decode=True)))
1183 return body, attachments
1185 def message_parse(self, cr, uid, message, save_original=False, context=None):
1186 """Parses a string or email.message.Message representing an
1187 RFC-2822 email, and returns a generic dict holding the
1190 :param message: the message to parse
1191 :type message: email.message.Message | string | unicode
1192 :param bool save_original: whether the returned dict
1193 should include an ``original`` attachment containing
1194 the source of the message
1196 :return: A dict with the following structure, where each
1197 field may not be present if missing in original
1200 { 'message_id': msg_id,
1205 'body': unified_body,
1206 'attachments': [('file1', 'bytes'),
1213 if not isinstance(message, Message):
1214 if isinstance(message, unicode):
1215 # Warning: message_from_string doesn't always work correctly on unicode,
1216 # we must use utf-8 strings here :-(
1217 message = message.encode('utf-8')
1218 message = email.message_from_string(message)
1220 message_id = message['message-id']
1222 # Very unusual situation, be we should be fault-tolerant here
1223 message_id = "<%s@localhost>" % time.time()
1224 _logger.debug('Parsing Message without message-id, generating a random one: %s', message_id)
1225 msg_dict['message_id'] = message_id
1227 if message.get('Subject'):
1228 msg_dict['subject'] = decode(message.get('Subject'))
1230 # Envelope fields not stored in mail.message but made available for message_new()
1231 msg_dict['from'] = decode(message.get('from'))
1232 msg_dict['to'] = decode(message.get('to'))
1233 msg_dict['cc'] = decode(message.get('cc'))
1234 msg_dict['email_from'] = decode(message.get('from'))
1235 partner_ids = self._message_find_partners(cr, uid, message, ['To', 'Cc'], context=context)
1236 msg_dict['partner_ids'] = [(4, partner_id) for partner_id in partner_ids]
1238 if message.get('Date'):
1240 date_hdr = decode(message.get('Date'))
1241 parsed_date = dateutil.parser.parse(date_hdr, fuzzy=True)
1242 if parsed_date.utcoffset() is None:
1243 # naive datetime, so we arbitrarily decide to make it
1244 # UTC, there's no better choice. Should not happen,
1245 # as RFC2822 requires timezone offset in Date headers.
1246 stored_date = parsed_date.replace(tzinfo=pytz.utc)
1248 stored_date = parsed_date.astimezone(tz=pytz.utc)
1250 _logger.warning('Failed to parse Date header %r in incoming mail '
1251 'with message-id %r, assuming current date/time.',
1252 message.get('Date'), message_id)
1253 stored_date = datetime.datetime.now()
1254 msg_dict['date'] = stored_date.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT)
1256 if message.get('In-Reply-To'):
1257 parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', '=', decode(message['In-Reply-To']))])
1259 msg_dict['parent_id'] = parent_ids[0]
1261 if message.get('References') and 'parent_id' not in msg_dict:
1262 parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', 'in',
1263 [x.strip() for x in decode(message['References']).split()])])
1265 msg_dict['parent_id'] = parent_ids[0]
1267 msg_dict['body'], msg_dict['attachments'] = self._message_extract_payload(message, save_original=save_original)
1270 #------------------------------------------------------
1272 #------------------------------------------------------
1274 def log(self, cr, uid, id, message, secondary=False, context=None):
1275 _logger.warning("log() is deprecated. As this module inherit from "\
1276 "mail.thread, the message will be managed by this "\
1277 "module instead of by the res.log mechanism. Please "\
1278 "use mail_thread.message_post() instead of the "\
1279 "now deprecated res.log.")
1280 self.message_post(cr, uid, [id], message, context=context)
1282 def _message_add_suggested_recipient(self, cr, uid, result, obj, partner=None, email=None, reason='', context=None):
1283 """ Called by message_get_suggested_recipients, to add a suggested
1284 recipient in the result dictionary. The form is :
1285 partner_id, partner_name<partner_email> or partner_name, reason """
1286 if email and not partner:
1287 # get partner info from email
1288 partner_info = self.message_partner_info_from_emails(cr, uid, obj.id, [email], context=context)[0]
1289 if partner_info.get('partner_id'):
1290 partner = self.pool.get('res.partner').browse(cr, SUPERUSER_ID, [partner_info['partner_id']], context=context)[0]
1291 if email and email in [val[1] for val in result[obj.id]]: # already existing email -> skip
1293 if partner and partner in obj.message_follower_ids: # recipient already in the followers -> skip
1295 if partner and partner in [val[0] for val in result[obj.id]]: # already existing partner ID -> skip
1297 if partner and partner.email: # complete profile: id, name <email>
1298 result[obj.id].append((partner.id, '%s<%s>' % (partner.name, partner.email), reason))
1299 elif partner: # incomplete profile: id, name
1300 result[obj.id].append((partner.id, '%s' % (partner.name), reason))
1301 else: # unknown partner, we are probably managing an email address
1302 result[obj.id].append((False, email, reason))
1305 def message_get_suggested_recipients(self, cr, uid, ids, context=None):
1306 """ Returns suggested recipients for ids. Those are a list of
1307 tuple (partner_id, partner_name, reason), to be managed by Chatter. """
1308 result = dict.fromkeys(ids, list())
1309 if self._all_columns.get('user_id'):
1310 for obj in self.browse(cr, SUPERUSER_ID, ids, context=context): # SUPERUSER because of a read on res.users that would crash otherwise
1311 if not obj.user_id or not obj.user_id.partner_id:
1313 self._message_add_suggested_recipient(cr, uid, result, obj, partner=obj.user_id.partner_id, reason=self._all_columns['user_id'].column.string, context=context)
1316 def _find_partner_from_emails(self, cr, uid, id, emails, model=None, context=None, check_followers=True):
1317 """ Utility method to find partners from email addresses. The rules are :
1318 1 - check in document (model | self, id) followers
1319 2 - try to find a matching partner that is also an user
1320 3 - try to find a matching partner
1322 :param list emails: list of email addresses
1323 :param string model: model to fetch related record; by default self
1325 :param boolean check_followers: check in document followers
1327 partner_obj = self.pool['res.partner']
1330 if id and (model or self._name != 'mail.thread') and check_followers:
1332 obj = self.pool[model].browse(cr, uid, id, context=context)
1334 obj = self.browse(cr, uid, id, context=context)
1335 for contact in emails:
1337 email_address = tools.email_split(contact)
1338 if not email_address:
1339 partner_ids.append(partner_id)
1341 email_address = email_address[0]
1342 # first try: check in document's followers
1344 for follower in obj.message_follower_ids:
1345 if follower.email == email_address:
1346 partner_id = follower.id
1347 # second try: check in partners that are also users
1349 ids = partner_obj.search(cr, SUPERUSER_ID, [
1350 ('email', 'ilike', email_address),
1351 ('user_ids', '!=', False)
1352 ], limit=1, context=context)
1355 # third try: check in partners
1357 ids = partner_obj.search(cr, SUPERUSER_ID, [
1358 ('email', 'ilike', email_address)
1359 ], limit=1, context=context)
1362 partner_ids.append(partner_id)
1365 def message_partner_info_from_emails(self, cr, uid, id, emails, link_mail=False, context=None):
1366 """ Convert a list of emails into a list partner_ids and a list
1367 new_partner_ids. The return value is non conventional because
1368 it is meant to be used by the mail widget.
1370 :return dict: partner_ids and new_partner_ids """
1371 mail_message_obj = self.pool.get('mail.message')
1372 partner_ids = self._find_partner_from_emails(cr, uid, id, emails, context=context)
1374 for idx in range(len(emails)):
1375 email_address = emails[idx]
1376 partner_id = partner_ids[idx]
1377 partner_info = {'full_name': email_address, 'partner_id': partner_id}
1378 result.append(partner_info)
1380 # link mail with this from mail to the new partner id
1381 if link_mail and partner_info['partner_id']:
1382 message_ids = mail_message_obj.search(cr, SUPERUSER_ID, [
1384 ('email_from', '=', email_address),
1385 ('email_from', 'ilike', '<%s>' % email_address),
1386 ('author_id', '=', False)
1389 mail_message_obj.write(cr, SUPERUSER_ID, message_ids, {'author_id': partner_info['partner_id']}, context=context)
1392 def _message_preprocess_attachments(self, cr, uid, attachments, attachment_ids, attach_model, attach_res_id, context=None):
1393 """ Preprocess attachments for mail_thread.message_post() or mail_mail.create().
1395 :param list attachments: list of attachment tuples in the form ``(name,content)``,
1396 where content is NOT base64 encoded
1397 :param list attachment_ids: a list of attachment ids, not in tomany command form
1398 :param str attach_model: the model of the attachments parent record
1399 :param integer attach_res_id: the id of the attachments parent record
1401 Attachment = self.pool['ir.attachment']
1402 m2m_attachment_ids = []
1404 filtered_attachment_ids = Attachment.search(cr, SUPERUSER_ID, [
1405 ('res_model', '=', 'mail.compose.message'),
1406 ('create_uid', '=', uid),
1407 ('id', 'in', attachment_ids)], context=context)
1408 if filtered_attachment_ids:
1409 Attachment.write(cr, SUPERUSER_ID, filtered_attachment_ids, {'res_model': attach_model, 'res_id': attach_res_id}, context=context)
1410 m2m_attachment_ids += [(4, id) for id in attachment_ids]
1411 # Handle attachments parameter, that is a dictionary of attachments
1412 for name, content in attachments:
1413 if isinstance(content, unicode):
1414 content = content.encode('utf-8')
1417 'datas': base64.b64encode(str(content)),
1418 'datas_fname': name,
1419 'description': name,
1420 'res_model': attach_model,
1421 'res_id': attach_res_id,
1423 m2m_attachment_ids.append((0, 0, data_attach))
1424 return m2m_attachment_ids
1426 def message_post(self, cr, uid, thread_id, body='', subject=None, type='notification',
1427 subtype=None, parent_id=False, attachments=None, context=None,
1428 content_subtype='html', **kwargs):
1429 """ Post a new message in an existing thread, returning the new
1432 :param int thread_id: thread ID to post into, or list with one ID;
1433 if False/0, mail.message model will also be set as False
1434 :param str body: body of the message, usually raw HTML that will
1436 :param str type: see mail_message.type field
1437 :param str content_subtype:: if plaintext: convert body into html
1438 :param int parent_id: handle reply to a previous message by adding the
1439 parent partners to the message in case of private discussion
1440 :param tuple(str,str) attachments or list id: list of attachment tuples in the form
1441 ``(name,content)``, where content is NOT base64 encoded
1443 Extra keyword arguments will be used as default column values for the
1444 new mail.message record. Special cases:
1445 - attachment_ids: supposed not attached to any document; attach them
1446 to the related document. Should only be set by Chatter.
1447 :return int: ID of newly created mail.message
1451 if attachments is None:
1453 mail_message = self.pool.get('mail.message')
1454 ir_attachment = self.pool.get('ir.attachment')
1456 assert (not thread_id) or \
1457 isinstance(thread_id, (int, long)) or \
1458 (isinstance(thread_id, (list, tuple)) and len(thread_id) == 1), \
1459 "Invalid thread_id; should be 0, False, an ID or a list with one ID"
1460 if isinstance(thread_id, (list, tuple)):
1461 thread_id = thread_id[0]
1463 # if we're processing a message directly coming from the gateway, the destination model was
1464 # set in the context.
1467 model = context.get('thread_model', self._name) if self._name == 'mail.thread' else self._name
1468 if model != self._name and hasattr(self.pool[model], 'message_post'):
1469 del context['thread_model']
1470 return self.pool[model].message_post(cr, uid, thread_id, body=body, subject=subject, type=type, subtype=subtype, parent_id=parent_id, attachments=attachments, context=context, content_subtype=content_subtype, **kwargs)
1472 #0: Find the message's author, because we need it for private discussion
1473 author_id = kwargs.get('author_id')
1474 if author_id is None: # keep False values
1475 author_id = self.pool.get('mail.message')._get_default_author(cr, uid, context=context)
1477 # 1: Handle content subtype: if plaintext, converto into HTML
1478 if content_subtype == 'plaintext':
1479 body = tools.plaintext2html(body)
1481 # 2: Private message: add recipients (recipients and author of parent message) - current author
1482 # + legacy-code management (! we manage only 4 and 6 commands)
1484 kwargs_partner_ids = kwargs.pop('partner_ids', [])
1485 for partner_id in kwargs_partner_ids:
1486 if isinstance(partner_id, (list, tuple)) and partner_id[0] == 4 and len(partner_id) == 2:
1487 partner_ids.add(partner_id[1])
1488 if isinstance(partner_id, (list, tuple)) and partner_id[0] == 6 and len(partner_id) == 3:
1489 partner_ids |= set(partner_id[2])
1490 elif isinstance(partner_id, (int, long)):
1491 partner_ids.add(partner_id)
1493 pass # we do not manage anything else
1494 if parent_id and not model:
1495 parent_message = mail_message.browse(cr, uid, parent_id, context=context)
1496 private_followers = set([partner.id for partner in parent_message.partner_ids])
1497 if parent_message.author_id:
1498 private_followers.add(parent_message.author_id.id)
1499 private_followers -= set([author_id])
1500 partner_ids |= private_followers
1503 # - HACK TDE FIXME: Chatter: attachments linked to the document (not done JS-side), load the message
1504 attachment_ids = self._message_preprocess_attachments(cr, uid, attachments, kwargs.pop('attachment_ids', []), model, thread_id, context)
1506 # 4: mail.message.subtype
1509 if '.' not in subtype:
1510 subtype = 'mail.%s' % subtype
1511 ref = self.pool.get('ir.model.data').get_object_reference(cr, uid, *subtype.split('.'))
1512 subtype_id = ref and ref[1] or False
1514 # automatically subscribe recipients if asked to
1515 if context.get('mail_post_autofollow') and thread_id and partner_ids:
1516 partner_to_subscribe = partner_ids
1517 if context.get('mail_post_autofollow_partner_ids'):
1518 partner_to_subscribe = filter(lambda item: item in context.get('mail_post_autofollow_partner_ids'), partner_ids)
1519 self.message_subscribe(cr, uid, [thread_id], list(partner_to_subscribe), context=context)
1521 # _mail_flat_thread: automatically set free messages to the first posted message
1522 if self._mail_flat_thread and not parent_id and thread_id:
1523 message_ids = mail_message.search(cr, uid, ['&', ('res_id', '=', thread_id), ('model', '=', model)], context=context, order="id ASC", limit=1)
1524 parent_id = message_ids and message_ids[0] or False
1525 # 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
1527 message_ids = mail_message.search(cr, SUPERUSER_ID, [('id', '=', parent_id), ('parent_id', '!=', False)], context=context)
1528 # avoid loops when finding ancestors
1531 message = mail_message.browse(cr, SUPERUSER_ID, message_ids[0], context=context)
1532 while (message.parent_id and message.parent_id.id not in processed_list):
1533 processed_list.append(message.parent_id.id)
1534 message = message.parent_id
1535 parent_id = message.id
1539 'author_id': author_id,
1541 'res_id': thread_id or False,
1543 'subject': subject or False,
1545 'parent_id': parent_id,
1546 'attachment_ids': attachment_ids,
1547 'subtype_id': subtype_id,
1548 'partner_ids': [(4, pid) for pid in partner_ids],
1551 # Avoid warnings about non-existing fields
1552 for x in ('from', 'to', 'cc'):
1555 # Create and auto subscribe the author
1556 msg_id = mail_message.create(cr, uid, values, context=context)
1557 message = mail_message.browse(cr, uid, msg_id, context=context)
1558 if message.author_id and thread_id and type != 'notification' and not context.get('mail_create_nosubscribe'):
1559 self.message_subscribe(cr, uid, [thread_id], [message.author_id.id], context=context)
1562 #------------------------------------------------------
1564 #------------------------------------------------------
1566 def message_get_subscription_data(self, cr, uid, ids, user_pid=None, context=None):
1567 """ Wrapper to get subtypes data. """
1568 return self._get_subscription_data(cr, uid, ids, None, None, user_pid=user_pid, context=context)
1570 def message_subscribe_users(self, cr, uid, ids, user_ids=None, subtype_ids=None, context=None):
1571 """ Wrapper on message_subscribe, using users. If user_ids is not
1572 provided, subscribe uid instead. """
1573 if user_ids is None:
1575 partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
1576 return self.message_subscribe(cr, uid, ids, partner_ids, subtype_ids=subtype_ids, context=context)
1578 def message_subscribe(self, cr, uid, ids, partner_ids, subtype_ids=None, context=None):
1579 """ Add partners to the records followers. """
1582 # not necessary for computation, but saves an access right check
1586 mail_followers_obj = self.pool.get('mail.followers')
1587 subtype_obj = self.pool.get('mail.message.subtype')
1589 user_pid = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1590 if set(partner_ids) == set([user_pid]):
1592 self.check_access_rights(cr, uid, 'read')
1593 self.check_access_rule(cr, uid, ids, 'read')
1594 except (osv.except_osv, orm.except_orm):
1597 self.check_access_rights(cr, uid, 'write')
1598 self.check_access_rule(cr, uid, ids, 'write')
1600 existing_pids_dict = {}
1601 fol_ids = mail_followers_obj.search(cr, SUPERUSER_ID, ['&', '&', ('res_model', '=', self._name), ('res_id', 'in', ids), ('partner_id', 'in', partner_ids)])
1602 for fol in mail_followers_obj.browse(cr, SUPERUSER_ID, fol_ids, context=context):
1603 existing_pids_dict.setdefault(fol.res_id, set()).add(fol.partner_id.id)
1605 # subtype_ids specified: update already subscribed partners
1606 if subtype_ids and fol_ids:
1607 mail_followers_obj.write(cr, SUPERUSER_ID, fol_ids, {'subtype_ids': [(6, 0, subtype_ids)]}, context=context)
1608 # subtype_ids not specified: do not update already subscribed partner, fetch default subtypes for new partners
1609 if subtype_ids is None:
1610 subtype_ids = subtype_obj.search(
1612 ('default', '=', True), '|', ('res_model', '=', self._name), ('res_model', '=', False)], context=context)
1615 existing_pids = existing_pids_dict.get(id, set())
1616 new_pids = set(partner_ids) - existing_pids
1618 # subscribe new followers
1619 for new_pid in new_pids:
1620 mail_followers_obj.create(
1622 'res_model': self._name,
1624 'partner_id': new_pid,
1625 'subtype_ids': [(6, 0, subtype_ids)],
1630 def message_unsubscribe_users(self, cr, uid, ids, user_ids=None, context=None):
1631 """ Wrapper on message_subscribe, using users. If user_ids is not
1632 provided, unsubscribe uid instead. """
1633 if user_ids is None:
1635 partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
1636 return self.message_unsubscribe(cr, uid, ids, partner_ids, context=context)
1638 def message_unsubscribe(self, cr, uid, ids, partner_ids, context=None):
1639 """ Remove partners from the records followers. """
1640 # not necessary for computation, but saves an access right check
1643 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
1644 if set(partner_ids) == set([user_pid]):
1645 self.check_access_rights(cr, uid, 'read')
1646 self.check_access_rule(cr, uid, ids, 'read')
1648 self.check_access_rights(cr, uid, 'write')
1649 self.check_access_rule(cr, uid, ids, 'write')
1650 fol_obj = self.pool['mail.followers']
1651 fol_ids = fol_obj.search(
1653 ('res_model', '=', self._name),
1654 ('res_id', 'in', ids),
1655 ('partner_id', 'in', partner_ids)
1657 return fol_obj.unlink(cr, SUPERUSER_ID, fol_ids, context=context)
1659 def _message_get_auto_subscribe_fields(self, cr, uid, updated_fields, auto_follow_fields=['user_id'], context=None):
1660 """ Returns the list of relational fields linking to res.users that should
1661 trigger an auto subscribe. The default list checks for the fields
1663 - linking to res.users
1664 - with track_visibility set
1665 In OpenERP V7, this is sufficent for all major addon such as opportunity,
1666 project, issue, recruitment, sale.
1667 Override this method if a custom behavior is needed about fields
1668 that automatically subscribe users.
1671 for name, column_info in self._all_columns.items():
1672 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':
1673 user_field_lst.append(name)
1674 return user_field_lst
1676 def message_auto_subscribe(self, cr, uid, ids, updated_fields, context=None, values=None):
1677 """ Handle auto subscription. Two methods for auto subscription exist:
1679 - tracked res.users relational fields, such as user_id fields. Those fields
1680 must be relation fields toward a res.users record, and must have the
1681 track_visilibity attribute set.
1682 - using subtypes parent relationship: check if the current model being
1683 modified has an header record (such as a project for tasks) whose followers
1684 can be added as followers of the current records. Example of structure
1685 with project and task:
1687 - st_project_1.parent_id = st_task_1
1688 - st_project_1.res_model = 'project.project'
1689 - st_project_1.relation_field = 'project_id'
1690 - st_task_1.model = 'project.task'
1692 :param list updated_fields: list of updated fields to track
1693 :param dict values: updated values; if None, the first record will be browsed
1694 to get the values. Added after releasing 7.0, therefore
1695 not merged with updated_fields argumment.
1697 subtype_obj = self.pool.get('mail.message.subtype')
1698 follower_obj = self.pool.get('mail.followers')
1699 new_followers = dict()
1701 # fetch auto_follow_fields: res.users relation fields whose changes are tracked for subscription
1702 user_field_lst = self._message_get_auto_subscribe_fields(cr, uid, updated_fields, context=context)
1704 # fetch header subtypes
1705 header_subtype_ids = subtype_obj.search(cr, uid, ['|', ('res_model', '=', False), ('parent_id.res_model', '=', self._name)], context=context)
1706 subtypes = subtype_obj.browse(cr, uid, header_subtype_ids, context=context)
1708 # if no change in tracked field or no change in tracked relational field: quit
1709 relation_fields = set([subtype.relation_field for subtype in subtypes if subtype.relation_field is not False])
1710 if not any(relation in updated_fields for relation in relation_fields) and not user_field_lst:
1713 # legacy behavior: if values is not given, compute the values by browsing
1714 # @TDENOTE: remove me in 8.0
1716 record = self.browse(cr, uid, ids[0], context=context)
1717 for updated_field in updated_fields:
1718 field_value = getattr(record, updated_field)
1719 if isinstance(field_value, browse_record):
1720 field_value = field_value.id
1721 elif isinstance(field_value, browse_null):
1723 values[updated_field] = field_value
1725 # find followers of headers, update structure for new followers
1727 for subtype in subtypes:
1728 if subtype.relation_field and values.get(subtype.relation_field):
1729 headers.add((subtype.res_model, values.get(subtype.relation_field)))
1731 header_domain = ['|'] * (len(headers) - 1)
1732 for header in headers:
1733 header_domain += ['&', ('res_model', '=', header[0]), ('res_id', '=', header[1])]
1734 header_follower_ids = follower_obj.search(
1739 for header_follower in follower_obj.browse(cr, SUPERUSER_ID, header_follower_ids, context=context):
1740 for subtype in header_follower.subtype_ids:
1741 if subtype.parent_id and subtype.parent_id.res_model == self._name:
1742 new_followers.setdefault(header_follower.partner_id.id, set()).add(subtype.parent_id.id)
1743 elif subtype.res_model is False:
1744 new_followers.setdefault(header_follower.partner_id.id, set()).add(subtype.id)
1746 # add followers coming from res.users relational fields that are tracked
1747 user_ids = [values[name] for name in user_field_lst if values.get(name)]
1748 user_pids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, SUPERUSER_ID, user_ids, context=context)]
1749 for partner_id in user_pids:
1750 new_followers.setdefault(partner_id, None)
1752 for pid, subtypes in new_followers.items():
1753 subtypes = list(subtypes) if subtypes is not None else None
1754 self.message_subscribe(cr, uid, ids, [pid], subtypes, context=context)
1756 # find first email message, set it as unread for auto_subscribe fields for them to have a notification
1758 for record_id in ids:
1759 message_obj = self.pool.get('mail.message')
1760 msg_ids = message_obj.search(cr, SUPERUSER_ID, [
1761 ('model', '=', self._name),
1762 ('res_id', '=', record_id),
1763 ('type', '=', 'email')], limit=1, context=context)
1765 msg_ids = message_obj.search(cr, SUPERUSER_ID, [
1766 ('model', '=', self._name),
1767 ('res_id', '=', record_id)], limit=1, context=context)
1769 self.pool.get('mail.notification')._notify(cr, uid, msg_ids[0], partners_to_notify=user_pids, context=context)
1773 #------------------------------------------------------
1775 #------------------------------------------------------
1777 def message_mark_as_unread(self, cr, uid, ids, context=None):
1778 """ Set as unread. """
1779 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1781 UPDATE mail_notification SET
1784 message_id IN (SELECT id from mail_message where res_id=any(%s) and model=%s limit 1) and
1786 ''', (ids, self._name, partner_id))
1789 def message_mark_as_read(self, cr, uid, ids, context=None):
1790 """ Set as read. """
1791 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1793 UPDATE mail_notification SET
1796 message_id IN (SELECT id FROM mail_message WHERE res_id=ANY(%s) AND model=%s) AND
1798 ''', (ids, self._name, partner_id))
1801 #------------------------------------------------------
1803 #------------------------------------------------------
1805 def get_suggested_thread(self, cr, uid, removed_suggested_threads=None, context=None):
1806 """Return a list of suggested threads, sorted by the numbers of followers"""
1810 # TDE HACK: originally by MAT from portal/mail_mail.py but not working until the inheritance graph bug is not solved in trunk
1811 # TDE FIXME: relocate in portal when it won't be necessary to reload the hr.employee model in an additional bridge module
1812 if self.pool['res.groups']._all_columns.get('is_portal'):
1813 user = self.pool.get('res.users').browse(cr, SUPERUSER_ID, uid, context=context)
1814 if any(group.is_portal for group in user.groups_id):
1818 if removed_suggested_threads is None:
1819 removed_suggested_threads = []
1821 thread_ids = self.search(cr, uid, [('id', 'not in', removed_suggested_threads), ('message_is_follower', '=', False)], context=context)
1822 for thread in self.browse(cr, uid, thread_ids, context=context):
1825 'popularity': len(thread.message_follower_ids),
1826 'name': thread.name,
1827 'image_small': thread.image_small
1829 threads.append(data)
1830 return sorted(threads, key=lambda x: (x['popularity'], x['id']), reverse=True)[:3]