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"))
161 def read_followers_data(self, cr, uid, follower_ids, context=None):
163 technical_group = self.pool.get('ir.model.data').get_object(cr, uid, 'base', 'group_no_one')
164 for follower in self.pool.get('res.partner').browse(cr, uid, follower_ids, context=context):
165 is_editable = uid in map(lambda x: x.id, technical_group.users)
166 is_uid = uid in map(lambda x: x.id, follower.user_ids)
169 {'is_editable': is_editable, 'is_uid': is_uid},
174 def _get_subscription_data(self, cr, uid, ids, name, args, user_pid=None, context=None):
176 - message_subtype_data: data about document subtypes: which are
177 available, which are followed if any """
178 res = dict((id, dict(message_subtype_data='')) for id in ids)
180 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
182 # find current model subtypes, add them to a dictionary
183 subtype_obj = self.pool.get('mail.message.subtype')
184 subtype_ids = subtype_obj.search(cr, uid, ['|', ('res_model', '=', self._name), ('res_model', '=', False)], context=context)
185 subtype_dict = dict((subtype.name, dict(default=subtype.default, followed=False, id=subtype.id)) for subtype in subtype_obj.browse(cr, uid, subtype_ids, context=context))
187 res[id]['message_subtype_data'] = subtype_dict.copy()
189 # find the document followers, update the data
190 fol_obj = self.pool.get('mail.followers')
191 fol_ids = fol_obj.search(cr, uid, [
192 ('partner_id', '=', user_pid),
193 ('res_id', 'in', ids),
194 ('res_model', '=', self._name),
196 for fol in fol_obj.browse(cr, uid, fol_ids, context=context):
197 thread_subtype_dict = res[fol.res_id]['message_subtype_data']
198 for subtype in fol.subtype_ids:
199 thread_subtype_dict[subtype.name]['followed'] = True
200 res[fol.res_id]['message_subtype_data'] = thread_subtype_dict
204 def _search_message_unread(self, cr, uid, obj=None, name=None, domain=None, context=None):
205 return [('message_ids.to_read', '=', True)]
207 def _get_followers(self, cr, uid, ids, name, arg, context=None):
208 fol_obj = self.pool.get('mail.followers')
209 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids)])
210 res = dict((id, dict(message_follower_ids=[], message_is_follower=False)) for id in ids)
211 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
212 for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids):
213 res[fol.res_id]['message_follower_ids'].append(fol.partner_id.id)
214 if fol.partner_id.id == user_pid:
215 res[fol.res_id]['message_is_follower'] = True
218 def _set_followers(self, cr, uid, id, name, value, arg, context=None):
221 partner_obj = self.pool.get('res.partner')
222 fol_obj = self.pool.get('mail.followers')
224 # read the old set of followers, and determine the new set of followers
225 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', '=', id)])
226 old = set(fol.partner_id.id for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids))
229 for command in value or []:
230 if isinstance(command, (int, long)):
232 elif command[0] == 0:
233 new.add(partner_obj.create(cr, uid, command[2], context=context))
234 elif command[0] == 1:
235 partner_obj.write(cr, uid, [command[1]], command[2], context=context)
237 elif command[0] == 2:
238 partner_obj.unlink(cr, uid, [command[1]], context=context)
239 new.discard(command[1])
240 elif command[0] == 3:
241 new.discard(command[1])
242 elif command[0] == 4:
244 elif command[0] == 5:
246 elif command[0] == 6:
247 new = set(command[2])
249 # remove partners that are no longer followers
250 self.message_unsubscribe(cr, uid, [id], list(old-new))
253 self.message_subscribe(cr, uid, [id], list(new-old))
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
327 thread_id = super(mail_thread, self).create(cr, uid, values, context=context)
329 # automatic logging unless asked not to (mainly for various testing purpose)
330 if not context.get('mail_create_nolog'):
331 self.message_post(cr, uid, thread_id, body=_('%s created') % (self._description), context=context)
333 # auto_subscribe: take values and defaults into account
334 create_values = dict(values)
335 for key, val in context.iteritems():
336 if key.startswith('default_'):
337 create_values[key[8:]] = val
338 self.message_auto_subscribe(cr, uid, [thread_id], create_values.keys(), context=context, values=create_values)
341 track_ctx = dict(context)
342 if 'lang' not in track_ctx:
343 track_ctx['lang'] = self.pool.get('res.users').browse(cr, uid, uid, context=context).lang
344 tracked_fields = self._get_tracked_fields(cr, uid, values.keys(), context=track_ctx)
346 initial_values = {thread_id: dict((item, False) for item in tracked_fields)}
347 self.message_track(cr, uid, [thread_id], tracked_fields, initial_values, context=track_ctx)
350 def write(self, cr, uid, ids, values, context=None):
353 if isinstance(ids, (int, long)):
355 # Track initial values of tracked fields
356 track_ctx = dict(context)
357 if 'lang' not in track_ctx:
358 track_ctx['lang'] = self.pool.get('res.users').browse(cr, uid, uid, context=context).lang
359 tracked_fields = self._get_tracked_fields(cr, uid, values.keys(), context=track_ctx)
361 records = self.browse(cr, uid, ids, context=track_ctx)
362 initial_values = dict((this.id, dict((key, getattr(this, key)) for key in tracked_fields.keys())) for this in records)
364 # Perform write, update followers
365 result = super(mail_thread, self).write(cr, uid, ids, values, context=context)
366 self.message_auto_subscribe(cr, uid, ids, values.keys(), context=context, values=values)
368 # Perform the tracking
370 self.message_track(cr, uid, ids, tracked_fields, initial_values, context=track_ctx)
373 def unlink(self, cr, uid, ids, context=None):
374 """ Override unlink to delete messages and followers. This cannot be
375 cascaded, because link is done through (res_model, res_id). """
376 msg_obj = self.pool.get('mail.message')
377 fol_obj = self.pool.get('mail.followers')
378 # delete messages and notifications
379 msg_ids = msg_obj.search(cr, uid, [('model', '=', self._name), ('res_id', 'in', ids)], context=context)
380 msg_obj.unlink(cr, uid, msg_ids, context=context)
382 res = super(mail_thread, self).unlink(cr, uid, ids, context=context)
384 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids)], context=context)
385 fol_obj.unlink(cr, SUPERUSER_ID, fol_ids, context=context)
388 def copy(self, cr, uid, id, default=None, context=None):
389 default = default or {}
390 default['message_ids'] = []
391 default['message_follower_ids'] = []
392 return super(mail_thread, self).copy(cr, uid, id, default=default, context=context)
394 #------------------------------------------------------
395 # Automatically log tracked fields
396 #------------------------------------------------------
398 def _get_tracked_fields(self, cr, uid, updated_fields, context=None):
399 """ Return a structure of tracked fields for the current model.
400 :param list updated_fields: modified field names
401 :return list: a list of (field_name, column_info obj), containing
402 always tracked fields and modified on_change fields
405 for name, column_info in self._all_columns.items():
406 visibility = getattr(column_info.column, 'track_visibility', False)
407 if visibility == 'always' or (visibility == 'onchange' and name in updated_fields) or name in self._track:
411 return self.fields_get(cr, uid, lst, context=context)
413 def message_track(self, cr, uid, ids, tracked_fields, initial_values, context=None):
415 def convert_for_display(value, col_info):
416 if not value and col_info['type'] == 'boolean':
420 if col_info['type'] == 'many2one':
421 return value.name_get()[0][1]
422 if col_info['type'] == 'selection':
423 return dict(col_info['selection'])[value]
426 def format_message(message_description, tracked_values):
428 if message_description:
429 message = '<span>%s</span>' % message_description
430 for name, change in tracked_values.items():
431 message += '<div> • <b>%s</b>: ' % change.get('col_info')
432 if change.get('old_value'):
433 message += '%s → ' % change.get('old_value')
434 message += '%s</div>' % change.get('new_value')
437 if not tracked_fields:
440 for browse_record in self.browse(cr, uid, ids, context=context):
441 initial = initial_values[browse_record.id]
445 # generate tracked_values data structure: {'col_name': {col_info, new_value, old_value}}
446 for col_name, col_info in tracked_fields.items():
447 initial_value = initial[col_name]
448 record_value = getattr(browse_record, col_name)
450 if record_value == initial_value and getattr(self._all_columns[col_name].column, 'track_visibility', None) == 'always':
451 tracked_values[col_name] = dict(col_info=col_info['string'],
452 new_value=convert_for_display(record_value, col_info))
453 elif record_value != initial_value and (record_value or initial_value): # because browse null != False
454 if getattr(self._all_columns[col_name].column, 'track_visibility', None) in ['always', 'onchange']:
455 tracked_values[col_name] = dict(col_info=col_info['string'],
456 old_value=convert_for_display(initial_value, col_info),
457 new_value=convert_for_display(record_value, col_info))
458 if col_name in tracked_fields:
459 changes.add(col_name)
463 # find subtypes and post messages or log if no subtype found
465 for field, track_info in self._track.items():
466 if field not in changes:
468 for subtype, method in track_info.items():
469 if method(self, cr, uid, browse_record, context):
470 subtypes.append(subtype)
473 for subtype in subtypes:
475 subtype_rec = self.pool.get('ir.model.data').get_object(cr, uid, subtype.split('.')[0], subtype.split('.')[1], context=context)
476 except ValueError, e:
477 _logger.debug('subtype %s not found, giving error "%s"' % (subtype, e))
479 message = format_message(subtype_rec.description if subtype_rec.description else subtype_rec.name, tracked_values)
480 self.message_post(cr, uid, browse_record.id, body=message, subtype=subtype, context=context)
483 message = format_message('', tracked_values)
484 self.message_post(cr, uid, browse_record.id, body=message, context=context)
487 #------------------------------------------------------
488 # mail.message wrappers and tools
489 #------------------------------------------------------
491 def _needaction_domain_get(self, cr, uid, context=None):
493 return [('message_unread', '=', True)]
496 def _garbage_collect_attachments(self, cr, uid, context=None):
497 """ Garbage collect lost mail attachments. Those are attachments
498 - linked to res_model 'mail.compose.message', the composer wizard
499 - with res_id 0, because they were created outside of an existing
500 wizard (typically user input through Chatter or reports
501 created on-the-fly by the templates)
502 - unused since at least one day (create_date and write_date)
504 limit_date = datetime.datetime.utcnow() - datetime.timedelta(days=1)
505 limit_date_str = datetime.datetime.strftime(limit_date, tools.DEFAULT_SERVER_DATETIME_FORMAT)
506 ir_attachment_obj = self.pool.get('ir.attachment')
507 attach_ids = ir_attachment_obj.search(cr, uid, [
508 ('res_model', '=', 'mail.compose.message'),
510 ('create_date', '<', limit_date_str),
511 ('write_date', '<', limit_date_str),
513 ir_attachment_obj.unlink(cr, uid, attach_ids, context=context)
516 def check_mail_message_access(self, cr, uid, mids, operation, model_obj=None, context=None):
517 """ mail.message check permission rules for related document. This method is
518 meant to be inherited in order to implement addons-specific behavior.
519 A common behavior would be to allow creating messages when having read
520 access rule on the document, for portal document such as issues. """
523 if hasattr(self, '_mail_post_access'):
524 create_allow = self._mail_post_access
526 create_allow = 'write'
528 if operation in ['write', 'unlink']:
529 check_operation = 'write'
530 elif operation == 'create' and create_allow in ['create', 'read', 'write', 'unlink']:
531 check_operation = create_allow
532 elif operation == 'create':
533 check_operation = 'write'
535 check_operation = operation
537 model_obj.check_access_rights(cr, uid, check_operation)
538 model_obj.check_access_rule(cr, uid, mids, check_operation, context=context)
540 def _get_formview_action(self, cr, uid, id, model=None, context=None):
541 """ Return an action to open the document. This method is meant to be
542 overridden in addons that want to give specific view ids for example.
544 :param int id: id of the document to open
545 :param string model: specific model that overrides self._name
548 'type': 'ir.actions.act_window',
549 'res_model': model or self._name,
552 'views': [(False, 'form')],
557 def _get_inbox_action_xml_id(self, cr, uid, context=None):
558 """ When redirecting towards the Inbox, choose which action xml_id has
559 to be fetched. This method is meant to be inherited, at least in portal
560 because portal users have a different Inbox action than classic users. """
561 return ('mail', 'action_mail_inbox_feeds')
563 def message_redirect_action(self, cr, uid, context=None):
564 """ For a given message, return an action that either
565 - opens the form view of the related document if model, res_id, and
566 read access to the document
567 - opens the Inbox with a default search on the conversation if model,
569 - opens the Inbox with context propagated
575 # default action is the Inbox action
576 self.pool.get('res.users').browse(cr, SUPERUSER_ID, uid, context=context)
577 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))
578 action = self.pool.get(act_model).read(cr, uid, act_id, [])
579 params = context.get('params')
580 msg_id = model = res_id = None
583 msg_id = params.get('message_id')
584 model = params.get('model')
585 res_id = params.get('res_id')
586 if not msg_id and not (model and res_id):
588 if msg_id and not (model and res_id):
589 msg = self.pool.get('mail.message').browse(cr, uid, msg_id, context=context)
591 model, res_id = msg.model, msg.res_id
593 # if model + res_id found: try to redirect to the document or fallback on the Inbox
595 model_obj = self.pool.get(model)
596 if model_obj.check_access_rights(cr, uid, 'read', raise_exception=False):
598 model_obj.check_access_rule(cr, uid, [res_id], 'read', context=context)
599 if not hasattr(model_obj, '_get_formview_action'):
600 action = self.pool.get('mail.thread')._get_formview_action(cr, uid, res_id, model=model, context=context)
602 action = model_obj._get_formview_action(cr, uid, res_id, context=context)
603 except (osv.except_osv, orm.except_orm):
607 'search_default_model': model,
608 'search_default_res_id': res_id,
613 #------------------------------------------------------
615 #------------------------------------------------------
617 def message_get_reply_to(self, cr, uid, ids, context=None):
618 """ Returns the preferred reply-to email address that is basically
619 the alias of the document, if it exists. """
620 if not self._inherits.get('mail.alias'):
621 return [False for id in ids]
622 return ["%s@%s" % (record['alias_name'], record['alias_domain'])
623 if record.get('alias_domain') and record.get('alias_name')
625 for record in self.read(cr, SUPERUSER_ID, ids, ['alias_name', 'alias_domain'], context=context)]
627 #------------------------------------------------------
629 #------------------------------------------------------
631 def message_capable_models(self, cr, uid, context=None):
632 """ Used by the plugin addon, based for plugin_outlook and others. """
634 for model_name in self.pool.obj_list():
635 model = self.pool[model_name]
636 if hasattr(model, "message_process") and hasattr(model, "message_post"):
637 ret_dict[model_name] = model._description
640 def _message_find_partners(self, cr, uid, message, header_fields=['From'], context=None):
641 """ Find partners related to some header fields of the message.
643 :param string message: an email.message instance """
644 s = ', '.join([decode(message.get(h)) for h in header_fields if message.get(h)])
645 return filter(lambda x: x, self._find_partner_from_emails(cr, uid, None, tools.email_split(s), context=context))
647 def message_route_verify(self, cr, uid, message, message_dict, route, update_author=True, assert_model=True, create_fallback=True, context=None):
648 """ Verify route validity. Check and rules:
649 1 - if thread_id -> check that document effectively exists; otherwise
650 fallback on a message_new by resetting thread_id
651 2 - check that message_update exists if thread_id is set; or at least
652 that message_new exist
653 [ - find author_id if udpate_author is set]
654 3 - if there is an alias, check alias_contact:
655 'followers' and thread_id:
656 check on target document that the author is in the followers
657 'followers' and alias_parent_thread_id:
658 check on alias parent document that the author is in the
660 'partners': check that author_id id set
663 assert isinstance(route, (list, tuple)), 'A route should be a list or a tuple'
664 assert len(route) == 5, 'A route should contain 5 elements: model, thread_id, custom_values, uid, alias record'
666 message_id = message.get('Message-Id')
667 email_from = decode_header(message, 'From')
668 author_id = message_dict.get('author_id')
669 model, thread_id, alias = route[0], route[1], route[4]
672 def _create_bounce_email():
673 mail_mail = self.pool.get('mail.mail')
674 mail_id = mail_mail.create(cr, uid, {
675 'body_html': '<div><p>Hello,</p>'
676 '<p>The following email sent to %s cannot be accepted because this is '
677 'a private email address. Only allowed people can contact us at this address.</p></div>'
678 '<blockquote>%s</blockquote>' % (message.get('to'), message_dict.get('body')),
679 'subject': 'Re: %s' % message.get('subject'),
680 'email_to': message.get('from'),
683 mail_mail.send(cr, uid, [mail_id], context=context)
686 _logger.warning('Routing mail with Message-Id %s: route %s: %s',
687 message_id, route, message)
690 if model and not model in self.pool:
692 assert model in self.pool, 'Routing: unknown target model %s' % model
693 _warn('unknown target model %s' % model)
696 model_pool = self.pool[model]
698 # Private message: should not contain any thread_id
699 if not model and thread_id:
701 assert thread_id == 0, 'Routing: posting a message without model should be with a null res_id (private message).'
702 _warn('posting a message without model should be with a null res_id (private message), resetting thread_id')
704 # Private message: should have a parent_id (only answers)
705 if not model and not message_dict.get('parent_id'):
707 assert message_dict.get('parent_id'), 'Routing: posting a message without model should be with a parent_id (private mesage).'
708 _warn('posting a message without model should be with a parent_id (private mesage), skipping')
711 # Existing Document: check if exists; if not, fallback on create if allowed
712 if thread_id and not model_pool.exists(cr, uid, thread_id):
714 _warn('reply to missing document (%s,%s), fall back on new document creation' % (model, thread_id))
717 assert model_pool.exists(cr, uid, thread_id), 'Routing: reply to missing document (%s,%s)' % (model, thread_id)
719 _warn('reply to missing document (%s,%s), skipping' % (model, thread_id))
722 # Existing Document: check model accepts the mailgateway
723 if thread_id and model and not hasattr(model_pool, 'message_update'):
725 _warn('model %s does not accept document update, fall back on document creation' % model)
728 assert hasattr(model_pool, 'message_update'), 'Routing: model %s does not accept document update, crashing' % model
730 _warn('model %s does not accept document update, skipping' % model)
733 # New Document: check model accepts the mailgateway
734 if not thread_id and model and not hasattr(model_pool, 'message_new'):
736 assert hasattr(model_pool, 'message_new'), 'Model %s does not accept document creation, crashing' % model
737 _warn('model %s does not accept document creation, skipping' % model)
740 # Update message author if asked
741 # We do it now because we need it for aliases (contact settings)
742 if not author_id and update_author:
743 author_ids = self._find_partner_from_emails(cr, uid, thread_id, [email_from], model=model, context=context)
745 author_id = author_ids[0]
746 message_dict['author_id'] = author_id
748 # Alias: check alias_contact settings
749 if alias and alias.alias_contact == 'followers' and (thread_id or alias.alias_parent_thread_id):
751 obj = self.pool[model].browse(cr, uid, thread_id, context=context)
753 obj = self.pool[alias.alias_parent_model_id.model].browse(cr, uid, alias.alias_parent_thread_id, context=context)
754 if not author_id or not author_id in [fol.id for fol in obj.message_follower_ids]:
755 _warn('alias %s restricted to internal followers, skipping' % alias.alias_name)
756 _create_bounce_email()
758 elif alias and alias.alias_contact == 'partners' and not author_id:
759 _warn('alias %s does not accept unknown author, skipping' % alias.alias_name)
760 _create_bounce_email()
763 return (model, thread_id, route[2], route[3], route[4])
765 def message_route(self, cr, uid, message, message_dict, model=None, thread_id=None,
766 custom_values=None, context=None):
767 """Attempt to figure out the correct target model, thread_id,
768 custom_values and user_id to use for an incoming message.
769 Multiple values may be returned, if a message had multiple
770 recipients matching existing mail.aliases, for example.
772 The following heuristics are used, in this order:
773 1. If the message replies to an existing thread_id, and
774 properly contains the thread model in the 'In-Reply-To'
775 header, use this model/thread_id pair, and ignore
776 custom_value (not needed as no creation will take place)
777 2. Look for a mail.alias entry matching the message
778 recipient, and use the corresponding model, thread_id,
779 custom_values and user_id.
780 3. Fallback to the ``model``, ``thread_id`` and ``custom_values``
782 4. If all the above fails, raise an exception.
784 :param string message: an email.message instance
785 :param dict message_dict: dictionary holding message variables
786 :param string model: the fallback model to use if the message
787 does not match any of the currently configured mail aliases
788 (may be None if a matching alias is supposed to be present)
789 :type dict custom_values: optional dictionary of default field values
790 to pass to ``message_new`` if a new record needs to be created.
791 Ignored if the thread record already exists, and also if a
792 matching mail.alias was found (aliases define their own defaults)
793 :param int thread_id: optional ID of the record/thread from ``model``
794 to which this mail should be attached. Only used if the message
795 does not reply to an existing thread and does not match any mail alias.
796 :return: list of [model, thread_id, custom_values, user_id, alias]
798 assert isinstance(message, Message), 'message must be an email.message.Message at this point'
799 fallback_model = model
801 # Get email.message.Message variables for future processing
802 message_id = message.get('Message-Id')
803 email_from = decode_header(message, 'From')
804 email_to = decode_header(message, 'To')
805 references = decode_header(message, 'References')
806 in_reply_to = decode_header(message, 'In-Reply-To')
808 # 1. Verify if this is a reply to an existing thread
809 thread_references = references or in_reply_to
810 ref_match = thread_references and tools.reference_re.search(thread_references)
812 thread_id = int(ref_match.group(1))
813 model = ref_match.group(2) or fallback_model
814 if thread_id and model in self.pool:
815 model_obj = self.pool[model]
816 if model_obj.exists(cr, uid, thread_id) and hasattr(model_obj, 'message_update'):
817 _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',
818 email_from, email_to, message_id, model, thread_id, custom_values, uid)
819 route = self.message_route_verify(cr, uid, message, message_dict,
820 (model, thread_id, custom_values, uid, None),
821 update_author=True, assert_model=True, create_fallback=True, context=context)
822 return route and [route] or []
824 # 2. Reply to a private message
826 mail_message_ids = self.pool.get('mail.message').search(cr, uid, [
827 ('message_id', '=', in_reply_to),
828 '!', ('message_id', 'ilike', 'reply_to')
829 ], limit=1, context=context)
831 mail_message = self.pool.get('mail.message').browse(cr, uid, mail_message_ids[0], context=context)
832 _logger.info('Routing mail from %s to %s with Message-Id %s: direct reply to a private message: %s, custom_values: %s, uid: %s',
833 email_from, email_to, message_id, mail_message.id, custom_values, uid)
834 route = self.message_route_verify(cr, uid, message, message_dict,
835 (mail_message.model, mail_message.res_id, custom_values, uid, None),
836 update_author=True, assert_model=True, create_fallback=True, context=context)
837 return route and [route] or []
839 # 3. Look for a matching mail.alias entry
840 # Delivered-To is a safe bet in most modern MTAs, but we have to fallback on To + Cc values
841 # for all the odd MTAs out there, as there is no standard header for the envelope's `rcpt_to` value.
843 ','.join([decode_header(message, 'Delivered-To'),
844 decode_header(message, 'To'),
845 decode_header(message, 'Cc'),
846 decode_header(message, 'Resent-To'),
847 decode_header(message, 'Resent-Cc')])
848 local_parts = [e.split('@')[0] for e in tools.email_split(rcpt_tos)]
850 mail_alias = self.pool.get('mail.alias')
851 alias_ids = mail_alias.search(cr, uid, [('alias_name', 'in', local_parts)])
854 for alias in mail_alias.browse(cr, uid, alias_ids, context=context):
855 user_id = alias.alias_user_id.id
857 # TDE note: this could cause crashes, because no clue that the user
858 # that send the email has the right to create or modify a new document
859 # Fallback on user_id = uid
860 # Note: recognized partners will be added as followers anyway
861 # user_id = self._message_find_user_id(cr, uid, message, context=context)
863 _logger.info('No matching user_id for the alias %s', alias.alias_name)
864 route = (alias.alias_model_id.model, alias.alias_force_thread_id, eval(alias.alias_defaults), user_id, alias)
865 _logger.info('Routing mail from %s to %s with Message-Id %s: direct alias match: %r',
866 email_from, email_to, message_id, route)
867 route = self.message_route_verify(cr, uid, message, message_dict, route,
868 update_author=True, assert_model=True, create_fallback=True, context=context)
873 # 4. Fallback to the provided parameters, if they work
875 # Legacy: fallback to matching [ID] in the Subject
876 match = tools.res_re.search(decode_header(message, 'Subject'))
877 thread_id = match and match.group(1)
878 # Convert into int (bug spotted in 7.0 because of str)
880 thread_id = int(thread_id)
883 _logger.info('Routing mail from %s to %s with Message-Id %s: fallback to model:%s, thread_id:%s, custom_values:%s, uid:%s',
884 email_from, email_to, message_id, fallback_model, thread_id, custom_values, uid)
885 route = self.message_route_verify(cr, uid, message, message_dict,
886 (fallback_model, thread_id, custom_values, uid, None),
887 update_author=True, assert_model=True, context=context)
891 # AssertionError if no routes found and if no bounce occured
893 "No possible route found for incoming message from %s to %s (Message-Id %s:)." \
894 "Create an appropriate mail.alias or force the destination model." % (email_from, email_to, message_id)
896 def message_route_process(self, cr, uid, message, message_dict, routes, context=None):
897 # postpone setting message_dict.partner_ids after message_post, to avoid double notifications
898 partner_ids = message_dict.pop('partner_ids', [])
900 for model, thread_id, custom_values, user_id, alias in routes:
901 if self._name == 'mail.thread':
902 context.update({'thread_model': model})
904 model_pool = self.pool[model]
905 assert thread_id and hasattr(model_pool, 'message_update') or hasattr(model_pool, 'message_new'), \
906 "Undeliverable mail with Message-Id %s, model %s does not accept incoming emails" % \
907 (message_dict['message_id'], model)
909 # disabled subscriptions during message_new/update to avoid having the system user running the
910 # email gateway become a follower of all inbound messages
911 nosub_ctx = dict(context, mail_create_nosubscribe=True, mail_create_nolog=True)
912 if thread_id and hasattr(model_pool, 'message_update'):
913 model_pool.message_update(cr, user_id, [thread_id], message_dict, context=nosub_ctx)
915 thread_id = model_pool.message_new(cr, user_id, message_dict, custom_values, context=nosub_ctx)
917 assert thread_id == 0, "Posting a message without model should be with a null res_id, to create a private message."
918 model_pool = self.pool.get('mail.thread')
919 if not hasattr(model_pool, 'message_post'):
920 context['thread_model'] = model
921 model_pool = self.pool['mail.thread']
922 new_msg_id = model_pool.message_post(cr, uid, [thread_id], context=context, subtype='mail.mt_comment', **message_dict)
925 # postponed after message_post, because this is an external message and we don't want to create
926 # duplicate emails due to notifications
927 self.pool.get('mail.message').write(cr, uid, [new_msg_id], {'partner_ids': partner_ids}, context=context)
930 def message_process(self, cr, uid, model, message, custom_values=None,
931 save_original=False, strip_attachments=False,
932 thread_id=None, context=None):
933 """ Process an incoming RFC2822 email message, relying on
934 ``mail.message.parse()`` for the parsing operation,
935 and ``message_route()`` to figure out the target model.
937 Once the target model is known, its ``message_new`` method
938 is called with the new message (if the thread record did not exist)
939 or its ``message_update`` method (if it did).
941 There is a special case where the target model is False: a reply
942 to a private message. In this case, we skip the message_new /
943 message_update step, to just post a new message using mail_thread
946 :param string model: the fallback model to use if the message
947 does not match any of the currently configured mail aliases
948 (may be None if a matching alias is supposed to be present)
949 :param message: source of the RFC2822 message
950 :type message: string or xmlrpclib.Binary
951 :type dict custom_values: optional dictionary of field values
952 to pass to ``message_new`` if a new record needs to be created.
953 Ignored if the thread record already exists, and also if a
954 matching mail.alias was found (aliases define their own defaults)
955 :param bool save_original: whether to keep a copy of the original
956 email source attached to the message after it is imported.
957 :param bool strip_attachments: whether to strip all attachments
958 before processing the message, in order to save some space.
959 :param int thread_id: optional ID of the record/thread from ``model``
960 to which this mail should be attached. When provided, this
961 overrides the automatic detection based on the message
967 # extract message bytes - we are forced to pass the message as binary because
968 # we don't know its encoding until we parse its headers and hence can't
969 # convert it to utf-8 for transport between the mailgate script and here.
970 if isinstance(message, xmlrpclib.Binary):
971 message = str(message.data)
972 # Warning: message_from_string doesn't always work correctly on unicode,
973 # we must use utf-8 strings here :-(
974 if isinstance(message, unicode):
975 message = message.encode('utf-8')
976 msg_txt = email.message_from_string(message)
978 # parse the message, verify we are not in a loop by checking message_id is not duplicated
979 msg = self.message_parse(cr, uid, msg_txt, save_original=save_original, context=context)
980 if strip_attachments:
981 msg.pop('attachments', None)
983 if msg.get('message_id'): # should always be True as message_parse generate one if missing
984 existing_msg_ids = self.pool.get('mail.message').search(cr, SUPERUSER_ID, [
985 ('message_id', '=', msg.get('message_id')),
988 _logger.info('Ignored mail from %s to %s with Message-Id %s: found duplicated Message-Id during processing',
989 msg.get('from'), msg.get('to'), msg.get('message_id'))
992 # find possible routes for the message
993 routes = self.message_route(cr, uid, msg_txt, msg, model, thread_id, custom_values, context=context)
994 thread_id = self.message_route_process(cr, uid, msg_txt, msg, routes, context=context)
997 def message_new(self, cr, uid, msg_dict, custom_values=None, context=None):
998 """Called by ``message_process`` when a new message is received
999 for a given thread model, if the message did not belong to
1001 The default behavior is to create a new record of the corresponding
1002 model (based on some very basic info extracted from the message).
1003 Additional behavior may be implemented by overriding this method.
1005 :param dict msg_dict: a map containing the email details and
1006 attachments. See ``message_process`` and
1007 ``mail.message.parse`` for details.
1008 :param dict custom_values: optional dictionary of additional
1009 field values to pass to create()
1010 when creating the new thread record.
1011 Be careful, these values may override
1012 any other values coming from the message.
1013 :param dict context: if a ``thread_model`` value is present
1014 in the context, its value will be used
1015 to determine the model of the record
1016 to create (instead of the current model).
1018 :return: the id of the newly created thread object
1023 if isinstance(custom_values, dict):
1024 data = custom_values.copy()
1025 model = context.get('thread_model') or self._name
1026 model_pool = self.pool[model]
1027 fields = model_pool.fields_get(cr, uid, context=context)
1028 if 'name' in fields and not data.get('name'):
1029 data['name'] = msg_dict.get('subject', '')
1030 res_id = model_pool.create(cr, uid, data, context=context)
1033 def message_update(self, cr, uid, ids, msg_dict, update_vals=None, context=None):
1034 """Called by ``message_process`` when a new message is received
1035 for an existing thread. The default behavior is to update the record
1036 with update_vals taken from the incoming email.
1037 Additional behavior may be implemented by overriding this
1039 :param dict msg_dict: a map containing the email details and
1040 attachments. See ``message_process`` and
1041 ``mail.message.parse()`` for details.
1042 :param dict update_vals: a dict containing values to update records
1043 given their ids; if the dict is None or is
1044 void, no write operation is performed.
1047 self.write(cr, uid, ids, update_vals, context=context)
1050 def _message_extract_payload(self, message, save_original=False):
1051 """Extract body as HTML and attachments from the mail message"""
1055 attachments.append(('original_email.eml', message.as_string()))
1056 if not message.is_multipart() or 'text/' in message.get('content-type', ''):
1057 encoding = message.get_content_charset()
1058 body = message.get_payload(decode=True)
1059 body = tools.ustr(body, encoding, errors='replace')
1060 if message.get_content_type() == 'text/plain':
1061 # text/plain -> <pre/>
1062 body = tools.append_content_to_html(u'', body, preserve=True)
1065 for part in message.walk():
1066 if part.get_content_type() == 'multipart/alternative':
1068 if part.get_content_maintype() == 'multipart':
1069 continue # skip container
1070 # part.get_filename returns decoded value if able to decode, coded otherwise.
1071 # original get_filename is not able to decode iso-8859-1 (for instance).
1072 # therefore, iso encoded attachements are not able to be decoded properly with get_filename
1073 # code here partially copy the original get_filename method, but handle more encoding
1074 filename=part.get_param('filename', None, 'content-disposition')
1076 filename=part.get_param('name', None)
1078 if isinstance(filename, tuple):
1080 filename=email.utils.collapse_rfc2231_value(filename).strip()
1082 filename=decode(filename)
1083 encoding = part.get_content_charset() # None if attachment
1084 # 1) Explicit Attachments -> attachments
1085 if filename or part.get('content-disposition', '').strip().startswith('attachment'):
1086 attachments.append((filename or 'attachment', part.get_payload(decode=True)))
1088 # 2) text/plain -> <pre/>
1089 if part.get_content_type() == 'text/plain' and (not alternative or not body):
1090 body = tools.append_content_to_html(body, tools.ustr(part.get_payload(decode=True),
1091 encoding, errors='replace'), preserve=True)
1092 # 3) text/html -> raw
1093 elif part.get_content_type() == 'text/html':
1094 html = tools.ustr(part.get_payload(decode=True), encoding, errors='replace')
1098 body = tools.append_content_to_html(body, html, plaintext=False)
1099 # 4) Anything else -> attachment
1101 attachments.append((filename or 'attachment', part.get_payload(decode=True)))
1102 return body, attachments
1104 def message_parse(self, cr, uid, message, save_original=False, context=None):
1105 """Parses a string or email.message.Message representing an
1106 RFC-2822 email, and returns a generic dict holding the
1109 :param message: the message to parse
1110 :type message: email.message.Message | string | unicode
1111 :param bool save_original: whether the returned dict
1112 should include an ``original`` attachment containing
1113 the source of the message
1115 :return: A dict with the following structure, where each
1116 field may not be present if missing in original
1119 { 'message_id': msg_id,
1124 'body': unified_body,
1125 'attachments': [('file1', 'bytes'),
1132 if not isinstance(message, Message):
1133 if isinstance(message, unicode):
1134 # Warning: message_from_string doesn't always work correctly on unicode,
1135 # we must use utf-8 strings here :-(
1136 message = message.encode('utf-8')
1137 message = email.message_from_string(message)
1139 message_id = message['message-id']
1141 # Very unusual situation, be we should be fault-tolerant here
1142 message_id = "<%s@localhost>" % time.time()
1143 _logger.debug('Parsing Message without message-id, generating a random one: %s', message_id)
1144 msg_dict['message_id'] = message_id
1146 if message.get('Subject'):
1147 msg_dict['subject'] = decode(message.get('Subject'))
1149 # Envelope fields not stored in mail.message but made available for message_new()
1150 msg_dict['from'] = decode(message.get('from'))
1151 msg_dict['to'] = decode(message.get('to'))
1152 msg_dict['cc'] = decode(message.get('cc'))
1153 msg_dict['email_from'] = decode(message.get('from'))
1154 partner_ids = self._message_find_partners(cr, uid, message, ['To', 'Cc'], context=context)
1155 msg_dict['partner_ids'] = [(4, partner_id) for partner_id in partner_ids]
1157 if message.get('Date'):
1159 date_hdr = decode(message.get('Date'))
1160 parsed_date = dateutil.parser.parse(date_hdr, fuzzy=True)
1161 if parsed_date.utcoffset() is None:
1162 # naive datetime, so we arbitrarily decide to make it
1163 # UTC, there's no better choice. Should not happen,
1164 # as RFC2822 requires timezone offset in Date headers.
1165 stored_date = parsed_date.replace(tzinfo=pytz.utc)
1167 stored_date = parsed_date.astimezone(tz=pytz.utc)
1169 _logger.warning('Failed to parse Date header %r in incoming mail '
1170 'with message-id %r, assuming current date/time.',
1171 message.get('Date'), message_id)
1172 stored_date = datetime.datetime.now()
1173 msg_dict['date'] = stored_date.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT)
1175 if message.get('In-Reply-To'):
1176 parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', '=', decode(message['In-Reply-To']))])
1178 msg_dict['parent_id'] = parent_ids[0]
1180 if message.get('References') and 'parent_id' not in msg_dict:
1181 parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', 'in',
1182 [x.strip() for x in decode(message['References']).split()])])
1184 msg_dict['parent_id'] = parent_ids[0]
1186 msg_dict['body'], msg_dict['attachments'] = self._message_extract_payload(message, save_original=save_original)
1189 #------------------------------------------------------
1191 #------------------------------------------------------
1193 def log(self, cr, uid, id, message, secondary=False, context=None):
1194 _logger.warning("log() is deprecated. As this module inherit from "\
1195 "mail.thread, the message will be managed by this "\
1196 "module instead of by the res.log mechanism. Please "\
1197 "use mail_thread.message_post() instead of the "\
1198 "now deprecated res.log.")
1199 self.message_post(cr, uid, [id], message, context=context)
1201 def _message_add_suggested_recipient(self, cr, uid, result, obj, partner=None, email=None, reason='', context=None):
1202 """ Called by message_get_suggested_recipients, to add a suggested
1203 recipient in the result dictionary. The form is :
1204 partner_id, partner_name<partner_email> or partner_name, reason """
1205 if email and not partner:
1206 # get partner info from email
1207 partner_info = self.message_partner_info_from_emails(cr, uid, obj.id, [email], context=context)[0]
1208 if partner_info.get('partner_id'):
1209 partner = self.pool.get('res.partner').browse(cr, SUPERUSER_ID, [partner_info['partner_id']], context=context)[0]
1210 if email and email in [val[1] for val in result[obj.id]]: # already existing email -> skip
1212 if partner and partner in obj.message_follower_ids: # recipient already in the followers -> skip
1214 if partner and partner in [val[0] for val in result[obj.id]]: # already existing partner ID -> skip
1216 if partner and partner.email: # complete profile: id, name <email>
1217 result[obj.id].append((partner.id, '%s<%s>' % (partner.name, partner.email), reason))
1218 elif partner: # incomplete profile: id, name
1219 result[obj.id].append((partner.id, '%s' % (partner.name), reason))
1220 else: # unknown partner, we are probably managing an email address
1221 result[obj.id].append((False, email, reason))
1224 def message_get_suggested_recipients(self, cr, uid, ids, context=None):
1225 """ Returns suggested recipients for ids. Those are a list of
1226 tuple (partner_id, partner_name, reason), to be managed by Chatter. """
1227 result = dict.fromkeys(ids, list())
1228 if self._all_columns.get('user_id'):
1229 for obj in self.browse(cr, SUPERUSER_ID, ids, context=context): # SUPERUSER because of a read on res.users that would crash otherwise
1230 if not obj.user_id or not obj.user_id.partner_id:
1232 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)
1235 def _find_partner_from_emails(self, cr, uid, id, emails, model=None, context=None, check_followers=True):
1236 """ Utility method to find partners from email addresses. The rules are :
1237 1 - check in document (model | self, id) followers
1238 2 - try to find a matching partner that is also an user
1239 3 - try to find a matching partner
1241 :param list emails: list of email addresses
1242 :param string model: model to fetch related record; by default self
1244 :param boolean check_followers: check in document followers
1246 partner_obj = self.pool['res.partner']
1249 if id and (model or self._name != 'mail.thread') and check_followers:
1251 obj = self.pool[model].browse(cr, uid, id, context=context)
1253 obj = self.browse(cr, uid, id, context=context)
1254 for contact in emails:
1256 email_address = tools.email_split(contact)
1257 if not email_address:
1258 partner_ids.append(partner_id)
1260 email_address = email_address[0]
1261 # first try: check in document's followers
1263 for follower in obj.message_follower_ids:
1264 if follower.email == email_address:
1265 partner_id = follower.id
1266 # second try: check in partners that are also users
1268 ids = partner_obj.search(cr, SUPERUSER_ID, [
1269 ('email', 'ilike', email_address),
1270 ('user_ids', '!=', False)
1271 ], limit=1, context=context)
1274 # third try: check in partners
1276 ids = partner_obj.search(cr, SUPERUSER_ID, [
1277 ('email', 'ilike', email_address)
1278 ], limit=1, context=context)
1281 partner_ids.append(partner_id)
1284 def message_partner_info_from_emails(self, cr, uid, id, emails, link_mail=False, context=None):
1285 """ Convert a list of emails into a list partner_ids and a list
1286 new_partner_ids. The return value is non conventional because
1287 it is meant to be used by the mail widget.
1289 :return dict: partner_ids and new_partner_ids """
1290 mail_message_obj = self.pool.get('mail.message')
1291 partner_ids = self._find_partner_from_emails(cr, uid, id, emails, context=context)
1293 for idx in range(len(emails)):
1294 email_address = emails[idx]
1295 partner_id = partner_ids[idx]
1296 partner_info = {'full_name': email_address, 'partner_id': partner_id}
1297 result.append(partner_info)
1299 # link mail with this from mail to the new partner id
1300 if link_mail and partner_info['partner_id']:
1301 message_ids = mail_message_obj.search(cr, SUPERUSER_ID, [
1303 ('email_from', '=', email_address),
1304 ('email_from', 'ilike', '<%s>' % email_address),
1305 ('author_id', '=', False)
1308 mail_message_obj.write(cr, SUPERUSER_ID, message_ids, {'author_id': partner_info['partner_id']}, context=context)
1311 def _message_preprocess_attachments(self, cr, uid, attachments, attachment_ids, attach_model, attach_res_id, context=None):
1312 """ Preprocess attachments for mail_thread.message_post() or mail_mail.create().
1314 :param list attachments: list of attachment tuples in the form ``(name,content)``,
1315 where content is NOT base64 encoded
1316 :param list attachment_ids: a list of attachment ids, not in tomany command form
1317 :param str attach_model: the model of the attachments parent record
1318 :param integer attach_res_id: the id of the attachments parent record
1320 Attachment = self.pool['ir.attachment']
1321 m2m_attachment_ids = []
1323 filtered_attachment_ids = Attachment.search(cr, SUPERUSER_ID, [
1324 ('res_model', '=', 'mail.compose.message'),
1325 ('create_uid', '=', uid),
1326 ('id', 'in', attachment_ids)], context=context)
1327 if filtered_attachment_ids:
1328 Attachment.write(cr, SUPERUSER_ID, filtered_attachment_ids, {'res_model': attach_model, 'res_id': attach_res_id}, context=context)
1329 m2m_attachment_ids += [(4, id) for id in attachment_ids]
1330 # Handle attachments parameter, that is a dictionary of attachments
1331 for name, content in attachments:
1332 if isinstance(content, unicode):
1333 content = content.encode('utf-8')
1336 'datas': base64.b64encode(str(content)),
1337 'datas_fname': name,
1338 'description': name,
1339 'res_model': attach_model,
1340 'res_id': attach_res_id,
1342 m2m_attachment_ids.append((0, 0, data_attach))
1343 return m2m_attachment_ids
1345 def message_post(self, cr, uid, thread_id, body='', subject=None, type='notification',
1346 subtype=None, parent_id=False, attachments=None, context=None,
1347 content_subtype='html', **kwargs):
1348 """ Post a new message in an existing thread, returning the new
1351 :param int thread_id: thread ID to post into, or list with one ID;
1352 if False/0, mail.message model will also be set as False
1353 :param str body: body of the message, usually raw HTML that will
1355 :param str type: see mail_message.type field
1356 :param str content_subtype:: if plaintext: convert body into html
1357 :param int parent_id: handle reply to a previous message by adding the
1358 parent partners to the message in case of private discussion
1359 :param tuple(str,str) attachments or list id: list of attachment tuples in the form
1360 ``(name,content)``, where content is NOT base64 encoded
1362 Extra keyword arguments will be used as default column values for the
1363 new mail.message record. Special cases:
1364 - attachment_ids: supposed not attached to any document; attach them
1365 to the related document. Should only be set by Chatter.
1366 :return int: ID of newly created mail.message
1370 if attachments is None:
1372 mail_message = self.pool.get('mail.message')
1373 ir_attachment = self.pool.get('ir.attachment')
1375 assert (not thread_id) or \
1376 isinstance(thread_id, (int, long)) or \
1377 (isinstance(thread_id, (list, tuple)) and len(thread_id) == 1), \
1378 "Invalid thread_id; should be 0, False, an ID or a list with one ID"
1379 if isinstance(thread_id, (list, tuple)):
1380 thread_id = thread_id[0]
1382 # if we're processing a message directly coming from the gateway, the destination model was
1383 # set in the context.
1386 model = context.get('thread_model', self._name) if self._name == 'mail.thread' else self._name
1387 if model != self._name and hasattr(self.pool[model], 'message_post'):
1388 del context['thread_model']
1389 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)
1391 #0: Find the message's author, because we need it for private discussion
1392 author_id = kwargs.get('author_id')
1393 if author_id is None: # keep False values
1394 author_id = self.pool.get('mail.message')._get_default_author(cr, uid, context=context)
1396 # 1: Handle content subtype: if plaintext, converto into HTML
1397 if content_subtype == 'plaintext':
1398 body = tools.plaintext2html(body)
1400 # 2: Private message: add recipients (recipients and author of parent message) - current author
1401 # + legacy-code management (! we manage only 4 and 6 commands)
1403 kwargs_partner_ids = kwargs.pop('partner_ids', [])
1404 for partner_id in kwargs_partner_ids:
1405 if isinstance(partner_id, (list, tuple)) and partner_id[0] == 4 and len(partner_id) == 2:
1406 partner_ids.add(partner_id[1])
1407 if isinstance(partner_id, (list, tuple)) and partner_id[0] == 6 and len(partner_id) == 3:
1408 partner_ids |= set(partner_id[2])
1409 elif isinstance(partner_id, (int, long)):
1410 partner_ids.add(partner_id)
1412 pass # we do not manage anything else
1413 if parent_id and not model:
1414 parent_message = mail_message.browse(cr, uid, parent_id, context=context)
1415 private_followers = set([partner.id for partner in parent_message.partner_ids])
1416 if parent_message.author_id:
1417 private_followers.add(parent_message.author_id.id)
1418 private_followers -= set([author_id])
1419 partner_ids |= private_followers
1422 # - HACK TDE FIXME: Chatter: attachments linked to the document (not done JS-side), load the message
1423 attachment_ids = self._message_preprocess_attachments(cr, uid, attachments, kwargs.pop('attachment_ids', []), model, thread_id, context)
1425 # 4: mail.message.subtype
1428 if '.' not in subtype:
1429 subtype = 'mail.%s' % subtype
1430 ref = self.pool.get('ir.model.data').get_object_reference(cr, uid, *subtype.split('.'))
1431 subtype_id = ref and ref[1] or False
1433 # automatically subscribe recipients if asked to
1434 if context.get('mail_post_autofollow') and thread_id and partner_ids:
1435 partner_to_subscribe = partner_ids
1436 if context.get('mail_post_autofollow_partner_ids'):
1437 partner_to_subscribe = filter(lambda item: item in context.get('mail_post_autofollow_partner_ids'), partner_ids)
1438 self.message_subscribe(cr, uid, [thread_id], list(partner_to_subscribe), context=context)
1440 # _mail_flat_thread: automatically set free messages to the first posted message
1441 if self._mail_flat_thread and not parent_id and thread_id:
1442 message_ids = mail_message.search(cr, uid, ['&', ('res_id', '=', thread_id), ('model', '=', model)], context=context, order="id ASC", limit=1)
1443 parent_id = message_ids and message_ids[0] or False
1444 # 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
1446 message_ids = mail_message.search(cr, SUPERUSER_ID, [('id', '=', parent_id), ('parent_id', '!=', False)], context=context)
1447 # avoid loops when finding ancestors
1450 message = mail_message.browse(cr, SUPERUSER_ID, message_ids[0], context=context)
1451 while (message.parent_id and message.parent_id.id not in processed_list):
1452 processed_list.append(message.parent_id.id)
1453 message = message.parent_id
1454 parent_id = message.id
1458 'author_id': author_id,
1460 'res_id': thread_id or False,
1462 'subject': subject or False,
1464 'parent_id': parent_id,
1465 'attachment_ids': attachment_ids,
1466 'subtype_id': subtype_id,
1467 'partner_ids': [(4, pid) for pid in partner_ids],
1470 # Avoid warnings about non-existing fields
1471 for x in ('from', 'to', 'cc'):
1474 # Create and auto subscribe the author
1475 msg_id = mail_message.create(cr, uid, values, context=context)
1476 message = mail_message.browse(cr, uid, msg_id, context=context)
1477 if message.author_id and thread_id and type != 'notification' and not context.get('mail_create_nosubscribe'):
1478 self.message_subscribe(cr, uid, [thread_id], [message.author_id.id], context=context)
1481 #------------------------------------------------------
1483 #------------------------------------------------------
1485 def message_get_subscription_data(self, cr, uid, ids, user_pid=None, context=None):
1486 """ Wrapper to get subtypes data. """
1487 return self._get_subscription_data(cr, uid, ids, None, None, user_pid=user_pid, context=context)
1489 def message_subscribe_users(self, cr, uid, ids, user_ids=None, subtype_ids=None, context=None):
1490 """ Wrapper on message_subscribe, using users. If user_ids is not
1491 provided, subscribe uid instead. """
1492 if user_ids is None:
1494 partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
1495 return self.message_subscribe(cr, uid, ids, partner_ids, subtype_ids=subtype_ids, context=context)
1497 def message_subscribe(self, cr, uid, ids, partner_ids, subtype_ids=None, context=None):
1498 """ Add partners to the records followers. """
1499 mail_followers_obj = self.pool.get('mail.followers')
1500 subtype_obj = self.pool.get('mail.message.subtype')
1502 user_pid = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1503 if set(partner_ids) == set([user_pid]):
1505 self.check_access_rights(cr, uid, 'read')
1506 self.check_access_rule(cr, uid, ids, 'read')
1507 except (osv.except_osv, orm.except_orm):
1510 self.check_access_rights(cr, uid, 'write')
1511 self.check_access_rule(cr, uid, ids, 'write')
1513 existing_pids_dict = {}
1514 fol_ids = mail_followers_obj.search(cr, SUPERUSER_ID, ['&', '&', ('res_model', '=', self._name), ('res_id', 'in', ids), ('partner_id', 'in', partner_ids)])
1515 for fol in mail_followers_obj.browse(cr, SUPERUSER_ID, fol_ids, context=context):
1516 existing_pids_dict.setdefault(fol.res_id, set()).add(fol.partner_id.id)
1518 # subtype_ids specified: update already subscribed partners
1519 if subtype_ids and fol_ids:
1520 mail_followers_obj.write(cr, SUPERUSER_ID, fol_ids, {'subtype_ids': [(6, 0, subtype_ids)]}, context=context)
1521 # subtype_ids not specified: do not update already subscribed partner, fetch default subtypes for new partners
1522 if subtype_ids is None:
1523 subtype_ids = subtype_obj.search(
1525 ('default', '=', True), '|', ('res_model', '=', self._name), ('res_model', '=', False)], context=context)
1528 existing_pids = existing_pids_dict.get(id, set())
1529 new_pids = set(partner_ids) - existing_pids
1531 # subscribe new followers
1532 for new_pid in new_pids:
1533 mail_followers_obj.create(
1535 'res_model': self._name,
1537 'partner_id': new_pid,
1538 'subtype_ids': [(6, 0, subtype_ids)],
1543 def message_unsubscribe_users(self, cr, uid, ids, user_ids=None, context=None):
1544 """ Wrapper on message_subscribe, using users. If user_ids is not
1545 provided, unsubscribe uid instead. """
1546 if user_ids is None:
1548 partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
1549 return self.message_unsubscribe(cr, uid, ids, partner_ids, context=context)
1551 def message_unsubscribe(self, cr, uid, ids, partner_ids, context=None):
1552 """ Remove partners from the records followers. """
1553 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
1554 if set(partner_ids) == set([user_pid]):
1555 self.check_access_rights(cr, uid, 'read')
1556 self.check_access_rule(cr, uid, ids, 'read')
1558 self.check_access_rights(cr, uid, 'write')
1559 self.check_access_rule(cr, uid, ids, 'write')
1560 fol_obj = self.pool['mail.followers']
1561 fol_ids = fol_obj.search(
1563 ('res_model', '=', self._name),
1564 ('res_id', 'in', ids),
1565 ('partner_id', 'in', partner_ids)
1567 return fol_obj.unlink(cr, SUPERUSER_ID, fol_ids, context=context)
1569 def _message_get_auto_subscribe_fields(self, cr, uid, updated_fields, auto_follow_fields=['user_id'], context=None):
1570 """ Returns the list of relational fields linking to res.users that should
1571 trigger an auto subscribe. The default list checks for the fields
1573 - linking to res.users
1574 - with track_visibility set
1575 In OpenERP V7, this is sufficent for all major addon such as opportunity,
1576 project, issue, recruitment, sale.
1577 Override this method if a custom behavior is needed about fields
1578 that automatically subscribe users.
1581 for name, column_info in self._all_columns.items():
1582 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':
1583 user_field_lst.append(name)
1584 return user_field_lst
1586 def message_auto_subscribe(self, cr, uid, ids, updated_fields, context=None, values=None):
1587 """ Handle auto subscription. Two methods for auto subscription exist:
1589 - tracked res.users relational fields, such as user_id fields. Those fields
1590 must be relation fields toward a res.users record, and must have the
1591 track_visilibity attribute set.
1592 - using subtypes parent relationship: check if the current model being
1593 modified has an header record (such as a project for tasks) whose followers
1594 can be added as followers of the current records. Example of structure
1595 with project and task:
1597 - st_project_1.parent_id = st_task_1
1598 - st_project_1.res_model = 'project.project'
1599 - st_project_1.relation_field = 'project_id'
1600 - st_task_1.model = 'project.task'
1602 :param list updated_fields: list of updated fields to track
1603 :param dict values: updated values; if None, the first record will be browsed
1604 to get the values. Added after releasing 7.0, therefore
1605 not merged with updated_fields argumment.
1607 subtype_obj = self.pool.get('mail.message.subtype')
1608 follower_obj = self.pool.get('mail.followers')
1609 new_followers = dict()
1611 # fetch auto_follow_fields: res.users relation fields whose changes are tracked for subscription
1612 user_field_lst = self._message_get_auto_subscribe_fields(cr, uid, updated_fields, context=context)
1614 # fetch header subtypes
1615 header_subtype_ids = subtype_obj.search(cr, uid, ['|', ('res_model', '=', False), ('parent_id.res_model', '=', self._name)], context=context)
1616 subtypes = subtype_obj.browse(cr, uid, header_subtype_ids, context=context)
1618 # if no change in tracked field or no change in tracked relational field: quit
1619 relation_fields = set([subtype.relation_field for subtype in subtypes if subtype.relation_field is not False])
1620 if not any(relation in updated_fields for relation in relation_fields) and not user_field_lst:
1623 # legacy behavior: if values is not given, compute the values by browsing
1624 # @TDENOTE: remove me in 8.0
1626 record = self.browse(cr, uid, ids[0], context=context)
1627 for updated_field in updated_fields:
1628 field_value = getattr(record, updated_field)
1629 if isinstance(field_value, browse_record):
1630 field_value = field_value.id
1631 elif isinstance(field_value, browse_null):
1633 values[updated_field] = field_value
1635 # find followers of headers, update structure for new followers
1637 for subtype in subtypes:
1638 if subtype.relation_field and values.get(subtype.relation_field):
1639 headers.add((subtype.res_model, values.get(subtype.relation_field)))
1641 header_domain = ['|'] * (len(headers) - 1)
1642 for header in headers:
1643 header_domain += ['&', ('res_model', '=', header[0]), ('res_id', '=', header[1])]
1644 header_follower_ids = follower_obj.search(
1649 for header_follower in follower_obj.browse(cr, SUPERUSER_ID, header_follower_ids, context=context):
1650 for subtype in header_follower.subtype_ids:
1651 if subtype.parent_id and subtype.parent_id.res_model == self._name:
1652 new_followers.setdefault(header_follower.partner_id.id, set()).add(subtype.parent_id.id)
1653 elif subtype.res_model is False:
1654 new_followers.setdefault(header_follower.partner_id.id, set()).add(subtype.id)
1656 # add followers coming from res.users relational fields that are tracked
1657 user_ids = [values[name] for name in user_field_lst if values.get(name)]
1658 user_pids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, SUPERUSER_ID, user_ids, context=context)]
1659 for partner_id in user_pids:
1660 new_followers.setdefault(partner_id, None)
1662 for pid, subtypes in new_followers.items():
1663 subtypes = list(subtypes) if subtypes is not None else None
1664 self.message_subscribe(cr, uid, ids, [pid], subtypes, context=context)
1666 # find first email message, set it as unread for auto_subscribe fields for them to have a notification
1668 for record_id in ids:
1669 message_obj = self.pool.get('mail.message')
1670 msg_ids = message_obj.search(cr, SUPERUSER_ID, [
1671 ('model', '=', self._name),
1672 ('res_id', '=', record_id),
1673 ('type', '=', 'email')], limit=1, context=context)
1675 msg_ids = message_obj.search(cr, SUPERUSER_ID, [
1676 ('model', '=', self._name),
1677 ('res_id', '=', record_id)], limit=1, context=context)
1679 self.pool.get('mail.notification')._notify(cr, uid, msg_ids[0], partners_to_notify=user_pids, context=context)
1683 #------------------------------------------------------
1685 #------------------------------------------------------
1687 def message_mark_as_unread(self, cr, uid, ids, context=None):
1688 """ Set as unread. """
1689 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1691 UPDATE mail_notification SET
1694 message_id IN (SELECT id from mail_message where res_id=any(%s) and model=%s limit 1) and
1696 ''', (ids, self._name, partner_id))
1699 def message_mark_as_read(self, cr, uid, ids, context=None):
1700 """ Set as read. """
1701 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1703 UPDATE mail_notification SET
1706 message_id IN (SELECT id FROM mail_message WHERE res_id=ANY(%s) AND model=%s) AND
1708 ''', (ids, self._name, partner_id))
1711 #------------------------------------------------------
1713 #------------------------------------------------------
1715 def get_suggested_thread(self, cr, uid, removed_suggested_threads=None, context=None):
1716 """Return a list of suggested threads, sorted by the numbers of followers"""
1720 # TDE HACK: originally by MAT from portal/mail_mail.py but not working until the inheritance graph bug is not solved in trunk
1721 # TDE FIXME: relocate in portal when it won't be necessary to reload the hr.employee model in an additional bridge module
1722 if self.pool['res.groups']._all_columns.get('is_portal'):
1723 user = self.pool.get('res.users').browse(cr, SUPERUSER_ID, uid, context=context)
1724 if any(group.is_portal for group in user.groups_id):
1728 if removed_suggested_threads is None:
1729 removed_suggested_threads = []
1731 thread_ids = self.search(cr, uid, [('id', 'not in', removed_suggested_threads), ('message_is_follower', '=', False)], context=context)
1732 for thread in self.browse(cr, uid, thread_ids, context=context):
1735 'popularity': len(thread.message_follower_ids),
1736 'name': thread.name,
1737 'image_small': thread.image_small
1739 threads.append(data)
1740 return sorted(threads, key=lambda x: (x['popularity'], x['id']), reverse=True)[:3]