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
74 # Automatic logging system if mail installed
77 # 'module.subtype_xml': lambda self, cr, uid, obj, context=None: obj[state] == done,
78 # 'module.subtype_xml2': lambda self, cr, uid, obj, context=None: obj[state] != done,
85 # :param string field: field name
86 # :param module.subtype_xml: xml_id of a mail.message.subtype (i.e. mail.mt_comment)
87 # :param obj: is a browse_record
88 # :param function lambda: returns whether the tracking should record using this subtype
91 def get_empty_list_help(self, cr, uid, help, context=None):
92 """ Override of BaseModel.get_empty_list_help() to generate an help message
93 that adds alias information. """
94 model = context.get('empty_list_help_model')
95 res_id = context.get('empty_list_help_id')
96 ir_config_parameter = self.pool.get("ir.config_parameter")
97 catchall_domain = ir_config_parameter.get_param(cr, uid, "mail.catchall.domain", context=context)
98 document_name = context.get('empty_list_help_document_name', _('document'))
101 if catchall_domain and model and res_id: # specific res_id -> find its alias (i.e. section_id specified)
102 object_id = self.pool.get(model).browse(cr, uid, res_id, context=context)
103 # check that the alias effectively creates new records
104 if object_id.alias_id and object_id.alias_id.alias_name and \
105 object_id.alias_id.alias_model_id and \
106 object_id.alias_id.alias_model_id.model == self._name and \
107 object_id.alias_id.alias_force_thread_id == 0:
108 alias = object_id.alias_id
109 elif catchall_domain and model: # no specific res_id given -> generic help message, take an example alias (i.e. alias of some section_id)
110 model_id = self.pool.get('ir.model').search(cr, uid, [("model", "=", self._name)], context=context)[0]
111 alias_obj = self.pool.get('mail.alias')
112 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')
113 if alias_ids and len(alias_ids) == 1: # if several aliases -> incoherent to propose one guessed from nowhere, therefore avoid if several aliases
114 alias = alias_obj.browse(cr, uid, alias_ids[0], context=context)
117 alias_email = alias.name_get()[0][1]
118 return _("""<p class='oe_view_nocontent_create'>
119 Click here to add new %(document)s or send an email to: <a href='mailto:%(email)s'>%(email)s</a>
123 'document': document_name,
124 'email': alias_email,
125 'static_help': help or ''
128 if document_name != 'document' and help and help.find("oe_view_nocontent_create") == -1:
129 return _("<p class='oe_view_nocontent_create'>Click here to add new %(document)s</p>%(static_help)s") % {
130 'document': document_name,
131 'static_help': help or '',
136 def _get_message_data(self, cr, uid, ids, name, args, context=None):
138 - message_unread: has uid unread message for the document
139 - message_summary: html snippet summarizing the Chatter for kanban views """
140 res = dict((id, dict(message_unread=False, message_unread_count=0, message_summary=' ')) for id in ids)
141 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
143 # search for unread messages, directly in SQL to improve performances
144 cr.execute(""" SELECT m.res_id FROM mail_message m
145 RIGHT JOIN mail_notification n
146 ON (n.message_id = m.id AND n.partner_id = %s AND (n.read = False or n.read IS NULL))
147 WHERE m.model = %s AND m.res_id in %s""",
148 (user_pid, self._name, tuple(ids),))
149 for result in cr.fetchall():
150 res[result[0]]['message_unread'] = True
151 res[result[0]]['message_unread_count'] += 1
154 if res[id]['message_unread_count']:
155 title = res[id]['message_unread_count'] > 1 and _("You have %d unread messages") % res[id]['message_unread_count'] or _("You have one unread message")
156 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"))
159 def check_technical_rights(self, cr, uid, ids, context=None):
160 grp_id = self.pool.get('ir.model.data').get_object_reference(cr, uid, 'base', 'group_no_one')
161 user_pid = self.pool.get('res.groups').read(cr, uid, grp_id[1], ['users'], context=context)['users']
166 def _get_subscription_data(self, cr, uid, ids, name, args, user_pid=None, context=None):
168 - message_subtype_data: data about document subtypes: which are
169 available, which are followed if any """
170 res = dict((id, dict(message_subtype_data='')) for id in ids)
172 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
174 # find current model subtypes, add them to a dictionary
175 subtype_obj = self.pool.get('mail.message.subtype')
176 subtype_ids = subtype_obj.search(cr, uid, ['|', ('res_model', '=', self._name), ('res_model', '=', False)], context=context)
177 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))
179 res[id]['message_subtype_data'] = subtype_dict.copy()
181 # find the document followers, update the data
182 fol_obj = self.pool.get('mail.followers')
183 fol_ids = fol_obj.search(cr, uid, [
184 ('partner_id', '=', user_pid),
185 ('res_id', 'in', ids),
186 ('res_model', '=', self._name),
188 for fol in fol_obj.browse(cr, uid, fol_ids, context=context):
189 thread_subtype_dict = res[fol.res_id]['message_subtype_data']
190 for subtype in fol.subtype_ids:
191 thread_subtype_dict[subtype.name]['followed'] = True
192 res[fol.res_id]['message_subtype_data'] = thread_subtype_dict
196 def _search_message_unread(self, cr, uid, obj=None, name=None, domain=None, context=None):
197 return [('message_ids.to_read', '=', True)]
199 def _get_followers(self, cr, uid, ids, name, arg, context=None):
200 fol_obj = self.pool.get('mail.followers')
201 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids)])
202 res = dict((id, dict(message_follower_ids=[], message_is_follower=False)) for id in ids)
203 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
204 for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids):
205 res[fol.res_id]['message_follower_ids'].append(fol.partner_id.id)
206 if fol.partner_id.id == user_pid:
207 res[fol.res_id]['message_is_follower'] = True
210 def _set_followers(self, cr, uid, id, name, value, arg, context=None):
213 partner_obj = self.pool.get('res.partner')
214 fol_obj = self.pool.get('mail.followers')
216 # read the old set of followers, and determine the new set of followers
217 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', '=', id)])
218 old = set(fol.partner_id.id for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids))
221 for command in value or []:
222 if isinstance(command, (int, long)):
224 elif command[0] == 0:
225 new.add(partner_obj.create(cr, uid, command[2], context=context))
226 elif command[0] == 1:
227 partner_obj.write(cr, uid, [command[1]], command[2], context=context)
229 elif command[0] == 2:
230 partner_obj.unlink(cr, uid, [command[1]], context=context)
231 new.discard(command[1])
232 elif command[0] == 3:
233 new.discard(command[1])
234 elif command[0] == 4:
236 elif command[0] == 5:
238 elif command[0] == 6:
239 new = set(command[2])
241 # remove partners that are no longer followers
242 fol_ids = fol_obj.search(cr, SUPERUSER_ID,
243 [('res_model', '=', self._name), ('res_id', '=', id), ('partner_id', 'not in', list(new))])
244 fol_obj.unlink(cr, SUPERUSER_ID, fol_ids)
247 for partner_id in new - old:
248 fol_obj.create(cr, SUPERUSER_ID, {'res_model': self._name, 'res_id': id, 'partner_id': partner_id})
250 def _search_followers(self, cr, uid, obj, name, args, context):
251 """Search function for message_follower_ids
253 Do not use with operator 'not in'. Use instead message_is_followers
255 fol_obj = self.pool.get('mail.followers')
257 for field, operator, value in args:
259 # TOFIX make it work with not in
260 assert operator != "not in", "Do not search message_follower_ids with 'not in'"
261 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('partner_id', operator, value)])
262 res_ids = [fol.res_id for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids)]
263 res.append(('id', 'in', res_ids))
266 def _search_is_follower(self, cr, uid, obj, name, args, context):
267 """Search function for message_is_follower"""
269 for field, operator, value in args:
271 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
272 if (operator == '=' and value) or (operator == '!=' and not value): # is a follower
273 res_ids = self.search(cr, uid, [('message_follower_ids', 'in', [partner_id])], context=context)
274 else: # is not a follower or unknown domain
275 mail_ids = self.search(cr, uid, [('message_follower_ids', 'in', [partner_id])], context=context)
276 res_ids = self.search(cr, uid, [('id', 'not in', mail_ids)], context=context)
277 res.append(('id', 'in', res_ids))
281 'message_is_follower': fields.function(_get_followers, type='boolean',
282 fnct_search=_search_is_follower, string='Is a Follower', multi='_get_followers,'),
283 'message_follower_ids': fields.function(_get_followers, fnct_inv=_set_followers,
284 fnct_search=_search_followers, type='many2many',
285 obj='res.partner', string='Followers', multi='_get_followers'),
286 'message_ids': fields.one2many('mail.message', 'res_id',
287 domain=lambda self: [('model', '=', self._name)],
290 help="Messages and communication history"),
291 'message_unread': fields.function(_get_message_data,
292 fnct_search=_search_message_unread, multi="_get_message_data",
293 type='boolean', string='Unread Messages',
294 help="If checked new messages require your attention."),
295 'message_summary': fields.function(_get_message_data, method=True,
296 type='text', string='Summary', multi="_get_message_data",
297 help="Holds the Chatter summary (number of messages, ...). "\
298 "This summary is directly in html format in order to "\
299 "be inserted in kanban views."),
302 #------------------------------------------------------
303 # CRUD overrides for automatic subscription and logging
304 #------------------------------------------------------
306 def create(self, cr, uid, values, context=None):
307 """ Chatter override :
309 - subscribe followers of parent
310 - log a creation message
314 thread_id = super(mail_thread, self).create(cr, uid, values, context=context)
316 # automatic logging unless asked not to (mainly for various testing purpose)
317 if not context.get('mail_create_nolog'):
318 self.message_post(cr, uid, thread_id, body=_('%s created') % (self._description), context=context)
320 # subscribe uid unless asked not to
321 if not context.get('mail_create_nosubscribe'):
322 self.message_subscribe_users(cr, uid, [thread_id], [uid], context=context)
323 # auto_subscribe: take values and defaults into account
324 create_values = set(values.keys())
325 for key, val in context.iteritems():
326 if key.startswith('default_'):
327 create_values.add(key[8:])
328 self.message_auto_subscribe(cr, uid, [thread_id], list(create_values), context=context)
331 tracked_fields = self._get_tracked_fields(cr, uid, values.keys(), context=context)
333 initial_values = {thread_id: dict((item, False) for item in tracked_fields)}
334 self.message_track(cr, uid, [thread_id], tracked_fields, initial_values, context=context)
338 def write(self, cr, uid, ids, values, context=None):
339 if isinstance(ids, (int, long)):
341 # Track initial values of tracked fields
342 tracked_fields = self._get_tracked_fields(cr, uid, values.keys(), context=context)
344 records = self.browse(cr, uid, ids, context=context)
345 initial_values = dict((this.id, dict((key, getattr(this, key)) for key in tracked_fields.keys())) for this in records)
347 # Perform write, update followers
348 result = super(mail_thread, self).write(cr, uid, ids, values, context=context)
349 self.message_auto_subscribe(cr, uid, ids, values.keys(), context=context)
351 # Perform the tracking
353 self.message_track(cr, uid, ids, tracked_fields, initial_values, context=context)
356 def unlink(self, cr, uid, ids, context=None):
357 """ Override unlink to delete messages and followers. This cannot be
358 cascaded, because link is done through (res_model, res_id). """
359 msg_obj = self.pool.get('mail.message')
360 fol_obj = self.pool.get('mail.followers')
361 # delete messages and notifications
362 msg_ids = msg_obj.search(cr, uid, [('model', '=', self._name), ('res_id', 'in', ids)], context=context)
363 msg_obj.unlink(cr, uid, msg_ids, context=context)
365 res = super(mail_thread, self).unlink(cr, uid, ids, context=context)
367 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids)], context=context)
368 fol_obj.unlink(cr, SUPERUSER_ID, fol_ids, context=context)
371 def copy(self, cr, uid, id, default=None, context=None):
372 default = default or {}
373 default['message_ids'] = []
374 default['message_follower_ids'] = []
375 return super(mail_thread, self).copy(cr, uid, id, default=default, context=context)
377 #------------------------------------------------------
378 # Automatically log tracked fields
379 #------------------------------------------------------
381 def _get_tracked_fields(self, cr, uid, updated_fields, context=None):
382 """ Return a structure of tracked fields for the current model.
383 :param list updated_fields: modified field names
384 :return list: a list of (field_name, column_info obj), containing
385 always tracked fields and modified on_change fields
388 for name, column_info in self._all_columns.items():
389 visibility = getattr(column_info.column, 'track_visibility', False)
390 if visibility == 'always' or (visibility == 'onchange' and name in updated_fields) or name in self._track:
394 return self.fields_get(cr, uid, lst, context=context)
396 def message_track(self, cr, uid, ids, tracked_fields, initial_values, context=None):
398 def convert_for_display(value, col_info):
399 if not value and col_info['type'] == 'boolean':
403 if col_info['type'] == 'many2one':
404 return value.name_get()[0][1]
405 if col_info['type'] == 'selection':
406 return dict(col_info['selection'])[value]
409 def format_message(message_description, tracked_values):
411 if message_description:
412 message = '<span>%s</span>' % message_description
413 for name, change in tracked_values.items():
414 message += '<div> • <b>%s</b>: ' % change.get('col_info')
415 if change.get('old_value'):
416 message += '%s → ' % change.get('old_value')
417 message += '%s</div>' % change.get('new_value')
420 if not tracked_fields:
423 for browse_record in self.browse(cr, uid, ids, context=context):
424 initial = initial_values[browse_record.id]
428 # generate tracked_values data structure: {'col_name': {col_info, new_value, old_value}}
429 for col_name, col_info in tracked_fields.items():
430 initial_value = initial[col_name]
431 record_value = getattr(browse_record, col_name)
433 if record_value == initial_value and getattr(self._all_columns[col_name].column, 'track_visibility', None) == 'always':
434 tracked_values[col_name] = dict(col_info=col_info['string'],
435 new_value=convert_for_display(record_value, col_info))
436 elif record_value != initial_value and (record_value or initial_value): # because browse null != False
437 if getattr(self._all_columns[col_name].column, 'track_visibility', None) in ['always', 'onchange']:
438 tracked_values[col_name] = dict(col_info=col_info['string'],
439 old_value=convert_for_display(initial_value, col_info),
440 new_value=convert_for_display(record_value, col_info))
441 if col_name in tracked_fields:
442 changes.add(col_name)
446 # find subtypes and post messages or log if no subtype found
448 for field, track_info in self._track.items():
449 if field not in changes:
451 for subtype, method in track_info.items():
452 if method(self, cr, uid, browse_record, context):
453 subtypes.append(subtype)
456 for subtype in subtypes:
458 subtype_rec = self.pool.get('ir.model.data').get_object(cr, uid, subtype.split('.')[0], subtype.split('.')[1], context=context)
459 except ValueError, e:
460 _logger.debug('subtype %s not found, giving error "%s"' % (subtype, e))
462 message = format_message(subtype_rec.description if subtype_rec.description else subtype_rec.name, tracked_values)
463 self.message_post(cr, uid, browse_record.id, body=message, subtype=subtype, context=context)
466 message = format_message('', tracked_values)
467 self.message_post(cr, uid, browse_record.id, body=message, context=context)
470 #------------------------------------------------------
471 # mail.message wrappers and tools
472 #------------------------------------------------------
474 def _needaction_domain_get(self, cr, uid, context=None):
476 return [('message_unread', '=', True)]
479 def _garbage_collect_attachments(self, cr, uid, context=None):
480 """ Garbage collect lost mail attachments. Those are attachments
481 - linked to res_model 'mail.compose.message', the composer wizard
482 - with res_id 0, because they were created outside of an existing
483 wizard (typically user input through Chatter or reports
484 created on-the-fly by the templates)
485 - unused since at least one day (create_date and write_date)
487 limit_date = datetime.datetime.utcnow() - datetime.timedelta(days=1)
488 limit_date_str = datetime.datetime.strftime(limit_date, tools.DEFAULT_SERVER_DATETIME_FORMAT)
489 ir_attachment_obj = self.pool.get('ir.attachment')
490 attach_ids = ir_attachment_obj.search(cr, uid, [
491 ('res_model', '=', 'mail.compose.message'),
493 ('create_date', '<', limit_date_str),
494 ('write_date', '<', limit_date_str),
496 ir_attachment_obj.unlink(cr, uid, attach_ids, context=context)
499 def check_mail_message_access(self, cr, uid, mids, operation, model_obj=None, context=None):
500 """ mail.message check permission rules for related document. This method is
501 meant to be inherited in order to implement addons-specific behavior.
502 A common behavior would be to allow creating messages when having read
503 access rule on the document, for portal document such as issues. """
506 if operation in ['create', 'write', 'unlink']:
507 model_obj.check_access_rights(cr, uid, 'write')
508 model_obj.check_access_rule(cr, uid, mids, 'write', context=context)
510 model_obj.check_access_rights(cr, uid, operation)
511 model_obj.check_access_rule(cr, uid, mids, operation, context=context)
513 def _get_formview_action(self, cr, uid, id, model=None, context=None):
514 """ Return an action to open the document. This method is meant to be
515 overridden in addons that want to give specific view ids for example.
517 :param int id: id of the document to open
518 :param string model: specific model that overrides self._name
521 'type': 'ir.actions.act_window',
522 'res_model': model or self._name,
525 'views': [(False, 'form')],
530 def _get_inbox_action_xml_id(self, cr, uid, context=None):
531 """ When redirecting towards the Inbox, choose which action xml_id has
532 to be fetched. This method is meant to be inherited, at least in portal
533 because portal users have a different Inbox action than classic users. """
534 return ('mail', 'action_mail_inbox_feeds')
536 def message_redirect_action(self, cr, uid, context=None):
537 """ For a given message, return an action that either
538 - opens the form view of the related document if model, res_id, and
539 read access to the document
540 - opens the Inbox with a default search on the conversation if model,
542 - opens the Inbox with context propagated
548 # default action is the Inbox action
549 self.pool.get('res.users').browse(cr, SUPERUSER_ID, uid, context=context)
550 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))
551 action = self.pool.get(act_model).read(cr, uid, act_id, [])
553 # if msg_id specified: try to redirect to the document or fallback on the Inbox
554 msg_id = context.get('params', {}).get('message_id')
557 msg = self.pool.get('mail.message').browse(cr, uid, msg_id, context=context)
558 if msg.model and msg.res_id:
561 'search_default_model': msg.model,
562 'search_default_res_id': msg.res_id,
565 if self.pool.get(msg.model).check_access_rights(cr, uid, 'read', raise_exception=False):
567 model_obj = self.pool.get(msg.model)
568 model_obj.check_access_rule(cr, uid, [msg.res_id], 'read', context=context)
569 if not hasattr(model_obj, '_get_formview_action'):
570 action = self.pool.get('mail.thread')._get_formview_action(cr, uid, msg.res_id, model=msg.model, context=context)
572 action = model_obj._get_formview_action(cr, uid, msg.res_id, context=context)
573 except (osv.except_osv, orm.except_orm):
577 #------------------------------------------------------
579 #------------------------------------------------------
581 def message_get_reply_to(self, cr, uid, ids, context=None):
582 """ Returns the preferred reply-to email address that is basically
583 the alias of the document, if it exists. """
584 if not self._inherits.get('mail.alias'):
585 return [False for id in ids]
586 return ["%s@%s" % (record['alias_name'], record['alias_domain'])
587 if record.get('alias_domain') and record.get('alias_name')
589 for record in self.read(cr, SUPERUSER_ID, ids, ['alias_name', 'alias_domain'], context=context)]
591 #------------------------------------------------------
593 #------------------------------------------------------
595 def message_capable_models(self, cr, uid, context=None):
596 """ Used by the plugin addon, based for plugin_outlook and others. """
598 for model_name in self.pool.obj_list():
599 model = self.pool[model_name]
600 if hasattr(model, "message_process") and hasattr(model, "message_post"):
601 ret_dict[model_name] = model._description
604 def _message_find_partners(self, cr, uid, message, header_fields=['From'], context=None):
605 """ Find partners related to some header fields of the message.
607 :param string message: an email.message instance """
608 s = ', '.join([decode(message.get(h)) for h in header_fields if message.get(h)])
609 return filter(lambda x: x, self._find_partner_from_emails(cr, uid, None, tools.email_split(s), context=context))
611 def message_route_verify(self, cr, uid, message, message_dict, route, update_author=True, assert_model=True, create_fallback=True, context=None):
612 """ Verify route validity. Check and rules:
613 1 - if thread_id -> check that document effectively exists; otherwise
614 fallback on a message_new by resetting thread_id
615 2 - check that message_update exists if thread_id is set; or at least
616 that message_new exist
617 [ - find author_id if udpate_author is set]
618 3 - if there is an alias, check alias_contact:
619 'followers' and thread_id:
620 check on target document that the author is in the followers
621 'followers' and alias_parent_thread_id:
622 check on alias parent document that the author is in the
624 'partners': check that author_id id set
627 assert isinstance(route, (list, tuple)), 'A route should be a list or a tuple'
628 assert len(route) == 5, 'A route should contain 5 elements: model, thread_id, custom_values, uid, alias record'
630 message_id = message.get('Message-Id')
631 email_from = decode_header(message, 'From')
632 author_id = message_dict.get('author_id')
633 model, thread_id, alias = route[0], route[1], route[4]
636 def _create_bounce_email():
637 mail_mail = self.pool.get('mail.mail')
638 mail_id = mail_mail.create(cr, uid, {
639 'body_html': '<div><p>Hello,</p>'
640 '<p>The following email sent to %s cannot be accepted because this is '
641 'a private email address. Only allowed people can contact us at this address.</p></div>'
642 '<blockquote>%s</blockquote>' % (message.get('to'), message_dict.get('body')),
643 'subject': 'Re: %s' % message.get('subject'),
644 'email_to': message.get('from'),
647 mail_mail.send(cr, uid, [mail_id], context=context)
650 _logger.warning('Routing mail with Message-Id %s: route %s: %s',
651 message_id, route, message)
654 if model and not model in self.pool:
656 assert model in self.pool, 'Routing: unknown target model %s' % model
657 _warn('unknown target model %s' % model)
660 model_pool = self.pool[model]
662 # Private message: should not contain any thread_id
663 if not model and thread_id:
665 assert thread_id == 0, 'Routing: posting a message without model should be with a null res_id (private message).'
666 _warn('posting a message without model should be with a null res_id (private message), resetting thread_id')
669 # Existing Document: check if exists; if not, fallback on create if allowed
670 if thread_id and not model_pool.exists(cr, uid, thread_id):
672 _warn('reply to missing document (%s,%s), fall back on new document creation' % (model, thread_id))
675 assert model_pool.exists(cr, uid, thread_id), 'Routing: reply to missing document (%s,%s)' % (model, thread_id)
677 _warn('reply to missing document (%s,%s), skipping' % (model, thread_id))
680 # Existing Document: check model accepts the mailgateway
681 if thread_id and not hasattr(model_pool, 'message_update'):
683 _warn('model %s does not accept document update, fall back on document creation' % model)
686 assert hasattr(model_pool, 'message_update'), 'Routing: model %s does not accept document update, crashing' % model
688 _warn('model %s does not accept document update, skipping' % model)
691 # New Document: check model accepts the mailgateway
692 if not thread_id and not hasattr(model_pool, 'message_new'):
694 assert hasattr(model_pool, 'message_new'), 'Model %s does not accept document creation, crashing' % model
695 _warn('model %s does not accept document creation, skipping' % model)
698 # Update message author if asked
699 # We do it now because we need it for aliases (contact settings)
700 if not author_id and update_author:
701 author_ids = self._find_partner_from_emails(cr, uid, thread_id, [email_from], model=model, context=context)
703 author_id = author_ids[0]
704 message_dict['author_id'] = author_id
706 # Alias: check alias_contact settings
707 if alias and alias.alias_contact == 'followers' and (thread_id or alias.alias_parent_thread_id):
709 obj = self.pool[model].browse(cr, uid, thread_id, context=context)
711 obj = self.pool[alias.alias_parent_model_id.model].browse(cr, uid, alias.alias_parent_thread_id, context=context)
712 if not author_id or not author_id in [fol.id for fol in obj.message_follower_ids]:
713 _warn('alias %s restricted to internal followers, skipping' % alias.alias_name)
714 _create_bounce_email()
716 elif alias and alias.alias_contact == 'partners' and not author_id:
717 _warn('alias %s does not accept unknown author, skipping' % alias.alias_name)
718 _create_bounce_email()
721 return (model, thread_id, route[2], route[3], route[4])
723 def message_route(self, cr, uid, message, message_dict, model=None, thread_id=None,
724 custom_values=None, context=None):
725 """Attempt to figure out the correct target model, thread_id,
726 custom_values and user_id to use for an incoming message.
727 Multiple values may be returned, if a message had multiple
728 recipients matching existing mail.aliases, for example.
730 The following heuristics are used, in this order:
731 1. If the message replies to an existing thread_id, and
732 properly contains the thread model in the 'In-Reply-To'
733 header, use this model/thread_id pair, and ignore
734 custom_value (not needed as no creation will take place)
735 2. Look for a mail.alias entry matching the message
736 recipient, and use the corresponding model, thread_id,
737 custom_values and user_id.
738 3. Fallback to the ``model``, ``thread_id`` and ``custom_values``
740 4. If all the above fails, raise an exception.
742 :param string message: an email.message instance
743 :param dict message_dict: dictionary holding message variables
744 :param string model: the fallback model to use if the message
745 does not match any of the currently configured mail aliases
746 (may be None if a matching alias is supposed to be present)
747 :type dict custom_values: optional dictionary of default field values
748 to pass to ``message_new`` if a new record needs to be created.
749 Ignored if the thread record already exists, and also if a
750 matching mail.alias was found (aliases define their own defaults)
751 :param int thread_id: optional ID of the record/thread from ``model``
752 to which this mail should be attached. Only used if the message
753 does not reply to an existing thread and does not match any mail alias.
754 :return: list of [model, thread_id, custom_values, user_id, alias]
756 assert isinstance(message, Message), 'message must be an email.message.Message at this point'
757 fallback_model = model
759 # Get email.message.Message variables for future processing
760 message_id = message.get('Message-Id')
761 email_from = decode_header(message, 'From')
762 email_to = decode_header(message, 'To')
763 references = decode_header(message, 'References')
764 in_reply_to = decode_header(message, 'In-Reply-To')
766 # 1. Verify if this is a reply to an existing thread
767 thread_references = references or in_reply_to
768 ref_match = thread_references and tools.reference_re.search(thread_references)
770 thread_id = int(ref_match.group(1))
771 model = ref_match.group(2) or fallback_model
772 if thread_id and model in self.pool:
773 model_obj = self.pool[model]
774 if model_obj.exists(cr, uid, thread_id) and hasattr(model_obj, 'message_update'):
775 _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',
776 email_from, email_to, message_id, model, thread_id, custom_values, uid)
777 route = self.message_route_verify(cr, uid, message, message_dict,
778 (model, thread_id, custom_values, uid, None),
779 update_author=True, assert_model=True, create_fallback=True, context=context)
780 return route and [route] or []
782 # 2. Reply to a private message
784 message_ids = self.pool.get('mail.message').search(cr, uid, [
785 ('message_id', '=', in_reply_to),
786 '!', ('message_id', 'ilike', 'reply_to')
787 ], limit=1, context=context)
789 message = self.pool.get('mail.message').browse(cr, uid, message_ids[0], context=context)
790 _logger.info('Routing mail from %s to %s with Message-Id %s: direct reply to a private message: %s, custom_values: %s, uid: %s',
791 email_from, email_to, message_id, message.id, custom_values, uid)
792 route = self.message_route_verify(cr, uid, message, message_dict,
793 (message.model, message.res_id, custom_values, uid, None),
794 update_author=True, assert_model=True, create_fallback=True, context=context)
795 return route and [route] or []
797 # 3. Look for a matching mail.alias entry
798 # Delivered-To is a safe bet in most modern MTAs, but we have to fallback on To + Cc values
799 # for all the odd MTAs out there, as there is no standard header for the envelope's `rcpt_to` value.
801 ','.join([decode_header(message, 'Delivered-To'),
802 decode_header(message, 'To'),
803 decode_header(message, 'Cc'),
804 decode_header(message, 'Resent-To'),
805 decode_header(message, 'Resent-Cc')])
806 local_parts = [e.split('@')[0] for e in tools.email_split(rcpt_tos)]
808 mail_alias = self.pool.get('mail.alias')
809 alias_ids = mail_alias.search(cr, uid, [('alias_name', 'in', local_parts)])
812 for alias in mail_alias.browse(cr, uid, alias_ids, context=context):
813 user_id = alias.alias_user_id.id
815 # TDE note: this could cause crashes, because no clue that the user
816 # that send the email has the right to create or modify a new document
817 # Fallback on user_id = uid
818 # Note: recognized partners will be added as followers anyway
819 # user_id = self._message_find_user_id(cr, uid, message, context=context)
821 _logger.info('No matching user_id for the alias %s', alias.alias_name)
822 route = (alias.alias_model_id.model, alias.alias_force_thread_id, eval(alias.alias_defaults), user_id, alias)
823 _logger.info('Routing mail from %s to %s with Message-Id %s: direct alias match: %r',
824 email_from, email_to, message_id, route)
825 route = self.message_route_verify(cr, uid, message, message_dict, route,
826 update_author=True, assert_model=True, create_fallback=True, context=context)
831 # 4. Fallback to the provided parameters, if they work
833 # Legacy: fallback to matching [ID] in the Subject
834 match = tools.res_re.search(decode_header(message, 'Subject'))
835 thread_id = match and match.group(1)
836 # Convert into int (bug spotted in 7.0 because of str)
838 thread_id = int(thread_id)
841 _logger.info('Routing mail from %s to %s with Message-Id %s: fallback to model:%s, thread_id:%s, custom_values:%s, uid:%s',
842 email_from, email_to, message_id, fallback_model, thread_id, custom_values, uid)
843 route = self.message_route_verify(cr, uid, message, message_dict,
844 (fallback_model, thread_id, custom_values, uid, None),
845 update_author=True, assert_model=True, context=context)
849 # AssertionError if no routes found and if no bounce occured
851 "No possible route found for incoming message from %s to %s (Message-Id %s:)." \
852 "Create an appropriate mail.alias or force the destination model." % (email_from, email_to, message_id)
854 def message_process(self, cr, uid, model, message, custom_values=None,
855 save_original=False, strip_attachments=False,
856 thread_id=None, context=None):
857 """ Process an incoming RFC2822 email message, relying on
858 ``mail.message.parse()`` for the parsing operation,
859 and ``message_route()`` to figure out the target model.
861 Once the target model is known, its ``message_new`` method
862 is called with the new message (if the thread record did not exist)
863 or its ``message_update`` method (if it did).
865 There is a special case where the target model is False: a reply
866 to a private message. In this case, we skip the message_new /
867 message_update step, to just post a new message using mail_thread
870 :param string model: the fallback model to use if the message
871 does not match any of the currently configured mail aliases
872 (may be None if a matching alias is supposed to be present)
873 :param message: source of the RFC2822 message
874 :type message: string or xmlrpclib.Binary
875 :type dict custom_values: optional dictionary of field values
876 to pass to ``message_new`` if a new record needs to be created.
877 Ignored if the thread record already exists, and also if a
878 matching mail.alias was found (aliases define their own defaults)
879 :param bool save_original: whether to keep a copy of the original
880 email source attached to the message after it is imported.
881 :param bool strip_attachments: whether to strip all attachments
882 before processing the message, in order to save some space.
883 :param int thread_id: optional ID of the record/thread from ``model``
884 to which this mail should be attached. When provided, this
885 overrides the automatic detection based on the message
891 # extract message bytes - we are forced to pass the message as binary because
892 # we don't know its encoding until we parse its headers and hence can't
893 # convert it to utf-8 for transport between the mailgate script and here.
894 if isinstance(message, xmlrpclib.Binary):
895 message = str(message.data)
896 # Warning: message_from_string doesn't always work correctly on unicode,
897 # we must use utf-8 strings here :-(
898 if isinstance(message, unicode):
899 message = message.encode('utf-8')
900 msg_txt = email.message_from_string(message)
902 # parse the message, verify we are not in a loop by checking message_id is not duplicated
903 msg = self.message_parse(cr, uid, msg_txt, save_original=save_original, context=context)
904 if strip_attachments:
905 msg.pop('attachments', None)
906 # postpone setting msg.partner_ids after message_post, to avoid double notifications
907 partner_ids = msg.pop('partner_ids', [])
908 if msg.get('message_id'): # should always be True as message_parse generate one if missing
909 existing_msg_ids = self.pool.get('mail.message').search(cr, SUPERUSER_ID, [
910 ('message_id', '=', msg.get('message_id')),
913 _logger.info('Ignored mail from %s to %s with Message-Id %s: found duplicated Message-Id during processing',
914 msg.get('from'), msg.get('to'), msg.get('message_id'))
917 # find possible routes for the message
918 routes = self.message_route(cr, uid, msg_txt, msg, model, thread_id, custom_values, context=context)
920 for model, thread_id, custom_values, user_id, alias in routes:
921 if self._name == 'mail.thread':
922 context.update({'thread_model': model})
924 model_pool = self.pool[model]
925 assert thread_id and hasattr(model_pool, 'message_update') or hasattr(model_pool, 'message_new'), \
926 "Undeliverable mail with Message-Id %s, model %s does not accept incoming emails" % \
927 (msg['message_id'], model)
929 # disabled subscriptions during message_new/update to avoid having the system user running the
930 # email gateway become a follower of all inbound messages
931 nosub_ctx = dict(context, mail_create_nosubscribe=True, mail_create_nolog=True)
932 if thread_id and hasattr(model_pool, 'message_update'):
933 model_pool.message_update(cr, user_id, [thread_id], msg, context=nosub_ctx)
935 thread_id = model_pool.message_new(cr, user_id, msg, custom_values, context=nosub_ctx)
937 assert thread_id == 0, "Posting a message without model should be with a null res_id, to create a private message."
938 model_pool = self.pool.get('mail.thread')
939 if not hasattr(model_pool, 'message_post'):
940 context['thread_model'] = model
941 model_pool = self.pool['mail.thread']
942 new_msg_id = model_pool.message_post(cr, uid, [thread_id], context=context, subtype='mail.mt_comment', **msg)
945 # postponed after message_post, because this is an external message and we don't want to create
946 # duplicate emails due to notifications
947 self.pool.get('mail.message').write(cr, uid, [new_msg_id], {'partner_ids': partner_ids}, context=context)
951 def message_new(self, cr, uid, msg_dict, custom_values=None, context=None):
952 """Called by ``message_process`` when a new message is received
953 for a given thread model, if the message did not belong to
955 The default behavior is to create a new record of the corresponding
956 model (based on some very basic info extracted from the message).
957 Additional behavior may be implemented by overriding this method.
959 :param dict msg_dict: a map containing the email details and
960 attachments. See ``message_process`` and
961 ``mail.message.parse`` for details.
962 :param dict custom_values: optional dictionary of additional
963 field values to pass to create()
964 when creating the new thread record.
965 Be careful, these values may override
966 any other values coming from the message.
967 :param dict context: if a ``thread_model`` value is present
968 in the context, its value will be used
969 to determine the model of the record
970 to create (instead of the current model).
972 :return: the id of the newly created thread object
977 if isinstance(custom_values, dict):
978 data = custom_values.copy()
979 model = context.get('thread_model') or self._name
980 model_pool = self.pool[model]
981 fields = model_pool.fields_get(cr, uid, context=context)
982 if 'name' in fields and not data.get('name'):
983 data['name'] = msg_dict.get('subject', '')
984 res_id = model_pool.create(cr, uid, data, context=context)
987 def message_update(self, cr, uid, ids, msg_dict, update_vals=None, context=None):
988 """Called by ``message_process`` when a new message is received
989 for an existing thread. The default behavior is to update the record
990 with update_vals taken from the incoming email.
991 Additional behavior may be implemented by overriding this
993 :param dict msg_dict: a map containing the email details and
994 attachments. See ``message_process`` and
995 ``mail.message.parse()`` for details.
996 :param dict update_vals: a dict containing values to update records
997 given their ids; if the dict is None or is
998 void, no write operation is performed.
1001 self.write(cr, uid, ids, update_vals, context=context)
1004 def _message_extract_payload(self, message, save_original=False):
1005 """Extract body as HTML and attachments from the mail message"""
1009 attachments.append(('original_email.eml', message.as_string()))
1010 if not message.is_multipart() or 'text/' in message.get('content-type', ''):
1011 encoding = message.get_content_charset()
1012 body = message.get_payload(decode=True)
1013 body = tools.ustr(body, encoding, errors='replace')
1014 if message.get_content_type() == 'text/plain':
1015 # text/plain -> <pre/>
1016 body = tools.append_content_to_html(u'', body, preserve=True)
1018 alternative = (message.get_content_type() == 'multipart/alternative')
1019 for part in message.walk():
1020 if part.get_content_maintype() == 'multipart':
1021 continue # skip container
1022 filename = part.get_filename() # None if normal part
1023 encoding = part.get_content_charset() # None if attachment
1024 # 1) Explicit Attachments -> attachments
1025 if filename or part.get('content-disposition', '').strip().startswith('attachment'):
1026 attachments.append((filename or 'attachment', part.get_payload(decode=True)))
1028 # 2) text/plain -> <pre/>
1029 if part.get_content_type() == 'text/plain' and (not alternative or not body):
1030 body = tools.append_content_to_html(body, tools.ustr(part.get_payload(decode=True),
1031 encoding, errors='replace'), preserve=True)
1032 # 3) text/html -> raw
1033 elif part.get_content_type() == 'text/html':
1034 html = tools.ustr(part.get_payload(decode=True), encoding, errors='replace')
1038 body = tools.append_content_to_html(body, html, plaintext=False)
1039 # 4) Anything else -> attachment
1041 attachments.append((filename or 'attachment', part.get_payload(decode=True)))
1042 return body, attachments
1044 def message_parse(self, cr, uid, message, save_original=False, context=None):
1045 """Parses a string or email.message.Message representing an
1046 RFC-2822 email, and returns a generic dict holding the
1049 :param message: the message to parse
1050 :type message: email.message.Message | string | unicode
1051 :param bool save_original: whether the returned dict
1052 should include an ``original`` attachment containing
1053 the source of the message
1055 :return: A dict with the following structure, where each
1056 field may not be present if missing in original
1059 { 'message_id': msg_id,
1064 'body': unified_body,
1065 'attachments': [('file1', 'bytes'),
1072 if not isinstance(message, Message):
1073 if isinstance(message, unicode):
1074 # Warning: message_from_string doesn't always work correctly on unicode,
1075 # we must use utf-8 strings here :-(
1076 message = message.encode('utf-8')
1077 message = email.message_from_string(message)
1079 message_id = message['message-id']
1081 # Very unusual situation, be we should be fault-tolerant here
1082 message_id = "<%s@localhost>" % time.time()
1083 _logger.debug('Parsing Message without message-id, generating a random one: %s', message_id)
1084 msg_dict['message_id'] = message_id
1086 if message.get('Subject'):
1087 msg_dict['subject'] = decode(message.get('Subject'))
1089 # Envelope fields not stored in mail.message but made available for message_new()
1090 msg_dict['from'] = decode(message.get('from'))
1091 msg_dict['to'] = decode(message.get('to'))
1092 msg_dict['cc'] = decode(message.get('cc'))
1093 msg_dict['email_from'] = decode(message.get('from'))
1094 partner_ids = self._message_find_partners(cr, uid, message, ['To', 'Cc'], context=context)
1095 msg_dict['partner_ids'] = [(4, partner_id) for partner_id in partner_ids]
1097 if message.get('Date'):
1099 date_hdr = decode(message.get('Date'))
1100 parsed_date = dateutil.parser.parse(date_hdr, fuzzy=True)
1101 if parsed_date.utcoffset() is None:
1102 # naive datetime, so we arbitrarily decide to make it
1103 # UTC, there's no better choice. Should not happen,
1104 # as RFC2822 requires timezone offset in Date headers.
1105 stored_date = parsed_date.replace(tzinfo=pytz.utc)
1107 stored_date = parsed_date.astimezone(tz=pytz.utc)
1109 _logger.warning('Failed to parse Date header %r in incoming mail '
1110 'with message-id %r, assuming current date/time.',
1111 message.get('Date'), message_id)
1112 stored_date = datetime.datetime.now()
1113 msg_dict['date'] = stored_date.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT)
1115 if message.get('In-Reply-To'):
1116 parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', '=', decode(message['In-Reply-To']))])
1118 msg_dict['parent_id'] = parent_ids[0]
1120 if message.get('References') and 'parent_id' not in msg_dict:
1121 parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', 'in',
1122 [x.strip() for x in decode(message['References']).split()])])
1124 msg_dict['parent_id'] = parent_ids[0]
1126 msg_dict['body'], msg_dict['attachments'] = self._message_extract_payload(message, save_original=save_original)
1129 #------------------------------------------------------
1131 #------------------------------------------------------
1133 def log(self, cr, uid, id, message, secondary=False, context=None):
1134 _logger.warning("log() is deprecated. As this module inherit from "\
1135 "mail.thread, the message will be managed by this "\
1136 "module instead of by the res.log mechanism. Please "\
1137 "use mail_thread.message_post() instead of the "\
1138 "now deprecated res.log.")
1139 self.message_post(cr, uid, [id], message, context=context)
1141 def _message_add_suggested_recipient(self, cr, uid, result, obj, partner=None, email=None, reason='', context=None):
1142 """ Called by message_get_suggested_recipients, to add a suggested
1143 recipient in the result dictionary. The form is :
1144 partner_id, partner_name<partner_email> or partner_name, reason """
1145 if email and not partner:
1146 # get partner info from email
1147 partner_info = self.message_partner_info_from_emails(cr, uid, obj.id, [email], context=context)[0]
1148 if partner_info.get('partner_id'):
1149 partner = self.pool.get('res.partner').browse(cr, SUPERUSER_ID, [partner_info.get('partner_id')], context=context)[0]
1150 if email and email in [val[1] for val in result[obj.id]]: # already existing email -> skip
1152 if partner and partner in obj.message_follower_ids: # recipient already in the followers -> skip
1154 if partner and partner in [val[0] for val in result[obj.id]]: # already existing partner ID -> skip
1156 if partner and partner.email: # complete profile: id, name <email>
1157 result[obj.id].append((partner.id, '%s<%s>' % (partner.name, partner.email), reason))
1158 elif partner: # incomplete profile: id, name
1159 result[obj.id].append((partner.id, '%s' % (partner.name), reason))
1160 else: # unknown partner, we are probably managing an email address
1161 result[obj.id].append((False, email, reason))
1164 def message_get_suggested_recipients(self, cr, uid, ids, context=None):
1165 """ Returns suggested recipients for ids. Those are a list of
1166 tuple (partner_id, partner_name, reason), to be managed by Chatter. """
1167 result = dict.fromkeys(ids, list())
1168 if self._all_columns.get('user_id'):
1169 for obj in self.browse(cr, SUPERUSER_ID, ids, context=context): # SUPERUSER because of a read on res.users that would crash otherwise
1170 if not obj.user_id or not obj.user_id.partner_id:
1172 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)
1175 def _find_partner_from_emails(self, cr, uid, id, emails, model=None, context=None, check_followers=True):
1176 """ Utility method to find partners from email addresses. The rules are :
1177 1 - check in document (model | self, id) followers
1178 2 - try to find a matching partner that is also an user
1179 3 - try to find a matching partner
1181 :param list emails: list of email addresses
1182 :param string model: model to fetch related record; by default self
1184 :param boolean check_followers: check in document followers
1186 partner_obj = self.pool['res.partner']
1189 if id and (model or self._name != 'mail.thread') and check_followers:
1191 obj = self.pool[model].browse(cr, uid, id, context=context)
1193 obj = self.browse(cr, uid, id, context=context)
1194 for contact in emails:
1196 email_address = tools.email_split(contact)
1197 if not email_address:
1198 partner_ids.append(partner_id)
1200 email_address = email_address[0]
1201 # first try: check in document's followers
1203 for follower in obj.message_follower_ids:
1204 if follower.email == email_address:
1205 partner_id = follower.id
1206 # second try: check in partners that are also users
1208 ids = partner_obj.search(cr, SUPERUSER_ID, [
1209 ('email', 'ilike', email_address),
1210 ('user_ids', '!=', False)
1211 ], limit=1, context=context)
1214 # third try: check in partners
1216 ids = partner_obj.search(cr, SUPERUSER_ID, [
1217 ('email', 'ilike', email_address)
1218 ], limit=1, context=context)
1221 partner_ids.append(partner_id)
1224 def message_partner_info_from_emails(self, cr, uid, id, emails, link_mail=False, context=None):
1225 """ Convert a list of emails into a list partner_ids and a list
1226 new_partner_ids. The return value is non conventional because
1227 it is meant to be used by the mail widget.
1229 :return dict: partner_ids and new_partner_ids """
1230 mail_message_obj = self.pool.get('mail.message')
1231 partner_ids = self._find_partner_from_emails(cr, uid, id, emails, context=context)
1233 for idx in range(len(emails)):
1234 email_address = emails[idx]
1235 partner_id = partner_ids[idx]
1236 partner_info = {'full_name': email_address, 'partner_id': partner_id}
1237 result.append(partner_info)
1239 # link mail with this from mail to the new partner id
1240 if link_mail and partner_info['partner_id']:
1241 message_ids = mail_message_obj.search(cr, SUPERUSER_ID, [
1243 ('email_from', '=', email_address),
1244 ('email_from', 'ilike', '<%s>' % email_address),
1245 ('author_id', '=', False)
1248 mail_message_obj.write(cr, SUPERUSER_ID, message_ids, {'author_id': partner_info['partner_id']}, context=context)
1251 def message_post(self, cr, uid, thread_id, body='', subject=None, type='notification',
1252 subtype=None, parent_id=False, attachments=None, context=None,
1253 content_subtype='html', **kwargs):
1254 """ Post a new message in an existing thread, returning the new
1257 :param int thread_id: thread ID to post into, or list with one ID;
1258 if False/0, mail.message model will also be set as False
1259 :param str body: body of the message, usually raw HTML that will
1261 :param str type: see mail_message.type field
1262 :param str content_subtype:: if plaintext: convert body into html
1263 :param int parent_id: handle reply to a previous message by adding the
1264 parent partners to the message in case of private discussion
1265 :param tuple(str,str) attachments or list id: list of attachment tuples in the form
1266 ``(name,content)``, where content is NOT base64 encoded
1268 Extra keyword arguments will be used as default column values for the
1269 new mail.message record. Special cases:
1270 - attachment_ids: supposed not attached to any document; attach them
1271 to the related document. Should only be set by Chatter.
1272 :return int: ID of newly created mail.message
1276 if attachments is None:
1278 mail_message = self.pool.get('mail.message')
1279 ir_attachment = self.pool.get('ir.attachment')
1281 assert (not thread_id) or \
1282 isinstance(thread_id, (int, long)) or \
1283 (isinstance(thread_id, (list, tuple)) and len(thread_id) == 1), \
1284 "Invalid thread_id; should be 0, False, an ID or a list with one ID"
1285 if isinstance(thread_id, (list, tuple)):
1286 thread_id = thread_id[0]
1288 # if we're processing a message directly coming from the gateway, the destination model was
1289 # set in the context.
1292 model = context.get('thread_model', self._name) if self._name == 'mail.thread' else self._name
1293 if model != self._name and hasattr(self.pool[model], 'message_post'):
1294 del context['thread_model']
1295 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)
1297 #0: Find the message's author, because we need it for private discussion
1298 author_id = kwargs.get('author_id')
1299 if author_id is None: # keep False values
1300 author_id = self.pool.get('mail.message')._get_default_author(cr, uid, context=context)
1302 # 1: Handle content subtype: if plaintext, converto into HTML
1303 if content_subtype == 'plaintext':
1304 body = tools.plaintext2html(body)
1306 # 2: Private message: add recipients (recipients and author of parent message) - current author
1307 # + legacy-code management (! we manage only 4 and 6 commands)
1309 kwargs_partner_ids = kwargs.pop('partner_ids', [])
1310 for partner_id in kwargs_partner_ids:
1311 if isinstance(partner_id, (list, tuple)) and partner_id[0] == 4 and len(partner_id) == 2:
1312 partner_ids.add(partner_id[1])
1313 if isinstance(partner_id, (list, tuple)) and partner_id[0] == 6 and len(partner_id) == 3:
1314 partner_ids |= set(partner_id[2])
1315 elif isinstance(partner_id, (int, long)):
1316 partner_ids.add(partner_id)
1318 pass # we do not manage anything else
1319 if parent_id and not model:
1320 parent_message = mail_message.browse(cr, uid, parent_id, context=context)
1321 private_followers = set([partner.id for partner in parent_message.partner_ids])
1322 if parent_message.author_id:
1323 private_followers.add(parent_message.author_id.id)
1324 private_followers -= set([author_id])
1325 partner_ids |= private_followers
1328 # - HACK TDE FIXME: Chatter: attachments linked to the document (not done JS-side), load the message
1329 attachment_ids = kwargs.pop('attachment_ids', []) or [] # because we could receive None (some old code sends None)
1331 filtered_attachment_ids = ir_attachment.search(cr, SUPERUSER_ID, [
1332 ('res_model', '=', 'mail.compose.message'),
1333 ('create_uid', '=', uid),
1334 ('id', 'in', attachment_ids)], context=context)
1335 if filtered_attachment_ids:
1336 ir_attachment.write(cr, SUPERUSER_ID, filtered_attachment_ids, {'res_model': model, 'res_id': thread_id}, context=context)
1337 attachment_ids = [(4, id) for id in attachment_ids]
1338 # Handle attachments parameter, that is a dictionary of attachments
1339 for name, content in attachments:
1340 if isinstance(content, unicode):
1341 content = content.encode('utf-8')
1344 'datas': base64.b64encode(str(content)),
1345 'datas_fname': name,
1346 'description': name,
1348 'res_id': thread_id,
1350 attachment_ids.append((0, 0, data_attach))
1352 # 4: mail.message.subtype
1355 if '.' not in subtype:
1356 subtype = 'mail.%s' % subtype
1357 ref = self.pool.get('ir.model.data').get_object_reference(cr, uid, *subtype.split('.'))
1358 subtype_id = ref and ref[1] or False
1360 # automatically subscribe recipients if asked to
1361 if context.get('mail_post_autofollow') and thread_id and partner_ids:
1362 partner_to_subscribe = partner_ids
1363 if context.get('mail_post_autofollow_partner_ids'):
1364 partner_to_subscribe = filter(lambda item: item in context.get('mail_post_autofollow_partner_ids'), partner_ids)
1365 self.message_subscribe(cr, uid, [thread_id], list(partner_to_subscribe), context=context)
1367 # _mail_flat_thread: automatically set free messages to the first posted message
1368 if self._mail_flat_thread and not parent_id and thread_id:
1369 message_ids = mail_message.search(cr, uid, ['&', ('res_id', '=', thread_id), ('model', '=', model)], context=context, order="id ASC", limit=1)
1370 parent_id = message_ids and message_ids[0] or False
1371 # 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
1373 message_ids = mail_message.search(cr, SUPERUSER_ID, [('id', '=', parent_id), ('parent_id', '!=', False)], context=context)
1374 # avoid loops when finding ancestors
1377 message = mail_message.browse(cr, SUPERUSER_ID, message_ids[0], context=context)
1378 while (message.parent_id and message.parent_id.id not in processed_list):
1379 processed_list.append(message.parent_id.id)
1380 message = message.parent_id
1381 parent_id = message.id
1385 'author_id': author_id,
1387 'res_id': thread_id or False,
1389 'subject': subject or False,
1391 'parent_id': parent_id,
1392 'attachment_ids': attachment_ids,
1393 'subtype_id': subtype_id,
1394 'partner_ids': [(4, pid) for pid in partner_ids],
1397 # Avoid warnings about non-existing fields
1398 for x in ('from', 'to', 'cc'):
1401 # Create and auto subscribe the author
1402 msg_id = mail_message.create(cr, uid, values, context=context)
1403 message = mail_message.browse(cr, uid, msg_id, context=context)
1404 if message.author_id and thread_id and type != 'notification' and not context.get('mail_create_nosubscribe'):
1405 self.message_subscribe(cr, uid, [thread_id], [message.author_id.id], context=context)
1408 #------------------------------------------------------
1410 #------------------------------------------------------
1412 def message_get_subscription_data(self, cr, uid, ids, user_pid=None, context=None):
1413 """ Wrapper to get subtypes data. """
1414 return self._get_subscription_data(cr, uid, ids, None, None, user_pid=user_pid, context=context)
1416 def message_subscribe_users(self, cr, uid, ids, user_ids=None, subtype_ids=None, context=None):
1417 """ Wrapper on message_subscribe, using users. If user_ids is not
1418 provided, subscribe uid instead. """
1419 if user_ids is None:
1421 partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
1422 return self.message_subscribe(cr, uid, ids, partner_ids, subtype_ids=subtype_ids, context=context)
1424 def message_subscribe(self, cr, uid, ids, partner_ids, subtype_ids=None, context=None):
1425 """ Add partners to the records followers. """
1426 mail_followers_obj = self.pool.get('mail.followers')
1427 subtype_obj = self.pool.get('mail.message.subtype')
1429 user_pid = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1430 if set(partner_ids) == set([user_pid]):
1432 self.check_access_rights(cr, uid, 'read')
1433 except (osv.except_osv, orm.except_orm):
1436 self.check_access_rights(cr, uid, 'write')
1438 for record in self.browse(cr, SUPERUSER_ID, ids, context=context):
1439 existing_pids = set([f.id for f in record.message_follower_ids
1440 if f.id in partner_ids])
1441 new_pids = set(partner_ids) - existing_pids
1443 # subtype_ids specified: update already subscribed partners
1444 if subtype_ids and existing_pids:
1445 fol_ids = mail_followers_obj.search(cr, SUPERUSER_ID, [
1446 ('res_model', '=', self._name),
1447 ('res_id', '=', record.id),
1448 ('partner_id', 'in', list(existing_pids)),
1450 mail_followers_obj.write(cr, SUPERUSER_ID, fol_ids, {'subtype_ids': [(6, 0, subtype_ids)]}, context=context)
1451 # subtype_ids not specified: do not update already subscribed partner, fetch default subtypes for new partners
1452 elif subtype_ids is None:
1453 subtype_ids = subtype_obj.search(cr, uid, [
1454 ('default', '=', True),
1456 ('res_model', '=', self._name),
1457 ('res_model', '=', False)
1459 # subscribe new followers
1460 for new_pid in new_pids:
1461 mail_followers_obj.create(cr, SUPERUSER_ID, {
1462 'res_model': self._name,
1463 'res_id': record.id,
1464 'partner_id': new_pid,
1465 'subtype_ids': [(6, 0, subtype_ids)],
1470 def message_unsubscribe_users(self, cr, uid, ids, user_ids=None, context=None):
1471 """ Wrapper on message_subscribe, using users. If user_ids is not
1472 provided, unsubscribe uid instead. """
1473 if user_ids is None:
1475 partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
1476 return self.message_unsubscribe(cr, uid, ids, partner_ids, context=context)
1478 def message_unsubscribe(self, cr, uid, ids, partner_ids, context=None):
1479 """ Remove partners from the records followers. """
1480 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
1481 if set(partner_ids) == set([user_pid]):
1482 self.check_access_rights(cr, uid, 'read')
1484 self.check_access_rights(cr, uid, 'write')
1485 return self.write(cr, SUPERUSER_ID, ids, {'message_follower_ids': [(3, pid) for pid in partner_ids]}, context=context)
1487 def _message_get_auto_subscribe_fields(self, cr, uid, updated_fields, auto_follow_fields=['user_id'], context=None):
1488 """ Returns the list of relational fields linking to res.users that should
1489 trigger an auto subscribe. The default list checks for the fields
1491 - linking to res.users
1492 - with track_visibility set
1493 In OpenERP V7, this is sufficent for all major addon such as opportunity,
1494 project, issue, recruitment, sale.
1495 Override this method if a custom behavior is needed about fields
1496 that automatically subscribe users.
1499 for name, column_info in self._all_columns.items():
1500 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':
1501 user_field_lst.append(name)
1502 return user_field_lst
1504 def message_auto_subscribe(self, cr, uid, ids, updated_fields, context=None):
1506 1. fetch project subtype related to task (parent_id.res_model = 'project.task')
1507 2. for each project subtype: subscribe the follower to the task
1509 subtype_obj = self.pool.get('mail.message.subtype')
1510 follower_obj = self.pool.get('mail.followers')
1512 # fetch auto_follow_fields
1513 user_field_lst = self._message_get_auto_subscribe_fields(cr, uid, updated_fields, context=context)
1515 # fetch related record subtypes
1516 related_subtype_ids = subtype_obj.search(cr, uid, ['|', ('res_model', '=', False), ('parent_id.res_model', '=', self._name)], context=context)
1517 subtypes = subtype_obj.browse(cr, uid, related_subtype_ids, context=context)
1518 default_subtypes = [subtype for subtype in subtypes if subtype.res_model == False]
1519 related_subtypes = [subtype for subtype in subtypes if subtype.res_model != False]
1520 relation_fields = set([subtype.relation_field for subtype in subtypes if subtype.relation_field != False])
1521 if (not related_subtypes or not any(relation in updated_fields for relation in relation_fields)) and not user_field_lst:
1524 for record in self.browse(cr, uid, ids, context=context):
1525 new_followers = dict()
1526 parent_res_id = False
1527 parent_model = False
1528 for subtype in related_subtypes:
1529 if not subtype.relation_field or not subtype.parent_id:
1531 if not subtype.relation_field in self._columns or not getattr(record, subtype.relation_field, False):
1533 parent_res_id = getattr(record, subtype.relation_field).id
1534 parent_model = subtype.res_model
1535 follower_ids = follower_obj.search(cr, SUPERUSER_ID, [
1536 ('res_model', '=', parent_model),
1537 ('res_id', '=', parent_res_id),
1538 ('subtype_ids', 'in', [subtype.id])
1540 for follower in follower_obj.browse(cr, SUPERUSER_ID, follower_ids, context=context):
1541 new_followers.setdefault(follower.partner_id.id, set()).add(subtype.parent_id.id)
1543 if parent_res_id and parent_model:
1544 for subtype in default_subtypes:
1545 follower_ids = follower_obj.search(cr, SUPERUSER_ID, [
1546 ('res_model', '=', parent_model),
1547 ('res_id', '=', parent_res_id),
1548 ('subtype_ids', 'in', [subtype.id])
1550 for follower in follower_obj.browse(cr, SUPERUSER_ID, follower_ids, context=context):
1551 new_followers.setdefault(follower.partner_id.id, set()).add(subtype.id)
1553 # add followers coming from res.users relational fields that are tracked
1554 user_ids = [getattr(record, name).id for name in user_field_lst if getattr(record, name)]
1555 user_id_partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, SUPERUSER_ID, user_ids, context=context)]
1556 for partner_id in user_id_partner_ids:
1557 new_followers.setdefault(partner_id, None)
1559 for pid, subtypes in new_followers.items():
1560 subtypes = list(subtypes) if subtypes is not None else None
1561 self.message_subscribe(cr, uid, [record.id], [pid], subtypes, context=context)
1563 # find first email message, set it as unread for auto_subscribe fields for them to have a notification
1564 if user_id_partner_ids:
1565 msg_ids = self.pool.get('mail.message').search(cr, uid, [
1566 ('model', '=', self._name),
1567 ('res_id', '=', record.id),
1568 ('type', '=', 'email')], limit=1, context=context)
1569 if not msg_ids and record.message_ids:
1570 msg_ids = [record.message_ids[-1].id]
1572 self.pool.get('mail.notification')._notify(cr, uid, msg_ids[0], partners_to_notify=user_id_partner_ids, context=context)
1576 #------------------------------------------------------
1578 #------------------------------------------------------
1580 def message_mark_as_unread(self, cr, uid, ids, context=None):
1581 """ Set as unread. """
1582 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1584 UPDATE mail_notification SET
1587 message_id IN (SELECT id from mail_message where res_id=any(%s) and model=%s limit 1) and
1589 ''', (ids, self._name, partner_id))
1592 def message_mark_as_read(self, cr, uid, ids, context=None):
1593 """ Set as read. """
1594 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1596 UPDATE mail_notification SET
1599 message_id IN (SELECT id FROM mail_message WHERE res_id=ANY(%s) AND model=%s) AND
1601 ''', (ids, self._name, partner_id))
1604 #------------------------------------------------------
1606 #------------------------------------------------------
1608 def get_suggested_thread(self, cr, uid, removed_suggested_threads=None, context=None):
1609 """Return a list of suggested threads, sorted by the numbers of followers"""
1613 # TDE HACK: originally by MAT from portal/mail_mail.py but not working until the inheritance graph bug is not solved in trunk
1614 # TDE FIXME: relocate in portal when it won't be necessary to reload the hr.employee model in an additional bridge module
1615 if self.pool['res.groups']._all_columns.get('is_portal'):
1616 user = self.pool.get('res.users').browse(cr, SUPERUSER_ID, uid, context=context)
1617 if any(group.is_portal for group in user.groups_id):
1621 if removed_suggested_threads is None:
1622 removed_suggested_threads = []
1624 thread_ids = self.search(cr, uid, [('id', 'not in', removed_suggested_threads), ('message_is_follower', '=', False)], context=context)
1625 for thread in self.browse(cr, uid, thread_ids, context=context):
1628 'popularity': len(thread.message_follower_ids),
1629 'name': thread.name,
1630 'image_small': thread.image_small
1632 threads.append(data)
1633 return sorted(threads, key=lambda x: (x['popularity'], x['id']), reverse=True)[:3]