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 read_followers_data(self, cr, uid, follower_ids, context=None):
161 partner_pool = self.pool.get('res.partner')
162 technical_group = self.pool.get('ir.model.data').get_object(cr, uid, 'base', 'group_no_one')
163 for follower in partner_pool.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)
169 {'is_editable': is_editable, 'is_uid': is_uid},
174 def _get_subscription_data(self, cr, uid, ids, name, args, user_pid=None, context=None):
176 - message_subtype_data: data about document subtypes: which are
177 available, which are followed if any """
178 res = dict((id, dict(message_subtype_data='')) for id in ids)
180 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
182 # find current model subtypes, add them to a dictionary
183 subtype_obj = self.pool.get('mail.message.subtype')
184 subtype_ids = subtype_obj.search(cr, uid, ['|', ('res_model', '=', self._name), ('res_model', '=', False)], context=context)
185 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))
187 res[id]['message_subtype_data'] = subtype_dict.copy()
189 # find the document followers, update the data
190 fol_obj = self.pool.get('mail.followers')
191 fol_ids = fol_obj.search(cr, uid, [
192 ('partner_id', '=', user_pid),
193 ('res_id', 'in', ids),
194 ('res_model', '=', self._name),
196 for fol in fol_obj.browse(cr, uid, fol_ids, context=context):
197 thread_subtype_dict = res[fol.res_id]['message_subtype_data']
198 for subtype in fol.subtype_ids:
199 thread_subtype_dict[subtype.name]['followed'] = True
200 res[fol.res_id]['message_subtype_data'] = thread_subtype_dict
204 def _search_message_unread(self, cr, uid, obj=None, name=None, domain=None, context=None):
205 return [('message_ids.to_read', '=', True)]
207 def _get_followers(self, cr, uid, ids, name, arg, context=None):
208 fol_obj = self.pool.get('mail.followers')
209 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids)])
210 res = dict((id, dict(message_follower_ids=[], message_is_follower=False)) for id in ids)
211 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
212 for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids):
213 res[fol.res_id]['message_follower_ids'].append(fol.partner_id.id)
214 if fol.partner_id.id == user_pid:
215 res[fol.res_id]['message_is_follower'] = True
218 def _set_followers(self, cr, uid, id, name, value, arg, context=None):
221 partner_obj = self.pool.get('res.partner')
222 fol_obj = self.pool.get('mail.followers')
224 # read the old set of followers, and determine the new set of followers
225 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', '=', id)])
226 old = set(fol.partner_id.id for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids))
229 for command in value or []:
230 if isinstance(command, (int, long)):
232 elif command[0] == 0:
233 new.add(partner_obj.create(cr, uid, command[2], context=context))
234 elif command[0] == 1:
235 partner_obj.write(cr, uid, [command[1]], command[2], context=context)
237 elif command[0] == 2:
238 partner_obj.unlink(cr, uid, [command[1]], context=context)
239 new.discard(command[1])
240 elif command[0] == 3:
241 new.discard(command[1])
242 elif command[0] == 4:
244 elif command[0] == 5:
246 elif command[0] == 6:
247 new = set(command[2])
249 # remove partners that are no longer followers
250 fol_ids = fol_obj.search(cr, SUPERUSER_ID,
251 [('res_model', '=', self._name), ('res_id', '=', id), ('partner_id', 'not in', list(new))])
252 fol_obj.unlink(cr, SUPERUSER_ID, fol_ids)
255 for partner_id in new - old:
256 fol_obj.create(cr, SUPERUSER_ID, {'res_model': self._name, 'res_id': id, 'partner_id': partner_id})
258 def _search_followers(self, cr, uid, obj, name, args, context):
259 """Search function for message_follower_ids
261 Do not use with operator 'not in'. Use instead message_is_followers
263 fol_obj = self.pool.get('mail.followers')
265 for field, operator, value in args:
267 # TOFIX make it work with not in
268 assert operator != "not in", "Do not search message_follower_ids with 'not in'"
269 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('partner_id', operator, value)])
270 res_ids = [fol.res_id for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids)]
271 res.append(('id', 'in', res_ids))
274 def _search_is_follower(self, cr, uid, obj, name, args, context):
275 """Search function for message_is_follower"""
277 for field, operator, value in args:
279 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
280 if (operator == '=' and value) or (operator == '!=' and not value): # is a follower
281 res_ids = self.search(cr, uid, [('message_follower_ids', 'in', [partner_id])], context=context)
282 else: # is not a follower or unknown domain
283 mail_ids = self.search(cr, uid, [('message_follower_ids', 'in', [partner_id])], context=context)
284 res_ids = self.search(cr, uid, [('id', 'not in', mail_ids)], context=context)
285 res.append(('id', 'in', res_ids))
289 'message_is_follower': fields.function(_get_followers, type='boolean',
290 fnct_search=_search_is_follower, string='Is a Follower', multi='_get_followers,'),
291 'message_follower_ids': fields.function(_get_followers, fnct_inv=_set_followers,
292 fnct_search=_search_followers, type='many2many',
293 obj='res.partner', string='Followers', multi='_get_followers'),
294 'message_ids': fields.one2many('mail.message', 'res_id',
295 domain=lambda self: [('model', '=', self._name)],
298 help="Messages and communication history"),
299 'message_unread': fields.function(_get_message_data,
300 fnct_search=_search_message_unread, multi="_get_message_data",
301 type='boolean', string='Unread Messages',
302 help="If checked new messages require your attention."),
303 'message_summary': fields.function(_get_message_data, method=True,
304 type='text', string='Summary', multi="_get_message_data",
305 help="Holds the Chatter summary (number of messages, ...). "\
306 "This summary is directly in html format in order to "\
307 "be inserted in kanban views."),
310 #------------------------------------------------------
311 # CRUD overrides for automatic subscription and logging
312 #------------------------------------------------------
314 def create(self, cr, uid, values, context=None):
315 """ Chatter override :
317 - subscribe followers of parent
318 - log a creation message
322 thread_id = super(mail_thread, self).create(cr, uid, values, context=context)
324 # automatic logging unless asked not to (mainly for various testing purpose)
325 if not context.get('mail_create_nolog'):
326 self.message_post(cr, uid, thread_id, body=_('%s created') % (self._description), context=context)
328 # subscribe uid unless asked not to
329 if not context.get('mail_create_nosubscribe'):
330 self.message_subscribe_users(cr, uid, [thread_id], [uid], context=context)
331 # auto_subscribe: take values and defaults into account
332 create_values = set(values.keys())
333 for key, val in context.iteritems():
334 if key.startswith('default_'):
335 create_values.add(key[8:])
336 self.message_auto_subscribe(cr, uid, [thread_id], list(create_values), context=context)
339 tracked_fields = self._get_tracked_fields(cr, uid, values.keys(), context=context)
341 initial_values = {thread_id: dict((item, False) for item in tracked_fields)}
342 self.message_track(cr, uid, [thread_id], tracked_fields, initial_values, context=context)
346 def write(self, cr, uid, ids, values, context=None):
347 if isinstance(ids, (int, long)):
349 # Track initial values of tracked fields
350 tracked_fields = self._get_tracked_fields(cr, uid, values.keys(), context=context)
352 records = self.browse(cr, uid, ids, context=context)
353 initial_values = dict((this.id, dict((key, getattr(this, key)) for key in tracked_fields.keys())) for this in records)
355 # Perform write, update followers
356 result = super(mail_thread, self).write(cr, uid, ids, values, context=context)
357 self.message_auto_subscribe(cr, uid, ids, values.keys(), context=context)
359 # Perform the tracking
361 self.message_track(cr, uid, ids, tracked_fields, initial_values, context=context)
364 def unlink(self, cr, uid, ids, context=None):
365 """ Override unlink to delete messages and followers. This cannot be
366 cascaded, because link is done through (res_model, res_id). """
367 msg_obj = self.pool.get('mail.message')
368 fol_obj = self.pool.get('mail.followers')
369 # delete messages and notifications
370 msg_ids = msg_obj.search(cr, uid, [('model', '=', self._name), ('res_id', 'in', ids)], context=context)
371 msg_obj.unlink(cr, uid, msg_ids, context=context)
373 res = super(mail_thread, self).unlink(cr, uid, ids, context=context)
375 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids)], context=context)
376 fol_obj.unlink(cr, SUPERUSER_ID, fol_ids, context=context)
379 def copy(self, cr, uid, id, default=None, context=None):
380 default = default or {}
381 default['message_ids'] = []
382 default['message_follower_ids'] = []
383 return super(mail_thread, self).copy(cr, uid, id, default=default, context=context)
385 #------------------------------------------------------
386 # Automatically log tracked fields
387 #------------------------------------------------------
389 def _get_tracked_fields(self, cr, uid, updated_fields, context=None):
390 """ Return a structure of tracked fields for the current model.
391 :param list updated_fields: modified field names
392 :return list: a list of (field_name, column_info obj), containing
393 always tracked fields and modified on_change fields
396 for name, column_info in self._all_columns.items():
397 visibility = getattr(column_info.column, 'track_visibility', False)
398 if visibility == 'always' or (visibility == 'onchange' and name in updated_fields) or name in self._track:
402 return self.fields_get(cr, uid, lst, context=context)
404 def message_track(self, cr, uid, ids, tracked_fields, initial_values, context=None):
406 def convert_for_display(value, col_info):
407 if not value and col_info['type'] == 'boolean':
411 if col_info['type'] == 'many2one':
412 return value.name_get()[0][1]
413 if col_info['type'] == 'selection':
414 return dict(col_info['selection'])[value]
417 def format_message(message_description, tracked_values):
419 if message_description:
420 message = '<span>%s</span>' % message_description
421 for name, change in tracked_values.items():
422 message += '<div> • <b>%s</b>: ' % change.get('col_info')
423 if change.get('old_value'):
424 message += '%s → ' % change.get('old_value')
425 message += '%s</div>' % change.get('new_value')
428 if not tracked_fields:
431 for browse_record in self.browse(cr, uid, ids, context=context):
432 initial = initial_values[browse_record.id]
436 # generate tracked_values data structure: {'col_name': {col_info, new_value, old_value}}
437 for col_name, col_info in tracked_fields.items():
438 initial_value = initial[col_name]
439 record_value = getattr(browse_record, col_name)
441 if record_value == initial_value and getattr(self._all_columns[col_name].column, 'track_visibility', None) == 'always':
442 tracked_values[col_name] = dict(col_info=col_info['string'],
443 new_value=convert_for_display(record_value, col_info))
444 elif record_value != initial_value and (record_value or initial_value): # because browse null != False
445 if getattr(self._all_columns[col_name].column, 'track_visibility', None) in ['always', 'onchange']:
446 tracked_values[col_name] = dict(col_info=col_info['string'],
447 old_value=convert_for_display(initial_value, col_info),
448 new_value=convert_for_display(record_value, col_info))
449 if col_name in tracked_fields:
450 changes.add(col_name)
454 # find subtypes and post messages or log if no subtype found
456 for field, track_info in self._track.items():
457 if field not in changes:
459 for subtype, method in track_info.items():
460 if method(self, cr, uid, browse_record, context):
461 subtypes.append(subtype)
464 for subtype in subtypes:
466 subtype_rec = self.pool.get('ir.model.data').get_object(cr, uid, subtype.split('.')[0], subtype.split('.')[1], context=context)
467 except ValueError, e:
468 _logger.debug('subtype %s not found, giving error "%s"' % (subtype, e))
470 message = format_message(subtype_rec.description if subtype_rec.description else subtype_rec.name, tracked_values)
471 self.message_post(cr, uid, browse_record.id, body=message, subtype=subtype, context=context)
474 message = format_message('', tracked_values)
475 self.message_post(cr, uid, browse_record.id, body=message, context=context)
478 #------------------------------------------------------
479 # mail.message wrappers and tools
480 #------------------------------------------------------
482 def _needaction_domain_get(self, cr, uid, context=None):
484 return [('message_unread', '=', True)]
487 def _garbage_collect_attachments(self, cr, uid, context=None):
488 """ Garbage collect lost mail attachments. Those are attachments
489 - linked to res_model 'mail.compose.message', the composer wizard
490 - with res_id 0, because they were created outside of an existing
491 wizard (typically user input through Chatter or reports
492 created on-the-fly by the templates)
493 - unused since at least one day (create_date and write_date)
495 limit_date = datetime.datetime.utcnow() - datetime.timedelta(days=1)
496 limit_date_str = datetime.datetime.strftime(limit_date, tools.DEFAULT_SERVER_DATETIME_FORMAT)
497 ir_attachment_obj = self.pool.get('ir.attachment')
498 attach_ids = ir_attachment_obj.search(cr, uid, [
499 ('res_model', '=', 'mail.compose.message'),
501 ('create_date', '<', limit_date_str),
502 ('write_date', '<', limit_date_str),
504 ir_attachment_obj.unlink(cr, uid, attach_ids, context=context)
507 def check_mail_message_access(self, cr, uid, mids, operation, model_obj=None, context=None):
508 """ mail.message check permission rules for related document. This method is
509 meant to be inherited in order to implement addons-specific behavior.
510 A common behavior would be to allow creating messages when having read
511 access rule on the document, for portal document such as issues. """
514 if operation in ['create', 'write', 'unlink']:
515 model_obj.check_access_rights(cr, uid, 'write')
516 model_obj.check_access_rule(cr, uid, mids, 'write', context=context)
518 model_obj.check_access_rights(cr, uid, operation)
519 model_obj.check_access_rule(cr, uid, mids, operation, context=context)
521 def _get_formview_action(self, cr, uid, id, model=None, context=None):
522 """ Return an action to open the document. This method is meant to be
523 overridden in addons that want to give specific view ids for example.
525 :param int id: id of the document to open
526 :param string model: specific model that overrides self._name
529 'type': 'ir.actions.act_window',
530 'res_model': model or self._name,
533 'views': [(False, 'form')],
538 def _get_inbox_action_xml_id(self, cr, uid, context=None):
539 """ When redirecting towards the Inbox, choose which action xml_id has
540 to be fetched. This method is meant to be inherited, at least in portal
541 because portal users have a different Inbox action than classic users. """
542 return ('mail', 'action_mail_inbox_feeds')
544 def message_redirect_action(self, cr, uid, context=None):
545 """ For a given message, return an action that either
546 - opens the form view of the related document if model, res_id, and
547 read access to the document
548 - opens the Inbox with a default search on the conversation if model,
550 - opens the Inbox with context propagated
556 # default action is the Inbox action
557 self.pool.get('res.users').browse(cr, SUPERUSER_ID, uid, context=context)
558 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))
559 action = self.pool.get(act_model).read(cr, uid, act_id, [])
561 # if msg_id specified: try to redirect to the document or fallback on the Inbox
562 msg_id = context.get('params', {}).get('message_id')
565 msg = self.pool.get('mail.message').browse(cr, uid, msg_id, context=context)
566 if msg.model and msg.res_id:
569 'search_default_model': msg.model,
570 'search_default_res_id': msg.res_id,
573 if self.pool.get(msg.model).check_access_rights(cr, uid, 'read', raise_exception=False):
575 model_obj = self.pool.get(msg.model)
576 model_obj.check_access_rule(cr, uid, [msg.res_id], 'read', context=context)
577 if not hasattr(model_obj, '_get_formview_action'):
578 action = self.pool.get('mail.thread')._get_formview_action(cr, uid, msg.res_id, model=msg.model, context=context)
580 action = model_obj._get_formview_action(cr, uid, msg.res_id, context=context)
581 except (osv.except_osv, orm.except_orm):
585 #------------------------------------------------------
587 #------------------------------------------------------
589 def message_get_reply_to(self, cr, uid, ids, context=None):
590 """ Returns the preferred reply-to email address that is basically
591 the alias of the document, if it exists. """
592 if not self._inherits.get('mail.alias'):
593 return [False for id in ids]
594 return ["%s@%s" % (record['alias_name'], record['alias_domain'])
595 if record.get('alias_domain') and record.get('alias_name')
597 for record in self.read(cr, SUPERUSER_ID, ids, ['alias_name', 'alias_domain'], context=context)]
599 #------------------------------------------------------
601 #------------------------------------------------------
603 def message_capable_models(self, cr, uid, context=None):
604 """ Used by the plugin addon, based for plugin_outlook and others. """
606 for model_name in self.pool.obj_list():
607 model = self.pool[model_name]
608 if hasattr(model, "message_process") and hasattr(model, "message_post"):
609 ret_dict[model_name] = model._description
612 def _message_find_partners(self, cr, uid, message, header_fields=['From'], context=None):
613 """ Find partners related to some header fields of the message.
615 :param string message: an email.message instance """
616 s = ', '.join([decode(message.get(h)) for h in header_fields if message.get(h)])
617 return filter(lambda x: x, self._find_partner_from_emails(cr, uid, None, tools.email_split(s), context=context))
619 def message_route_verify(self, cr, uid, message, message_dict, route, update_author=True, assert_model=True, create_fallback=True, context=None):
620 """ Verify route validity. Check and rules:
621 1 - if thread_id -> check that document effectively exists; otherwise
622 fallback on a message_new by resetting thread_id
623 2 - check that message_update exists if thread_id is set; or at least
624 that message_new exist
625 [ - find author_id if udpate_author is set]
626 3 - if there is an alias, check alias_contact:
627 'followers' and thread_id:
628 check on target document that the author is in the followers
629 'followers' and alias_parent_thread_id:
630 check on alias parent document that the author is in the
632 'partners': check that author_id id set
635 assert isinstance(route, (list, tuple)), 'A route should be a list or a tuple'
636 assert len(route) == 5, 'A route should contain 5 elements: model, thread_id, custom_values, uid, alias record'
638 message_id = message.get('Message-Id')
639 email_from = decode_header(message, 'From')
640 author_id = message_dict.get('author_id')
641 model, thread_id, alias = route[0], route[1], route[4]
644 def _create_bounce_email():
645 mail_mail = self.pool.get('mail.mail')
646 mail_id = mail_mail.create(cr, uid, {
647 'body_html': '<div><p>Hello,</p>'
648 '<p>The following email sent to %s cannot be accepted because this is '
649 'a private email address. Only allowed people can contact us at this address.</p></div>'
650 '<blockquote>%s</blockquote>' % (message.get('to'), message_dict.get('body')),
651 'subject': 'Re: %s' % message.get('subject'),
652 'email_to': message.get('from'),
655 mail_mail.send(cr, uid, [mail_id], context=context)
658 _logger.warning('Routing mail with Message-Id %s: route %s: %s',
659 message_id, route, message)
662 if model and not model in self.pool:
664 assert model in self.pool, 'Routing: unknown target model %s' % model
665 _warn('unknown target model %s' % model)
668 model_pool = self.pool[model]
670 # Private message: should not contain any thread_id
671 if not model and thread_id:
673 assert thread_id == 0, 'Routing: posting a message without model should be with a null res_id (private message).'
674 _warn('posting a message without model should be with a null res_id (private message), resetting thread_id')
677 # Existing Document: check if exists; if not, fallback on create if allowed
678 if thread_id and not model_pool.exists(cr, uid, thread_id):
680 _warn('reply to missing document (%s,%s), fall back on new document creation' % (model, thread_id))
683 assert model_pool.exists(cr, uid, thread_id), 'Routing: reply to missing document (%s,%s)' % (model, thread_id)
685 _warn('reply to missing document (%s,%s), skipping' % (model, thread_id))
688 # Existing Document: check model accepts the mailgateway
689 if thread_id and not hasattr(model_pool, 'message_update'):
691 _warn('model %s does not accept document update, fall back on document creation' % model)
694 assert hasattr(model_pool, 'message_update'), 'Routing: model %s does not accept document update, crashing' % model
696 _warn('model %s does not accept document update, skipping' % model)
699 # New Document: check model accepts the mailgateway
700 if not thread_id and not hasattr(model_pool, 'message_new'):
702 assert hasattr(model_pool, 'message_new'), 'Model %s does not accept document creation, crashing' % model
703 _warn('model %s does not accept document creation, skipping' % model)
706 # Update message author if asked
707 # We do it now because we need it for aliases (contact settings)
708 if not author_id and update_author:
709 author_ids = self._find_partner_from_emails(cr, uid, thread_id, [email_from], model=model, context=context)
711 author_id = author_ids[0]
712 message_dict['author_id'] = author_id
714 # Alias: check alias_contact settings
715 if alias and alias.alias_contact == 'followers' and (thread_id or alias.alias_parent_thread_id):
717 obj = self.pool[model].browse(cr, uid, thread_id, context=context)
719 obj = self.pool[alias.alias_parent_model_id.model].browse(cr, uid, alias.alias_parent_thread_id, context=context)
720 if not author_id or not author_id in [fol.id for fol in obj.message_follower_ids]:
721 _warn('alias %s restricted to internal followers, skipping' % alias.alias_name)
722 _create_bounce_email()
724 elif alias and alias.alias_contact == 'partners' and not author_id:
725 _warn('alias %s does not accept unknown author, skipping' % alias.alias_name)
726 _create_bounce_email()
729 return (model, thread_id, route[2], route[3], route[4])
731 def message_route(self, cr, uid, message, message_dict, model=None, thread_id=None,
732 custom_values=None, context=None):
733 """Attempt to figure out the correct target model, thread_id,
734 custom_values and user_id to use for an incoming message.
735 Multiple values may be returned, if a message had multiple
736 recipients matching existing mail.aliases, for example.
738 The following heuristics are used, in this order:
739 1. If the message replies to an existing thread_id, and
740 properly contains the thread model in the 'In-Reply-To'
741 header, use this model/thread_id pair, and ignore
742 custom_value (not needed as no creation will take place)
743 2. Look for a mail.alias entry matching the message
744 recipient, and use the corresponding model, thread_id,
745 custom_values and user_id.
746 3. Fallback to the ``model``, ``thread_id`` and ``custom_values``
748 4. If all the above fails, raise an exception.
750 :param string message: an email.message instance
751 :param dict message_dict: dictionary holding message variables
752 :param string model: the fallback model to use if the message
753 does not match any of the currently configured mail aliases
754 (may be None if a matching alias is supposed to be present)
755 :type dict custom_values: optional dictionary of default field values
756 to pass to ``message_new`` if a new record needs to be created.
757 Ignored if the thread record already exists, and also if a
758 matching mail.alias was found (aliases define their own defaults)
759 :param int thread_id: optional ID of the record/thread from ``model``
760 to which this mail should be attached. Only used if the message
761 does not reply to an existing thread and does not match any mail alias.
762 :return: list of [model, thread_id, custom_values, user_id, alias]
764 assert isinstance(message, Message), 'message must be an email.message.Message at this point'
765 fallback_model = model
767 # Get email.message.Message variables for future processing
768 message_id = message.get('Message-Id')
769 email_from = decode_header(message, 'From')
770 email_to = decode_header(message, 'To')
771 references = decode_header(message, 'References')
772 in_reply_to = decode_header(message, 'In-Reply-To')
774 # 1. Verify if this is a reply to an existing thread
775 thread_references = references or in_reply_to
776 ref_match = thread_references and tools.reference_re.search(thread_references)
778 thread_id = int(ref_match.group(1))
779 model = ref_match.group(2) or fallback_model
780 if thread_id and model in self.pool:
781 model_obj = self.pool[model]
782 if model_obj.exists(cr, uid, thread_id) and hasattr(model_obj, 'message_update'):
783 _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',
784 email_from, email_to, message_id, model, thread_id, custom_values, uid)
785 route = self.message_route_verify(cr, uid, message, message_dict,
786 (model, thread_id, custom_values, uid, None),
787 update_author=True, assert_model=True, create_fallback=True, context=context)
788 return route and [route] or []
790 # 2. Reply to a private message
792 message_ids = self.pool.get('mail.message').search(cr, uid, [
793 ('message_id', '=', in_reply_to),
794 '!', ('message_id', 'ilike', 'reply_to')
795 ], limit=1, context=context)
797 message = self.pool.get('mail.message').browse(cr, uid, message_ids[0], context=context)
798 _logger.info('Routing mail from %s to %s with Message-Id %s: direct reply to a private message: %s, custom_values: %s, uid: %s',
799 email_from, email_to, message_id, message.id, custom_values, uid)
800 route = self.message_route_verify(cr, uid, message, message_dict,
801 (message.model, message.res_id, custom_values, uid, None),
802 update_author=True, assert_model=True, create_fallback=True, context=context)
803 return route and [route] or []
805 # 3. Look for a matching mail.alias entry
806 # Delivered-To is a safe bet in most modern MTAs, but we have to fallback on To + Cc values
807 # for all the odd MTAs out there, as there is no standard header for the envelope's `rcpt_to` value.
809 ','.join([decode_header(message, 'Delivered-To'),
810 decode_header(message, 'To'),
811 decode_header(message, 'Cc'),
812 decode_header(message, 'Resent-To'),
813 decode_header(message, 'Resent-Cc')])
814 local_parts = [e.split('@')[0] for e in tools.email_split(rcpt_tos)]
816 mail_alias = self.pool.get('mail.alias')
817 alias_ids = mail_alias.search(cr, uid, [('alias_name', 'in', local_parts)])
820 for alias in mail_alias.browse(cr, uid, alias_ids, context=context):
821 user_id = alias.alias_user_id.id
823 # TDE note: this could cause crashes, because no clue that the user
824 # that send the email has the right to create or modify a new document
825 # Fallback on user_id = uid
826 # Note: recognized partners will be added as followers anyway
827 # user_id = self._message_find_user_id(cr, uid, message, context=context)
829 _logger.info('No matching user_id for the alias %s', alias.alias_name)
830 route = (alias.alias_model_id.model, alias.alias_force_thread_id, eval(alias.alias_defaults), user_id, alias)
831 _logger.info('Routing mail from %s to %s with Message-Id %s: direct alias match: %r',
832 email_from, email_to, message_id, route)
833 route = self.message_route_verify(cr, uid, message, message_dict, route,
834 update_author=True, assert_model=True, create_fallback=True, context=context)
839 # 4. Fallback to the provided parameters, if they work
841 # Legacy: fallback to matching [ID] in the Subject
842 match = tools.res_re.search(decode_header(message, 'Subject'))
843 thread_id = match and match.group(1)
844 # Convert into int (bug spotted in 7.0 because of str)
846 thread_id = int(thread_id)
849 _logger.info('Routing mail from %s to %s with Message-Id %s: fallback to model:%s, thread_id:%s, custom_values:%s, uid:%s',
850 email_from, email_to, message_id, fallback_model, thread_id, custom_values, uid)
851 route = self.message_route_verify(cr, uid, message, message_dict,
852 (fallback_model, thread_id, custom_values, uid, None),
853 update_author=True, assert_model=True, context=context)
857 # AssertionError if no routes found and if no bounce occured
859 "No possible route found for incoming message from %s to %s (Message-Id %s:)." \
860 "Create an appropriate mail.alias or force the destination model." % (email_from, email_to, message_id)
862 def message_process(self, cr, uid, model, message, custom_values=None,
863 save_original=False, strip_attachments=False,
864 thread_id=None, context=None):
865 """ Process an incoming RFC2822 email message, relying on
866 ``mail.message.parse()`` for the parsing operation,
867 and ``message_route()`` to figure out the target model.
869 Once the target model is known, its ``message_new`` method
870 is called with the new message (if the thread record did not exist)
871 or its ``message_update`` method (if it did).
873 There is a special case where the target model is False: a reply
874 to a private message. In this case, we skip the message_new /
875 message_update step, to just post a new message using mail_thread
878 :param string model: the fallback model to use if the message
879 does not match any of the currently configured mail aliases
880 (may be None if a matching alias is supposed to be present)
881 :param message: source of the RFC2822 message
882 :type message: string or xmlrpclib.Binary
883 :type dict custom_values: optional dictionary of field values
884 to pass to ``message_new`` if a new record needs to be created.
885 Ignored if the thread record already exists, and also if a
886 matching mail.alias was found (aliases define their own defaults)
887 :param bool save_original: whether to keep a copy of the original
888 email source attached to the message after it is imported.
889 :param bool strip_attachments: whether to strip all attachments
890 before processing the message, in order to save some space.
891 :param int thread_id: optional ID of the record/thread from ``model``
892 to which this mail should be attached. When provided, this
893 overrides the automatic detection based on the message
899 # extract message bytes - we are forced to pass the message as binary because
900 # we don't know its encoding until we parse its headers and hence can't
901 # convert it to utf-8 for transport between the mailgate script and here.
902 if isinstance(message, xmlrpclib.Binary):
903 message = str(message.data)
904 # Warning: message_from_string doesn't always work correctly on unicode,
905 # we must use utf-8 strings here :-(
906 if isinstance(message, unicode):
907 message = message.encode('utf-8')
908 msg_txt = email.message_from_string(message)
910 # parse the message, verify we are not in a loop by checking message_id is not duplicated
911 msg = self.message_parse(cr, uid, msg_txt, save_original=save_original, context=context)
912 if strip_attachments:
913 msg.pop('attachments', None)
914 # postpone setting msg.partner_ids after message_post, to avoid double notifications
915 partner_ids = msg.pop('partner_ids', [])
916 if msg.get('message_id'): # should always be True as message_parse generate one if missing
917 existing_msg_ids = self.pool.get('mail.message').search(cr, SUPERUSER_ID, [
918 ('message_id', '=', msg.get('message_id')),
921 _logger.info('Ignored mail from %s to %s with Message-Id %s: found duplicated Message-Id during processing',
922 msg.get('from'), msg.get('to'), msg.get('message_id'))
925 # find possible routes for the message
926 routes = self.message_route(cr, uid, msg_txt, msg, model, thread_id, custom_values, context=context)
928 for model, thread_id, custom_values, user_id, alias in routes:
929 if self._name == 'mail.thread':
930 context.update({'thread_model': model})
932 model_pool = self.pool[model]
933 assert thread_id and hasattr(model_pool, 'message_update') or hasattr(model_pool, 'message_new'), \
934 "Undeliverable mail with Message-Id %s, model %s does not accept incoming emails" % \
935 (msg['message_id'], model)
937 # disabled subscriptions during message_new/update to avoid having the system user running the
938 # email gateway become a follower of all inbound messages
939 nosub_ctx = dict(context, mail_create_nosubscribe=True, mail_create_nolog=True)
940 if thread_id and hasattr(model_pool, 'message_update'):
941 model_pool.message_update(cr, user_id, [thread_id], msg, context=nosub_ctx)
943 thread_id = model_pool.message_new(cr, user_id, msg, custom_values, context=nosub_ctx)
945 assert thread_id == 0, "Posting a message without model should be with a null res_id, to create a private message."
946 model_pool = self.pool.get('mail.thread')
947 if not hasattr(model_pool, 'message_post'):
948 context['thread_model'] = model
949 model_pool = self.pool['mail.thread']
950 new_msg_id = model_pool.message_post(cr, uid, [thread_id], context=context, subtype='mail.mt_comment', **msg)
953 # postponed after message_post, because this is an external message and we don't want to create
954 # duplicate emails due to notifications
955 self.pool.get('mail.message').write(cr, uid, [new_msg_id], {'partner_ids': partner_ids}, context=context)
959 def message_new(self, cr, uid, msg_dict, custom_values=None, context=None):
960 """Called by ``message_process`` when a new message is received
961 for a given thread model, if the message did not belong to
963 The default behavior is to create a new record of the corresponding
964 model (based on some very basic info extracted from the message).
965 Additional behavior may be implemented by overriding this method.
967 :param dict msg_dict: a map containing the email details and
968 attachments. See ``message_process`` and
969 ``mail.message.parse`` for details.
970 :param dict custom_values: optional dictionary of additional
971 field values to pass to create()
972 when creating the new thread record.
973 Be careful, these values may override
974 any other values coming from the message.
975 :param dict context: if a ``thread_model`` value is present
976 in the context, its value will be used
977 to determine the model of the record
978 to create (instead of the current model).
980 :return: the id of the newly created thread object
985 if isinstance(custom_values, dict):
986 data = custom_values.copy()
987 model = context.get('thread_model') or self._name
988 model_pool = self.pool[model]
989 fields = model_pool.fields_get(cr, uid, context=context)
990 if 'name' in fields and not data.get('name'):
991 data['name'] = msg_dict.get('subject', '')
992 res_id = model_pool.create(cr, uid, data, context=context)
995 def message_update(self, cr, uid, ids, msg_dict, update_vals=None, context=None):
996 """Called by ``message_process`` when a new message is received
997 for an existing thread. The default behavior is to update the record
998 with update_vals taken from the incoming email.
999 Additional behavior may be implemented by overriding this
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 update_vals: a dict containing values to update records
1005 given their ids; if the dict is None or is
1006 void, no write operation is performed.
1009 self.write(cr, uid, ids, update_vals, context=context)
1012 def _message_extract_payload(self, message, save_original=False):
1013 """Extract body as HTML and attachments from the mail message"""
1017 attachments.append(('original_email.eml', message.as_string()))
1018 if not message.is_multipart() or 'text/' in message.get('content-type', ''):
1019 encoding = message.get_content_charset()
1020 body = message.get_payload(decode=True)
1021 body = tools.ustr(body, encoding, errors='replace')
1022 if message.get_content_type() == 'text/plain':
1023 # text/plain -> <pre/>
1024 body = tools.append_content_to_html(u'', body, preserve=True)
1026 alternative = (message.get_content_type() == 'multipart/alternative')
1027 for part in message.walk():
1028 if part.get_content_maintype() == 'multipart':
1029 continue # skip container
1030 filename = part.get_filename() # None if normal part
1031 encoding = part.get_content_charset() # None if attachment
1032 # 1) Explicit Attachments -> attachments
1033 if filename or part.get('content-disposition', '').strip().startswith('attachment'):
1034 attachments.append((filename or 'attachment', part.get_payload(decode=True)))
1036 # 2) text/plain -> <pre/>
1037 if part.get_content_type() == 'text/plain' and (not alternative or not body):
1038 body = tools.append_content_to_html(body, tools.ustr(part.get_payload(decode=True),
1039 encoding, errors='replace'), preserve=True)
1040 # 3) text/html -> raw
1041 elif part.get_content_type() == 'text/html':
1042 html = tools.ustr(part.get_payload(decode=True), encoding, errors='replace')
1046 body = tools.append_content_to_html(body, html, plaintext=False)
1047 # 4) Anything else -> attachment
1049 attachments.append((filename or 'attachment', part.get_payload(decode=True)))
1050 return body, attachments
1052 def message_parse(self, cr, uid, message, save_original=False, context=None):
1053 """Parses a string or email.message.Message representing an
1054 RFC-2822 email, and returns a generic dict holding the
1057 :param message: the message to parse
1058 :type message: email.message.Message | string | unicode
1059 :param bool save_original: whether the returned dict
1060 should include an ``original`` attachment containing
1061 the source of the message
1063 :return: A dict with the following structure, where each
1064 field may not be present if missing in original
1067 { 'message_id': msg_id,
1072 'body': unified_body,
1073 'attachments': [('file1', 'bytes'),
1080 if not isinstance(message, Message):
1081 if isinstance(message, unicode):
1082 # Warning: message_from_string doesn't always work correctly on unicode,
1083 # we must use utf-8 strings here :-(
1084 message = message.encode('utf-8')
1085 message = email.message_from_string(message)
1087 message_id = message['message-id']
1089 # Very unusual situation, be we should be fault-tolerant here
1090 message_id = "<%s@localhost>" % time.time()
1091 _logger.debug('Parsing Message without message-id, generating a random one: %s', message_id)
1092 msg_dict['message_id'] = message_id
1094 if message.get('Subject'):
1095 msg_dict['subject'] = decode(message.get('Subject'))
1097 # Envelope fields not stored in mail.message but made available for message_new()
1098 msg_dict['from'] = decode(message.get('from'))
1099 msg_dict['to'] = decode(message.get('to'))
1100 msg_dict['cc'] = decode(message.get('cc'))
1101 msg_dict['email_from'] = decode(message.get('from'))
1102 partner_ids = self._message_find_partners(cr, uid, message, ['To', 'Cc'], context=context)
1103 msg_dict['partner_ids'] = [(4, partner_id) for partner_id in partner_ids]
1105 if message.get('Date'):
1107 date_hdr = decode(message.get('Date'))
1108 parsed_date = dateutil.parser.parse(date_hdr, fuzzy=True)
1109 if parsed_date.utcoffset() is None:
1110 # naive datetime, so we arbitrarily decide to make it
1111 # UTC, there's no better choice. Should not happen,
1112 # as RFC2822 requires timezone offset in Date headers.
1113 stored_date = parsed_date.replace(tzinfo=pytz.utc)
1115 stored_date = parsed_date.astimezone(tz=pytz.utc)
1117 _logger.warning('Failed to parse Date header %r in incoming mail '
1118 'with message-id %r, assuming current date/time.',
1119 message.get('Date'), message_id)
1120 stored_date = datetime.datetime.now()
1121 msg_dict['date'] = stored_date.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT)
1123 if message.get('In-Reply-To'):
1124 parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', '=', decode(message['In-Reply-To']))])
1126 msg_dict['parent_id'] = parent_ids[0]
1128 if message.get('References') and 'parent_id' not in msg_dict:
1129 parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', 'in',
1130 [x.strip() for x in decode(message['References']).split()])])
1132 msg_dict['parent_id'] = parent_ids[0]
1134 msg_dict['body'], msg_dict['attachments'] = self._message_extract_payload(message, save_original=save_original)
1137 #------------------------------------------------------
1139 #------------------------------------------------------
1141 def log(self, cr, uid, id, message, secondary=False, context=None):
1142 _logger.warning("log() is deprecated. As this module inherit from "\
1143 "mail.thread, the message will be managed by this "\
1144 "module instead of by the res.log mechanism. Please "\
1145 "use mail_thread.message_post() instead of the "\
1146 "now deprecated res.log.")
1147 self.message_post(cr, uid, [id], message, context=context)
1149 def _message_add_suggested_recipient(self, cr, uid, result, obj, partner=None, email=None, reason='', context=None):
1150 """ Called by message_get_suggested_recipients, to add a suggested
1151 recipient in the result dictionary. The form is :
1152 partner_id, partner_name<partner_email> or partner_name, reason """
1153 if email and not partner:
1154 # get partner info from email
1155 partner_info = self.message_partner_info_from_emails(cr, uid, obj.id, [email], context=context)[0]
1156 if partner_info.get('partner_id'):
1157 partner = self.pool.get('res.partner').browse(cr, SUPERUSER_ID, [partner_info.get('partner_id')], context=context)[0]
1158 if email and email in [val[1] for val in result[obj.id]]: # already existing email -> skip
1160 if partner and partner in obj.message_follower_ids: # recipient already in the followers -> skip
1162 if partner and partner in [val[0] for val in result[obj.id]]: # already existing partner ID -> skip
1164 if partner and partner.email: # complete profile: id, name <email>
1165 result[obj.id].append((partner.id, '%s<%s>' % (partner.name, partner.email), reason))
1166 elif partner: # incomplete profile: id, name
1167 result[obj.id].append((partner.id, '%s' % (partner.name), reason))
1168 else: # unknown partner, we are probably managing an email address
1169 result[obj.id].append((False, email, reason))
1172 def message_get_suggested_recipients(self, cr, uid, ids, context=None):
1173 """ Returns suggested recipients for ids. Those are a list of
1174 tuple (partner_id, partner_name, reason), to be managed by Chatter. """
1175 result = dict.fromkeys(ids, list())
1176 if self._all_columns.get('user_id'):
1177 for obj in self.browse(cr, SUPERUSER_ID, ids, context=context): # SUPERUSER because of a read on res.users that would crash otherwise
1178 if not obj.user_id or not obj.user_id.partner_id:
1180 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)
1183 def _find_partner_from_emails(self, cr, uid, id, emails, model=None, context=None, check_followers=True):
1184 """ Utility method to find partners from email addresses. The rules are :
1185 1 - check in document (model | self, id) followers
1186 2 - try to find a matching partner that is also an user
1187 3 - try to find a matching partner
1189 :param list emails: list of email addresses
1190 :param string model: model to fetch related record; by default self
1192 :param boolean check_followers: check in document followers
1194 partner_obj = self.pool['res.partner']
1197 if id and (model or self._name != 'mail.thread') and check_followers:
1199 obj = self.pool[model].browse(cr, uid, id, context=context)
1201 obj = self.browse(cr, uid, id, context=context)
1202 for contact in emails:
1204 email_address = tools.email_split(contact)
1205 if not email_address:
1206 partner_ids.append(partner_id)
1208 email_address = email_address[0]
1209 # first try: check in document's followers
1211 for follower in obj.message_follower_ids:
1212 if follower.email == email_address:
1213 partner_id = follower.id
1214 # second try: check in partners that are also users
1216 ids = partner_obj.search(cr, SUPERUSER_ID, [
1217 ('email', 'ilike', email_address),
1218 ('user_ids', '!=', False)
1219 ], limit=1, context=context)
1222 # third try: check in partners
1224 ids = partner_obj.search(cr, SUPERUSER_ID, [
1225 ('email', 'ilike', email_address)
1226 ], limit=1, context=context)
1229 partner_ids.append(partner_id)
1232 def message_partner_info_from_emails(self, cr, uid, id, emails, link_mail=False, context=None):
1233 """ Convert a list of emails into a list partner_ids and a list
1234 new_partner_ids. The return value is non conventional because
1235 it is meant to be used by the mail widget.
1237 :return dict: partner_ids and new_partner_ids """
1238 mail_message_obj = self.pool.get('mail.message')
1239 partner_ids = self._find_partner_from_emails(cr, uid, id, emails, context=context)
1241 for idx in range(len(emails)):
1242 email_address = emails[idx]
1243 partner_id = partner_ids[idx]
1244 partner_info = {'full_name': email_address, 'partner_id': partner_id}
1245 result.append(partner_info)
1247 # link mail with this from mail to the new partner id
1248 if link_mail and partner_info['partner_id']:
1249 message_ids = mail_message_obj.search(cr, SUPERUSER_ID, [
1251 ('email_from', '=', email_address),
1252 ('email_from', 'ilike', '<%s>' % email_address),
1253 ('author_id', '=', False)
1256 mail_message_obj.write(cr, SUPERUSER_ID, message_ids, {'author_id': partner_info['partner_id']}, context=context)
1259 def message_post(self, cr, uid, thread_id, body='', subject=None, type='notification',
1260 subtype=None, parent_id=False, attachments=None, context=None,
1261 content_subtype='html', **kwargs):
1262 """ Post a new message in an existing thread, returning the new
1265 :param int thread_id: thread ID to post into, or list with one ID;
1266 if False/0, mail.message model will also be set as False
1267 :param str body: body of the message, usually raw HTML that will
1269 :param str type: see mail_message.type field
1270 :param str content_subtype:: if plaintext: convert body into html
1271 :param int parent_id: handle reply to a previous message by adding the
1272 parent partners to the message in case of private discussion
1273 :param tuple(str,str) attachments or list id: list of attachment tuples in the form
1274 ``(name,content)``, where content is NOT base64 encoded
1276 Extra keyword arguments will be used as default column values for the
1277 new mail.message record. Special cases:
1278 - attachment_ids: supposed not attached to any document; attach them
1279 to the related document. Should only be set by Chatter.
1280 :return int: ID of newly created mail.message
1284 if attachments is None:
1286 mail_message = self.pool.get('mail.message')
1287 ir_attachment = self.pool.get('ir.attachment')
1289 assert (not thread_id) or \
1290 isinstance(thread_id, (int, long)) or \
1291 (isinstance(thread_id, (list, tuple)) and len(thread_id) == 1), \
1292 "Invalid thread_id; should be 0, False, an ID or a list with one ID"
1293 if isinstance(thread_id, (list, tuple)):
1294 thread_id = thread_id[0]
1296 # if we're processing a message directly coming from the gateway, the destination model was
1297 # set in the context.
1300 model = context.get('thread_model', self._name) if self._name == 'mail.thread' else self._name
1301 if model != self._name and hasattr(self.pool[model], 'message_post'):
1302 del context['thread_model']
1303 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)
1305 #0: Find the message's author, because we need it for private discussion
1306 author_id = kwargs.get('author_id')
1307 if author_id is None: # keep False values
1308 author_id = self.pool.get('mail.message')._get_default_author(cr, uid, context=context)
1310 # 1: Handle content subtype: if plaintext, converto into HTML
1311 if content_subtype == 'plaintext':
1312 body = tools.plaintext2html(body)
1314 # 2: Private message: add recipients (recipients and author of parent message) - current author
1315 # + legacy-code management (! we manage only 4 and 6 commands)
1317 kwargs_partner_ids = kwargs.pop('partner_ids', [])
1318 for partner_id in kwargs_partner_ids:
1319 if isinstance(partner_id, (list, tuple)) and partner_id[0] == 4 and len(partner_id) == 2:
1320 partner_ids.add(partner_id[1])
1321 if isinstance(partner_id, (list, tuple)) and partner_id[0] == 6 and len(partner_id) == 3:
1322 partner_ids |= set(partner_id[2])
1323 elif isinstance(partner_id, (int, long)):
1324 partner_ids.add(partner_id)
1326 pass # we do not manage anything else
1327 if parent_id and not model:
1328 parent_message = mail_message.browse(cr, uid, parent_id, context=context)
1329 private_followers = set([partner.id for partner in parent_message.partner_ids])
1330 if parent_message.author_id:
1331 private_followers.add(parent_message.author_id.id)
1332 private_followers -= set([author_id])
1333 partner_ids |= private_followers
1336 # - HACK TDE FIXME: Chatter: attachments linked to the document (not done JS-side), load the message
1337 attachment_ids = kwargs.pop('attachment_ids', []) or [] # because we could receive None (some old code sends None)
1339 filtered_attachment_ids = ir_attachment.search(cr, SUPERUSER_ID, [
1340 ('res_model', '=', 'mail.compose.message'),
1341 ('create_uid', '=', uid),
1342 ('id', 'in', attachment_ids)], context=context)
1343 if filtered_attachment_ids:
1344 ir_attachment.write(cr, SUPERUSER_ID, filtered_attachment_ids, {'res_model': model, 'res_id': thread_id}, context=context)
1345 attachment_ids = [(4, id) for id in attachment_ids]
1346 # Handle attachments parameter, that is a dictionary of attachments
1347 for name, content in attachments:
1348 if isinstance(content, unicode):
1349 content = content.encode('utf-8')
1352 'datas': base64.b64encode(str(content)),
1353 'datas_fname': name,
1354 'description': name,
1356 'res_id': thread_id,
1358 attachment_ids.append((0, 0, data_attach))
1360 # 4: mail.message.subtype
1363 if '.' not in subtype:
1364 subtype = 'mail.%s' % subtype
1365 ref = self.pool.get('ir.model.data').get_object_reference(cr, uid, *subtype.split('.'))
1366 subtype_id = ref and ref[1] or False
1368 # automatically subscribe recipients if asked to
1369 if context.get('mail_post_autofollow') and thread_id and partner_ids:
1370 partner_to_subscribe = partner_ids
1371 if context.get('mail_post_autofollow_partner_ids'):
1372 partner_to_subscribe = filter(lambda item: item in context.get('mail_post_autofollow_partner_ids'), partner_ids)
1373 self.message_subscribe(cr, uid, [thread_id], list(partner_to_subscribe), context=context)
1375 # _mail_flat_thread: automatically set free messages to the first posted message
1376 if self._mail_flat_thread and not parent_id and thread_id:
1377 message_ids = mail_message.search(cr, uid, ['&', ('res_id', '=', thread_id), ('model', '=', model)], context=context, order="id ASC", limit=1)
1378 parent_id = message_ids and message_ids[0] or False
1379 # 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
1381 message_ids = mail_message.search(cr, SUPERUSER_ID, [('id', '=', parent_id), ('parent_id', '!=', False)], context=context)
1382 # avoid loops when finding ancestors
1385 message = mail_message.browse(cr, SUPERUSER_ID, message_ids[0], context=context)
1386 while (message.parent_id and message.parent_id.id not in processed_list):
1387 processed_list.append(message.parent_id.id)
1388 message = message.parent_id
1389 parent_id = message.id
1393 'author_id': author_id,
1395 'res_id': thread_id or False,
1397 'subject': subject or False,
1399 'parent_id': parent_id,
1400 'attachment_ids': attachment_ids,
1401 'subtype_id': subtype_id,
1402 'partner_ids': [(4, pid) for pid in partner_ids],
1405 # Avoid warnings about non-existing fields
1406 for x in ('from', 'to', 'cc'):
1409 # Create and auto subscribe the author
1410 msg_id = mail_message.create(cr, uid, values, context=context)
1411 message = mail_message.browse(cr, uid, msg_id, context=context)
1412 if message.author_id and thread_id and type != 'notification' and not context.get('mail_create_nosubscribe'):
1413 self.message_subscribe(cr, uid, [thread_id], [message.author_id.id], context=context)
1416 #------------------------------------------------------
1418 #------------------------------------------------------
1420 def message_get_subscription_data(self, cr, uid, ids, user_pid=None, context=None):
1421 """ Wrapper to get subtypes data. """
1422 return self._get_subscription_data(cr, uid, ids, None, None, user_pid=user_pid, context=context)
1424 def message_subscribe_users(self, cr, uid, ids, user_ids=None, subtype_ids=None, context=None):
1425 """ Wrapper on message_subscribe, using users. If user_ids is not
1426 provided, subscribe uid instead. """
1427 if user_ids is None:
1429 partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
1430 return self.message_subscribe(cr, uid, ids, partner_ids, subtype_ids=subtype_ids, context=context)
1432 def message_subscribe(self, cr, uid, ids, partner_ids, subtype_ids=None, context=None):
1433 """ Add partners to the records followers. """
1434 mail_followers_obj = self.pool.get('mail.followers')
1435 subtype_obj = self.pool.get('mail.message.subtype')
1437 user_pid = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1438 if set(partner_ids) == set([user_pid]):
1440 self.check_access_rights(cr, uid, 'read')
1441 except (osv.except_osv, orm.except_orm):
1444 self.check_access_rights(cr, uid, 'write')
1446 for record in self.browse(cr, SUPERUSER_ID, ids, context=context):
1447 existing_pids = set([f.id for f in record.message_follower_ids
1448 if f.id in partner_ids])
1449 new_pids = set(partner_ids) - existing_pids
1451 # subtype_ids specified: update already subscribed partners
1452 if subtype_ids and existing_pids:
1453 fol_ids = mail_followers_obj.search(cr, SUPERUSER_ID, [
1454 ('res_model', '=', self._name),
1455 ('res_id', '=', record.id),
1456 ('partner_id', 'in', list(existing_pids)),
1458 mail_followers_obj.write(cr, SUPERUSER_ID, fol_ids, {'subtype_ids': [(6, 0, subtype_ids)]}, context=context)
1459 # subtype_ids not specified: do not update already subscribed partner, fetch default subtypes for new partners
1460 elif subtype_ids is None:
1461 subtype_ids = subtype_obj.search(cr, uid, [
1462 ('default', '=', True),
1464 ('res_model', '=', self._name),
1465 ('res_model', '=', False)
1467 # subscribe new followers
1468 for new_pid in new_pids:
1469 mail_followers_obj.create(cr, SUPERUSER_ID, {
1470 'res_model': self._name,
1471 'res_id': record.id,
1472 'partner_id': new_pid,
1473 'subtype_ids': [(6, 0, subtype_ids)],
1478 def message_unsubscribe_users(self, cr, uid, ids, user_ids=None, context=None):
1479 """ Wrapper on message_subscribe, using users. If user_ids is not
1480 provided, unsubscribe uid instead. """
1481 if user_ids is None:
1483 partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
1484 return self.message_unsubscribe(cr, uid, ids, partner_ids, context=context)
1486 def message_unsubscribe(self, cr, uid, ids, partner_ids, context=None):
1487 """ Remove partners from the records followers. """
1488 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
1489 if set(partner_ids) == set([user_pid]):
1490 self.check_access_rights(cr, uid, 'read')
1492 self.check_access_rights(cr, uid, 'write')
1493 return self.write(cr, SUPERUSER_ID, ids, {'message_follower_ids': [(3, pid) for pid in partner_ids]}, context=context)
1495 def _message_get_auto_subscribe_fields(self, cr, uid, updated_fields, auto_follow_fields=['user_id'], context=None):
1496 """ Returns the list of relational fields linking to res.users that should
1497 trigger an auto subscribe. The default list checks for the fields
1499 - linking to res.users
1500 - with track_visibility set
1501 In OpenERP V7, this is sufficent for all major addon such as opportunity,
1502 project, issue, recruitment, sale.
1503 Override this method if a custom behavior is needed about fields
1504 that automatically subscribe users.
1507 for name, column_info in self._all_columns.items():
1508 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':
1509 user_field_lst.append(name)
1510 return user_field_lst
1512 def message_auto_subscribe(self, cr, uid, ids, updated_fields, context=None):
1514 1. fetch project subtype related to task (parent_id.res_model = 'project.task')
1515 2. for each project subtype: subscribe the follower to the task
1517 subtype_obj = self.pool.get('mail.message.subtype')
1518 follower_obj = self.pool.get('mail.followers')
1520 # fetch auto_follow_fields
1521 user_field_lst = self._message_get_auto_subscribe_fields(cr, uid, updated_fields, context=context)
1523 # fetch related record subtypes
1524 related_subtype_ids = subtype_obj.search(cr, uid, ['|', ('res_model', '=', False), ('parent_id.res_model', '=', self._name)], context=context)
1525 subtypes = subtype_obj.browse(cr, uid, related_subtype_ids, context=context)
1526 default_subtypes = [subtype for subtype in subtypes if subtype.res_model == False]
1527 related_subtypes = [subtype for subtype in subtypes if subtype.res_model != False]
1528 relation_fields = set([subtype.relation_field for subtype in subtypes if subtype.relation_field != False])
1529 if (not related_subtypes or not any(relation in updated_fields for relation in relation_fields)) and not user_field_lst:
1532 for record in self.browse(cr, uid, ids, context=context):
1533 new_followers = dict()
1534 parent_res_id = False
1535 parent_model = False
1536 for subtype in related_subtypes:
1537 if not subtype.relation_field or not subtype.parent_id:
1539 if not subtype.relation_field in self._columns or not getattr(record, subtype.relation_field, False):
1541 parent_res_id = getattr(record, subtype.relation_field).id
1542 parent_model = subtype.res_model
1543 follower_ids = follower_obj.search(cr, SUPERUSER_ID, [
1544 ('res_model', '=', parent_model),
1545 ('res_id', '=', parent_res_id),
1546 ('subtype_ids', 'in', [subtype.id])
1548 for follower in follower_obj.browse(cr, SUPERUSER_ID, follower_ids, context=context):
1549 new_followers.setdefault(follower.partner_id.id, set()).add(subtype.parent_id.id)
1551 if parent_res_id and parent_model:
1552 for subtype in default_subtypes:
1553 follower_ids = follower_obj.search(cr, SUPERUSER_ID, [
1554 ('res_model', '=', parent_model),
1555 ('res_id', '=', parent_res_id),
1556 ('subtype_ids', 'in', [subtype.id])
1558 for follower in follower_obj.browse(cr, SUPERUSER_ID, follower_ids, context=context):
1559 new_followers.setdefault(follower.partner_id.id, set()).add(subtype.id)
1561 # add followers coming from res.users relational fields that are tracked
1562 user_ids = [getattr(record, name).id for name in user_field_lst if getattr(record, name)]
1563 user_id_partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, SUPERUSER_ID, user_ids, context=context)]
1564 for partner_id in user_id_partner_ids:
1565 new_followers.setdefault(partner_id, None)
1567 for pid, subtypes in new_followers.items():
1568 subtypes = list(subtypes) if subtypes is not None else None
1569 self.message_subscribe(cr, uid, [record.id], [pid], subtypes, context=context)
1571 # find first email message, set it as unread for auto_subscribe fields for them to have a notification
1572 if user_id_partner_ids:
1573 msg_ids = self.pool.get('mail.message').search(cr, uid, [
1574 ('model', '=', self._name),
1575 ('res_id', '=', record.id),
1576 ('type', '=', 'email')], limit=1, context=context)
1577 if not msg_ids and record.message_ids:
1578 msg_ids = [record.message_ids[-1].id]
1580 self.pool.get('mail.notification')._notify(cr, uid, msg_ids[0], partners_to_notify=user_id_partner_ids, context=context)
1584 #------------------------------------------------------
1586 #------------------------------------------------------
1588 def message_mark_as_unread(self, cr, uid, ids, context=None):
1589 """ Set as unread. """
1590 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1592 UPDATE mail_notification SET
1595 message_id IN (SELECT id from mail_message where res_id=any(%s) and model=%s limit 1) and
1597 ''', (ids, self._name, partner_id))
1600 def message_mark_as_read(self, cr, uid, ids, context=None):
1601 """ Set as read. """
1602 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1604 UPDATE mail_notification SET
1607 message_id IN (SELECT id FROM mail_message WHERE res_id=ANY(%s) AND model=%s) AND
1609 ''', (ids, self._name, partner_id))
1612 #------------------------------------------------------
1614 #------------------------------------------------------
1616 def get_suggested_thread(self, cr, uid, removed_suggested_threads=None, context=None):
1617 """Return a list of suggested threads, sorted by the numbers of followers"""
1621 # TDE HACK: originally by MAT from portal/mail_mail.py but not working until the inheritance graph bug is not solved in trunk
1622 # TDE FIXME: relocate in portal when it won't be necessary to reload the hr.employee model in an additional bridge module
1623 if self.pool['res.groups']._all_columns.get('is_portal'):
1624 user = self.pool.get('res.users').browse(cr, SUPERUSER_ID, uid, context=context)
1625 if any(group.is_portal for group in user.groups_id):
1629 if removed_suggested_threads is None:
1630 removed_suggested_threads = []
1632 thread_ids = self.search(cr, uid, [('id', 'not in', removed_suggested_threads), ('message_is_follower', '=', False)], context=context)
1633 for thread in self.browse(cr, uid, thread_ids, context=context):
1636 'popularity': len(thread.message_follower_ids),
1637 'name': thread.name,
1638 'image_small': thread.image_small
1640 threads.append(data)
1641 return sorted(threads, key=lambda x: (x['popularity'], x['id']), reverse=True)[:3]