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 ##############################################################################
30 from email.message import Message
32 from openerp import tools
33 from openerp import SUPERUSER_ID
34 from openerp.addons.mail.mail_message import decode
35 from openerp.osv import fields, osv, orm
36 from openerp.tools.safe_eval import safe_eval as eval
37 from openerp.tools.translate import _
39 _logger = logging.getLogger(__name__)
42 def decode_header(message, header, separator=' '):
43 return separator.join(map(decode, filter(None, message.get_all(header, []))))
46 class mail_thread(osv.AbstractModel):
47 ''' mail_thread model is meant to be inherited by any model that needs to
48 act as a discussion topic on which messages can be attached. Public
49 methods are prefixed with ``message_`` in order to avoid name
50 collisions with methods of the models that will inherit from this class.
52 ``mail.thread`` defines fields used to handle and display the
53 communication history. ``mail.thread`` also manages followers of
54 inheriting classes. All features and expected behavior are managed
55 by mail.thread. Widgets has been designed for the 7.0 and following
58 Inheriting classes are not required to implement any method, as the
59 default implementation will work for any model. However it is common
60 to override at least the ``message_new`` and ``message_update``
61 methods (calling ``super``) to add model-specific behavior at
62 creation and update of a thread when processing incoming emails.
65 - _mail_flat_thread: if set to True, all messages without parent_id
66 are automatically attached to the first message posted on the
67 ressource. If set to False, the display of Chatter is done using
68 threads, and no parent_id is automatically set.
71 _description = 'Email Thread'
72 _mail_flat_thread = True
73 _mail_post_access = 'write'
75 # Automatic logging system if mail installed
78 # 'module.subtype_xml': lambda self, cr, uid, obj, context=None: obj[state] == done,
79 # 'module.subtype_xml2': lambda self, cr, uid, obj, context=None: obj[state] != done,
86 # :param string field: field name
87 # :param module.subtype_xml: xml_id of a mail.message.subtype (i.e. mail.mt_comment)
88 # :param obj: is a browse_record
89 # :param function lambda: returns whether the tracking should record using this subtype
92 def get_empty_list_help(self, cr, uid, help, context=None):
93 """ Override of BaseModel.get_empty_list_help() to generate an help message
94 that adds alias information. """
95 model = context.get('empty_list_help_model')
96 res_id = context.get('empty_list_help_id')
97 ir_config_parameter = self.pool.get("ir.config_parameter")
98 catchall_domain = ir_config_parameter.get_param(cr, uid, "mail.catchall.domain", context=context)
99 document_name = context.get('empty_list_help_document_name', _('document'))
102 if catchall_domain and model and res_id: # specific res_id -> find its alias (i.e. section_id specified)
103 object_id = self.pool.get(model).browse(cr, uid, res_id, context=context)
104 # check that the alias effectively creates new records
105 if object_id.alias_id and object_id.alias_id.alias_name and \
106 object_id.alias_id.alias_model_id and \
107 object_id.alias_id.alias_model_id.model == self._name and \
108 object_id.alias_id.alias_force_thread_id == 0:
109 alias = object_id.alias_id
110 elif catchall_domain and model: # no specific res_id given -> generic help message, take an example alias (i.e. alias of some section_id)
111 model_id = self.pool.get('ir.model').search(cr, uid, [("model", "=", self._name)], context=context)[0]
112 alias_obj = self.pool.get('mail.alias')
113 alias_ids = alias_obj.search(cr, uid, [("alias_model_id", "=", model_id), ("alias_name", "!=", False), ('alias_force_thread_id', '=', 0)], context=context, order='id ASC')
114 if alias_ids and len(alias_ids) == 1: # if several aliases -> incoherent to propose one guessed from nowhere, therefore avoid if several aliases
115 alias = alias_obj.browse(cr, uid, alias_ids[0], context=context)
118 alias_email = alias.name_get()[0][1]
119 return _("""<p class='oe_view_nocontent_create'>
120 Click here to add new %(document)s or send an email to: <a href='mailto:%(email)s'>%(email)s</a>
124 'document': document_name,
125 'email': alias_email,
126 'static_help': help or ''
129 if document_name != 'document' and help and help.find("oe_view_nocontent_create") == -1:
130 return _("<p class='oe_view_nocontent_create'>Click here to add new %(document)s</p>%(static_help)s") % {
131 'document': document_name,
132 'static_help': help or '',
137 def _get_message_data(self, cr, uid, ids, name, args, context=None):
139 - message_unread: has uid unread message for the document
140 - message_summary: html snippet summarizing the Chatter for kanban views """
141 res = dict((id, dict(message_unread=False, message_unread_count=0, message_summary=' ')) for id in ids)
142 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
144 # search for unread messages, directly in SQL to improve performances
145 cr.execute(""" SELECT m.res_id FROM mail_message m
146 RIGHT JOIN mail_notification n
147 ON (n.message_id = m.id AND n.partner_id = %s AND (n.read = False or n.read IS NULL))
148 WHERE m.model = %s AND m.res_id in %s""",
149 (user_pid, self._name, tuple(ids),))
150 for result in cr.fetchall():
151 res[result[0]]['message_unread'] = True
152 res[result[0]]['message_unread_count'] += 1
155 if res[id]['message_unread_count']:
156 title = res[id]['message_unread_count'] > 1 and _("You have %d unread messages") % res[id]['message_unread_count'] or _("You have one unread message")
157 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"))
160 def read_followers_data(self, cr, uid, follower_ids, context=None):
162 technical_group = self.pool.get('ir.model.data').get_object(cr, uid, 'base', 'group_no_one')
163 for follower in self.pool.get('res.partner').browse(cr, uid, follower_ids, context=context):
164 is_editable = uid in map(lambda x: x.id, technical_group.users)
165 is_uid = uid in map(lambda x: x.id, follower.user_ids)
168 {'is_editable': is_editable, 'is_uid': is_uid},
173 def _get_subscription_data(self, cr, uid, ids, name, args, user_pid=None, context=None):
175 - message_subtype_data: data about document subtypes: which are
176 available, which are followed if any """
177 res = dict((id, dict(message_subtype_data='')) for id in ids)
179 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
181 # find current model subtypes, add them to a dictionary
182 subtype_obj = self.pool.get('mail.message.subtype')
183 subtype_ids = subtype_obj.search(cr, uid, ['|', ('res_model', '=', self._name), ('res_model', '=', False)], context=context)
184 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))
186 res[id]['message_subtype_data'] = subtype_dict.copy()
188 # find the document followers, update the data
189 fol_obj = self.pool.get('mail.followers')
190 fol_ids = fol_obj.search(cr, uid, [
191 ('partner_id', '=', user_pid),
192 ('res_id', 'in', ids),
193 ('res_model', '=', self._name),
195 for fol in fol_obj.browse(cr, uid, fol_ids, context=context):
196 thread_subtype_dict = res[fol.res_id]['message_subtype_data']
197 for subtype in fol.subtype_ids:
198 thread_subtype_dict[subtype.name]['followed'] = True
199 res[fol.res_id]['message_subtype_data'] = thread_subtype_dict
203 def _search_message_unread(self, cr, uid, obj=None, name=None, domain=None, context=None):
204 return [('message_ids.to_read', '=', True)]
206 def _get_followers(self, cr, uid, ids, name, arg, context=None):
207 fol_obj = self.pool.get('mail.followers')
208 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids)])
209 res = dict((id, dict(message_follower_ids=[], message_is_follower=False)) for id in ids)
210 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
211 for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids):
212 res[fol.res_id]['message_follower_ids'].append(fol.partner_id.id)
213 if fol.partner_id.id == user_pid:
214 res[fol.res_id]['message_is_follower'] = True
217 def _set_followers(self, cr, uid, id, name, value, arg, context=None):
220 partner_obj = self.pool.get('res.partner')
221 fol_obj = self.pool.get('mail.followers')
223 # read the old set of followers, and determine the new set of followers
224 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', '=', id)])
225 old = set(fol.partner_id.id for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids))
228 for command in value or []:
229 if isinstance(command, (int, long)):
231 elif command[0] == 0:
232 new.add(partner_obj.create(cr, uid, command[2], context=context))
233 elif command[0] == 1:
234 partner_obj.write(cr, uid, [command[1]], command[2], context=context)
236 elif command[0] == 2:
237 partner_obj.unlink(cr, uid, [command[1]], context=context)
238 new.discard(command[1])
239 elif command[0] == 3:
240 new.discard(command[1])
241 elif command[0] == 4:
243 elif command[0] == 5:
245 elif command[0] == 6:
246 new = set(command[2])
248 # remove partners that are no longer followers
249 fol_ids = fol_obj.search(cr, SUPERUSER_ID,
250 [('res_model', '=', self._name), ('res_id', '=', id), ('partner_id', 'not in', list(new))])
251 fol_obj.unlink(cr, SUPERUSER_ID, fol_ids)
254 for partner_id in new - old:
255 fol_obj.create(cr, SUPERUSER_ID, {'res_model': self._name, 'res_id': id, 'partner_id': partner_id})
257 def _search_followers(self, cr, uid, obj, name, args, context):
258 """Search function for message_follower_ids
260 Do not use with operator 'not in'. Use instead message_is_followers
262 fol_obj = self.pool.get('mail.followers')
264 for field, operator, value in args:
266 # TOFIX make it work with not in
267 assert operator != "not in", "Do not search message_follower_ids with 'not in'"
268 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('partner_id', operator, value)])
269 res_ids = [fol.res_id for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids)]
270 res.append(('id', 'in', res_ids))
273 def _search_is_follower(self, cr, uid, obj, name, args, context):
274 """Search function for message_is_follower"""
276 for field, operator, value in args:
278 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
279 if (operator == '=' and value) or (operator == '!=' and not value): # is a follower
280 res_ids = self.search(cr, uid, [('message_follower_ids', 'in', [partner_id])], context=context)
281 else: # is not a follower or unknown domain
282 mail_ids = self.search(cr, uid, [('message_follower_ids', 'in', [partner_id])], context=context)
283 res_ids = self.search(cr, uid, [('id', 'not in', mail_ids)], context=context)
284 res.append(('id', 'in', res_ids))
288 'message_is_follower': fields.function(_get_followers, type='boolean',
289 fnct_search=_search_is_follower, string='Is a Follower', multi='_get_followers,'),
290 'message_follower_ids': fields.function(_get_followers, fnct_inv=_set_followers,
291 fnct_search=_search_followers, type='many2many',
292 obj='res.partner', string='Followers', multi='_get_followers'),
293 'message_ids': fields.one2many('mail.message', 'res_id',
294 domain=lambda self: [('model', '=', self._name)],
297 help="Messages and communication history"),
298 'message_unread': fields.function(_get_message_data,
299 fnct_search=_search_message_unread, multi="_get_message_data",
300 type='boolean', string='Unread Messages',
301 help="If checked new messages require your attention."),
302 'message_summary': fields.function(_get_message_data, method=True,
303 type='text', string='Summary', multi="_get_message_data",
304 help="Holds the Chatter summary (number of messages, ...). "\
305 "This summary is directly in html format in order to "\
306 "be inserted in kanban views."),
309 #------------------------------------------------------
310 # CRUD overrides for automatic subscription and logging
311 #------------------------------------------------------
313 def create(self, cr, uid, values, context=None):
314 """ Chatter override :
316 - subscribe followers of parent
317 - log a creation message
321 thread_id = super(mail_thread, self).create(cr, uid, values, context=context)
323 # automatic logging unless asked not to (mainly for various testing purpose)
324 if not context.get('mail_create_nolog'):
325 self.message_post(cr, uid, thread_id, body=_('%s created') % (self._description), context=context)
327 # subscribe uid unless asked not to
328 if not context.get('mail_create_nosubscribe'):
329 self.message_subscribe_users(cr, uid, [thread_id], [uid], context=context)
330 # auto_subscribe: take values and defaults into account
331 create_values = set(values.keys())
332 for key, val in context.iteritems():
333 if key.startswith('default_'):
334 create_values.add(key[8:])
335 self.message_auto_subscribe(cr, uid, [thread_id], list(create_values), context=context)
338 tracked_fields = self._get_tracked_fields(cr, uid, values.keys(), context=context)
340 initial_values = {thread_id: dict((item, False) for item in tracked_fields)}
341 self.message_track(cr, uid, [thread_id], tracked_fields, initial_values, context=context)
345 def write(self, cr, uid, ids, values, context=None):
346 if isinstance(ids, (int, long)):
348 # Track initial values of tracked fields
349 tracked_fields = self._get_tracked_fields(cr, uid, values.keys(), context=context)
351 records = self.browse(cr, uid, ids, context=context)
352 initial_values = dict((this.id, dict((key, getattr(this, key)) for key in tracked_fields.keys())) for this in records)
354 # Perform write, update followers
355 result = super(mail_thread, self).write(cr, uid, ids, values, context=context)
356 self.message_auto_subscribe(cr, uid, ids, values.keys(), context=context)
358 # Perform the tracking
360 self.message_track(cr, uid, ids, tracked_fields, initial_values, context=context)
363 def unlink(self, cr, uid, ids, context=None):
364 """ Override unlink to delete messages and followers. This cannot be
365 cascaded, because link is done through (res_model, res_id). """
366 msg_obj = self.pool.get('mail.message')
367 fol_obj = self.pool.get('mail.followers')
368 # delete messages and notifications
369 msg_ids = msg_obj.search(cr, uid, [('model', '=', self._name), ('res_id', 'in', ids)], context=context)
370 msg_obj.unlink(cr, uid, msg_ids, context=context)
372 res = super(mail_thread, self).unlink(cr, uid, ids, context=context)
374 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids)], context=context)
375 fol_obj.unlink(cr, SUPERUSER_ID, fol_ids, context=context)
378 def copy(self, cr, uid, id, default=None, context=None):
379 default = default or {}
380 default['message_ids'] = []
381 default['message_follower_ids'] = []
382 return super(mail_thread, self).copy(cr, uid, id, default=default, context=context)
384 #------------------------------------------------------
385 # Automatically log tracked fields
386 #------------------------------------------------------
388 def _get_tracked_fields(self, cr, uid, updated_fields, context=None):
389 """ Return a structure of tracked fields for the current model.
390 :param list updated_fields: modified field names
391 :return list: a list of (field_name, column_info obj), containing
392 always tracked fields and modified on_change fields
395 for name, column_info in self._all_columns.items():
396 visibility = getattr(column_info.column, 'track_visibility', False)
397 if visibility == 'always' or (visibility == 'onchange' and name in updated_fields) or name in self._track:
401 return self.fields_get(cr, uid, lst, context=context)
403 def message_track(self, cr, uid, ids, tracked_fields, initial_values, context=None):
405 def convert_for_display(value, col_info):
406 if not value and col_info['type'] == 'boolean':
410 if col_info['type'] == 'many2one':
411 return value.name_get()[0][1]
412 if col_info['type'] == 'selection':
413 return dict(col_info['selection'])[value]
416 def format_message(message_description, tracked_values):
418 if message_description:
419 message = '<span>%s</span>' % message_description
420 for name, change in tracked_values.items():
421 message += '<div> • <b>%s</b>: ' % change.get('col_info')
422 if change.get('old_value'):
423 message += '%s → ' % change.get('old_value')
424 message += '%s</div>' % change.get('new_value')
427 if not tracked_fields:
430 for browse_record in self.browse(cr, uid, ids, context=context):
431 initial = initial_values[browse_record.id]
435 # generate tracked_values data structure: {'col_name': {col_info, new_value, old_value}}
436 for col_name, col_info in tracked_fields.items():
437 initial_value = initial[col_name]
438 record_value = getattr(browse_record, col_name)
440 if record_value == initial_value and getattr(self._all_columns[col_name].column, 'track_visibility', None) == 'always':
441 tracked_values[col_name] = dict(col_info=col_info['string'],
442 new_value=convert_for_display(record_value, col_info))
443 elif record_value != initial_value and (record_value or initial_value): # because browse null != False
444 if getattr(self._all_columns[col_name].column, 'track_visibility', None) in ['always', 'onchange']:
445 tracked_values[col_name] = dict(col_info=col_info['string'],
446 old_value=convert_for_display(initial_value, col_info),
447 new_value=convert_for_display(record_value, col_info))
448 if col_name in tracked_fields:
449 changes.add(col_name)
453 # find subtypes and post messages or log if no subtype found
455 for field, track_info in self._track.items():
456 if field not in changes:
458 for subtype, method in track_info.items():
459 if method(self, cr, uid, browse_record, context):
460 subtypes.append(subtype)
463 for subtype in subtypes:
465 subtype_rec = self.pool.get('ir.model.data').get_object(cr, uid, subtype.split('.')[0], subtype.split('.')[1], context=context)
466 except ValueError, e:
467 _logger.debug('subtype %s not found, giving error "%s"' % (subtype, e))
469 message = format_message(subtype_rec.description if subtype_rec.description else subtype_rec.name, tracked_values)
470 self.message_post(cr, uid, browse_record.id, body=message, subtype=subtype, context=context)
473 message = format_message('', tracked_values)
474 self.message_post(cr, uid, browse_record.id, body=message, context=context)
477 #------------------------------------------------------
478 # mail.message wrappers and tools
479 #------------------------------------------------------
481 def _needaction_domain_get(self, cr, uid, context=None):
483 return [('message_unread', '=', True)]
486 def _garbage_collect_attachments(self, cr, uid, context=None):
487 """ Garbage collect lost mail attachments. Those are attachments
488 - linked to res_model 'mail.compose.message', the composer wizard
489 - with res_id 0, because they were created outside of an existing
490 wizard (typically user input through Chatter or reports
491 created on-the-fly by the templates)
492 - unused since at least one day (create_date and write_date)
494 limit_date = datetime.datetime.utcnow() - datetime.timedelta(days=1)
495 limit_date_str = datetime.datetime.strftime(limit_date, tools.DEFAULT_SERVER_DATETIME_FORMAT)
496 ir_attachment_obj = self.pool.get('ir.attachment')
497 attach_ids = ir_attachment_obj.search(cr, uid, [
498 ('res_model', '=', 'mail.compose.message'),
500 ('create_date', '<', limit_date_str),
501 ('write_date', '<', limit_date_str),
503 ir_attachment_obj.unlink(cr, uid, attach_ids, context=context)
506 def check_mail_message_access(self, cr, uid, mids, operation, model_obj=None, context=None):
507 """ mail.message check permission rules for related document. This method is
508 meant to be inherited in order to implement addons-specific behavior.
509 A common behavior would be to allow creating messages when having read
510 access rule on the document, for portal document such as issues. """
513 if hasattr(self, '_mail_post_access'):
514 create_allow = self._mail_post_access
516 create_allow = 'write'
518 if operation in ['write', 'unlink']:
519 check_operation = 'write'
520 elif operation == 'create' and create_allow in ['create', 'read', 'write', 'unlink']:
521 check_operation = create_allow
522 elif operation == 'create':
523 check_operation = 'write'
525 check_operation = operation
527 model_obj.check_access_rights(cr, uid, check_operation)
528 model_obj.check_access_rule(cr, uid, mids, check_operation, context=context)
530 def _get_formview_action(self, cr, uid, id, model=None, context=None):
531 """ Return an action to open the document. This method is meant to be
532 overridden in addons that want to give specific view ids for example.
534 :param int id: id of the document to open
535 :param string model: specific model that overrides self._name
538 'type': 'ir.actions.act_window',
539 'res_model': model or self._name,
542 'views': [(False, 'form')],
547 def _get_inbox_action_xml_id(self, cr, uid, context=None):
548 """ When redirecting towards the Inbox, choose which action xml_id has
549 to be fetched. This method is meant to be inherited, at least in portal
550 because portal users have a different Inbox action than classic users. """
551 return ('mail', 'action_mail_inbox_feeds')
553 def message_redirect_action(self, cr, uid, context=None):
554 """ For a given message, return an action that either
555 - opens the form view of the related document if model, res_id, and
556 read access to the document
557 - opens the Inbox with a default search on the conversation if model,
559 - opens the Inbox with context propagated
565 # default action is the Inbox action
566 self.pool.get('res.users').browse(cr, SUPERUSER_ID, uid, context=context)
567 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))
568 action = self.pool.get(act_model).read(cr, uid, act_id, [])
570 # if msg_id specified: try to redirect to the document or fallback on the Inbox
571 msg_id = context.get('params', {}).get('message_id')
574 msg = self.pool.get('mail.message').browse(cr, uid, msg_id, context=context)
575 if msg.model and msg.res_id:
578 'search_default_model': msg.model,
579 'search_default_res_id': msg.res_id,
582 if self.pool.get(msg.model).check_access_rights(cr, uid, 'read', raise_exception=False):
584 model_obj = self.pool.get(msg.model)
585 model_obj.check_access_rule(cr, uid, [msg.res_id], 'read', context=context)
586 if not hasattr(model_obj, '_get_formview_action'):
587 action = self.pool.get('mail.thread')._get_formview_action(cr, uid, msg.res_id, model=msg.model, context=context)
589 action = model_obj._get_formview_action(cr, uid, msg.res_id, context=context)
590 except (osv.except_osv, orm.except_orm):
594 #------------------------------------------------------
596 #------------------------------------------------------
598 def message_get_reply_to(self, cr, uid, ids, context=None):
599 """ Returns the preferred reply-to email address that is basically
600 the alias of the document, if it exists. """
601 if not self._inherits.get('mail.alias'):
602 return [False for id in ids]
603 return ["%s@%s" % (record['alias_name'], record['alias_domain'])
604 if record.get('alias_domain') and record.get('alias_name')
606 for record in self.read(cr, SUPERUSER_ID, ids, ['alias_name', 'alias_domain'], context=context)]
608 #------------------------------------------------------
610 #------------------------------------------------------
612 def message_capable_models(self, cr, uid, context=None):
613 """ Used by the plugin addon, based for plugin_outlook and others. """
615 for model_name in self.pool.obj_list():
616 model = self.pool[model_name]
617 if hasattr(model, "message_process") and hasattr(model, "message_post"):
618 ret_dict[model_name] = model._description
621 def _message_find_partners(self, cr, uid, message, header_fields=['From'], context=None):
622 """ Find partners related to some header fields of the message.
624 :param string message: an email.message instance """
625 s = ', '.join([decode(message.get(h)) for h in header_fields if message.get(h)])
626 return filter(lambda x: x, self._find_partner_from_emails(cr, uid, None, tools.email_split(s), context=context))
628 def message_route_verify(self, cr, uid, message, message_dict, route, update_author=True, assert_model=True, create_fallback=True, context=None):
629 """ Verify route validity. Check and rules:
630 1 - if thread_id -> check that document effectively exists; otherwise
631 fallback on a message_new by resetting thread_id
632 2 - check that message_update exists if thread_id is set; or at least
633 that message_new exist
634 [ - find author_id if udpate_author is set]
635 3 - if there is an alias, check alias_contact:
636 'followers' and thread_id:
637 check on target document that the author is in the followers
638 'followers' and alias_parent_thread_id:
639 check on alias parent document that the author is in the
641 'partners': check that author_id id set
644 assert isinstance(route, (list, tuple)), 'A route should be a list or a tuple'
645 assert len(route) == 5, 'A route should contain 5 elements: model, thread_id, custom_values, uid, alias record'
647 message_id = message.get('Message-Id')
648 email_from = decode_header(message, 'From')
649 author_id = message_dict.get('author_id')
650 model, thread_id, alias = route[0], route[1], route[4]
653 def _create_bounce_email():
654 mail_mail = self.pool.get('mail.mail')
655 mail_id = mail_mail.create(cr, uid, {
656 'body_html': '<div><p>Hello,</p>'
657 '<p>The following email sent to %s cannot be accepted because this is '
658 'a private email address. Only allowed people can contact us at this address.</p></div>'
659 '<blockquote>%s</blockquote>' % (message.get('to'), message_dict.get('body')),
660 'subject': 'Re: %s' % message.get('subject'),
661 'email_to': message.get('from'),
664 mail_mail.send(cr, uid, [mail_id], context=context)
667 _logger.warning('Routing mail with Message-Id %s: route %s: %s',
668 message_id, route, message)
671 if model and not model in self.pool:
673 assert model in self.pool, 'Routing: unknown target model %s' % model
674 _warn('unknown target model %s' % model)
677 model_pool = self.pool[model]
679 # Private message: should not contain any thread_id
680 if not model and thread_id:
682 assert thread_id == 0, 'Routing: posting a message without model should be with a null res_id (private message).'
683 _warn('posting a message without model should be with a null res_id (private message), resetting thread_id')
685 # Private message: should have a parent_id (only answers)
686 if not model and not message_dict.get('parent_id'):
688 assert message_dict.get('parent_id'), 'Routing: posting a message without model should be with a parent_id (private mesage).'
689 _warn('posting a message without model should be with a parent_id (private mesage), skipping')
692 # Existing Document: check if exists; if not, fallback on create if allowed
693 if thread_id and not model_pool.exists(cr, uid, thread_id):
695 _warn('reply to missing document (%s,%s), fall back on new document creation' % (model, thread_id))
698 assert model_pool.exists(cr, uid, thread_id), 'Routing: reply to missing document (%s,%s)' % (model, thread_id)
700 _warn('reply to missing document (%s,%s), skipping' % (model, thread_id))
703 # Existing Document: check model accepts the mailgateway
704 if thread_id and model and not hasattr(model_pool, 'message_update'):
706 _warn('model %s does not accept document update, fall back on document creation' % model)
709 assert hasattr(model_pool, 'message_update'), 'Routing: model %s does not accept document update, crashing' % model
711 _warn('model %s does not accept document update, skipping' % model)
714 # New Document: check model accepts the mailgateway
715 if not thread_id and model and not hasattr(model_pool, 'message_new'):
717 assert hasattr(model_pool, 'message_new'), 'Model %s does not accept document creation, crashing' % model
718 _warn('model %s does not accept document creation, skipping' % model)
721 # Update message author if asked
722 # We do it now because we need it for aliases (contact settings)
723 if not author_id and update_author:
724 author_ids = self._find_partner_from_emails(cr, uid, thread_id, [email_from], model=model, context=context)
726 author_id = author_ids[0]
727 message_dict['author_id'] = author_id
729 # Alias: check alias_contact settings
730 if alias and alias.alias_contact == 'followers' and (thread_id or alias.alias_parent_thread_id):
732 obj = self.pool[model].browse(cr, uid, thread_id, context=context)
734 obj = self.pool[alias.alias_parent_model_id.model].browse(cr, uid, alias.alias_parent_thread_id, context=context)
735 if not author_id or not author_id in [fol.id for fol in obj.message_follower_ids]:
736 _warn('alias %s restricted to internal followers, skipping' % alias.alias_name)
737 _create_bounce_email()
739 elif alias and alias.alias_contact == 'partners' and not author_id:
740 _warn('alias %s does not accept unknown author, skipping' % alias.alias_name)
741 _create_bounce_email()
744 return (model, thread_id, route[2], route[3], route[4])
746 def message_route(self, cr, uid, message, message_dict, model=None, thread_id=None,
747 custom_values=None, context=None):
748 """Attempt to figure out the correct target model, thread_id,
749 custom_values and user_id to use for an incoming message.
750 Multiple values may be returned, if a message had multiple
751 recipients matching existing mail.aliases, for example.
753 The following heuristics are used, in this order:
754 1. If the message replies to an existing thread_id, and
755 properly contains the thread model in the 'In-Reply-To'
756 header, use this model/thread_id pair, and ignore
757 custom_value (not needed as no creation will take place)
758 2. Look for a mail.alias entry matching the message
759 recipient, and use the corresponding model, thread_id,
760 custom_values and user_id.
761 3. Fallback to the ``model``, ``thread_id`` and ``custom_values``
763 4. If all the above fails, raise an exception.
765 :param string message: an email.message instance
766 :param dict message_dict: dictionary holding message variables
767 :param string model: the fallback model to use if the message
768 does not match any of the currently configured mail aliases
769 (may be None if a matching alias is supposed to be present)
770 :type dict custom_values: optional dictionary of default field values
771 to pass to ``message_new`` if a new record needs to be created.
772 Ignored if the thread record already exists, and also if a
773 matching mail.alias was found (aliases define their own defaults)
774 :param int thread_id: optional ID of the record/thread from ``model``
775 to which this mail should be attached. Only used if the message
776 does not reply to an existing thread and does not match any mail alias.
777 :return: list of [model, thread_id, custom_values, user_id, alias]
779 assert isinstance(message, Message), 'message must be an email.message.Message at this point'
780 fallback_model = model
781 bounce_alias = self.pool['ir.config_parameter'].get_param(cr, uid, "mail.bounce.alias", context=context)
783 # Get email.message.Message variables for future processing
784 message_id = message.get('Message-Id')
785 email_from = decode_header(message, 'From')
786 email_to = decode_header(message, 'To')
787 references = decode_header(message, 'References')
788 in_reply_to = decode_header(message, 'In-Reply-To')
790 # 0. Verify whether this is a bounced email (wrong destination,...) -> use it to collect data, such as dead leads
791 if bounce_alias in email_to:
792 bounce_match = tools.bounce_re.search(email_to)
794 bounced_mail_id = bounce_match.group(1)
795 if self.pool['mail.mail'].exists(cr, uid, bounced_mail_id):
796 mail = self.pool['mail.mail'].browse(cr, uid, bounced_mail_id, context=context)
797 bounced_model = mail.model
798 bounced_thread_id = mail.res_id
800 bounced_model = bounce_match.group(2)
801 bounced_thread_id = int(bounce_match.group(3)) if bounce_match.group(3) else 0
802 _logger.info('Routing mail from %s to %s with Message-Id %s: bounced mail from mail %s, model: %s, thread_id: %s',
803 email_from, email_to, message_id, bounced_mail_id, bounced_model, bounced_thread_id)
804 if bounced_model and bounced_model in self.pool and hasattr(self.pool[bounced_model], 'message_receive_bounce'):
805 self.pool[bounced_model].message_receive_bounce(cr, uid, [bounced_thread_id], mail_id=bounced_mail_id, context=context)
808 # 1. Verify if this is a reply to an existing thread
809 thread_references = references or in_reply_to
810 ref_match = thread_references and tools.reference_re.search(thread_references)
812 thread_id = int(ref_match.group(1))
813 model = ref_match.group(2) or fallback_model
814 if thread_id and model in self.pool:
815 model_obj = self.pool[model]
816 if model_obj.exists(cr, uid, thread_id) and hasattr(model_obj, 'message_update'):
817 _logger.info('Routing mail from %s to %s with Message-Id %s: direct reply to model: %s, thread_id: %s, custom_values: %s, uid: %s',
818 email_from, email_to, message_id, model, thread_id, custom_values, uid)
819 route = self.message_route_verify(cr, uid, message, message_dict,
820 (model, thread_id, custom_values, uid, None),
821 update_author=True, assert_model=True, create_fallback=True, context=context)
822 return route and [route] or []
824 # 2. Reply to a private message
826 mail_message_ids = self.pool.get('mail.message').search(cr, uid, [
827 ('message_id', '=', in_reply_to),
828 '!', ('message_id', 'ilike', 'reply_to')
829 ], limit=1, context=context)
831 mail_message = self.pool.get('mail.message').browse(cr, uid, mail_message_ids[0], context=context)
832 _logger.info('Routing mail from %s to %s with Message-Id %s: direct reply to a private message: %s, custom_values: %s, uid: %s',
833 email_from, email_to, message_id, mail_message.id, custom_values, uid)
834 route = self.message_route_verify(cr, uid, message, message_dict,
835 (mail_message.model, mail_message.res_id, custom_values, uid, None),
836 update_author=True, assert_model=True, create_fallback=True, context=context)
837 return route and [route] or []
839 # 3. Look for a matching mail.alias entry
840 # Delivered-To is a safe bet in most modern MTAs, but we have to fallback on To + Cc values
841 # for all the odd MTAs out there, as there is no standard header for the envelope's `rcpt_to` value.
843 ','.join([decode_header(message, 'Delivered-To'),
844 decode_header(message, 'To'),
845 decode_header(message, 'Cc'),
846 decode_header(message, 'Resent-To'),
847 decode_header(message, 'Resent-Cc')])
848 local_parts = [e.split('@')[0] for e in tools.email_split(rcpt_tos)]
850 mail_alias = self.pool.get('mail.alias')
851 alias_ids = mail_alias.search(cr, uid, [('alias_name', 'in', local_parts)])
854 for alias in mail_alias.browse(cr, uid, alias_ids, context=context):
855 user_id = alias.alias_user_id.id
857 # TDE note: this could cause crashes, because no clue that the user
858 # that send the email has the right to create or modify a new document
859 # Fallback on user_id = uid
860 # Note: recognized partners will be added as followers anyway
861 # user_id = self._message_find_user_id(cr, uid, message, context=context)
863 _logger.info('No matching user_id for the alias %s', alias.alias_name)
864 route = (alias.alias_model_id.model, alias.alias_force_thread_id, eval(alias.alias_defaults), user_id, alias)
865 _logger.info('Routing mail from %s to %s with Message-Id %s: direct alias match: %r',
866 email_from, email_to, message_id, route)
867 route = self.message_route_verify(cr, uid, message, message_dict, route,
868 update_author=True, assert_model=True, create_fallback=True, context=context)
873 # 4. Fallback to the provided parameters, if they work
875 # Legacy: fallback to matching [ID] in the Subject
876 match = tools.res_re.search(decode_header(message, 'Subject'))
877 thread_id = match and match.group(1)
878 # Convert into int (bug spotted in 7.0 because of str)
880 thread_id = int(thread_id)
883 _logger.info('Routing mail from %s to %s with Message-Id %s: fallback to model:%s, thread_id:%s, custom_values:%s, uid:%s',
884 email_from, email_to, message_id, fallback_model, thread_id, custom_values, uid)
885 route = self.message_route_verify(cr, uid, message, message_dict,
886 (fallback_model, thread_id, custom_values, uid, None),
887 update_author=True, assert_model=True, context=context)
891 # AssertionError if no routes found and if no bounce occured
893 "No possible route found for incoming message from %s to %s (Message-Id %s:)." \
894 "Create an appropriate mail.alias or force the destination model." % (email_from, email_to, message_id)
896 def message_process(self, cr, uid, model, message, custom_values=None,
897 save_original=False, strip_attachments=False,
898 thread_id=None, context=None):
899 """ Process an incoming RFC2822 email message, relying on
900 ``mail.message.parse()`` for the parsing operation,
901 and ``message_route()`` to figure out the target model.
903 Once the target model is known, its ``message_new`` method
904 is called with the new message (if the thread record did not exist)
905 or its ``message_update`` method (if it did).
907 There is a special case where the target model is False: a reply
908 to a private message. In this case, we skip the message_new /
909 message_update step, to just post a new message using mail_thread
912 :param string model: the fallback model to use if the message
913 does not match any of the currently configured mail aliases
914 (may be None if a matching alias is supposed to be present)
915 :param message: source of the RFC2822 message
916 :type message: string or xmlrpclib.Binary
917 :type dict custom_values: optional dictionary of field values
918 to pass to ``message_new`` if a new record needs to be created.
919 Ignored if the thread record already exists, and also if a
920 matching mail.alias was found (aliases define their own defaults)
921 :param bool save_original: whether to keep a copy of the original
922 email source attached to the message after it is imported.
923 :param bool strip_attachments: whether to strip all attachments
924 before processing the message, in order to save some space.
925 :param int thread_id: optional ID of the record/thread from ``model``
926 to which this mail should be attached. When provided, this
927 overrides the automatic detection based on the message
933 # extract message bytes - we are forced to pass the message as binary because
934 # we don't know its encoding until we parse its headers and hence can't
935 # convert it to utf-8 for transport between the mailgate script and here.
936 if isinstance(message, xmlrpclib.Binary):
937 message = str(message.data)
938 # Warning: message_from_string doesn't always work correctly on unicode,
939 # we must use utf-8 strings here :-(
940 if isinstance(message, unicode):
941 message = message.encode('utf-8')
942 msg_txt = email.message_from_string(message)
944 # parse the message, verify we are not in a loop by checking message_id is not duplicated
945 msg = self.message_parse(cr, uid, msg_txt, save_original=save_original, context=context)
946 if strip_attachments:
947 msg.pop('attachments', None)
948 # postpone setting msg.partner_ids after message_post, to avoid double notifications
949 partner_ids = msg.pop('partner_ids', [])
950 if msg.get('message_id'): # should always be True as message_parse generate one if missing
951 existing_msg_ids = self.pool.get('mail.message').search(cr, SUPERUSER_ID, [
952 ('message_id', '=', msg.get('message_id')),
955 _logger.info('Ignored mail from %s to %s with Message-Id %s: found duplicated Message-Id during processing',
956 msg.get('from'), msg.get('to'), msg.get('message_id'))
959 # find possible routes for the message
960 routes = self.message_route(cr, uid, msg_txt, msg, model, thread_id, custom_values, context=context)
962 for model, thread_id, custom_values, user_id, alias in routes:
963 if self._name == 'mail.thread':
964 context.update({'thread_model': model})
966 model_pool = self.pool[model]
967 assert thread_id and hasattr(model_pool, 'message_update') or hasattr(model_pool, 'message_new'), \
968 "Undeliverable mail with Message-Id %s, model %s does not accept incoming emails" % \
969 (msg['message_id'], model)
971 # disabled subscriptions during message_new/update to avoid having the system user running the
972 # email gateway become a follower of all inbound messages
973 nosub_ctx = dict(context, mail_create_nosubscribe=True, mail_create_nolog=True)
974 if thread_id and hasattr(model_pool, 'message_update'):
975 model_pool.message_update(cr, user_id, [thread_id], msg, context=nosub_ctx)
977 thread_id = model_pool.message_new(cr, user_id, msg, custom_values, context=nosub_ctx)
979 assert thread_id == 0, "Posting a message without model should be with a null res_id, to create a private message."
980 model_pool = self.pool.get('mail.thread')
981 if not hasattr(model_pool, 'message_post'):
982 context['thread_model'] = model
983 model_pool = self.pool['mail.thread']
984 new_msg_id = model_pool.message_post(cr, uid, [thread_id], context=context, subtype='mail.mt_comment', **msg)
987 # postponed after message_post, because this is an external message and we don't want to create
988 # duplicate emails due to notifications
989 self.pool.get('mail.message').write(cr, uid, [new_msg_id], {'partner_ids': partner_ids}, context=context)
993 def message_new(self, cr, uid, msg_dict, custom_values=None, context=None):
994 """Called by ``message_process`` when a new message is received
995 for a given thread model, if the message did not belong to
997 The default behavior is to create a new record of the corresponding
998 model (based on some very basic info extracted from the message).
999 Additional behavior may be implemented by overriding this method.
1001 :param dict msg_dict: a map containing the email details and
1002 attachments. See ``message_process`` and
1003 ``mail.message.parse`` for details.
1004 :param dict custom_values: optional dictionary of additional
1005 field values to pass to create()
1006 when creating the new thread record.
1007 Be careful, these values may override
1008 any other values coming from the message.
1009 :param dict context: if a ``thread_model`` value is present
1010 in the context, its value will be used
1011 to determine the model of the record
1012 to create (instead of the current model).
1014 :return: the id of the newly created thread object
1019 if isinstance(custom_values, dict):
1020 data = custom_values.copy()
1021 model = context.get('thread_model') or self._name
1022 model_pool = self.pool[model]
1023 fields = model_pool.fields_get(cr, uid, context=context)
1024 if 'name' in fields and not data.get('name'):
1025 data['name'] = msg_dict.get('subject', '')
1026 res_id = model_pool.create(cr, uid, data, context=context)
1029 def message_update(self, cr, uid, ids, msg_dict, update_vals=None, context=None):
1030 """Called by ``message_process`` when a new message is received
1031 for an existing thread. The default behavior is to update the record
1032 with update_vals taken from the incoming email.
1033 Additional behavior may be implemented by overriding this
1035 :param dict msg_dict: a map containing the email details and
1036 attachments. See ``message_process`` and
1037 ``mail.message.parse()`` for details.
1038 :param dict update_vals: a dict containing values to update records
1039 given their ids; if the dict is None or is
1040 void, no write operation is performed.
1043 self.write(cr, uid, ids, update_vals, context=context)
1046 def message_receive_bounce(self, cr, uid, ids, mail_id=None, context=None):
1047 """Called by ``message_process`` when a bounce email (such as Undelivered
1048 Mail Returned to Sender) is received for an existing thread. The default
1049 behavior is to check is an integer ``message_bounce`` column exists.
1050 If it is the case, its content is incremented. """
1051 if self._all_columns.get('message_bounce'):
1052 for obj in self.browse(cr, uid, ids, context=context):
1053 self.write(cr, uid, [obj.id], {'message_bounce': obj.message_bounce + 1}, context=context)
1055 def _message_extract_payload(self, message, save_original=False):
1056 """Extract body as HTML and attachments from the mail message"""
1060 attachments.append(('original_email.eml', message.as_string()))
1061 if not message.is_multipart() or 'text/' in message.get('content-type', ''):
1062 encoding = message.get_content_charset()
1063 body = message.get_payload(decode=True)
1064 body = tools.ustr(body, encoding, errors='replace')
1065 if message.get_content_type() == 'text/plain':
1066 # text/plain -> <pre/>
1067 body = tools.append_content_to_html(u'', body, preserve=True)
1070 for part in message.walk():
1071 if part.get_content_type() == 'multipart/alternative':
1073 if part.get_content_maintype() == 'multipart':
1074 continue # skip container
1075 filename = part.get_filename() # None if normal part
1076 encoding = part.get_content_charset() # None if attachment
1077 # 1) Explicit Attachments -> attachments
1078 if filename or part.get('content-disposition', '').strip().startswith('attachment'):
1079 attachments.append((filename or 'attachment', part.get_payload(decode=True)))
1081 # 2) text/plain -> <pre/>
1082 if part.get_content_type() == 'text/plain' and (not alternative or not body):
1083 body = tools.append_content_to_html(body, tools.ustr(part.get_payload(decode=True),
1084 encoding, errors='replace'), preserve=True)
1085 # 3) text/html -> raw
1086 elif part.get_content_type() == 'text/html':
1087 html = tools.ustr(part.get_payload(decode=True), encoding, errors='replace')
1091 body = tools.append_content_to_html(body, html, plaintext=False)
1092 # 4) Anything else -> attachment
1094 attachments.append((filename or 'attachment', part.get_payload(decode=True)))
1095 return body, attachments
1097 def message_parse(self, cr, uid, message, save_original=False, context=None):
1098 """Parses a string or email.message.Message representing an
1099 RFC-2822 email, and returns a generic dict holding the
1102 :param message: the message to parse
1103 :type message: email.message.Message | string | unicode
1104 :param bool save_original: whether the returned dict
1105 should include an ``original`` attachment containing
1106 the source of the message
1108 :return: A dict with the following structure, where each
1109 field may not be present if missing in original
1112 { 'message_id': msg_id,
1117 'body': unified_body,
1118 'attachments': [('file1', 'bytes'),
1125 if not isinstance(message, Message):
1126 if isinstance(message, unicode):
1127 # Warning: message_from_string doesn't always work correctly on unicode,
1128 # we must use utf-8 strings here :-(
1129 message = message.encode('utf-8')
1130 message = email.message_from_string(message)
1132 message_id = message['message-id']
1134 # Very unusual situation, be we should be fault-tolerant here
1135 message_id = "<%s@localhost>" % time.time()
1136 _logger.debug('Parsing Message without message-id, generating a random one: %s', message_id)
1137 msg_dict['message_id'] = message_id
1139 if message.get('Subject'):
1140 msg_dict['subject'] = decode(message.get('Subject'))
1142 # Envelope fields not stored in mail.message but made available for message_new()
1143 msg_dict['from'] = decode(message.get('from'))
1144 msg_dict['to'] = decode(message.get('to'))
1145 msg_dict['cc'] = decode(message.get('cc'))
1146 msg_dict['email_from'] = decode(message.get('from'))
1147 partner_ids = self._message_find_partners(cr, uid, message, ['To', 'Cc'], context=context)
1148 msg_dict['partner_ids'] = [(4, partner_id) for partner_id in partner_ids]
1150 if message.get('Date'):
1152 date_hdr = decode(message.get('Date'))
1153 parsed_date = dateutil.parser.parse(date_hdr, fuzzy=True)
1154 if parsed_date.utcoffset() is None:
1155 # naive datetime, so we arbitrarily decide to make it
1156 # UTC, there's no better choice. Should not happen,
1157 # as RFC2822 requires timezone offset in Date headers.
1158 stored_date = parsed_date.replace(tzinfo=pytz.utc)
1160 stored_date = parsed_date.astimezone(tz=pytz.utc)
1162 _logger.warning('Failed to parse Date header %r in incoming mail '
1163 'with message-id %r, assuming current date/time.',
1164 message.get('Date'), message_id)
1165 stored_date = datetime.datetime.now()
1166 msg_dict['date'] = stored_date.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT)
1168 if message.get('In-Reply-To'):
1169 parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', '=', decode(message['In-Reply-To']))])
1171 msg_dict['parent_id'] = parent_ids[0]
1173 if message.get('References') and 'parent_id' not in msg_dict:
1174 parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', 'in',
1175 [x.strip() for x in decode(message['References']).split()])])
1177 msg_dict['parent_id'] = parent_ids[0]
1179 msg_dict['body'], msg_dict['attachments'] = self._message_extract_payload(message, save_original=save_original)
1182 #------------------------------------------------------
1184 #------------------------------------------------------
1186 def log(self, cr, uid, id, message, secondary=False, context=None):
1187 _logger.warning("log() is deprecated. As this module inherit from "\
1188 "mail.thread, the message will be managed by this "\
1189 "module instead of by the res.log mechanism. Please "\
1190 "use mail_thread.message_post() instead of the "\
1191 "now deprecated res.log.")
1192 self.message_post(cr, uid, [id], message, context=context)
1194 def _message_add_suggested_recipient(self, cr, uid, result, obj, partner=None, email=None, reason='', context=None):
1195 """ Called by message_get_suggested_recipients, to add a suggested
1196 recipient in the result dictionary. The form is :
1197 partner_id, partner_name<partner_email> or partner_name, reason """
1198 if email and not partner:
1199 # get partner info from email
1200 partner_info = self.message_partner_info_from_emails(cr, uid, obj.id, [email], context=context)[0]
1201 if partner_info.get('partner_id'):
1202 partner = self.pool.get('res.partner').browse(cr, SUPERUSER_ID, [partner_info.get('partner_id')], context=context)[0]
1203 if email and email in [val[1] for val in result[obj.id]]: # already existing email -> skip
1205 if partner and partner in obj.message_follower_ids: # recipient already in the followers -> skip
1207 if partner and partner in [val[0] for val in result[obj.id]]: # already existing partner ID -> skip
1209 if partner and partner.email: # complete profile: id, name <email>
1210 result[obj.id].append((partner.id, '%s<%s>' % (partner.name, partner.email), reason))
1211 elif partner: # incomplete profile: id, name
1212 result[obj.id].append((partner.id, '%s' % (partner.name), reason))
1213 else: # unknown partner, we are probably managing an email address
1214 result[obj.id].append((False, email, reason))
1217 def message_get_suggested_recipients(self, cr, uid, ids, context=None):
1218 """ Returns suggested recipients for ids. Those are a list of
1219 tuple (partner_id, partner_name, reason), to be managed by Chatter. """
1220 result = dict.fromkeys(ids, list())
1221 if self._all_columns.get('user_id'):
1222 for obj in self.browse(cr, SUPERUSER_ID, ids, context=context): # SUPERUSER because of a read on res.users that would crash otherwise
1223 if not obj.user_id or not obj.user_id.partner_id:
1225 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)
1228 def _find_partner_from_emails(self, cr, uid, id, emails, model=None, context=None, check_followers=True):
1229 """ Utility method to find partners from email addresses. The rules are :
1230 1 - check in document (model | self, id) followers
1231 2 - try to find a matching partner that is also an user
1232 3 - try to find a matching partner
1234 :param list emails: list of email addresses
1235 :param string model: model to fetch related record; by default self
1237 :param boolean check_followers: check in document followers
1239 partner_obj = self.pool['res.partner']
1242 if id and (model or self._name != 'mail.thread') and check_followers:
1244 obj = self.pool[model].browse(cr, uid, id, context=context)
1246 obj = self.browse(cr, uid, id, context=context)
1247 for contact in emails:
1249 email_address = tools.email_split(contact)
1250 if not email_address:
1251 partner_ids.append(partner_id)
1253 email_address = email_address[0]
1254 # first try: check in document's followers
1256 for follower in obj.message_follower_ids:
1257 if follower.email == email_address:
1258 partner_id = follower.id
1259 # second try: check in partners that are also users
1261 ids = partner_obj.search(cr, SUPERUSER_ID, [
1262 ('email', 'ilike', email_address),
1263 ('user_ids', '!=', False)
1264 ], limit=1, context=context)
1267 # third try: check in partners
1269 ids = partner_obj.search(cr, SUPERUSER_ID, [
1270 ('email', 'ilike', email_address)
1271 ], limit=1, context=context)
1274 partner_ids.append(partner_id)
1277 def message_partner_info_from_emails(self, cr, uid, id, emails, link_mail=False, context=None):
1278 """ Convert a list of emails into a list partner_ids and a list
1279 new_partner_ids. The return value is non conventional because
1280 it is meant to be used by the mail widget.
1282 :return dict: partner_ids and new_partner_ids """
1283 mail_message_obj = self.pool.get('mail.message')
1284 partner_ids = self._find_partner_from_emails(cr, uid, id, emails, context=context)
1286 for idx in range(len(emails)):
1287 email_address = emails[idx]
1288 partner_id = partner_ids[idx]
1289 partner_info = {'full_name': email_address, 'partner_id': partner_id}
1290 result.append(partner_info)
1292 # link mail with this from mail to the new partner id
1293 if link_mail and partner_info['partner_id']:
1294 message_ids = mail_message_obj.search(cr, SUPERUSER_ID, [
1296 ('email_from', '=', email_address),
1297 ('email_from', 'ilike', '<%s>' % email_address),
1298 ('author_id', '=', False)
1301 mail_message_obj.write(cr, SUPERUSER_ID, message_ids, {'author_id': partner_info['partner_id']}, context=context)
1304 def message_post(self, cr, uid, thread_id, body='', subject=None, type='notification',
1305 subtype=None, parent_id=False, attachments=None, context=None,
1306 content_subtype='html', **kwargs):
1307 """ Post a new message in an existing thread, returning the new
1310 :param int thread_id: thread ID to post into, or list with one ID;
1311 if False/0, mail.message model will also be set as False
1312 :param str body: body of the message, usually raw HTML that will
1314 :param str type: see mail_message.type field
1315 :param str content_subtype:: if plaintext: convert body into html
1316 :param int parent_id: handle reply to a previous message by adding the
1317 parent partners to the message in case of private discussion
1318 :param tuple(str,str) attachments or list id: list of attachment tuples in the form
1319 ``(name,content)``, where content is NOT base64 encoded
1321 Extra keyword arguments will be used as default column values for the
1322 new mail.message record. Special cases:
1323 - attachment_ids: supposed not attached to any document; attach them
1324 to the related document. Should only be set by Chatter.
1325 :return int: ID of newly created mail.message
1329 if attachments is None:
1331 mail_message = self.pool.get('mail.message')
1332 ir_attachment = self.pool.get('ir.attachment')
1334 assert (not thread_id) or \
1335 isinstance(thread_id, (int, long)) or \
1336 (isinstance(thread_id, (list, tuple)) and len(thread_id) == 1), \
1337 "Invalid thread_id; should be 0, False, an ID or a list with one ID"
1338 if isinstance(thread_id, (list, tuple)):
1339 thread_id = thread_id[0]
1341 # if we're processing a message directly coming from the gateway, the destination model was
1342 # set in the context.
1345 model = context.get('thread_model', self._name) if self._name == 'mail.thread' else self._name
1346 if model != self._name and hasattr(self.pool[model], 'message_post'):
1347 del context['thread_model']
1348 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)
1350 #0: Find the message's author, because we need it for private discussion
1351 author_id = kwargs.get('author_id')
1352 if author_id is None: # keep False values
1353 author_id = self.pool.get('mail.message')._get_default_author(cr, uid, context=context)
1355 # 1: Handle content subtype: if plaintext, converto into HTML
1356 if content_subtype == 'plaintext':
1357 body = tools.plaintext2html(body)
1359 # 2: Private message: add recipients (recipients and author of parent message) - current author
1360 # + legacy-code management (! we manage only 4 and 6 commands)
1362 kwargs_partner_ids = kwargs.pop('partner_ids', [])
1363 for partner_id in kwargs_partner_ids:
1364 if isinstance(partner_id, (list, tuple)) and partner_id[0] == 4 and len(partner_id) == 2:
1365 partner_ids.add(partner_id[1])
1366 if isinstance(partner_id, (list, tuple)) and partner_id[0] == 6 and len(partner_id) == 3:
1367 partner_ids |= set(partner_id[2])
1368 elif isinstance(partner_id, (int, long)):
1369 partner_ids.add(partner_id)
1371 pass # we do not manage anything else
1372 if parent_id and not model:
1373 parent_message = mail_message.browse(cr, uid, parent_id, context=context)
1374 private_followers = set([partner.id for partner in parent_message.partner_ids])
1375 if parent_message.author_id:
1376 private_followers.add(parent_message.author_id.id)
1377 private_followers -= set([author_id])
1378 partner_ids |= private_followers
1381 # - HACK TDE FIXME: Chatter: attachments linked to the document (not done JS-side), load the message
1382 attachment_ids = kwargs.pop('attachment_ids', []) or [] # because we could receive None (some old code sends None)
1384 filtered_attachment_ids = ir_attachment.search(cr, SUPERUSER_ID, [
1385 ('res_model', '=', 'mail.compose.message'),
1386 ('create_uid', '=', uid),
1387 ('id', 'in', attachment_ids)], context=context)
1388 if filtered_attachment_ids:
1389 ir_attachment.write(cr, SUPERUSER_ID, filtered_attachment_ids, {'res_model': model, 'res_id': thread_id}, context=context)
1390 attachment_ids = [(4, id) for id in attachment_ids]
1391 # Handle attachments parameter, that is a dictionary of attachments
1392 for name, content in attachments:
1393 if isinstance(content, unicode):
1394 content = content.encode('utf-8')
1397 'datas': base64.b64encode(str(content)),
1398 'datas_fname': name,
1399 'description': name,
1401 'res_id': thread_id,
1403 attachment_ids.append((0, 0, data_attach))
1405 # 4: mail.message.subtype
1408 if '.' not in subtype:
1409 subtype = 'mail.%s' % subtype
1410 ref = self.pool.get('ir.model.data').get_object_reference(cr, uid, *subtype.split('.'))
1411 subtype_id = ref and ref[1] or False
1413 # automatically subscribe recipients if asked to
1414 if context.get('mail_post_autofollow') and thread_id and partner_ids:
1415 partner_to_subscribe = partner_ids
1416 if context.get('mail_post_autofollow_partner_ids'):
1417 partner_to_subscribe = filter(lambda item: item in context.get('mail_post_autofollow_partner_ids'), partner_ids)
1418 self.message_subscribe(cr, uid, [thread_id], list(partner_to_subscribe), context=context)
1420 # _mail_flat_thread: automatically set free messages to the first posted message
1421 if self._mail_flat_thread and not parent_id and thread_id:
1422 message_ids = mail_message.search(cr, uid, ['&', ('res_id', '=', thread_id), ('model', '=', model)], context=context, order="id ASC", limit=1)
1423 parent_id = message_ids and message_ids[0] or False
1424 # 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
1426 # update original mail_mail if exists
1428 mail_mail_ids = self.pool['mail.mail'].search(cr, SUPERUSER_ID, [('mail_message_id', '=', parent_id)], context=context)
1429 for mail in self.pool['mail.mail'].browse(cr, SUPERUSER_ID, mail_mail_ids, context=context):
1430 self.pool['mail.mail'].write(cr, SUPERUSER_ID, [mail.id], {'replied': mail.replied + 1}, context=context)
1432 message_ids = mail_message.search(cr, SUPERUSER_ID, [('id', '=', parent_id), ('parent_id', '!=', False)], context=context)
1433 # avoid loops when finding ancestors
1436 message = mail_message.browse(cr, SUPERUSER_ID, message_ids[0], context=context)
1437 while (message.parent_id and message.parent_id.id not in processed_list):
1438 processed_list.append(message.parent_id.id)
1439 message = message.parent_id
1440 parent_id = message.id
1444 'author_id': author_id,
1446 'res_id': thread_id or False,
1448 'subject': subject or False,
1450 'parent_id': parent_id,
1451 'attachment_ids': attachment_ids,
1452 'subtype_id': subtype_id,
1453 'partner_ids': [(4, pid) for pid in partner_ids],
1456 # Avoid warnings about non-existing fields
1457 for x in ('from', 'to', 'cc'):
1460 # Create and auto subscribe the author
1461 msg_id = mail_message.create(cr, uid, values, context=context)
1462 message = mail_message.browse(cr, uid, msg_id, context=context)
1463 if message.author_id and thread_id and type != 'notification' and not context.get('mail_create_nosubscribe'):
1464 self.message_subscribe(cr, uid, [thread_id], [message.author_id.id], context=context)
1467 #------------------------------------------------------
1469 #------------------------------------------------------
1471 def message_get_subscription_data(self, cr, uid, ids, user_pid=None, context=None):
1472 """ Wrapper to get subtypes data. """
1473 return self._get_subscription_data(cr, uid, ids, None, None, user_pid=user_pid, context=context)
1475 def message_subscribe_users(self, cr, uid, ids, user_ids=None, subtype_ids=None, context=None):
1476 """ Wrapper on message_subscribe, using users. If user_ids is not
1477 provided, subscribe uid instead. """
1478 if user_ids is None:
1480 partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
1481 return self.message_subscribe(cr, uid, ids, partner_ids, subtype_ids=subtype_ids, context=context)
1483 def message_subscribe(self, cr, uid, ids, partner_ids, subtype_ids=None, context=None):
1484 """ Add partners to the records followers. """
1485 mail_followers_obj = self.pool.get('mail.followers')
1486 subtype_obj = self.pool.get('mail.message.subtype')
1488 user_pid = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1489 if set(partner_ids) == set([user_pid]):
1491 self.check_access_rights(cr, uid, 'read')
1492 except (osv.except_osv, orm.except_orm):
1495 self.check_access_rights(cr, uid, 'write')
1497 for record in self.browse(cr, SUPERUSER_ID, ids, context=context):
1498 existing_pids = set([f.id for f in record.message_follower_ids
1499 if f.id in partner_ids])
1500 new_pids = set(partner_ids) - existing_pids
1502 # subtype_ids specified: update already subscribed partners
1503 if subtype_ids and existing_pids:
1504 fol_ids = mail_followers_obj.search(cr, SUPERUSER_ID, [
1505 ('res_model', '=', self._name),
1506 ('res_id', '=', record.id),
1507 ('partner_id', 'in', list(existing_pids)),
1509 mail_followers_obj.write(cr, SUPERUSER_ID, fol_ids, {'subtype_ids': [(6, 0, subtype_ids)]}, context=context)
1510 # subtype_ids not specified: do not update already subscribed partner, fetch default subtypes for new partners
1511 elif subtype_ids is None:
1512 subtype_ids = subtype_obj.search(cr, uid, [
1513 ('default', '=', True),
1515 ('res_model', '=', self._name),
1516 ('res_model', '=', False)
1518 # subscribe new followers
1519 for new_pid in new_pids:
1520 mail_followers_obj.create(cr, SUPERUSER_ID, {
1521 'res_model': self._name,
1522 'res_id': record.id,
1523 'partner_id': new_pid,
1524 'subtype_ids': [(6, 0, subtype_ids)],
1529 def message_unsubscribe_users(self, cr, uid, ids, user_ids=None, context=None):
1530 """ Wrapper on message_subscribe, using users. If user_ids is not
1531 provided, unsubscribe uid instead. """
1532 if user_ids is None:
1534 partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
1535 return self.message_unsubscribe(cr, uid, ids, partner_ids, context=context)
1537 def message_unsubscribe(self, cr, uid, ids, partner_ids, context=None):
1538 """ Remove partners from the records followers. """
1539 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
1540 if set(partner_ids) == set([user_pid]):
1541 self.check_access_rights(cr, uid, 'read')
1543 self.check_access_rights(cr, uid, 'write')
1544 return self.write(cr, SUPERUSER_ID, ids, {'message_follower_ids': [(3, pid) for pid in partner_ids]}, context=context)
1546 def _message_get_auto_subscribe_fields(self, cr, uid, updated_fields, auto_follow_fields=['user_id'], context=None):
1547 """ Returns the list of relational fields linking to res.users that should
1548 trigger an auto subscribe. The default list checks for the fields
1550 - linking to res.users
1551 - with track_visibility set
1552 In OpenERP V7, this is sufficent for all major addon such as opportunity,
1553 project, issue, recruitment, sale.
1554 Override this method if a custom behavior is needed about fields
1555 that automatically subscribe users.
1558 for name, column_info in self._all_columns.items():
1559 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':
1560 user_field_lst.append(name)
1561 return user_field_lst
1563 def message_auto_subscribe(self, cr, uid, ids, updated_fields, context=None):
1565 1. fetch project subtype related to task (parent_id.res_model = 'project.task')
1566 2. for each project subtype: subscribe the follower to the task
1568 subtype_obj = self.pool.get('mail.message.subtype')
1569 follower_obj = self.pool.get('mail.followers')
1571 # fetch auto_follow_fields
1572 user_field_lst = self._message_get_auto_subscribe_fields(cr, uid, updated_fields, context=context)
1574 # fetch related record subtypes
1575 related_subtype_ids = subtype_obj.search(cr, uid, ['|', ('res_model', '=', False), ('parent_id.res_model', '=', self._name)], context=context)
1576 subtypes = subtype_obj.browse(cr, uid, related_subtype_ids, context=context)
1577 default_subtypes = [subtype for subtype in subtypes if subtype.res_model == False]
1578 related_subtypes = [subtype for subtype in subtypes if subtype.res_model != False]
1579 relation_fields = set([subtype.relation_field for subtype in subtypes if subtype.relation_field != False])
1580 if (not related_subtypes or not any(relation in updated_fields for relation in relation_fields)) and not user_field_lst:
1583 for record in self.browse(cr, uid, ids, context=context):
1584 new_followers = dict()
1585 parent_res_id = False
1586 parent_model = False
1587 for subtype in related_subtypes:
1588 if not subtype.relation_field or not subtype.parent_id:
1590 if not subtype.relation_field in self._columns or not getattr(record, subtype.relation_field, False):
1592 parent_res_id = getattr(record, subtype.relation_field).id
1593 parent_model = subtype.res_model
1594 follower_ids = follower_obj.search(cr, SUPERUSER_ID, [
1595 ('res_model', '=', parent_model),
1596 ('res_id', '=', parent_res_id),
1597 ('subtype_ids', 'in', [subtype.id])
1599 for follower in follower_obj.browse(cr, SUPERUSER_ID, follower_ids, context=context):
1600 new_followers.setdefault(follower.partner_id.id, set()).add(subtype.parent_id.id)
1602 if parent_res_id and parent_model:
1603 for subtype in default_subtypes:
1604 follower_ids = follower_obj.search(cr, SUPERUSER_ID, [
1605 ('res_model', '=', parent_model),
1606 ('res_id', '=', parent_res_id),
1607 ('subtype_ids', 'in', [subtype.id])
1609 for follower in follower_obj.browse(cr, SUPERUSER_ID, follower_ids, context=context):
1610 new_followers.setdefault(follower.partner_id.id, set()).add(subtype.id)
1612 # add followers coming from res.users relational fields that are tracked
1613 user_ids = [getattr(record, name).id for name in user_field_lst if getattr(record, name)]
1614 user_id_partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, SUPERUSER_ID, user_ids, context=context)]
1615 for partner_id in user_id_partner_ids:
1616 new_followers.setdefault(partner_id, None)
1618 for pid, subtypes in new_followers.items():
1619 subtypes = list(subtypes) if subtypes is not None else None
1620 self.message_subscribe(cr, uid, [record.id], [pid], subtypes, context=context)
1622 # find first email message, set it as unread for auto_subscribe fields for them to have a notification
1623 if user_id_partner_ids:
1624 msg_ids = self.pool.get('mail.message').search(cr, uid, [
1625 ('model', '=', self._name),
1626 ('res_id', '=', record.id),
1627 ('type', '=', 'email')], limit=1, context=context)
1628 if not msg_ids and record.message_ids:
1629 msg_ids = [record.message_ids[-1].id]
1631 self.pool.get('mail.notification')._notify(cr, uid, msg_ids[0], partners_to_notify=user_id_partner_ids, context=context)
1635 #------------------------------------------------------
1637 #------------------------------------------------------
1639 def message_mark_as_unread(self, cr, uid, ids, context=None):
1640 """ Set as unread. """
1641 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1643 UPDATE mail_notification SET
1646 message_id IN (SELECT id from mail_message where res_id=any(%s) and model=%s limit 1) and
1648 ''', (ids, self._name, partner_id))
1651 def message_mark_as_read(self, cr, uid, ids, context=None):
1652 """ Set as read. """
1653 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1655 UPDATE mail_notification SET
1658 message_id IN (SELECT id FROM mail_message WHERE res_id=ANY(%s) AND model=%s) AND
1660 ''', (ids, self._name, partner_id))
1663 #------------------------------------------------------
1665 #------------------------------------------------------
1667 def get_suggested_thread(self, cr, uid, removed_suggested_threads=None, context=None):
1668 """Return a list of suggested threads, sorted by the numbers of followers"""
1672 # TDE HACK: originally by MAT from portal/mail_mail.py but not working until the inheritance graph bug is not solved in trunk
1673 # TDE FIXME: relocate in portal when it won't be necessary to reload the hr.employee model in an additional bridge module
1674 if self.pool['res.groups']._all_columns.get('is_portal'):
1675 user = self.pool.get('res.users').browse(cr, SUPERUSER_ID, uid, context=context)
1676 if any(group.is_portal for group in user.groups_id):
1680 if removed_suggested_threads is None:
1681 removed_suggested_threads = []
1683 thread_ids = self.search(cr, uid, [('id', 'not in', removed_suggested_threads), ('message_is_follower', '=', False)], context=context)
1684 for thread in self.browse(cr, uid, thread_ids, context=context):
1687 'popularity': len(thread.message_follower_ids),
1688 'name': thread.name,
1689 'image_small': thread.image_small
1691 threads.append(data)
1692 return sorted(threads, key=lambda x: (x['popularity'], x['id']), reverse=True)[:3]