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 alias = object_id.alias_id
105 elif catchall_domain and model: # no specific res_id given -> generic help message, take an example alias (i.e. alias of some section_id)
106 model_id = self.pool.get('ir.model').search(cr, uid, [("model", "=", self._name)], context=context)[0]
107 alias_obj = self.pool.get('mail.alias')
108 alias_ids = alias_obj.search(cr, uid, [("alias_model_id", "=", model_id)], context=context, limit=1, order='id ASC')
110 alias = alias_obj.browse(cr, uid, alias_ids[0], context=context)
113 alias_email = alias.name_get()[0][1]
114 return _("""<p class='oe_view_nocontent_create'>
115 Click here to add a new %(document)s or send an email to: <a href='mailto:%(email)s'>%(email)s</a>
119 'document': document_name,
120 'email': alias_email,
121 'static_help': help or ''
124 if document_name != 'document' and help and help.find("oe_view_nocontent_create") == -1:
125 return _("<p class='oe_view_nocontent_create'>Click here to add a new %(document)s</p>%(static_help)s") % {
126 'document': document_name,
127 'static_help': help or '',
132 def _get_message_data(self, cr, uid, ids, name, args, context=None):
134 - message_unread: has uid unread message for the document
135 - message_summary: html snippet summarizing the Chatter for kanban views """
136 res = dict((id, dict(message_unread=False, message_unread_count=0, message_summary=' ')) for id in ids)
137 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
139 # search for unread messages, directly in SQL to improve performances
140 cr.execute(""" SELECT m.res_id FROM mail_message m
141 RIGHT JOIN mail_notification n
142 ON (n.message_id = m.id AND n.partner_id = %s AND (n.read = False or n.read IS NULL))
143 WHERE m.model = %s AND m.res_id in %s""",
144 (user_pid, self._name, tuple(ids),))
145 for result in cr.fetchall():
146 res[result[0]]['message_unread'] = True
147 res[result[0]]['message_unread_count'] += 1
150 if res[id]['message_unread_count']:
151 title = res[id]['message_unread_count'] > 1 and _("You have %d unread messages") % res[id]['message_unread_count'] or _("You have one unread message")
152 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"))
155 def _get_subscription_data(self, cr, uid, ids, name, args, context=None):
157 - message_subtype_data: data about document subtypes: which are
158 available, which are followed if any """
159 res = dict((id, dict(message_subtype_data='')) for id in ids)
160 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
162 # find current model subtypes, add them to a dictionary
163 subtype_obj = self.pool.get('mail.message.subtype')
164 subtype_ids = subtype_obj.search(cr, uid, ['|', ('res_model', '=', self._name), ('res_model', '=', False)], context=context)
165 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))
167 res[id]['message_subtype_data'] = subtype_dict.copy()
169 # find the document followers, update the data
170 fol_obj = self.pool.get('mail.followers')
171 fol_ids = fol_obj.search(cr, uid, [
172 ('partner_id', '=', user_pid),
173 ('res_id', 'in', ids),
174 ('res_model', '=', self._name),
176 for fol in fol_obj.browse(cr, uid, fol_ids, context=context):
177 thread_subtype_dict = res[fol.res_id]['message_subtype_data']
178 for subtype in fol.subtype_ids:
179 thread_subtype_dict[subtype.name]['followed'] = True
180 res[fol.res_id]['message_subtype_data'] = thread_subtype_dict
184 def _search_message_unread(self, cr, uid, obj=None, name=None, domain=None, context=None):
185 return [('message_ids.to_read', '=', True)]
187 def _get_followers(self, cr, uid, ids, name, arg, context=None):
188 fol_obj = self.pool.get('mail.followers')
189 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids)])
190 res = dict((id, dict(message_follower_ids=[], message_is_follower=False)) for id in ids)
191 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
192 for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids):
193 res[fol.res_id]['message_follower_ids'].append(fol.partner_id.id)
194 if fol.partner_id.id == user_pid:
195 res[fol.res_id]['message_is_follower'] = True
198 def _set_followers(self, cr, uid, id, name, value, arg, context=None):
201 partner_obj = self.pool.get('res.partner')
202 fol_obj = self.pool.get('mail.followers')
204 # read the old set of followers, and determine the new set of followers
205 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', '=', id)])
206 old = set(fol.partner_id.id for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids))
209 for command in value or []:
210 if isinstance(command, (int, long)):
212 elif command[0] == 0:
213 new.add(partner_obj.create(cr, uid, command[2], context=context))
214 elif command[0] == 1:
215 partner_obj.write(cr, uid, [command[1]], command[2], context=context)
217 elif command[0] == 2:
218 partner_obj.unlink(cr, uid, [command[1]], context=context)
219 new.discard(command[1])
220 elif command[0] == 3:
221 new.discard(command[1])
222 elif command[0] == 4:
224 elif command[0] == 5:
226 elif command[0] == 6:
227 new = set(command[2])
229 # remove partners that are no longer followers
230 fol_ids = fol_obj.search(cr, SUPERUSER_ID,
231 [('res_model', '=', self._name), ('res_id', '=', id), ('partner_id', 'not in', list(new))])
232 fol_obj.unlink(cr, SUPERUSER_ID, fol_ids)
235 for partner_id in new - old:
236 fol_obj.create(cr, SUPERUSER_ID, {'res_model': self._name, 'res_id': id, 'partner_id': partner_id})
238 def _search_followers(self, cr, uid, obj, name, args, context):
239 fol_obj = self.pool.get('mail.followers')
241 for field, operator, value in args:
243 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('partner_id', operator, value)])
244 res_ids = [fol.res_id for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids)]
245 res.append(('id', 'in', res_ids))
249 'message_is_follower': fields.function(_get_followers,
250 type='boolean', string='Is a Follower', multi='_get_followers,'),
251 'message_follower_ids': fields.function(_get_followers, fnct_inv=_set_followers,
252 fnct_search=_search_followers, type='many2many',
253 obj='res.partner', string='Followers', multi='_get_followers'),
254 'message_ids': fields.one2many('mail.message', 'res_id',
255 domain=lambda self: [('model', '=', self._name)],
258 help="Messages and communication history"),
259 'message_unread': fields.function(_get_message_data,
260 fnct_search=_search_message_unread, multi="_get_message_data",
261 type='boolean', string='Unread Messages',
262 help="If checked new messages require your attention."),
263 'message_summary': fields.function(_get_message_data, method=True,
264 type='text', string='Summary', multi="_get_message_data",
265 help="Holds the Chatter summary (number of messages, ...). "\
266 "This summary is directly in html format in order to "\
267 "be inserted in kanban views."),
270 #------------------------------------------------------
271 # CRUD overrides for automatic subscription and logging
272 #------------------------------------------------------
274 def create(self, cr, uid, values, context=None):
275 """ Chatter override :
277 - subscribe followers of parent
278 - log a creation message
282 thread_id = super(mail_thread, self).create(cr, uid, values, context=context)
284 # subscribe uid unless asked not to
285 if not context.get('mail_create_nosubscribe'):
286 self.message_subscribe_users(cr, uid, [thread_id], [uid], context=context)
287 self.message_auto_subscribe(cr, uid, [thread_id], values.keys(), context=context)
289 # automatic logging unless asked not to (mainly for various testing purpose)
290 if not context.get('mail_create_nolog'):
291 self.message_post(cr, uid, thread_id, body=_('%s created') % (self._description), context=context)
294 def write(self, cr, uid, ids, values, context=None):
295 if isinstance(ids, (int, long)):
297 # Track initial values of tracked fields
298 tracked_fields = self._get_tracked_fields(cr, uid, values.keys(), context=context)
300 initial = self.read(cr, uid, ids, tracked_fields.keys(), context=context)
301 initial_values = dict((item['id'], item) for item in initial)
303 # Perform write, update followers
304 result = super(mail_thread, self).write(cr, uid, ids, values, context=context)
305 self.message_auto_subscribe(cr, uid, ids, values.keys(), context=context)
307 # Perform the tracking
309 self.message_track(cr, uid, ids, tracked_fields, initial_values, context=context)
312 def unlink(self, cr, uid, ids, context=None):
313 """ Override unlink to delete messages and followers. This cannot be
314 cascaded, because link is done through (res_model, res_id). """
315 msg_obj = self.pool.get('mail.message')
316 fol_obj = self.pool.get('mail.followers')
317 # delete messages and notifications
318 msg_ids = msg_obj.search(cr, uid, [('model', '=', self._name), ('res_id', 'in', ids)], context=context)
319 msg_obj.unlink(cr, uid, msg_ids, context=context)
321 res = super(mail_thread, self).unlink(cr, uid, ids, context=context)
323 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids)], context=context)
324 fol_obj.unlink(cr, SUPERUSER_ID, fol_ids, context=context)
327 def copy(self, cr, uid, id, default=None, context=None):
328 default = default or {}
329 default['message_ids'] = []
330 default['message_follower_ids'] = []
331 return super(mail_thread, self).copy(cr, uid, id, default=default, context=context)
333 #------------------------------------------------------
334 # Automatically log tracked fields
335 #------------------------------------------------------
337 def _get_tracked_fields(self, cr, uid, updated_fields, context=None):
338 """ Return a structure of tracked fields for the current model.
339 :param list updated_fields: modified field names
340 :return list: a list of (field_name, column_info obj), containing
341 always tracked fields and modified on_change fields
344 for name, column_info in self._all_columns.items():
345 visibility = getattr(column_info.column, 'track_visibility', False)
346 if visibility == 'always' or (visibility == 'onchange' and name in updated_fields) or name in self._track:
350 return self.fields_get(cr, uid, lst, context=context)
352 def message_track(self, cr, uid, ids, tracked_fields, initial_values, context=None):
354 def convert_for_display(value, col_info):
355 if not value and col_info['type'] == 'boolean':
359 if col_info['type'] == 'many2one':
361 if col_info['type'] == 'selection':
362 return dict(col_info['selection'])[value]
365 def format_message(message_description, tracked_values):
367 if message_description:
368 message = '<span>%s</span>' % message_description
369 for name, change in tracked_values.items():
370 message += '<div> • <b>%s</b>: ' % change.get('col_info')
371 if change.get('old_value'):
372 message += '%s → ' % change.get('old_value')
373 message += '%s</div>' % change.get('new_value')
376 if not tracked_fields:
379 for record in self.read(cr, uid, ids, tracked_fields.keys(), context=context):
380 initial = initial_values[record['id']]
384 # generate tracked_values data structure: {'col_name': {col_info, new_value, old_value}}
385 for col_name, col_info in tracked_fields.items():
386 if record[col_name] == initial[col_name] and getattr(self._all_columns[col_name].column, 'track_visibility', None) == 'always':
387 tracked_values[col_name] = dict(col_info=col_info['string'],
388 new_value=convert_for_display(record[col_name], col_info))
389 elif record[col_name] != initial[col_name]:
390 if getattr(self._all_columns[col_name].column, 'track_visibility', None) in ['always', 'onchange']:
391 tracked_values[col_name] = dict(col_info=col_info['string'],
392 old_value=convert_for_display(initial[col_name], col_info),
393 new_value=convert_for_display(record[col_name], col_info))
394 if col_name in tracked_fields:
395 changes.append(col_name)
399 # find subtypes and post messages or log if no subtype found
401 for field, track_info in self._track.items():
402 if field not in changes:
404 for subtype, method in track_info.items():
405 if method(self, cr, uid, record, context):
406 subtypes.append(subtype)
409 for subtype in subtypes:
411 subtype_rec = self.pool.get('ir.model.data').get_object(cr, uid, subtype.split('.')[0], subtype.split('.')[1], context=context)
412 except ValueError, e:
413 _logger.debug('subtype %s not found, giving error "%s"' % (subtype, e))
415 message = format_message(subtype_rec.description if subtype_rec.description else subtype_rec.name, tracked_values)
416 self.message_post(cr, uid, record['id'], body=message, subtype=subtype, context=context)
419 message = format_message('', tracked_values)
420 self.message_post(cr, uid, record['id'], body=message, context=context)
423 #------------------------------------------------------
424 # mail.message wrappers and tools
425 #------------------------------------------------------
427 def _needaction_domain_get(self, cr, uid, context=None):
429 return [('message_unread', '=', True)]
432 def _garbage_collect_attachments(self, cr, uid, context=None):
433 """ Garbage collect lost mail attachments. Those are attachments
434 - linked to res_model 'mail.compose.message', the composer wizard
435 - with res_id 0, because they were created outside of an existing
436 wizard (typically user input through Chatter or reports
437 created on-the-fly by the templates)
438 - unused since at least one day (create_date and write_date)
440 limit_date = datetime.datetime.utcnow() - datetime.timedelta(days=1)
441 limit_date_str = datetime.datetime.strftime(limit_date, tools.DEFAULT_SERVER_DATETIME_FORMAT)
442 ir_attachment_obj = self.pool.get('ir.attachment')
443 attach_ids = ir_attachment_obj.search(cr, uid, [
444 ('res_model', '=', 'mail.compose.message'),
446 ('create_date', '<', limit_date_str),
447 ('write_date', '<', limit_date_str),
449 ir_attachment_obj.unlink(cr, uid, attach_ids, context=context)
452 def _get_document_action(self, cr, uid, id, model=None, context=None):
453 """ Return an action to open the document. This method is meant to be
454 overridden in addons that want to give specific view ids for example.
456 :param int id: id of the document to open
457 :param string model: specific model that overrides self._name
460 'type': 'ir.actions.act_window',
461 'res_model': model or self._name,
464 'views': [(False, 'form')],
469 def _get_inbox_action_xml_id(self, cr, uid, context=None):
470 """ When redirecting towards the Inbox, choose which action xml_id has
471 to be fetched. This method is meant to be inherited, at least in portal
472 because portal users have a different Inbox action than classic users. """
473 return ('mail', 'action_mail_inbox_feeds')
475 def message_redirect_action(self, cr, uid, context=None):
476 """ For a given message, return an action that either
477 - opens the form view of the related document if model, res_id, and
478 read access to the document
479 - opens the Inbox with a default search on the conversation if model,
481 - opens the Inbox with context propagated
487 # default action is the Inbox action
488 self.pool.get('res.users').browse(cr, SUPERUSER_ID, uid, context=context)
489 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))
490 action = self.pool.get(act_model).read(cr, uid, act_id, [])
492 # if msg_id specified: try to redirect to the document or fallback on the Inbox
493 msg_id = context.get('params', {}).get('message_id')
496 msg = self.pool.get('mail.message').browse(cr, uid, msg_id, context=context)
497 if msg.model and msg.res_id and self.pool.get(msg.model).check_access_rights(cr, uid, 'read', raise_exception=False):
499 model_obj = self.pool.get(msg.model)
500 model_obj.check_access_rule(cr, uid, [msg.res_id], 'read', context=context)
501 if not hasattr(model_obj, '_get_document_action'):
502 action = self.pool.get('mail.thread')._get_document_action(cr, uid, msg.res_id, model=msg.model, context=context)
504 action = model_obj._get_document_action(cr, uid, msg.res_id, context=context)
505 except (osv.except_osv, orm.except_orm):
508 'search_default_model': msg.model,
509 'search_default_res_id': msg.res_id,
514 #------------------------------------------------------
516 #------------------------------------------------------
518 def message_get_reply_to(self, cr, uid, ids, context=None):
519 if not self._inherits.get('mail.alias'):
520 return [False for id in ids]
521 return ["%s@%s" % (record['alias_name'], record['alias_domain'])
522 if record.get('alias_domain') and record.get('alias_name')
524 for record in self.read(cr, uid, ids, ['alias_name', 'alias_domain'], context=context)]
526 #------------------------------------------------------
528 #------------------------------------------------------
530 def message_capable_models(self, cr, uid, context=None):
531 """ Used by the plugin addon, based for plugin_outlook and others. """
533 for model_name in self.pool.obj_list():
534 model = self.pool[model_name]
535 if 'mail.thread' in getattr(model, '_inherit', []):
536 ret_dict[model_name] = model._description
539 def _message_find_partners(self, cr, uid, message, header_fields=['From'], context=None):
540 """ Find partners related to some header fields of the message.
542 TDE TODO: merge me with other partner finding methods in 8.0 """
543 partner_obj = self.pool.get('res.partner')
545 s = ', '.join([decode(message.get(h)) for h in header_fields if message.get(h)])
546 for email_address in tools.email_split(s):
547 related_partners = partner_obj.search(cr, uid, [('email', 'ilike', email_address), ('user_ids', '!=', False)], limit=1, context=context)
548 if not related_partners:
549 related_partners = partner_obj.search(cr, uid, [('email', 'ilike', email_address)], limit=1, context=context)
550 partner_ids += related_partners
553 def _message_find_user_id(self, cr, uid, message, context=None):
554 """ TDE TODO: check and maybe merge me with other user finding methods in 8.0 """
555 from_local_part = tools.email_split(decode(message.get('From')))[0]
556 # FP Note: canonification required, the minimu: .lower()
557 user_ids = self.pool.get('res.users').search(cr, uid, ['|',
558 ('login', '=', from_local_part),
559 ('email', '=', from_local_part)], context=context)
560 return user_ids[0] if user_ids else uid
562 def message_route(self, cr, uid, message, model=None, thread_id=None,
563 custom_values=None, context=None):
564 """Attempt to figure out the correct target model, thread_id,
565 custom_values and user_id to use for an incoming message.
566 Multiple values may be returned, if a message had multiple
567 recipients matching existing mail.aliases, for example.
569 The following heuristics are used, in this order:
570 1. If the message replies to an existing thread_id, and
571 properly contains the thread model in the 'In-Reply-To'
572 header, use this model/thread_id pair, and ignore
573 custom_value (not needed as no creation will take place)
574 2. Look for a mail.alias entry matching the message
575 recipient, and use the corresponding model, thread_id,
576 custom_values and user_id.
577 3. Fallback to the ``model``, ``thread_id`` and ``custom_values``
579 4. If all the above fails, raise an exception.
581 :param string message: an email.message instance
582 :param string model: the fallback model to use if the message
583 does not match any of the currently configured mail aliases
584 (may be None if a matching alias is supposed to be present)
585 :type dict custom_values: optional dictionary of default field values
586 to pass to ``message_new`` if a new record needs to be created.
587 Ignored if the thread record already exists, and also if a
588 matching mail.alias was found (aliases define their own defaults)
589 :param int thread_id: optional ID of the record/thread from ``model``
590 to which this mail should be attached. Only used if the message
591 does not reply to an existing thread and does not match any mail alias.
592 :return: list of [model, thread_id, custom_values, user_id]
594 assert isinstance(message, Message), 'message must be an email.message.Message at this point'
595 message_id = message.get('Message-Id')
596 email_from = decode_header(message, 'From')
597 email_to = decode_header(message, 'To')
598 references = decode_header(message, 'References')
599 in_reply_to = decode_header(message, 'In-Reply-To')
601 # 1. Verify if this is a reply to an existing thread
602 thread_references = references or in_reply_to
603 ref_match = thread_references and tools.reference_re.search(thread_references)
606 thread_id = int(ref_match.group(1))
607 model = ref_match.group(2) or model
608 if thread_id and model in self.pool:
609 model_obj = self.pool[model]
610 if model_obj.exists(cr, uid, thread_id) and hasattr(model_obj, 'message_update'):
611 _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',
612 email_from, email_to, message_id, model, thread_id, custom_values, uid)
613 return [(model, thread_id, custom_values, uid)]
615 # Verify whether this is a reply to a private message
617 message_ids = self.pool.get('mail.message').search(cr, uid, [
618 ('message_id', '=', in_reply_to),
619 '!', ('message_id', 'ilike', 'reply_to')
620 ], limit=1, context=context)
622 message = self.pool.get('mail.message').browse(cr, uid, message_ids[0], context=context)
623 _logger.info('Routing mail from %s to %s with Message-Id %s: direct reply to a private message: %s, custom_values: %s, uid: %s',
624 email_from, email_to, message_id, message.id, custom_values, uid)
625 return [(message.model, message.res_id, custom_values, uid)]
627 # 2. Look for a matching mail.alias entry
628 # Delivered-To is a safe bet in most modern MTAs, but we have to fallback on To + Cc values
629 # for all the odd MTAs out there, as there is no standard header for the envelope's `rcpt_to` value.
631 ','.join([decode_header(message, 'Delivered-To'),
632 decode_header(message, 'To'),
633 decode_header(message, 'Cc'),
634 decode_header(message, 'Resent-To'),
635 decode_header(message, 'Resent-Cc')])
636 local_parts = [e.split('@')[0] for e in tools.email_split(rcpt_tos)]
638 mail_alias = self.pool.get('mail.alias')
639 alias_ids = mail_alias.search(cr, uid, [('alias_name', 'in', local_parts)])
642 for alias in mail_alias.browse(cr, uid, alias_ids, context=context):
643 user_id = alias.alias_user_id.id
645 # TDE note: this could cause crashes, because no clue that the user
646 # that send the email has the right to create or modify a new document
647 # Fallback on user_id = uid
648 # Note: recognized partners will be added as followers anyway
649 # user_id = self._message_find_user_id(cr, uid, message, context=context)
651 _logger.info('No matching user_id for the alias %s', alias.alias_name)
652 routes.append((alias.alias_model_id.model, alias.alias_force_thread_id, \
653 eval(alias.alias_defaults), user_id))
654 _logger.info('Routing mail from %s to %s with Message-Id %s: direct alias match: %r',
655 email_from, email_to, message_id, routes)
658 # 3. Fallback to the provided parameters, if they work
659 model_pool = self.pool.get(model)
661 # Legacy: fallback to matching [ID] in the Subject
662 match = tools.res_re.search(decode_header(message, 'Subject'))
663 thread_id = match and match.group(1)
664 # Convert into int (bug spotted in 7.0 because of str)
666 thread_id = int(thread_id)
669 assert thread_id and hasattr(model_pool, 'message_update') or hasattr(model_pool, 'message_new'), \
670 "No possible route found for incoming message from %s to %s (Message-Id %s:)." \
671 "Create an appropriate mail.alias or force the destination model." % (email_from, email_to, message_id)
672 if thread_id and not model_pool.exists(cr, uid, thread_id):
673 _logger.warning('Received mail reply to missing document %s! Ignoring and creating new document instead for Message-Id %s',
674 thread_id, message_id)
676 _logger.info('Routing mail from %s to %s with Message-Id %s: fallback to model:%s, thread_id:%s, custom_values:%s, uid:%s',
677 email_from, email_to, message_id, model, thread_id, custom_values, uid)
678 return [(model, thread_id, custom_values, uid)]
680 def message_process(self, cr, uid, model, message, custom_values=None,
681 save_original=False, strip_attachments=False,
682 thread_id=None, context=None):
683 """ Process an incoming RFC2822 email message, relying on
684 ``mail.message.parse()`` for the parsing operation,
685 and ``message_route()`` to figure out the target model.
687 Once the target model is known, its ``message_new`` method
688 is called with the new message (if the thread record did not exist)
689 or its ``message_update`` method (if it did).
691 There is a special case where the target model is False: a reply
692 to a private message. In this case, we skip the message_new /
693 message_update step, to just post a new message using mail_thread
696 :param string model: the fallback model to use if the message
697 does not match any of the currently configured mail aliases
698 (may be None if a matching alias is supposed to be present)
699 :param message: source of the RFC2822 message
700 :type message: string or xmlrpclib.Binary
701 :type dict custom_values: optional dictionary of field values
702 to pass to ``message_new`` if a new record needs to be created.
703 Ignored if the thread record already exists, and also if a
704 matching mail.alias was found (aliases define their own defaults)
705 :param bool save_original: whether to keep a copy of the original
706 email source attached to the message after it is imported.
707 :param bool strip_attachments: whether to strip all attachments
708 before processing the message, in order to save some space.
709 :param int thread_id: optional ID of the record/thread from ``model``
710 to which this mail should be attached. When provided, this
711 overrides the automatic detection based on the message
717 # extract message bytes - we are forced to pass the message as binary because
718 # we don't know its encoding until we parse its headers and hence can't
719 # convert it to utf-8 for transport between the mailgate script and here.
720 if isinstance(message, xmlrpclib.Binary):
721 message = str(message.data)
722 # Warning: message_from_string doesn't always work correctly on unicode,
723 # we must use utf-8 strings here :-(
724 if isinstance(message, unicode):
725 message = message.encode('utf-8')
726 msg_txt = email.message_from_string(message)
728 # parse the message, verify we are not in a loop by checking message_id is not duplicated
729 msg = self.message_parse(cr, uid, msg_txt, save_original=save_original, context=context)
730 if strip_attachments:
731 msg.pop('attachments', None)
732 if msg.get('message_id'): # should always be True as message_parse generate one if missing
733 existing_msg_ids = self.pool.get('mail.message').search(cr, SUPERUSER_ID, [
734 ('message_id', '=', msg.get('message_id')),
737 _logger.info('Ignored mail from %s to %s with Message-Id %s:: found duplicated Message-Id during processing',
738 msg.get('from'), msg.get('to'), msg.get('message_id'))
741 # find possible routes for the message
742 routes = self.message_route(cr, uid, msg_txt, model,
743 thread_id, custom_values,
746 # postpone setting msg.partner_ids after message_post, to avoid double notifications
747 partner_ids = msg.pop('partner_ids', [])
750 for model, thread_id, custom_values, user_id in routes:
751 if self._name == 'mail.thread':
752 context.update({'thread_model': model})
754 model_pool = self.pool[model]
755 assert thread_id and hasattr(model_pool, 'message_update') or hasattr(model_pool, 'message_new'), \
756 "Undeliverable mail with Message-Id %s, model %s does not accept incoming emails" % \
757 (msg['message_id'], model)
759 # disabled subscriptions during message_new/update to avoid having the system user running the
760 # email gateway become a follower of all inbound messages
761 nosub_ctx = dict(context, mail_create_nosubscribe=True)
762 if thread_id and hasattr(model_pool, 'message_update'):
763 model_pool.message_update(cr, user_id, [thread_id], msg, context=nosub_ctx)
765 nosub_ctx = dict(nosub_ctx, mail_create_nolog=True)
766 thread_id = model_pool.message_new(cr, user_id, msg, custom_values, context=nosub_ctx)
768 assert thread_id == 0, "Posting a message without model should be with a null res_id, to create a private message."
769 model_pool = self.pool.get('mail.thread')
770 new_msg_id = model_pool.message_post(cr, uid, [thread_id], context=context, subtype='mail.mt_comment', **msg)
773 # postponed after message_post, because this is an external message and we don't want to create
774 # duplicate emails due to notifications
775 self.pool.get('mail.message').write(cr, uid, [new_msg_id], {'partner_ids': partner_ids}, context=context)
779 def message_new(self, cr, uid, msg_dict, custom_values=None, context=None):
780 """Called by ``message_process`` when a new message is received
781 for a given thread model, if the message did not belong to
783 The default behavior is to create a new record of the corresponding
784 model (based on some very basic info extracted from the message).
785 Additional behavior may be implemented by overriding this method.
787 :param dict msg_dict: a map containing the email details and
788 attachments. See ``message_process`` and
789 ``mail.message.parse`` for details.
790 :param dict custom_values: optional dictionary of additional
791 field values to pass to create()
792 when creating the new thread record.
793 Be careful, these values may override
794 any other values coming from the message.
795 :param dict context: if a ``thread_model`` value is present
796 in the context, its value will be used
797 to determine the model of the record
798 to create (instead of the current model).
800 :return: the id of the newly created thread object
805 if isinstance(custom_values, dict):
806 data = custom_values.copy()
807 model = context.get('thread_model') or self._name
808 model_pool = self.pool[model]
809 fields = model_pool.fields_get(cr, uid, context=context)
810 if 'name' in fields and not data.get('name'):
811 data['name'] = msg_dict.get('subject', '')
812 res_id = model_pool.create(cr, uid, data, context=context)
815 def message_update(self, cr, uid, ids, msg_dict, update_vals=None, context=None):
816 """Called by ``message_process`` when a new message is received
817 for an existing thread. The default behavior is to update the record
818 with update_vals taken from the incoming email.
819 Additional behavior may be implemented by overriding this
821 :param dict msg_dict: a map containing the email details and
822 attachments. See ``message_process`` and
823 ``mail.message.parse()`` for details.
824 :param dict update_vals: a dict containing values to update records
825 given their ids; if the dict is None or is
826 void, no write operation is performed.
829 self.write(cr, uid, ids, update_vals, context=context)
832 def _message_extract_payload(self, message, save_original=False):
833 """Extract body as HTML and attachments from the mail message"""
837 attachments.append(('original_email.eml', message.as_string()))
838 if not message.is_multipart() or 'text/' in message.get('content-type', ''):
839 encoding = message.get_content_charset()
840 body = message.get_payload(decode=True)
841 body = tools.ustr(body, encoding, errors='replace')
842 if message.get_content_type() == 'text/plain':
843 # text/plain -> <pre/>
844 body = tools.append_content_to_html(u'', body, preserve=True)
846 alternative = (message.get_content_type() == 'multipart/alternative')
847 for part in message.walk():
848 if part.get_content_maintype() == 'multipart':
849 continue # skip container
850 filename = part.get_filename() # None if normal part
851 encoding = part.get_content_charset() # None if attachment
852 # 1) Explicit Attachments -> attachments
853 if filename or part.get('content-disposition', '').strip().startswith('attachment'):
854 attachments.append((filename or 'attachment', part.get_payload(decode=True)))
856 # 2) text/plain -> <pre/>
857 if part.get_content_type() == 'text/plain' and (not alternative or not body):
858 body = tools.append_content_to_html(body, tools.ustr(part.get_payload(decode=True),
859 encoding, errors='replace'), preserve=True)
860 # 3) text/html -> raw
861 elif part.get_content_type() == 'text/html':
862 html = tools.ustr(part.get_payload(decode=True), encoding, errors='replace')
866 body = tools.append_content_to_html(body, html, plaintext=False)
867 # 4) Anything else -> attachment
869 attachments.append((filename or 'attachment', part.get_payload(decode=True)))
870 return body, attachments
872 def message_parse(self, cr, uid, message, save_original=False, context=None):
873 """Parses a string or email.message.Message representing an
874 RFC-2822 email, and returns a generic dict holding the
877 :param message: the message to parse
878 :type message: email.message.Message | string | unicode
879 :param bool save_original: whether the returned dict
880 should include an ``original`` attachment containing
881 the source of the message
883 :return: A dict with the following structure, where each
884 field may not be present if missing in original
887 { 'message_id': msg_id,
892 'body': unified_body,
893 'attachments': [('file1', 'bytes'),
901 if not isinstance(message, Message):
902 if isinstance(message, unicode):
903 # Warning: message_from_string doesn't always work correctly on unicode,
904 # we must use utf-8 strings here :-(
905 message = message.encode('utf-8')
906 message = email.message_from_string(message)
908 message_id = message['message-id']
910 # Very unusual situation, be we should be fault-tolerant here
911 message_id = "<%s@localhost>" % time.time()
912 _logger.debug('Parsing Message without message-id, generating a random one: %s', message_id)
913 msg_dict['message_id'] = message_id
915 if message.get('Subject'):
916 msg_dict['subject'] = decode(message.get('Subject'))
918 # Envelope fields not stored in mail.message but made available for message_new()
919 msg_dict['from'] = decode(message.get('from'))
920 msg_dict['to'] = decode(message.get('to'))
921 msg_dict['cc'] = decode(message.get('cc'))
923 if message.get('From'):
924 author_ids = self._message_find_partners(cr, uid, message, ['From'], context=context)
926 msg_dict['author_id'] = author_ids[0]
927 msg_dict['email_from'] = decode(message.get('from'))
928 partner_ids = self._message_find_partners(cr, uid, message, ['To', 'Cc'], context=context)
929 msg_dict['partner_ids'] = [(4, partner_id) for partner_id in partner_ids]
931 if message.get('Date'):
933 date_hdr = decode(message.get('Date'))
934 parsed_date = dateutil.parser.parse(date_hdr, fuzzy=True)
935 if parsed_date.utcoffset() is None:
936 # naive datetime, so we arbitrarily decide to make it
937 # UTC, there's no better choice. Should not happen,
938 # as RFC2822 requires timezone offset in Date headers.
939 stored_date = parsed_date.replace(tzinfo=pytz.utc)
941 stored_date = parsed_date.astimezone(tz=pytz.utc)
943 _logger.warning('Failed to parse Date header %r in incoming mail '
944 'with message-id %r, assuming current date/time.',
945 message.get('Date'), message_id)
946 stored_date = datetime.datetime.now()
947 msg_dict['date'] = stored_date.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT)
949 if message.get('In-Reply-To'):
950 parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', '=', decode(message['In-Reply-To']))])
952 msg_dict['parent_id'] = parent_ids[0]
954 if message.get('References') and 'parent_id' not in msg_dict:
955 parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', 'in',
956 [x.strip() for x in decode(message['References']).split()])])
958 msg_dict['parent_id'] = parent_ids[0]
960 msg_dict['body'], msg_dict['attachments'] = self._message_extract_payload(message, save_original=save_original)
963 #------------------------------------------------------
965 #------------------------------------------------------
967 def log(self, cr, uid, id, message, secondary=False, context=None):
968 _logger.warning("log() is deprecated. As this module inherit from "\
969 "mail.thread, the message will be managed by this "\
970 "module instead of by the res.log mechanism. Please "\
971 "use mail_thread.message_post() instead of the "\
972 "now deprecated res.log.")
973 self.message_post(cr, uid, [id], message, context=context)
975 def _message_add_suggested_recipient(self, cr, uid, result, obj, partner=None, email=None, reason='', context=None):
976 """ Called by message_get_suggested_recipients, to add a suggested
977 recipient in the result dictionary. The form is :
978 partner_id, partner_name<partner_email> or partner_name, reason """
979 if email and not partner:
980 # get partner info from email
981 partner_info = self.message_get_partner_info_from_emails(cr, uid, [email], context=context, res_id=obj.id)[0]
982 if partner_info.get('partner_id'):
983 partner = self.pool.get('res.partner').browse(cr, SUPERUSER_ID, [partner_info.get('partner_id')], context=context)[0]
984 if email and email in [val[1] for val in result[obj.id]]: # already existing email -> skip
986 if partner and partner in obj.message_follower_ids: # recipient already in the followers -> skip
988 if partner and partner in [val[0] for val in result[obj.id]]: # already existing partner ID -> skip
990 if partner and partner.email: # complete profile: id, name <email>
991 result[obj.id].append((partner.id, '%s<%s>' % (partner.name, partner.email), reason))
992 elif partner: # incomplete profile: id, name
993 result[obj.id].append((partner.id, '%s' % (partner.name), reason))
994 else: # unknown partner, we are probably managing an email address
995 result[obj.id].append((False, email, reason))
998 def message_get_suggested_recipients(self, cr, uid, ids, context=None):
999 """ Returns suggested recipients for ids. Those are a list of
1000 tuple (partner_id, partner_name, reason), to be managed by Chatter. """
1001 result = dict.fromkeys(ids, list())
1002 if self._all_columns.get('user_id'):
1003 for obj in self.browse(cr, SUPERUSER_ID, ids, context=context): # SUPERUSER because of a read on res.users that would crash otherwise
1004 if not obj.user_id or not obj.user_id.partner_id:
1006 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)
1009 def message_get_partner_info_from_emails(self, cr, uid, emails, link_mail=False, context=None, res_id=None):
1010 """ Wrapper with weird order parameter because of 7.0 fix.
1012 TDE TODO: remove me in 8.0 """
1013 return self.message_find_partner_from_emails(cr, uid, res_id, emails, link_mail=link_mail, context=context)
1015 def message_find_partner_from_emails(self, cr, uid, id, emails, link_mail=False, context=None):
1016 """ Convert a list of emails into a list partner_ids and a list
1017 new_partner_ids. The return value is non conventional because
1018 it is meant to be used by the mail widget.
1020 :return dict: partner_ids and new_partner_ids
1022 TDE TODO: merge me with other partner finding methods in 8.0 """
1023 mail_message_obj = self.pool.get('mail.message')
1024 partner_obj = self.pool.get('res.partner')
1026 if id and self._name != 'mail.thread':
1027 obj = self.browse(cr, SUPERUSER_ID, id, context=context)
1030 for email in emails:
1031 partner_info = {'full_name': email, 'partner_id': False}
1032 m = re.search(r"((.+?)\s*<)?([^<>]+@[^<>]+)>?", email, re.IGNORECASE | re.DOTALL)
1035 email_address = m.group(3)
1036 # first try: check in document's followers
1038 for follower in obj.message_follower_ids:
1039 if follower.email == email_address:
1040 partner_info['partner_id'] = follower.id
1041 # second try: check in partners
1042 if not partner_info.get('partner_id'):
1043 ids = partner_obj.search(cr, SUPERUSER_ID, [('email', 'ilike', email_address), ('user_ids', '!=', False)], limit=1, context=context)
1045 ids = partner_obj.search(cr, SUPERUSER_ID, [('email', 'ilike', email_address)], limit=1, context=context)
1047 partner_info['partner_id'] = ids[0]
1048 result.append(partner_info)
1050 # link mail with this from mail to the new partner id
1051 if link_mail and partner_info['partner_id']:
1052 message_ids = mail_message_obj.search(cr, SUPERUSER_ID, [
1054 ('email_from', '=', email),
1055 ('email_from', 'ilike', '<%s>' % email),
1056 ('author_id', '=', False)
1059 mail_message_obj.write(cr, SUPERUSER_ID, message_ids, {'author_id': partner_info['partner_id']}, context=context)
1062 def message_post(self, cr, uid, thread_id, body='', subject=None, type='notification',
1063 subtype=None, parent_id=False, attachments=None, context=None,
1064 content_subtype='html', **kwargs):
1065 """ Post a new message in an existing thread, returning the new
1068 :param int thread_id: thread ID to post into, or list with one ID;
1069 if False/0, mail.message model will also be set as False
1070 :param str body: body of the message, usually raw HTML that will
1072 :param str type: see mail_message.type field
1073 :param str content_subtype:: if plaintext: convert body into html
1074 :param int parent_id: handle reply to a previous message by adding the
1075 parent partners to the message in case of private discussion
1076 :param tuple(str,str) attachments or list id: list of attachment tuples in the form
1077 ``(name,content)``, where content is NOT base64 encoded
1079 Extra keyword arguments will be used as default column values for the
1080 new mail.message record. Special cases:
1081 - attachment_ids: supposed not attached to any document; attach them
1082 to the related document. Should only be set by Chatter.
1083 :return int: ID of newly created mail.message
1087 if attachments is None:
1089 mail_message = self.pool.get('mail.message')
1090 ir_attachment = self.pool.get('ir.attachment')
1092 assert (not thread_id) or \
1093 isinstance(thread_id, (int, long)) or \
1094 (isinstance(thread_id, (list, tuple)) and len(thread_id) == 1), \
1095 "Invalid thread_id; should be 0, False, an ID or a list with one ID"
1096 if isinstance(thread_id, (list, tuple)):
1097 thread_id = thread_id[0]
1099 # if we're processing a message directly coming from the gateway, the destination model was
1100 # set in the context.
1103 model = context.get('thread_model', self._name) if self._name == 'mail.thread' else self._name
1104 if model != self._name:
1105 del context['thread_model']
1106 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)
1108 # 0: Parse email-from, try to find a better author_id based on document's followers for incoming emails
1109 email_from = kwargs.get('email_from')
1110 if email_from and thread_id and type == 'email' and kwargs.get('author_id'):
1111 email_list = tools.email_split(email_from)
1112 doc = self.browse(cr, uid, thread_id, context=context)
1113 if email_list and doc:
1114 author_ids = self.pool.get('res.partner').search(cr, uid, [
1115 ('email', 'ilike', email_list[0]),
1116 ('id', 'in', [f.id for f in doc.message_follower_ids])
1117 ], limit=1, context=context)
1119 kwargs['author_id'] = author_ids[0]
1120 author_id = kwargs.get('author_id')
1121 if author_id is None: # keep False values
1122 author_id = self.pool.get('mail.message')._get_default_author(cr, uid, context=context)
1124 # 1: Handle content subtype: if plaintext, converto into HTML
1125 if content_subtype == 'plaintext':
1126 body = tools.plaintext2html(body)
1128 # 2: Private message: add recipients (recipients and author of parent message) - current author
1129 # + legacy-code management (! we manage only 4 and 6 commands)
1131 kwargs_partner_ids = kwargs.pop('partner_ids', [])
1132 for partner_id in kwargs_partner_ids:
1133 if isinstance(partner_id, (list, tuple)) and partner_id[0] == 4 and len(partner_id) == 2:
1134 partner_ids.add(partner_id[1])
1135 if isinstance(partner_id, (list, tuple)) and partner_id[0] == 6 and len(partner_id) == 3:
1136 partner_ids |= set(partner_id[2])
1137 elif isinstance(partner_id, (int, long)):
1138 partner_ids.add(partner_id)
1140 pass # we do not manage anything else
1141 if parent_id and not model:
1142 parent_message = mail_message.browse(cr, uid, parent_id, context=context)
1143 private_followers = set([partner.id for partner in parent_message.partner_ids])
1144 if parent_message.author_id:
1145 private_followers.add(parent_message.author_id.id)
1146 private_followers -= set([author_id])
1147 partner_ids |= private_followers
1150 # - HACK TDE FIXME: Chatter: attachments linked to the document (not done JS-side), load the message
1151 attachment_ids = kwargs.pop('attachment_ids', []) or [] # because we could receive None (some old code sends None)
1153 filtered_attachment_ids = ir_attachment.search(cr, SUPERUSER_ID, [
1154 ('res_model', '=', 'mail.compose.message'),
1155 ('create_uid', '=', uid),
1156 ('id', 'in', attachment_ids)], context=context)
1157 if filtered_attachment_ids:
1158 ir_attachment.write(cr, SUPERUSER_ID, filtered_attachment_ids, {'res_model': model, 'res_id': thread_id}, context=context)
1159 attachment_ids = [(4, id) for id in attachment_ids]
1160 # Handle attachments parameter, that is a dictionary of attachments
1161 for name, content in attachments:
1162 if isinstance(content, unicode):
1163 content = content.encode('utf-8')
1166 'datas': base64.b64encode(str(content)),
1167 'datas_fname': name,
1168 'description': name,
1170 'res_id': thread_id,
1172 attachment_ids.append((0, 0, data_attach))
1174 # 4: mail.message.subtype
1177 if '.' not in subtype:
1178 subtype = 'mail.%s' % subtype
1179 ref = self.pool.get('ir.model.data').get_object_reference(cr, uid, *subtype.split('.'))
1180 subtype_id = ref and ref[1] or False
1182 # automatically subscribe recipients if asked to
1183 if context.get('mail_post_autofollow') and thread_id and partner_ids:
1184 partner_to_subscribe = partner_ids
1185 if context.get('mail_post_autofollow_partner_ids'):
1186 partner_to_subscribe = filter(lambda item: item in context.get('mail_post_autofollow_partner_ids'), partner_ids)
1187 self.message_subscribe(cr, uid, [thread_id], list(partner_to_subscribe), context=context)
1189 # _mail_flat_thread: automatically set free messages to the first posted message
1190 if self._mail_flat_thread and not parent_id and thread_id:
1191 message_ids = mail_message.search(cr, uid, ['&', ('res_id', '=', thread_id), ('model', '=', model)], context=context, order="id ASC", limit=1)
1192 parent_id = message_ids and message_ids[0] or False
1193 # 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
1195 message_ids = mail_message.search(cr, SUPERUSER_ID, [('id', '=', parent_id), ('parent_id', '!=', False)], context=context)
1196 # avoid loops when finding ancestors
1199 message = mail_message.browse(cr, SUPERUSER_ID, message_ids[0], context=context)
1200 while (message.parent_id and message.parent_id.id not in processed_list):
1201 processed_list.append(message.parent_id.id)
1202 message = message.parent_id
1203 parent_id = message.id
1207 'author_id': author_id,
1209 'res_id': thread_id or False,
1211 'subject': subject or False,
1213 'parent_id': parent_id,
1214 'attachment_ids': attachment_ids,
1215 'subtype_id': subtype_id,
1216 'partner_ids': [(4, pid) for pid in partner_ids],
1219 # Avoid warnings about non-existing fields
1220 for x in ('from', 'to', 'cc'):
1223 # Create and auto subscribe the author
1224 msg_id = mail_message.create(cr, uid, values, context=context)
1225 message = mail_message.browse(cr, uid, msg_id, context=context)
1226 if message.author_id and thread_id and type != 'notification' and not context.get('mail_create_nosubscribe'):
1227 self.message_subscribe(cr, uid, [thread_id], [message.author_id.id], context=context)
1230 #------------------------------------------------------
1231 # Compatibility methods: do not use
1232 # TDE TODO: remove me in 8.0
1233 #------------------------------------------------------
1235 def message_create_partners_from_emails(self, cr, uid, emails, context=None):
1236 return {'partner_ids': [], 'new_partner_ids': []}
1238 def message_post_user_api(self, cr, uid, thread_id, body='', parent_id=False,
1239 attachment_ids=None, content_subtype='plaintext',
1240 context=None, **kwargs):
1241 return self.message_post(cr, uid, thread_id, body=body, parent_id=parent_id,
1242 attachment_ids=attachment_ids, content_subtype=content_subtype,
1243 context=context, **kwargs)
1245 #------------------------------------------------------
1247 #------------------------------------------------------
1249 def message_get_subscription_data(self, cr, uid, ids, context=None):
1250 """ Wrapper to get subtypes data. """
1251 return self._get_subscription_data(cr, uid, ids, None, None, context=context)
1253 def message_subscribe_users(self, cr, uid, ids, user_ids=None, subtype_ids=None, context=None):
1254 """ Wrapper on message_subscribe, using users. If user_ids is not
1255 provided, subscribe uid instead. """
1256 if user_ids is None:
1258 partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
1259 return self.message_subscribe(cr, uid, ids, partner_ids, subtype_ids=subtype_ids, context=context)
1261 def message_subscribe(self, cr, uid, ids, partner_ids, subtype_ids=None, context=None):
1262 """ Add partners to the records followers. """
1263 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
1264 if set(partner_ids) == set([user_pid]):
1265 self.check_access_rights(cr, uid, 'read')
1267 self.check_access_rights(cr, uid, 'write')
1269 self.write(cr, SUPERUSER_ID, ids, {'message_follower_ids': [(4, pid) for pid in partner_ids]}, context=context)
1270 # if subtypes are not specified (and not set to a void list), fetch default ones
1271 if subtype_ids is None:
1272 subtype_obj = self.pool.get('mail.message.subtype')
1273 subtype_ids = subtype_obj.search(cr, uid, [('default', '=', True), '|', ('res_model', '=', self._name), ('res_model', '=', False)], context=context)
1274 # update the subscriptions
1275 fol_obj = self.pool.get('mail.followers')
1276 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids), ('partner_id', 'in', partner_ids)], context=context)
1277 fol_obj.write(cr, SUPERUSER_ID, fol_ids, {'subtype_ids': [(6, 0, subtype_ids)]}, context=context)
1280 def message_unsubscribe_users(self, cr, uid, ids, user_ids=None, context=None):
1281 """ Wrapper on message_subscribe, using users. If user_ids is not
1282 provided, unsubscribe uid instead. """
1283 if user_ids is None:
1285 partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
1286 return self.message_unsubscribe(cr, uid, ids, partner_ids, context=context)
1288 def message_unsubscribe(self, cr, uid, ids, partner_ids, context=None):
1289 """ Remove partners from the records followers. """
1290 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
1291 if set(partner_ids) == set([user_pid]):
1292 self.check_access_rights(cr, uid, 'read')
1294 self.check_access_rights(cr, uid, 'write')
1295 return self.write(cr, SUPERUSER_ID, ids, {'message_follower_ids': [(3, pid) for pid in partner_ids]}, context=context)
1297 def _message_get_auto_subscribe_fields(self, cr, uid, updated_fields, auto_follow_fields=['user_id'], context=None):
1298 """ Returns the list of relational fields linking to res.users that should
1299 trigger an auto subscribe. The default list checks for the fields
1301 - linking to res.users
1302 - with track_visibility set
1303 In OpenERP V7, this is sufficent for all major addon such as opportunity,
1304 project, issue, recruitment, sale.
1305 Override this method if a custom behavior is needed about fields
1306 that automatically subscribe users.
1309 for name, column_info in self._all_columns.items():
1310 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':
1311 user_field_lst.append(name)
1312 return user_field_lst
1314 def message_auto_subscribe(self, cr, uid, ids, updated_fields, context=None):
1316 1. fetch project subtype related to task (parent_id.res_model = 'project.task')
1317 2. for each project subtype: subscribe the follower to the task
1319 subtype_obj = self.pool.get('mail.message.subtype')
1320 follower_obj = self.pool.get('mail.followers')
1322 # fetch auto_follow_fields
1323 user_field_lst = self._message_get_auto_subscribe_fields(cr, uid, updated_fields, context=context)
1325 # fetch related record subtypes
1326 related_subtype_ids = subtype_obj.search(cr, uid, ['|', ('res_model', '=', False), ('parent_id.res_model', '=', self._name)], context=context)
1327 subtypes = subtype_obj.browse(cr, uid, related_subtype_ids, context=context)
1328 default_subtypes = [subtype for subtype in subtypes if subtype.res_model == False]
1329 related_subtypes = [subtype for subtype in subtypes if subtype.res_model != False]
1330 relation_fields = set([subtype.relation_field for subtype in subtypes if subtype.relation_field != False])
1331 if (not related_subtypes or not any(relation in updated_fields for relation in relation_fields)) and not user_field_lst:
1334 for record in self.browse(cr, uid, ids, context=context):
1335 new_followers = dict()
1336 parent_res_id = False
1337 parent_model = False
1338 for subtype in related_subtypes:
1339 if not subtype.relation_field or not subtype.parent_id:
1341 if not subtype.relation_field in self._columns or not getattr(record, subtype.relation_field, False):
1343 parent_res_id = getattr(record, subtype.relation_field).id
1344 parent_model = subtype.res_model
1345 follower_ids = follower_obj.search(cr, SUPERUSER_ID, [
1346 ('res_model', '=', parent_model),
1347 ('res_id', '=', parent_res_id),
1348 ('subtype_ids', 'in', [subtype.id])
1350 for follower in follower_obj.browse(cr, SUPERUSER_ID, follower_ids, context=context):
1351 new_followers.setdefault(follower.partner_id.id, set()).add(subtype.parent_id.id)
1353 if parent_res_id and parent_model:
1354 for subtype in default_subtypes:
1355 follower_ids = follower_obj.search(cr, SUPERUSER_ID, [
1356 ('res_model', '=', parent_model),
1357 ('res_id', '=', parent_res_id),
1358 ('subtype_ids', 'in', [subtype.id])
1360 for follower in follower_obj.browse(cr, SUPERUSER_ID, follower_ids, context=context):
1361 new_followers.setdefault(follower.partner_id.id, set()).add(subtype.id)
1363 # add followers coming from res.users relational fields that are tracked
1364 user_ids = [getattr(record, name).id for name in user_field_lst if getattr(record, name)]
1365 user_id_partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, SUPERUSER_ID, user_ids, context=context)]
1366 for partner_id in user_id_partner_ids:
1367 new_followers.setdefault(partner_id, None)
1369 for pid, subtypes in new_followers.items():
1370 subtypes = list(subtypes) if subtypes is not None else None
1371 self.message_subscribe(cr, uid, [record.id], [pid], subtypes, context=context)
1373 # find first email message, set it as unread for auto_subscribe fields for them to have a notification
1374 if user_id_partner_ids:
1375 msg_ids = self.pool.get('mail.message').search(cr, uid, [
1376 ('model', '=', self._name),
1377 ('res_id', '=', record.id),
1378 ('type', '=', 'email')], limit=1, context=context)
1379 if not msg_ids and record.message_ids:
1380 msg_ids = [record.message_ids[-1].id]
1382 self.pool.get('mail.notification')._notify(cr, uid, msg_ids[0], partners_to_notify=user_id_partner_ids, context=context)
1386 #------------------------------------------------------
1388 #------------------------------------------------------
1390 def message_mark_as_unread(self, cr, uid, ids, context=None):
1391 """ Set as unread. """
1392 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1394 UPDATE mail_notification SET
1397 message_id IN (SELECT id from mail_message where res_id=any(%s) and model=%s limit 1) and
1399 ''', (ids, self._name, partner_id))
1402 def message_mark_as_read(self, cr, uid, ids, context=None):
1403 """ Set as read. """
1404 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1406 UPDATE mail_notification SET
1409 message_id IN (SELECT id FROM mail_message WHERE res_id=ANY(%s) AND model=%s) AND
1411 ''', (ids, self._name, partner_id))
1414 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: