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 ##############################################################################
31 from email.message import Message
33 from openerp import tools
34 from openerp import SUPERUSER_ID
35 from openerp.addons.mail.mail_message import decode
36 from openerp.osv import fields, osv, orm
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
75 # Automatic logging system if mail installed
78 # 'module.subtype_xml': lambda self, cr, uid, obj, context=None: obj[state] == done,
79 # 'module.subtype_xml2': lambda self, cr, uid, obj, context=None: obj[state] != done,
86 # :param string field: field name
87 # :param module.subtype_xml: xml_id of a mail.message.subtype (i.e. mail.mt_comment)
88 # :param obj: is a browse_record
89 # :param function lambda: returns whether the tracking should record using this subtype
92 def get_empty_list_help(self, cr, uid, help, context=None):
93 """ Override of BaseModel.get_empty_list_help() to generate an help message
94 that adds alias information. """
95 model = context.get('empty_list_help_model')
96 res_id = context.get('empty_list_help_id')
97 ir_config_parameter = self.pool.get("ir.config_parameter")
98 catchall_domain = ir_config_parameter.get_param(cr, uid, "mail.catchall.domain", context=context)
99 document_name = context.get('empty_list_help_document_name', _('document'))
102 if catchall_domain and model and res_id: # specific res_id -> find its alias (i.e. section_id specified)
103 object_id = self.pool.get(model).browse(cr, uid, res_id, context=context)
104 # check that the alias effectively creates new records
105 if object_id.alias_id and object_id.alias_id.alias_model_id and \
106 object_id.alias_id.alias_model_id.model == self._name and \
107 object_id.alias_id.alias_force_thread_id == 0:
108 alias = object_id.alias_id
109 elif catchall_domain and model: # no specific res_id given -> generic help message, take an example alias (i.e. alias of some section_id)
110 model_id = self.pool.get('ir.model').search(cr, uid, [("model", "=", self._name)], context=context)[0]
111 alias_obj = self.pool.get('mail.alias')
112 alias_ids = alias_obj.search(cr, uid, [("alias_model_id", "=", model_id), ('alias_force_thread_id', '=', 0)], context=context, order='id ASC')
113 if alias_ids and len(alias_ids) == 1: # if several aliases -> incoherent to propose one guessed from nowhere, therefore avoid if several aliases
114 alias = alias_obj.browse(cr, uid, alias_ids[0], context=context)
117 alias_email = alias.name_get()[0][1]
118 return _("""<p class='oe_view_nocontent_create'>
119 Click here to add a new %(document)s or send an email to: <a href='mailto:%(email)s'>%(email)s</a>
123 'document': document_name,
124 'email': alias_email,
125 'static_help': help or ''
128 if document_name != 'document' and help and help.find("oe_view_nocontent_create") == -1:
129 return _("<p class='oe_view_nocontent_create'>Click here to add a new %(document)s</p>%(static_help)s") % {
130 'document': document_name,
131 'static_help': help or '',
136 def _get_message_data(self, cr, uid, ids, name, args, context=None):
138 - message_unread: has uid unread message for the document
139 - message_summary: html snippet summarizing the Chatter for kanban views """
140 res = dict((id, dict(message_unread=False, message_unread_count=0, message_summary=' ')) for id in ids)
141 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
143 # search for unread messages, directly in SQL to improve performances
144 cr.execute(""" SELECT m.res_id FROM mail_message m
145 RIGHT JOIN mail_notification n
146 ON (n.message_id = m.id AND n.partner_id = %s AND (n.read = False or n.read IS NULL))
147 WHERE m.model = %s AND m.res_id in %s""",
148 (user_pid, self._name, tuple(ids),))
149 for result in cr.fetchall():
150 res[result[0]]['message_unread'] = True
151 res[result[0]]['message_unread_count'] += 1
154 if res[id]['message_unread_count']:
155 title = res[id]['message_unread_count'] > 1 and _("You have %d unread messages") % res[id]['message_unread_count'] or _("You have one unread message")
156 res[id]['message_summary'] = "<span class='oe_kanban_mail_new' title='%s'><span class='oe_e'>9</span> %d %s</span>" % (title, res[id].pop('message_unread_count'), _("New"))
159 def check_technical_rights(self, cr, uid, ids, context=None):
160 grp_id = self.pool.get('ir.model.data').get_object_reference(cr, uid, 'base', 'group_no_one')
161 user_pid = self.pool.get('res.groups').read(cr, uid, grp_id[1], ['users'], context=context)['users']
166 def _get_subscription_data(self, cr, uid, ids, name, args, user_pid=None, context=None):
168 - message_subtype_data: data about document subtypes: which are
169 available, which are followed if any """
170 res = dict((id, dict(message_subtype_data='')) for id in ids)
172 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
174 # find current model subtypes, add them to a dictionary
175 subtype_obj = self.pool.get('mail.message.subtype')
176 subtype_ids = subtype_obj.search(cr, uid, ['|', ('res_model', '=', self._name), ('res_model', '=', False)], context=context)
177 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))
179 res[id]['message_subtype_data'] = subtype_dict.copy()
181 # find the document followers, update the data
182 fol_obj = self.pool.get('mail.followers')
183 fol_ids = fol_obj.search(cr, uid, [
184 ('partner_id', '=', user_pid),
185 ('res_id', 'in', ids),
186 ('res_model', '=', self._name),
188 for fol in fol_obj.browse(cr, uid, fol_ids, context=context):
189 thread_subtype_dict = res[fol.res_id]['message_subtype_data']
190 for subtype in fol.subtype_ids:
191 thread_subtype_dict[subtype.name]['followed'] = True
192 res[fol.res_id]['message_subtype_data'] = thread_subtype_dict
196 def _search_message_unread(self, cr, uid, obj=None, name=None, domain=None, context=None):
197 return [('message_ids.to_read', '=', True)]
199 def _get_followers(self, cr, uid, ids, name, arg, context=None):
200 fol_obj = self.pool.get('mail.followers')
201 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids)])
202 res = dict((id, dict(message_follower_ids=[], message_is_follower=False)) for id in ids)
203 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
204 for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids):
205 res[fol.res_id]['message_follower_ids'].append(fol.partner_id.id)
206 if fol.partner_id.id == user_pid:
207 res[fol.res_id]['message_is_follower'] = True
210 def _set_followers(self, cr, uid, id, name, value, arg, context=None):
213 partner_obj = self.pool.get('res.partner')
214 fol_obj = self.pool.get('mail.followers')
216 # read the old set of followers, and determine the new set of followers
217 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', '=', id)])
218 old = set(fol.partner_id.id for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids))
221 for command in value or []:
222 if isinstance(command, (int, long)):
224 elif command[0] == 0:
225 new.add(partner_obj.create(cr, uid, command[2], context=context))
226 elif command[0] == 1:
227 partner_obj.write(cr, uid, [command[1]], command[2], context=context)
229 elif command[0] == 2:
230 partner_obj.unlink(cr, uid, [command[1]], context=context)
231 new.discard(command[1])
232 elif command[0] == 3:
233 new.discard(command[1])
234 elif command[0] == 4:
236 elif command[0] == 5:
238 elif command[0] == 6:
239 new = set(command[2])
241 # remove partners that are no longer followers
242 fol_ids = fol_obj.search(cr, SUPERUSER_ID,
243 [('res_model', '=', self._name), ('res_id', '=', id), ('partner_id', 'not in', list(new))])
244 fol_obj.unlink(cr, SUPERUSER_ID, fol_ids)
247 for partner_id in new - old:
248 fol_obj.create(cr, SUPERUSER_ID, {'res_model': self._name, 'res_id': id, 'partner_id': partner_id})
250 def _search_followers(self, cr, uid, obj, name, args, context):
251 fol_obj = self.pool.get('mail.followers')
253 for field, operator, value in args:
255 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('partner_id', operator, value)])
256 res_ids = [fol.res_id for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids)]
257 res.append(('id', 'in', res_ids))
261 'message_is_follower': fields.function(_get_followers,
262 type='boolean', string='Is a Follower', multi='_get_followers,'),
263 'message_follower_ids': fields.function(_get_followers, fnct_inv=_set_followers,
264 fnct_search=_search_followers, type='many2many',
265 obj='res.partner', string='Followers', multi='_get_followers'),
266 'message_ids': fields.one2many('mail.message', 'res_id',
267 domain=lambda self: [('model', '=', self._name)],
270 help="Messages and communication history"),
271 'message_unread': fields.function(_get_message_data,
272 fnct_search=_search_message_unread, multi="_get_message_data",
273 type='boolean', string='Unread Messages',
274 help="If checked new messages require your attention."),
275 'message_summary': fields.function(_get_message_data, method=True,
276 type='text', string='Summary', multi="_get_message_data",
277 help="Holds the Chatter summary (number of messages, ...). "\
278 "This summary is directly in html format in order to "\
279 "be inserted in kanban views."),
282 #------------------------------------------------------
283 # CRUD overrides for automatic subscription and logging
284 #------------------------------------------------------
286 def create(self, cr, uid, values, context=None):
287 """ Chatter override :
289 - subscribe followers of parent
290 - log a creation message
294 thread_id = super(mail_thread, self).create(cr, uid, values, context=context)
296 # automatic logging unless asked not to (mainly for various testing purpose)
297 if not context.get('mail_create_nolog'):
298 self.message_post(cr, uid, thread_id, body=_('%s created') % (self._description), context=context)
300 # subscribe uid unless asked not to
301 if not context.get('mail_create_nosubscribe'):
302 self.message_subscribe_users(cr, uid, [thread_id], [uid], context=context)
303 self.message_auto_subscribe(cr, uid, [thread_id], values.keys(), context=context)
306 tracked_fields = self._get_tracked_fields(cr, uid, values.keys(), context=context)
308 initial_values = {thread_id: dict((item, False) for item in tracked_fields)}
309 self.message_track(cr, uid, [thread_id], tracked_fields, initial_values, context=context)
313 def write(self, cr, uid, ids, values, context=None):
314 if isinstance(ids, (int, long)):
316 # Track initial values of tracked fields
317 tracked_fields = self._get_tracked_fields(cr, uid, values.keys(), context=context)
319 initial = self.read(cr, uid, ids, tracked_fields.keys(), context=context)
320 initial_values = dict((item['id'], item) for item in initial)
322 # Perform write, update followers
323 result = super(mail_thread, self).write(cr, uid, ids, values, context=context)
324 self.message_auto_subscribe(cr, uid, ids, values.keys(), context=context)
326 # Perform the tracking
328 self.message_track(cr, uid, ids, tracked_fields, initial_values, context=context)
331 def unlink(self, cr, uid, ids, context=None):
332 """ Override unlink to delete messages and followers. This cannot be
333 cascaded, because link is done through (res_model, res_id). """
334 msg_obj = self.pool.get('mail.message')
335 fol_obj = self.pool.get('mail.followers')
336 # delete messages and notifications
337 msg_ids = msg_obj.search(cr, uid, [('model', '=', self._name), ('res_id', 'in', ids)], context=context)
338 msg_obj.unlink(cr, uid, msg_ids, context=context)
340 res = super(mail_thread, self).unlink(cr, uid, ids, context=context)
342 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids)], context=context)
343 fol_obj.unlink(cr, SUPERUSER_ID, fol_ids, context=context)
346 def copy(self, cr, uid, id, default=None, context=None):
347 default = default or {}
348 default['message_ids'] = []
349 default['message_follower_ids'] = []
350 return super(mail_thread, self).copy(cr, uid, id, default=default, context=context)
352 #------------------------------------------------------
353 # Automatically log tracked fields
354 #------------------------------------------------------
356 def _get_tracked_fields(self, cr, uid, updated_fields, context=None):
357 """ Return a structure of tracked fields for the current model.
358 :param list updated_fields: modified field names
359 :return list: a list of (field_name, column_info obj), containing
360 always tracked fields and modified on_change fields
363 for name, column_info in self._all_columns.items():
364 visibility = getattr(column_info.column, 'track_visibility', False)
365 if visibility == 'always' or (visibility == 'onchange' and name in updated_fields) or name in self._track:
369 return self.fields_get(cr, uid, lst, context=context)
371 def message_track(self, cr, uid, ids, tracked_fields, initial_values, context=None):
373 def convert_for_display(value, col_info):
374 if not value and col_info['type'] == 'boolean':
378 if col_info['type'] == 'many2one':
380 if col_info['type'] == 'selection':
381 return dict(col_info['selection'])[value]
384 def format_message(message_description, tracked_values):
386 if message_description:
387 message = '<span>%s</span>' % message_description
388 for name, change in tracked_values.items():
389 message += '<div> • <b>%s</b>: ' % change.get('col_info')
390 if change.get('old_value'):
391 message += '%s → ' % change.get('old_value')
392 message += '%s</div>' % change.get('new_value')
395 if not tracked_fields:
398 for record in self.read(cr, uid, ids, tracked_fields.keys(), context=context):
399 initial = initial_values[record['id']]
403 # generate tracked_values data structure: {'col_name': {col_info, new_value, old_value}}
404 for col_name, col_info in tracked_fields.items():
405 if record[col_name] == initial[col_name] and getattr(self._all_columns[col_name].column, 'track_visibility', None) == 'always':
406 tracked_values[col_name] = dict(col_info=col_info['string'],
407 new_value=convert_for_display(record[col_name], col_info))
408 elif record[col_name] != initial[col_name]:
409 if getattr(self._all_columns[col_name].column, 'track_visibility', None) in ['always', 'onchange']:
410 tracked_values[col_name] = dict(col_info=col_info['string'],
411 old_value=convert_for_display(initial[col_name], col_info),
412 new_value=convert_for_display(record[col_name], col_info))
413 if col_name in tracked_fields:
414 changes.append(col_name)
418 # find subtypes and post messages or log if no subtype found
420 for field, track_info in self._track.items():
421 if field not in changes:
423 for subtype, method in track_info.items():
424 if method(self, cr, uid, record, context):
425 subtypes.append(subtype)
428 for subtype in subtypes:
430 subtype_rec = self.pool.get('ir.model.data').get_object(cr, uid, subtype.split('.')[0], subtype.split('.')[1], context=context)
431 except ValueError, e:
432 _logger.debug('subtype %s not found, giving error "%s"' % (subtype, e))
434 message = format_message(subtype_rec.description if subtype_rec.description else subtype_rec.name, tracked_values)
435 self.message_post(cr, uid, record['id'], body=message, subtype=subtype, context=context)
438 message = format_message('', tracked_values)
439 self.message_post(cr, uid, record['id'], body=message, context=context)
442 #------------------------------------------------------
443 # mail.message wrappers and tools
444 #------------------------------------------------------
446 def _needaction_domain_get(self, cr, uid, context=None):
448 return [('message_unread', '=', True)]
451 def _garbage_collect_attachments(self, cr, uid, context=None):
452 """ Garbage collect lost mail attachments. Those are attachments
453 - linked to res_model 'mail.compose.message', the composer wizard
454 - with res_id 0, because they were created outside of an existing
455 wizard (typically user input through Chatter or reports
456 created on-the-fly by the templates)
457 - unused since at least one day (create_date and write_date)
459 limit_date = datetime.datetime.utcnow() - datetime.timedelta(days=1)
460 limit_date_str = datetime.datetime.strftime(limit_date, tools.DEFAULT_SERVER_DATETIME_FORMAT)
461 ir_attachment_obj = self.pool.get('ir.attachment')
462 attach_ids = ir_attachment_obj.search(cr, uid, [
463 ('res_model', '=', 'mail.compose.message'),
465 ('create_date', '<', limit_date_str),
466 ('write_date', '<', limit_date_str),
468 ir_attachment_obj.unlink(cr, uid, attach_ids, context=context)
471 def check_mail_message_access(self, cr, uid, mids, operation, model_obj=None, context=None):
472 """ mail.message check permission rules for related document. This method is
473 meant to be inherited in order to implement addons-specific behavior.
474 A common behavior would be to allow creating messages when having read
475 access rule on the document, for portal document such as issues. """
478 if operation in ['create', 'write', 'unlink']:
479 model_obj.check_access_rights(cr, uid, 'write')
480 model_obj.check_access_rule(cr, uid, mids, 'write', context=context)
482 model_obj.check_access_rights(cr, uid, operation)
483 model_obj.check_access_rule(cr, uid, mids, operation, context=context)
485 def _get_formview_action(self, cr, uid, id, model=None, context=None):
486 """ Return an action to open the document. This method is meant to be
487 overridden in addons that want to give specific view ids for example.
489 :param int id: id of the document to open
490 :param string model: specific model that overrides self._name
493 'type': 'ir.actions.act_window',
494 'res_model': model or self._name,
497 'views': [(False, 'form')],
502 def _get_inbox_action_xml_id(self, cr, uid, context=None):
503 """ When redirecting towards the Inbox, choose which action xml_id has
504 to be fetched. This method is meant to be inherited, at least in portal
505 because portal users have a different Inbox action than classic users. """
506 return ('mail', 'action_mail_inbox_feeds')
508 def message_redirect_action(self, cr, uid, context=None):
509 """ For a given message, return an action that either
510 - opens the form view of the related document if model, res_id, and
511 read access to the document
512 - opens the Inbox with a default search on the conversation if model,
514 - opens the Inbox with context propagated
520 # default action is the Inbox action
521 self.pool.get('res.users').browse(cr, SUPERUSER_ID, uid, context=context)
522 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))
523 action = self.pool.get(act_model).read(cr, uid, act_id, [])
525 # if msg_id specified: try to redirect to the document or fallback on the Inbox
526 msg_id = context.get('params', {}).get('message_id')
529 msg = self.pool.get('mail.message').browse(cr, uid, msg_id, context=context)
530 if msg.model and msg.res_id:
533 'search_default_model': msg.model,
534 'search_default_res_id': msg.res_id,
537 if self.pool.get(msg.model).check_access_rights(cr, uid, 'read', raise_exception=False):
539 model_obj = self.pool.get(msg.model)
540 model_obj.check_access_rule(cr, uid, [msg.res_id], 'read', context=context)
541 if not hasattr(model_obj, '_get_formview_action'):
542 action = self.pool.get('mail.thread')._get_formview_action(cr, uid, msg.res_id, model=msg.model, context=context)
544 action = model_obj._get_formview_action(cr, uid, msg.res_id, context=context)
545 except (osv.except_osv, orm.except_orm):
549 #------------------------------------------------------
551 #------------------------------------------------------
553 def message_get_reply_to(self, cr, uid, ids, context=None):
554 if not self._inherits.get('mail.alias'):
555 return [False for id in ids]
556 return ["%s@%s" % (record['alias_name'], record['alias_domain'])
557 if record.get('alias_domain') and record.get('alias_name')
559 for record in self.read(cr, SUPERUSER_ID, ids, ['alias_name', 'alias_domain'], context=context)]
561 #------------------------------------------------------
563 #------------------------------------------------------
565 def message_capable_models(self, cr, uid, context=None):
566 """ Used by the plugin addon, based for plugin_outlook and others. """
568 for model_name in self.pool.obj_list():
569 model = self.pool[model_name]
570 if 'mail.thread' in getattr(model, '_inherit', []):
571 ret_dict[model_name] = model._description
574 def _message_find_partners(self, cr, uid, message, header_fields=['From'], context=None):
575 """ Find partners related to some header fields of the message.
577 TDE TODO: merge me with other partner finding methods in 8.0 """
578 partner_obj = self.pool.get('res.partner')
580 s = ', '.join([decode(message.get(h)) for h in header_fields if message.get(h)])
581 for email_address in tools.email_split(s):
582 related_partners = partner_obj.search(cr, uid, [('email', 'ilike', email_address), ('user_ids', '!=', False)], limit=1, context=context)
583 if not related_partners:
584 related_partners = partner_obj.search(cr, uid, [('email', 'ilike', email_address)], limit=1, context=context)
585 partner_ids += related_partners
588 def _message_find_user_id(self, cr, uid, message, context=None):
589 """ TDE TODO: check and maybe merge me with other user finding methods in 8.0 """
590 from_local_part = tools.email_split(decode(message.get('From')))[0]
591 # FP Note: canonification required, the minimu: .lower()
592 user_ids = self.pool.get('res.users').search(cr, uid, ['|',
593 ('login', '=', from_local_part),
594 ('email', '=', from_local_part)], context=context)
595 return user_ids[0] if user_ids else uid
597 def message_route(self, cr, uid, message, model=None, thread_id=None,
598 custom_values=None, context=None):
599 """Attempt to figure out the correct target model, thread_id,
600 custom_values and user_id to use for an incoming message.
601 Multiple values may be returned, if a message had multiple
602 recipients matching existing mail.aliases, for example.
604 The following heuristics are used, in this order:
605 1. If the message replies to an existing thread_id, and
606 properly contains the thread model in the 'In-Reply-To'
607 header, use this model/thread_id pair, and ignore
608 custom_value (not needed as no creation will take place)
609 2. Look for a mail.alias entry matching the message
610 recipient, and use the corresponding model, thread_id,
611 custom_values and user_id.
612 3. Fallback to the ``model``, ``thread_id`` and ``custom_values``
614 4. If all the above fails, raise an exception.
616 :param string message: an email.message instance
617 :param string model: the fallback model to use if the message
618 does not match any of the currently configured mail aliases
619 (may be None if a matching alias is supposed to be present)
620 :type dict custom_values: optional dictionary of default field values
621 to pass to ``message_new`` if a new record needs to be created.
622 Ignored if the thread record already exists, and also if a
623 matching mail.alias was found (aliases define their own defaults)
624 :param int thread_id: optional ID of the record/thread from ``model``
625 to which this mail should be attached. Only used if the message
626 does not reply to an existing thread and does not match any mail alias.
627 :return: list of [model, thread_id, custom_values, user_id]
629 assert isinstance(message, Message), 'message must be an email.message.Message at this point'
630 message_id = message.get('Message-Id')
631 email_from = decode_header(message, 'From')
632 email_to = decode_header(message, 'To')
633 references = decode_header(message, 'References')
634 in_reply_to = decode_header(message, 'In-Reply-To')
636 # 1. Verify if this is a reply to an existing thread
637 thread_references = references or in_reply_to
638 ref_match = thread_references and tools.reference_re.search(thread_references)
641 thread_id = int(ref_match.group(1))
642 model = ref_match.group(2) or model
643 if thread_id and model in self.pool:
644 model_obj = self.pool[model]
645 if model_obj.exists(cr, uid, thread_id) and hasattr(model_obj, 'message_update'):
646 _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',
647 email_from, email_to, message_id, model, thread_id, custom_values, uid)
648 return [(model, thread_id, custom_values, uid)]
650 # Verify whether this is a reply to a private message
652 message_ids = self.pool.get('mail.message').search(cr, uid, [
653 ('message_id', '=', in_reply_to),
654 '!', ('message_id', 'ilike', 'reply_to')
655 ], limit=1, context=context)
657 message = self.pool.get('mail.message').browse(cr, uid, message_ids[0], context=context)
658 _logger.info('Routing mail from %s to %s with Message-Id %s: direct reply to a private message: %s, custom_values: %s, uid: %s',
659 email_from, email_to, message_id, message.id, custom_values, uid)
660 return [(message.model, message.res_id, custom_values, uid)]
662 # 2. Look for a matching mail.alias entry
663 # Delivered-To is a safe bet in most modern MTAs, but we have to fallback on To + Cc values
664 # for all the odd MTAs out there, as there is no standard header for the envelope's `rcpt_to` value.
666 ','.join([decode_header(message, 'Delivered-To'),
667 decode_header(message, 'To'),
668 decode_header(message, 'Cc'),
669 decode_header(message, 'Resent-To'),
670 decode_header(message, 'Resent-Cc')])
671 local_parts = [e.split('@')[0] for e in tools.email_split(rcpt_tos)]
673 mail_alias = self.pool.get('mail.alias')
674 alias_ids = mail_alias.search(cr, uid, [('alias_name', 'in', local_parts)])
677 for alias in mail_alias.browse(cr, uid, alias_ids, context=context):
678 user_id = alias.alias_user_id.id
680 # TDE note: this could cause crashes, because no clue that the user
681 # that send the email has the right to create or modify a new document
682 # Fallback on user_id = uid
683 # Note: recognized partners will be added as followers anyway
684 # user_id = self._message_find_user_id(cr, uid, message, context=context)
686 _logger.info('No matching user_id for the alias %s', alias.alias_name)
687 routes.append((alias.alias_model_id.model, alias.alias_force_thread_id, \
688 eval(alias.alias_defaults), user_id))
689 _logger.info('Routing mail from %s to %s with Message-Id %s: direct alias match: %r',
690 email_from, email_to, message_id, routes)
693 # 3. Fallback to the provided parameters, if they work
694 model_pool = self.pool.get(model)
696 # Legacy: fallback to matching [ID] in the Subject
697 match = tools.res_re.search(decode_header(message, 'Subject'))
698 thread_id = match and match.group(1)
699 # Convert into int (bug spotted in 7.0 because of str)
701 thread_id = int(thread_id)
704 assert thread_id and hasattr(model_pool, 'message_update') or hasattr(model_pool, 'message_new'), \
705 "No possible route found for incoming message from %s to %s (Message-Id %s:)." \
706 "Create an appropriate mail.alias or force the destination model." % (email_from, email_to, message_id)
707 if thread_id and not model_pool.exists(cr, uid, thread_id):
708 _logger.warning('Received mail reply to missing document %s! Ignoring and creating new document instead for Message-Id %s',
709 thread_id, message_id)
711 _logger.info('Routing mail from %s to %s with Message-Id %s: fallback to model:%s, thread_id:%s, custom_values:%s, uid:%s',
712 email_from, email_to, message_id, model, thread_id, custom_values, uid)
713 return [(model, thread_id, custom_values, uid)]
715 def message_process(self, cr, uid, model, message, custom_values=None,
716 save_original=False, strip_attachments=False,
717 thread_id=None, context=None):
718 """ Process an incoming RFC2822 email message, relying on
719 ``mail.message.parse()`` for the parsing operation,
720 and ``message_route()`` to figure out the target model.
722 Once the target model is known, its ``message_new`` method
723 is called with the new message (if the thread record did not exist)
724 or its ``message_update`` method (if it did).
726 There is a special case where the target model is False: a reply
727 to a private message. In this case, we skip the message_new /
728 message_update step, to just post a new message using mail_thread
731 :param string model: the fallback model to use if the message
732 does not match any of the currently configured mail aliases
733 (may be None if a matching alias is supposed to be present)
734 :param message: source of the RFC2822 message
735 :type message: string or xmlrpclib.Binary
736 :type dict custom_values: optional dictionary of field values
737 to pass to ``message_new`` if a new record needs to be created.
738 Ignored if the thread record already exists, and also if a
739 matching mail.alias was found (aliases define their own defaults)
740 :param bool save_original: whether to keep a copy of the original
741 email source attached to the message after it is imported.
742 :param bool strip_attachments: whether to strip all attachments
743 before processing the message, in order to save some space.
744 :param int thread_id: optional ID of the record/thread from ``model``
745 to which this mail should be attached. When provided, this
746 overrides the automatic detection based on the message
752 # extract message bytes - we are forced to pass the message as binary because
753 # we don't know its encoding until we parse its headers and hence can't
754 # convert it to utf-8 for transport between the mailgate script and here.
755 if isinstance(message, xmlrpclib.Binary):
756 message = str(message.data)
757 # Warning: message_from_string doesn't always work correctly on unicode,
758 # we must use utf-8 strings here :-(
759 if isinstance(message, unicode):
760 message = message.encode('utf-8')
761 msg_txt = email.message_from_string(message)
763 # parse the message, verify we are not in a loop by checking message_id is not duplicated
764 msg = self.message_parse(cr, uid, msg_txt, save_original=save_original, context=context)
765 if strip_attachments:
766 msg.pop('attachments', None)
767 if msg.get('message_id'): # should always be True as message_parse generate one if missing
768 existing_msg_ids = self.pool.get('mail.message').search(cr, SUPERUSER_ID, [
769 ('message_id', '=', msg.get('message_id')),
772 _logger.info('Ignored mail from %s to %s with Message-Id %s:: found duplicated Message-Id during processing',
773 msg.get('from'), msg.get('to'), msg.get('message_id'))
776 # find possible routes for the message
777 routes = self.message_route(cr, uid, msg_txt, model,
778 thread_id, custom_values,
781 # postpone setting msg.partner_ids after message_post, to avoid double notifications
782 partner_ids = msg.pop('partner_ids', [])
785 for model, thread_id, custom_values, user_id in routes:
786 if self._name == 'mail.thread':
787 context.update({'thread_model': model})
789 model_pool = self.pool[model]
790 assert thread_id and hasattr(model_pool, 'message_update') or hasattr(model_pool, 'message_new'), \
791 "Undeliverable mail with Message-Id %s, model %s does not accept incoming emails" % \
792 (msg['message_id'], model)
794 # disabled subscriptions during message_new/update to avoid having the system user running the
795 # email gateway become a follower of all inbound messages
796 nosub_ctx = dict(context, mail_create_nosubscribe=True)
797 if thread_id and hasattr(model_pool, 'message_update'):
798 model_pool.message_update(cr, user_id, [thread_id], msg, context=nosub_ctx)
800 nosub_ctx = dict(nosub_ctx, mail_create_nolog=True)
801 thread_id = model_pool.message_new(cr, user_id, msg, custom_values, context=nosub_ctx)
803 assert thread_id == 0, "Posting a message without model should be with a null res_id, to create a private message."
804 model_pool = self.pool.get('mail.thread')
805 new_msg_id = model_pool.message_post(cr, uid, [thread_id], context=context, subtype='mail.mt_comment', **msg)
808 # postponed after message_post, because this is an external message and we don't want to create
809 # duplicate emails due to notifications
810 self.pool.get('mail.message').write(cr, uid, [new_msg_id], {'partner_ids': partner_ids}, context=context)
814 def message_new(self, cr, uid, msg_dict, custom_values=None, context=None):
815 """Called by ``message_process`` when a new message is received
816 for a given thread model, if the message did not belong to
818 The default behavior is to create a new record of the corresponding
819 model (based on some very basic info extracted from the message).
820 Additional behavior may be implemented by overriding this method.
822 :param dict msg_dict: a map containing the email details and
823 attachments. See ``message_process`` and
824 ``mail.message.parse`` for details.
825 :param dict custom_values: optional dictionary of additional
826 field values to pass to create()
827 when creating the new thread record.
828 Be careful, these values may override
829 any other values coming from the message.
830 :param dict context: if a ``thread_model`` value is present
831 in the context, its value will be used
832 to determine the model of the record
833 to create (instead of the current model).
835 :return: the id of the newly created thread object
840 if isinstance(custom_values, dict):
841 data = custom_values.copy()
842 model = context.get('thread_model') or self._name
843 model_pool = self.pool[model]
844 fields = model_pool.fields_get(cr, uid, context=context)
845 if 'name' in fields and not data.get('name'):
846 data['name'] = msg_dict.get('subject', '')
847 res_id = model_pool.create(cr, uid, data, context=context)
850 def message_update(self, cr, uid, ids, msg_dict, update_vals=None, context=None):
851 """Called by ``message_process`` when a new message is received
852 for an existing thread. The default behavior is to update the record
853 with update_vals taken from the incoming email.
854 Additional behavior may be implemented by overriding this
856 :param dict msg_dict: a map containing the email details and
857 attachments. See ``message_process`` and
858 ``mail.message.parse()`` for details.
859 :param dict update_vals: a dict containing values to update records
860 given their ids; if the dict is None or is
861 void, no write operation is performed.
864 self.write(cr, uid, ids, update_vals, context=context)
867 def _message_extract_payload(self, message, save_original=False):
868 """Extract body as HTML and attachments from the mail message"""
872 attachments.append(('original_email.eml', message.as_string()))
873 if not message.is_multipart() or 'text/' in message.get('content-type', ''):
874 encoding = message.get_content_charset()
875 body = message.get_payload(decode=True)
876 body = tools.ustr(body, encoding, errors='replace')
877 if message.get_content_type() == 'text/plain':
878 # text/plain -> <pre/>
879 body = tools.append_content_to_html(u'', body, preserve=True)
881 alternative = (message.get_content_type() == 'multipart/alternative')
882 for part in message.walk():
883 if part.get_content_maintype() == 'multipart':
884 continue # skip container
885 filename = part.get_filename() # None if normal part
886 encoding = part.get_content_charset() # None if attachment
887 # 1) Explicit Attachments -> attachments
888 if filename or part.get('content-disposition', '').strip().startswith('attachment'):
889 attachments.append((filename or 'attachment', part.get_payload(decode=True)))
891 # 2) text/plain -> <pre/>
892 if part.get_content_type() == 'text/plain' and (not alternative or not body):
893 body = tools.append_content_to_html(body, tools.ustr(part.get_payload(decode=True),
894 encoding, errors='replace'), preserve=True)
895 # 3) text/html -> raw
896 elif part.get_content_type() == 'text/html':
897 html = tools.ustr(part.get_payload(decode=True), encoding, errors='replace')
901 body = tools.append_content_to_html(body, html, plaintext=False)
902 # 4) Anything else -> attachment
904 attachments.append((filename or 'attachment', part.get_payload(decode=True)))
905 return body, attachments
907 def message_parse(self, cr, uid, message, save_original=False, context=None):
908 """Parses a string or email.message.Message representing an
909 RFC-2822 email, and returns a generic dict holding the
912 :param message: the message to parse
913 :type message: email.message.Message | string | unicode
914 :param bool save_original: whether the returned dict
915 should include an ``original`` attachment containing
916 the source of the message
918 :return: A dict with the following structure, where each
919 field may not be present if missing in original
922 { 'message_id': msg_id,
927 'body': unified_body,
928 'attachments': [('file1', 'bytes'),
936 if not isinstance(message, Message):
937 if isinstance(message, unicode):
938 # Warning: message_from_string doesn't always work correctly on unicode,
939 # we must use utf-8 strings here :-(
940 message = message.encode('utf-8')
941 message = email.message_from_string(message)
943 message_id = message['message-id']
945 # Very unusual situation, be we should be fault-tolerant here
946 message_id = "<%s@localhost>" % time.time()
947 _logger.debug('Parsing Message without message-id, generating a random one: %s', message_id)
948 msg_dict['message_id'] = message_id
950 if message.get('Subject'):
951 msg_dict['subject'] = decode(message.get('Subject'))
953 # Envelope fields not stored in mail.message but made available for message_new()
954 msg_dict['from'] = decode(message.get('from'))
955 msg_dict['to'] = decode(message.get('to'))
956 msg_dict['cc'] = decode(message.get('cc'))
958 if message.get('From'):
959 author_ids = self._message_find_partners(cr, uid, message, ['From'], context=context)
961 msg_dict['author_id'] = author_ids[0]
962 msg_dict['email_from'] = decode(message.get('from'))
963 partner_ids = self._message_find_partners(cr, uid, message, ['To', 'Cc'], context=context)
964 msg_dict['partner_ids'] = [(4, partner_id) for partner_id in partner_ids]
966 if message.get('Date'):
968 date_hdr = decode(message.get('Date'))
969 parsed_date = dateutil.parser.parse(date_hdr, fuzzy=True)
970 if parsed_date.utcoffset() is None:
971 # naive datetime, so we arbitrarily decide to make it
972 # UTC, there's no better choice. Should not happen,
973 # as RFC2822 requires timezone offset in Date headers.
974 stored_date = parsed_date.replace(tzinfo=pytz.utc)
976 stored_date = parsed_date.astimezone(tz=pytz.utc)
978 _logger.warning('Failed to parse Date header %r in incoming mail '
979 'with message-id %r, assuming current date/time.',
980 message.get('Date'), message_id)
981 stored_date = datetime.datetime.now()
982 msg_dict['date'] = stored_date.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT)
984 if message.get('In-Reply-To'):
985 parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', '=', decode(message['In-Reply-To']))])
987 msg_dict['parent_id'] = parent_ids[0]
989 if message.get('References') and 'parent_id' not in msg_dict:
990 parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', 'in',
991 [x.strip() for x in decode(message['References']).split()])])
993 msg_dict['parent_id'] = parent_ids[0]
995 msg_dict['body'], msg_dict['attachments'] = self._message_extract_payload(message, save_original=save_original)
998 #------------------------------------------------------
1000 #------------------------------------------------------
1002 def log(self, cr, uid, id, message, secondary=False, context=None):
1003 _logger.warning("log() is deprecated. As this module inherit from "\
1004 "mail.thread, the message will be managed by this "\
1005 "module instead of by the res.log mechanism. Please "\
1006 "use mail_thread.message_post() instead of the "\
1007 "now deprecated res.log.")
1008 self.message_post(cr, uid, [id], message, context=context)
1010 def _message_add_suggested_recipient(self, cr, uid, result, obj, partner=None, email=None, reason='', context=None):
1011 """ Called by message_get_suggested_recipients, to add a suggested
1012 recipient in the result dictionary. The form is :
1013 partner_id, partner_name<partner_email> or partner_name, reason """
1014 if email and not partner:
1015 # get partner info from email
1016 partner_info = self.message_get_partner_info_from_emails(cr, uid, [email], context=context, res_id=obj.id)[0]
1017 if partner_info.get('partner_id'):
1018 partner = self.pool.get('res.partner').browse(cr, SUPERUSER_ID, [partner_info.get('partner_id')], context=context)[0]
1019 if email and email in [val[1] for val in result[obj.id]]: # already existing email -> skip
1021 if partner and partner in obj.message_follower_ids: # recipient already in the followers -> skip
1023 if partner and partner in [val[0] for val in result[obj.id]]: # already existing partner ID -> skip
1025 if partner and partner.email: # complete profile: id, name <email>
1026 result[obj.id].append((partner.id, '%s<%s>' % (partner.name, partner.email), reason))
1027 elif partner: # incomplete profile: id, name
1028 result[obj.id].append((partner.id, '%s' % (partner.name), reason))
1029 else: # unknown partner, we are probably managing an email address
1030 result[obj.id].append((False, email, reason))
1033 def message_get_suggested_recipients(self, cr, uid, ids, context=None):
1034 """ Returns suggested recipients for ids. Those are a list of
1035 tuple (partner_id, partner_name, reason), to be managed by Chatter. """
1036 result = dict.fromkeys(ids, list())
1037 if self._all_columns.get('user_id'):
1038 for obj in self.browse(cr, SUPERUSER_ID, ids, context=context): # SUPERUSER because of a read on res.users that would crash otherwise
1039 if not obj.user_id or not obj.user_id.partner_id:
1041 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)
1044 def message_get_partner_info_from_emails(self, cr, uid, emails, link_mail=False, context=None, res_id=None):
1045 """ Wrapper with weird order parameter because of 7.0 fix.
1047 TDE TODO: remove me in 8.0 """
1048 return self.message_find_partner_from_emails(cr, uid, res_id, emails, link_mail=link_mail, context=context)
1050 def message_find_partner_from_emails(self, cr, uid, id, emails, link_mail=False, context=None):
1051 """ Convert a list of emails into a list partner_ids and a list
1052 new_partner_ids. The return value is non conventional because
1053 it is meant to be used by the mail widget.
1055 :return dict: partner_ids and new_partner_ids
1057 TDE TODO: merge me with other partner finding methods in 8.0 """
1058 mail_message_obj = self.pool.get('mail.message')
1059 partner_obj = self.pool.get('res.partner')
1061 if id and self._name != 'mail.thread':
1062 obj = self.browse(cr, SUPERUSER_ID, id, context=context)
1065 for email in emails:
1066 partner_info = {'full_name': email, 'partner_id': False}
1067 m = re.search(r"((.+?)\s*<)?([^<>]+@[^<>]+)>?", email, re.IGNORECASE | re.DOTALL)
1070 email_address = m.group(3)
1071 # first try: check in document's followers
1073 for follower in obj.message_follower_ids:
1074 if follower.email == email_address:
1075 partner_info['partner_id'] = follower.id
1076 # second try: check in partners
1077 if not partner_info.get('partner_id'):
1078 ids = partner_obj.search(cr, SUPERUSER_ID, [('email', 'ilike', email_address), ('user_ids', '!=', False)], limit=1, context=context)
1080 ids = partner_obj.search(cr, SUPERUSER_ID, [('email', 'ilike', email_address)], limit=1, context=context)
1082 partner_info['partner_id'] = ids[0]
1083 result.append(partner_info)
1085 # link mail with this from mail to the new partner id
1086 if link_mail and partner_info['partner_id']:
1087 message_ids = mail_message_obj.search(cr, SUPERUSER_ID, [
1089 ('email_from', '=', email),
1090 ('email_from', 'ilike', '<%s>' % email),
1091 ('author_id', '=', False)
1094 mail_message_obj.write(cr, SUPERUSER_ID, message_ids, {'author_id': partner_info['partner_id']}, context=context)
1097 def message_post(self, cr, uid, thread_id, body='', subject=None, type='notification',
1098 subtype=None, parent_id=False, attachments=None, context=None,
1099 content_subtype='html', **kwargs):
1100 """ Post a new message in an existing thread, returning the new
1103 :param int thread_id: thread ID to post into, or list with one ID;
1104 if False/0, mail.message model will also be set as False
1105 :param str body: body of the message, usually raw HTML that will
1107 :param str type: see mail_message.type field
1108 :param str content_subtype:: if plaintext: convert body into html
1109 :param int parent_id: handle reply to a previous message by adding the
1110 parent partners to the message in case of private discussion
1111 :param tuple(str,str) attachments or list id: list of attachment tuples in the form
1112 ``(name,content)``, where content is NOT base64 encoded
1114 Extra keyword arguments will be used as default column values for the
1115 new mail.message record. Special cases:
1116 - attachment_ids: supposed not attached to any document; attach them
1117 to the related document. Should only be set by Chatter.
1118 :return int: ID of newly created mail.message
1122 if attachments is None:
1124 mail_message = self.pool.get('mail.message')
1125 ir_attachment = self.pool.get('ir.attachment')
1127 assert (not thread_id) or \
1128 isinstance(thread_id, (int, long)) or \
1129 (isinstance(thread_id, (list, tuple)) and len(thread_id) == 1), \
1130 "Invalid thread_id; should be 0, False, an ID or a list with one ID"
1131 if isinstance(thread_id, (list, tuple)):
1132 thread_id = thread_id[0]
1134 # if we're processing a message directly coming from the gateway, the destination model was
1135 # set in the context.
1138 model = context.get('thread_model', self._name) if self._name == 'mail.thread' else self._name
1139 if model != self._name:
1140 del context['thread_model']
1141 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)
1143 # 0: Parse email-from, try to find a better author_id based on document's followers for incoming emails
1144 email_from = kwargs.get('email_from')
1145 if email_from and thread_id and type == 'email' and kwargs.get('author_id'):
1146 email_list = tools.email_split(email_from)
1147 doc = self.browse(cr, uid, thread_id, context=context)
1148 if email_list and doc:
1149 author_ids = self.pool.get('res.partner').search(cr, uid, [
1150 ('email', 'ilike', email_list[0]),
1151 ('id', 'in', [f.id for f in doc.message_follower_ids])
1152 ], limit=1, context=context)
1154 kwargs['author_id'] = author_ids[0]
1155 author_id = kwargs.get('author_id')
1156 if author_id is None: # keep False values
1157 author_id = self.pool.get('mail.message')._get_default_author(cr, uid, context=context)
1159 # 1: Handle content subtype: if plaintext, converto into HTML
1160 if content_subtype == 'plaintext':
1161 body = tools.plaintext2html(body)
1163 # 2: Private message: add recipients (recipients and author of parent message) - current author
1164 # + legacy-code management (! we manage only 4 and 6 commands)
1166 kwargs_partner_ids = kwargs.pop('partner_ids', [])
1167 for partner_id in kwargs_partner_ids:
1168 if isinstance(partner_id, (list, tuple)) and partner_id[0] == 4 and len(partner_id) == 2:
1169 partner_ids.add(partner_id[1])
1170 if isinstance(partner_id, (list, tuple)) and partner_id[0] == 6 and len(partner_id) == 3:
1171 partner_ids |= set(partner_id[2])
1172 elif isinstance(partner_id, (int, long)):
1173 partner_ids.add(partner_id)
1175 pass # we do not manage anything else
1176 if parent_id and not model:
1177 parent_message = mail_message.browse(cr, uid, parent_id, context=context)
1178 private_followers = set([partner.id for partner in parent_message.partner_ids])
1179 if parent_message.author_id:
1180 private_followers.add(parent_message.author_id.id)
1181 private_followers -= set([author_id])
1182 partner_ids |= private_followers
1185 # - HACK TDE FIXME: Chatter: attachments linked to the document (not done JS-side), load the message
1186 attachment_ids = kwargs.pop('attachment_ids', []) or [] # because we could receive None (some old code sends None)
1188 filtered_attachment_ids = ir_attachment.search(cr, SUPERUSER_ID, [
1189 ('res_model', '=', 'mail.compose.message'),
1190 ('create_uid', '=', uid),
1191 ('id', 'in', attachment_ids)], context=context)
1192 if filtered_attachment_ids:
1193 ir_attachment.write(cr, SUPERUSER_ID, filtered_attachment_ids, {'res_model': model, 'res_id': thread_id}, context=context)
1194 attachment_ids = [(4, id) for id in attachment_ids]
1195 # Handle attachments parameter, that is a dictionary of attachments
1196 for name, content in attachments:
1197 if isinstance(content, unicode):
1198 content = content.encode('utf-8')
1201 'datas': base64.b64encode(str(content)),
1202 'datas_fname': name,
1203 'description': name,
1205 'res_id': thread_id,
1207 attachment_ids.append((0, 0, data_attach))
1209 # 4: mail.message.subtype
1212 if '.' not in subtype:
1213 subtype = 'mail.%s' % subtype
1214 ref = self.pool.get('ir.model.data').get_object_reference(cr, uid, *subtype.split('.'))
1215 subtype_id = ref and ref[1] or False
1217 # automatically subscribe recipients if asked to
1218 if context.get('mail_post_autofollow') and thread_id and partner_ids:
1219 partner_to_subscribe = partner_ids
1220 if context.get('mail_post_autofollow_partner_ids'):
1221 partner_to_subscribe = filter(lambda item: item in context.get('mail_post_autofollow_partner_ids'), partner_ids)
1222 self.message_subscribe(cr, uid, [thread_id], list(partner_to_subscribe), context=context)
1224 # _mail_flat_thread: automatically set free messages to the first posted message
1225 if self._mail_flat_thread and not parent_id and thread_id:
1226 message_ids = mail_message.search(cr, uid, ['&', ('res_id', '=', thread_id), ('model', '=', model)], context=context, order="id ASC", limit=1)
1227 parent_id = message_ids and message_ids[0] or False
1228 # 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
1230 message_ids = mail_message.search(cr, SUPERUSER_ID, [('id', '=', parent_id), ('parent_id', '!=', False)], context=context)
1231 # avoid loops when finding ancestors
1234 message = mail_message.browse(cr, SUPERUSER_ID, message_ids[0], context=context)
1235 while (message.parent_id and message.parent_id.id not in processed_list):
1236 processed_list.append(message.parent_id.id)
1237 message = message.parent_id
1238 parent_id = message.id
1242 'author_id': author_id,
1244 'res_id': thread_id or False,
1246 'subject': subject or False,
1248 'parent_id': parent_id,
1249 'attachment_ids': attachment_ids,
1250 'subtype_id': subtype_id,
1251 'partner_ids': [(4, pid) for pid in partner_ids],
1254 # Avoid warnings about non-existing fields
1255 for x in ('from', 'to', 'cc'):
1258 # Create and auto subscribe the author
1259 msg_id = mail_message.create(cr, uid, values, context=context)
1260 message = mail_message.browse(cr, uid, msg_id, context=context)
1261 if message.author_id and thread_id and type != 'notification' and not context.get('mail_create_nosubscribe'):
1262 self.message_subscribe(cr, uid, [thread_id], [message.author_id.id], context=context)
1265 #------------------------------------------------------
1266 # Compatibility methods: do not use
1267 # TDE TODO: remove me in 8.0
1268 #------------------------------------------------------
1270 def message_create_partners_from_emails(self, cr, uid, emails, context=None):
1271 return {'partner_ids': [], 'new_partner_ids': []}
1273 def message_post_user_api(self, cr, uid, thread_id, body='', parent_id=False,
1274 attachment_ids=None, content_subtype='plaintext',
1275 context=None, **kwargs):
1276 return self.message_post(cr, uid, thread_id, body=body, parent_id=parent_id,
1277 attachment_ids=attachment_ids, content_subtype=content_subtype,
1278 context=context, **kwargs)
1280 #------------------------------------------------------
1282 #------------------------------------------------------
1284 def message_get_subscription_data(self, cr, uid, ids, user_pid=None, context=None):
1285 """ Wrapper to get subtypes data. """
1286 return self._get_subscription_data(cr, uid, ids, None, None, user_pid=user_pid, context=context)
1288 def message_subscribe_users(self, cr, uid, ids, user_ids=None, subtype_ids=None, context=None):
1289 """ Wrapper on message_subscribe, using users. If user_ids is not
1290 provided, subscribe uid instead. """
1291 if user_ids is None:
1293 partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
1294 return self.message_subscribe(cr, uid, ids, partner_ids, subtype_ids=subtype_ids, context=context)
1296 def message_subscribe(self, cr, uid, ids, partner_ids, subtype_ids=None, context=None):
1297 """ Add partners to the records followers. """
1298 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
1299 if set(partner_ids) == set([user_pid]):
1301 self.check_access_rights(cr, uid, 'read')
1302 except (osv.except_osv, orm.except_orm):
1305 self.check_access_rights(cr, uid, 'write')
1307 # subscribe partners
1308 self.write(cr, SUPERUSER_ID, ids, {'message_follower_ids': [(4, pid) for pid in partner_ids]}, context=context)
1310 # subtype specified: update the subscriptions
1311 fol_obj = self.pool.get('mail.followers')
1312 if subtype_ids is not None:
1313 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids), ('partner_id', 'in', partner_ids)], context=context)
1314 fol_obj.write(cr, SUPERUSER_ID, fol_ids, {'subtype_ids': [(6, 0, subtype_ids)]}, context=context)
1315 # no subtypes: default ones for new subscription, do not update existing subscriptions
1317 # search new subscriptions: subtype_ids is False
1318 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [
1319 ('res_model', '=', self._name),
1320 ('res_id', 'in', ids),
1321 ('partner_id', 'in', partner_ids),
1322 ('subtype_ids', '=', False)
1325 subtype_obj = self.pool.get('mail.message.subtype')
1326 subtype_ids = subtype_obj.search(cr, uid, [('default', '=', True), '|', ('res_model', '=', self._name), ('res_model', '=', False)], context=context)
1327 fol_obj.write(cr, SUPERUSER_ID, fol_ids, {'subtype_ids': [(6, 0, subtype_ids)]}, context=context)
1331 def message_unsubscribe_users(self, cr, uid, ids, user_ids=None, context=None):
1332 """ Wrapper on message_subscribe, using users. If user_ids is not
1333 provided, unsubscribe uid instead. """
1334 if user_ids is None:
1336 partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
1337 return self.message_unsubscribe(cr, uid, ids, partner_ids, context=context)
1339 def message_unsubscribe(self, cr, uid, ids, partner_ids, context=None):
1340 """ Remove partners from the records followers. """
1341 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
1342 if set(partner_ids) == set([user_pid]):
1343 self.check_access_rights(cr, uid, 'read')
1345 self.check_access_rights(cr, uid, 'write')
1346 return self.write(cr, SUPERUSER_ID, ids, {'message_follower_ids': [(3, pid) for pid in partner_ids]}, context=context)
1348 def _message_get_auto_subscribe_fields(self, cr, uid, updated_fields, auto_follow_fields=['user_id'], context=None):
1349 """ Returns the list of relational fields linking to res.users that should
1350 trigger an auto subscribe. The default list checks for the fields
1352 - linking to res.users
1353 - with track_visibility set
1354 In OpenERP V7, this is sufficent for all major addon such as opportunity,
1355 project, issue, recruitment, sale.
1356 Override this method if a custom behavior is needed about fields
1357 that automatically subscribe users.
1360 for name, column_info in self._all_columns.items():
1361 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':
1362 user_field_lst.append(name)
1363 return user_field_lst
1365 def message_auto_subscribe(self, cr, uid, ids, updated_fields, context=None):
1367 1. fetch project subtype related to task (parent_id.res_model = 'project.task')
1368 2. for each project subtype: subscribe the follower to the task
1370 subtype_obj = self.pool.get('mail.message.subtype')
1371 follower_obj = self.pool.get('mail.followers')
1373 # fetch auto_follow_fields
1374 user_field_lst = self._message_get_auto_subscribe_fields(cr, uid, updated_fields, context=context)
1376 # fetch related record subtypes
1377 related_subtype_ids = subtype_obj.search(cr, uid, ['|', ('res_model', '=', False), ('parent_id.res_model', '=', self._name)], context=context)
1378 subtypes = subtype_obj.browse(cr, uid, related_subtype_ids, context=context)
1379 default_subtypes = [subtype for subtype in subtypes if subtype.res_model == False]
1380 related_subtypes = [subtype for subtype in subtypes if subtype.res_model != False]
1381 relation_fields = set([subtype.relation_field for subtype in subtypes if subtype.relation_field != False])
1382 if (not related_subtypes or not any(relation in updated_fields for relation in relation_fields)) and not user_field_lst:
1385 for record in self.browse(cr, uid, ids, context=context):
1386 new_followers = dict()
1387 parent_res_id = False
1388 parent_model = False
1389 for subtype in related_subtypes:
1390 if not subtype.relation_field or not subtype.parent_id:
1392 if not subtype.relation_field in self._columns or not getattr(record, subtype.relation_field, False):
1394 parent_res_id = getattr(record, subtype.relation_field).id
1395 parent_model = subtype.res_model
1396 follower_ids = follower_obj.search(cr, SUPERUSER_ID, [
1397 ('res_model', '=', parent_model),
1398 ('res_id', '=', parent_res_id),
1399 ('subtype_ids', 'in', [subtype.id])
1401 for follower in follower_obj.browse(cr, SUPERUSER_ID, follower_ids, context=context):
1402 new_followers.setdefault(follower.partner_id.id, set()).add(subtype.parent_id.id)
1404 if parent_res_id and parent_model:
1405 for subtype in default_subtypes:
1406 follower_ids = follower_obj.search(cr, SUPERUSER_ID, [
1407 ('res_model', '=', parent_model),
1408 ('res_id', '=', parent_res_id),
1409 ('subtype_ids', 'in', [subtype.id])
1411 for follower in follower_obj.browse(cr, SUPERUSER_ID, follower_ids, context=context):
1412 new_followers.setdefault(follower.partner_id.id, set()).add(subtype.id)
1414 # add followers coming from res.users relational fields that are tracked
1415 user_ids = [getattr(record, name).id for name in user_field_lst if getattr(record, name)]
1416 user_id_partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, SUPERUSER_ID, user_ids, context=context)]
1417 for partner_id in user_id_partner_ids:
1418 new_followers.setdefault(partner_id, None)
1420 for pid, subtypes in new_followers.items():
1421 subtypes = list(subtypes) if subtypes is not None else None
1422 self.message_subscribe(cr, uid, [record.id], [pid], subtypes, context=context)
1424 # find first email message, set it as unread for auto_subscribe fields for them to have a notification
1425 if user_id_partner_ids:
1426 msg_ids = self.pool.get('mail.message').search(cr, uid, [
1427 ('model', '=', self._name),
1428 ('res_id', '=', record.id),
1429 ('type', '=', 'email')], limit=1, context=context)
1430 if not msg_ids and record.message_ids:
1431 msg_ids = [record.message_ids[-1].id]
1433 self.pool.get('mail.notification')._notify(cr, uid, msg_ids[0], partners_to_notify=user_id_partner_ids, context=context)
1437 #------------------------------------------------------
1439 #------------------------------------------------------
1441 def message_mark_as_unread(self, cr, uid, ids, context=None):
1442 """ Set as unread. """
1443 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1445 UPDATE mail_notification SET
1448 message_id IN (SELECT id from mail_message where res_id=any(%s) and model=%s limit 1) and
1450 ''', (ids, self._name, partner_id))
1453 def message_mark_as_read(self, cr, uid, ids, context=None):
1454 """ Set as read. """
1455 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1457 UPDATE mail_notification SET
1460 message_id IN (SELECT id FROM mail_message WHERE res_id=ANY(%s) AND model=%s) AND
1462 ''', (ids, self._name, partner_id))
1465 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: