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.osv.orm import browse_record, browse_null
37 from openerp.tools.safe_eval import safe_eval as eval
38 from openerp.tools.translate import _
40 _logger = logging.getLogger(__name__)
43 def decode_header(message, header, separator=' '):
44 return separator.join(map(decode, filter(None, message.get_all(header, []))))
47 class mail_thread(osv.AbstractModel):
48 ''' mail_thread model is meant to be inherited by any model that needs to
49 act as a discussion topic on which messages can be attached. Public
50 methods are prefixed with ``message_`` in order to avoid name
51 collisions with methods of the models that will inherit from this class.
53 ``mail.thread`` defines fields used to handle and display the
54 communication history. ``mail.thread`` also manages followers of
55 inheriting classes. All features and expected behavior are managed
56 by mail.thread. Widgets has been designed for the 7.0 and following
59 Inheriting classes are not required to implement any method, as the
60 default implementation will work for any model. However it is common
61 to override at least the ``message_new`` and ``message_update``
62 methods (calling ``super``) to add model-specific behavior at
63 creation and update of a thread when processing incoming emails.
66 - _mail_flat_thread: if set to True, all messages without parent_id
67 are automatically attached to the first message posted on the
68 ressource. If set to False, the display of Chatter is done using
69 threads, and no parent_id is automatically set.
72 _description = 'Email Thread'
73 _mail_flat_thread = True
74 _mail_post_access = 'write'
76 # Automatic logging system if mail installed
79 # 'module.subtype_xml': lambda self, cr, uid, obj, context=None: obj[state] == done,
80 # 'module.subtype_xml2': lambda self, cr, uid, obj, context=None: obj[state] != done,
87 # :param string field: field name
88 # :param module.subtype_xml: xml_id of a mail.message.subtype (i.e. mail.mt_comment)
89 # :param obj: is a browse_record
90 # :param function lambda: returns whether the tracking should record using this subtype
93 def get_empty_list_help(self, cr, uid, help, context=None):
94 """ Override of BaseModel.get_empty_list_help() to generate an help message
95 that adds alias information. """
96 model = context.get('empty_list_help_model')
97 res_id = context.get('empty_list_help_id')
98 ir_config_parameter = self.pool.get("ir.config_parameter")
99 catchall_domain = ir_config_parameter.get_param(cr, uid, "mail.catchall.domain", context=context)
100 document_name = context.get('empty_list_help_document_name', _('document'))
103 if catchall_domain and model and res_id: # specific res_id -> find its alias (i.e. section_id specified)
104 object_id = self.pool.get(model).browse(cr, uid, res_id, context=context)
105 # check that the alias effectively creates new records
106 if object_id.alias_id and object_id.alias_id.alias_name and \
107 object_id.alias_id.alias_model_id and \
108 object_id.alias_id.alias_model_id.model == self._name and \
109 object_id.alias_id.alias_force_thread_id == 0:
110 alias = object_id.alias_id
111 elif catchall_domain and model: # no specific res_id given -> generic help message, take an example alias (i.e. alias of some section_id)
112 model_id = self.pool.get('ir.model').search(cr, uid, [("model", "=", self._name)], context=context)[0]
113 alias_obj = self.pool.get('mail.alias')
114 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')
115 if alias_ids and len(alias_ids) == 1: # if several aliases -> incoherent to propose one guessed from nowhere, therefore avoid if several aliases
116 alias = alias_obj.browse(cr, uid, alias_ids[0], context=context)
119 alias_email = alias.name_get()[0][1]
120 return _("""<p class='oe_view_nocontent_create'>
121 Click here to add new %(document)s or send an email to: <a href='mailto:%(email)s'>%(email)s</a>
125 'document': document_name,
126 'email': alias_email,
127 'static_help': help or ''
130 if document_name != 'document' and help and help.find("oe_view_nocontent_create") == -1:
131 return _("<p class='oe_view_nocontent_create'>Click here to add new %(document)s</p>%(static_help)s") % {
132 'document': document_name,
133 'static_help': help or '',
138 def _get_message_data(self, cr, uid, ids, name, args, context=None):
140 - message_unread: has uid unread message for the document
141 - message_summary: html snippet summarizing the Chatter for kanban views """
142 res = dict((id, dict(message_unread=False, message_unread_count=0, message_summary=' ')) for id in ids)
143 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
145 # search for unread messages, directly in SQL to improve performances
146 cr.execute(""" SELECT m.res_id FROM mail_message m
147 RIGHT JOIN mail_notification n
148 ON (n.message_id = m.id AND n.partner_id = %s AND (n.read = False or n.read IS NULL))
149 WHERE m.model = %s AND m.res_id in %s""",
150 (user_pid, self._name, tuple(ids),))
151 for result in cr.fetchall():
152 res[result[0]]['message_unread'] = True
153 res[result[0]]['message_unread_count'] += 1
156 if res[id]['message_unread_count']:
157 title = res[id]['message_unread_count'] > 1 and _("You have %d unread messages") % res[id]['message_unread_count'] or _("You have one unread message")
158 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 res[id].pop('message_unread_count', None)
162 def read_followers_data(self, cr, uid, follower_ids, context=None):
164 technical_group = self.pool.get('ir.model.data').get_object(cr, uid, 'base', 'group_no_one')
165 for follower in self.pool.get('res.partner').browse(cr, uid, follower_ids, context=context):
166 is_editable = uid in map(lambda x: x.id, technical_group.users)
167 is_uid = uid in map(lambda x: x.id, follower.user_ids)
170 {'is_editable': is_editable, 'is_uid': is_uid},
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 self.message_unsubscribe(cr, uid, [id], list(old-new), context=context)
253 self.message_subscribe(cr, uid, [id], list(new-old), context=context)
255 def _search_followers(self, cr, uid, obj, name, args, context):
256 """Search function for message_follower_ids
258 Do not use with operator 'not in'. Use instead message_is_followers
260 fol_obj = self.pool.get('mail.followers')
262 for field, operator, value in args:
264 # TOFIX make it work with not in
265 assert operator != "not in", "Do not search message_follower_ids with 'not in'"
266 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('partner_id', operator, value)])
267 res_ids = [fol.res_id for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids)]
268 res.append(('id', 'in', res_ids))
271 def _search_is_follower(self, cr, uid, obj, name, args, context):
272 """Search function for message_is_follower"""
274 for field, operator, value in args:
276 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
277 if (operator == '=' and value) or (operator == '!=' and not value): # is a follower
278 res_ids = self.search(cr, uid, [('message_follower_ids', 'in', [partner_id])], context=context)
279 else: # is not a follower or unknown domain
280 mail_ids = self.search(cr, uid, [('message_follower_ids', 'in', [partner_id])], context=context)
281 res_ids = self.search(cr, uid, [('id', 'not in', mail_ids)], context=context)
282 res.append(('id', 'in', res_ids))
286 'message_is_follower': fields.function(_get_followers, type='boolean',
287 fnct_search=_search_is_follower, string='Is a Follower', multi='_get_followers,'),
288 'message_follower_ids': fields.function(_get_followers, fnct_inv=_set_followers,
289 fnct_search=_search_followers, type='many2many', priority=-10,
290 obj='res.partner', string='Followers', multi='_get_followers'),
291 'message_ids': fields.one2many('mail.message', 'res_id',
292 domain=lambda self: [('model', '=', self._name)],
295 help="Messages and communication history"),
296 'message_unread': fields.function(_get_message_data,
297 fnct_search=_search_message_unread, multi="_get_message_data",
298 type='boolean', string='Unread Messages',
299 help="If checked new messages require your attention."),
300 'message_summary': fields.function(_get_message_data, method=True,
301 type='text', string='Summary', multi="_get_message_data",
302 help="Holds the Chatter summary (number of messages, ...). "\
303 "This summary is directly in html format in order to "\
304 "be inserted in kanban views."),
307 #------------------------------------------------------
308 # CRUD overrides for automatic subscription and logging
309 #------------------------------------------------------
311 def create(self, cr, uid, values, context=None):
312 """ Chatter override :
314 - subscribe followers of parent
315 - log a creation message
320 # subscribe uid unless asked not to
321 if not context.get('mail_create_nosubscribe'):
322 pid = self.pool['res.users'].browse(cr, SUPERUSER_ID, uid).partner_id.id
323 message_follower_ids = values.get('message_follower_ids') or [] # webclient can send None or False
324 message_follower_ids.append([4, pid])
325 values['message_follower_ids'] = message_follower_ids
326 # add operation to ignore access rule checking for subscription
327 context_operation = dict(context, operation='create')
329 context_operation = context
330 thread_id = super(mail_thread, self).create(cr, uid, values, context=context_operation)
332 # automatic logging unless asked not to (mainly for various testing purpose)
333 if not context.get('mail_create_nolog'):
334 self.message_post(cr, uid, thread_id, body=_('%s created') % (self._description), context=context)
336 # auto_subscribe: take values and defaults into account
337 create_values = dict(values)
338 for key, val in context.iteritems():
339 if key.startswith('default_'):
340 create_values[key[8:]] = val
341 self.message_auto_subscribe(cr, uid, [thread_id], create_values.keys(), context=context, values=create_values)
344 track_ctx = dict(context)
345 if 'lang' not in track_ctx:
346 track_ctx['lang'] = self.pool.get('res.users').browse(cr, uid, uid, context=context).lang
347 if not context.get('mail_notrack'):
348 tracked_fields = self._get_tracked_fields(cr, uid, values.keys(), context=track_ctx)
350 initial_values = {thread_id: dict((item, False) for item in tracked_fields)}
351 self.message_track(cr, uid, [thread_id], tracked_fields, initial_values, context=track_ctx)
354 def write(self, cr, uid, ids, values, context=None):
357 if isinstance(ids, (int, long)):
359 # Track initial values of tracked fields
360 track_ctx = dict(context)
361 if 'lang' not in track_ctx:
362 track_ctx['lang'] = self.pool.get('res.users').browse(cr, uid, uid, context=context).lang
363 tracked_fields = self._get_tracked_fields(cr, uid, values.keys(), context=track_ctx)
365 records = self.browse(cr, uid, ids, context=track_ctx)
366 initial_values = dict((this.id, dict((key, getattr(this, key)) for key in tracked_fields.keys())) for this in records)
368 # Perform write, update followers
369 result = super(mail_thread, self).write(cr, uid, ids, values, context=context)
370 self.message_auto_subscribe(cr, uid, ids, values.keys(), context=context, values=values)
372 if not context.get('mail_notrack'):
373 # Perform the tracking
374 tracked_fields = self._get_tracked_fields(cr, uid, values.keys(), context=context)
376 tracked_fields = None
378 self.message_track(cr, uid, ids, tracked_fields, initial_values, context=track_ctx)
381 def unlink(self, cr, uid, ids, context=None):
382 """ Override unlink to delete messages and followers. This cannot be
383 cascaded, because link is done through (res_model, res_id). """
384 msg_obj = self.pool.get('mail.message')
385 fol_obj = self.pool.get('mail.followers')
386 # delete messages and notifications
387 msg_ids = msg_obj.search(cr, uid, [('model', '=', self._name), ('res_id', 'in', ids)], context=context)
388 msg_obj.unlink(cr, uid, msg_ids, context=context)
390 res = super(mail_thread, self).unlink(cr, uid, ids, context=context)
392 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids)], context=context)
393 fol_obj.unlink(cr, SUPERUSER_ID, fol_ids, context=context)
396 def copy(self, cr, uid, id, default=None, context=None):
397 # avoid tracking multiple temporary changes during copy
398 context = dict(context or {}, mail_notrack=True)
400 default = default or {}
401 default['message_ids'] = []
402 default['message_follower_ids'] = []
403 return super(mail_thread, self).copy(cr, uid, id, default=default, context=context)
405 #------------------------------------------------------
406 # Automatically log tracked fields
407 #------------------------------------------------------
409 def _get_tracked_fields(self, cr, uid, updated_fields, context=None):
410 """ Return a structure of tracked fields for the current model.
411 :param list updated_fields: modified field names
412 :return list: a list of (field_name, column_info obj), containing
413 always tracked fields and modified on_change fields
416 for name, column_info in self._all_columns.items():
417 visibility = getattr(column_info.column, 'track_visibility', False)
418 if visibility == 'always' or (visibility == 'onchange' and name in updated_fields) or name in self._track:
422 return self.fields_get(cr, uid, lst, context=context)
424 def message_track(self, cr, uid, ids, tracked_fields, initial_values, context=None):
426 def convert_for_display(value, col_info):
427 if not value and col_info['type'] == 'boolean':
431 if col_info['type'] == 'many2one':
432 return value.name_get()[0][1]
433 if col_info['type'] == 'selection':
434 return dict(col_info['selection'])[value]
437 def format_message(message_description, tracked_values):
439 if message_description:
440 message = '<span>%s</span>' % message_description
441 for name, change in tracked_values.items():
442 message += '<div> • <b>%s</b>: ' % change.get('col_info')
443 if change.get('old_value'):
444 message += '%s → ' % change.get('old_value')
445 message += '%s</div>' % change.get('new_value')
448 if not tracked_fields:
451 for browse_record in self.browse(cr, uid, ids, context=context):
452 initial = initial_values[browse_record.id]
456 # generate tracked_values data structure: {'col_name': {col_info, new_value, old_value}}
457 for col_name, col_info in tracked_fields.items():
458 initial_value = initial[col_name]
459 record_value = getattr(browse_record, col_name)
461 if record_value == initial_value and getattr(self._all_columns[col_name].column, 'track_visibility', None) == 'always':
462 tracked_values[col_name] = dict(col_info=col_info['string'],
463 new_value=convert_for_display(record_value, col_info))
464 elif record_value != initial_value and (record_value or initial_value): # because browse null != False
465 if getattr(self._all_columns[col_name].column, 'track_visibility', None) in ['always', 'onchange']:
466 tracked_values[col_name] = dict(col_info=col_info['string'],
467 old_value=convert_for_display(initial_value, col_info),
468 new_value=convert_for_display(record_value, col_info))
469 if col_name in tracked_fields:
470 changes.add(col_name)
474 # find subtypes and post messages or log if no subtype found
476 for field, track_info in self._track.items():
477 if field not in changes:
479 for subtype, method in track_info.items():
480 if method(self, cr, uid, browse_record, context):
481 subtypes.append(subtype)
484 for subtype in subtypes:
486 subtype_rec = self.pool.get('ir.model.data').get_object(cr, uid, subtype.split('.')[0], subtype.split('.')[1], context=context)
487 except ValueError, e:
488 _logger.debug('subtype %s not found, giving error "%s"' % (subtype, e))
490 message = format_message(subtype_rec.description if subtype_rec.description else subtype_rec.name, tracked_values)
491 self.message_post(cr, uid, browse_record.id, body=message, subtype=subtype, context=context)
494 message = format_message('', tracked_values)
495 self.message_post(cr, uid, browse_record.id, body=message, context=context)
498 #------------------------------------------------------
499 # mail.message wrappers and tools
500 #------------------------------------------------------
502 def _needaction_domain_get(self, cr, uid, context=None):
504 return [('message_unread', '=', True)]
507 def _garbage_collect_attachments(self, cr, uid, context=None):
508 """ Garbage collect lost mail attachments. Those are attachments
509 - linked to res_model 'mail.compose.message', the composer wizard
510 - with res_id 0, because they were created outside of an existing
511 wizard (typically user input through Chatter or reports
512 created on-the-fly by the templates)
513 - unused since at least one day (create_date and write_date)
515 limit_date = datetime.datetime.utcnow() - datetime.timedelta(days=1)
516 limit_date_str = datetime.datetime.strftime(limit_date, tools.DEFAULT_SERVER_DATETIME_FORMAT)
517 ir_attachment_obj = self.pool.get('ir.attachment')
518 attach_ids = ir_attachment_obj.search(cr, uid, [
519 ('res_model', '=', 'mail.compose.message'),
521 ('create_date', '<', limit_date_str),
522 ('write_date', '<', limit_date_str),
524 ir_attachment_obj.unlink(cr, uid, attach_ids, context=context)
527 def check_mail_message_access(self, cr, uid, mids, operation, model_obj=None, context=None):
528 """ mail.message check permission rules for related document. This method is
529 meant to be inherited in order to implement addons-specific behavior.
530 A common behavior would be to allow creating messages when having read
531 access rule on the document, for portal document such as issues. """
534 if hasattr(self, '_mail_post_access'):
535 create_allow = self._mail_post_access
537 create_allow = 'write'
539 if operation in ['write', 'unlink']:
540 check_operation = 'write'
541 elif operation == 'create' and create_allow in ['create', 'read', 'write', 'unlink']:
542 check_operation = create_allow
543 elif operation == 'create':
544 check_operation = 'write'
546 check_operation = operation
548 model_obj.check_access_rights(cr, uid, check_operation)
549 model_obj.check_access_rule(cr, uid, mids, check_operation, context=context)
551 def _get_formview_action(self, cr, uid, id, model=None, context=None):
552 """ Return an action to open the document. This method is meant to be
553 overridden in addons that want to give specific view ids for example.
555 :param int id: id of the document to open
556 :param string model: specific model that overrides self._name
559 'type': 'ir.actions.act_window',
560 'res_model': model or self._name,
563 'views': [(False, 'form')],
568 def _get_inbox_action_xml_id(self, cr, uid, context=None):
569 """ When redirecting towards the Inbox, choose which action xml_id has
570 to be fetched. This method is meant to be inherited, at least in portal
571 because portal users have a different Inbox action than classic users. """
572 return ('mail', 'action_mail_inbox_feeds')
574 def message_redirect_action(self, cr, uid, context=None):
575 """ For a given message, return an action that either
576 - opens the form view of the related document if model, res_id, and
577 read access to the document
578 - opens the Inbox with a default search on the conversation if model,
580 - opens the Inbox with context propagated
586 # default action is the Inbox action
587 self.pool.get('res.users').browse(cr, SUPERUSER_ID, uid, context=context)
588 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))
589 action = self.pool.get(act_model).read(cr, uid, act_id, [])
590 params = context.get('params')
591 msg_id = model = res_id = None
594 msg_id = params.get('message_id')
595 model = params.get('model')
596 res_id = params.get('res_id')
597 if not msg_id and not (model and res_id):
599 if msg_id and not (model and res_id):
600 msg = self.pool.get('mail.message').browse(cr, uid, msg_id, context=context)
602 model, res_id = msg.model, msg.res_id
604 # if model + res_id found: try to redirect to the document or fallback on the Inbox
606 model_obj = self.pool.get(model)
607 if model_obj.check_access_rights(cr, uid, 'read', raise_exception=False):
609 model_obj.check_access_rule(cr, uid, [res_id], 'read', context=context)
610 if not hasattr(model_obj, '_get_formview_action'):
611 action = self.pool.get('mail.thread')._get_formview_action(cr, uid, res_id, model=model, context=context)
613 action = model_obj._get_formview_action(cr, uid, res_id, context=context)
614 except (osv.except_osv, orm.except_orm):
618 'search_default_model': model,
619 'search_default_res_id': res_id,
624 #------------------------------------------------------
626 #------------------------------------------------------
628 def message_get_reply_to(self, cr, uid, ids, context=None):
629 """ Returns the preferred reply-to email address that is basically
630 the alias of the document, if it exists. """
631 if not self._inherits.get('mail.alias'):
632 return [False for id in ids]
633 return ["%s@%s" % (record['alias_name'], record['alias_domain'])
634 if record.get('alias_domain') and record.get('alias_name')
636 for record in self.read(cr, SUPERUSER_ID, ids, ['alias_name', 'alias_domain'], context=context)]
638 #------------------------------------------------------
640 #------------------------------------------------------
642 def message_capable_models(self, cr, uid, context=None):
643 """ Used by the plugin addon, based for plugin_outlook and others. """
645 for model_name in self.pool.obj_list():
646 model = self.pool[model_name]
647 if hasattr(model, "message_process") and hasattr(model, "message_post"):
648 ret_dict[model_name] = model._description
651 def _message_find_partners(self, cr, uid, message, header_fields=['From'], context=None):
652 """ Find partners related to some header fields of the message.
654 :param string message: an email.message instance """
655 s = ', '.join([decode(message.get(h)) for h in header_fields if message.get(h)])
656 return filter(lambda x: x, self._find_partner_from_emails(cr, uid, None, tools.email_split(s), context=context))
658 def message_route_verify(self, cr, uid, message, message_dict, route, update_author=True, assert_model=True, create_fallback=True, context=None):
659 """ Verify route validity. Check and rules:
660 1 - if thread_id -> check that document effectively exists; otherwise
661 fallback on a message_new by resetting thread_id
662 2 - check that message_update exists if thread_id is set; or at least
663 that message_new exist
664 [ - find author_id if udpate_author is set]
665 3 - if there is an alias, check alias_contact:
666 'followers' and thread_id:
667 check on target document that the author is in the followers
668 'followers' and alias_parent_thread_id:
669 check on alias parent document that the author is in the
671 'partners': check that author_id id set
674 assert isinstance(route, (list, tuple)), 'A route should be a list or a tuple'
675 assert len(route) == 5, 'A route should contain 5 elements: model, thread_id, custom_values, uid, alias record'
677 message_id = message.get('Message-Id')
678 email_from = decode_header(message, 'From')
679 author_id = message_dict.get('author_id')
680 model, thread_id, alias = route[0], route[1], route[4]
683 def _create_bounce_email():
684 mail_mail = self.pool.get('mail.mail')
685 mail_id = mail_mail.create(cr, uid, {
686 'body_html': '<div><p>Hello,</p>'
687 '<p>The following email sent to %s cannot be accepted because this is '
688 'a private email address. Only allowed people can contact us at this address.</p></div>'
689 '<blockquote>%s</blockquote>' % (message.get('to'), message_dict.get('body')),
690 'subject': 'Re: %s' % message.get('subject'),
691 'email_to': message.get('from'),
694 mail_mail.send(cr, uid, [mail_id], context=context)
697 _logger.warning('Routing mail with Message-Id %s: route %s: %s',
698 message_id, route, message)
701 if model and not model in self.pool:
703 assert model in self.pool, 'Routing: unknown target model %s' % model
704 _warn('unknown target model %s' % model)
707 model_pool = self.pool[model]
709 # Private message: should not contain any thread_id
710 if not model and thread_id:
713 raise ValueError('Routing: posting a message without model should be with a null res_id (private message).')
714 _warn('posting a message without model should be with a null res_id (private message), resetting thread_id')
716 # Private message: should have a parent_id (only answers)
717 if not model and not message_dict.get('parent_id'):
719 if not message_dict.get('parent_id'):
720 raise ValueError('Routing: posting a message without model should be with a parent_id (private mesage).')
721 _warn('posting a message without model should be with a parent_id (private mesage), skipping')
724 # Existing Document: check if exists; if not, fallback on create if allowed
725 if thread_id and not model_pool.exists(cr, uid, thread_id):
727 _warn('reply to missing document (%s,%s), fall back on new document creation' % (model, thread_id))
730 assert model_pool.exists(cr, uid, thread_id), 'Routing: reply to missing document (%s,%s)' % (model, thread_id)
732 _warn('reply to missing document (%s,%s), skipping' % (model, thread_id))
735 # Existing Document: check model accepts the mailgateway
736 if thread_id and model and not hasattr(model_pool, 'message_update'):
738 _warn('model %s does not accept document update, fall back on document creation' % model)
741 assert hasattr(model_pool, 'message_update'), 'Routing: model %s does not accept document update, crashing' % model
743 _warn('model %s does not accept document update, skipping' % model)
746 # New Document: check model accepts the mailgateway
747 if not thread_id and model and not hasattr(model_pool, 'message_new'):
749 if not hasattr(model_pool, 'message_new'):
751 'Model %s does not accept document creation, crashing' % model
753 _warn('model %s does not accept document creation, skipping' % model)
756 # Update message author if asked
757 # We do it now because we need it for aliases (contact settings)
758 if not author_id and update_author:
759 author_ids = self._find_partner_from_emails(cr, uid, thread_id, [email_from], model=model, context=context)
761 author_id = author_ids[0]
762 message_dict['author_id'] = author_id
764 # Alias: check alias_contact settings
765 if alias and alias.alias_contact == 'followers' and (thread_id or alias.alias_parent_thread_id):
767 obj = self.pool[model].browse(cr, uid, thread_id, context=context)
769 obj = self.pool[alias.alias_parent_model_id.model].browse(cr, uid, alias.alias_parent_thread_id, context=context)
770 if not author_id or not author_id in [fol.id for fol in obj.message_follower_ids]:
771 _warn('alias %s restricted to internal followers, skipping' % alias.alias_name)
772 _create_bounce_email()
774 elif alias and alias.alias_contact == 'partners' and not author_id:
775 _warn('alias %s does not accept unknown author, skipping' % alias.alias_name)
776 _create_bounce_email()
779 return (model, thread_id, route[2], route[3], route[4])
781 def message_route(self, cr, uid, message, message_dict, model=None, thread_id=None,
782 custom_values=None, context=None):
783 """Attempt to figure out the correct target model, thread_id,
784 custom_values and user_id to use for an incoming message.
785 Multiple values may be returned, if a message had multiple
786 recipients matching existing mail.aliases, for example.
788 The following heuristics are used, in this order:
789 1. If the message replies to an existing thread_id, and
790 properly contains the thread model in the 'In-Reply-To'
791 header, use this model/thread_id pair, and ignore
792 custom_value (not needed as no creation will take place)
793 2. Look for a mail.alias entry matching the message
794 recipient, and use the corresponding model, thread_id,
795 custom_values and user_id.
796 3. Fallback to the ``model``, ``thread_id`` and ``custom_values``
798 4. If all the above fails, raise an exception.
800 :param string message: an email.message instance
801 :param dict message_dict: dictionary holding message variables
802 :param string model: the fallback model to use if the message
803 does not match any of the currently configured mail aliases
804 (may be None if a matching alias is supposed to be present)
805 :type dict custom_values: optional dictionary of default field values
806 to pass to ``message_new`` if a new record needs to be created.
807 Ignored if the thread record already exists, and also if a
808 matching mail.alias was found (aliases define their own defaults)
809 :param int thread_id: optional ID of the record/thread from ``model``
810 to which this mail should be attached. Only used if the message
811 does not reply to an existing thread and does not match any mail alias.
812 :return: list of [model, thread_id, custom_values, user_id, alias]
814 :raises: ValueError, TypeError
816 if not isinstance(message, Message):
817 raise TypeError('message must be an email.message.Message at this point')
818 fallback_model = model
820 # Get email.message.Message variables for future processing
821 message_id = message.get('Message-Id')
822 email_from = decode_header(message, 'From')
823 email_to = decode_header(message, 'To')
824 references = decode_header(message, 'References')
825 in_reply_to = decode_header(message, 'In-Reply-To')
827 # 1. Verify if this is a reply to an existing thread
828 thread_references = references or in_reply_to
829 ref_match = thread_references and tools.reference_re.search(thread_references)
831 thread_id = int(ref_match.group(1))
832 model = ref_match.group(2) or fallback_model
833 if thread_id and model in self.pool:
834 model_obj = self.pool[model]
835 if model_obj.exists(cr, uid, thread_id) and hasattr(model_obj, 'message_update'):
836 _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',
837 email_from, email_to, message_id, model, thread_id, custom_values, uid)
838 route = self.message_route_verify(cr, uid, message, message_dict,
839 (model, thread_id, custom_values, uid, None),
840 update_author=True, assert_model=True, create_fallback=True, context=context)
841 return route and [route] or []
843 # 2. Reply to a private message
845 mail_message_ids = self.pool.get('mail.message').search(cr, uid, [
846 ('message_id', '=', in_reply_to),
847 '!', ('message_id', 'ilike', 'reply_to')
848 ], limit=1, context=context)
850 mail_message = self.pool.get('mail.message').browse(cr, uid, mail_message_ids[0], context=context)
851 _logger.info('Routing mail from %s to %s with Message-Id %s: direct reply to a private message: %s, custom_values: %s, uid: %s',
852 email_from, email_to, message_id, mail_message.id, custom_values, uid)
853 route = self.message_route_verify(cr, uid, message, message_dict,
854 (mail_message.model, mail_message.res_id, custom_values, uid, None),
855 update_author=True, assert_model=True, create_fallback=True, context=context)
856 return route and [route] or []
858 # 3. Look for a matching mail.alias entry
859 # Delivered-To is a safe bet in most modern MTAs, but we have to fallback on To + Cc values
860 # for all the odd MTAs out there, as there is no standard header for the envelope's `rcpt_to` value.
862 ','.join([decode_header(message, 'Delivered-To'),
863 decode_header(message, 'To'),
864 decode_header(message, 'Cc'),
865 decode_header(message, 'Resent-To'),
866 decode_header(message, 'Resent-Cc')])
867 local_parts = [e.split('@')[0] for e in tools.email_split(rcpt_tos)]
869 mail_alias = self.pool.get('mail.alias')
870 alias_ids = mail_alias.search(cr, uid, [('alias_name', 'in', local_parts)])
873 for alias in mail_alias.browse(cr, uid, alias_ids, context=context):
874 user_id = alias.alias_user_id.id
876 # TDE note: this could cause crashes, because no clue that the user
877 # that send the email has the right to create or modify a new document
878 # Fallback on user_id = uid
879 # Note: recognized partners will be added as followers anyway
880 # user_id = self._message_find_user_id(cr, uid, message, context=context)
882 _logger.info('No matching user_id for the alias %s', alias.alias_name)
883 route = (alias.alias_model_id.model, alias.alias_force_thread_id, eval(alias.alias_defaults), user_id, alias)
884 _logger.info('Routing mail from %s to %s with Message-Id %s: direct alias match: %r',
885 email_from, email_to, message_id, route)
886 route = self.message_route_verify(cr, uid, message, message_dict, route,
887 update_author=True, assert_model=True, create_fallback=True, context=context)
892 # 4. Fallback to the provided parameters, if they work
894 # Legacy: fallback to matching [ID] in the Subject
895 match = tools.res_re.search(decode_header(message, 'Subject'))
896 thread_id = match and match.group(1)
897 # Convert into int (bug spotted in 7.0 because of str)
899 thread_id = int(thread_id)
902 _logger.info('Routing mail from %s to %s with Message-Id %s: fallback to model:%s, thread_id:%s, custom_values:%s, uid:%s',
903 email_from, email_to, message_id, fallback_model, thread_id, custom_values, uid)
904 route = self.message_route_verify(cr, uid, message, message_dict,
905 (fallback_model, thread_id, custom_values, uid, None),
906 update_author=True, assert_model=True, context=context)
910 # AssertionError if no routes found and if no bounce occured
912 'No possible route found for incoming message from %s to %s (Message-Id %s:). '
913 'Create an appropriate mail.alias or force the destination model.' %
914 (email_from, email_to, message_id)
917 def message_route_process(self, cr, uid, message, message_dict, routes, context=None):
918 # postpone setting message_dict.partner_ids after message_post, to avoid double notifications
919 partner_ids = message_dict.pop('partner_ids', [])
921 for model, thread_id, custom_values, user_id, alias in routes:
922 if self._name == 'mail.thread':
923 context.update({'thread_model': model})
925 model_pool = self.pool[model]
926 if not (thread_id and hasattr(model_pool, 'message_update') or hasattr(model_pool, 'message_new')):
928 "Undeliverable mail with Message-Id %s, model %s does not accept incoming emails" %
929 (message_dict['message_id'], model)
932 # disabled subscriptions during message_new/update to avoid having the system user running the
933 # email gateway become a follower of all inbound messages
934 nosub_ctx = dict(context, mail_create_nosubscribe=True, mail_create_nolog=True)
935 if thread_id and hasattr(model_pool, 'message_update'):
936 model_pool.message_update(cr, user_id, [thread_id], message_dict, context=nosub_ctx)
938 thread_id = model_pool.message_new(cr, user_id, message_dict, custom_values, context=nosub_ctx)
941 raise ValueError("Posting a message without model should be with a null res_id, to create a private message.")
942 model_pool = self.pool.get('mail.thread')
943 if not hasattr(model_pool, 'message_post'):
944 context['thread_model'] = model
945 model_pool = self.pool['mail.thread']
946 new_msg_id = model_pool.message_post(cr, uid, [thread_id], context=context, subtype='mail.mt_comment', **message_dict)
949 # postponed after message_post, because this is an external message and we don't want to create
950 # duplicate emails due to notifications
951 self.pool.get('mail.message').write(cr, uid, [new_msg_id], {'partner_ids': partner_ids}, context=context)
954 def message_process(self, cr, uid, model, message, custom_values=None,
955 save_original=False, strip_attachments=False,
956 thread_id=None, context=None):
957 """ Process an incoming RFC2822 email message, relying on
958 ``mail.message.parse()`` for the parsing operation,
959 and ``message_route()`` to figure out the target model.
961 Once the target model is known, its ``message_new`` method
962 is called with the new message (if the thread record did not exist)
963 or its ``message_update`` method (if it did).
965 There is a special case where the target model is False: a reply
966 to a private message. In this case, we skip the message_new /
967 message_update step, to just post a new message using mail_thread
970 :param string model: the fallback model to use if the message
971 does not match any of the currently configured mail aliases
972 (may be None if a matching alias is supposed to be present)
973 :param message: source of the RFC2822 message
974 :type message: string or xmlrpclib.Binary
975 :type dict custom_values: optional dictionary of field values
976 to pass to ``message_new`` if a new record needs to be created.
977 Ignored if the thread record already exists, and also if a
978 matching mail.alias was found (aliases define their own defaults)
979 :param bool save_original: whether to keep a copy of the original
980 email source attached to the message after it is imported.
981 :param bool strip_attachments: whether to strip all attachments
982 before processing the message, in order to save some space.
983 :param int thread_id: optional ID of the record/thread from ``model``
984 to which this mail should be attached. When provided, this
985 overrides the automatic detection based on the message
991 # extract message bytes - we are forced to pass the message as binary because
992 # we don't know its encoding until we parse its headers and hence can't
993 # convert it to utf-8 for transport between the mailgate script and here.
994 if isinstance(message, xmlrpclib.Binary):
995 message = str(message.data)
996 # Warning: message_from_string doesn't always work correctly on unicode,
997 # we must use utf-8 strings here :-(
998 if isinstance(message, unicode):
999 message = message.encode('utf-8')
1000 msg_txt = email.message_from_string(message)
1002 # parse the message, verify we are not in a loop by checking message_id is not duplicated
1003 msg = self.message_parse(cr, uid, msg_txt, save_original=save_original, context=context)
1004 if strip_attachments:
1005 msg.pop('attachments', None)
1007 if msg.get('message_id'): # should always be True as message_parse generate one if missing
1008 existing_msg_ids = self.pool.get('mail.message').search(cr, SUPERUSER_ID, [
1009 ('message_id', '=', msg.get('message_id')),
1011 if existing_msg_ids:
1012 _logger.info('Ignored mail from %s to %s with Message-Id %s: found duplicated Message-Id during processing',
1013 msg.get('from'), msg.get('to'), msg.get('message_id'))
1016 # find possible routes for the message
1017 routes = self.message_route(cr, uid, msg_txt, msg, model, thread_id, custom_values, context=context)
1018 thread_id = self.message_route_process(cr, uid, msg_txt, msg, routes, context=context)
1021 def message_new(self, cr, uid, msg_dict, custom_values=None, context=None):
1022 """Called by ``message_process`` when a new message is received
1023 for a given thread model, if the message did not belong to
1025 The default behavior is to create a new record of the corresponding
1026 model (based on some very basic info extracted from the message).
1027 Additional behavior may be implemented by overriding this method.
1029 :param dict msg_dict: a map containing the email details and
1030 attachments. See ``message_process`` and
1031 ``mail.message.parse`` for details.
1032 :param dict custom_values: optional dictionary of additional
1033 field values to pass to create()
1034 when creating the new thread record.
1035 Be careful, these values may override
1036 any other values coming from the message.
1037 :param dict context: if a ``thread_model`` value is present
1038 in the context, its value will be used
1039 to determine the model of the record
1040 to create (instead of the current model).
1042 :return: the id of the newly created thread object
1047 if isinstance(custom_values, dict):
1048 data = custom_values.copy()
1049 model = context.get('thread_model') or self._name
1050 model_pool = self.pool[model]
1051 fields = model_pool.fields_get(cr, uid, context=context)
1052 if 'name' in fields and not data.get('name'):
1053 data['name'] = msg_dict.get('subject', '')
1054 res_id = model_pool.create(cr, uid, data, context=context)
1057 def message_update(self, cr, uid, ids, msg_dict, update_vals=None, context=None):
1058 """Called by ``message_process`` when a new message is received
1059 for an existing thread. The default behavior is to update the record
1060 with update_vals taken from the incoming email.
1061 Additional behavior may be implemented by overriding this
1063 :param dict msg_dict: a map containing the email details and
1064 attachments. See ``message_process`` and
1065 ``mail.message.parse()`` for details.
1066 :param dict update_vals: a dict containing values to update records
1067 given their ids; if the dict is None or is
1068 void, no write operation is performed.
1071 self.write(cr, uid, ids, update_vals, context=context)
1074 def _message_extract_payload(self, message, save_original=False):
1075 """Extract body as HTML and attachments from the mail message"""
1079 attachments.append(('original_email.eml', message.as_string()))
1080 if not message.is_multipart() or 'text/' in message.get('content-type', ''):
1081 encoding = message.get_content_charset()
1082 body = message.get_payload(decode=True)
1083 body = tools.ustr(body, encoding, errors='replace')
1084 if message.get_content_type() == 'text/plain':
1085 # text/plain -> <pre/>
1086 body = tools.append_content_to_html(u'', body, preserve=True)
1089 for part in message.walk():
1090 if part.get_content_type() == 'multipart/alternative':
1092 if part.get_content_maintype() == 'multipart':
1093 continue # skip container
1094 # part.get_filename returns decoded value if able to decode, coded otherwise.
1095 # original get_filename is not able to decode iso-8859-1 (for instance).
1096 # therefore, iso encoded attachements are not able to be decoded properly with get_filename
1097 # code here partially copy the original get_filename method, but handle more encoding
1098 filename=part.get_param('filename', None, 'content-disposition')
1100 filename=part.get_param('name', None)
1102 if isinstance(filename, tuple):
1104 filename=email.utils.collapse_rfc2231_value(filename).strip()
1106 filename=decode(filename)
1107 encoding = part.get_content_charset() # None if attachment
1108 # 1) Explicit Attachments -> attachments
1109 if filename or part.get('content-disposition', '').strip().startswith('attachment'):
1110 attachments.append((filename or 'attachment', part.get_payload(decode=True)))
1112 # 2) text/plain -> <pre/>
1113 if part.get_content_type() == 'text/plain' and (not alternative or not body):
1114 body = tools.append_content_to_html(body, tools.ustr(part.get_payload(decode=True),
1115 encoding, errors='replace'), preserve=True)
1116 # 3) text/html -> raw
1117 elif part.get_content_type() == 'text/html':
1118 html = tools.ustr(part.get_payload(decode=True), encoding, errors='replace')
1122 body = tools.append_content_to_html(body, html, plaintext=False)
1123 # 4) Anything else -> attachment
1125 attachments.append((filename or 'attachment', part.get_payload(decode=True)))
1126 return body, attachments
1128 def message_parse(self, cr, uid, message, save_original=False, context=None):
1129 """Parses a string or email.message.Message representing an
1130 RFC-2822 email, and returns a generic dict holding the
1133 :param message: the message to parse
1134 :type message: email.message.Message | string | unicode
1135 :param bool save_original: whether the returned dict
1136 should include an ``original`` attachment containing
1137 the source of the message
1139 :return: A dict with the following structure, where each
1140 field may not be present if missing in original
1143 { 'message_id': msg_id,
1148 'body': unified_body,
1149 'attachments': [('file1', 'bytes'),
1156 if not isinstance(message, Message):
1157 if isinstance(message, unicode):
1158 # Warning: message_from_string doesn't always work correctly on unicode,
1159 # we must use utf-8 strings here :-(
1160 message = message.encode('utf-8')
1161 message = email.message_from_string(message)
1163 message_id = message['message-id']
1165 # Very unusual situation, be we should be fault-tolerant here
1166 message_id = "<%s@localhost>" % time.time()
1167 _logger.debug('Parsing Message without message-id, generating a random one: %s', message_id)
1168 msg_dict['message_id'] = message_id
1170 if message.get('Subject'):
1171 msg_dict['subject'] = decode(message.get('Subject'))
1173 # Envelope fields not stored in mail.message but made available for message_new()
1174 msg_dict['from'] = decode(message.get('from'))
1175 msg_dict['to'] = decode(message.get('to'))
1176 msg_dict['cc'] = decode(message.get('cc'))
1177 msg_dict['email_from'] = decode(message.get('from'))
1178 partner_ids = self._message_find_partners(cr, uid, message, ['To', 'Cc'], context=context)
1179 msg_dict['partner_ids'] = [(4, partner_id) for partner_id in partner_ids]
1181 if message.get('Date'):
1183 date_hdr = decode(message.get('Date'))
1184 parsed_date = dateutil.parser.parse(date_hdr, fuzzy=True)
1185 if parsed_date.utcoffset() is None:
1186 # naive datetime, so we arbitrarily decide to make it
1187 # UTC, there's no better choice. Should not happen,
1188 # as RFC2822 requires timezone offset in Date headers.
1189 stored_date = parsed_date.replace(tzinfo=pytz.utc)
1191 stored_date = parsed_date.astimezone(tz=pytz.utc)
1193 _logger.warning('Failed to parse Date header %r in incoming mail '
1194 'with message-id %r, assuming current date/time.',
1195 message.get('Date'), message_id)
1196 stored_date = datetime.datetime.now()
1197 msg_dict['date'] = stored_date.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT)
1199 if message.get('In-Reply-To'):
1200 parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', '=', decode(message['In-Reply-To']))])
1202 msg_dict['parent_id'] = parent_ids[0]
1204 if message.get('References') and 'parent_id' not in msg_dict:
1205 parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', 'in',
1206 [x.strip() for x in decode(message['References']).split()])])
1208 msg_dict['parent_id'] = parent_ids[0]
1210 msg_dict['body'], msg_dict['attachments'] = self._message_extract_payload(message, save_original=save_original)
1213 #------------------------------------------------------
1215 #------------------------------------------------------
1217 def log(self, cr, uid, id, message, secondary=False, context=None):
1218 _logger.warning("log() is deprecated. As this module inherit from "\
1219 "mail.thread, the message will be managed by this "\
1220 "module instead of by the res.log mechanism. Please "\
1221 "use mail_thread.message_post() instead of the "\
1222 "now deprecated res.log.")
1223 self.message_post(cr, uid, [id], message, context=context)
1225 def _message_add_suggested_recipient(self, cr, uid, result, obj, partner=None, email=None, reason='', context=None):
1226 """ Called by message_get_suggested_recipients, to add a suggested
1227 recipient in the result dictionary. The form is :
1228 partner_id, partner_name<partner_email> or partner_name, reason """
1229 if email and not partner:
1230 # get partner info from email
1231 partner_info = self.message_partner_info_from_emails(cr, uid, obj.id, [email], context=context)[0]
1232 if partner_info.get('partner_id'):
1233 partner = self.pool.get('res.partner').browse(cr, SUPERUSER_ID, [partner_info['partner_id']], context=context)[0]
1234 if email and email in [val[1] for val in result[obj.id]]: # already existing email -> skip
1236 if partner and partner in obj.message_follower_ids: # recipient already in the followers -> skip
1238 if partner and partner in [val[0] for val in result[obj.id]]: # already existing partner ID -> skip
1240 if partner and partner.email: # complete profile: id, name <email>
1241 result[obj.id].append((partner.id, '%s<%s>' % (partner.name, partner.email), reason))
1242 elif partner: # incomplete profile: id, name
1243 result[obj.id].append((partner.id, '%s' % (partner.name), reason))
1244 else: # unknown partner, we are probably managing an email address
1245 result[obj.id].append((False, email, reason))
1248 def message_get_suggested_recipients(self, cr, uid, ids, context=None):
1249 """ Returns suggested recipients for ids. Those are a list of
1250 tuple (partner_id, partner_name, reason), to be managed by Chatter. """
1251 result = dict.fromkeys(ids, list())
1252 if self._all_columns.get('user_id'):
1253 for obj in self.browse(cr, SUPERUSER_ID, ids, context=context): # SUPERUSER because of a read on res.users that would crash otherwise
1254 if not obj.user_id or not obj.user_id.partner_id:
1256 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)
1259 def _find_partner_from_emails(self, cr, uid, id, emails, model=None, context=None, check_followers=True):
1260 """ Utility method to find partners from email addresses. The rules are :
1261 1 - check in document (model | self, id) followers
1262 2 - try to find a matching partner that is also an user
1263 3 - try to find a matching partner
1265 :param list emails: list of email addresses
1266 :param string model: model to fetch related record; by default self
1268 :param boolean check_followers: check in document followers
1270 partner_obj = self.pool['res.partner']
1273 if id and (model or self._name != 'mail.thread') and check_followers:
1275 obj = self.pool[model].browse(cr, uid, id, context=context)
1277 obj = self.browse(cr, uid, id, context=context)
1278 for contact in emails:
1280 email_address = tools.email_split(contact)
1281 if not email_address:
1282 partner_ids.append(partner_id)
1284 email_address = email_address[0]
1285 # first try: check in document's followers
1287 for follower in obj.message_follower_ids:
1288 if follower.email == email_address:
1289 partner_id = follower.id
1290 # second try: check in partners that are also users
1292 ids = partner_obj.search(cr, SUPERUSER_ID, [
1293 ('email', 'ilike', email_address),
1294 ('user_ids', '!=', False)
1295 ], limit=1, context=context)
1298 # third try: check in partners
1300 ids = partner_obj.search(cr, SUPERUSER_ID, [
1301 ('email', 'ilike', email_address)
1302 ], limit=1, context=context)
1305 partner_ids.append(partner_id)
1308 def message_partner_info_from_emails(self, cr, uid, id, emails, link_mail=False, context=None):
1309 """ Convert a list of emails into a list partner_ids and a list
1310 new_partner_ids. The return value is non conventional because
1311 it is meant to be used by the mail widget.
1313 :return dict: partner_ids and new_partner_ids """
1314 mail_message_obj = self.pool.get('mail.message')
1315 partner_ids = self._find_partner_from_emails(cr, uid, id, emails, context=context)
1317 for idx in range(len(emails)):
1318 email_address = emails[idx]
1319 partner_id = partner_ids[idx]
1320 partner_info = {'full_name': email_address, 'partner_id': partner_id}
1321 result.append(partner_info)
1323 # link mail with this from mail to the new partner id
1324 if link_mail and partner_info['partner_id']:
1325 message_ids = mail_message_obj.search(cr, SUPERUSER_ID, [
1327 ('email_from', '=', email_address),
1328 ('email_from', 'ilike', '<%s>' % email_address),
1329 ('author_id', '=', False)
1332 mail_message_obj.write(cr, SUPERUSER_ID, message_ids, {'author_id': partner_info['partner_id']}, context=context)
1335 def _message_preprocess_attachments(self, cr, uid, attachments, attachment_ids, attach_model, attach_res_id, context=None):
1336 """ Preprocess attachments for mail_thread.message_post() or mail_mail.create().
1338 :param list attachments: list of attachment tuples in the form ``(name,content)``,
1339 where content is NOT base64 encoded
1340 :param list attachment_ids: a list of attachment ids, not in tomany command form
1341 :param str attach_model: the model of the attachments parent record
1342 :param integer attach_res_id: the id of the attachments parent record
1344 Attachment = self.pool['ir.attachment']
1345 m2m_attachment_ids = []
1347 filtered_attachment_ids = Attachment.search(cr, SUPERUSER_ID, [
1348 ('res_model', '=', 'mail.compose.message'),
1349 ('create_uid', '=', uid),
1350 ('id', 'in', attachment_ids)], context=context)
1351 if filtered_attachment_ids:
1352 Attachment.write(cr, SUPERUSER_ID, filtered_attachment_ids, {'res_model': attach_model, 'res_id': attach_res_id}, context=context)
1353 m2m_attachment_ids += [(4, id) for id in attachment_ids]
1354 # Handle attachments parameter, that is a dictionary of attachments
1355 for name, content in attachments:
1356 if isinstance(content, unicode):
1357 content = content.encode('utf-8')
1360 'datas': base64.b64encode(str(content)),
1361 'datas_fname': name,
1362 'description': name,
1363 'res_model': attach_model,
1364 'res_id': attach_res_id,
1366 m2m_attachment_ids.append((0, 0, data_attach))
1367 return m2m_attachment_ids
1369 def message_post(self, cr, uid, thread_id, body='', subject=None, type='notification',
1370 subtype=None, parent_id=False, attachments=None, context=None,
1371 content_subtype='html', **kwargs):
1372 """ Post a new message in an existing thread, returning the new
1375 :param int thread_id: thread ID to post into, or list with one ID;
1376 if False/0, mail.message model will also be set as False
1377 :param str body: body of the message, usually raw HTML that will
1379 :param str type: see mail_message.type field
1380 :param str content_subtype:: if plaintext: convert body into html
1381 :param int parent_id: handle reply to a previous message by adding the
1382 parent partners to the message in case of private discussion
1383 :param tuple(str,str) attachments or list id: list of attachment tuples in the form
1384 ``(name,content)``, where content is NOT base64 encoded
1386 Extra keyword arguments will be used as default column values for the
1387 new mail.message record. Special cases:
1388 - attachment_ids: supposed not attached to any document; attach them
1389 to the related document. Should only be set by Chatter.
1390 :return int: ID of newly created mail.message
1394 if attachments is None:
1396 mail_message = self.pool.get('mail.message')
1397 ir_attachment = self.pool.get('ir.attachment')
1399 assert (not thread_id) or \
1400 isinstance(thread_id, (int, long)) or \
1401 (isinstance(thread_id, (list, tuple)) and len(thread_id) == 1), \
1402 "Invalid thread_id; should be 0, False, an ID or a list with one ID"
1403 if isinstance(thread_id, (list, tuple)):
1404 thread_id = thread_id[0]
1406 # if we're processing a message directly coming from the gateway, the destination model was
1407 # set in the context.
1410 model = context.get('thread_model', self._name) if self._name == 'mail.thread' else self._name
1411 if model != self._name and hasattr(self.pool[model], 'message_post'):
1412 del context['thread_model']
1413 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)
1415 #0: Find the message's author, because we need it for private discussion
1416 author_id = kwargs.get('author_id')
1417 if author_id is None: # keep False values
1418 author_id = self.pool.get('mail.message')._get_default_author(cr, uid, context=context)
1420 # 1: Handle content subtype: if plaintext, converto into HTML
1421 if content_subtype == 'plaintext':
1422 body = tools.plaintext2html(body)
1424 # 2: Private message: add recipients (recipients and author of parent message) - current author
1425 # + legacy-code management (! we manage only 4 and 6 commands)
1427 kwargs_partner_ids = kwargs.pop('partner_ids', [])
1428 for partner_id in kwargs_partner_ids:
1429 if isinstance(partner_id, (list, tuple)) and partner_id[0] == 4 and len(partner_id) == 2:
1430 partner_ids.add(partner_id[1])
1431 if isinstance(partner_id, (list, tuple)) and partner_id[0] == 6 and len(partner_id) == 3:
1432 partner_ids |= set(partner_id[2])
1433 elif isinstance(partner_id, (int, long)):
1434 partner_ids.add(partner_id)
1436 pass # we do not manage anything else
1437 if parent_id and not model:
1438 parent_message = mail_message.browse(cr, uid, parent_id, context=context)
1439 private_followers = set([partner.id for partner in parent_message.partner_ids])
1440 if parent_message.author_id:
1441 private_followers.add(parent_message.author_id.id)
1442 private_followers -= set([author_id])
1443 partner_ids |= private_followers
1446 # - HACK TDE FIXME: Chatter: attachments linked to the document (not done JS-side), load the message
1447 attachment_ids = self._message_preprocess_attachments(cr, uid, attachments, kwargs.pop('attachment_ids', []), model, thread_id, context)
1449 # 4: mail.message.subtype
1452 if '.' not in subtype:
1453 subtype = 'mail.%s' % subtype
1454 ref = self.pool.get('ir.model.data').get_object_reference(cr, uid, *subtype.split('.'))
1455 subtype_id = ref and ref[1] or False
1457 # automatically subscribe recipients if asked to
1458 if context.get('mail_post_autofollow') and thread_id and partner_ids:
1459 partner_to_subscribe = partner_ids
1460 if context.get('mail_post_autofollow_partner_ids'):
1461 partner_to_subscribe = filter(lambda item: item in context.get('mail_post_autofollow_partner_ids'), partner_ids)
1462 self.message_subscribe(cr, uid, [thread_id], list(partner_to_subscribe), context=context)
1464 # _mail_flat_thread: automatically set free messages to the first posted message
1465 if self._mail_flat_thread and not parent_id and thread_id:
1466 message_ids = mail_message.search(cr, uid, ['&', ('res_id', '=', thread_id), ('model', '=', model)], context=context, order="id ASC", limit=1)
1467 parent_id = message_ids and message_ids[0] or False
1468 # 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
1470 message_ids = mail_message.search(cr, SUPERUSER_ID, [('id', '=', parent_id), ('parent_id', '!=', False)], context=context)
1471 # avoid loops when finding ancestors
1474 message = mail_message.browse(cr, SUPERUSER_ID, message_ids[0], context=context)
1475 while (message.parent_id and message.parent_id.id not in processed_list):
1476 processed_list.append(message.parent_id.id)
1477 message = message.parent_id
1478 parent_id = message.id
1482 'author_id': author_id,
1484 'res_id': thread_id or False,
1486 'subject': subject or False,
1488 'parent_id': parent_id,
1489 'attachment_ids': attachment_ids,
1490 'subtype_id': subtype_id,
1491 'partner_ids': [(4, pid) for pid in partner_ids],
1494 # Avoid warnings about non-existing fields
1495 for x in ('from', 'to', 'cc'):
1498 # Create and auto subscribe the author
1499 msg_id = mail_message.create(cr, uid, values, context=context)
1500 message = mail_message.browse(cr, uid, msg_id, context=context)
1501 if message.author_id and thread_id and type != 'notification' and not context.get('mail_create_nosubscribe'):
1502 self.message_subscribe(cr, uid, [thread_id], [message.author_id.id], context=context)
1505 #------------------------------------------------------
1507 #------------------------------------------------------
1509 def message_get_subscription_data(self, cr, uid, ids, user_pid=None, context=None):
1510 """ Wrapper to get subtypes data. """
1511 return self._get_subscription_data(cr, uid, ids, None, None, user_pid=user_pid, context=context)
1513 def message_subscribe_users(self, cr, uid, ids, user_ids=None, subtype_ids=None, context=None):
1514 """ Wrapper on message_subscribe, using users. If user_ids is not
1515 provided, subscribe uid instead. """
1516 if user_ids is None:
1518 partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
1519 return self.message_subscribe(cr, uid, ids, partner_ids, subtype_ids=subtype_ids, context=context)
1521 def message_subscribe(self, cr, uid, ids, partner_ids, subtype_ids=None, context=None):
1522 """ Add partners to the records followers. """
1525 # not necessary for computation, but saves an access right check
1529 mail_followers_obj = self.pool.get('mail.followers')
1530 subtype_obj = self.pool.get('mail.message.subtype')
1532 user_pid = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1533 if set(partner_ids) == set([user_pid]):
1535 self.check_access_rights(cr, uid, 'read')
1536 if context.get('operation', '') == 'create':
1537 self.check_access_rule(cr, uid, ids, 'create')
1539 self.check_access_rule(cr, uid, ids, 'read')
1540 except (osv.except_osv, orm.except_orm):
1543 self.check_access_rights(cr, uid, 'write')
1544 self.check_access_rule(cr, uid, ids, 'write')
1546 existing_pids_dict = {}
1547 fol_ids = mail_followers_obj.search(cr, SUPERUSER_ID, ['&', '&', ('res_model', '=', self._name), ('res_id', 'in', ids), ('partner_id', 'in', partner_ids)])
1548 for fol in mail_followers_obj.browse(cr, SUPERUSER_ID, fol_ids, context=context):
1549 existing_pids_dict.setdefault(fol.res_id, set()).add(fol.partner_id.id)
1551 # subtype_ids specified: update already subscribed partners
1552 if subtype_ids and fol_ids:
1553 mail_followers_obj.write(cr, SUPERUSER_ID, fol_ids, {'subtype_ids': [(6, 0, subtype_ids)]}, context=context)
1554 # subtype_ids not specified: do not update already subscribed partner, fetch default subtypes for new partners
1555 if subtype_ids is None:
1556 subtype_ids = subtype_obj.search(
1558 ('default', '=', True), '|', ('res_model', '=', self._name), ('res_model', '=', False)], context=context)
1561 existing_pids = existing_pids_dict.get(id, set())
1562 new_pids = set(partner_ids) - existing_pids
1564 # subscribe new followers
1565 for new_pid in new_pids:
1566 mail_followers_obj.create(
1568 'res_model': self._name,
1570 'partner_id': new_pid,
1571 'subtype_ids': [(6, 0, subtype_ids)],
1576 def message_unsubscribe_users(self, cr, uid, ids, user_ids=None, context=None):
1577 """ Wrapper on message_subscribe, using users. If user_ids is not
1578 provided, unsubscribe uid instead. """
1579 if user_ids is None:
1581 partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
1582 return self.message_unsubscribe(cr, uid, ids, partner_ids, context=context)
1584 def message_unsubscribe(self, cr, uid, ids, partner_ids, context=None):
1585 """ Remove partners from the records followers. """
1586 # not necessary for computation, but saves an access right check
1589 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
1590 if set(partner_ids) == set([user_pid]):
1591 self.check_access_rights(cr, uid, 'read')
1592 self.check_access_rule(cr, uid, ids, 'read')
1594 self.check_access_rights(cr, uid, 'write')
1595 self.check_access_rule(cr, uid, ids, 'write')
1596 fol_obj = self.pool['mail.followers']
1597 fol_ids = fol_obj.search(
1599 ('res_model', '=', self._name),
1600 ('res_id', 'in', ids),
1601 ('partner_id', 'in', partner_ids)
1603 return fol_obj.unlink(cr, SUPERUSER_ID, fol_ids, context=context)
1605 def _message_get_auto_subscribe_fields(self, cr, uid, updated_fields, auto_follow_fields=['user_id'], context=None):
1606 """ Returns the list of relational fields linking to res.users that should
1607 trigger an auto subscribe. The default list checks for the fields
1609 - linking to res.users
1610 - with track_visibility set
1611 In OpenERP V7, this is sufficent for all major addon such as opportunity,
1612 project, issue, recruitment, sale.
1613 Override this method if a custom behavior is needed about fields
1614 that automatically subscribe users.
1617 for name, column_info in self._all_columns.items():
1618 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':
1619 user_field_lst.append(name)
1620 return user_field_lst
1622 def message_auto_subscribe(self, cr, uid, ids, updated_fields, context=None, values=None):
1623 """ Handle auto subscription. Two methods for auto subscription exist:
1625 - tracked res.users relational fields, such as user_id fields. Those fields
1626 must be relation fields toward a res.users record, and must have the
1627 track_visilibity attribute set.
1628 - using subtypes parent relationship: check if the current model being
1629 modified has an header record (such as a project for tasks) whose followers
1630 can be added as followers of the current records. Example of structure
1631 with project and task:
1633 - st_project_1.parent_id = st_task_1
1634 - st_project_1.res_model = 'project.project'
1635 - st_project_1.relation_field = 'project_id'
1636 - st_task_1.model = 'project.task'
1638 :param list updated_fields: list of updated fields to track
1639 :param dict values: updated values; if None, the first record will be browsed
1640 to get the values. Added after releasing 7.0, therefore
1641 not merged with updated_fields argumment.
1643 subtype_obj = self.pool.get('mail.message.subtype')
1644 follower_obj = self.pool.get('mail.followers')
1645 new_followers = dict()
1647 # fetch auto_follow_fields: res.users relation fields whose changes are tracked for subscription
1648 user_field_lst = self._message_get_auto_subscribe_fields(cr, uid, updated_fields, context=context)
1650 # fetch header subtypes
1651 header_subtype_ids = subtype_obj.search(cr, uid, ['|', ('res_model', '=', False), ('parent_id.res_model', '=', self._name)], context=context)
1652 subtypes = subtype_obj.browse(cr, uid, header_subtype_ids, context=context)
1654 # if no change in tracked field or no change in tracked relational field: quit
1655 relation_fields = set([subtype.relation_field for subtype in subtypes if subtype.relation_field is not False])
1656 if not any(relation in updated_fields for relation in relation_fields) and not user_field_lst:
1659 # legacy behavior: if values is not given, compute the values by browsing
1660 # @TDENOTE: remove me in 8.0
1662 record = self.browse(cr, uid, ids[0], context=context)
1663 for updated_field in updated_fields:
1664 field_value = getattr(record, updated_field)
1665 if isinstance(field_value, browse_record):
1666 field_value = field_value.id
1667 elif isinstance(field_value, browse_null):
1669 values[updated_field] = field_value
1671 # find followers of headers, update structure for new followers
1673 for subtype in subtypes:
1674 if subtype.relation_field and values.get(subtype.relation_field):
1675 headers.add((subtype.res_model, values.get(subtype.relation_field)))
1677 header_domain = ['|'] * (len(headers) - 1)
1678 for header in headers:
1679 header_domain += ['&', ('res_model', '=', header[0]), ('res_id', '=', header[1])]
1680 header_follower_ids = follower_obj.search(
1685 for header_follower in follower_obj.browse(cr, SUPERUSER_ID, header_follower_ids, context=context):
1686 for subtype in header_follower.subtype_ids:
1687 if subtype.parent_id and subtype.parent_id.res_model == self._name:
1688 new_followers.setdefault(header_follower.partner_id.id, set()).add(subtype.parent_id.id)
1689 elif subtype.res_model is False:
1690 new_followers.setdefault(header_follower.partner_id.id, set()).add(subtype.id)
1692 # add followers coming from res.users relational fields that are tracked
1693 user_ids = [values[name] for name in user_field_lst if values.get(name)]
1694 user_pids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, SUPERUSER_ID, user_ids, context=context)]
1695 for partner_id in user_pids:
1696 new_followers.setdefault(partner_id, None)
1698 for pid, subtypes in new_followers.items():
1699 subtypes = list(subtypes) if subtypes is not None else None
1700 self.message_subscribe(cr, uid, ids, [pid], subtypes, context=context)
1702 # find first email message, set it as unread for auto_subscribe fields for them to have a notification
1704 for record_id in ids:
1705 message_obj = self.pool.get('mail.message')
1706 msg_ids = message_obj.search(cr, SUPERUSER_ID, [
1707 ('model', '=', self._name),
1708 ('res_id', '=', record_id),
1709 ('type', '=', 'email')], limit=1, context=context)
1711 msg_ids = message_obj.search(cr, SUPERUSER_ID, [
1712 ('model', '=', self._name),
1713 ('res_id', '=', record_id)], limit=1, context=context)
1715 self.pool.get('mail.notification')._notify(cr, uid, msg_ids[0], partners_to_notify=user_pids, context=context)
1719 #------------------------------------------------------
1721 #------------------------------------------------------
1723 def message_mark_as_unread(self, cr, uid, ids, context=None):
1724 """ Set as unread. """
1725 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1727 UPDATE mail_notification SET
1730 message_id IN (SELECT id from mail_message where res_id=any(%s) and model=%s limit 1) and
1732 ''', (ids, self._name, partner_id))
1735 def message_mark_as_read(self, cr, uid, ids, context=None):
1736 """ Set as read. """
1737 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1739 UPDATE mail_notification SET
1742 message_id IN (SELECT id FROM mail_message WHERE res_id=ANY(%s) AND model=%s) AND
1744 ''', (ids, self._name, partner_id))
1747 #------------------------------------------------------
1749 #------------------------------------------------------
1751 def get_suggested_thread(self, cr, uid, removed_suggested_threads=None, context=None):
1752 """Return a list of suggested threads, sorted by the numbers of followers"""
1756 # TDE HACK: originally by MAT from portal/mail_mail.py but not working until the inheritance graph bug is not solved in trunk
1757 # TDE FIXME: relocate in portal when it won't be necessary to reload the hr.employee model in an additional bridge module
1758 if self.pool['res.groups']._all_columns.get('is_portal'):
1759 user = self.pool.get('res.users').browse(cr, SUPERUSER_ID, uid, context=context)
1760 if any(group.is_portal for group in user.groups_id):
1764 if removed_suggested_threads is None:
1765 removed_suggested_threads = []
1767 thread_ids = self.search(cr, uid, [('id', 'not in', removed_suggested_threads), ('message_is_follower', '=', False)], context=context)
1768 for thread in self.browse(cr, uid, thread_ids, context=context):
1771 'popularity': len(thread.message_follower_ids),
1772 'name': thread.name,
1773 'image_small': thread.image_small
1775 threads.append(data)
1776 return sorted(threads, key=lambda x: (x['popularity'], x['id']), reverse=True)[:3]