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 _get_subscription_data(self, cr, uid, ids, name, args, context=None):
161 - message_subtype_data: data about document subtypes: which are
162 available, which are followed if any """
163 res = dict((id, dict(message_subtype_data='')) for id in ids)
164 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
166 # find current model subtypes, add them to a dictionary
167 subtype_obj = self.pool.get('mail.message.subtype')
168 subtype_ids = subtype_obj.search(cr, uid, ['|', ('res_model', '=', self._name), ('res_model', '=', False)], context=context)
169 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))
171 res[id]['message_subtype_data'] = subtype_dict.copy()
173 # find the document followers, update the data
174 fol_obj = self.pool.get('mail.followers')
175 fol_ids = fol_obj.search(cr, uid, [
176 ('partner_id', '=', user_pid),
177 ('res_id', 'in', ids),
178 ('res_model', '=', self._name),
180 for fol in fol_obj.browse(cr, uid, fol_ids, context=context):
181 thread_subtype_dict = res[fol.res_id]['message_subtype_data']
182 for subtype in fol.subtype_ids:
183 thread_subtype_dict[subtype.name]['followed'] = True
184 res[fol.res_id]['message_subtype_data'] = thread_subtype_dict
188 def _search_message_unread(self, cr, uid, obj=None, name=None, domain=None, context=None):
189 return [('message_ids.to_read', '=', True)]
191 def _get_followers(self, cr, uid, ids, name, arg, context=None):
192 fol_obj = self.pool.get('mail.followers')
193 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids)])
194 res = dict((id, dict(message_follower_ids=[], message_is_follower=False)) for id in ids)
195 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
196 for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids):
197 res[fol.res_id]['message_follower_ids'].append(fol.partner_id.id)
198 if fol.partner_id.id == user_pid:
199 res[fol.res_id]['message_is_follower'] = True
202 def _set_followers(self, cr, uid, id, name, value, arg, context=None):
205 partner_obj = self.pool.get('res.partner')
206 fol_obj = self.pool.get('mail.followers')
208 # read the old set of followers, and determine the new set of followers
209 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', '=', id)])
210 old = set(fol.partner_id.id for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids))
213 for command in value or []:
214 if isinstance(command, (int, long)):
216 elif command[0] == 0:
217 new.add(partner_obj.create(cr, uid, command[2], context=context))
218 elif command[0] == 1:
219 partner_obj.write(cr, uid, [command[1]], command[2], context=context)
221 elif command[0] == 2:
222 partner_obj.unlink(cr, uid, [command[1]], context=context)
223 new.discard(command[1])
224 elif command[0] == 3:
225 new.discard(command[1])
226 elif command[0] == 4:
228 elif command[0] == 5:
230 elif command[0] == 6:
231 new = set(command[2])
233 # remove partners that are no longer followers
234 fol_ids = fol_obj.search(cr, SUPERUSER_ID,
235 [('res_model', '=', self._name), ('res_id', '=', id), ('partner_id', 'not in', list(new))])
236 fol_obj.unlink(cr, SUPERUSER_ID, fol_ids)
239 for partner_id in new - old:
240 fol_obj.create(cr, SUPERUSER_ID, {'res_model': self._name, 'res_id': id, 'partner_id': partner_id})
242 def _search_followers(self, cr, uid, obj, name, args, context):
243 """Search function for message_follower_ids
245 Do not use with operator 'not in'. Use instead message_is_followers
247 fol_obj = self.pool.get('mail.followers')
249 for field, operator, value in args:
251 # TOFIX make it work with not in
252 assert operator != "not in", "Do not search message_follower_ids with 'not in'"
253 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('partner_id', operator, value)])
254 res_ids = [fol.res_id for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids)]
255 res.append(('id', 'in', res_ids))
258 def _search_is_follower(self, cr, uid, obj, name, args, context):
259 """Search function for message_is_follower"""
261 for field, operator, value in args:
263 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
264 if (operator == '=' and value) or (operator == '!=' and not value): # is a follower
265 res_ids = self.search(cr, uid, [('message_follower_ids', 'in', [partner_id])], context=context)
266 else: # is not a follower or unknown domain
267 mail_ids = self.search(cr, uid, [('message_follower_ids', 'in', [partner_id])], context=context)
268 res_ids = self.search(cr, uid, [('id', 'not in', mail_ids)], context=context)
269 res.append(('id', 'in', res_ids))
273 'message_is_follower': fields.function(_get_followers, type='boolean',
274 fnct_search=_search_is_follower, string='Is a Follower', multi='_get_followers,'),
275 'message_follower_ids': fields.function(_get_followers, fnct_inv=_set_followers,
276 fnct_search=_search_followers, type='many2many',
277 obj='res.partner', string='Followers', multi='_get_followers'),
278 'message_ids': fields.one2many('mail.message', 'res_id',
279 domain=lambda self: [('model', '=', self._name)],
282 help="Messages and communication history"),
283 'message_unread': fields.function(_get_message_data,
284 fnct_search=_search_message_unread, multi="_get_message_data",
285 type='boolean', string='Unread Messages',
286 help="If checked new messages require your attention."),
287 'message_summary': fields.function(_get_message_data, method=True,
288 type='text', string='Summary', multi="_get_message_data",
289 help="Holds the Chatter summary (number of messages, ...). "\
290 "This summary is directly in html format in order to "\
291 "be inserted in kanban views."),
294 #------------------------------------------------------
295 # CRUD overrides for automatic subscription and logging
296 #------------------------------------------------------
298 def create(self, cr, uid, values, context=None):
299 """ Chatter override :
301 - subscribe followers of parent
302 - log a creation message
306 thread_id = super(mail_thread, self).create(cr, uid, values, context=context)
308 # automatic logging unless asked not to (mainly for various testing purpose)
309 if not context.get('mail_create_nolog'):
310 self.message_post(cr, uid, thread_id, body=_('%s created') % (self._description), context=context)
312 # subscribe uid unless asked not to
313 if not context.get('mail_create_nosubscribe'):
314 self.message_subscribe_users(cr, uid, [thread_id], [uid], context=context)
315 self.message_auto_subscribe(cr, uid, [thread_id], values.keys(), context=context)
318 tracked_fields = self._get_tracked_fields(cr, uid, values.keys(), context=context)
320 initial_values = {thread_id: dict((item, False) for item in tracked_fields)}
321 self.message_track(cr, uid, [thread_id], tracked_fields, initial_values, context=context)
325 def write(self, cr, uid, ids, values, context=None):
326 if isinstance(ids, (int, long)):
328 # Track initial values of tracked fields
329 tracked_fields = self._get_tracked_fields(cr, uid, values.keys(), context=context)
331 initial = self.read(cr, uid, ids, tracked_fields.keys(), context=context)
332 initial_values = dict((item['id'], item) for item in initial)
334 # Perform write, update followers
335 result = super(mail_thread, self).write(cr, uid, ids, values, context=context)
336 self.message_auto_subscribe(cr, uid, ids, values.keys(), context=context)
338 # Perform the tracking
340 self.message_track(cr, uid, ids, tracked_fields, initial_values, context=context)
343 def unlink(self, cr, uid, ids, context=None):
344 """ Override unlink to delete messages and followers. This cannot be
345 cascaded, because link is done through (res_model, res_id). """
346 msg_obj = self.pool.get('mail.message')
347 fol_obj = self.pool.get('mail.followers')
348 # delete messages and notifications
349 msg_ids = msg_obj.search(cr, uid, [('model', '=', self._name), ('res_id', 'in', ids)], context=context)
350 msg_obj.unlink(cr, uid, msg_ids, context=context)
352 res = super(mail_thread, self).unlink(cr, uid, ids, context=context)
354 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids)], context=context)
355 fol_obj.unlink(cr, SUPERUSER_ID, fol_ids, context=context)
358 def copy(self, cr, uid, id, default=None, context=None):
359 default = default or {}
360 default['message_ids'] = []
361 default['message_follower_ids'] = []
362 return super(mail_thread, self).copy(cr, uid, id, default=default, context=context)
364 #------------------------------------------------------
365 # Automatically log tracked fields
366 #------------------------------------------------------
368 def _get_tracked_fields(self, cr, uid, updated_fields, context=None):
369 """ Return a structure of tracked fields for the current model.
370 :param list updated_fields: modified field names
371 :return list: a list of (field_name, column_info obj), containing
372 always tracked fields and modified on_change fields
375 for name, column_info in self._all_columns.items():
376 visibility = getattr(column_info.column, 'track_visibility', False)
377 if visibility == 'always' or (visibility == 'onchange' and name in updated_fields) or name in self._track:
381 return self.fields_get(cr, uid, lst, context=context)
383 def message_track(self, cr, uid, ids, tracked_fields, initial_values, context=None):
385 def convert_for_display(value, col_info):
386 if not value and col_info['type'] == 'boolean':
390 if col_info['type'] == 'many2one':
392 if col_info['type'] == 'selection':
393 return dict(col_info['selection'])[value]
396 def format_message(message_description, tracked_values):
398 if message_description:
399 message = '<span>%s</span>' % message_description
400 for name, change in tracked_values.items():
401 message += '<div> • <b>%s</b>: ' % change.get('col_info')
402 if change.get('old_value'):
403 message += '%s → ' % change.get('old_value')
404 message += '%s</div>' % change.get('new_value')
407 if not tracked_fields:
410 for record in self.read(cr, uid, ids, tracked_fields.keys(), context=context):
411 initial = initial_values[record['id']]
415 # generate tracked_values data structure: {'col_name': {col_info, new_value, old_value}}
416 for col_name, col_info in tracked_fields.items():
417 if record[col_name] == initial[col_name] and getattr(self._all_columns[col_name].column, 'track_visibility', None) == 'always':
418 tracked_values[col_name] = dict(col_info=col_info['string'],
419 new_value=convert_for_display(record[col_name], col_info))
420 elif record[col_name] != initial[col_name]:
421 if getattr(self._all_columns[col_name].column, 'track_visibility', None) in ['always', 'onchange']:
422 tracked_values[col_name] = dict(col_info=col_info['string'],
423 old_value=convert_for_display(initial[col_name], col_info),
424 new_value=convert_for_display(record[col_name], col_info))
425 if col_name in tracked_fields:
426 changes.append(col_name)
430 # find subtypes and post messages or log if no subtype found
432 for field, track_info in self._track.items():
433 if field not in changes:
435 for subtype, method in track_info.items():
436 if method(self, cr, uid, record, context):
437 subtypes.append(subtype)
440 for subtype in subtypes:
442 subtype_rec = self.pool.get('ir.model.data').get_object(cr, uid, subtype.split('.')[0], subtype.split('.')[1], context=context)
443 except ValueError, e:
444 _logger.debug('subtype %s not found, giving error "%s"' % (subtype, e))
446 message = format_message(subtype_rec.description if subtype_rec.description else subtype_rec.name, tracked_values)
447 self.message_post(cr, uid, record['id'], body=message, subtype=subtype, context=context)
450 message = format_message('', tracked_values)
451 self.message_post(cr, uid, record['id'], body=message, context=context)
454 #------------------------------------------------------
455 # mail.message wrappers and tools
456 #------------------------------------------------------
458 def _needaction_domain_get(self, cr, uid, context=None):
460 return [('message_unread', '=', True)]
463 def _garbage_collect_attachments(self, cr, uid, context=None):
464 """ Garbage collect lost mail attachments. Those are attachments
465 - linked to res_model 'mail.compose.message', the composer wizard
466 - with res_id 0, because they were created outside of an existing
467 wizard (typically user input through Chatter or reports
468 created on-the-fly by the templates)
469 - unused since at least one day (create_date and write_date)
471 limit_date = datetime.datetime.utcnow() - datetime.timedelta(days=1)
472 limit_date_str = datetime.datetime.strftime(limit_date, tools.DEFAULT_SERVER_DATETIME_FORMAT)
473 ir_attachment_obj = self.pool.get('ir.attachment')
474 attach_ids = ir_attachment_obj.search(cr, uid, [
475 ('res_model', '=', 'mail.compose.message'),
477 ('create_date', '<', limit_date_str),
478 ('write_date', '<', limit_date_str),
480 ir_attachment_obj.unlink(cr, uid, attach_ids, context=context)
483 def check_mail_message_access(self, cr, uid, mids, operation, model_obj=None, context=None):
484 """ mail.message check permission rules for related document. This method is
485 meant to be inherited in order to implement addons-specific behavior.
486 A common behavior would be to allow creating messages when having read
487 access rule on the document, for portal document such as issues. """
490 if operation in ['create', 'write', 'unlink']:
491 model_obj.check_access_rights(cr, uid, 'write')
492 model_obj.check_access_rule(cr, uid, mids, 'write', context=context)
494 model_obj.check_access_rights(cr, uid, operation)
495 model_obj.check_access_rule(cr, uid, mids, operation, context=context)
497 def _get_formview_action(self, cr, uid, id, model=None, context=None):
498 """ Return an action to open the document. This method is meant to be
499 overridden in addons that want to give specific view ids for example.
501 :param int id: id of the document to open
502 :param string model: specific model that overrides self._name
505 'type': 'ir.actions.act_window',
506 'res_model': model or self._name,
509 'views': [(False, 'form')],
514 def _get_inbox_action_xml_id(self, cr, uid, context=None):
515 """ When redirecting towards the Inbox, choose which action xml_id has
516 to be fetched. This method is meant to be inherited, at least in portal
517 because portal users have a different Inbox action than classic users. """
518 return ('mail', 'action_mail_inbox_feeds')
520 def message_redirect_action(self, cr, uid, context=None):
521 """ For a given message, return an action that either
522 - opens the form view of the related document if model, res_id, and
523 read access to the document
524 - opens the Inbox with a default search on the conversation if model,
526 - opens the Inbox with context propagated
532 # default action is the Inbox action
533 self.pool.get('res.users').browse(cr, SUPERUSER_ID, uid, context=context)
534 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))
535 action = self.pool.get(act_model).read(cr, uid, act_id, [])
537 # if msg_id specified: try to redirect to the document or fallback on the Inbox
538 msg_id = context.get('params', {}).get('message_id')
541 msg = self.pool.get('mail.message').browse(cr, uid, msg_id, context=context)
542 if msg.model and msg.res_id:
545 'search_default_model': msg.model,
546 'search_default_res_id': msg.res_id,
549 if self.pool.get(msg.model).check_access_rights(cr, uid, 'read', raise_exception=False):
551 model_obj = self.pool.get(msg.model)
552 model_obj.check_access_rule(cr, uid, [msg.res_id], 'read', context=context)
553 if not hasattr(model_obj, '_get_formview_action'):
554 action = self.pool.get('mail.thread')._get_formview_action(cr, uid, msg.res_id, model=msg.model, context=context)
556 action = model_obj._get_formview_action(cr, uid, msg.res_id, context=context)
557 except (osv.except_osv, orm.except_orm):
561 #------------------------------------------------------
563 #------------------------------------------------------
565 def message_get_reply_to(self, cr, uid, ids, context=None):
566 """ Returns the preferred reply-to email address that is basically
567 the alias of the document, if it exists. """
568 if not self._inherits.get('mail.alias'):
569 return [False for id in ids]
570 return ["%s@%s" % (record['alias_name'], record['alias_domain'])
571 if record.get('alias_domain') and record.get('alias_name')
573 for record in self.read(cr, SUPERUSER_ID, ids, ['alias_name', 'alias_domain'], context=context)]
575 #------------------------------------------------------
577 #------------------------------------------------------
579 def message_capable_models(self, cr, uid, context=None):
580 """ Used by the plugin addon, based for plugin_outlook and others. """
582 for model_name in self.pool.obj_list():
583 model = self.pool[model_name]
584 if hasattr(model, "message_process") and hasattr(model, "message_post"):
585 ret_dict[model_name] = model._description
588 def _message_find_partners(self, cr, uid, message, header_fields=['From'], context=None):
589 """ Find partners related to some header fields of the message.
591 :param string message: an email.message instance """
592 s = ', '.join([decode(message.get(h)) for h in header_fields if message.get(h)])
593 return filter(lambda x: x, self._find_partner_from_emails(cr, uid, None, tools.email_split(s), context=context))
595 def message_route_verify(self, cr, uid, message, message_dict, route, update_author=True, assert_model=True, create_fallback=True, context=None):
596 """ Verify route validity. Check and rules:
597 1 - if thread_id -> check that document effectively exists; otherwise
598 fallback on a message_new by resetting thread_id
599 2 - check that message_update exists if thread_id is set; or at least
600 that message_new exist
601 [ - find author_id if udpate_author is set]
602 3 - if there is an alias, check alias_contact:
603 'followers' and thread_id:
604 check on target document that the author is in the followers
605 'followers' and alias_parent_thread_id:
606 check on alias parent document that the author is in the
608 'partners': check that author_id id set
611 assert isinstance(route, (list, tuple)), 'A route should be a list or a tuple'
612 assert len(route) == 5, 'A route should contain 5 elements: model, thread_id, custom_values, uid, alias record'
614 message_id = message.get('Message-Id')
615 email_from = decode_header(message, 'From')
616 author_id = message_dict.get('author_id')
617 model, thread_id, alias = route[0], route[1], route[4]
620 def _create_bounce_email():
621 mail_mail = self.pool.get('mail.mail')
622 mail_id = mail_mail.create(cr, uid, {
623 'body_html': '<div><p>Hello,</p>'
624 '<p>The following email sent to %s cannot be accepted because this is '
625 'a private email address. Only allowed people can contact us at this address.</p></div>'
626 '<blockquote>%s</blockquote>' % (message.get('to'), message_dict.get('body')),
627 'subject': 'Re: %s' % message.get('subject'),
628 'email_to': message.get('from'),
631 mail_mail.send(cr, uid, [mail_id], context=context)
634 _logger.warning('Routing mail with Message-Id %s: route %s: %s',
635 message_id, route, message)
638 if model and not model in self.pool:
640 assert model in self.pool, 'Routing: unknown target model %s' % model
641 _warn('unknown target model %s' % model)
644 model_pool = self.pool[model]
646 # Private message: should not contain any thread_id
647 if not model and thread_id:
649 assert thread_id == 0, 'Routing: posting a message without model should be with a null res_id (private message).'
650 _warn('posting a message without model should be with a null res_id (private message), resetting thread_id')
653 # Existing Document: check if exists; if not, fallback on create if allowed
654 if thread_id and not model_pool.exists(cr, uid, thread_id):
656 _warn('reply to missing document (%s,%s), fall back on new document creation' % (model, thread_id))
659 assert model_pool.exists(cr, uid, thread_id), 'Routing: reply to missing document (%s,%s)' % (model, thread_id)
661 _warn('reply to missing document (%s,%s), skipping' % (model, thread_id))
664 # Existing Document: check model accepts the mailgateway
665 if thread_id and not hasattr(model_pool, 'message_update'):
667 _warn('model %s does not accept document update, fall back on document creation' % model)
670 assert hasattr(model_pool, 'message_update'), 'Routing: model %s does not accept document update, crashing' % model
672 _warn('model %s does not accept document update, skipping' % model)
675 # New Document: check model accepts the mailgateway
676 if not thread_id and not hasattr(model_pool, 'message_new'):
678 assert hasattr(model_pool, 'message_new'), 'Model %s does not accept document creation, crashing' % model
679 _warn('model %s does not accept document creation, skipping' % model)
682 # Update message author if asked
683 # We do it now because we need it for aliases (contact settings)
684 if not author_id and update_author:
685 author_ids = self._find_partner_from_emails(cr, uid, thread_id, [email_from], model=model, context=context)
687 author_id = author_ids[0]
688 message_dict['author_id'] = author_id
690 # Alias: check alias_contact settings
691 if alias and alias.alias_contact == 'followers' and (thread_id or alias.alias_parent_thread_id):
693 obj = self.pool[model].browse(cr, uid, thread_id, context=context)
695 obj = self.pool[alias.alias_parent_model_id.model].browse(cr, uid, alias.alias_parent_thread_id, context=context)
696 if not author_id or not author_id in [fol.id for fol in obj.message_follower_ids]:
697 _warn('alias %s restricted to internal followers, skipping' % alias.alias_name)
698 _create_bounce_email()
700 elif alias and alias.alias_contact == 'partners' and not author_id:
701 _warn('alias %s does not accept unknown author, skipping' % alias.alias_name)
702 _create_bounce_email()
705 return (model, thread_id, route[2], route[3], route[4])
707 def message_route(self, cr, uid, message, message_dict, model=None, thread_id=None,
708 custom_values=None, context=None):
709 """Attempt to figure out the correct target model, thread_id,
710 custom_values and user_id to use for an incoming message.
711 Multiple values may be returned, if a message had multiple
712 recipients matching existing mail.aliases, for example.
714 The following heuristics are used, in this order:
715 1. If the message replies to an existing thread_id, and
716 properly contains the thread model in the 'In-Reply-To'
717 header, use this model/thread_id pair, and ignore
718 custom_value (not needed as no creation will take place)
719 2. Look for a mail.alias entry matching the message
720 recipient, and use the corresponding model, thread_id,
721 custom_values and user_id.
722 3. Fallback to the ``model``, ``thread_id`` and ``custom_values``
724 4. If all the above fails, raise an exception.
726 :param string message: an email.message instance
727 :param dict message_dict: dictionary holding message variables
728 :param string model: the fallback model to use if the message
729 does not match any of the currently configured mail aliases
730 (may be None if a matching alias is supposed to be present)
731 :type dict custom_values: optional dictionary of default field values
732 to pass to ``message_new`` if a new record needs to be created.
733 Ignored if the thread record already exists, and also if a
734 matching mail.alias was found (aliases define their own defaults)
735 :param int thread_id: optional ID of the record/thread from ``model``
736 to which this mail should be attached. Only used if the message
737 does not reply to an existing thread and does not match any mail alias.
738 :return: list of [model, thread_id, custom_values, user_id, alias]
740 assert isinstance(message, Message), 'message must be an email.message.Message at this point'
741 fallback_model = model
743 # Get email.message.Message variables for future processing
744 message_id = message.get('Message-Id')
745 email_from = decode_header(message, 'From')
746 email_to = decode_header(message, 'To')
747 references = decode_header(message, 'References')
748 in_reply_to = decode_header(message, 'In-Reply-To')
750 # 1. Verify if this is a reply to an existing thread
751 thread_references = references or in_reply_to
752 ref_match = thread_references and tools.reference_re.search(thread_references)
754 thread_id = int(ref_match.group(1))
755 model = ref_match.group(2) or fallback_model
756 if thread_id and model in self.pool:
757 model_obj = self.pool[model]
758 if model_obj.exists(cr, uid, thread_id) and hasattr(model_obj, 'message_update'):
759 _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',
760 email_from, email_to, message_id, model, thread_id, custom_values, uid)
761 route = self.message_route_verify(cr, uid, message, message_dict,
762 (model, thread_id, custom_values, uid, None),
763 update_author=True, assert_model=True, create_fallback=True, context=context)
764 return route and [route] or []
766 # 2. Reply to a private message
768 message_ids = self.pool.get('mail.message').search(cr, uid, [
769 ('message_id', '=', in_reply_to),
770 '!', ('message_id', 'ilike', 'reply_to')
771 ], limit=1, context=context)
773 message = self.pool.get('mail.message').browse(cr, uid, message_ids[0], context=context)
774 _logger.info('Routing mail from %s to %s with Message-Id %s: direct reply to a private message: %s, custom_values: %s, uid: %s',
775 email_from, email_to, message_id, message.id, custom_values, uid)
776 route = self.message_route_verify(cr, uid, message, message_dict,
777 (message.model, message.res_id, custom_values, uid, None),
778 update_author=True, assert_model=True, create_fallback=True, context=context)
779 return route and [route] or []
781 # 3. Look for a matching mail.alias entry
782 # Delivered-To is a safe bet in most modern MTAs, but we have to fallback on To + Cc values
783 # for all the odd MTAs out there, as there is no standard header for the envelope's `rcpt_to` value.
785 ','.join([decode_header(message, 'Delivered-To'),
786 decode_header(message, 'To'),
787 decode_header(message, 'Cc'),
788 decode_header(message, 'Resent-To'),
789 decode_header(message, 'Resent-Cc')])
790 local_parts = [e.split('@')[0] for e in tools.email_split(rcpt_tos)]
792 mail_alias = self.pool.get('mail.alias')
793 alias_ids = mail_alias.search(cr, uid, [('alias_name', 'in', local_parts)])
796 for alias in mail_alias.browse(cr, uid, alias_ids, context=context):
797 user_id = alias.alias_user_id.id
799 # TDE note: this could cause crashes, because no clue that the user
800 # that send the email has the right to create or modify a new document
801 # Fallback on user_id = uid
802 # Note: recognized partners will be added as followers anyway
803 # user_id = self._message_find_user_id(cr, uid, message, context=context)
805 _logger.info('No matching user_id for the alias %s', alias.alias_name)
806 route = (alias.alias_model_id.model, alias.alias_force_thread_id, eval(alias.alias_defaults), user_id, alias)
807 _logger.info('Routing mail from %s to %s with Message-Id %s: direct alias match: %r',
808 email_from, email_to, message_id, route)
809 route = self.message_route_verify(cr, uid, message, message_dict, route,
810 update_author=True, assert_model=True, create_fallback=True, context=context)
815 # 4. Fallback to the provided parameters, if they work
817 # Legacy: fallback to matching [ID] in the Subject
818 match = tools.res_re.search(decode_header(message, 'Subject'))
819 thread_id = match and match.group(1)
820 # Convert into int (bug spotted in 7.0 because of str)
822 thread_id = int(thread_id)
825 _logger.info('Routing mail from %s to %s with Message-Id %s: fallback to model:%s, thread_id:%s, custom_values:%s, uid:%s',
826 email_from, email_to, message_id, fallback_model, thread_id, custom_values, uid)
827 route = self.message_route_verify(cr, uid, message, message_dict,
828 (fallback_model, thread_id, custom_values, uid, None),
829 update_author=True, assert_model=True, context=context)
833 # AssertionError if no routes found and if no bounce occured
835 "No possible route found for incoming message from %s to %s (Message-Id %s:)." \
836 "Create an appropriate mail.alias or force the destination model." % (email_from, email_to, message_id)
838 def message_process(self, cr, uid, model, message, custom_values=None,
839 save_original=False, strip_attachments=False,
840 thread_id=None, context=None):
841 """ Process an incoming RFC2822 email message, relying on
842 ``mail.message.parse()`` for the parsing operation,
843 and ``message_route()`` to figure out the target model.
845 Once the target model is known, its ``message_new`` method
846 is called with the new message (if the thread record did not exist)
847 or its ``message_update`` method (if it did).
849 There is a special case where the target model is False: a reply
850 to a private message. In this case, we skip the message_new /
851 message_update step, to just post a new message using mail_thread
854 :param string model: the fallback model to use if the message
855 does not match any of the currently configured mail aliases
856 (may be None if a matching alias is supposed to be present)
857 :param message: source of the RFC2822 message
858 :type message: string or xmlrpclib.Binary
859 :type dict custom_values: optional dictionary of field values
860 to pass to ``message_new`` if a new record needs to be created.
861 Ignored if the thread record already exists, and also if a
862 matching mail.alias was found (aliases define their own defaults)
863 :param bool save_original: whether to keep a copy of the original
864 email source attached to the message after it is imported.
865 :param bool strip_attachments: whether to strip all attachments
866 before processing the message, in order to save some space.
867 :param int thread_id: optional ID of the record/thread from ``model``
868 to which this mail should be attached. When provided, this
869 overrides the automatic detection based on the message
875 # extract message bytes - we are forced to pass the message as binary because
876 # we don't know its encoding until we parse its headers and hence can't
877 # convert it to utf-8 for transport between the mailgate script and here.
878 if isinstance(message, xmlrpclib.Binary):
879 message = str(message.data)
880 # Warning: message_from_string doesn't always work correctly on unicode,
881 # we must use utf-8 strings here :-(
882 if isinstance(message, unicode):
883 message = message.encode('utf-8')
884 msg_txt = email.message_from_string(message)
886 # parse the message, verify we are not in a loop by checking message_id is not duplicated
887 msg = self.message_parse(cr, uid, msg_txt, save_original=save_original, context=context)
888 if strip_attachments:
889 msg.pop('attachments', None)
890 # postpone setting msg.partner_ids after message_post, to avoid double notifications
891 partner_ids = msg.pop('partner_ids', [])
892 if msg.get('message_id'): # should always be True as message_parse generate one if missing
893 existing_msg_ids = self.pool.get('mail.message').search(cr, SUPERUSER_ID, [
894 ('message_id', '=', msg.get('message_id')),
897 _logger.info('Ignored mail from %s to %s with Message-Id %s: found duplicated Message-Id during processing',
898 msg.get('from'), msg.get('to'), msg.get('message_id'))
901 # find possible routes for the message
902 routes = self.message_route(cr, uid, msg_txt, msg, model, thread_id, custom_values, context=context)
904 for model, thread_id, custom_values, user_id, alias in routes:
905 if self._name == 'mail.thread':
906 context.update({'thread_model': model})
908 model_pool = self.pool[model]
909 assert thread_id and hasattr(model_pool, 'message_update') or hasattr(model_pool, 'message_new'), \
910 "Undeliverable mail with Message-Id %s, model %s does not accept incoming emails" % \
911 (msg['message_id'], model)
913 # disabled subscriptions during message_new/update to avoid having the system user running the
914 # email gateway become a follower of all inbound messages
915 nosub_ctx = dict(context, mail_create_nosubscribe=True, mail_create_nolog=True)
916 if thread_id and hasattr(model_pool, 'message_update'):
917 model_pool.message_update(cr, user_id, [thread_id], msg, context=nosub_ctx)
919 thread_id = model_pool.message_new(cr, user_id, msg, custom_values, context=nosub_ctx)
921 assert thread_id == 0, "Posting a message without model should be with a null res_id, to create a private message."
922 model_pool = self.pool.get('mail.thread')
923 if not hasattr(model_pool, 'message_post'):
924 context['thread_model'] = model
925 model_pool = self.pool['mail.thread']
926 new_msg_id = model_pool.message_post(cr, uid, [thread_id], context=context, subtype='mail.mt_comment', **msg)
929 # postponed after message_post, because this is an external message and we don't want to create
930 # duplicate emails due to notifications
931 self.pool.get('mail.message').write(cr, uid, [new_msg_id], {'partner_ids': partner_ids}, context=context)
935 def message_new(self, cr, uid, msg_dict, custom_values=None, context=None):
936 """Called by ``message_process`` when a new message is received
937 for a given thread model, if the message did not belong to
939 The default behavior is to create a new record of the corresponding
940 model (based on some very basic info extracted from the message).
941 Additional behavior may be implemented by overriding this method.
943 :param dict msg_dict: a map containing the email details and
944 attachments. See ``message_process`` and
945 ``mail.message.parse`` for details.
946 :param dict custom_values: optional dictionary of additional
947 field values to pass to create()
948 when creating the new thread record.
949 Be careful, these values may override
950 any other values coming from the message.
951 :param dict context: if a ``thread_model`` value is present
952 in the context, its value will be used
953 to determine the model of the record
954 to create (instead of the current model).
956 :return: the id of the newly created thread object
961 if isinstance(custom_values, dict):
962 data = custom_values.copy()
963 model = context.get('thread_model') or self._name
964 model_pool = self.pool[model]
965 fields = model_pool.fields_get(cr, uid, context=context)
966 if 'name' in fields and not data.get('name'):
967 data['name'] = msg_dict.get('subject', '')
968 res_id = model_pool.create(cr, uid, data, context=context)
971 def message_update(self, cr, uid, ids, msg_dict, update_vals=None, context=None):
972 """Called by ``message_process`` when a new message is received
973 for an existing thread. The default behavior is to update the record
974 with update_vals taken from the incoming email.
975 Additional behavior may be implemented by overriding this
977 :param dict msg_dict: a map containing the email details and
978 attachments. See ``message_process`` and
979 ``mail.message.parse()`` for details.
980 :param dict update_vals: a dict containing values to update records
981 given their ids; if the dict is None or is
982 void, no write operation is performed.
985 self.write(cr, uid, ids, update_vals, context=context)
988 def _message_extract_payload(self, message, save_original=False):
989 """Extract body as HTML and attachments from the mail message"""
993 attachments.append(('original_email.eml', message.as_string()))
994 if not message.is_multipart() or 'text/' in message.get('content-type', ''):
995 encoding = message.get_content_charset()
996 body = message.get_payload(decode=True)
997 body = tools.ustr(body, encoding, errors='replace')
998 if message.get_content_type() == 'text/plain':
999 # text/plain -> <pre/>
1000 body = tools.append_content_to_html(u'', body, preserve=True)
1002 alternative = (message.get_content_type() == 'multipart/alternative')
1003 for part in message.walk():
1004 if part.get_content_maintype() == 'multipart':
1005 continue # skip container
1006 filename = part.get_filename() # None if normal part
1007 encoding = part.get_content_charset() # None if attachment
1008 # 1) Explicit Attachments -> attachments
1009 if filename or part.get('content-disposition', '').strip().startswith('attachment'):
1010 attachments.append((filename or 'attachment', part.get_payload(decode=True)))
1012 # 2) text/plain -> <pre/>
1013 if part.get_content_type() == 'text/plain' and (not alternative or not body):
1014 body = tools.append_content_to_html(body, tools.ustr(part.get_payload(decode=True),
1015 encoding, errors='replace'), preserve=True)
1016 # 3) text/html -> raw
1017 elif part.get_content_type() == 'text/html':
1018 html = tools.ustr(part.get_payload(decode=True), encoding, errors='replace')
1022 body = tools.append_content_to_html(body, html, plaintext=False)
1023 # 4) Anything else -> attachment
1025 attachments.append((filename or 'attachment', part.get_payload(decode=True)))
1026 return body, attachments
1028 def message_parse(self, cr, uid, message, save_original=False, context=None):
1029 """Parses a string or email.message.Message representing an
1030 RFC-2822 email, and returns a generic dict holding the
1033 :param message: the message to parse
1034 :type message: email.message.Message | string | unicode
1035 :param bool save_original: whether the returned dict
1036 should include an ``original`` attachment containing
1037 the source of the message
1039 :return: A dict with the following structure, where each
1040 field may not be present if missing in original
1043 { 'message_id': msg_id,
1048 'body': unified_body,
1049 'attachments': [('file1', 'bytes'),
1056 if not isinstance(message, Message):
1057 if isinstance(message, unicode):
1058 # Warning: message_from_string doesn't always work correctly on unicode,
1059 # we must use utf-8 strings here :-(
1060 message = message.encode('utf-8')
1061 message = email.message_from_string(message)
1063 message_id = message['message-id']
1065 # Very unusual situation, be we should be fault-tolerant here
1066 message_id = "<%s@localhost>" % time.time()
1067 _logger.debug('Parsing Message without message-id, generating a random one: %s', message_id)
1068 msg_dict['message_id'] = message_id
1070 if message.get('Subject'):
1071 msg_dict['subject'] = decode(message.get('Subject'))
1073 # Envelope fields not stored in mail.message but made available for message_new()
1074 msg_dict['from'] = decode(message.get('from'))
1075 msg_dict['to'] = decode(message.get('to'))
1076 msg_dict['cc'] = decode(message.get('cc'))
1077 msg_dict['email_from'] = decode(message.get('from'))
1078 partner_ids = self._message_find_partners(cr, uid, message, ['To', 'Cc'], context=context)
1079 msg_dict['partner_ids'] = [(4, partner_id) for partner_id in partner_ids]
1081 if message.get('Date'):
1083 date_hdr = decode(message.get('Date'))
1084 parsed_date = dateutil.parser.parse(date_hdr, fuzzy=True)
1085 if parsed_date.utcoffset() is None:
1086 # naive datetime, so we arbitrarily decide to make it
1087 # UTC, there's no better choice. Should not happen,
1088 # as RFC2822 requires timezone offset in Date headers.
1089 stored_date = parsed_date.replace(tzinfo=pytz.utc)
1091 stored_date = parsed_date.astimezone(tz=pytz.utc)
1093 _logger.warning('Failed to parse Date header %r in incoming mail '
1094 'with message-id %r, assuming current date/time.',
1095 message.get('Date'), message_id)
1096 stored_date = datetime.datetime.now()
1097 msg_dict['date'] = stored_date.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT)
1099 if message.get('In-Reply-To'):
1100 parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', '=', decode(message['In-Reply-To']))])
1102 msg_dict['parent_id'] = parent_ids[0]
1104 if message.get('References') and 'parent_id' not in msg_dict:
1105 parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', 'in',
1106 [x.strip() for x in decode(message['References']).split()])])
1108 msg_dict['parent_id'] = parent_ids[0]
1110 msg_dict['body'], msg_dict['attachments'] = self._message_extract_payload(message, save_original=save_original)
1113 #------------------------------------------------------
1115 #------------------------------------------------------
1117 def log(self, cr, uid, id, message, secondary=False, context=None):
1118 _logger.warning("log() is deprecated. As this module inherit from "\
1119 "mail.thread, the message will be managed by this "\
1120 "module instead of by the res.log mechanism. Please "\
1121 "use mail_thread.message_post() instead of the "\
1122 "now deprecated res.log.")
1123 self.message_post(cr, uid, [id], message, context=context)
1125 def _message_add_suggested_recipient(self, cr, uid, result, obj, partner=None, email=None, reason='', context=None):
1126 """ Called by message_get_suggested_recipients, to add a suggested
1127 recipient in the result dictionary. The form is :
1128 partner_id, partner_name<partner_email> or partner_name, reason """
1129 if email and not partner:
1130 # get partner info from email
1131 partner_info = self.message_partner_info_from_emails(cr, uid, obj.id, [email], context=context)[0]
1132 if partner_info.get('partner_id'):
1133 partner = self.pool.get('res.partner').browse(cr, SUPERUSER_ID, [partner_info.get('partner_id')], context=context)[0]
1134 if email and email in [val[1] for val in result[obj.id]]: # already existing email -> skip
1136 if partner and partner in obj.message_follower_ids: # recipient already in the followers -> skip
1138 if partner and partner in [val[0] for val in result[obj.id]]: # already existing partner ID -> skip
1140 if partner and partner.email: # complete profile: id, name <email>
1141 result[obj.id].append((partner.id, '%s<%s>' % (partner.name, partner.email), reason))
1142 elif partner: # incomplete profile: id, name
1143 result[obj.id].append((partner.id, '%s' % (partner.name), reason))
1144 else: # unknown partner, we are probably managing an email address
1145 result[obj.id].append((False, email, reason))
1148 def message_get_suggested_recipients(self, cr, uid, ids, context=None):
1149 """ Returns suggested recipients for ids. Those are a list of
1150 tuple (partner_id, partner_name, reason), to be managed by Chatter. """
1151 result = dict.fromkeys(ids, list())
1152 if self._all_columns.get('user_id'):
1153 for obj in self.browse(cr, SUPERUSER_ID, ids, context=context): # SUPERUSER because of a read on res.users that would crash otherwise
1154 if not obj.user_id or not obj.user_id.partner_id:
1156 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)
1159 def _find_partner_from_emails(self, cr, uid, id, emails, model=None, context=None, check_followers=True):
1160 """ Utility method to find partners from email addresses. The rules are :
1161 1 - check in document (model | self, id) followers
1162 2 - try to find a matching partner that is also an user
1163 3 - try to find a matching partner
1165 :param list emails: list of email addresses
1166 :param string model: model to fetch related record; by default self
1168 :param boolean check_followers: check in document followers
1170 partner_obj = self.pool['res.partner']
1173 if id and (model or self._name != 'mail.thread') and check_followers:
1175 obj = self.pool[model].browse(cr, uid, id, context=context)
1177 obj = self.browse(cr, uid, id, context=context)
1178 for contact in emails:
1180 email_address = tools.email_split(contact)
1181 if not email_address:
1182 partner_ids.append(partner_id)
1184 email_address = email_address[0]
1185 # first try: check in document's followers
1187 for follower in obj.message_follower_ids:
1188 if follower.email == email_address:
1189 partner_id = follower.id
1190 # second try: check in partners that are also users
1192 ids = partner_obj.search(cr, SUPERUSER_ID, [
1193 ('email', 'ilike', email_address),
1194 ('user_ids', '!=', False)
1195 ], limit=1, context=context)
1198 # third try: check in partners
1200 ids = partner_obj.search(cr, SUPERUSER_ID, [
1201 ('email', 'ilike', email_address)
1202 ], limit=1, context=context)
1205 partner_ids.append(partner_id)
1208 def message_partner_info_from_emails(self, cr, uid, id, emails, link_mail=False, context=None):
1209 """ Convert a list of emails into a list partner_ids and a list
1210 new_partner_ids. The return value is non conventional because
1211 it is meant to be used by the mail widget.
1213 :return dict: partner_ids and new_partner_ids """
1214 mail_message_obj = self.pool.get('mail.message')
1215 partner_ids = self._find_partner_from_emails(cr, uid, id, emails, context=context)
1217 for idx in range(len(emails)):
1218 email_address = emails[idx]
1219 partner_id = partner_ids[idx]
1220 partner_info = {'full_name': email_address, 'partner_id': partner_id}
1221 result.append(partner_info)
1223 # link mail with this from mail to the new partner id
1224 if link_mail and partner_info['partner_id']:
1225 message_ids = mail_message_obj.search(cr, SUPERUSER_ID, [
1227 ('email_from', '=', email_address),
1228 ('email_from', 'ilike', '<%s>' % email_address),
1229 ('author_id', '=', False)
1232 mail_message_obj.write(cr, SUPERUSER_ID, message_ids, {'author_id': partner_info['partner_id']}, context=context)
1235 def message_post(self, cr, uid, thread_id, body='', subject=None, type='notification',
1236 subtype=None, parent_id=False, attachments=None, context=None,
1237 content_subtype='html', **kwargs):
1238 """ Post a new message in an existing thread, returning the new
1241 :param int thread_id: thread ID to post into, or list with one ID;
1242 if False/0, mail.message model will also be set as False
1243 :param str body: body of the message, usually raw HTML that will
1245 :param str type: see mail_message.type field
1246 :param str content_subtype:: if plaintext: convert body into html
1247 :param int parent_id: handle reply to a previous message by adding the
1248 parent partners to the message in case of private discussion
1249 :param tuple(str,str) attachments or list id: list of attachment tuples in the form
1250 ``(name,content)``, where content is NOT base64 encoded
1252 Extra keyword arguments will be used as default column values for the
1253 new mail.message record. Special cases:
1254 - attachment_ids: supposed not attached to any document; attach them
1255 to the related document. Should only be set by Chatter.
1256 :return int: ID of newly created mail.message
1260 if attachments is None:
1262 mail_message = self.pool.get('mail.message')
1263 ir_attachment = self.pool.get('ir.attachment')
1265 assert (not thread_id) or \
1266 isinstance(thread_id, (int, long)) or \
1267 (isinstance(thread_id, (list, tuple)) and len(thread_id) == 1), \
1268 "Invalid thread_id; should be 0, False, an ID or a list with one ID"
1269 if isinstance(thread_id, (list, tuple)):
1270 thread_id = thread_id[0]
1272 # if we're processing a message directly coming from the gateway, the destination model was
1273 # set in the context.
1276 model = context.get('thread_model', self._name) if self._name == 'mail.thread' else self._name
1277 if model != self._name and hasattr(self.pool[model], 'message_post'):
1278 del context['thread_model']
1279 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)
1281 #0: Find the message's author, because we need it for private discussion
1282 author_id = kwargs.get('author_id')
1283 if author_id is None: # keep False values
1284 author_id = self.pool.get('mail.message')._get_default_author(cr, uid, context=context)
1286 # 1: Handle content subtype: if plaintext, converto into HTML
1287 if content_subtype == 'plaintext':
1288 body = tools.plaintext2html(body)
1290 # 2: Private message: add recipients (recipients and author of parent message) - current author
1291 # + legacy-code management (! we manage only 4 and 6 commands)
1293 kwargs_partner_ids = kwargs.pop('partner_ids', [])
1294 for partner_id in kwargs_partner_ids:
1295 if isinstance(partner_id, (list, tuple)) and partner_id[0] == 4 and len(partner_id) == 2:
1296 partner_ids.add(partner_id[1])
1297 if isinstance(partner_id, (list, tuple)) and partner_id[0] == 6 and len(partner_id) == 3:
1298 partner_ids |= set(partner_id[2])
1299 elif isinstance(partner_id, (int, long)):
1300 partner_ids.add(partner_id)
1302 pass # we do not manage anything else
1303 if parent_id and not model:
1304 parent_message = mail_message.browse(cr, uid, parent_id, context=context)
1305 private_followers = set([partner.id for partner in parent_message.partner_ids])
1306 if parent_message.author_id:
1307 private_followers.add(parent_message.author_id.id)
1308 private_followers -= set([author_id])
1309 partner_ids |= private_followers
1312 # - HACK TDE FIXME: Chatter: attachments linked to the document (not done JS-side), load the message
1313 attachment_ids = kwargs.pop('attachment_ids', []) or [] # because we could receive None (some old code sends None)
1315 filtered_attachment_ids = ir_attachment.search(cr, SUPERUSER_ID, [
1316 ('res_model', '=', 'mail.compose.message'),
1317 ('create_uid', '=', uid),
1318 ('id', 'in', attachment_ids)], context=context)
1319 if filtered_attachment_ids:
1320 ir_attachment.write(cr, SUPERUSER_ID, filtered_attachment_ids, {'res_model': model, 'res_id': thread_id}, context=context)
1321 attachment_ids = [(4, id) for id in attachment_ids]
1322 # Handle attachments parameter, that is a dictionary of attachments
1323 for name, content in attachments:
1324 if isinstance(content, unicode):
1325 content = content.encode('utf-8')
1328 'datas': base64.b64encode(str(content)),
1329 'datas_fname': name,
1330 'description': name,
1332 'res_id': thread_id,
1334 attachment_ids.append((0, 0, data_attach))
1336 # 4: mail.message.subtype
1339 if '.' not in subtype:
1340 subtype = 'mail.%s' % subtype
1341 ref = self.pool.get('ir.model.data').get_object_reference(cr, uid, *subtype.split('.'))
1342 subtype_id = ref and ref[1] or False
1344 # automatically subscribe recipients if asked to
1345 if context.get('mail_post_autofollow') and thread_id and partner_ids:
1346 partner_to_subscribe = partner_ids
1347 if context.get('mail_post_autofollow_partner_ids'):
1348 partner_to_subscribe = filter(lambda item: item in context.get('mail_post_autofollow_partner_ids'), partner_ids)
1349 self.message_subscribe(cr, uid, [thread_id], list(partner_to_subscribe), context=context)
1351 # _mail_flat_thread: automatically set free messages to the first posted message
1352 if self._mail_flat_thread and not parent_id and thread_id:
1353 message_ids = mail_message.search(cr, uid, ['&', ('res_id', '=', thread_id), ('model', '=', model)], context=context, order="id ASC", limit=1)
1354 parent_id = message_ids and message_ids[0] or False
1355 # 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
1357 message_ids = mail_message.search(cr, SUPERUSER_ID, [('id', '=', parent_id), ('parent_id', '!=', False)], context=context)
1358 # avoid loops when finding ancestors
1361 message = mail_message.browse(cr, SUPERUSER_ID, message_ids[0], context=context)
1362 while (message.parent_id and message.parent_id.id not in processed_list):
1363 processed_list.append(message.parent_id.id)
1364 message = message.parent_id
1365 parent_id = message.id
1369 'author_id': author_id,
1371 'res_id': thread_id or False,
1373 'subject': subject or False,
1375 'parent_id': parent_id,
1376 'attachment_ids': attachment_ids,
1377 'subtype_id': subtype_id,
1378 'partner_ids': [(4, pid) for pid in partner_ids],
1381 # Avoid warnings about non-existing fields
1382 for x in ('from', 'to', 'cc'):
1385 # Create and auto subscribe the author
1386 msg_id = mail_message.create(cr, uid, values, context=context)
1387 message = mail_message.browse(cr, uid, msg_id, context=context)
1388 if message.author_id and thread_id and type != 'notification' and not context.get('mail_create_nosubscribe'):
1389 self.message_subscribe(cr, uid, [thread_id], [message.author_id.id], context=context)
1392 #------------------------------------------------------
1394 #------------------------------------------------------
1396 def message_get_subscription_data(self, cr, uid, ids, context=None):
1397 """ Wrapper to get subtypes data. """
1398 return self._get_subscription_data(cr, uid, ids, None, None, context=context)
1400 def message_subscribe_users(self, cr, uid, ids, user_ids=None, subtype_ids=None, context=None):
1401 """ Wrapper on message_subscribe, using users. If user_ids is not
1402 provided, subscribe uid instead. """
1403 if user_ids is None:
1405 partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
1406 return self.message_subscribe(cr, uid, ids, partner_ids, subtype_ids=subtype_ids, context=context)
1408 def message_subscribe(self, cr, uid, ids, partner_ids, subtype_ids=None, context=None):
1409 """ Add partners to the records followers. """
1410 mail_followers_obj = self.pool.get('mail.followers')
1411 subtype_obj = self.pool.get('mail.message.subtype')
1413 user_pid = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1414 if set(partner_ids) == set([user_pid]):
1416 self.check_access_rights(cr, uid, 'read')
1417 except (osv.except_osv, orm.except_orm):
1420 self.check_access_rights(cr, uid, 'write')
1422 for record in self.browse(cr, SUPERUSER_ID, ids, context=context):
1423 existing_pids = set([f.id for f in record.message_follower_ids
1424 if f.id in partner_ids])
1425 new_pids = set(partner_ids) - existing_pids
1427 # subtype_ids specified: update already subscribed partners
1428 if subtype_ids and existing_pids:
1429 fol_ids = mail_followers_obj.search(cr, SUPERUSER_ID, [
1430 ('res_model', '=', self._name),
1431 ('res_id', '=', record.id),
1432 ('partner_id', 'in', list(existing_pids)),
1434 mail_followers_obj.write(cr, SUPERUSER_ID, fol_ids, {'subtype_ids': [(6, 0, subtype_ids)]}, context=context)
1435 # subtype_ids not specified: do not update already subscribed partner, fetch default subtypes for new partners
1437 subtype_ids = subtype_obj.search(cr, uid, [
1438 ('default', '=', True),
1440 ('res_model', '=', self._name),
1441 ('res_model', '=', False)
1443 # subscribe new followers
1444 for new_pid in new_pids:
1445 mail_followers_obj.create(cr, SUPERUSER_ID, {
1446 'res_model': self._name,
1447 'res_id': record.id,
1448 'partner_id': new_pid,
1449 'subtype_ids': [(6, 0, subtype_ids)],
1454 def message_unsubscribe_users(self, cr, uid, ids, user_ids=None, context=None):
1455 """ Wrapper on message_subscribe, using users. If user_ids is not
1456 provided, unsubscribe uid instead. """
1457 if user_ids is None:
1459 partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
1460 return self.message_unsubscribe(cr, uid, ids, partner_ids, context=context)
1462 def message_unsubscribe(self, cr, uid, ids, partner_ids, context=None):
1463 """ Remove partners from the records followers. """
1464 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
1465 if set(partner_ids) == set([user_pid]):
1466 self.check_access_rights(cr, uid, 'read')
1468 self.check_access_rights(cr, uid, 'write')
1469 return self.write(cr, SUPERUSER_ID, ids, {'message_follower_ids': [(3, pid) for pid in partner_ids]}, context=context)
1471 def _message_get_auto_subscribe_fields(self, cr, uid, updated_fields, auto_follow_fields=['user_id'], context=None):
1472 """ Returns the list of relational fields linking to res.users that should
1473 trigger an auto subscribe. The default list checks for the fields
1475 - linking to res.users
1476 - with track_visibility set
1477 In OpenERP V7, this is sufficent for all major addon such as opportunity,
1478 project, issue, recruitment, sale.
1479 Override this method if a custom behavior is needed about fields
1480 that automatically subscribe users.
1483 for name, column_info in self._all_columns.items():
1484 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':
1485 user_field_lst.append(name)
1486 return user_field_lst
1488 def message_auto_subscribe(self, cr, uid, ids, updated_fields, context=None):
1490 1. fetch project subtype related to task (parent_id.res_model = 'project.task')
1491 2. for each project subtype: subscribe the follower to the task
1493 subtype_obj = self.pool.get('mail.message.subtype')
1494 follower_obj = self.pool.get('mail.followers')
1496 # fetch auto_follow_fields
1497 user_field_lst = self._message_get_auto_subscribe_fields(cr, uid, updated_fields, context=context)
1499 # fetch related record subtypes
1500 related_subtype_ids = subtype_obj.search(cr, uid, ['|', ('res_model', '=', False), ('parent_id.res_model', '=', self._name)], context=context)
1501 subtypes = subtype_obj.browse(cr, uid, related_subtype_ids, context=context)
1502 default_subtypes = [subtype for subtype in subtypes if subtype.res_model == False]
1503 related_subtypes = [subtype for subtype in subtypes if subtype.res_model != False]
1504 relation_fields = set([subtype.relation_field for subtype in subtypes if subtype.relation_field != False])
1505 if (not related_subtypes or not any(relation in updated_fields for relation in relation_fields)) and not user_field_lst:
1508 for record in self.browse(cr, uid, ids, context=context):
1509 new_followers = dict()
1510 parent_res_id = False
1511 parent_model = False
1512 for subtype in related_subtypes:
1513 if not subtype.relation_field or not subtype.parent_id:
1515 if not subtype.relation_field in self._columns or not getattr(record, subtype.relation_field, False):
1517 parent_res_id = getattr(record, subtype.relation_field).id
1518 parent_model = subtype.res_model
1519 follower_ids = follower_obj.search(cr, SUPERUSER_ID, [
1520 ('res_model', '=', parent_model),
1521 ('res_id', '=', parent_res_id),
1522 ('subtype_ids', 'in', [subtype.id])
1524 for follower in follower_obj.browse(cr, SUPERUSER_ID, follower_ids, context=context):
1525 new_followers.setdefault(follower.partner_id.id, set()).add(subtype.parent_id.id)
1527 if parent_res_id and parent_model:
1528 for subtype in default_subtypes:
1529 follower_ids = follower_obj.search(cr, SUPERUSER_ID, [
1530 ('res_model', '=', parent_model),
1531 ('res_id', '=', parent_res_id),
1532 ('subtype_ids', 'in', [subtype.id])
1534 for follower in follower_obj.browse(cr, SUPERUSER_ID, follower_ids, context=context):
1535 new_followers.setdefault(follower.partner_id.id, set()).add(subtype.id)
1537 # add followers coming from res.users relational fields that are tracked
1538 user_ids = [getattr(record, name).id for name in user_field_lst if getattr(record, name)]
1539 user_id_partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, SUPERUSER_ID, user_ids, context=context)]
1540 for partner_id in user_id_partner_ids:
1541 new_followers.setdefault(partner_id, None)
1543 for pid, subtypes in new_followers.items():
1544 subtypes = list(subtypes) if subtypes is not None else None
1545 self.message_subscribe(cr, uid, [record.id], [pid], subtypes, context=context)
1547 # find first email message, set it as unread for auto_subscribe fields for them to have a notification
1548 if user_id_partner_ids:
1549 msg_ids = self.pool.get('mail.message').search(cr, uid, [
1550 ('model', '=', self._name),
1551 ('res_id', '=', record.id),
1552 ('type', '=', 'email')], limit=1, context=context)
1553 if not msg_ids and record.message_ids:
1554 msg_ids = [record.message_ids[-1].id]
1556 self.pool.get('mail.notification')._notify(cr, uid, msg_ids[0], partners_to_notify=user_id_partner_ids, context=context)
1560 #------------------------------------------------------
1562 #------------------------------------------------------
1564 def message_mark_as_unread(self, cr, uid, ids, context=None):
1565 """ Set as unread. """
1566 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1568 UPDATE mail_notification SET
1571 message_id IN (SELECT id from mail_message where res_id=any(%s) and model=%s limit 1) and
1573 ''', (ids, self._name, partner_id))
1576 def message_mark_as_read(self, cr, uid, ids, context=None):
1577 """ Set as read. """
1578 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1580 UPDATE mail_notification SET
1583 message_id IN (SELECT id FROM mail_message WHERE res_id=ANY(%s) AND model=%s) AND
1585 ''', (ids, self._name, partner_id))
1588 #------------------------------------------------------
1590 #------------------------------------------------------
1592 def get_suggested_thread(self, cr, uid, removed_suggested_threads=None, context=None):
1593 """Return a list of suggested threads, sorted by the numbers of followers"""
1597 # TDE HACK: originally by MAT from portal/mail_mail.py but not working until the inheritance graph bug is not solved in trunk
1598 # TDE FIXME: relocate in portal when it won't be necessary to reload the hr.employee model in an additional bridge module
1599 if self.pool['res.groups']._all_columns.get('is_portal'):
1600 user = self.pool.get('res.users').browse(cr, SUPERUSER_ID, uid, context=context)
1601 if any(group.is_portal for group in user.groups_id):
1605 if removed_suggested_threads is None:
1606 removed_suggested_threads = []
1608 thread_ids = self.search(cr, uid, [('id', 'not in', removed_suggested_threads), ('message_is_follower', '=', False)], context=context)
1609 for thread in self.browse(cr, uid, thread_ids, context=context):
1612 'popularity': len(thread.message_follower_ids),
1613 'name': thread.name,
1614 'image_small': thread.image_small
1616 threads.append(data)
1617 return sorted(threads, key=lambda x: (x['popularity'], x['id']), reverse=True)[:3]