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 ##############################################################################
27 import simplejson as json
30 from lxml import etree
35 from email.message import Message
37 from openerp import tools
38 from openerp import SUPERUSER_ID
39 from openerp.addons.mail.mail_message import decode
40 from openerp.osv import fields, osv, orm
41 from openerp.osv.orm import browse_record, browse_null
42 from openerp.tools.safe_eval import safe_eval as eval
43 from openerp.tools.translate import _
45 _logger = logging.getLogger(__name__)
48 def decode_header(message, header, separator=' '):
49 return separator.join(map(decode, filter(None, message.get_all(header, []))))
52 class mail_thread(osv.AbstractModel):
53 ''' mail_thread model is meant to be inherited by any model that needs to
54 act as a discussion topic on which messages can be attached. Public
55 methods are prefixed with ``message_`` in order to avoid name
56 collisions with methods of the models that will inherit from this class.
58 ``mail.thread`` defines fields used to handle and display the
59 communication history. ``mail.thread`` also manages followers of
60 inheriting classes. All features and expected behavior are managed
61 by mail.thread. Widgets has been designed for the 7.0 and following
64 Inheriting classes are not required to implement any method, as the
65 default implementation will work for any model. However it is common
66 to override at least the ``message_new`` and ``message_update``
67 methods (calling ``super``) to add model-specific behavior at
68 creation and update of a thread when processing incoming emails.
71 - _mail_flat_thread: if set to True, all messages without parent_id
72 are automatically attached to the first message posted on the
73 ressource. If set to False, the display of Chatter is done using
74 threads, and no parent_id is automatically set.
77 _description = 'Email Thread'
78 _mail_flat_thread = True
79 _mail_post_access = 'write'
81 # Automatic logging system if mail installed
84 # 'module.subtype_xml': lambda self, cr, uid, obj, context=None: obj[state] == done,
85 # 'module.subtype_xml2': lambda self, cr, uid, obj, context=None: obj[state] != done,
92 # :param string field: field name
93 # :param module.subtype_xml: xml_id of a mail.message.subtype (i.e. mail.mt_comment)
94 # :param obj: is a browse_record
95 # :param function lambda: returns whether the tracking should record using this subtype
98 def get_empty_list_help(self, cr, uid, help, context=None):
99 """ Override of BaseModel.get_empty_list_help() to generate an help message
100 that adds alias information. """
101 model = context.get('empty_list_help_model')
102 res_id = context.get('empty_list_help_id')
103 ir_config_parameter = self.pool.get("ir.config_parameter")
104 catchall_domain = ir_config_parameter.get_param(cr, uid, "mail.catchall.domain", context=context)
105 document_name = context.get('empty_list_help_document_name', _('document'))
108 if catchall_domain and model and res_id: # specific res_id -> find its alias (i.e. section_id specified)
109 object_id = self.pool.get(model).browse(cr, uid, res_id, context=context)
110 # check that the alias effectively creates new records
111 if object_id.alias_id and object_id.alias_id.alias_name and \
112 object_id.alias_id.alias_model_id and \
113 object_id.alias_id.alias_model_id.model == self._name and \
114 object_id.alias_id.alias_force_thread_id == 0:
115 alias = object_id.alias_id
116 if catchall_domain and model and not alias: #check for example alias if res_id not given or given res_id dose not contain alias-> generic help message, take an example alias (i.e. alias of some section_id)
117 model_id = self.pool.get('ir.model').search(cr, uid, [("model", "=", self._name)], context=context)[0]
118 alias_obj = self.pool.get('mail.alias')
119 alias_ids = alias_obj.search(cr, uid, [("alias_model_id", "=", model_id), ("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: # if several aliases -> incoherent to propose one guessed from nowhere, therefore avoid if several aliases
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')
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(cr, uid, ['|', ('res_model', '=', self._name), ('res_model', '=', False)], context=context)
190 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))
192 res[id]['message_subtype_data'] = subtype_dict.copy()
194 # find the document followers, update the data
195 fol_obj = self.pool.get('mail.followers')
196 fol_ids = fol_obj.search(cr, uid, [
197 ('partner_id', '=', user_pid),
198 ('res_id', 'in', ids),
199 ('res_model', '=', self._name),
201 for fol in fol_obj.browse(cr, uid, fol_ids, context=context):
202 thread_subtype_dict = res[fol.res_id]['message_subtype_data']
203 for subtype in fol.subtype_ids:
204 thread_subtype_dict[subtype.name]['followed'] = True
205 res[fol.res_id]['message_subtype_data'] = thread_subtype_dict
209 def _search_message_unread(self, cr, uid, obj=None, name=None, domain=None, context=None):
210 return [('message_ids.to_read', '=', True)]
212 def _get_followers(self, cr, uid, ids, name, arg, context=None):
213 fol_obj = self.pool.get('mail.followers')
214 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids)])
215 res = dict((id, dict(message_follower_ids=[], message_is_follower=False)) for id in ids)
216 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
217 for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids):
218 res[fol.res_id]['message_follower_ids'].append(fol.partner_id.id)
219 if fol.partner_id.id == user_pid:
220 res[fol.res_id]['message_is_follower'] = True
223 def _set_followers(self, cr, uid, id, name, value, arg, context=None):
226 partner_obj = self.pool.get('res.partner')
227 fol_obj = self.pool.get('mail.followers')
229 # read the old set of followers, and determine the new set of followers
230 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', '=', id)])
231 old = set(fol.partner_id.id for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids))
234 for command in value or []:
235 if isinstance(command, (int, long)):
237 elif command[0] == 0:
238 new.add(partner_obj.create(cr, uid, command[2], context=context))
239 elif command[0] == 1:
240 partner_obj.write(cr, uid, [command[1]], command[2], context=context)
242 elif command[0] == 2:
243 partner_obj.unlink(cr, uid, [command[1]], context=context)
244 new.discard(command[1])
245 elif command[0] == 3:
246 new.discard(command[1])
247 elif command[0] == 4:
249 elif command[0] == 5:
251 elif command[0] == 6:
252 new = set(command[2])
254 # remove partners that are no longer followers
255 self.message_unsubscribe(cr, uid, [id], list(old-new))
258 self.message_subscribe(cr, uid, [id], list(new-old))
260 def _search_followers(self, cr, uid, obj, name, args, context):
261 """Search function for message_follower_ids
263 Do not use with operator 'not in'. Use instead message_is_followers
265 fol_obj = self.pool.get('mail.followers')
267 for field, operator, value in args:
269 # TOFIX make it work with not in
270 assert operator != "not in", "Do not search message_follower_ids with 'not in'"
271 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('partner_id', operator, value)])
272 res_ids = [fol.res_id for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids)]
273 res.append(('id', 'in', res_ids))
276 def _search_is_follower(self, cr, uid, obj, name, args, context):
277 """Search function for message_is_follower"""
279 for field, operator, value in args:
281 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
282 if (operator == '=' and value) or (operator == '!=' and not value): # is a follower
283 res_ids = self.search(cr, uid, [('message_follower_ids', 'in', [partner_id])], context=context)
284 else: # is not a follower or unknown domain
285 mail_ids = self.search(cr, uid, [('message_follower_ids', 'in', [partner_id])], context=context)
286 res_ids = self.search(cr, uid, [('id', 'not in', mail_ids)], context=context)
287 res.append(('id', 'in', res_ids))
291 'message_is_follower': fields.function(_get_followers, type='boolean',
292 fnct_search=_search_is_follower, string='Is a Follower', multi='_get_followers,'),
293 'message_follower_ids': fields.function(_get_followers, fnct_inv=_set_followers,
294 fnct_search=_search_followers, type='many2many',
295 obj='res.partner', string='Followers', multi='_get_followers'),
296 'message_ids': fields.one2many('mail.message', 'res_id',
297 domain=lambda self: [('model', '=', self._name)],
300 help="Messages and communication history"),
301 'message_unread': fields.function(_get_message_data,
302 fnct_search=_search_message_unread, multi="_get_message_data",
303 type='boolean', string='Unread Messages',
304 help="If checked new messages require your attention."),
305 'message_summary': fields.function(_get_message_data, method=True,
306 type='text', string='Summary', multi="_get_message_data",
307 help="Holds the Chatter summary (number of messages, ...). "\
308 "This summary is directly in html format in order to "\
309 "be inserted in kanban views."),
312 def _get_user_chatter_options(self, cr, uid, context=None):
314 'display_log_button': False
316 group_ids = self.pool.get('res.users').browse(cr, uid, uid, context=context).groups_id
317 group_user_id = self.pool.get("ir.model.data").get_object_reference(cr, uid, 'base', 'group_user')[1]
318 is_employee = group_user_id in [group.id for group in group_ids]
320 options['display_log_button'] = True
323 def fields_view_get(self, cr, uid, view_id=None, view_type='form', context=None, toolbar=False, submenu=False):
324 res = super(mail_thread, self).fields_view_get(cr, uid, view_id=view_id, view_type=view_type, context=context, toolbar=toolbar, submenu=submenu)
325 if view_type == 'form':
326 doc = etree.XML(res['arch'])
327 for node in doc.xpath("//field[@name='message_ids']"):
328 options = json.loads(node.get('options', '{}'))
329 options.update(self._get_user_chatter_options(cr, uid, context=context))
330 node.set('options', json.dumps(options))
331 res['arch'] = etree.tostring(doc)
334 #------------------------------------------------------
335 # CRUD overrides for automatic subscription and logging
336 #------------------------------------------------------
338 def create(self, cr, uid, values, context=None):
339 """ Chatter override :
341 - subscribe followers of parent
342 - log a creation message
347 thread_id = super(mail_thread, self).create(cr, uid, values, context=context)
349 # automatic logging unless asked not to (mainly for various testing purpose)
350 if not context.get('mail_create_nolog'):
351 self.message_post(cr, uid, thread_id, body=_('%s created') % (self._description), context=context)
353 # subscribe uid unless asked not to
354 if not context.get('mail_create_nosubscribe'):
355 self.message_subscribe_users(cr, uid, [thread_id], [uid], context=context)
357 # auto_subscribe: take values and defaults into account
358 create_values = dict(values)
359 for key, val in context.iteritems():
360 if key.startswith('default_'):
361 create_values[key[8:]] = val
362 self.message_auto_subscribe(cr, uid, [thread_id], create_values.keys(), context=context, values=create_values)
365 track_ctx = dict(context)
366 if 'lang' not in track_ctx:
367 track_ctx['lang'] = self.pool.get('res.users').browse(cr, uid, uid, context=context).lang
368 tracked_fields = self._get_tracked_fields(cr, uid, values.keys(), context=track_ctx)
370 initial_values = {thread_id: dict((item, False) for item in tracked_fields)}
371 self.message_track(cr, uid, [thread_id], tracked_fields, initial_values, context=track_ctx)
374 def write(self, cr, uid, ids, values, context=None):
377 if isinstance(ids, (int, long)):
379 # Track initial values of tracked fields
380 track_ctx = dict(context)
381 if 'lang' not in track_ctx:
382 track_ctx['lang'] = self.pool.get('res.users').browse(cr, uid, uid, context=context).lang
383 tracked_fields = self._get_tracked_fields(cr, uid, values.keys(), context=track_ctx)
385 records = self.browse(cr, uid, ids, context=track_ctx)
386 initial_values = dict((this.id, dict((key, getattr(this, key)) for key in tracked_fields.keys())) for this in records)
388 # Perform write, update followers
389 result = super(mail_thread, self).write(cr, uid, ids, values, context=context)
390 self.message_auto_subscribe(cr, uid, ids, values.keys(), context=context, values=values)
392 # Perform the tracking
394 self.message_track(cr, uid, ids, tracked_fields, initial_values, context=track_ctx)
397 def unlink(self, cr, uid, ids, context=None):
398 """ Override unlink to delete messages and followers. This cannot be
399 cascaded, because link is done through (res_model, res_id). """
400 msg_obj = self.pool.get('mail.message')
401 fol_obj = self.pool.get('mail.followers')
402 # delete messages and notifications
403 msg_ids = msg_obj.search(cr, uid, [('model', '=', self._name), ('res_id', 'in', ids)], context=context)
404 msg_obj.unlink(cr, uid, msg_ids, context=context)
406 res = super(mail_thread, self).unlink(cr, uid, ids, context=context)
408 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids)], context=context)
409 fol_obj.unlink(cr, SUPERUSER_ID, fol_ids, context=context)
412 def copy(self, cr, uid, id, default=None, context=None):
413 default = default or {}
414 default['message_ids'] = []
415 default['message_follower_ids'] = []
416 return super(mail_thread, self).copy(cr, uid, id, default=default, context=context)
418 #------------------------------------------------------
419 # Automatically log tracked fields
420 #------------------------------------------------------
422 def _get_tracked_fields(self, cr, uid, updated_fields, context=None):
423 """ Return a structure of tracked fields for the current model.
424 :param list updated_fields: modified field names
425 :return list: a list of (field_name, column_info obj), containing
426 always tracked fields and modified on_change fields
429 for name, column_info in self._all_columns.items():
430 visibility = getattr(column_info.column, 'track_visibility', False)
431 if visibility == 'always' or (visibility == 'onchange' and name in updated_fields) or name in self._track:
435 return self.fields_get(cr, uid, lst, context=context)
437 def message_track(self, cr, uid, ids, tracked_fields, initial_values, context=None):
439 def convert_for_display(value, col_info):
440 if not value and col_info['type'] == 'boolean':
444 if col_info['type'] == 'many2one':
445 return value.name_get()[0][1]
446 if col_info['type'] == 'selection':
447 return dict(col_info['selection'])[value]
450 def format_message(message_description, tracked_values):
452 if message_description:
453 message = '<span>%s</span>' % message_description
454 for name, change in tracked_values.items():
455 message += '<div> • <b>%s</b>: ' % change.get('col_info')
456 if change.get('old_value'):
457 message += '%s → ' % change.get('old_value')
458 message += '%s</div>' % change.get('new_value')
461 if not tracked_fields:
464 for browse_record in self.browse(cr, uid, ids, context=context):
465 initial = initial_values[browse_record.id]
469 # generate tracked_values data structure: {'col_name': {col_info, new_value, old_value}}
470 for col_name, col_info in tracked_fields.items():
471 initial_value = initial[col_name]
472 record_value = getattr(browse_record, col_name)
474 if record_value == initial_value and getattr(self._all_columns[col_name].column, 'track_visibility', None) == 'always':
475 tracked_values[col_name] = dict(col_info=col_info['string'],
476 new_value=convert_for_display(record_value, col_info))
477 elif record_value != initial_value and (record_value or initial_value): # because browse null != False
478 if getattr(self._all_columns[col_name].column, 'track_visibility', None) in ['always', 'onchange']:
479 tracked_values[col_name] = dict(col_info=col_info['string'],
480 old_value=convert_for_display(initial_value, col_info),
481 new_value=convert_for_display(record_value, col_info))
482 if col_name in tracked_fields:
483 changes.add(col_name)
487 # find subtypes and post messages or log if no subtype found
489 for field, track_info in self._track.items():
490 if field not in changes:
492 for subtype, method in track_info.items():
493 if method(self, cr, uid, browse_record, context):
494 subtypes.append(subtype)
497 for subtype in subtypes:
499 subtype_rec = self.pool.get('ir.model.data').get_object(cr, uid, subtype.split('.')[0], subtype.split('.')[1], context=context)
500 except ValueError, e:
501 _logger.debug('subtype %s not found, giving error "%s"' % (subtype, e))
503 message = format_message(subtype_rec.description if subtype_rec.description else subtype_rec.name, tracked_values)
504 self.message_post(cr, uid, browse_record.id, body=message, subtype=subtype, context=context)
507 message = format_message('', tracked_values)
508 self.message_post(cr, uid, browse_record.id, body=message, context=context)
511 #------------------------------------------------------
512 # mail.message wrappers and tools
513 #------------------------------------------------------
515 def _needaction_domain_get(self, cr, uid, context=None):
517 return [('message_unread', '=', True)]
520 def _garbage_collect_attachments(self, cr, uid, context=None):
521 """ Garbage collect lost mail attachments. Those are attachments
522 - linked to res_model 'mail.compose.message', the composer wizard
523 - with res_id 0, because they were created outside of an existing
524 wizard (typically user input through Chatter or reports
525 created on-the-fly by the templates)
526 - unused since at least one day (create_date and write_date)
528 limit_date = datetime.datetime.utcnow() - datetime.timedelta(days=1)
529 limit_date_str = datetime.datetime.strftime(limit_date, tools.DEFAULT_SERVER_DATETIME_FORMAT)
530 ir_attachment_obj = self.pool.get('ir.attachment')
531 attach_ids = ir_attachment_obj.search(cr, uid, [
532 ('res_model', '=', 'mail.compose.message'),
534 ('create_date', '<', limit_date_str),
535 ('write_date', '<', limit_date_str),
537 ir_attachment_obj.unlink(cr, uid, attach_ids, context=context)
540 def check_mail_message_access(self, cr, uid, mids, operation, model_obj=None, context=None):
541 """ mail.message check permission rules for related document. This method is
542 meant to be inherited in order to implement addons-specific behavior.
543 A common behavior would be to allow creating messages when having read
544 access rule on the document, for portal document such as issues. """
547 if hasattr(self, '_mail_post_access'):
548 create_allow = self._mail_post_access
550 create_allow = 'write'
552 if operation in ['write', 'unlink']:
553 check_operation = 'write'
554 elif operation == 'create' and create_allow in ['create', 'read', 'write', 'unlink']:
555 check_operation = create_allow
556 elif operation == 'create':
557 check_operation = 'write'
559 check_operation = operation
561 model_obj.check_access_rights(cr, uid, check_operation)
562 model_obj.check_access_rule(cr, uid, mids, check_operation, context=context)
564 def _get_formview_action(self, cr, uid, id, model=None, context=None):
565 """ Return an action to open the document. This method is meant to be
566 overridden in addons that want to give specific view ids for example.
568 :param int id: id of the document to open
569 :param string model: specific model that overrides self._name
572 'type': 'ir.actions.act_window',
573 'res_model': model or self._name,
576 'views': [(False, 'form')],
581 def _get_inbox_action_xml_id(self, cr, uid, context=None):
582 """ When redirecting towards the Inbox, choose which action xml_id has
583 to be fetched. This method is meant to be inherited, at least in portal
584 because portal users have a different Inbox action than classic users. """
585 return ('mail', 'action_mail_inbox_feeds')
587 def message_redirect_action(self, cr, uid, context=None):
588 """ For a given message, return an action that either
589 - opens the form view of the related document if model, res_id, and
590 read access to the document
591 - opens the Inbox with a default search on the conversation if model,
593 - opens the Inbox with context propagated
599 # default action is the Inbox action
600 self.pool.get('res.users').browse(cr, SUPERUSER_ID, uid, context=context)
601 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))
602 action = self.pool.get(act_model).read(cr, uid, act_id, [])
603 params = context.get('params')
604 msg_id = model = res_id = None
607 msg_id = params.get('message_id')
608 model = params.get('model')
609 res_id = params.get('res_id')
610 if not msg_id and not (model and res_id):
612 if msg_id and not (model and res_id):
613 msg = self.pool.get('mail.message').browse(cr, uid, msg_id, context=context)
615 model, res_id = msg.model, msg.res_id
617 # if model + res_id found: try to redirect to the document or fallback on the Inbox
619 model_obj = self.pool.get(model)
620 if model_obj.check_access_rights(cr, uid, 'read', raise_exception=False):
622 model_obj.check_access_rule(cr, uid, [res_id], 'read', context=context)
623 if not hasattr(model_obj, '_get_formview_action'):
624 action = self.pool.get('mail.thread')._get_formview_action(cr, uid, res_id, model=model, context=context)
626 action = model_obj._get_formview_action(cr, uid, res_id, context=context)
627 except (osv.except_osv, orm.except_orm):
631 'search_default_model': model,
632 'search_default_res_id': res_id,
637 #------------------------------------------------------
639 #------------------------------------------------------
641 def message_get_reply_to(self, cr, uid, ids, context=None):
642 """ Returns the preferred reply-to email address that is basically
643 the alias of the document, if it exists. """
644 if not self._inherits.get('mail.alias'):
645 return [False for id in ids]
646 return ["%s@%s" % (record['alias_name'], record['alias_domain'])
647 if record.get('alias_domain') and record.get('alias_name')
649 for record in self.read(cr, SUPERUSER_ID, ids, ['alias_name', 'alias_domain'], context=context)]
651 #------------------------------------------------------
653 #------------------------------------------------------
655 def message_capable_models(self, cr, uid, context=None):
656 """ Used by the plugin addon, based for plugin_outlook and others. """
658 for model_name in self.pool.obj_list():
659 model = self.pool[model_name]
660 if hasattr(model, "message_process") and hasattr(model, "message_post"):
661 ret_dict[model_name] = model._description
664 def _message_find_partners(self, cr, uid, message, header_fields=['From'], context=None):
665 """ Find partners related to some header fields of the message.
667 :param string message: an email.message instance """
668 s = ', '.join([decode(message.get(h)) for h in header_fields if message.get(h)])
669 return filter(lambda x: x, self._find_partner_from_emails(cr, uid, None, tools.email_split(s), context=context))
671 def message_route_verify(self, cr, uid, message, message_dict, route, update_author=True, assert_model=True, create_fallback=True, context=None):
672 """ Verify route validity. Check and rules:
673 1 - if thread_id -> check that document effectively exists; otherwise
674 fallback on a message_new by resetting thread_id
675 2 - check that message_update exists if thread_id is set; or at least
676 that message_new exist
677 [ - find author_id if udpate_author is set]
678 3 - if there is an alias, check alias_contact:
679 'followers' and thread_id:
680 check on target document that the author is in the followers
681 'followers' and alias_parent_thread_id:
682 check on alias parent document that the author is in the
684 'partners': check that author_id id set
687 assert isinstance(route, (list, tuple)), 'A route should be a list or a tuple'
688 assert len(route) == 5, 'A route should contain 5 elements: model, thread_id, custom_values, uid, alias record'
690 message_id = message.get('Message-Id')
691 email_from = decode_header(message, 'From')
692 author_id = message_dict.get('author_id')
693 model, thread_id, alias = route[0], route[1], route[4]
696 def _create_bounce_email():
697 mail_mail = self.pool.get('mail.mail')
698 mail_id = mail_mail.create(cr, uid, {
699 'body_html': '<div><p>Hello,</p>'
700 '<p>The following email sent to %s cannot be accepted because this is '
701 'a private email address. Only allowed people can contact us at this address.</p></div>'
702 '<blockquote>%s</blockquote>' % (message.get('to'), message_dict.get('body')),
703 'subject': 'Re: %s' % message.get('subject'),
704 'email_to': message.get('from'),
707 mail_mail.send(cr, uid, [mail_id], context=context)
710 _logger.warning('Routing mail with Message-Id %s: route %s: %s',
711 message_id, route, message)
714 if model and not model in self.pool:
716 assert model in self.pool, 'Routing: unknown target model %s' % model
717 _warn('unknown target model %s' % model)
720 model_pool = self.pool[model]
722 # Private message: should not contain any thread_id
723 if not model and thread_id:
725 assert thread_id == 0, 'Routing: posting a message without model should be with a null res_id (private message), received %s.' % thread_id
726 _warn('posting a message without model should be with a null res_id (private message), received %s, resetting thread_id' % thread_id)
728 # Private message: should have a parent_id (only answers)
729 if not model and not message_dict.get('parent_id'):
731 assert message_dict.get('parent_id'), 'Routing: posting a message without model should be with a parent_id (private mesage).'
732 _warn('posting a message without model should be with a parent_id (private mesage), skipping')
735 # Existing Document: check if exists; if not, fallback on create if allowed
736 if thread_id and not model_pool.exists(cr, uid, thread_id):
738 _warn('reply to missing document (%s,%s), fall back on new document creation' % (model, thread_id))
741 assert model_pool.exists(cr, uid, thread_id), 'Routing: reply to missing document (%s,%s)' % (model, thread_id)
743 _warn('reply to missing document (%s,%s), skipping' % (model, thread_id))
746 # Existing Document: check model accepts the mailgateway
747 if thread_id and model and not hasattr(model_pool, 'message_update'):
749 _warn('model %s does not accept document update, fall back on document creation' % model)
752 assert hasattr(model_pool, 'message_update'), 'Routing: model %s does not accept document update, crashing' % model
754 _warn('model %s does not accept document update, skipping' % model)
757 # New Document: check model accepts the mailgateway
758 if not thread_id and model and not hasattr(model_pool, 'message_new'):
760 assert hasattr(model_pool, 'message_new'), 'Model %s does not accept document creation, crashing' % model
761 _warn('model %s does not accept document creation, skipping' % model)
764 # Update message author if asked
765 # We do it now because we need it for aliases (contact settings)
766 if not author_id and update_author:
767 author_ids = self._find_partner_from_emails(cr, uid, thread_id, [email_from], model=model, context=context)
769 author_id = author_ids[0]
770 message_dict['author_id'] = author_id
772 # Alias: check alias_contact settings
773 if alias and alias.alias_contact == 'followers' and (thread_id or alias.alias_parent_thread_id):
775 obj = self.pool[model].browse(cr, uid, thread_id, context=context)
777 obj = self.pool[alias.alias_parent_model_id.model].browse(cr, uid, alias.alias_parent_thread_id, context=context)
778 if not author_id or not author_id in [fol.id for fol in obj.message_follower_ids]:
779 _warn('alias %s restricted to internal followers, skipping' % alias.alias_name)
780 _create_bounce_email()
782 elif alias and alias.alias_contact == 'partners' and not author_id:
783 _warn('alias %s does not accept unknown author, skipping' % alias.alias_name)
784 _create_bounce_email()
787 return (model, thread_id, route[2], route[3], route[4])
789 def message_route(self, cr, uid, message, message_dict, model=None, thread_id=None,
790 custom_values=None, context=None):
791 """Attempt to figure out the correct target model, thread_id,
792 custom_values and user_id to use for an incoming message.
793 Multiple values may be returned, if a message had multiple
794 recipients matching existing mail.aliases, for example.
796 The following heuristics are used, in this order:
797 1. If the message replies to an existing thread_id, and
798 properly contains the thread model in the 'In-Reply-To'
799 header, use this model/thread_id pair, and ignore
800 custom_value (not needed as no creation will take place)
801 2. Look for a mail.alias entry matching the message
802 recipient, and use the corresponding model, thread_id,
803 custom_values and user_id.
804 3. Fallback to the ``model``, ``thread_id`` and ``custom_values``
806 4. If all the above fails, raise an exception.
808 :param string message: an email.message instance
809 :param dict message_dict: dictionary holding message variables
810 :param string model: the fallback model to use if the message
811 does not match any of the currently configured mail aliases
812 (may be None if a matching alias is supposed to be present)
813 :type dict custom_values: optional dictionary of default field values
814 to pass to ``message_new`` if a new record needs to be created.
815 Ignored if the thread record already exists, and also if a
816 matching mail.alias was found (aliases define their own defaults)
817 :param int thread_id: optional ID of the record/thread from ``model``
818 to which this mail should be attached. Only used if the message
819 does not reply to an existing thread and does not match any mail alias.
820 :return: list of [model, thread_id, custom_values, user_id, alias]
822 assert isinstance(message, Message), 'message must be an email.message.Message at this point'
823 mail_msg_obj = self.pool['mail.message']
824 fallback_model = model
826 # Get email.message.Message variables for future processing
827 message_id = message.get('Message-Id')
828 email_from = decode_header(message, 'From')
829 email_to = decode_header(message, 'To')
830 references = decode_header(message, 'References')
831 in_reply_to = decode_header(message, 'In-Reply-To')
832 thread_references = references or in_reply_to
834 # 1. message is a reply to an existing message (exact match of message_id)
835 msg_references = thread_references.split()
836 mail_message_ids = mail_msg_obj.search(cr, uid, [('message_id', 'in', msg_references)], context=context)
838 original_msg = mail_msg_obj.browse(cr, SUPERUSER_ID, mail_message_ids[0], context=context)
839 model, thread_id = original_msg.model, original_msg.res_id
841 'Routing mail from %s to %s with Message-Id %s: direct reply to msg: model: %s, thread_id: %s, custom_values: %s, uid: %s',
842 email_from, email_to, message_id, model, thread_id, custom_values, uid)
843 route = self.message_route_verify(
844 cr, uid, message, message_dict,
845 (model, thread_id, custom_values, uid, None),
846 update_author=True, assert_model=True, create_fallback=True, context=context)
847 return route and [route] or []
849 # 2. message is a reply to an existign thread (6.1 compatibility)
850 ref_match = thread_references and tools.reference_re.search(thread_references)
852 thread_id = int(ref_match.group(1))
853 model = ref_match.group(2) or fallback_model
854 if thread_id and model in self.pool:
855 model_obj = self.pool[model]
856 compat_mail_msg_ids = mail_msg_obj.search(
858 ('message_id', '=', False),
859 ('model', '=', model),
860 ('res_id', '=', thread_id),
862 if compat_mail_msg_ids and model_obj.exists(cr, uid, thread_id) and hasattr(model_obj, 'message_update'):
864 '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',
865 email_from, email_to, message_id, model, thread_id, custom_values, uid)
866 route = self.message_route_verify(
867 cr, uid, message, message_dict,
868 (model, thread_id, custom_values, uid, None),
869 update_author=True, assert_model=True, create_fallback=True, context=context)
870 return route and [route] or []
872 # 2. Reply to a private message
874 mail_message_ids = mail_msg_obj.search(cr, uid, [
875 ('message_id', '=', in_reply_to),
876 '!', ('message_id', 'ilike', 'reply_to')
877 ], limit=1, context=context)
879 mail_message = mail_msg_obj.browse(cr, uid, mail_message_ids[0], context=context)
880 _logger.info('Routing mail from %s to %s with Message-Id %s: direct reply to a private message: %s, custom_values: %s, uid: %s',
881 email_from, email_to, message_id, mail_message.id, custom_values, uid)
882 route = self.message_route_verify(cr, uid, message, message_dict,
883 (mail_message.model, mail_message.res_id, custom_values, uid, None),
884 update_author=True, assert_model=True, create_fallback=True, context=context)
885 return route and [route] or []
887 # 3. Look for a matching mail.alias entry
888 # Delivered-To is a safe bet in most modern MTAs, but we have to fallback on To + Cc values
889 # for all the odd MTAs out there, as there is no standard header for the envelope's `rcpt_to` value.
891 ','.join([decode_header(message, 'Delivered-To'),
892 decode_header(message, 'To'),
893 decode_header(message, 'Cc'),
894 decode_header(message, 'Resent-To'),
895 decode_header(message, 'Resent-Cc')])
896 local_parts = [e.split('@')[0] for e in tools.email_split(rcpt_tos)]
898 mail_alias = self.pool.get('mail.alias')
899 alias_ids = mail_alias.search(cr, uid, [('alias_name', 'in', local_parts)])
902 for alias in mail_alias.browse(cr, uid, alias_ids, context=context):
903 user_id = alias.alias_user_id.id
905 # TDE note: this could cause crashes, because no clue that the user
906 # that send the email has the right to create or modify a new document
907 # Fallback on user_id = uid
908 # Note: recognized partners will be added as followers anyway
909 # user_id = self._message_find_user_id(cr, uid, message, context=context)
911 _logger.info('No matching user_id for the alias %s', alias.alias_name)
912 route = (alias.alias_model_id.model, alias.alias_force_thread_id, eval(alias.alias_defaults), user_id, alias)
913 _logger.info('Routing mail from %s to %s with Message-Id %s: direct alias match: %r',
914 email_from, email_to, message_id, route)
915 route = self.message_route_verify(cr, uid, message, message_dict, route,
916 update_author=True, assert_model=True, create_fallback=True, context=context)
921 # 4. Fallback to the provided parameters, if they work
923 # Legacy: fallback to matching [ID] in the Subject
924 match = tools.res_re.search(decode_header(message, 'Subject'))
925 thread_id = match and match.group(1)
926 # Convert into int (bug spotted in 7.0 because of str)
928 thread_id = int(thread_id)
931 _logger.info('Routing mail from %s to %s with Message-Id %s: fallback to model:%s, thread_id:%s, custom_values:%s, uid:%s',
932 email_from, email_to, message_id, fallback_model, thread_id, custom_values, uid)
933 route = self.message_route_verify(cr, uid, message, message_dict,
934 (fallback_model, thread_id, custom_values, uid, None),
935 update_author=True, assert_model=True, context=context)
939 # AssertionError if no routes found and if no bounce occured
941 "No possible route found for incoming message from %s to %s (Message-Id %s:)." \
942 "Create an appropriate mail.alias or force the destination model." % (email_from, email_to, message_id)
944 def message_route_process(self, cr, uid, message, message_dict, routes, context=None):
945 # postpone setting message_dict.partner_ids after message_post, to avoid double notifications
946 partner_ids = message_dict.pop('partner_ids', [])
948 for model, thread_id, custom_values, user_id, alias in routes:
949 if self._name == 'mail.thread':
950 context.update({'thread_model': model})
952 model_pool = self.pool[model]
953 assert thread_id and hasattr(model_pool, 'message_update') or hasattr(model_pool, 'message_new'), \
954 "Undeliverable mail with Message-Id %s, model %s does not accept incoming emails" % \
955 (message_dict['message_id'], model)
957 # disabled subscriptions during message_new/update to avoid having the system user running the
958 # email gateway become a follower of all inbound messages
959 nosub_ctx = dict(context, mail_create_nosubscribe=True, mail_create_nolog=True)
960 if thread_id and hasattr(model_pool, 'message_update'):
961 model_pool.message_update(cr, user_id, [thread_id], message_dict, context=nosub_ctx)
963 thread_id = model_pool.message_new(cr, user_id, message_dict, custom_values, context=nosub_ctx)
965 assert thread_id == 0, "Posting a message without model should be with a null res_id, to create a private message."
966 model_pool = self.pool.get('mail.thread')
967 if not hasattr(model_pool, 'message_post'):
968 context['thread_model'] = model
969 model_pool = self.pool['mail.thread']
970 new_msg_id = model_pool.message_post(cr, uid, [thread_id], context=context, subtype='mail.mt_comment', **message_dict)
973 # postponed after message_post, because this is an external message and we don't want to create
974 # duplicate emails due to notifications
975 self.pool.get('mail.message').write(cr, uid, [new_msg_id], {'partner_ids': partner_ids}, context=context)
978 def message_process(self, cr, uid, model, message, custom_values=None,
979 save_original=False, strip_attachments=False,
980 thread_id=None, context=None):
981 """ Process an incoming RFC2822 email message, relying on
982 ``mail.message.parse()`` for the parsing operation,
983 and ``message_route()`` to figure out the target model.
985 Once the target model is known, its ``message_new`` method
986 is called with the new message (if the thread record did not exist)
987 or its ``message_update`` method (if it did).
989 There is a special case where the target model is False: a reply
990 to a private message. In this case, we skip the message_new /
991 message_update step, to just post a new message using mail_thread
994 :param string model: the fallback model to use if the message
995 does not match any of the currently configured mail aliases
996 (may be None if a matching alias is supposed to be present)
997 :param message: source of the RFC2822 message
998 :type message: string or xmlrpclib.Binary
999 :type dict custom_values: optional dictionary of field values
1000 to pass to ``message_new`` if a new record needs to be created.
1001 Ignored if the thread record already exists, and also if a
1002 matching mail.alias was found (aliases define their own defaults)
1003 :param bool save_original: whether to keep a copy of the original
1004 email source attached to the message after it is imported.
1005 :param bool strip_attachments: whether to strip all attachments
1006 before processing the message, in order to save some space.
1007 :param int thread_id: optional ID of the record/thread from ``model``
1008 to which this mail should be attached. When provided, this
1009 overrides the automatic detection based on the message
1015 # extract message bytes - we are forced to pass the message as binary because
1016 # we don't know its encoding until we parse its headers and hence can't
1017 # convert it to utf-8 for transport between the mailgate script and here.
1018 if isinstance(message, xmlrpclib.Binary):
1019 message = str(message.data)
1020 # Warning: message_from_string doesn't always work correctly on unicode,
1021 # we must use utf-8 strings here :-(
1022 if isinstance(message, unicode):
1023 message = message.encode('utf-8')
1024 msg_txt = email.message_from_string(message)
1026 # parse the message, verify we are not in a loop by checking message_id is not duplicated
1027 msg = self.message_parse(cr, uid, msg_txt, save_original=save_original, context=context)
1028 if strip_attachments:
1029 msg.pop('attachments', None)
1031 if msg.get('message_id'): # should always be True as message_parse generate one if missing
1032 existing_msg_ids = self.pool.get('mail.message').search(cr, SUPERUSER_ID, [
1033 ('message_id', '=', msg.get('message_id')),
1035 if existing_msg_ids:
1036 _logger.info('Ignored mail from %s to %s with Message-Id %s: found duplicated Message-Id during processing',
1037 msg.get('from'), msg.get('to'), msg.get('message_id'))
1040 # find possible routes for the message
1041 routes = self.message_route(cr, uid, msg_txt, msg, model, thread_id, custom_values, context=context)
1042 thread_id = self.message_route_process(cr, uid, msg_txt, msg, routes, context=context)
1045 def message_new(self, cr, uid, msg_dict, custom_values=None, context=None):
1046 """Called by ``message_process`` when a new message is received
1047 for a given thread model, if the message did not belong to
1049 The default behavior is to create a new record of the corresponding
1050 model (based on some very basic info extracted from the message).
1051 Additional behavior may be implemented by overriding this method.
1053 :param dict msg_dict: a map containing the email details and
1054 attachments. See ``message_process`` and
1055 ``mail.message.parse`` for details.
1056 :param dict custom_values: optional dictionary of additional
1057 field values to pass to create()
1058 when creating the new thread record.
1059 Be careful, these values may override
1060 any other values coming from the message.
1061 :param dict context: if a ``thread_model`` value is present
1062 in the context, its value will be used
1063 to determine the model of the record
1064 to create (instead of the current model).
1066 :return: the id of the newly created thread object
1071 if isinstance(custom_values, dict):
1072 data = custom_values.copy()
1073 model = context.get('thread_model') or self._name
1074 model_pool = self.pool[model]
1075 fields = model_pool.fields_get(cr, uid, context=context)
1076 if 'name' in fields and not data.get('name'):
1077 data['name'] = msg_dict.get('subject', '')
1078 res_id = model_pool.create(cr, uid, data, context=context)
1081 def message_update(self, cr, uid, ids, msg_dict, update_vals=None, context=None):
1082 """Called by ``message_process`` when a new message is received
1083 for an existing thread. The default behavior is to update the record
1084 with update_vals taken from the incoming email.
1085 Additional behavior may be implemented by overriding this
1087 :param dict msg_dict: a map containing the email details and
1088 attachments. See ``message_process`` and
1089 ``mail.message.parse()`` for details.
1090 :param dict update_vals: a dict containing values to update records
1091 given their ids; if the dict is None or is
1092 void, no write operation is performed.
1095 self.write(cr, uid, ids, update_vals, context=context)
1098 def _message_extract_payload(self, message, save_original=False):
1099 """Extract body as HTML and attachments from the mail message"""
1103 attachments.append(('original_email.eml', message.as_string()))
1104 if not message.is_multipart() or 'text/' in message.get('content-type', ''):
1105 encoding = message.get_content_charset()
1106 body = message.get_payload(decode=True)
1107 body = tools.ustr(body, encoding, errors='replace')
1108 if message.get_content_type() == 'text/plain':
1109 # text/plain -> <pre/>
1110 body = tools.append_content_to_html(u'', body, preserve=True)
1113 for part in message.walk():
1114 if part.get_content_type() == 'multipart/alternative':
1116 if part.get_content_maintype() == 'multipart':
1117 continue # skip container
1118 filename = part.get_filename() # None if normal part
1119 encoding = part.get_content_charset() # None if attachment
1120 # 1) Explicit Attachments -> attachments
1121 if filename or part.get('content-disposition', '').strip().startswith('attachment'):
1122 attachments.append((decode(filename) or 'attachment', part.get_payload(decode=True)))
1124 # 2) text/plain -> <pre/>
1125 if part.get_content_type() == 'text/plain' and (not alternative or not body):
1126 body = tools.append_content_to_html(body, tools.ustr(part.get_payload(decode=True),
1127 encoding, errors='replace'), preserve=True)
1128 # 3) text/html -> raw
1129 elif part.get_content_type() == 'text/html':
1130 html = tools.ustr(part.get_payload(decode=True), encoding, errors='replace')
1134 body = tools.append_content_to_html(body, html, plaintext=False)
1135 # 4) Anything else -> attachment
1137 attachments.append((filename or 'attachment', part.get_payload(decode=True)))
1138 return body, attachments
1140 def message_parse(self, cr, uid, message, save_original=False, context=None):
1141 """Parses a string or email.message.Message representing an
1142 RFC-2822 email, and returns a generic dict holding the
1145 :param message: the message to parse
1146 :type message: email.message.Message | string | unicode
1147 :param bool save_original: whether the returned dict
1148 should include an ``original`` attachment containing
1149 the source of the message
1151 :return: A dict with the following structure, where each
1152 field may not be present if missing in original
1155 { 'message_id': msg_id,
1160 'body': unified_body,
1161 'attachments': [('file1', 'bytes'),
1168 if not isinstance(message, Message):
1169 if isinstance(message, unicode):
1170 # Warning: message_from_string doesn't always work correctly on unicode,
1171 # we must use utf-8 strings here :-(
1172 message = message.encode('utf-8')
1173 message = email.message_from_string(message)
1175 message_id = message['message-id']
1177 # Very unusual situation, be we should be fault-tolerant here
1178 message_id = "<%s@localhost>" % time.time()
1179 _logger.debug('Parsing Message without message-id, generating a random one: %s', message_id)
1180 msg_dict['message_id'] = message_id
1182 if message.get('Subject'):
1183 msg_dict['subject'] = decode(message.get('Subject'))
1185 # Envelope fields not stored in mail.message but made available for message_new()
1186 msg_dict['from'] = decode(message.get('from'))
1187 msg_dict['to'] = decode(message.get('to'))
1188 msg_dict['cc'] = decode(message.get('cc'))
1189 msg_dict['email_from'] = decode(message.get('from'))
1190 partner_ids = self._message_find_partners(cr, uid, message, ['To', 'Cc'], context=context)
1191 msg_dict['partner_ids'] = [(4, partner_id) for partner_id in partner_ids]
1193 if message.get('Date'):
1195 date_hdr = decode(message.get('Date'))
1196 parsed_date = dateutil.parser.parse(date_hdr, fuzzy=True)
1197 if parsed_date.utcoffset() is None:
1198 # naive datetime, so we arbitrarily decide to make it
1199 # UTC, there's no better choice. Should not happen,
1200 # as RFC2822 requires timezone offset in Date headers.
1201 stored_date = parsed_date.replace(tzinfo=pytz.utc)
1203 stored_date = parsed_date.astimezone(tz=pytz.utc)
1205 _logger.warning('Failed to parse Date header %r in incoming mail '
1206 'with message-id %r, assuming current date/time.',
1207 message.get('Date'), message_id)
1208 stored_date = datetime.datetime.now()
1209 msg_dict['date'] = stored_date.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT)
1211 if message.get('In-Reply-To'):
1212 parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', '=', decode(message['In-Reply-To']))])
1214 msg_dict['parent_id'] = parent_ids[0]
1216 if message.get('References') and 'parent_id' not in msg_dict:
1217 parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', 'in',
1218 [x.strip() for x in decode(message['References']).split()])])
1220 msg_dict['parent_id'] = parent_ids[0]
1222 msg_dict['body'], msg_dict['attachments'] = self._message_extract_payload(message, save_original=save_original)
1225 #------------------------------------------------------
1227 #------------------------------------------------------
1229 def log(self, cr, uid, id, message, secondary=False, context=None):
1230 _logger.warning("log() is deprecated. As this module inherit from "\
1231 "mail.thread, the message will be managed by this "\
1232 "module instead of by the res.log mechanism. Please "\
1233 "use mail_thread.message_post() instead of the "\
1234 "now deprecated res.log.")
1235 self.message_post(cr, uid, [id], message, context=context)
1237 def _message_add_suggested_recipient(self, cr, uid, result, obj, partner=None, email=None, reason='', context=None):
1238 """ Called by message_get_suggested_recipients, to add a suggested
1239 recipient in the result dictionary. The form is :
1240 partner_id, partner_name<partner_email> or partner_name, reason """
1241 if email and not partner:
1242 # get partner info from email
1243 partner_info = self.message_partner_info_from_emails(cr, uid, obj.id, [email], context=context)[0]
1244 if partner_info.get('partner_id'):
1245 partner = self.pool.get('res.partner').browse(cr, SUPERUSER_ID, [partner_info['partner_id']], context=context)[0]
1246 if email and email in [val[1] for val in result[obj.id]]: # already existing email -> skip
1248 if partner and partner in obj.message_follower_ids: # recipient already in the followers -> skip
1250 if partner and partner in [val[0] for val in result[obj.id]]: # already existing partner ID -> skip
1252 if partner and partner.email: # complete profile: id, name <email>
1253 result[obj.id].append((partner.id, '%s<%s>' % (partner.name, partner.email), reason))
1254 elif partner: # incomplete profile: id, name
1255 result[obj.id].append((partner.id, '%s' % (partner.name), reason))
1256 else: # unknown partner, we are probably managing an email address
1257 result[obj.id].append((False, email, reason))
1260 def message_get_suggested_recipients(self, cr, uid, ids, context=None):
1261 """ Returns suggested recipients for ids. Those are a list of
1262 tuple (partner_id, partner_name, reason), to be managed by Chatter. """
1263 result = dict.fromkeys(ids, list())
1264 if self._all_columns.get('user_id'):
1265 for obj in self.browse(cr, SUPERUSER_ID, ids, context=context): # SUPERUSER because of a read on res.users that would crash otherwise
1266 if not obj.user_id or not obj.user_id.partner_id:
1268 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)
1271 def _find_partner_from_emails(self, cr, uid, id, emails, model=None, context=None, check_followers=True):
1272 """ Utility method to find partners from email addresses. The rules are :
1273 1 - check in document (model | self, id) followers
1274 2 - try to find a matching partner that is also an user
1275 3 - try to find a matching partner
1277 :param list emails: list of email addresses
1278 :param string model: model to fetch related record; by default self
1280 :param boolean check_followers: check in document followers
1282 partner_obj = self.pool['res.partner']
1285 if id and (model or self._name != 'mail.thread') and check_followers:
1287 obj = self.pool[model].browse(cr, uid, id, context=context)
1289 obj = self.browse(cr, uid, id, context=context)
1290 for contact in emails:
1292 email_address = tools.email_split(contact)
1293 if not email_address:
1294 partner_ids.append(partner_id)
1296 email_address = email_address[0]
1297 # first try: check in document's followers
1299 for follower in obj.message_follower_ids:
1300 if follower.email == email_address:
1301 partner_id = follower.id
1302 # second try: check in partners that are also users
1304 ids = partner_obj.search(cr, SUPERUSER_ID, [
1305 ('email', 'ilike', email_address),
1306 ('user_ids', '!=', False)
1307 ], limit=1, context=context)
1310 # third try: check in partners
1312 ids = partner_obj.search(cr, SUPERUSER_ID, [
1313 ('email', 'ilike', email_address)
1314 ], limit=1, context=context)
1317 partner_ids.append(partner_id)
1320 def message_partner_info_from_emails(self, cr, uid, id, emails, link_mail=False, context=None):
1321 """ Convert a list of emails into a list partner_ids and a list
1322 new_partner_ids. The return value is non conventional because
1323 it is meant to be used by the mail widget.
1325 :return dict: partner_ids and new_partner_ids """
1326 mail_message_obj = self.pool.get('mail.message')
1327 partner_ids = self._find_partner_from_emails(cr, uid, id, emails, context=context)
1329 for idx in range(len(emails)):
1330 email_address = emails[idx]
1331 partner_id = partner_ids[idx]
1332 partner_info = {'full_name': email_address, 'partner_id': partner_id}
1333 result.append(partner_info)
1335 # link mail with this from mail to the new partner id
1336 if link_mail and partner_info['partner_id']:
1337 message_ids = mail_message_obj.search(cr, SUPERUSER_ID, [
1339 ('email_from', '=', email_address),
1340 ('email_from', 'ilike', '<%s>' % email_address),
1341 ('author_id', '=', False)
1344 mail_message_obj.write(cr, SUPERUSER_ID, message_ids, {'author_id': partner_info['partner_id']}, context=context)
1347 def _message_preprocess_attachments(self, cr, uid, attachments, attachment_ids, attach_model, attach_res_id, context=None):
1348 """ Preprocess attachments for mail_thread.message_post() or mail_mail.create().
1350 :param list attachments: list of attachment tuples in the form ``(name,content)``,
1351 where content is NOT base64 encoded
1352 :param list attachment_ids: a list of attachment ids, not in tomany command form
1353 :param str attach_model: the model of the attachments parent record
1354 :param integer attach_res_id: the id of the attachments parent record
1356 Attachment = self.pool['ir.attachment']
1357 m2m_attachment_ids = []
1359 filtered_attachment_ids = Attachment.search(cr, SUPERUSER_ID, [
1360 ('res_model', '=', 'mail.compose.message'),
1361 ('create_uid', '=', uid),
1362 ('id', 'in', attachment_ids)], context=context)
1363 if filtered_attachment_ids:
1364 Attachment.write(cr, SUPERUSER_ID, filtered_attachment_ids, {'res_model': attach_model, 'res_id': attach_res_id}, context=context)
1365 m2m_attachment_ids += [(4, id) for id in attachment_ids]
1366 # Handle attachments parameter, that is a dictionary of attachments
1367 for name, content in attachments:
1368 if isinstance(content, unicode):
1369 content = content.encode('utf-8')
1372 'datas': base64.b64encode(str(content)),
1373 'datas_fname': name,
1374 'description': name,
1375 'res_model': attach_model,
1376 'res_id': attach_res_id,
1378 m2m_attachment_ids.append((0, 0, data_attach))
1379 return m2m_attachment_ids
1381 def message_post(self, cr, uid, thread_id, body='', subject=None, type='notification',
1382 subtype=None, parent_id=False, attachments=None, context=None,
1383 content_subtype='html', **kwargs):
1384 """ Post a new message in an existing thread, returning the new
1387 :param int thread_id: thread ID to post into, or list with one ID;
1388 if False/0, mail.message model will also be set as False
1389 :param str body: body of the message, usually raw HTML that will
1391 :param str type: see mail_message.type field
1392 :param str content_subtype:: if plaintext: convert body into html
1393 :param int parent_id: handle reply to a previous message by adding the
1394 parent partners to the message in case of private discussion
1395 :param tuple(str,str) attachments or list id: list of attachment tuples in the form
1396 ``(name,content)``, where content is NOT base64 encoded
1398 Extra keyword arguments will be used as default column values for the
1399 new mail.message record. Special cases:
1400 - attachment_ids: supposed not attached to any document; attach them
1401 to the related document. Should only be set by Chatter.
1402 :return int: ID of newly created mail.message
1406 if attachments is None:
1408 mail_message = self.pool.get('mail.message')
1409 ir_attachment = self.pool.get('ir.attachment')
1411 assert (not thread_id) or \
1412 isinstance(thread_id, (int, long)) or \
1413 (isinstance(thread_id, (list, tuple)) and len(thread_id) == 1), \
1414 "Invalid thread_id; should be 0, False, an ID or a list with one ID"
1415 if isinstance(thread_id, (list, tuple)):
1416 thread_id = thread_id[0]
1418 # if we're processing a message directly coming from the gateway, the destination model was
1419 # set in the context.
1422 model = context.get('thread_model', self._name) if self._name == 'mail.thread' else self._name
1423 if model != self._name and hasattr(self.pool[model], 'message_post'):
1424 del context['thread_model']
1425 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)
1427 #0: Find the message's author, because we need it for private discussion
1428 author_id = kwargs.get('author_id')
1429 if author_id is None: # keep False values
1430 author_id = self.pool.get('mail.message')._get_default_author(cr, uid, context=context)
1432 # 1: Handle content subtype: if plaintext, converto into HTML
1433 if content_subtype == 'plaintext':
1434 body = tools.plaintext2html(body)
1436 # 2: Private message: add recipients (recipients and author of parent message) - current author
1437 # + legacy-code management (! we manage only 4 and 6 commands)
1439 kwargs_partner_ids = kwargs.pop('partner_ids', [])
1440 for partner_id in kwargs_partner_ids:
1441 if isinstance(partner_id, (list, tuple)) and partner_id[0] == 4 and len(partner_id) == 2:
1442 partner_ids.add(partner_id[1])
1443 if isinstance(partner_id, (list, tuple)) and partner_id[0] == 6 and len(partner_id) == 3:
1444 partner_ids |= set(partner_id[2])
1445 elif isinstance(partner_id, (int, long)):
1446 partner_ids.add(partner_id)
1448 pass # we do not manage anything else
1449 if parent_id and not model:
1450 parent_message = mail_message.browse(cr, uid, parent_id, context=context)
1451 private_followers = set([partner.id for partner in parent_message.partner_ids])
1452 if parent_message.author_id:
1453 private_followers.add(parent_message.author_id.id)
1454 private_followers -= set([author_id])
1455 partner_ids |= private_followers
1458 # - HACK TDE FIXME: Chatter: attachments linked to the document (not done JS-side), load the message
1459 attachment_ids = self._message_preprocess_attachments(cr, uid, attachments, kwargs.pop('attachment_ids', []), model, thread_id, context)
1461 # 4: mail.message.subtype
1464 if '.' not in subtype:
1465 subtype = 'mail.%s' % subtype
1466 ref = self.pool.get('ir.model.data').get_object_reference(cr, uid, *subtype.split('.'))
1467 subtype_id = ref and ref[1] or False
1469 # automatically subscribe recipients if asked to
1470 if context.get('mail_post_autofollow') and thread_id and partner_ids:
1471 partner_to_subscribe = partner_ids
1472 if context.get('mail_post_autofollow_partner_ids'):
1473 partner_to_subscribe = filter(lambda item: item in context.get('mail_post_autofollow_partner_ids'), partner_ids)
1474 self.message_subscribe(cr, uid, [thread_id], list(partner_to_subscribe), context=context)
1476 # _mail_flat_thread: automatically set free messages to the first posted message
1477 if self._mail_flat_thread and not parent_id and thread_id:
1478 message_ids = mail_message.search(cr, uid, ['&', ('res_id', '=', thread_id), ('model', '=', model)], context=context, order="id ASC", limit=1)
1479 parent_id = message_ids and message_ids[0] or False
1480 # 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
1482 message_ids = mail_message.search(cr, SUPERUSER_ID, [('id', '=', parent_id), ('parent_id', '!=', False)], context=context)
1483 # avoid loops when finding ancestors
1486 message = mail_message.browse(cr, SUPERUSER_ID, message_ids[0], context=context)
1487 while (message.parent_id and message.parent_id.id not in processed_list):
1488 processed_list.append(message.parent_id.id)
1489 message = message.parent_id
1490 parent_id = message.id
1494 'author_id': author_id,
1496 'res_id': thread_id or False,
1498 'subject': subject or False,
1500 'parent_id': parent_id,
1501 'attachment_ids': attachment_ids,
1502 'subtype_id': subtype_id,
1503 'partner_ids': [(4, pid) for pid in partner_ids],
1506 # Avoid warnings about non-existing fields
1507 for x in ('from', 'to', 'cc'):
1510 # Create and auto subscribe the author
1511 msg_id = mail_message.create(cr, uid, values, context=context)
1512 message = mail_message.browse(cr, uid, msg_id, context=context)
1513 if message.author_id and thread_id and type != 'notification' and not context.get('mail_create_nosubscribe'):
1514 self.message_subscribe(cr, uid, [thread_id], [message.author_id.id], context=context)
1517 #------------------------------------------------------
1519 #------------------------------------------------------
1521 def message_get_subscription_data(self, cr, uid, ids, user_pid=None, context=None):
1522 """ Wrapper to get subtypes data. """
1523 return self._get_subscription_data(cr, uid, ids, None, None, user_pid=user_pid, context=context)
1525 def message_subscribe_users(self, cr, uid, ids, user_ids=None, subtype_ids=None, context=None):
1526 """ Wrapper on message_subscribe, using users. If user_ids is not
1527 provided, subscribe uid instead. """
1528 if user_ids is None:
1530 partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
1531 return self.message_subscribe(cr, uid, ids, partner_ids, subtype_ids=subtype_ids, context=context)
1533 def message_subscribe(self, cr, uid, ids, partner_ids, subtype_ids=None, context=None):
1534 """ Add partners to the records followers. """
1535 mail_followers_obj = self.pool.get('mail.followers')
1536 subtype_obj = self.pool.get('mail.message.subtype')
1538 user_pid = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1539 if set(partner_ids) == set([user_pid]):
1541 self.check_access_rights(cr, uid, 'read')
1542 except (osv.except_osv, orm.except_orm):
1545 self.check_access_rights(cr, uid, 'write')
1547 existing_pids_dict = {}
1548 fol_ids = mail_followers_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids)])
1549 for fol in mail_followers_obj.browse(cr, SUPERUSER_ID, fol_ids, context=context):
1550 existing_pids_dict.setdefault(fol.res_id, set()).add(fol.partner_id.id)
1552 # subtype_ids specified: update already subscribed partners
1553 if subtype_ids and fol_ids:
1554 mail_followers_obj.write(cr, SUPERUSER_ID, fol_ids, {'subtype_ids': [(6, 0, subtype_ids)]}, context=context)
1555 # subtype_ids not specified: do not update already subscribed partner, fetch default subtypes for new partners
1556 if subtype_ids is None:
1557 subtype_ids = subtype_obj.search(
1559 ('default', '=', True), '|', ('res_model', '=', self._name), ('res_model', '=', False)], context=context)
1562 existing_pids = existing_pids_dict.get(id, set())
1563 new_pids = set(partner_ids) - existing_pids
1565 # subscribe new followers
1566 for new_pid in new_pids:
1567 mail_followers_obj.create(
1569 'res_model': self._name,
1571 'partner_id': new_pid,
1572 'subtype_ids': [(6, 0, subtype_ids)],
1577 def message_unsubscribe_users(self, cr, uid, ids, user_ids=None, context=None):
1578 """ Wrapper on message_subscribe, using users. If user_ids is not
1579 provided, unsubscribe uid instead. """
1580 if user_ids is None:
1582 partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
1583 return self.message_unsubscribe(cr, uid, ids, partner_ids, context=context)
1585 def message_unsubscribe(self, cr, uid, ids, partner_ids, context=None):
1586 """ Remove partners from the records followers. """
1587 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
1588 if set(partner_ids) == set([user_pid]):
1589 self.check_access_rights(cr, uid, 'read')
1591 self.check_access_rights(cr, uid, 'write')
1592 fol_obj = self.pool['mail.followers']
1593 fol_ids = fol_obj.search(
1595 ('res_model', '=', self._name),
1596 ('res_id', 'in', ids),
1597 ('partner_id', 'in', partner_ids)
1599 return fol_obj.unlink(cr, SUPERUSER_ID, fol_ids, context=context)
1601 def _message_get_auto_subscribe_fields(self, cr, uid, updated_fields, auto_follow_fields=['user_id'], context=None):
1602 """ Returns the list of relational fields linking to res.users that should
1603 trigger an auto subscribe. The default list checks for the fields
1605 - linking to res.users
1606 - with track_visibility set
1607 In OpenERP V7, this is sufficent for all major addon such as opportunity,
1608 project, issue, recruitment, sale.
1609 Override this method if a custom behavior is needed about fields
1610 that automatically subscribe users.
1613 for name, column_info in self._all_columns.items():
1614 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':
1615 user_field_lst.append(name)
1616 return user_field_lst
1618 def message_auto_subscribe(self, cr, uid, ids, updated_fields, context=None, values=None):
1619 """ Handle auto subscription. Two methods for auto subscription exist:
1621 - tracked res.users relational fields, such as user_id fields. Those fields
1622 must be relation fields toward a res.users record, and must have the
1623 track_visilibity attribute set.
1624 - using subtypes parent relationship: check if the current model being
1625 modified has an header record (such as a project for tasks) whose followers
1626 can be added as followers of the current records. Example of structure
1627 with project and task:
1629 - st_project_1.parent_id = st_task_1
1630 - st_project_1.res_model = 'project.project'
1631 - st_project_1.relation_field = 'project_id'
1632 - st_task_1.model = 'project.task'
1634 :param list updated_fields: list of updated fields to track
1635 :param dict values: updated values; if None, the first record will be browsed
1636 to get the values. Added after releasing 7.0, therefore
1637 not merged with updated_fields argumment.
1639 subtype_obj = self.pool.get('mail.message.subtype')
1640 follower_obj = self.pool.get('mail.followers')
1641 new_followers = dict()
1643 # fetch auto_follow_fields: res.users relation fields whose changes are tracked for subscription
1644 user_field_lst = self._message_get_auto_subscribe_fields(cr, uid, updated_fields, context=context)
1646 # fetch header subtypes
1647 header_subtype_ids = subtype_obj.search(cr, uid, ['|', ('res_model', '=', False), ('parent_id.res_model', '=', self._name)], context=context)
1648 subtypes = subtype_obj.browse(cr, uid, header_subtype_ids, context=context)
1650 # if no change in tracked field or no change in tracked relational field: quit
1651 relation_fields = set([subtype.relation_field for subtype in subtypes if subtype.relation_field is not False])
1652 if not any(relation in updated_fields for relation in relation_fields) and not user_field_lst:
1655 # legacy behavior: if values is not given, compute the values by browsing
1656 # @TDENOTE: remove me in 8.0
1658 record = self.browse(cr, uid, ids[0], context=context)
1659 for updated_field in updated_fields:
1660 field_value = getattr(record, updated_field)
1661 if isinstance(field_value, browse_record):
1662 field_value = field_value.id
1663 elif isinstance(field_value, browse_null):
1665 values[updated_field] = field_value
1667 # find followers of headers, update structure for new followers
1669 for subtype in subtypes:
1670 if subtype.relation_field and values.get(subtype.relation_field):
1671 headers.add((subtype.res_model, values.get(subtype.relation_field)))
1673 header_domain = ['|'] * (len(headers) - 1)
1674 for header in headers:
1675 header_domain += ['&', ('res_model', '=', header[0]), ('res_id', '=', header[1])]
1676 header_follower_ids = follower_obj.search(
1681 for header_follower in follower_obj.browse(cr, SUPERUSER_ID, header_follower_ids, context=context):
1682 for subtype in header_follower.subtype_ids:
1683 if subtype.parent_id and subtype.parent_id.res_model == self._name:
1684 new_followers.setdefault(header_follower.partner_id.id, set()).add(subtype.parent_id.id)
1685 elif subtype.res_model is False:
1686 new_followers.setdefault(header_follower.partner_id.id, set()).add(subtype.id)
1688 # add followers coming from res.users relational fields that are tracked
1689 user_ids = [values[name] for name in user_field_lst if values.get(name)]
1690 user_pids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, SUPERUSER_ID, user_ids, context=context)]
1691 for partner_id in user_pids:
1692 new_followers.setdefault(partner_id, None)
1694 for pid, subtypes in new_followers.items():
1695 subtypes = list(subtypes) if subtypes is not None else None
1696 self.message_subscribe(cr, uid, ids, [pid], subtypes, context=context)
1698 # find first email message, set it as unread for auto_subscribe fields for them to have a notification
1700 for record_id in ids:
1701 message_obj = self.pool.get('mail.message')
1702 msg_ids = message_obj.search(cr, SUPERUSER_ID, [
1703 ('model', '=', self._name),
1704 ('res_id', '=', record_id),
1705 ('type', '=', 'email')], limit=1, context=context)
1707 msg_ids = message_obj.search(cr, SUPERUSER_ID, [
1708 ('model', '=', self._name),
1709 ('res_id', '=', record_id)], limit=1, context=context)
1711 self.pool.get('mail.notification')._notify(cr, uid, msg_ids[0], partners_to_notify=user_pids, context=context)
1715 #------------------------------------------------------
1717 #------------------------------------------------------
1719 def message_mark_as_unread(self, cr, uid, ids, context=None):
1720 """ Set as unread. """
1721 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1723 UPDATE mail_notification SET
1726 message_id IN (SELECT id from mail_message where res_id=any(%s) and model=%s limit 1) and
1728 ''', (ids, self._name, partner_id))
1731 def message_mark_as_read(self, cr, uid, ids, context=None):
1732 """ Set as read. """
1733 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1735 UPDATE mail_notification SET
1738 message_id IN (SELECT id FROM mail_message WHERE res_id=ANY(%s) AND model=%s) AND
1740 ''', (ids, self._name, partner_id))
1743 #------------------------------------------------------
1745 #------------------------------------------------------
1747 def get_suggested_thread(self, cr, uid, removed_suggested_threads=None, context=None):
1748 """Return a list of suggested threads, sorted by the numbers of followers"""
1752 # TDE HACK: originally by MAT from portal/mail_mail.py but not working until the inheritance graph bug is not solved in trunk
1753 # TDE FIXME: relocate in portal when it won't be necessary to reload the hr.employee model in an additional bridge module
1754 if self.pool['res.groups']._all_columns.get('is_portal'):
1755 user = self.pool.get('res.users').browse(cr, SUPERUSER_ID, uid, context=context)
1756 if any(group.is_portal for group in user.groups_id):
1760 if removed_suggested_threads is None:
1761 removed_suggested_threads = []
1763 thread_ids = self.search(cr, uid, [('id', 'not in', removed_suggested_threads), ('message_is_follower', '=', False)], context=context)
1764 for thread in self.browse(cr, uid, thread_ids, context=context):
1767 'popularity': len(thread.message_follower_ids),
1768 'name': thread.name,
1769 'image_small': thread.image_small
1771 threads.append(data)
1772 return sorted(threads, key=lambda x: (x['popularity'], x['id']), reverse=True)[:3]