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 ##############################################################################
27 import simplejson as json
30 from lxml import etree
35 from email.message import Message
37 from openerp import tools
38 from openerp import SUPERUSER_ID
39 from openerp.addons.mail.mail_message import decode
40 from openerp.osv import fields, osv, orm
41 from openerp.osv.orm import browse_record, browse_null
42 from openerp.tools.safe_eval import safe_eval as eval
43 from openerp.tools.translate import _
45 _logger = logging.getLogger(__name__)
48 def decode_header(message, header, separator=' '):
49 return separator.join(map(decode, filter(None, message.get_all(header, []))))
52 class mail_thread(osv.AbstractModel):
53 ''' mail_thread model is meant to be inherited by any model that needs to
54 act as a discussion topic on which messages can be attached. Public
55 methods are prefixed with ``message_`` in order to avoid name
56 collisions with methods of the models that will inherit from this class.
58 ``mail.thread`` defines fields used to handle and display the
59 communication history. ``mail.thread`` also manages followers of
60 inheriting classes. All features and expected behavior are managed
61 by mail.thread. Widgets has been designed for the 7.0 and following
64 Inheriting classes are not required to implement any method, as the
65 default implementation will work for any model. However it is common
66 to override at least the ``message_new`` and ``message_update``
67 methods (calling ``super``) to add model-specific behavior at
68 creation and update of a thread when processing incoming emails.
71 - _mail_flat_thread: if set to True, all messages without parent_id
72 are automatically attached to the first message posted on the
73 ressource. If set to False, the display of Chatter is done using
74 threads, and no parent_id is automatically set.
77 _description = 'Email Thread'
78 _mail_flat_thread = True
79 _mail_post_access = 'write'
81 # Automatic logging system if mail installed
84 # 'module.subtype_xml': lambda self, cr, uid, obj, context=None: obj[state] == done,
85 # 'module.subtype_xml2': lambda self, cr, uid, obj, context=None: obj[state] != done,
92 # :param string field: field name
93 # :param module.subtype_xml: xml_id of a mail.message.subtype (i.e. mail.mt_comment)
94 # :param obj: is a browse_record
95 # :param function lambda: returns whether the tracking should record using this subtype
98 def get_empty_list_help(self, cr, uid, help, context=None):
99 """ Override of BaseModel.get_empty_list_help() to generate an help message
100 that adds alias information. """
101 model = context.get('empty_list_help_model')
102 res_id = context.get('empty_list_help_id')
103 ir_config_parameter = self.pool.get("ir.config_parameter")
104 catchall_domain = ir_config_parameter.get_param(cr, uid, "mail.catchall.domain", context=context)
105 document_name = context.get('empty_list_help_document_name', _('document'))
108 if catchall_domain and model and res_id: # specific res_id -> find its alias (i.e. section_id specified)
109 object_id = self.pool.get(model).browse(cr, uid, res_id, context=context)
110 # check that the alias effectively creates new records
111 if object_id.alias_id and object_id.alias_id.alias_name and \
112 object_id.alias_id.alias_model_id and \
113 object_id.alias_id.alias_model_id.model == self._name and \
114 object_id.alias_id.alias_force_thread_id == 0:
115 alias = object_id.alias_id
116 elif catchall_domain and model: # no specific res_id given -> generic help message, take an example alias (i.e. alias of some section_id)
117 alias_obj = self.pool.get('mail.alias')
118 alias_ids = alias_obj.search(cr, uid, [("alias_parent_model_id.model", "=", model), ("alias_name", "!=", False), ('alias_force_thread_id', '=', False)], context=context, order='id ASC')
119 if alias_ids and len(alias_ids) == 1:
120 alias = alias_obj.browse(cr, uid, alias_ids[0], context=context)
123 alias_email = alias.name_get()[0][1]
124 return _("""<p class='oe_view_nocontent_create'>
125 Click here to add new %(document)s or send an email to: <a href='mailto:%(email)s'>%(email)s</a>
129 'document': document_name,
130 'email': alias_email,
131 'static_help': help or ''
134 if document_name != 'document' and help and help.find("oe_view_nocontent_create") == -1:
135 return _("<p class='oe_view_nocontent_create'>Click here to add new %(document)s</p>%(static_help)s") % {
136 'document': document_name,
137 'static_help': help or '',
142 def _get_message_data(self, cr, uid, ids, name, args, context=None):
144 - message_unread: has uid unread message for the document
145 - message_summary: html snippet summarizing the Chatter for kanban views """
146 res = dict((id, dict(message_unread=False, message_unread_count=0, message_summary=' ')) for id in ids)
147 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
149 # search for unread messages, directly in SQL to improve performances
150 cr.execute(""" SELECT m.res_id FROM mail_message m
151 RIGHT JOIN mail_notification n
152 ON (n.message_id = m.id AND n.partner_id = %s AND (n.read = False or n.read IS NULL))
153 WHERE m.model = %s AND m.res_id in %s""",
154 (user_pid, self._name, tuple(ids),))
155 for result in cr.fetchall():
156 res[result[0]]['message_unread'] = True
157 res[result[0]]['message_unread_count'] += 1
160 if res[id]['message_unread_count']:
161 title = res[id]['message_unread_count'] > 1 and _("You have %d unread messages") % res[id]['message_unread_count'] or _("You have one unread message")
162 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"))
163 res[id].pop('message_unread_count', None)
166 def read_followers_data(self, cr, uid, follower_ids, context=None):
168 technical_group = self.pool.get('ir.model.data').get_object(cr, uid, 'base', 'group_no_one', context=context)
169 for follower in self.pool.get('res.partner').browse(cr, uid, follower_ids, context=context):
170 is_editable = uid in map(lambda x: x.id, technical_group.users)
171 is_uid = uid in map(lambda x: x.id, follower.user_ids)
174 {'is_editable': is_editable, 'is_uid': is_uid},
179 def _get_subscription_data(self, cr, uid, ids, name, args, user_pid=None, context=None):
181 - message_subtype_data: data about document subtypes: which are
182 available, which are followed if any """
183 res = dict((id, dict(message_subtype_data='')) for id in ids)
185 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
187 # find current model subtypes, add them to a dictionary
188 subtype_obj = self.pool.get('mail.message.subtype')
189 subtype_ids = subtype_obj.search(cr, uid, ['|', ('res_model', '=', self._name), ('res_model', '=', False)], context=context)
190 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))
192 res[id]['message_subtype_data'] = subtype_dict.copy()
194 # find the document followers, update the data
195 fol_obj = self.pool.get('mail.followers')
196 fol_ids = fol_obj.search(cr, uid, [
197 ('partner_id', '=', user_pid),
198 ('res_id', 'in', ids),
199 ('res_model', '=', self._name),
201 for fol in fol_obj.browse(cr, uid, fol_ids, context=context):
202 thread_subtype_dict = res[fol.res_id]['message_subtype_data']
203 for subtype in [st for st in fol.subtype_ids if st.name in thread_subtype_dict]:
204 thread_subtype_dict[subtype.name]['followed'] = True
205 res[fol.res_id]['message_subtype_data'] = thread_subtype_dict
209 def _search_message_unread(self, cr, uid, obj=None, name=None, domain=None, context=None):
210 return [('message_ids.to_read', '=', True)]
212 def _get_followers(self, cr, uid, ids, name, arg, context=None):
213 fol_obj = self.pool.get('mail.followers')
214 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids)])
215 res = dict((id, dict(message_follower_ids=[], message_is_follower=False)) for id in ids)
216 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
217 for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids):
218 res[fol.res_id]['message_follower_ids'].append(fol.partner_id.id)
219 if fol.partner_id.id == user_pid:
220 res[fol.res_id]['message_is_follower'] = True
223 def _set_followers(self, cr, uid, id, name, value, arg, context=None):
226 partner_obj = self.pool.get('res.partner')
227 fol_obj = self.pool.get('mail.followers')
229 # read the old set of followers, and determine the new set of followers
230 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', '=', id)])
231 old = set(fol.partner_id.id for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids))
234 for command in value or []:
235 if isinstance(command, (int, long)):
237 elif command[0] == 0:
238 new.add(partner_obj.create(cr, uid, command[2], context=context))
239 elif command[0] == 1:
240 partner_obj.write(cr, uid, [command[1]], command[2], context=context)
242 elif command[0] == 2:
243 partner_obj.unlink(cr, uid, [command[1]], context=context)
244 new.discard(command[1])
245 elif command[0] == 3:
246 new.discard(command[1])
247 elif command[0] == 4:
249 elif command[0] == 5:
251 elif command[0] == 6:
252 new = set(command[2])
254 # remove partners that are no longer followers
255 self.message_unsubscribe(cr, uid, [id], list(old-new), context=context)
257 self.message_subscribe(cr, uid, [id], list(new-old), context=context)
259 def _search_followers(self, cr, uid, obj, name, args, context):
260 """Search function for message_follower_ids
262 Do not use with operator 'not in'. Use instead message_is_followers
264 fol_obj = self.pool.get('mail.followers')
266 for field, operator, value in args:
268 # TOFIX make it work with not in
269 assert operator != "not in", "Do not search message_follower_ids with 'not in'"
270 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('partner_id', operator, value)])
271 res_ids = [fol.res_id for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids)]
272 res.append(('id', 'in', res_ids))
275 def _search_is_follower(self, cr, uid, obj, name, args, context):
276 """Search function for message_is_follower"""
278 for field, operator, value in args:
280 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
281 if (operator == '=' and value) or (operator == '!=' and not value): # is a follower
282 res_ids = self.search(cr, uid, [('message_follower_ids', 'in', [partner_id])], context=context)
283 else: # is not a follower or unknown domain
284 mail_ids = self.search(cr, uid, [('message_follower_ids', 'in', [partner_id])], context=context)
285 res_ids = self.search(cr, uid, [('id', 'not in', mail_ids)], context=context)
286 res.append(('id', 'in', res_ids))
290 'message_is_follower': fields.function(_get_followers, type='boolean',
291 fnct_search=_search_is_follower, string='Is a Follower', multi='_get_followers,'),
292 'message_follower_ids': fields.function(_get_followers, fnct_inv=_set_followers,
293 fnct_search=_search_followers, type='many2many', priority=-10,
294 obj='res.partner', string='Followers', multi='_get_followers'),
295 'message_ids': fields.one2many('mail.message', 'res_id',
296 domain=lambda self: [('model', '=', self._name)],
299 help="Messages and communication history"),
300 'message_unread': fields.function(_get_message_data,
301 fnct_search=_search_message_unread, multi="_get_message_data",
302 type='boolean', string='Unread Messages',
303 help="If checked new messages require your attention."),
304 'message_summary': fields.function(_get_message_data, method=True,
305 type='text', string='Summary', multi="_get_message_data",
306 help="Holds the Chatter summary (number of messages, ...). "\
307 "This summary is directly in html format in order to "\
308 "be inserted in kanban views."),
311 def _get_user_chatter_options(self, cr, uid, context=None):
313 'display_log_button': False
315 group_ids = self.pool.get('res.users').browse(cr, uid, uid, context=context).groups_id
316 group_user_id = self.pool.get("ir.model.data").get_object_reference(cr, uid, 'base', 'group_user')[1]
317 is_employee = group_user_id in [group.id for group in group_ids]
319 options['display_log_button'] = True
322 def fields_view_get(self, cr, uid, view_id=None, view_type='form', context=None, toolbar=False, submenu=False):
323 res = super(mail_thread, self).fields_view_get(cr, uid, view_id=view_id, view_type=view_type, context=context, toolbar=toolbar, submenu=submenu)
324 if view_type == 'form':
325 doc = etree.XML(res['arch'])
326 for node in doc.xpath("//field[@name='message_ids']"):
327 options = json.loads(node.get('options', '{}'))
328 options.update(self._get_user_chatter_options(cr, uid, context=context))
329 node.set('options', json.dumps(options))
330 res['arch'] = etree.tostring(doc)
333 #------------------------------------------------------
334 # CRUD overrides for automatic subscription and logging
335 #------------------------------------------------------
337 def create(self, cr, uid, values, context=None):
338 """ Chatter override :
340 - subscribe followers of parent
341 - log a creation message
346 # subscribe uid unless asked not to
347 if not context.get('mail_create_nosubscribe'):
348 pid = self.pool['res.users'].browse(cr, SUPERUSER_ID, uid).partner_id.id
349 message_follower_ids = values.get('message_follower_ids') or [] # webclient can send None or False
350 message_follower_ids.append([4, pid])
351 values['message_follower_ids'] = message_follower_ids
352 # add operation to ignore access rule checking for subscription
353 context_operation = dict(context, operation='create')
355 context_operation = context
356 thread_id = super(mail_thread, self).create(cr, uid, values, context=context_operation)
358 # automatic logging unless asked not to (mainly for various testing purpose)
359 if not context.get('mail_create_nolog'):
360 self.message_post(cr, uid, thread_id, body=_('%s created') % (self._description), context=context)
362 # auto_subscribe: take values and defaults into account
363 create_values = dict(values)
364 for key, val in context.iteritems():
365 if key.startswith('default_'):
366 create_values[key[8:]] = val
367 self.message_auto_subscribe(cr, uid, [thread_id], create_values.keys(), context=context, values=create_values)
370 track_ctx = dict(context)
371 if 'lang' not in track_ctx:
372 track_ctx['lang'] = self.pool.get('res.users').browse(cr, uid, uid, context=context).lang
373 if not context.get('mail_notrack'):
374 tracked_fields = self._get_tracked_fields(cr, uid, values.keys(), context=track_ctx)
376 initial_values = {thread_id: dict((item, False) for item in tracked_fields)}
377 self.message_track(cr, uid, [thread_id], tracked_fields, initial_values, context=track_ctx)
380 def write(self, cr, uid, ids, values, context=None):
383 if isinstance(ids, (int, long)):
385 # Track initial values of tracked fields
386 track_ctx = dict(context)
387 if 'lang' not in track_ctx:
388 track_ctx['lang'] = self.pool.get('res.users').browse(cr, uid, uid, context=context).lang
389 tracked_fields = self._get_tracked_fields(cr, uid, values.keys(), context=track_ctx)
391 records = self.browse(cr, uid, ids, context=track_ctx)
392 initial_values = dict((this.id, dict((key, getattr(this, key)) for key in tracked_fields.keys())) for this in records)
394 # Perform write, update followers
395 result = super(mail_thread, self).write(cr, uid, ids, values, context=context)
396 self.message_auto_subscribe(cr, uid, ids, values.keys(), context=context, values=values)
398 if not context.get('mail_notrack'):
399 # Perform the tracking
400 tracked_fields = self._get_tracked_fields(cr, uid, values.keys(), context=context)
402 tracked_fields = None
404 self.message_track(cr, uid, ids, tracked_fields, initial_values, context=track_ctx)
407 def unlink(self, cr, uid, ids, context=None):
408 """ Override unlink to delete messages and followers. This cannot be
409 cascaded, because link is done through (res_model, res_id). """
410 msg_obj = self.pool.get('mail.message')
411 fol_obj = self.pool.get('mail.followers')
412 # delete messages and notifications
413 msg_ids = msg_obj.search(cr, uid, [('model', '=', self._name), ('res_id', 'in', ids)], context=context)
414 msg_obj.unlink(cr, uid, msg_ids, context=context)
416 res = super(mail_thread, self).unlink(cr, uid, ids, context=context)
418 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids)], context=context)
419 fol_obj.unlink(cr, SUPERUSER_ID, fol_ids, context=context)
422 def copy(self, cr, uid, id, default=None, context=None):
423 # avoid tracking multiple temporary changes during copy
424 context = dict(context or {}, mail_notrack=True)
426 default = default or {}
427 default['message_ids'] = []
428 default['message_follower_ids'] = []
429 return super(mail_thread, self).copy(cr, uid, id, default=default, context=context)
431 #------------------------------------------------------
432 # Automatically log tracked fields
433 #------------------------------------------------------
435 def _get_tracked_fields(self, cr, uid, updated_fields, context=None):
436 """ Return a structure of tracked fields for the current model.
437 :param list updated_fields: modified field names
438 :return list: a list of (field_name, column_info obj), containing
439 always tracked fields and modified on_change fields
442 for name, column_info in self._all_columns.items():
443 visibility = getattr(column_info.column, 'track_visibility', False)
444 if visibility == 'always' or (visibility == 'onchange' and name in updated_fields) or name in self._track:
448 return self.fields_get(cr, uid, lst, context=context)
450 def message_track(self, cr, uid, ids, tracked_fields, initial_values, context=None):
452 def convert_for_display(value, col_info):
453 if not value and col_info['type'] == 'boolean':
457 if col_info['type'] == 'many2one':
458 return value.name_get()[0][1]
459 if col_info['type'] == 'selection':
460 return dict(col_info['selection'])[value]
463 def format_message(message_description, tracked_values):
465 if message_description:
466 message = '<span>%s</span>' % message_description
467 for name, change in tracked_values.items():
468 message += '<div> • <b>%s</b>: ' % change.get('col_info')
469 if change.get('old_value'):
470 message += '%s → ' % change.get('old_value')
471 message += '%s</div>' % change.get('new_value')
474 if not tracked_fields:
477 for browse_record in self.browse(cr, uid, ids, context=context):
478 initial = initial_values[browse_record.id]
482 # generate tracked_values data structure: {'col_name': {col_info, new_value, old_value}}
483 for col_name, col_info in tracked_fields.items():
484 initial_value = initial[col_name]
485 record_value = getattr(browse_record, col_name)
487 if record_value == initial_value and getattr(self._all_columns[col_name].column, 'track_visibility', None) == 'always':
488 tracked_values[col_name] = dict(col_info=col_info['string'],
489 new_value=convert_for_display(record_value, col_info))
490 elif record_value != initial_value and (record_value or initial_value): # because browse null != False
491 if getattr(self._all_columns[col_name].column, 'track_visibility', None) in ['always', 'onchange']:
492 tracked_values[col_name] = dict(col_info=col_info['string'],
493 old_value=convert_for_display(initial_value, col_info),
494 new_value=convert_for_display(record_value, col_info))
495 if col_name in tracked_fields:
496 changes.add(col_name)
500 # find subtypes and post messages or log if no subtype found
502 for field, track_info in self._track.items():
503 if field not in changes:
505 for subtype, method in track_info.items():
506 if method(self, cr, uid, browse_record, context):
507 subtypes.append(subtype)
510 for subtype in subtypes:
511 subtype_rec = self.pool.get('ir.model.data').xmlid_to_object(cr, uid, subtype, context=context)
512 if not (subtype_rec and subtype_rec.exists()):
513 _logger.debug('subtype %s not found' % subtype)
515 message = format_message(subtype_rec.description if subtype_rec.description else subtype_rec.name, tracked_values)
516 self.message_post(cr, uid, browse_record.id, body=message, subtype=subtype, context=context)
519 message = format_message('', tracked_values)
520 self.message_post(cr, uid, browse_record.id, body=message, context=context)
523 #------------------------------------------------------
524 # mail.message wrappers and tools
525 #------------------------------------------------------
527 def _needaction_domain_get(self, cr, uid, context=None):
529 return [('message_unread', '=', True)]
532 def _garbage_collect_attachments(self, cr, uid, context=None):
533 """ Garbage collect lost mail attachments. Those are attachments
534 - linked to res_model 'mail.compose.message', the composer wizard
535 - with res_id 0, because they were created outside of an existing
536 wizard (typically user input through Chatter or reports
537 created on-the-fly by the templates)
538 - unused since at least one day (create_date and write_date)
540 limit_date = datetime.datetime.utcnow() - datetime.timedelta(days=1)
541 limit_date_str = datetime.datetime.strftime(limit_date, tools.DEFAULT_SERVER_DATETIME_FORMAT)
542 ir_attachment_obj = self.pool.get('ir.attachment')
543 attach_ids = ir_attachment_obj.search(cr, uid, [
544 ('res_model', '=', 'mail.compose.message'),
546 ('create_date', '<', limit_date_str),
547 ('write_date', '<', limit_date_str),
549 ir_attachment_obj.unlink(cr, uid, attach_ids, context=context)
552 def check_mail_message_access(self, cr, uid, mids, operation, model_obj=None, context=None):
553 """ mail.message check permission rules for related document. This method is
554 meant to be inherited in order to implement addons-specific behavior.
555 A common behavior would be to allow creating messages when having read
556 access rule on the document, for portal document such as issues. """
559 if hasattr(self, '_mail_post_access'):
560 create_allow = self._mail_post_access
562 create_allow = 'write'
564 if operation in ['write', 'unlink']:
565 check_operation = 'write'
566 elif operation == 'create' and create_allow in ['create', 'read', 'write', 'unlink']:
567 check_operation = create_allow
568 elif operation == 'create':
569 check_operation = 'write'
571 check_operation = operation
573 model_obj.check_access_rights(cr, uid, check_operation)
574 model_obj.check_access_rule(cr, uid, mids, check_operation, context=context)
576 def _get_formview_action(self, cr, uid, id, model=None, context=None):
577 """ Return an action to open the document. This method is meant to be
578 overridden in addons that want to give specific view ids for example.
580 :param int id: id of the document to open
581 :param string model: specific model that overrides self._name
584 'type': 'ir.actions.act_window',
585 'res_model': model or self._name,
588 'views': [(False, 'form')],
593 def _get_inbox_action_xml_id(self, cr, uid, context=None):
594 """ When redirecting towards the Inbox, choose which action xml_id has
595 to be fetched. This method is meant to be inherited, at least in portal
596 because portal users have a different Inbox action than classic users. """
597 return ('mail', 'action_mail_inbox_feeds')
599 def message_redirect_action(self, cr, uid, context=None):
600 """ For a given message, return an action that either
601 - opens the form view of the related document if model, res_id, and
602 read access to the document
603 - opens the Inbox with a default search on the conversation if model,
605 - opens the Inbox with context propagated
611 # default action is the Inbox action
612 self.pool.get('res.users').browse(cr, SUPERUSER_ID, uid, context=context)
613 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))
614 action = self.pool.get(act_model).read(cr, uid, act_id, [])
615 params = context.get('params')
616 msg_id = model = res_id = None
619 msg_id = params.get('message_id')
620 model = params.get('model')
621 res_id = params.get('res_id')
622 if not msg_id and not (model and res_id):
624 if msg_id and not (model and res_id):
625 msg = self.pool.get('mail.message').browse(cr, uid, msg_id, context=context)
627 model, res_id = msg.model, msg.res_id
629 # if model + res_id found: try to redirect to the document or fallback on the Inbox
631 model_obj = self.pool.get(model)
632 if model_obj.check_access_rights(cr, uid, 'read', raise_exception=False):
634 model_obj.check_access_rule(cr, uid, [res_id], 'read', context=context)
635 if not hasattr(model_obj, '_get_formview_action'):
636 action = self.pool.get('mail.thread')._get_formview_action(cr, uid, res_id, model=model, context=context)
638 action = model_obj._get_formview_action(cr, uid, res_id, context=context)
639 except (osv.except_osv, orm.except_orm):
643 'search_default_model': model,
644 'search_default_res_id': res_id,
649 #------------------------------------------------------
651 #------------------------------------------------------
653 def message_get_reply_to(self, cr, uid, ids, context=None):
654 """ Returns the preferred reply-to email address that is basically
655 the alias of the document, if it exists. """
656 if not self._inherits.get('mail.alias'):
657 return [False for id in ids]
658 return ["%s@%s" % (record['alias_name'], record['alias_domain'])
659 if record.get('alias_domain') and record.get('alias_name')
661 for record in self.read(cr, SUPERUSER_ID, ids, ['alias_name', 'alias_domain'], context=context)]
663 #------------------------------------------------------
665 #------------------------------------------------------
667 def message_capable_models(self, cr, uid, context=None):
668 """ Used by the plugin addon, based for plugin_outlook and others. """
670 for model_name in self.pool.obj_list():
671 model = self.pool[model_name]
672 if hasattr(model, "message_process") and hasattr(model, "message_post"):
673 ret_dict[model_name] = model._description
676 def _message_find_partners(self, cr, uid, message, header_fields=['From'], context=None):
677 """ Find partners related to some header fields of the message.
679 :param string message: an email.message instance """
680 s = ', '.join([decode(message.get(h)) for h in header_fields if message.get(h)])
681 return filter(lambda x: x, self._find_partner_from_emails(cr, uid, None, tools.email_split(s), context=context))
683 def message_route_verify(self, cr, uid, message, message_dict, route, update_author=True, assert_model=True, create_fallback=True, context=None):
684 """ Verify route validity. Check and rules:
685 1 - if thread_id -> check that document effectively exists; otherwise
686 fallback on a message_new by resetting thread_id
687 2 - check that message_update exists if thread_id is set; or at least
688 that message_new exist
689 [ - find author_id if udpate_author is set]
690 3 - if there is an alias, check alias_contact:
691 'followers' and thread_id:
692 check on target document that the author is in the followers
693 'followers' and alias_parent_thread_id:
694 check on alias parent document that the author is in the
696 'partners': check that author_id id set
699 assert isinstance(route, (list, tuple)), 'A route should be a list or a tuple'
700 assert len(route) == 5, 'A route should contain 5 elements: model, thread_id, custom_values, uid, alias record'
702 message_id = message.get('Message-Id')
703 email_from = decode_header(message, 'From')
704 author_id = message_dict.get('author_id')
705 model, thread_id, alias = route[0], route[1], route[4]
708 def _create_bounce_email():
709 mail_mail = self.pool.get('mail.mail')
710 mail_id = mail_mail.create(cr, uid, {
711 'body_html': '<div><p>Hello,</p>'
712 '<p>The following email sent to %s cannot be accepted because this is '
713 'a private email address. Only allowed people can contact us at this address.</p></div>'
714 '<blockquote>%s</blockquote>' % (message.get('to'), message_dict.get('body')),
715 'subject': 'Re: %s' % message.get('subject'),
716 'email_to': message.get('from'),
719 mail_mail.send(cr, uid, [mail_id], context=context)
722 _logger.warning('Routing mail with Message-Id %s: route %s: %s',
723 message_id, route, message)
726 if model and not model in self.pool:
728 assert model in self.pool, 'Routing: unknown target model %s' % model
729 _warn('unknown target model %s' % model)
732 model_pool = self.pool[model]
734 # Private message: should not contain any thread_id
735 if not model and thread_id:
738 raise ValueError('Routing: posting a message without model should be with a null res_id (private message), received %s.' % thread_id)
739 _warn('posting a message without model should be with a null res_id (private message), received %s resetting thread_id' % thread_id)
741 # Private message: should have a parent_id (only answers)
742 if not model and not message_dict.get('parent_id'):
744 if not message_dict.get('parent_id'):
745 raise ValueError('Routing: posting a message without model should be with a parent_id (private mesage).')
746 _warn('posting a message without model should be with a parent_id (private mesage), skipping')
749 # Existing Document: check if exists; if not, fallback on create if allowed
750 if thread_id and not model_pool.exists(cr, uid, thread_id):
752 _warn('reply to missing document (%s,%s), fall back on new document creation' % (model, thread_id))
755 assert model_pool.exists(cr, uid, thread_id), 'Routing: reply to missing document (%s,%s)' % (model, thread_id)
757 _warn('reply to missing document (%s,%s), skipping' % (model, thread_id))
760 # Existing Document: check model accepts the mailgateway
761 if thread_id and model and not hasattr(model_pool, 'message_update'):
763 _warn('model %s does not accept document update, fall back on document creation' % model)
766 assert hasattr(model_pool, 'message_update'), 'Routing: model %s does not accept document update, crashing' % model
768 _warn('model %s does not accept document update, skipping' % model)
771 # New Document: check model accepts the mailgateway
772 if not thread_id and model and not hasattr(model_pool, 'message_new'):
774 if not hasattr(model_pool, 'message_new'):
776 'Model %s does not accept document creation, crashing' % model
778 _warn('model %s does not accept document creation, skipping' % model)
781 # Update message author if asked
782 # We do it now because we need it for aliases (contact settings)
783 if not author_id and update_author:
784 author_ids = self._find_partner_from_emails(cr, uid, thread_id, [email_from], model=model, context=context)
786 author_id = author_ids[0]
787 message_dict['author_id'] = author_id
789 # Alias: check alias_contact settings
790 if alias and alias.alias_contact == 'followers' and (thread_id or alias.alias_parent_thread_id):
792 obj = self.pool[model].browse(cr, uid, thread_id, context=context)
794 obj = self.pool[alias.alias_parent_model_id.model].browse(cr, uid, alias.alias_parent_thread_id, context=context)
795 if not author_id or not author_id in [fol.id for fol in obj.message_follower_ids]:
796 _warn('alias %s restricted to internal followers, skipping' % alias.alias_name)
797 _create_bounce_email()
799 elif alias and alias.alias_contact == 'partners' and not author_id:
800 _warn('alias %s does not accept unknown author, skipping' % alias.alias_name)
801 _create_bounce_email()
804 return (model, thread_id, route[2], route[3], route[4])
806 def message_route(self, cr, uid, message, message_dict, model=None, thread_id=None,
807 custom_values=None, context=None):
808 """Attempt to figure out the correct target model, thread_id,
809 custom_values and user_id to use for an incoming message.
810 Multiple values may be returned, if a message had multiple
811 recipients matching existing mail.aliases, for example.
813 The following heuristics are used, in this order:
814 1. If the message replies to an existing thread_id, and
815 properly contains the thread model in the 'In-Reply-To'
816 header, use this model/thread_id pair, and ignore
817 custom_value (not needed as no creation will take place)
818 2. Look for a mail.alias entry matching the message
819 recipient, and use the corresponding model, thread_id,
820 custom_values and user_id.
821 3. Fallback to the ``model``, ``thread_id`` and ``custom_values``
823 4. If all the above fails, raise an exception.
825 :param string message: an email.message instance
826 :param dict message_dict: dictionary holding message variables
827 :param string model: the fallback model to use if the message
828 does not match any of the currently configured mail aliases
829 (may be None if a matching alias is supposed to be present)
830 :type dict custom_values: optional dictionary of default field values
831 to pass to ``message_new`` if a new record needs to be created.
832 Ignored if the thread record already exists, and also if a
833 matching mail.alias was found (aliases define their own defaults)
834 :param int thread_id: optional ID of the record/thread from ``model``
835 to which this mail should be attached. Only used if the message
836 does not reply to an existing thread and does not match any mail alias.
837 :return: list of [model, thread_id, custom_values, user_id, alias]
839 :raises: ValueError, TypeError
841 if not isinstance(message, Message):
842 raise TypeError('message must be an email.message.Message at this point')
843 mail_msg_obj = self.pool['mail.message']
844 fallback_model = model
846 # Get email.message.Message variables for future processing
847 message_id = message.get('Message-Id')
848 email_from = decode_header(message, 'From')
849 email_to = decode_header(message, 'To')
850 references = decode_header(message, 'References')
851 in_reply_to = decode_header(message, 'In-Reply-To')
852 thread_references = references or in_reply_to
854 # 1. message is a reply to an existing message (exact match of message_id)
855 msg_references = thread_references.split()
856 mail_message_ids = mail_msg_obj.search(cr, uid, [('message_id', 'in', msg_references)], context=context)
858 original_msg = mail_msg_obj.browse(cr, SUPERUSER_ID, mail_message_ids[0], context=context)
859 model, thread_id = original_msg.model, original_msg.res_id
861 'Routing mail from %s to %s with Message-Id %s: direct reply to msg: model: %s, thread_id: %s, custom_values: %s, uid: %s',
862 email_from, email_to, message_id, model, thread_id, custom_values, uid)
863 route = self.message_route_verify(
864 cr, uid, message, message_dict,
865 (model, thread_id, custom_values, uid, None),
866 update_author=True, assert_model=True, create_fallback=True, context=context)
867 return route and [route] or []
869 # 2. message is a reply to an existign thread (6.1 compatibility)
870 ref_match = thread_references and tools.reference_re.search(thread_references)
872 thread_id = int(ref_match.group(1))
873 model = ref_match.group(2) or fallback_model
874 if thread_id and model in self.pool:
875 model_obj = self.pool[model]
876 compat_mail_msg_ids = mail_msg_obj.search(
878 ('message_id', '=', False),
879 ('model', '=', model),
880 ('res_id', '=', thread_id),
882 if compat_mail_msg_ids and model_obj.exists(cr, uid, thread_id) and hasattr(model_obj, 'message_update'):
884 'Routing mail from %s to %s with Message-Id %s: direct thread reply (compat-mode) to model: %s, thread_id: %s, custom_values: %s, uid: %s',
885 email_from, email_to, message_id, model, thread_id, custom_values, uid)
886 route = self.message_route_verify(
887 cr, uid, message, message_dict,
888 (model, thread_id, custom_values, uid, None),
889 update_author=True, assert_model=True, create_fallback=True, context=context)
890 return route and [route] or []
892 # 2. Reply to a private message
894 mail_message_ids = mail_msg_obj.search(cr, uid, [
895 ('message_id', '=', in_reply_to),
896 '!', ('message_id', 'ilike', 'reply_to')
897 ], limit=1, context=context)
899 mail_message = mail_msg_obj.browse(cr, uid, mail_message_ids[0], context=context)
900 _logger.info('Routing mail from %s to %s with Message-Id %s: direct reply to a private message: %s, custom_values: %s, uid: %s',
901 email_from, email_to, message_id, mail_message.id, custom_values, uid)
902 route = self.message_route_verify(cr, uid, message, message_dict,
903 (mail_message.model, mail_message.res_id, custom_values, uid, None),
904 update_author=True, assert_model=True, create_fallback=True, context=context)
905 return route and [route] or []
907 # 3. Look for a matching mail.alias entry
908 # Delivered-To is a safe bet in most modern MTAs, but we have to fallback on To + Cc values
909 # for all the odd MTAs out there, as there is no standard header for the envelope's `rcpt_to` value.
911 ','.join([decode_header(message, 'Delivered-To'),
912 decode_header(message, 'To'),
913 decode_header(message, 'Cc'),
914 decode_header(message, 'Resent-To'),
915 decode_header(message, 'Resent-Cc')])
916 local_parts = [e.split('@')[0] for e in tools.email_split(rcpt_tos)]
918 mail_alias = self.pool.get('mail.alias')
919 alias_ids = mail_alias.search(cr, uid, [('alias_name', 'in', local_parts)])
922 for alias in mail_alias.browse(cr, uid, alias_ids, context=context):
923 user_id = alias.alias_user_id.id
925 # TDE note: this could cause crashes, because no clue that the user
926 # that send the email has the right to create or modify a new document
927 # Fallback on user_id = uid
928 # Note: recognized partners will be added as followers anyway
929 # user_id = self._message_find_user_id(cr, uid, message, context=context)
931 _logger.info('No matching user_id for the alias %s', alias.alias_name)
932 route = (alias.alias_model_id.model, alias.alias_force_thread_id, eval(alias.alias_defaults), user_id, alias)
933 _logger.info('Routing mail from %s to %s with Message-Id %s: direct alias match: %r',
934 email_from, email_to, message_id, route)
935 route = self.message_route_verify(cr, uid, message, message_dict, route,
936 update_author=True, assert_model=True, create_fallback=True, context=context)
941 # 4. Fallback to the provided parameters, if they work
943 # Legacy: fallback to matching [ID] in the Subject
944 match = tools.res_re.search(decode_header(message, 'Subject'))
945 thread_id = match and match.group(1)
946 # Convert into int (bug spotted in 7.0 because of str)
948 thread_id = int(thread_id)
951 _logger.info('Routing mail from %s to %s with Message-Id %s: fallback to model:%s, thread_id:%s, custom_values:%s, uid:%s',
952 email_from, email_to, message_id, fallback_model, thread_id, custom_values, uid)
953 route = self.message_route_verify(cr, uid, message, message_dict,
954 (fallback_model, thread_id, custom_values, uid, None),
955 update_author=True, assert_model=True, context=context)
959 # AssertionError if no routes found and if no bounce occured
961 'No possible route found for incoming message from %s to %s (Message-Id %s:). '
962 'Create an appropriate mail.alias or force the destination model.' %
963 (email_from, email_to, message_id)
966 def message_route_process(self, cr, uid, message, message_dict, routes, context=None):
967 # postpone setting message_dict.partner_ids after message_post, to avoid double notifications
968 partner_ids = message_dict.pop('partner_ids', [])
970 for model, thread_id, custom_values, user_id, alias in routes:
971 if self._name == 'mail.thread':
972 context.update({'thread_model': model})
974 model_pool = self.pool[model]
975 if not (thread_id and hasattr(model_pool, 'message_update') or hasattr(model_pool, 'message_new')):
977 "Undeliverable mail with Message-Id %s, model %s does not accept incoming emails" %
978 (message_dict['message_id'], model)
981 # disabled subscriptions during message_new/update to avoid having the system user running the
982 # email gateway become a follower of all inbound messages
983 nosub_ctx = dict(context, mail_create_nosubscribe=True, mail_create_nolog=True)
984 if thread_id and hasattr(model_pool, 'message_update'):
985 model_pool.message_update(cr, user_id, [thread_id], message_dict, context=nosub_ctx)
987 thread_id = model_pool.message_new(cr, user_id, message_dict, custom_values, context=nosub_ctx)
990 raise ValueError("Posting a message without model should be with a null res_id, to create a private message.")
991 model_pool = self.pool.get('mail.thread')
992 if not hasattr(model_pool, 'message_post'):
993 context['thread_model'] = model
994 model_pool = self.pool['mail.thread']
995 new_msg_id = model_pool.message_post(cr, uid, [thread_id], context=context, subtype='mail.mt_comment', **message_dict)
998 # postponed after message_post, because this is an external message and we don't want to create
999 # duplicate emails due to notifications
1000 self.pool.get('mail.message').write(cr, uid, [new_msg_id], {'partner_ids': partner_ids}, context=context)
1003 def message_process(self, cr, uid, model, message, custom_values=None,
1004 save_original=False, strip_attachments=False,
1005 thread_id=None, context=None):
1006 """ Process an incoming RFC2822 email message, relying on
1007 ``mail.message.parse()`` for the parsing operation,
1008 and ``message_route()`` to figure out the target model.
1010 Once the target model is known, its ``message_new`` method
1011 is called with the new message (if the thread record did not exist)
1012 or its ``message_update`` method (if it did).
1014 There is a special case where the target model is False: a reply
1015 to a private message. In this case, we skip the message_new /
1016 message_update step, to just post a new message using mail_thread
1019 :param string model: the fallback model to use if the message
1020 does not match any of the currently configured mail aliases
1021 (may be None if a matching alias is supposed to be present)
1022 :param message: source of the RFC2822 message
1023 :type message: string or xmlrpclib.Binary
1024 :type dict custom_values: optional dictionary of field values
1025 to pass to ``message_new`` if a new record needs to be created.
1026 Ignored if the thread record already exists, and also if a
1027 matching mail.alias was found (aliases define their own defaults)
1028 :param bool save_original: whether to keep a copy of the original
1029 email source attached to the message after it is imported.
1030 :param bool strip_attachments: whether to strip all attachments
1031 before processing the message, in order to save some space.
1032 :param int thread_id: optional ID of the record/thread from ``model``
1033 to which this mail should be attached. When provided, this
1034 overrides the automatic detection based on the message
1040 # extract message bytes - we are forced to pass the message as binary because
1041 # we don't know its encoding until we parse its headers and hence can't
1042 # convert it to utf-8 for transport between the mailgate script and here.
1043 if isinstance(message, xmlrpclib.Binary):
1044 message = str(message.data)
1045 # Warning: message_from_string doesn't always work correctly on unicode,
1046 # we must use utf-8 strings here :-(
1047 if isinstance(message, unicode):
1048 message = message.encode('utf-8')
1049 msg_txt = email.message_from_string(message)
1051 # parse the message, verify we are not in a loop by checking message_id is not duplicated
1052 msg = self.message_parse(cr, uid, msg_txt, save_original=save_original, context=context)
1053 if strip_attachments:
1054 msg.pop('attachments', None)
1056 if msg.get('message_id'): # should always be True as message_parse generate one if missing
1057 existing_msg_ids = self.pool.get('mail.message').search(cr, SUPERUSER_ID, [
1058 ('message_id', '=', msg.get('message_id')),
1060 if existing_msg_ids:
1061 _logger.info('Ignored mail from %s to %s with Message-Id %s: found duplicated Message-Id during processing',
1062 msg.get('from'), msg.get('to'), msg.get('message_id'))
1065 # find possible routes for the message
1066 routes = self.message_route(cr, uid, msg_txt, msg, model, thread_id, custom_values, context=context)
1067 thread_id = self.message_route_process(cr, uid, msg_txt, msg, routes, context=context)
1070 def message_new(self, cr, uid, msg_dict, custom_values=None, context=None):
1071 """Called by ``message_process`` when a new message is received
1072 for a given thread model, if the message did not belong to
1074 The default behavior is to create a new record of the corresponding
1075 model (based on some very basic info extracted from the message).
1076 Additional behavior may be implemented by overriding this method.
1078 :param dict msg_dict: a map containing the email details and
1079 attachments. See ``message_process`` and
1080 ``mail.message.parse`` for details.
1081 :param dict custom_values: optional dictionary of additional
1082 field values to pass to create()
1083 when creating the new thread record.
1084 Be careful, these values may override
1085 any other values coming from the message.
1086 :param dict context: if a ``thread_model`` value is present
1087 in the context, its value will be used
1088 to determine the model of the record
1089 to create (instead of the current model).
1091 :return: the id of the newly created thread object
1096 if isinstance(custom_values, dict):
1097 data = custom_values.copy()
1098 model = context.get('thread_model') or self._name
1099 model_pool = self.pool[model]
1100 fields = model_pool.fields_get(cr, uid, context=context)
1101 if 'name' in fields and not data.get('name'):
1102 data['name'] = msg_dict.get('subject', '')
1103 res_id = model_pool.create(cr, uid, data, context=context)
1106 def message_update(self, cr, uid, ids, msg_dict, update_vals=None, context=None):
1107 """Called by ``message_process`` when a new message is received
1108 for an existing thread. The default behavior is to update the record
1109 with update_vals taken from the incoming email.
1110 Additional behavior may be implemented by overriding this
1112 :param dict msg_dict: a map containing the email details and
1113 attachments. See ``message_process`` and
1114 ``mail.message.parse()`` for details.
1115 :param dict update_vals: a dict containing values to update records
1116 given their ids; if the dict is None or is
1117 void, no write operation is performed.
1120 self.write(cr, uid, ids, update_vals, context=context)
1123 def _message_extract_payload(self, message, save_original=False):
1124 """Extract body as HTML and attachments from the mail message"""
1128 attachments.append(('original_email.eml', message.as_string()))
1129 if not message.is_multipart() or 'text/' in message.get('content-type', ''):
1130 encoding = message.get_content_charset()
1131 body = message.get_payload(decode=True)
1132 body = tools.ustr(body, encoding, errors='replace')
1133 if message.get_content_type() == 'text/plain':
1134 # text/plain -> <pre/>
1135 body = tools.append_content_to_html(u'', body, preserve=True)
1138 for part in message.walk():
1139 if part.get_content_type() == 'multipart/alternative':
1141 if part.get_content_maintype() == 'multipart':
1142 continue # skip container
1143 # part.get_filename returns decoded value if able to decode, coded otherwise.
1144 # original get_filename is not able to decode iso-8859-1 (for instance).
1145 # therefore, iso encoded attachements are not able to be decoded properly with get_filename
1146 # code here partially copy the original get_filename method, but handle more encoding
1147 filename=part.get_param('filename', None, 'content-disposition')
1149 filename=part.get_param('name', None)
1151 if isinstance(filename, tuple):
1153 filename=email.utils.collapse_rfc2231_value(filename).strip()
1155 filename=decode(filename)
1156 encoding = part.get_content_charset() # None if attachment
1157 # 1) Explicit Attachments -> attachments
1158 if filename or part.get('content-disposition', '').strip().startswith('attachment'):
1159 attachments.append((filename or 'attachment', part.get_payload(decode=True)))
1161 # 2) text/plain -> <pre/>
1162 if part.get_content_type() == 'text/plain' and (not alternative or not body):
1163 body = tools.append_content_to_html(body, tools.ustr(part.get_payload(decode=True),
1164 encoding, errors='replace'), preserve=True)
1165 # 3) text/html -> raw
1166 elif part.get_content_type() == 'text/html':
1167 html = tools.ustr(part.get_payload(decode=True), encoding, errors='replace')
1171 body = tools.append_content_to_html(body, html, plaintext=False)
1172 # 4) Anything else -> attachment
1174 attachments.append((filename or 'attachment', part.get_payload(decode=True)))
1175 return body, attachments
1177 def message_parse(self, cr, uid, message, save_original=False, context=None):
1178 """Parses a string or email.message.Message representing an
1179 RFC-2822 email, and returns a generic dict holding the
1182 :param message: the message to parse
1183 :type message: email.message.Message | string | unicode
1184 :param bool save_original: whether the returned dict
1185 should include an ``original`` attachment containing
1186 the source of the message
1188 :return: A dict with the following structure, where each
1189 field may not be present if missing in original
1192 { 'message_id': msg_id,
1197 'body': unified_body,
1198 'attachments': [('file1', 'bytes'),
1205 if not isinstance(message, Message):
1206 if isinstance(message, unicode):
1207 # Warning: message_from_string doesn't always work correctly on unicode,
1208 # we must use utf-8 strings here :-(
1209 message = message.encode('utf-8')
1210 message = email.message_from_string(message)
1212 message_id = message['message-id']
1214 # Very unusual situation, be we should be fault-tolerant here
1215 message_id = "<%s@localhost>" % time.time()
1216 _logger.debug('Parsing Message without message-id, generating a random one: %s', message_id)
1217 msg_dict['message_id'] = message_id
1219 if message.get('Subject'):
1220 msg_dict['subject'] = decode(message.get('Subject'))
1222 # Envelope fields not stored in mail.message but made available for message_new()
1223 msg_dict['from'] = decode(message.get('from'))
1224 msg_dict['to'] = decode(message.get('to'))
1225 msg_dict['cc'] = decode(message.get('cc'))
1226 msg_dict['email_from'] = decode(message.get('from'))
1227 partner_ids = self._message_find_partners(cr, uid, message, ['To', 'Cc'], context=context)
1228 msg_dict['partner_ids'] = [(4, partner_id) for partner_id in partner_ids]
1230 if message.get('Date'):
1232 date_hdr = decode(message.get('Date'))
1233 parsed_date = dateutil.parser.parse(date_hdr, fuzzy=True)
1234 if parsed_date.utcoffset() is None:
1235 # naive datetime, so we arbitrarily decide to make it
1236 # UTC, there's no better choice. Should not happen,
1237 # as RFC2822 requires timezone offset in Date headers.
1238 stored_date = parsed_date.replace(tzinfo=pytz.utc)
1240 stored_date = parsed_date.astimezone(tz=pytz.utc)
1242 _logger.warning('Failed to parse Date header %r in incoming mail '
1243 'with message-id %r, assuming current date/time.',
1244 message.get('Date'), message_id)
1245 stored_date = datetime.datetime.now()
1246 msg_dict['date'] = stored_date.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT)
1248 if message.get('In-Reply-To'):
1249 parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', '=', decode(message['In-Reply-To']))])
1251 msg_dict['parent_id'] = parent_ids[0]
1253 if message.get('References') and 'parent_id' not in msg_dict:
1254 parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', 'in',
1255 [x.strip() for x in decode(message['References']).split()])])
1257 msg_dict['parent_id'] = parent_ids[0]
1259 msg_dict['body'], msg_dict['attachments'] = self._message_extract_payload(message, save_original=save_original)
1262 #------------------------------------------------------
1264 #------------------------------------------------------
1266 def log(self, cr, uid, id, message, secondary=False, context=None):
1267 _logger.warning("log() is deprecated. As this module inherit from "\
1268 "mail.thread, the message will be managed by this "\
1269 "module instead of by the res.log mechanism. Please "\
1270 "use mail_thread.message_post() instead of the "\
1271 "now deprecated res.log.")
1272 self.message_post(cr, uid, [id], message, context=context)
1274 def _message_add_suggested_recipient(self, cr, uid, result, obj, partner=None, email=None, reason='', context=None):
1275 """ Called by message_get_suggested_recipients, to add a suggested
1276 recipient in the result dictionary. The form is :
1277 partner_id, partner_name<partner_email> or partner_name, reason """
1278 if email and not partner:
1279 # get partner info from email
1280 partner_info = self.message_partner_info_from_emails(cr, uid, obj.id, [email], context=context)[0]
1281 if partner_info.get('partner_id'):
1282 partner = self.pool.get('res.partner').browse(cr, SUPERUSER_ID, [partner_info['partner_id']], context=context)[0]
1283 if email and email in [val[1] for val in result[obj.id]]: # already existing email -> skip
1285 if partner and partner in obj.message_follower_ids: # recipient already in the followers -> skip
1287 if partner and partner in [val[0] for val in result[obj.id]]: # already existing partner ID -> skip
1289 if partner and partner.email: # complete profile: id, name <email>
1290 result[obj.id].append((partner.id, '%s<%s>' % (partner.name, partner.email), reason))
1291 elif partner: # incomplete profile: id, name
1292 result[obj.id].append((partner.id, '%s' % (partner.name), reason))
1293 else: # unknown partner, we are probably managing an email address
1294 result[obj.id].append((False, email, reason))
1297 def message_get_suggested_recipients(self, cr, uid, ids, context=None):
1298 """ Returns suggested recipients for ids. Those are a list of
1299 tuple (partner_id, partner_name, reason), to be managed by Chatter. """
1300 result = dict.fromkeys(ids, list())
1301 if self._all_columns.get('user_id'):
1302 for obj in self.browse(cr, SUPERUSER_ID, ids, context=context): # SUPERUSER because of a read on res.users that would crash otherwise
1303 if not obj.user_id or not obj.user_id.partner_id:
1305 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)
1308 def _find_partner_from_emails(self, cr, uid, id, emails, model=None, context=None, check_followers=True):
1309 """ Utility method to find partners from email addresses. The rules are :
1310 1 - check in document (model | self, id) followers
1311 2 - try to find a matching partner that is also an user
1312 3 - try to find a matching partner
1314 :param list emails: list of email addresses
1315 :param string model: model to fetch related record; by default self
1317 :param boolean check_followers: check in document followers
1319 partner_obj = self.pool['res.partner']
1322 if id and (model or self._name != 'mail.thread') and check_followers:
1324 obj = self.pool[model].browse(cr, uid, id, context=context)
1326 obj = self.browse(cr, uid, id, context=context)
1327 for contact in emails:
1329 email_address = tools.email_split(contact)
1330 if not email_address:
1331 partner_ids.append(partner_id)
1333 email_address = email_address[0]
1334 # first try: check in document's followers
1336 for follower in obj.message_follower_ids:
1337 if follower.email == email_address:
1338 partner_id = follower.id
1339 # second try: check in partners that are also users
1341 ids = partner_obj.search(cr, SUPERUSER_ID, [
1342 ('email', 'ilike', email_address),
1343 ('user_ids', '!=', False)
1344 ], limit=1, context=context)
1347 # third try: check in partners
1349 ids = partner_obj.search(cr, SUPERUSER_ID, [
1350 ('email', 'ilike', email_address)
1351 ], limit=1, context=context)
1354 partner_ids.append(partner_id)
1357 def message_partner_info_from_emails(self, cr, uid, id, emails, link_mail=False, context=None):
1358 """ Convert a list of emails into a list partner_ids and a list
1359 new_partner_ids. The return value is non conventional because
1360 it is meant to be used by the mail widget.
1362 :return dict: partner_ids and new_partner_ids """
1363 mail_message_obj = self.pool.get('mail.message')
1364 partner_ids = self._find_partner_from_emails(cr, uid, id, emails, context=context)
1366 for idx in range(len(emails)):
1367 email_address = emails[idx]
1368 partner_id = partner_ids[idx]
1369 partner_info = {'full_name': email_address, 'partner_id': partner_id}
1370 result.append(partner_info)
1372 # link mail with this from mail to the new partner id
1373 if link_mail and partner_info['partner_id']:
1374 message_ids = mail_message_obj.search(cr, SUPERUSER_ID, [
1376 ('email_from', '=', email_address),
1377 ('email_from', 'ilike', '<%s>' % email_address),
1378 ('author_id', '=', False)
1381 mail_message_obj.write(cr, SUPERUSER_ID, message_ids, {'author_id': partner_info['partner_id']}, context=context)
1384 def _message_preprocess_attachments(self, cr, uid, attachments, attachment_ids, attach_model, attach_res_id, context=None):
1385 """ Preprocess attachments for mail_thread.message_post() or mail_mail.create().
1387 :param list attachments: list of attachment tuples in the form ``(name,content)``,
1388 where content is NOT base64 encoded
1389 :param list attachment_ids: a list of attachment ids, not in tomany command form
1390 :param str attach_model: the model of the attachments parent record
1391 :param integer attach_res_id: the id of the attachments parent record
1393 Attachment = self.pool['ir.attachment']
1394 m2m_attachment_ids = []
1396 filtered_attachment_ids = Attachment.search(cr, SUPERUSER_ID, [
1397 ('res_model', '=', 'mail.compose.message'),
1398 ('create_uid', '=', uid),
1399 ('id', 'in', attachment_ids)], context=context)
1400 if filtered_attachment_ids:
1401 Attachment.write(cr, SUPERUSER_ID, filtered_attachment_ids, {'res_model': attach_model, 'res_id': attach_res_id}, context=context)
1402 m2m_attachment_ids += [(4, id) for id in attachment_ids]
1403 # Handle attachments parameter, that is a dictionary of attachments
1404 for name, content in attachments:
1405 if isinstance(content, unicode):
1406 content = content.encode('utf-8')
1409 'datas': base64.b64encode(str(content)),
1410 'datas_fname': name,
1411 'description': name,
1412 'res_model': attach_model,
1413 'res_id': attach_res_id,
1415 m2m_attachment_ids.append((0, 0, data_attach))
1416 return m2m_attachment_ids
1418 def message_post(self, cr, uid, thread_id, body='', subject=None, type='notification',
1419 subtype=None, parent_id=False, attachments=None, context=None,
1420 content_subtype='html', **kwargs):
1421 """ Post a new message in an existing thread, returning the new
1424 :param int thread_id: thread ID to post into, or list with one ID;
1425 if False/0, mail.message model will also be set as False
1426 :param str body: body of the message, usually raw HTML that will
1428 :param str type: see mail_message.type field
1429 :param str content_subtype:: if plaintext: convert body into html
1430 :param int parent_id: handle reply to a previous message by adding the
1431 parent partners to the message in case of private discussion
1432 :param tuple(str,str) attachments or list id: list of attachment tuples in the form
1433 ``(name,content)``, where content is NOT base64 encoded
1435 Extra keyword arguments will be used as default column values for the
1436 new mail.message record. Special cases:
1437 - attachment_ids: supposed not attached to any document; attach them
1438 to the related document. Should only be set by Chatter.
1439 :return int: ID of newly created mail.message
1443 if attachments is None:
1445 mail_message = self.pool.get('mail.message')
1446 ir_attachment = self.pool.get('ir.attachment')
1448 assert (not thread_id) or \
1449 isinstance(thread_id, (int, long)) or \
1450 (isinstance(thread_id, (list, tuple)) and len(thread_id) == 1), \
1451 "Invalid thread_id; should be 0, False, an ID or a list with one ID"
1452 if isinstance(thread_id, (list, tuple)):
1453 thread_id = thread_id[0]
1455 # if we're processing a message directly coming from the gateway, the destination model was
1456 # set in the context.
1459 model = context.get('thread_model', self._name) if self._name == 'mail.thread' else self._name
1460 if model != self._name and hasattr(self.pool[model], 'message_post'):
1461 del context['thread_model']
1462 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)
1464 #0: Find the message's author, because we need it for private discussion
1465 author_id = kwargs.get('author_id')
1466 if author_id is None: # keep False values
1467 author_id = self.pool.get('mail.message')._get_default_author(cr, uid, context=context)
1469 # 1: Handle content subtype: if plaintext, converto into HTML
1470 if content_subtype == 'plaintext':
1471 body = tools.plaintext2html(body)
1473 # 2: Private message: add recipients (recipients and author of parent message) - current author
1474 # + legacy-code management (! we manage only 4 and 6 commands)
1476 kwargs_partner_ids = kwargs.pop('partner_ids', [])
1477 for partner_id in kwargs_partner_ids:
1478 if isinstance(partner_id, (list, tuple)) and partner_id[0] == 4 and len(partner_id) == 2:
1479 partner_ids.add(partner_id[1])
1480 if isinstance(partner_id, (list, tuple)) and partner_id[0] == 6 and len(partner_id) == 3:
1481 partner_ids |= set(partner_id[2])
1482 elif isinstance(partner_id, (int, long)):
1483 partner_ids.add(partner_id)
1485 pass # we do not manage anything else
1486 if parent_id and not model:
1487 parent_message = mail_message.browse(cr, uid, parent_id, context=context)
1488 private_followers = set([partner.id for partner in parent_message.partner_ids])
1489 if parent_message.author_id:
1490 private_followers.add(parent_message.author_id.id)
1491 private_followers -= set([author_id])
1492 partner_ids |= private_followers
1495 # - HACK TDE FIXME: Chatter: attachments linked to the document (not done JS-side), load the message
1496 attachment_ids = self._message_preprocess_attachments(cr, uid, attachments, kwargs.pop('attachment_ids', []), model, thread_id, context)
1498 # 4: mail.message.subtype
1501 if '.' not in subtype:
1502 subtype = 'mail.%s' % subtype
1503 ref = self.pool.get('ir.model.data').get_object_reference(cr, uid, *subtype.split('.'))
1504 subtype_id = ref and ref[1] or False
1506 # automatically subscribe recipients if asked to
1507 if context.get('mail_post_autofollow') and thread_id and partner_ids:
1508 partner_to_subscribe = partner_ids
1509 if context.get('mail_post_autofollow_partner_ids'):
1510 partner_to_subscribe = filter(lambda item: item in context.get('mail_post_autofollow_partner_ids'), partner_ids)
1511 self.message_subscribe(cr, uid, [thread_id], list(partner_to_subscribe), context=context)
1513 # _mail_flat_thread: automatically set free messages to the first posted message
1514 if self._mail_flat_thread and not parent_id and thread_id:
1515 message_ids = mail_message.search(cr, uid, ['&', ('res_id', '=', thread_id), ('model', '=', model)], context=context, order="id ASC", limit=1)
1516 parent_id = message_ids and message_ids[0] or False
1517 # 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
1519 message_ids = mail_message.search(cr, SUPERUSER_ID, [('id', '=', parent_id), ('parent_id', '!=', False)], context=context)
1520 # avoid loops when finding ancestors
1523 message = mail_message.browse(cr, SUPERUSER_ID, message_ids[0], context=context)
1524 while (message.parent_id and message.parent_id.id not in processed_list):
1525 processed_list.append(message.parent_id.id)
1526 message = message.parent_id
1527 parent_id = message.id
1531 'author_id': author_id,
1533 'res_id': thread_id or False,
1535 'subject': subject or False,
1537 'parent_id': parent_id,
1538 'attachment_ids': attachment_ids,
1539 'subtype_id': subtype_id,
1540 'partner_ids': [(4, pid) for pid in partner_ids],
1543 # Avoid warnings about non-existing fields
1544 for x in ('from', 'to', 'cc'):
1547 # Create and auto subscribe the author
1548 msg_id = mail_message.create(cr, uid, values, context=context)
1549 message = mail_message.browse(cr, uid, msg_id, context=context)
1550 if message.author_id and thread_id and type != 'notification' and not context.get('mail_create_nosubscribe'):
1551 self.message_subscribe(cr, uid, [thread_id], [message.author_id.id], context=context)
1554 #------------------------------------------------------
1556 #------------------------------------------------------
1558 def message_get_subscription_data(self, cr, uid, ids, user_pid=None, context=None):
1559 """ Wrapper to get subtypes data. """
1560 return self._get_subscription_data(cr, uid, ids, None, None, user_pid=user_pid, context=context)
1562 def message_subscribe_users(self, cr, uid, ids, user_ids=None, subtype_ids=None, context=None):
1563 """ Wrapper on message_subscribe, using users. If user_ids is not
1564 provided, subscribe uid instead. """
1565 if user_ids is None:
1567 partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
1568 return self.message_subscribe(cr, uid, ids, partner_ids, subtype_ids=subtype_ids, context=context)
1570 def message_subscribe(self, cr, uid, ids, partner_ids, subtype_ids=None, context=None):
1571 """ Add partners to the records followers. """
1574 # not necessary for computation, but saves an access right check
1578 mail_followers_obj = self.pool.get('mail.followers')
1579 subtype_obj = self.pool.get('mail.message.subtype')
1581 user_pid = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1582 if set(partner_ids) == set([user_pid]):
1584 self.check_access_rights(cr, uid, 'read')
1585 if context.get('operation', '') == 'create':
1586 self.check_access_rule(cr, uid, ids, 'create')
1588 self.check_access_rule(cr, uid, ids, 'read')
1589 except (osv.except_osv, orm.except_orm):
1592 self.check_access_rights(cr, uid, 'write')
1593 self.check_access_rule(cr, uid, ids, 'write')
1595 existing_pids_dict = {}
1596 fol_ids = mail_followers_obj.search(cr, SUPERUSER_ID, ['&', '&', ('res_model', '=', self._name), ('res_id', 'in', ids), ('partner_id', 'in', partner_ids)])
1597 for fol in mail_followers_obj.browse(cr, SUPERUSER_ID, fol_ids, context=context):
1598 existing_pids_dict.setdefault(fol.res_id, set()).add(fol.partner_id.id)
1600 # subtype_ids specified: update already subscribed partners
1601 if subtype_ids and fol_ids:
1602 mail_followers_obj.write(cr, SUPERUSER_ID, fol_ids, {'subtype_ids': [(6, 0, subtype_ids)]}, context=context)
1603 # subtype_ids not specified: do not update already subscribed partner, fetch default subtypes for new partners
1604 if subtype_ids is None:
1605 subtype_ids = subtype_obj.search(
1607 ('default', '=', True), '|', ('res_model', '=', self._name), ('res_model', '=', False)], context=context)
1610 existing_pids = existing_pids_dict.get(id, set())
1611 new_pids = set(partner_ids) - existing_pids
1613 # subscribe new followers
1614 for new_pid in new_pids:
1615 mail_followers_obj.create(
1617 'res_model': self._name,
1619 'partner_id': new_pid,
1620 'subtype_ids': [(6, 0, subtype_ids)],
1625 def message_unsubscribe_users(self, cr, uid, ids, user_ids=None, context=None):
1626 """ Wrapper on message_subscribe, using users. If user_ids is not
1627 provided, unsubscribe uid instead. """
1628 if user_ids is None:
1630 partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
1631 return self.message_unsubscribe(cr, uid, ids, partner_ids, context=context)
1633 def message_unsubscribe(self, cr, uid, ids, partner_ids, context=None):
1634 """ Remove partners from the records followers. """
1635 # not necessary for computation, but saves an access right check
1638 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
1639 if set(partner_ids) == set([user_pid]):
1640 self.check_access_rights(cr, uid, 'read')
1641 self.check_access_rule(cr, uid, ids, 'read')
1643 self.check_access_rights(cr, uid, 'write')
1644 self.check_access_rule(cr, uid, ids, 'write')
1645 fol_obj = self.pool['mail.followers']
1646 fol_ids = fol_obj.search(
1648 ('res_model', '=', self._name),
1649 ('res_id', 'in', ids),
1650 ('partner_id', 'in', partner_ids)
1652 return fol_obj.unlink(cr, SUPERUSER_ID, fol_ids, context=context)
1654 def _message_get_auto_subscribe_fields(self, cr, uid, updated_fields, auto_follow_fields=['user_id'], context=None):
1655 """ Returns the list of relational fields linking to res.users that should
1656 trigger an auto subscribe. The default list checks for the fields
1658 - linking to res.users
1659 - with track_visibility set
1660 In OpenERP V7, this is sufficent for all major addon such as opportunity,
1661 project, issue, recruitment, sale.
1662 Override this method if a custom behavior is needed about fields
1663 that automatically subscribe users.
1666 for name, column_info in self._all_columns.items():
1667 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':
1668 user_field_lst.append(name)
1669 return user_field_lst
1671 def message_auto_subscribe(self, cr, uid, ids, updated_fields, context=None, values=None):
1672 """ Handle auto subscription. Two methods for auto subscription exist:
1674 - tracked res.users relational fields, such as user_id fields. Those fields
1675 must be relation fields toward a res.users record, and must have the
1676 track_visilibity attribute set.
1677 - using subtypes parent relationship: check if the current model being
1678 modified has an header record (such as a project for tasks) whose followers
1679 can be added as followers of the current records. Example of structure
1680 with project and task:
1682 - st_project_1.parent_id = st_task_1
1683 - st_project_1.res_model = 'project.project'
1684 - st_project_1.relation_field = 'project_id'
1685 - st_task_1.model = 'project.task'
1687 :param list updated_fields: list of updated fields to track
1688 :param dict values: updated values; if None, the first record will be browsed
1689 to get the values. Added after releasing 7.0, therefore
1690 not merged with updated_fields argumment.
1692 subtype_obj = self.pool.get('mail.message.subtype')
1693 follower_obj = self.pool.get('mail.followers')
1694 new_followers = dict()
1696 # fetch auto_follow_fields: res.users relation fields whose changes are tracked for subscription
1697 user_field_lst = self._message_get_auto_subscribe_fields(cr, uid, updated_fields, context=context)
1699 # fetch header subtypes
1700 header_subtype_ids = subtype_obj.search(cr, uid, ['|', ('res_model', '=', False), ('parent_id.res_model', '=', self._name)], context=context)
1701 subtypes = subtype_obj.browse(cr, uid, header_subtype_ids, context=context)
1703 # if no change in tracked field or no change in tracked relational field: quit
1704 relation_fields = set([subtype.relation_field for subtype in subtypes if subtype.relation_field is not False])
1705 if not any(relation in updated_fields for relation in relation_fields) and not user_field_lst:
1708 # legacy behavior: if values is not given, compute the values by browsing
1709 # @TDENOTE: remove me in 8.0
1711 record = self.browse(cr, uid, ids[0], context=context)
1712 for updated_field in updated_fields:
1713 field_value = getattr(record, updated_field)
1714 if isinstance(field_value, browse_record):
1715 field_value = field_value.id
1716 elif isinstance(field_value, browse_null):
1718 values[updated_field] = field_value
1720 # find followers of headers, update structure for new followers
1722 for subtype in subtypes:
1723 if subtype.relation_field and values.get(subtype.relation_field):
1724 headers.add((subtype.res_model, values.get(subtype.relation_field)))
1726 header_domain = ['|'] * (len(headers) - 1)
1727 for header in headers:
1728 header_domain += ['&', ('res_model', '=', header[0]), ('res_id', '=', header[1])]
1729 header_follower_ids = follower_obj.search(
1734 for header_follower in follower_obj.browse(cr, SUPERUSER_ID, header_follower_ids, context=context):
1735 for subtype in header_follower.subtype_ids:
1736 if subtype.parent_id and subtype.parent_id.res_model == self._name:
1737 new_followers.setdefault(header_follower.partner_id.id, set()).add(subtype.parent_id.id)
1738 elif subtype.res_model is False:
1739 new_followers.setdefault(header_follower.partner_id.id, set()).add(subtype.id)
1741 # add followers coming from res.users relational fields that are tracked
1742 user_ids = [values[name] for name in user_field_lst if values.get(name)]
1743 user_pids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, SUPERUSER_ID, user_ids, context=context)]
1744 for partner_id in user_pids:
1745 new_followers.setdefault(partner_id, None)
1747 for pid, subtypes in new_followers.items():
1748 subtypes = list(subtypes) if subtypes is not None else None
1749 self.message_subscribe(cr, uid, ids, [pid], subtypes, context=context)
1751 # find first email message, set it as unread for auto_subscribe fields for them to have a notification
1753 for record_id in ids:
1754 message_obj = self.pool.get('mail.message')
1755 msg_ids = message_obj.search(cr, SUPERUSER_ID, [
1756 ('model', '=', self._name),
1757 ('res_id', '=', record_id),
1758 ('type', '=', 'email')], limit=1, context=context)
1760 msg_ids = message_obj.search(cr, SUPERUSER_ID, [
1761 ('model', '=', self._name),
1762 ('res_id', '=', record_id)], limit=1, context=context)
1764 self.pool.get('mail.notification')._notify(cr, uid, msg_ids[0], partners_to_notify=user_pids, context=context)
1768 #------------------------------------------------------
1770 #------------------------------------------------------
1772 def message_mark_as_unread(self, cr, uid, ids, context=None):
1773 """ Set as unread. """
1774 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1776 UPDATE mail_notification SET
1779 message_id IN (SELECT id from mail_message where res_id=any(%s) and model=%s limit 1) and
1781 ''', (ids, self._name, partner_id))
1784 def message_mark_as_read(self, cr, uid, ids, context=None):
1785 """ Set as read. """
1786 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1788 UPDATE mail_notification SET
1791 message_id IN (SELECT id FROM mail_message WHERE res_id=ANY(%s) AND model=%s) AND
1793 ''', (ids, self._name, partner_id))
1796 #------------------------------------------------------
1798 #------------------------------------------------------
1800 def get_suggested_thread(self, cr, uid, removed_suggested_threads=None, context=None):
1801 """Return a list of suggested threads, sorted by the numbers of followers"""
1805 # TDE HACK: originally by MAT from portal/mail_mail.py but not working until the inheritance graph bug is not solved in trunk
1806 # TDE FIXME: relocate in portal when it won't be necessary to reload the hr.employee model in an additional bridge module
1807 if self.pool['res.groups']._all_columns.get('is_portal'):
1808 user = self.pool.get('res.users').browse(cr, SUPERUSER_ID, uid, context=context)
1809 if any(group.is_portal for group in user.groups_id):
1813 if removed_suggested_threads is None:
1814 removed_suggested_threads = []
1816 thread_ids = self.search(cr, uid, [('id', 'not in', removed_suggested_threads), ('message_is_follower', '=', False)], context=context)
1817 for thread in self.browse(cr, uid, thread_ids, context=context):
1820 'popularity': len(thread.message_follower_ids),
1821 'name': thread.name,
1822 'image_small': thread.image_small
1824 threads.append(data)
1825 return sorted(threads, key=lambda x: (x['popularity'], x['id']), reverse=True)[:3]