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, ids, context=None):
160 data = self.pool.get('res.partner').read(cr, uid, ids, ['name', 'user_ids'], context=context)
162 grp_id = self.pool.get('ir.model.data').get_object_reference(cr, uid, 'base', 'group_no_one')
163 tech_user = self.pool.get('res.groups').read(cr, uid, grp_id[1], ['users'], context=context)['users']
164 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
168 if dict['id'] in [user_pid]:
169 dict.update({'is_editable': True})
170 if uid in dict['user_ids']:
171 dict.update({'is_uid': True})
175 def _get_subscription_data(self, cr, uid, ids, name, args, user_pid=None, context=None):
177 - message_subtype_data: data about document subtypes: which are
178 available, which are followed if any """
179 res = dict((id, dict(message_subtype_data='')) for id in ids)
181 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
183 # find current model subtypes, add them to a dictionary
184 subtype_obj = self.pool.get('mail.message.subtype')
185 subtype_ids = subtype_obj.search(cr, uid, ['|', ('res_model', '=', self._name), ('res_model', '=', False)], context=context)
186 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))
188 res[id]['message_subtype_data'] = subtype_dict.copy()
190 # find the document followers, update the data
191 fol_obj = self.pool.get('mail.followers')
192 fol_ids = fol_obj.search(cr, uid, [
193 ('partner_id', '=', user_pid),
194 ('res_id', 'in', ids),
195 ('res_model', '=', self._name),
197 for fol in fol_obj.browse(cr, uid, fol_ids, context=context):
198 thread_subtype_dict = res[fol.res_id]['message_subtype_data']
199 for subtype in fol.subtype_ids:
200 thread_subtype_dict[subtype.name]['followed'] = True
201 res[fol.res_id]['message_subtype_data'] = thread_subtype_dict
205 def _search_message_unread(self, cr, uid, obj=None, name=None, domain=None, context=None):
206 return [('message_ids.to_read', '=', True)]
208 def _get_followers(self, cr, uid, ids, name, arg, context=None):
209 fol_obj = self.pool.get('mail.followers')
210 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids)])
211 res = dict((id, dict(message_follower_ids=[], message_is_follower=False)) for id in ids)
212 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
213 for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids):
214 res[fol.res_id]['message_follower_ids'].append(fol.partner_id.id)
215 if fol.partner_id.id == user_pid:
216 res[fol.res_id]['message_is_follower'] = True
219 def _set_followers(self, cr, uid, id, name, value, arg, context=None):
222 partner_obj = self.pool.get('res.partner')
223 fol_obj = self.pool.get('mail.followers')
225 # read the old set of followers, and determine the new set of followers
226 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', '=', id)])
227 old = set(fol.partner_id.id for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids))
230 for command in value or []:
231 if isinstance(command, (int, long)):
233 elif command[0] == 0:
234 new.add(partner_obj.create(cr, uid, command[2], context=context))
235 elif command[0] == 1:
236 partner_obj.write(cr, uid, [command[1]], command[2], context=context)
238 elif command[0] == 2:
239 partner_obj.unlink(cr, uid, [command[1]], context=context)
240 new.discard(command[1])
241 elif command[0] == 3:
242 new.discard(command[1])
243 elif command[0] == 4:
245 elif command[0] == 5:
247 elif command[0] == 6:
248 new = set(command[2])
250 # remove partners that are no longer followers
251 fol_ids = fol_obj.search(cr, SUPERUSER_ID,
252 [('res_model', '=', self._name), ('res_id', '=', id), ('partner_id', 'not in', list(new))])
253 fol_obj.unlink(cr, SUPERUSER_ID, fol_ids)
256 for partner_id in new - old:
257 fol_obj.create(cr, SUPERUSER_ID, {'res_model': self._name, 'res_id': id, 'partner_id': partner_id})
259 def _search_followers(self, cr, uid, obj, name, args, context):
260 """Search function for message_follower_ids
262 Do not use with operator 'not in'. Use instead message_is_followers
264 fol_obj = self.pool.get('mail.followers')
266 for field, operator, value in args:
268 # TOFIX make it work with not in
269 assert operator != "not in", "Do not search message_follower_ids with 'not in'"
270 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('partner_id', operator, value)])
271 res_ids = [fol.res_id for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids)]
272 res.append(('id', 'in', res_ids))
275 def _search_is_follower(self, cr, uid, obj, name, args, context):
276 """Search function for message_is_follower"""
278 for field, operator, value in args:
280 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
281 if (operator == '=' and value) or (operator == '!=' and not value): # is a follower
282 res_ids = self.search(cr, uid, [('message_follower_ids', 'in', [partner_id])], context=context)
283 else: # is not a follower or unknown domain
284 mail_ids = self.search(cr, uid, [('message_follower_ids', 'in', [partner_id])], context=context)
285 res_ids = self.search(cr, uid, [('id', 'not in', mail_ids)], context=context)
286 res.append(('id', 'in', res_ids))
290 'message_is_follower': fields.function(_get_followers, type='boolean',
291 fnct_search=_search_is_follower, string='Is a Follower', multi='_get_followers,'),
292 'message_follower_ids': fields.function(_get_followers, fnct_inv=_set_followers,
293 fnct_search=_search_followers, type='many2many',
294 obj='res.partner', string='Followers', multi='_get_followers'),
295 'message_ids': fields.one2many('mail.message', 'res_id',
296 domain=lambda self: [('model', '=', self._name)],
299 help="Messages and communication history"),
300 'message_unread': fields.function(_get_message_data,
301 fnct_search=_search_message_unread, multi="_get_message_data",
302 type='boolean', string='Unread Messages',
303 help="If checked new messages require your attention."),
304 'message_summary': fields.function(_get_message_data, method=True,
305 type='text', string='Summary', multi="_get_message_data",
306 help="Holds the Chatter summary (number of messages, ...). "\
307 "This summary is directly in html format in order to "\
308 "be inserted in kanban views."),
311 #------------------------------------------------------
312 # CRUD overrides for automatic subscription and logging
313 #------------------------------------------------------
315 def create(self, cr, uid, values, context=None):
316 """ Chatter override :
318 - subscribe followers of parent
319 - log a creation message
323 thread_id = super(mail_thread, self).create(cr, uid, values, context=context)
325 # automatic logging unless asked not to (mainly for various testing purpose)
326 if not context.get('mail_create_nolog'):
327 self.message_post(cr, uid, thread_id, body=_('%s created') % (self._description), context=context)
329 # subscribe uid unless asked not to
330 if not context.get('mail_create_nosubscribe'):
331 self.message_subscribe_users(cr, uid, [thread_id], [uid], context=context)
332 # auto_subscribe: take values and defaults into account
333 create_values = set(values.keys())
334 for key, val in context.iteritems():
335 if key.startswith('default_'):
336 create_values.add(key[8:])
337 self.message_auto_subscribe(cr, uid, [thread_id], list(create_values), context=context)
340 tracked_fields = self._get_tracked_fields(cr, uid, values.keys(), context=context)
342 initial_values = {thread_id: dict((item, False) for item in tracked_fields)}
343 self.message_track(cr, uid, [thread_id], tracked_fields, initial_values, context=context)
347 def write(self, cr, uid, ids, values, context=None):
348 if isinstance(ids, (int, long)):
350 # Track initial values of tracked fields
351 tracked_fields = self._get_tracked_fields(cr, uid, values.keys(), context=context)
353 records = self.browse(cr, uid, ids, context=context)
354 initial_values = dict((this.id, dict((key, getattr(this, key)) for key in tracked_fields.keys())) for this in records)
356 # Perform write, update followers
357 result = super(mail_thread, self).write(cr, uid, ids, values, context=context)
358 self.message_auto_subscribe(cr, uid, ids, values.keys(), context=context)
360 # Perform the tracking
362 self.message_track(cr, uid, ids, tracked_fields, initial_values, context=context)
365 def unlink(self, cr, uid, ids, context=None):
366 """ Override unlink to delete messages and followers. This cannot be
367 cascaded, because link is done through (res_model, res_id). """
368 msg_obj = self.pool.get('mail.message')
369 fol_obj = self.pool.get('mail.followers')
370 # delete messages and notifications
371 msg_ids = msg_obj.search(cr, uid, [('model', '=', self._name), ('res_id', 'in', ids)], context=context)
372 msg_obj.unlink(cr, uid, msg_ids, context=context)
374 res = super(mail_thread, self).unlink(cr, uid, ids, context=context)
376 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids)], context=context)
377 fol_obj.unlink(cr, SUPERUSER_ID, fol_ids, context=context)
380 def copy(self, cr, uid, id, default=None, context=None):
381 default = default or {}
382 default['message_ids'] = []
383 default['message_follower_ids'] = []
384 return super(mail_thread, self).copy(cr, uid, id, default=default, context=context)
386 #------------------------------------------------------
387 # Automatically log tracked fields
388 #------------------------------------------------------
390 def _get_tracked_fields(self, cr, uid, updated_fields, context=None):
391 """ Return a structure of tracked fields for the current model.
392 :param list updated_fields: modified field names
393 :return list: a list of (field_name, column_info obj), containing
394 always tracked fields and modified on_change fields
397 for name, column_info in self._all_columns.items():
398 visibility = getattr(column_info.column, 'track_visibility', False)
399 if visibility == 'always' or (visibility == 'onchange' and name in updated_fields) or name in self._track:
403 return self.fields_get(cr, uid, lst, context=context)
405 def message_track(self, cr, uid, ids, tracked_fields, initial_values, context=None):
407 def convert_for_display(value, col_info):
408 if not value and col_info['type'] == 'boolean':
412 if col_info['type'] == 'many2one':
413 return value.name_get()[0][1]
414 if col_info['type'] == 'selection':
415 return dict(col_info['selection'])[value]
418 def format_message(message_description, tracked_values):
420 if message_description:
421 message = '<span>%s</span>' % message_description
422 for name, change in tracked_values.items():
423 message += '<div> • <b>%s</b>: ' % change.get('col_info')
424 if change.get('old_value'):
425 message += '%s → ' % change.get('old_value')
426 message += '%s</div>' % change.get('new_value')
429 if not tracked_fields:
432 for browse_record in self.browse(cr, uid, ids, context=context):
433 initial = initial_values[browse_record.id]
437 # generate tracked_values data structure: {'col_name': {col_info, new_value, old_value}}
438 for col_name, col_info in tracked_fields.items():
439 initial_value = initial[col_name]
440 record_value = getattr(browse_record, col_name)
442 if record_value == initial_value and getattr(self._all_columns[col_name].column, 'track_visibility', None) == 'always':
443 tracked_values[col_name] = dict(col_info=col_info['string'],
444 new_value=convert_for_display(record_value, col_info))
445 elif record_value != initial_value and (record_value or initial_value): # because browse null != False
446 if getattr(self._all_columns[col_name].column, 'track_visibility', None) in ['always', 'onchange']:
447 tracked_values[col_name] = dict(col_info=col_info['string'],
448 old_value=convert_for_display(initial_value, col_info),
449 new_value=convert_for_display(record_value, col_info))
450 if col_name in tracked_fields:
451 changes.add(col_name)
455 # find subtypes and post messages or log if no subtype found
457 for field, track_info in self._track.items():
458 if field not in changes:
460 for subtype, method in track_info.items():
461 if method(self, cr, uid, browse_record, context):
462 subtypes.append(subtype)
465 for subtype in subtypes:
467 subtype_rec = self.pool.get('ir.model.data').get_object(cr, uid, subtype.split('.')[0], subtype.split('.')[1], context=context)
468 except ValueError, e:
469 _logger.debug('subtype %s not found, giving error "%s"' % (subtype, e))
471 message = format_message(subtype_rec.description if subtype_rec.description else subtype_rec.name, tracked_values)
472 self.message_post(cr, uid, browse_record.id, body=message, subtype=subtype, context=context)
475 message = format_message('', tracked_values)
476 self.message_post(cr, uid, browse_record.id, body=message, context=context)
479 #------------------------------------------------------
480 # mail.message wrappers and tools
481 #------------------------------------------------------
483 def _needaction_domain_get(self, cr, uid, context=None):
485 return [('message_unread', '=', True)]
488 def _garbage_collect_attachments(self, cr, uid, context=None):
489 """ Garbage collect lost mail attachments. Those are attachments
490 - linked to res_model 'mail.compose.message', the composer wizard
491 - with res_id 0, because they were created outside of an existing
492 wizard (typically user input through Chatter or reports
493 created on-the-fly by the templates)
494 - unused since at least one day (create_date and write_date)
496 limit_date = datetime.datetime.utcnow() - datetime.timedelta(days=1)
497 limit_date_str = datetime.datetime.strftime(limit_date, tools.DEFAULT_SERVER_DATETIME_FORMAT)
498 ir_attachment_obj = self.pool.get('ir.attachment')
499 attach_ids = ir_attachment_obj.search(cr, uid, [
500 ('res_model', '=', 'mail.compose.message'),
502 ('create_date', '<', limit_date_str),
503 ('write_date', '<', limit_date_str),
505 ir_attachment_obj.unlink(cr, uid, attach_ids, context=context)
508 def check_mail_message_access(self, cr, uid, mids, operation, model_obj=None, context=None):
509 """ mail.message check permission rules for related document. This method is
510 meant to be inherited in order to implement addons-specific behavior.
511 A common behavior would be to allow creating messages when having read
512 access rule on the document, for portal document such as issues. """
515 if operation in ['create', 'write', 'unlink']:
516 model_obj.check_access_rights(cr, uid, 'write')
517 model_obj.check_access_rule(cr, uid, mids, 'write', context=context)
519 model_obj.check_access_rights(cr, uid, operation)
520 model_obj.check_access_rule(cr, uid, mids, operation, context=context)
522 def _get_formview_action(self, cr, uid, id, model=None, context=None):
523 """ Return an action to open the document. This method is meant to be
524 overridden in addons that want to give specific view ids for example.
526 :param int id: id of the document to open
527 :param string model: specific model that overrides self._name
530 'type': 'ir.actions.act_window',
531 'res_model': model or self._name,
534 'views': [(False, 'form')],
539 def _get_inbox_action_xml_id(self, cr, uid, context=None):
540 """ When redirecting towards the Inbox, choose which action xml_id has
541 to be fetched. This method is meant to be inherited, at least in portal
542 because portal users have a different Inbox action than classic users. """
543 return ('mail', 'action_mail_inbox_feeds')
545 def message_redirect_action(self, cr, uid, context=None):
546 """ For a given message, return an action that either
547 - opens the form view of the related document if model, res_id, and
548 read access to the document
549 - opens the Inbox with a default search on the conversation if model,
551 - opens the Inbox with context propagated
557 # default action is the Inbox action
558 self.pool.get('res.users').browse(cr, SUPERUSER_ID, uid, context=context)
559 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))
560 action = self.pool.get(act_model).read(cr, uid, act_id, [])
562 # if msg_id specified: try to redirect to the document or fallback on the Inbox
563 msg_id = context.get('params', {}).get('message_id')
566 msg = self.pool.get('mail.message').browse(cr, uid, msg_id, context=context)
567 if msg.model and msg.res_id:
570 'search_default_model': msg.model,
571 'search_default_res_id': msg.res_id,
574 if self.pool.get(msg.model).check_access_rights(cr, uid, 'read', raise_exception=False):
576 model_obj = self.pool.get(msg.model)
577 model_obj.check_access_rule(cr, uid, [msg.res_id], 'read', context=context)
578 if not hasattr(model_obj, '_get_formview_action'):
579 action = self.pool.get('mail.thread')._get_formview_action(cr, uid, msg.res_id, model=msg.model, context=context)
581 action = model_obj._get_formview_action(cr, uid, msg.res_id, context=context)
582 except (osv.except_osv, orm.except_orm):
586 #------------------------------------------------------
588 #------------------------------------------------------
590 def message_get_reply_to(self, cr, uid, ids, context=None):
591 """ Returns the preferred reply-to email address that is basically
592 the alias of the document, if it exists. """
593 if not self._inherits.get('mail.alias'):
594 return [False for id in ids]
595 return ["%s@%s" % (record['alias_name'], record['alias_domain'])
596 if record.get('alias_domain') and record.get('alias_name')
598 for record in self.read(cr, SUPERUSER_ID, ids, ['alias_name', 'alias_domain'], context=context)]
600 #------------------------------------------------------
602 #------------------------------------------------------
604 def message_capable_models(self, cr, uid, context=None):
605 """ Used by the plugin addon, based for plugin_outlook and others. """
607 for model_name in self.pool.obj_list():
608 model = self.pool[model_name]
609 if hasattr(model, "message_process") and hasattr(model, "message_post"):
610 ret_dict[model_name] = model._description
613 def _message_find_partners(self, cr, uid, message, header_fields=['From'], context=None):
614 """ Find partners related to some header fields of the message.
616 :param string message: an email.message instance """
617 s = ', '.join([decode(message.get(h)) for h in header_fields if message.get(h)])
618 return filter(lambda x: x, self._find_partner_from_emails(cr, uid, None, tools.email_split(s), context=context))
620 def message_route_verify(self, cr, uid, message, message_dict, route, update_author=True, assert_model=True, create_fallback=True, context=None):
621 """ Verify route validity. Check and rules:
622 1 - if thread_id -> check that document effectively exists; otherwise
623 fallback on a message_new by resetting thread_id
624 2 - check that message_update exists if thread_id is set; or at least
625 that message_new exist
626 [ - find author_id if udpate_author is set]
627 3 - if there is an alias, check alias_contact:
628 'followers' and thread_id:
629 check on target document that the author is in the followers
630 'followers' and alias_parent_thread_id:
631 check on alias parent document that the author is in the
633 'partners': check that author_id id set
636 assert isinstance(route, (list, tuple)), 'A route should be a list or a tuple'
637 assert len(route) == 5, 'A route should contain 5 elements: model, thread_id, custom_values, uid, alias record'
639 message_id = message.get('Message-Id')
640 email_from = decode_header(message, 'From')
641 author_id = message_dict.get('author_id')
642 model, thread_id, alias = route[0], route[1], route[4]
645 def _create_bounce_email():
646 mail_mail = self.pool.get('mail.mail')
647 mail_id = mail_mail.create(cr, uid, {
648 'body_html': '<div><p>Hello,</p>'
649 '<p>The following email sent to %s cannot be accepted because this is '
650 'a private email address. Only allowed people can contact us at this address.</p></div>'
651 '<blockquote>%s</blockquote>' % (message.get('to'), message_dict.get('body')),
652 'subject': 'Re: %s' % message.get('subject'),
653 'email_to': message.get('from'),
656 mail_mail.send(cr, uid, [mail_id], context=context)
659 _logger.warning('Routing mail with Message-Id %s: route %s: %s',
660 message_id, route, message)
663 if model and not model in self.pool:
665 assert model in self.pool, 'Routing: unknown target model %s' % model
666 _warn('unknown target model %s' % model)
669 model_pool = self.pool[model]
671 # Private message: should not contain any thread_id
672 if not model and thread_id:
674 assert thread_id == 0, 'Routing: posting a message without model should be with a null res_id (private message).'
675 _warn('posting a message without model should be with a null res_id (private message), resetting thread_id')
678 # Existing Document: check if exists; if not, fallback on create if allowed
679 if thread_id and not model_pool.exists(cr, uid, thread_id):
681 _warn('reply to missing document (%s,%s), fall back on new document creation' % (model, thread_id))
684 assert model_pool.exists(cr, uid, thread_id), 'Routing: reply to missing document (%s,%s)' % (model, thread_id)
686 _warn('reply to missing document (%s,%s), skipping' % (model, thread_id))
689 # Existing Document: check model accepts the mailgateway
690 if thread_id and not hasattr(model_pool, 'message_update'):
692 _warn('model %s does not accept document update, fall back on document creation' % model)
695 assert hasattr(model_pool, 'message_update'), 'Routing: model %s does not accept document update, crashing' % model
697 _warn('model %s does not accept document update, skipping' % model)
700 # New Document: check model accepts the mailgateway
701 if not thread_id and not hasattr(model_pool, 'message_new'):
703 assert hasattr(model_pool, 'message_new'), 'Model %s does not accept document creation, crashing' % model
704 _warn('model %s does not accept document creation, skipping' % model)
707 # Update message author if asked
708 # We do it now because we need it for aliases (contact settings)
709 if not author_id and update_author:
710 author_ids = self._find_partner_from_emails(cr, uid, thread_id, [email_from], model=model, context=context)
712 author_id = author_ids[0]
713 message_dict['author_id'] = author_id
715 # Alias: check alias_contact settings
716 if alias and alias.alias_contact == 'followers' and (thread_id or alias.alias_parent_thread_id):
718 obj = self.pool[model].browse(cr, uid, thread_id, context=context)
720 obj = self.pool[alias.alias_parent_model_id.model].browse(cr, uid, alias.alias_parent_thread_id, context=context)
721 if not author_id or not author_id in [fol.id for fol in obj.message_follower_ids]:
722 _warn('alias %s restricted to internal followers, skipping' % alias.alias_name)
723 _create_bounce_email()
725 elif alias and alias.alias_contact == 'partners' and not author_id:
726 _warn('alias %s does not accept unknown author, skipping' % alias.alias_name)
727 _create_bounce_email()
730 return (model, thread_id, route[2], route[3], route[4])
732 def message_route(self, cr, uid, message, message_dict, model=None, thread_id=None,
733 custom_values=None, context=None):
734 """Attempt to figure out the correct target model, thread_id,
735 custom_values and user_id to use for an incoming message.
736 Multiple values may be returned, if a message had multiple
737 recipients matching existing mail.aliases, for example.
739 The following heuristics are used, in this order:
740 1. If the message replies to an existing thread_id, and
741 properly contains the thread model in the 'In-Reply-To'
742 header, use this model/thread_id pair, and ignore
743 custom_value (not needed as no creation will take place)
744 2. Look for a mail.alias entry matching the message
745 recipient, and use the corresponding model, thread_id,
746 custom_values and user_id.
747 3. Fallback to the ``model``, ``thread_id`` and ``custom_values``
749 4. If all the above fails, raise an exception.
751 :param string message: an email.message instance
752 :param dict message_dict: dictionary holding message variables
753 :param string model: the fallback model to use if the message
754 does not match any of the currently configured mail aliases
755 (may be None if a matching alias is supposed to be present)
756 :type dict custom_values: optional dictionary of default field values
757 to pass to ``message_new`` if a new record needs to be created.
758 Ignored if the thread record already exists, and also if a
759 matching mail.alias was found (aliases define their own defaults)
760 :param int thread_id: optional ID of the record/thread from ``model``
761 to which this mail should be attached. Only used if the message
762 does not reply to an existing thread and does not match any mail alias.
763 :return: list of [model, thread_id, custom_values, user_id, alias]
765 assert isinstance(message, Message), 'message must be an email.message.Message at this point'
766 fallback_model = model
768 # Get email.message.Message variables for future processing
769 message_id = message.get('Message-Id')
770 email_from = decode_header(message, 'From')
771 email_to = decode_header(message, 'To')
772 references = decode_header(message, 'References')
773 in_reply_to = decode_header(message, 'In-Reply-To')
775 # 1. Verify if this is a reply to an existing thread
776 thread_references = references or in_reply_to
777 ref_match = thread_references and tools.reference_re.search(thread_references)
779 thread_id = int(ref_match.group(1))
780 model = ref_match.group(2) or fallback_model
781 if thread_id and model in self.pool:
782 model_obj = self.pool[model]
783 if model_obj.exists(cr, uid, thread_id) and hasattr(model_obj, 'message_update'):
784 _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',
785 email_from, email_to, message_id, model, thread_id, custom_values, uid)
786 route = self.message_route_verify(cr, uid, message, message_dict,
787 (model, thread_id, custom_values, uid, None),
788 update_author=True, assert_model=True, create_fallback=True, context=context)
789 return route and [route] or []
791 # 2. Reply to a private message
793 message_ids = self.pool.get('mail.message').search(cr, uid, [
794 ('message_id', '=', in_reply_to),
795 '!', ('message_id', 'ilike', 'reply_to')
796 ], limit=1, context=context)
798 message = self.pool.get('mail.message').browse(cr, uid, message_ids[0], context=context)
799 _logger.info('Routing mail from %s to %s with Message-Id %s: direct reply to a private message: %s, custom_values: %s, uid: %s',
800 email_from, email_to, message_id, message.id, custom_values, uid)
801 route = self.message_route_verify(cr, uid, message, message_dict,
802 (message.model, message.res_id, custom_values, uid, None),
803 update_author=True, assert_model=True, create_fallback=True, context=context)
804 return route and [route] or []
806 # 3. Look for a matching mail.alias entry
807 # Delivered-To is a safe bet in most modern MTAs, but we have to fallback on To + Cc values
808 # for all the odd MTAs out there, as there is no standard header for the envelope's `rcpt_to` value.
810 ','.join([decode_header(message, 'Delivered-To'),
811 decode_header(message, 'To'),
812 decode_header(message, 'Cc'),
813 decode_header(message, 'Resent-To'),
814 decode_header(message, 'Resent-Cc')])
815 local_parts = [e.split('@')[0] for e in tools.email_split(rcpt_tos)]
817 mail_alias = self.pool.get('mail.alias')
818 alias_ids = mail_alias.search(cr, uid, [('alias_name', 'in', local_parts)])
821 for alias in mail_alias.browse(cr, uid, alias_ids, context=context):
822 user_id = alias.alias_user_id.id
824 # TDE note: this could cause crashes, because no clue that the user
825 # that send the email has the right to create or modify a new document
826 # Fallback on user_id = uid
827 # Note: recognized partners will be added as followers anyway
828 # user_id = self._message_find_user_id(cr, uid, message, context=context)
830 _logger.info('No matching user_id for the alias %s', alias.alias_name)
831 route = (alias.alias_model_id.model, alias.alias_force_thread_id, eval(alias.alias_defaults), user_id, alias)
832 _logger.info('Routing mail from %s to %s with Message-Id %s: direct alias match: %r',
833 email_from, email_to, message_id, route)
834 route = self.message_route_verify(cr, uid, message, message_dict, route,
835 update_author=True, assert_model=True, create_fallback=True, context=context)
840 # 4. Fallback to the provided parameters, if they work
842 # Legacy: fallback to matching [ID] in the Subject
843 match = tools.res_re.search(decode_header(message, 'Subject'))
844 thread_id = match and match.group(1)
845 # Convert into int (bug spotted in 7.0 because of str)
847 thread_id = int(thread_id)
850 _logger.info('Routing mail from %s to %s with Message-Id %s: fallback to model:%s, thread_id:%s, custom_values:%s, uid:%s',
851 email_from, email_to, message_id, fallback_model, thread_id, custom_values, uid)
852 route = self.message_route_verify(cr, uid, message, message_dict,
853 (fallback_model, thread_id, custom_values, uid, None),
854 update_author=True, assert_model=True, context=context)
858 # AssertionError if no routes found and if no bounce occured
860 "No possible route found for incoming message from %s to %s (Message-Id %s:)." \
861 "Create an appropriate mail.alias or force the destination model." % (email_from, email_to, message_id)
863 def message_process(self, cr, uid, model, message, custom_values=None,
864 save_original=False, strip_attachments=False,
865 thread_id=None, context=None):
866 """ Process an incoming RFC2822 email message, relying on
867 ``mail.message.parse()`` for the parsing operation,
868 and ``message_route()`` to figure out the target model.
870 Once the target model is known, its ``message_new`` method
871 is called with the new message (if the thread record did not exist)
872 or its ``message_update`` method (if it did).
874 There is a special case where the target model is False: a reply
875 to a private message. In this case, we skip the message_new /
876 message_update step, to just post a new message using mail_thread
879 :param string model: the fallback model to use if the message
880 does not match any of the currently configured mail aliases
881 (may be None if a matching alias is supposed to be present)
882 :param message: source of the RFC2822 message
883 :type message: string or xmlrpclib.Binary
884 :type dict custom_values: optional dictionary of field values
885 to pass to ``message_new`` if a new record needs to be created.
886 Ignored if the thread record already exists, and also if a
887 matching mail.alias was found (aliases define their own defaults)
888 :param bool save_original: whether to keep a copy of the original
889 email source attached to the message after it is imported.
890 :param bool strip_attachments: whether to strip all attachments
891 before processing the message, in order to save some space.
892 :param int thread_id: optional ID of the record/thread from ``model``
893 to which this mail should be attached. When provided, this
894 overrides the automatic detection based on the message
900 # extract message bytes - we are forced to pass the message as binary because
901 # we don't know its encoding until we parse its headers and hence can't
902 # convert it to utf-8 for transport between the mailgate script and here.
903 if isinstance(message, xmlrpclib.Binary):
904 message = str(message.data)
905 # Warning: message_from_string doesn't always work correctly on unicode,
906 # we must use utf-8 strings here :-(
907 if isinstance(message, unicode):
908 message = message.encode('utf-8')
909 msg_txt = email.message_from_string(message)
911 # parse the message, verify we are not in a loop by checking message_id is not duplicated
912 msg = self.message_parse(cr, uid, msg_txt, save_original=save_original, context=context)
913 if strip_attachments:
914 msg.pop('attachments', None)
915 # postpone setting msg.partner_ids after message_post, to avoid double notifications
916 partner_ids = msg.pop('partner_ids', [])
917 if msg.get('message_id'): # should always be True as message_parse generate one if missing
918 existing_msg_ids = self.pool.get('mail.message').search(cr, SUPERUSER_ID, [
919 ('message_id', '=', msg.get('message_id')),
922 _logger.info('Ignored mail from %s to %s with Message-Id %s: found duplicated Message-Id during processing',
923 msg.get('from'), msg.get('to'), msg.get('message_id'))
926 # find possible routes for the message
927 routes = self.message_route(cr, uid, msg_txt, msg, model, thread_id, custom_values, context=context)
929 for model, thread_id, custom_values, user_id, alias in routes:
930 if self._name == 'mail.thread':
931 context.update({'thread_model': model})
933 model_pool = self.pool[model]
934 assert thread_id and hasattr(model_pool, 'message_update') or hasattr(model_pool, 'message_new'), \
935 "Undeliverable mail with Message-Id %s, model %s does not accept incoming emails" % \
936 (msg['message_id'], model)
938 # disabled subscriptions during message_new/update to avoid having the system user running the
939 # email gateway become a follower of all inbound messages
940 nosub_ctx = dict(context, mail_create_nosubscribe=True, mail_create_nolog=True)
941 if thread_id and hasattr(model_pool, 'message_update'):
942 model_pool.message_update(cr, user_id, [thread_id], msg, context=nosub_ctx)
944 thread_id = model_pool.message_new(cr, user_id, msg, custom_values, context=nosub_ctx)
946 assert thread_id == 0, "Posting a message without model should be with a null res_id, to create a private message."
947 model_pool = self.pool.get('mail.thread')
948 if not hasattr(model_pool, 'message_post'):
949 context['thread_model'] = model
950 model_pool = self.pool['mail.thread']
951 new_msg_id = model_pool.message_post(cr, uid, [thread_id], context=context, subtype='mail.mt_comment', **msg)
954 # postponed after message_post, because this is an external message and we don't want to create
955 # duplicate emails due to notifications
956 self.pool.get('mail.message').write(cr, uid, [new_msg_id], {'partner_ids': partner_ids}, context=context)
960 def message_new(self, cr, uid, msg_dict, custom_values=None, context=None):
961 """Called by ``message_process`` when a new message is received
962 for a given thread model, if the message did not belong to
964 The default behavior is to create a new record of the corresponding
965 model (based on some very basic info extracted from the message).
966 Additional behavior may be implemented by overriding this method.
968 :param dict msg_dict: a map containing the email details and
969 attachments. See ``message_process`` and
970 ``mail.message.parse`` for details.
971 :param dict custom_values: optional dictionary of additional
972 field values to pass to create()
973 when creating the new thread record.
974 Be careful, these values may override
975 any other values coming from the message.
976 :param dict context: if a ``thread_model`` value is present
977 in the context, its value will be used
978 to determine the model of the record
979 to create (instead of the current model).
981 :return: the id of the newly created thread object
986 if isinstance(custom_values, dict):
987 data = custom_values.copy()
988 model = context.get('thread_model') or self._name
989 model_pool = self.pool[model]
990 fields = model_pool.fields_get(cr, uid, context=context)
991 if 'name' in fields and not data.get('name'):
992 data['name'] = msg_dict.get('subject', '')
993 res_id = model_pool.create(cr, uid, data, context=context)
996 def message_update(self, cr, uid, ids, msg_dict, update_vals=None, context=None):
997 """Called by ``message_process`` when a new message is received
998 for an existing thread. The default behavior is to update the record
999 with update_vals taken from the incoming email.
1000 Additional behavior may be implemented by overriding this
1002 :param dict msg_dict: a map containing the email details and
1003 attachments. See ``message_process`` and
1004 ``mail.message.parse()`` for details.
1005 :param dict update_vals: a dict containing values to update records
1006 given their ids; if the dict is None or is
1007 void, no write operation is performed.
1010 self.write(cr, uid, ids, update_vals, context=context)
1013 def _message_extract_payload(self, message, save_original=False):
1014 """Extract body as HTML and attachments from the mail message"""
1018 attachments.append(('original_email.eml', message.as_string()))
1019 if not message.is_multipart() or 'text/' in message.get('content-type', ''):
1020 encoding = message.get_content_charset()
1021 body = message.get_payload(decode=True)
1022 body = tools.ustr(body, encoding, errors='replace')
1023 if message.get_content_type() == 'text/plain':
1024 # text/plain -> <pre/>
1025 body = tools.append_content_to_html(u'', body, preserve=True)
1027 alternative = (message.get_content_type() == 'multipart/alternative')
1028 for part in message.walk():
1029 if part.get_content_maintype() == 'multipart':
1030 continue # skip container
1031 filename = part.get_filename() # None if normal part
1032 encoding = part.get_content_charset() # None if attachment
1033 # 1) Explicit Attachments -> attachments
1034 if filename or part.get('content-disposition', '').strip().startswith('attachment'):
1035 attachments.append((filename or 'attachment', part.get_payload(decode=True)))
1037 # 2) text/plain -> <pre/>
1038 if part.get_content_type() == 'text/plain' and (not alternative or not body):
1039 body = tools.append_content_to_html(body, tools.ustr(part.get_payload(decode=True),
1040 encoding, errors='replace'), preserve=True)
1041 # 3) text/html -> raw
1042 elif part.get_content_type() == 'text/html':
1043 html = tools.ustr(part.get_payload(decode=True), encoding, errors='replace')
1047 body = tools.append_content_to_html(body, html, plaintext=False)
1048 # 4) Anything else -> attachment
1050 attachments.append((filename or 'attachment', part.get_payload(decode=True)))
1051 return body, attachments
1053 def message_parse(self, cr, uid, message, save_original=False, context=None):
1054 """Parses a string or email.message.Message representing an
1055 RFC-2822 email, and returns a generic dict holding the
1058 :param message: the message to parse
1059 :type message: email.message.Message | string | unicode
1060 :param bool save_original: whether the returned dict
1061 should include an ``original`` attachment containing
1062 the source of the message
1064 :return: A dict with the following structure, where each
1065 field may not be present if missing in original
1068 { 'message_id': msg_id,
1073 'body': unified_body,
1074 'attachments': [('file1', 'bytes'),
1081 if not isinstance(message, Message):
1082 if isinstance(message, unicode):
1083 # Warning: message_from_string doesn't always work correctly on unicode,
1084 # we must use utf-8 strings here :-(
1085 message = message.encode('utf-8')
1086 message = email.message_from_string(message)
1088 message_id = message['message-id']
1090 # Very unusual situation, be we should be fault-tolerant here
1091 message_id = "<%s@localhost>" % time.time()
1092 _logger.debug('Parsing Message without message-id, generating a random one: %s', message_id)
1093 msg_dict['message_id'] = message_id
1095 if message.get('Subject'):
1096 msg_dict['subject'] = decode(message.get('Subject'))
1098 # Envelope fields not stored in mail.message but made available for message_new()
1099 msg_dict['from'] = decode(message.get('from'))
1100 msg_dict['to'] = decode(message.get('to'))
1101 msg_dict['cc'] = decode(message.get('cc'))
1102 msg_dict['email_from'] = decode(message.get('from'))
1103 partner_ids = self._message_find_partners(cr, uid, message, ['To', 'Cc'], context=context)
1104 msg_dict['partner_ids'] = [(4, partner_id) for partner_id in partner_ids]
1106 if message.get('Date'):
1108 date_hdr = decode(message.get('Date'))
1109 parsed_date = dateutil.parser.parse(date_hdr, fuzzy=True)
1110 if parsed_date.utcoffset() is None:
1111 # naive datetime, so we arbitrarily decide to make it
1112 # UTC, there's no better choice. Should not happen,
1113 # as RFC2822 requires timezone offset in Date headers.
1114 stored_date = parsed_date.replace(tzinfo=pytz.utc)
1116 stored_date = parsed_date.astimezone(tz=pytz.utc)
1118 _logger.warning('Failed to parse Date header %r in incoming mail '
1119 'with message-id %r, assuming current date/time.',
1120 message.get('Date'), message_id)
1121 stored_date = datetime.datetime.now()
1122 msg_dict['date'] = stored_date.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT)
1124 if message.get('In-Reply-To'):
1125 parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', '=', decode(message['In-Reply-To']))])
1127 msg_dict['parent_id'] = parent_ids[0]
1129 if message.get('References') and 'parent_id' not in msg_dict:
1130 parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', 'in',
1131 [x.strip() for x in decode(message['References']).split()])])
1133 msg_dict['parent_id'] = parent_ids[0]
1135 msg_dict['body'], msg_dict['attachments'] = self._message_extract_payload(message, save_original=save_original)
1138 #------------------------------------------------------
1140 #------------------------------------------------------
1142 def log(self, cr, uid, id, message, secondary=False, context=None):
1143 _logger.warning("log() is deprecated. As this module inherit from "\
1144 "mail.thread, the message will be managed by this "\
1145 "module instead of by the res.log mechanism. Please "\
1146 "use mail_thread.message_post() instead of the "\
1147 "now deprecated res.log.")
1148 self.message_post(cr, uid, [id], message, context=context)
1150 def _message_add_suggested_recipient(self, cr, uid, result, obj, partner=None, email=None, reason='', context=None):
1151 """ Called by message_get_suggested_recipients, to add a suggested
1152 recipient in the result dictionary. The form is :
1153 partner_id, partner_name<partner_email> or partner_name, reason """
1154 if email and not partner:
1155 # get partner info from email
1156 partner_info = self.message_partner_info_from_emails(cr, uid, obj.id, [email], context=context)[0]
1157 if partner_info.get('partner_id'):
1158 partner = self.pool.get('res.partner').browse(cr, SUPERUSER_ID, [partner_info.get('partner_id')], context=context)[0]
1159 if email and email in [val[1] for val in result[obj.id]]: # already existing email -> skip
1161 if partner and partner in obj.message_follower_ids: # recipient already in the followers -> skip
1163 if partner and partner in [val[0] for val in result[obj.id]]: # already existing partner ID -> skip
1165 if partner and partner.email: # complete profile: id, name <email>
1166 result[obj.id].append((partner.id, '%s<%s>' % (partner.name, partner.email), reason))
1167 elif partner: # incomplete profile: id, name
1168 result[obj.id].append((partner.id, '%s' % (partner.name), reason))
1169 else: # unknown partner, we are probably managing an email address
1170 result[obj.id].append((False, email, reason))
1173 def message_get_suggested_recipients(self, cr, uid, ids, context=None):
1174 """ Returns suggested recipients for ids. Those are a list of
1175 tuple (partner_id, partner_name, reason), to be managed by Chatter. """
1176 result = dict.fromkeys(ids, list())
1177 if self._all_columns.get('user_id'):
1178 for obj in self.browse(cr, SUPERUSER_ID, ids, context=context): # SUPERUSER because of a read on res.users that would crash otherwise
1179 if not obj.user_id or not obj.user_id.partner_id:
1181 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)
1184 def _find_partner_from_emails(self, cr, uid, id, emails, model=None, context=None, check_followers=True):
1185 """ Utility method to find partners from email addresses. The rules are :
1186 1 - check in document (model | self, id) followers
1187 2 - try to find a matching partner that is also an user
1188 3 - try to find a matching partner
1190 :param list emails: list of email addresses
1191 :param string model: model to fetch related record; by default self
1193 :param boolean check_followers: check in document followers
1195 partner_obj = self.pool['res.partner']
1198 if id and (model or self._name != 'mail.thread') and check_followers:
1200 obj = self.pool[model].browse(cr, uid, id, context=context)
1202 obj = self.browse(cr, uid, id, context=context)
1203 for contact in emails:
1205 email_address = tools.email_split(contact)
1206 if not email_address:
1207 partner_ids.append(partner_id)
1209 email_address = email_address[0]
1210 # first try: check in document's followers
1212 for follower in obj.message_follower_ids:
1213 if follower.email == email_address:
1214 partner_id = follower.id
1215 # second try: check in partners that are also users
1217 ids = partner_obj.search(cr, SUPERUSER_ID, [
1218 ('email', 'ilike', email_address),
1219 ('user_ids', '!=', False)
1220 ], limit=1, context=context)
1223 # third try: check in partners
1225 ids = partner_obj.search(cr, SUPERUSER_ID, [
1226 ('email', 'ilike', email_address)
1227 ], limit=1, context=context)
1230 partner_ids.append(partner_id)
1233 def message_partner_info_from_emails(self, cr, uid, id, emails, link_mail=False, context=None):
1234 """ Convert a list of emails into a list partner_ids and a list
1235 new_partner_ids. The return value is non conventional because
1236 it is meant to be used by the mail widget.
1238 :return dict: partner_ids and new_partner_ids """
1239 mail_message_obj = self.pool.get('mail.message')
1240 partner_ids = self._find_partner_from_emails(cr, uid, id, emails, context=context)
1242 for idx in range(len(emails)):
1243 email_address = emails[idx]
1244 partner_id = partner_ids[idx]
1245 partner_info = {'full_name': email_address, 'partner_id': partner_id}
1246 result.append(partner_info)
1248 # link mail with this from mail to the new partner id
1249 if link_mail and partner_info['partner_id']:
1250 message_ids = mail_message_obj.search(cr, SUPERUSER_ID, [
1252 ('email_from', '=', email_address),
1253 ('email_from', 'ilike', '<%s>' % email_address),
1254 ('author_id', '=', False)
1257 mail_message_obj.write(cr, SUPERUSER_ID, message_ids, {'author_id': partner_info['partner_id']}, context=context)
1260 def message_post(self, cr, uid, thread_id, body='', subject=None, type='notification',
1261 subtype=None, parent_id=False, attachments=None, context=None,
1262 content_subtype='html', **kwargs):
1263 """ Post a new message in an existing thread, returning the new
1266 :param int thread_id: thread ID to post into, or list with one ID;
1267 if False/0, mail.message model will also be set as False
1268 :param str body: body of the message, usually raw HTML that will
1270 :param str type: see mail_message.type field
1271 :param str content_subtype:: if plaintext: convert body into html
1272 :param int parent_id: handle reply to a previous message by adding the
1273 parent partners to the message in case of private discussion
1274 :param tuple(str,str) attachments or list id: list of attachment tuples in the form
1275 ``(name,content)``, where content is NOT base64 encoded
1277 Extra keyword arguments will be used as default column values for the
1278 new mail.message record. Special cases:
1279 - attachment_ids: supposed not attached to any document; attach them
1280 to the related document. Should only be set by Chatter.
1281 :return int: ID of newly created mail.message
1285 if attachments is None:
1287 mail_message = self.pool.get('mail.message')
1288 ir_attachment = self.pool.get('ir.attachment')
1290 assert (not thread_id) or \
1291 isinstance(thread_id, (int, long)) or \
1292 (isinstance(thread_id, (list, tuple)) and len(thread_id) == 1), \
1293 "Invalid thread_id; should be 0, False, an ID or a list with one ID"
1294 if isinstance(thread_id, (list, tuple)):
1295 thread_id = thread_id[0]
1297 # if we're processing a message directly coming from the gateway, the destination model was
1298 # set in the context.
1301 model = context.get('thread_model', self._name) if self._name == 'mail.thread' else self._name
1302 if model != self._name and hasattr(self.pool[model], 'message_post'):
1303 del context['thread_model']
1304 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)
1306 #0: Find the message's author, because we need it for private discussion
1307 author_id = kwargs.get('author_id')
1308 if author_id is None: # keep False values
1309 author_id = self.pool.get('mail.message')._get_default_author(cr, uid, context=context)
1311 # 1: Handle content subtype: if plaintext, converto into HTML
1312 if content_subtype == 'plaintext':
1313 body = tools.plaintext2html(body)
1315 # 2: Private message: add recipients (recipients and author of parent message) - current author
1316 # + legacy-code management (! we manage only 4 and 6 commands)
1318 kwargs_partner_ids = kwargs.pop('partner_ids', [])
1319 for partner_id in kwargs_partner_ids:
1320 if isinstance(partner_id, (list, tuple)) and partner_id[0] == 4 and len(partner_id) == 2:
1321 partner_ids.add(partner_id[1])
1322 if isinstance(partner_id, (list, tuple)) and partner_id[0] == 6 and len(partner_id) == 3:
1323 partner_ids |= set(partner_id[2])
1324 elif isinstance(partner_id, (int, long)):
1325 partner_ids.add(partner_id)
1327 pass # we do not manage anything else
1328 if parent_id and not model:
1329 parent_message = mail_message.browse(cr, uid, parent_id, context=context)
1330 private_followers = set([partner.id for partner in parent_message.partner_ids])
1331 if parent_message.author_id:
1332 private_followers.add(parent_message.author_id.id)
1333 private_followers -= set([author_id])
1334 partner_ids |= private_followers
1337 # - HACK TDE FIXME: Chatter: attachments linked to the document (not done JS-side), load the message
1338 attachment_ids = kwargs.pop('attachment_ids', []) or [] # because we could receive None (some old code sends None)
1340 filtered_attachment_ids = ir_attachment.search(cr, SUPERUSER_ID, [
1341 ('res_model', '=', 'mail.compose.message'),
1342 ('create_uid', '=', uid),
1343 ('id', 'in', attachment_ids)], context=context)
1344 if filtered_attachment_ids:
1345 ir_attachment.write(cr, SUPERUSER_ID, filtered_attachment_ids, {'res_model': model, 'res_id': thread_id}, context=context)
1346 attachment_ids = [(4, id) for id in attachment_ids]
1347 # Handle attachments parameter, that is a dictionary of attachments
1348 for name, content in attachments:
1349 if isinstance(content, unicode):
1350 content = content.encode('utf-8')
1353 'datas': base64.b64encode(str(content)),
1354 'datas_fname': name,
1355 'description': name,
1357 'res_id': thread_id,
1359 attachment_ids.append((0, 0, data_attach))
1361 # 4: mail.message.subtype
1364 if '.' not in subtype:
1365 subtype = 'mail.%s' % subtype
1366 ref = self.pool.get('ir.model.data').get_object_reference(cr, uid, *subtype.split('.'))
1367 subtype_id = ref and ref[1] or False
1369 # automatically subscribe recipients if asked to
1370 if context.get('mail_post_autofollow') and thread_id and partner_ids:
1371 partner_to_subscribe = partner_ids
1372 if context.get('mail_post_autofollow_partner_ids'):
1373 partner_to_subscribe = filter(lambda item: item in context.get('mail_post_autofollow_partner_ids'), partner_ids)
1374 self.message_subscribe(cr, uid, [thread_id], list(partner_to_subscribe), context=context)
1376 # _mail_flat_thread: automatically set free messages to the first posted message
1377 if self._mail_flat_thread and not parent_id and thread_id:
1378 message_ids = mail_message.search(cr, uid, ['&', ('res_id', '=', thread_id), ('model', '=', model)], context=context, order="id ASC", limit=1)
1379 parent_id = message_ids and message_ids[0] or False
1380 # 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
1382 message_ids = mail_message.search(cr, SUPERUSER_ID, [('id', '=', parent_id), ('parent_id', '!=', False)], context=context)
1383 # avoid loops when finding ancestors
1386 message = mail_message.browse(cr, SUPERUSER_ID, message_ids[0], context=context)
1387 while (message.parent_id and message.parent_id.id not in processed_list):
1388 processed_list.append(message.parent_id.id)
1389 message = message.parent_id
1390 parent_id = message.id
1394 'author_id': author_id,
1396 'res_id': thread_id or False,
1398 'subject': subject or False,
1400 'parent_id': parent_id,
1401 'attachment_ids': attachment_ids,
1402 'subtype_id': subtype_id,
1403 'partner_ids': [(4, pid) for pid in partner_ids],
1406 # Avoid warnings about non-existing fields
1407 for x in ('from', 'to', 'cc'):
1410 # Create and auto subscribe the author
1411 msg_id = mail_message.create(cr, uid, values, context=context)
1412 message = mail_message.browse(cr, uid, msg_id, context=context)
1413 if message.author_id and thread_id and type != 'notification' and not context.get('mail_create_nosubscribe'):
1414 self.message_subscribe(cr, uid, [thread_id], [message.author_id.id], context=context)
1417 #------------------------------------------------------
1419 #------------------------------------------------------
1421 def message_get_subscription_data(self, cr, uid, ids, user_pid=None, context=None):
1422 """ Wrapper to get subtypes data. """
1423 return self._get_subscription_data(cr, uid, ids, None, None, user_pid=user_pid, context=context)
1425 def message_subscribe_users(self, cr, uid, ids, user_ids=None, subtype_ids=None, context=None):
1426 """ Wrapper on message_subscribe, using users. If user_ids is not
1427 provided, subscribe uid instead. """
1428 if user_ids is None:
1430 partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
1431 return self.message_subscribe(cr, uid, ids, partner_ids, subtype_ids=subtype_ids, context=context)
1433 def message_subscribe(self, cr, uid, ids, partner_ids, subtype_ids=None, context=None):
1434 """ Add partners to the records followers. """
1435 mail_followers_obj = self.pool.get('mail.followers')
1436 subtype_obj = self.pool.get('mail.message.subtype')
1438 user_pid = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1439 if set(partner_ids) == set([user_pid]):
1441 self.check_access_rights(cr, uid, 'read')
1442 except (osv.except_osv, orm.except_orm):
1445 self.check_access_rights(cr, uid, 'write')
1447 for record in self.browse(cr, SUPERUSER_ID, ids, context=context):
1448 existing_pids = set([f.id for f in record.message_follower_ids
1449 if f.id in partner_ids])
1450 new_pids = set(partner_ids) - existing_pids
1452 # subtype_ids specified: update already subscribed partners
1453 if subtype_ids and existing_pids:
1454 fol_ids = mail_followers_obj.search(cr, SUPERUSER_ID, [
1455 ('res_model', '=', self._name),
1456 ('res_id', '=', record.id),
1457 ('partner_id', 'in', list(existing_pids)),
1459 mail_followers_obj.write(cr, SUPERUSER_ID, fol_ids, {'subtype_ids': [(6, 0, subtype_ids)]}, context=context)
1460 # subtype_ids not specified: do not update already subscribed partner, fetch default subtypes for new partners
1461 elif subtype_ids is None:
1462 subtype_ids = subtype_obj.search(cr, uid, [
1463 ('default', '=', True),
1465 ('res_model', '=', self._name),
1466 ('res_model', '=', False)
1468 # subscribe new followers
1469 for new_pid in new_pids:
1470 mail_followers_obj.create(cr, SUPERUSER_ID, {
1471 'res_model': self._name,
1472 'res_id': record.id,
1473 'partner_id': new_pid,
1474 'subtype_ids': [(6, 0, subtype_ids)],
1479 def message_unsubscribe_users(self, cr, uid, ids, user_ids=None, context=None):
1480 """ Wrapper on message_subscribe, using users. If user_ids is not
1481 provided, unsubscribe uid instead. """
1482 if user_ids is None:
1484 partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
1485 return self.message_unsubscribe(cr, uid, ids, partner_ids, context=context)
1487 def message_unsubscribe(self, cr, uid, ids, partner_ids, context=None):
1488 """ Remove partners from the records followers. """
1489 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
1490 if set(partner_ids) == set([user_pid]):
1491 self.check_access_rights(cr, uid, 'read')
1493 self.check_access_rights(cr, uid, 'write')
1494 return self.write(cr, SUPERUSER_ID, ids, {'message_follower_ids': [(3, pid) for pid in partner_ids]}, context=context)
1496 def _message_get_auto_subscribe_fields(self, cr, uid, updated_fields, auto_follow_fields=['user_id'], context=None):
1497 """ Returns the list of relational fields linking to res.users that should
1498 trigger an auto subscribe. The default list checks for the fields
1500 - linking to res.users
1501 - with track_visibility set
1502 In OpenERP V7, this is sufficent for all major addon such as opportunity,
1503 project, issue, recruitment, sale.
1504 Override this method if a custom behavior is needed about fields
1505 that automatically subscribe users.
1508 for name, column_info in self._all_columns.items():
1509 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':
1510 user_field_lst.append(name)
1511 return user_field_lst
1513 def message_auto_subscribe(self, cr, uid, ids, updated_fields, context=None):
1515 1. fetch project subtype related to task (parent_id.res_model = 'project.task')
1516 2. for each project subtype: subscribe the follower to the task
1518 subtype_obj = self.pool.get('mail.message.subtype')
1519 follower_obj = self.pool.get('mail.followers')
1521 # fetch auto_follow_fields
1522 user_field_lst = self._message_get_auto_subscribe_fields(cr, uid, updated_fields, context=context)
1524 # fetch related record subtypes
1525 related_subtype_ids = subtype_obj.search(cr, uid, ['|', ('res_model', '=', False), ('parent_id.res_model', '=', self._name)], context=context)
1526 subtypes = subtype_obj.browse(cr, uid, related_subtype_ids, context=context)
1527 default_subtypes = [subtype for subtype in subtypes if subtype.res_model == False]
1528 related_subtypes = [subtype for subtype in subtypes if subtype.res_model != False]
1529 relation_fields = set([subtype.relation_field for subtype in subtypes if subtype.relation_field != False])
1530 if (not related_subtypes or not any(relation in updated_fields for relation in relation_fields)) and not user_field_lst:
1533 for record in self.browse(cr, uid, ids, context=context):
1534 new_followers = dict()
1535 parent_res_id = False
1536 parent_model = False
1537 for subtype in related_subtypes:
1538 if not subtype.relation_field or not subtype.parent_id:
1540 if not subtype.relation_field in self._columns or not getattr(record, subtype.relation_field, False):
1542 parent_res_id = getattr(record, subtype.relation_field).id
1543 parent_model = subtype.res_model
1544 follower_ids = follower_obj.search(cr, SUPERUSER_ID, [
1545 ('res_model', '=', parent_model),
1546 ('res_id', '=', parent_res_id),
1547 ('subtype_ids', 'in', [subtype.id])
1549 for follower in follower_obj.browse(cr, SUPERUSER_ID, follower_ids, context=context):
1550 new_followers.setdefault(follower.partner_id.id, set()).add(subtype.parent_id.id)
1552 if parent_res_id and parent_model:
1553 for subtype in default_subtypes:
1554 follower_ids = follower_obj.search(cr, SUPERUSER_ID, [
1555 ('res_model', '=', parent_model),
1556 ('res_id', '=', parent_res_id),
1557 ('subtype_ids', 'in', [subtype.id])
1559 for follower in follower_obj.browse(cr, SUPERUSER_ID, follower_ids, context=context):
1560 new_followers.setdefault(follower.partner_id.id, set()).add(subtype.id)
1562 # add followers coming from res.users relational fields that are tracked
1563 user_ids = [getattr(record, name).id for name in user_field_lst if getattr(record, name)]
1564 user_id_partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, SUPERUSER_ID, user_ids, context=context)]
1565 for partner_id in user_id_partner_ids:
1566 new_followers.setdefault(partner_id, None)
1568 for pid, subtypes in new_followers.items():
1569 subtypes = list(subtypes) if subtypes is not None else None
1570 self.message_subscribe(cr, uid, [record.id], [pid], subtypes, context=context)
1572 # find first email message, set it as unread for auto_subscribe fields for them to have a notification
1573 if user_id_partner_ids:
1574 msg_ids = self.pool.get('mail.message').search(cr, uid, [
1575 ('model', '=', self._name),
1576 ('res_id', '=', record.id),
1577 ('type', '=', 'email')], limit=1, context=context)
1578 if not msg_ids and record.message_ids:
1579 msg_ids = [record.message_ids[-1].id]
1581 self.pool.get('mail.notification')._notify(cr, uid, msg_ids[0], partners_to_notify=user_id_partner_ids, context=context)
1585 #------------------------------------------------------
1587 #------------------------------------------------------
1589 def message_mark_as_unread(self, cr, uid, ids, context=None):
1590 """ Set as unread. """
1591 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1593 UPDATE mail_notification SET
1596 message_id IN (SELECT id from mail_message where res_id=any(%s) and model=%s limit 1) and
1598 ''', (ids, self._name, partner_id))
1601 def message_mark_as_read(self, cr, uid, ids, context=None):
1602 """ Set as read. """
1603 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1605 UPDATE mail_notification SET
1608 message_id IN (SELECT id FROM mail_message WHERE res_id=ANY(%s) AND model=%s) AND
1610 ''', (ids, self._name, partner_id))
1613 #------------------------------------------------------
1615 #------------------------------------------------------
1617 def get_suggested_thread(self, cr, uid, removed_suggested_threads=None, context=None):
1618 """Return a list of suggested threads, sorted by the numbers of followers"""
1622 # TDE HACK: originally by MAT from portal/mail_mail.py but not working until the inheritance graph bug is not solved in trunk
1623 # TDE FIXME: relocate in portal when it won't be necessary to reload the hr.employee model in an additional bridge module
1624 if self.pool['res.groups']._all_columns.get('is_portal'):
1625 user = self.pool.get('res.users').browse(cr, SUPERUSER_ID, uid, context=context)
1626 if any(group.is_portal for group in user.groups_id):
1630 if removed_suggested_threads is None:
1631 removed_suggested_threads = []
1633 thread_ids = self.search(cr, uid, [('id', 'not in', removed_suggested_threads), ('message_is_follower', '=', False)], context=context)
1634 for thread in self.browse(cr, uid, thread_ids, context=context):
1637 'popularity': len(thread.message_follower_ids),
1638 'name': thread.name,
1639 'image_small': thread.image_small
1641 threads.append(data)
1642 return sorted(threads, key=lambda x: (x['popularity'], x['id']), reverse=True)[:3]