1 # -*- coding: utf-8 -*-
2 ##############################################################################
4 # OpenERP, Open Source Management Solution
5 # Copyright (C) 2009-today OpenERP SA (<http://www.openerp.com>)
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Affero General Public License as
9 # published by the Free Software Foundation, either version 3 of the
10 # License, or (at your option) any later version
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU Affero General Public License for more details
17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program. If not, see <http://www.gnu.org/licenses/>
20 ##############################################################################
30 from email.message import Message
32 from openerp import tools
33 from openerp import SUPERUSER_ID
34 from openerp.addons.mail.mail_message import decode
35 from openerp.osv import fields, osv
36 from openerp.tools.safe_eval import safe_eval as eval
38 _logger = logging.getLogger(__name__)
41 def decode_header(message, header, separator=' '):
42 return separator.join(map(decode, message.get_all(header, [])))
45 class mail_thread(osv.AbstractModel):
46 ''' mail_thread model is meant to be inherited by any model that needs to
47 act as a discussion topic on which messages can be attached. Public
48 methods are prefixed with ``message_`` in order to avoid name
49 collisions with methods of the models that will inherit from this class.
51 ``mail.thread`` defines fields used to handle and display the
52 communication history. ``mail.thread`` also manages followers of
53 inheriting classes. All features and expected behavior are managed
54 by mail.thread. Widgets has been designed for the 7.0 and following
57 Inheriting classes are not required to implement any method, as the
58 default implementation will work for any model. However it is common
59 to override at least the ``message_new`` and ``message_update``
60 methods (calling ``super``) to add model-specific behavior at
61 creation and update of a thread when processing incoming emails.
64 - _mail_flat_thread: if set to True, all messages without parent_id
65 are automatically attached to the first message posted on the
66 ressource. If set to False, the display of Chatter is done using
67 threads, and no parent_id is automatically set.
70 _description = 'Email Thread'
71 _mail_flat_thread = True
73 # Automatic logging system if mail installed
76 # 'module.subtype_xml': lambda self, cr, uid, obj, context=None: obj[state] == done,
77 # 'module.subtype_xml2': lambda self, cr, uid, obj, context=None: obj[state] != done,
84 # :param string field: field name
85 # :param module.subtype_xml: xml_id of a mail.message.subtype (i.e. mail.mt_comment)
86 # :param obj: is a browse_record
87 # :param function lambda: returns whether the tracking should record using this subtype
90 def _get_message_data(self, cr, uid, ids, name, args, context=None):
92 - message_unread: has uid unread message for the document
93 - message_summary: html snippet summarizing the Chatter for kanban views """
94 res = dict((id, dict(message_unread=False, message_summary='')) for id in ids)
95 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
97 # search for unread messages, directly in SQL to improve performances
98 cr.execute(""" SELECT m.res_id FROM mail_message m
99 RIGHT JOIN mail_notification n
100 ON (n.message_id = m.id AND n.partner_id = %s AND (n.read = False or n.read IS NULL))
101 WHERE m.model = %s AND m.res_id in %s""",
102 (user_pid, self._name, tuple(ids),))
103 msg_ids = [result[0] for result in cr.fetchall()]
104 for msg_id in msg_ids:
105 res[msg_id]['message_unread'] = True
107 for thread in self.browse(cr, uid, ids, context=context):
108 cls = res[thread.id]['message_unread'] and ' class="oe_kanban_mail_new"' or ''
109 res[thread.id]['message_summary'] = "<span%s><span class='oe_e'>9</span> %d</span> <span><span class='oe_e'>+</span> %d</span>" % (cls, len(thread.message_ids), len(thread.message_follower_ids))
113 def _get_subscription_data(self, cr, uid, ids, name, args, context=None):
115 - message_subtype_data: data about document subtypes: which are
116 available, which are followed if any """
117 res = dict((id, dict(message_subtype_data='')) for id in ids)
118 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
120 # find current model subtypes, add them to a dictionary
121 subtype_obj = self.pool.get('mail.message.subtype')
122 subtype_ids = subtype_obj.search(cr, uid, ['|', ('res_model', '=', self._name), ('res_model', '=', False)], context=context)
123 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))
125 res[id]['message_subtype_data'] = subtype_dict.copy()
127 # find the document followers, update the data
128 fol_obj = self.pool.get('mail.followers')
129 fol_ids = fol_obj.search(cr, uid, [
130 ('partner_id', '=', user_pid),
131 ('res_id', 'in', ids),
132 ('res_model', '=', self._name),
134 for fol in fol_obj.browse(cr, uid, fol_ids, context=context):
135 thread_subtype_dict = res[fol.res_id]['message_subtype_data']
136 for subtype in fol.subtype_ids:
137 thread_subtype_dict[subtype.name]['followed'] = True
138 res[fol.res_id]['message_subtype_data'] = thread_subtype_dict
142 def _search_message_unread(self, cr, uid, obj=None, name=None, domain=None, context=None):
143 return [('message_ids.to_read', '=', True)]
145 def _get_followers(self, cr, uid, ids, name, arg, context=None):
146 fol_obj = self.pool.get('mail.followers')
147 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids)])
148 res = dict((id, dict(message_follower_ids=[], message_is_follower=False)) for id in ids)
149 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
150 for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids):
151 res[fol.res_id]['message_follower_ids'].append(fol.partner_id.id)
152 if fol.partner_id.id == user_pid:
153 res[fol.res_id]['message_is_follower'] = True
156 def _set_followers(self, cr, uid, id, name, value, arg, context=None):
159 partner_obj = self.pool.get('res.partner')
160 fol_obj = self.pool.get('mail.followers')
162 # read the old set of followers, and determine the new set of followers
163 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', '=', id)])
164 old = set(fol.partner_id.id for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids))
167 for command in value or []:
168 if isinstance(command, (int, long)):
170 elif command[0] == 0:
171 new.add(partner_obj.create(cr, uid, command[2], context=context))
172 elif command[0] == 1:
173 partner_obj.write(cr, uid, [command[1]], command[2], context=context)
175 elif command[0] == 2:
176 partner_obj.unlink(cr, uid, [command[1]], context=context)
177 new.discard(command[1])
178 elif command[0] == 3:
179 new.discard(command[1])
180 elif command[0] == 4:
182 elif command[0] == 5:
184 elif command[0] == 6:
185 new = set(command[2])
187 # remove partners that are no longer followers
188 fol_ids = fol_obj.search(cr, SUPERUSER_ID,
189 [('res_model', '=', self._name), ('res_id', '=', id), ('partner_id', 'not in', list(new))])
190 fol_obj.unlink(cr, SUPERUSER_ID, fol_ids)
193 for partner_id in new - old:
194 fol_obj.create(cr, SUPERUSER_ID, {'res_model': self._name, 'res_id': id, 'partner_id': partner_id})
196 def _search_followers(self, cr, uid, obj, name, args, context):
197 fol_obj = self.pool.get('mail.followers')
199 for field, operator, value in args:
201 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('partner_id', operator, value)])
202 res_ids = [fol.res_id for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids)]
203 res.append(('id', 'in', res_ids))
207 'message_is_follower': fields.function(_get_followers,
208 type='boolean', string='Is a Follower', multi='_get_followers,'),
209 'message_follower_ids': fields.function(_get_followers, fnct_inv=_set_followers,
210 fnct_search=_search_followers, type='many2many',
211 obj='res.partner', string='Followers', multi='_get_followers'),
212 'message_ids': fields.one2many('mail.message', 'res_id',
213 domain=lambda self: [('model', '=', self._name)],
216 help="Messages and communication history"),
217 'message_unread': fields.function(_get_message_data,
218 fnct_search=_search_message_unread, multi="_get_message_data",
219 type='boolean', string='Unread Messages',
220 help="If checked new messages require your attention."),
221 'message_summary': fields.function(_get_message_data, method=True,
222 type='text', string='Summary', multi="_get_message_data",
223 help="Holds the Chatter summary (number of messages, ...). "\
224 "This summary is directly in html format in order to "\
225 "be inserted in kanban views."),
228 #------------------------------------------------------
229 # CRUD overrides for automatic subscription and logging
230 #------------------------------------------------------
232 def create(self, cr, uid, values, context=None):
233 """ Chatter override :
235 - subscribe followers of parent
236 - log a creation message
240 thread_id = super(mail_thread, self).create(cr, uid, values, context=context)
242 # subscribe uid unless asked not to
243 if not context.get('mail_create_nosubscribe'):
244 self.message_subscribe_users(cr, uid, [thread_id], [uid], context=context)
245 self.message_subscribe_from_parent(cr, uid, [thread_id], values.keys(), context=context)
247 # automatic logging unless asked not to (mainly for various testing purpose)
248 if not context.get('mail_create_nolog'):
249 self.message_post(cr, uid, thread_id, body='Document created', context=context)
252 def write(self, cr, uid, ids, values, context=None):
253 if isinstance(ids, (int, long)):
255 # Track initial values of tracked fields
256 tracked_fields = self._get_tracked_fields(cr, uid, values.keys(), context=context)
258 initial = self.read(cr, uid, ids, tracked_fields.keys(), context=context)
259 initial_values = dict((item['id'], item) for item in initial)
261 # Perform write, update followers
262 result = super(mail_thread, self).write(cr, uid, ids, values, context=context)
263 self.message_subscribe_from_parent(cr, uid, ids, values.keys(), context=context)
265 # Perform the tracking
267 self.message_track(cr, uid, ids, tracked_fields, initial_values, context=context)
270 def unlink(self, cr, uid, ids, context=None):
271 """ Override unlink to delete messages and followers. This cannot be
272 cascaded, because link is done through (res_model, res_id). """
273 msg_obj = self.pool.get('mail.message')
274 fol_obj = self.pool.get('mail.followers')
275 # delete messages and notifications
276 msg_ids = msg_obj.search(cr, uid, [('model', '=', self._name), ('res_id', 'in', ids)], context=context)
277 msg_obj.unlink(cr, uid, msg_ids, context=context)
279 res = super(mail_thread, self).unlink(cr, uid, ids, context=context)
281 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids)], context=context)
282 fol_obj.unlink(cr, SUPERUSER_ID, fol_ids, context=context)
285 def copy(self, cr, uid, id, default=None, context=None):
286 default = default or {}
287 default['message_ids'] = []
288 default['message_follower_ids'] = []
289 return super(mail_thread, self).copy(cr, uid, id, default=default, context=context)
291 #------------------------------------------------------
292 # Automatically log tracked fields
293 #------------------------------------------------------
295 def _get_tracked_fields(self, cr, uid, updated_fields, context=None):
296 """ Return a structure of tracked fields for the current model.
297 :param list updated_fields: modified field names
298 :return list: a list of (field_name, column_info obj), containing
299 always tracked fields and modified on_change fields
302 for name, column_info in self._all_columns.items():
303 visibility = getattr(column_info.column, 'track_visibility', False)
304 if visibility == 'always' or (visibility == 'onchange' and name in updated_fields) or name in self._track:
308 return self.fields_get(cr, uid, lst, context=context)
310 def message_track(self, cr, uid, ids, tracked_fields, initial_values, context=None):
312 def convert_for_display(value, col_info):
313 if not value and col_info['type'] == 'boolean':
317 if col_info['type'] == 'many2one':
319 if col_info['type'] == 'selection':
320 return dict(col_info['selection'])[value]
323 def format_message(message_description, tracked_values):
325 if message_description:
326 message = '<span>%s</span>' % message_description
327 for name, change in tracked_values.items():
328 message += '<div> • <b>%s</b>: ' % change.get('col_info')
329 if change.get('old_value'):
330 message += '%s → ' % change.get('old_value')
331 message += '%s</div>' % change.get('new_value')
334 if not tracked_fields:
337 for record in self.read(cr, uid, ids, tracked_fields.keys(), context=context):
338 initial = initial_values[record['id']]
342 # generate tracked_values data structure: {'col_name': {col_info, new_value, old_value}}
343 for col_name, col_info in tracked_fields.items():
344 if record[col_name] == initial[col_name] and getattr(self._all_columns[col_name].column, 'track_visibility', None) == 'always':
345 tracked_values[col_name] = dict(col_info=col_info['string'],
346 new_value=convert_for_display(record[col_name], col_info))
347 elif record[col_name] != initial[col_name]:
348 if getattr(self._all_columns[col_name].column, 'track_visibility', None) in ['always', 'onchange']:
349 tracked_values[col_name] = dict(col_info=col_info['string'],
350 old_value=convert_for_display(initial[col_name], col_info),
351 new_value=convert_for_display(record[col_name], col_info))
352 if col_name in tracked_fields:
353 changes.append(col_name)
357 # find subtypes and post messages or log if no subtype found
359 for field, track_info in self._track.items():
360 if field not in changes:
362 for subtype, method in track_info.items():
363 if method(self, cr, uid, record, context):
364 subtypes.append(subtype)
367 for subtype in subtypes:
369 subtype_rec = self.pool.get('ir.model.data').get_object(cr, uid, subtype.split('.')[0], subtype.split('.')[1])
370 except ValueError, e:
371 _logger.debug('subtype %s not found, giving error "%s"' % (subtype, e))
373 message = format_message(subtype_rec.description if subtype_rec.description else subtype_rec.name, tracked_values)
374 self.message_post(cr, uid, record['id'], body=message, subtype=subtype, context=context)
377 message = format_message('', tracked_values)
378 self.message_post(cr, uid, record['id'], body=message, context=context)
381 #------------------------------------------------------
382 # mail.message wrappers and tools
383 #------------------------------------------------------
385 def _needaction_domain_get(self, cr, uid, context=None):
387 return [('message_unread', '=', True)]
390 #------------------------------------------------------
392 #------------------------------------------------------
394 def message_get_reply_to(self, cr, uid, ids, context=None):
395 if not self._inherits.get('mail.alias'):
396 return [False for id in ids]
397 return ["%s@%s" % (record['alias_name'], record['alias_domain'])
398 if record.get('alias_domain') and record.get('alias_name')
400 for record in self.read(cr, uid, ids, ['alias_name', 'alias_domain'], context=context)]
402 #------------------------------------------------------
404 #------------------------------------------------------
406 def message_capable_models(self, cr, uid, context=None):
407 """ Used by the plugin addon, based for plugin_outlook and others. """
409 for model_name in self.pool.obj_list():
410 model = self.pool.get(model_name)
411 if 'mail.thread' in getattr(model, '_inherit', []):
412 ret_dict[model_name] = model._description
415 def _message_find_partners(self, cr, uid, message, header_fields=['From'], context=None):
416 """ Find partners related to some header fields of the message. """
417 s = ', '.join([decode(message.get(h)) for h in header_fields if message.get(h)])
418 return [partner_id for email in tools.email_split(s)
419 for partner_id in self.pool.get('res.partner').search(cr, uid, [('email', 'ilike', email)], limit=1, context=context)]
421 def _message_find_user_id(self, cr, uid, message, context=None):
422 from_local_part = tools.email_split(decode(message.get('From')))[0]
423 # FP Note: canonification required, the minimu: .lower()
424 user_ids = self.pool.get('res.users').search(cr, uid, ['|',
425 ('login', '=', from_local_part),
426 ('email', '=', from_local_part)], context=context)
427 return user_ids[0] if user_ids else uid
429 def message_route(self, cr, uid, message, model=None, thread_id=None,
430 custom_values=None, context=None):
431 """Attempt to figure out the correct target model, thread_id,
432 custom_values and user_id to use for an incoming message.
433 Multiple values may be returned, if a message had multiple
434 recipients matching existing mail.aliases, for example.
436 The following heuristics are used, in this order:
437 1. If the message replies to an existing thread_id, and
438 properly contains the thread model in the 'In-Reply-To'
439 header, use this model/thread_id pair, and ignore
440 custom_value (not needed as no creation will take place)
441 2. Look for a mail.alias entry matching the message
442 recipient, and use the corresponding model, thread_id,
443 custom_values and user_id.
444 3. Fallback to the ``model``, ``thread_id`` and ``custom_values``
446 4. If all the above fails, raise an exception.
448 :param string message: an email.message instance
449 :param string model: the fallback model to use if the message
450 does not match any of the currently configured mail aliases
451 (may be None if a matching alias is supposed to be present)
452 :type dict custom_values: optional dictionary of default field values
453 to pass to ``message_new`` if a new record needs to be created.
454 Ignored if the thread record already exists, and also if a
455 matching mail.alias was found (aliases define their own defaults)
456 :param int thread_id: optional ID of the record/thread from ``model``
457 to which this mail should be attached. Only used if the message
458 does not reply to an existing thread and does not match any mail alias.
459 :return: list of [model, thread_id, custom_values, user_id]
461 assert isinstance(message, Message), 'message must be an email.message.Message at this point'
462 message_id = message.get('Message-Id')
463 references = decode_header(message, 'References')
464 in_reply_to = decode_header(message, 'In-Reply-To')
466 # 1. Verify if this is a reply to an existing thread
467 thread_references = references or in_reply_to
468 ref_match = thread_references and tools.reference_re.search(thread_references)
470 thread_id = int(ref_match.group(1))
471 model = ref_match.group(2) or model
472 model_pool = self.pool.get(model)
473 if thread_id and model and model_pool and model_pool.exists(cr, uid, thread_id) \
474 and hasattr(model_pool, 'message_update'):
475 _logger.debug('Routing mail with Message-Id %s: direct reply to model: %s, thread_id: %s, custom_values: %s, uid: %s',
476 message_id, model, thread_id, custom_values, uid)
477 return [(model, thread_id, custom_values, uid)]
479 # Verify whether this is a reply to a private message
481 message_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', '=', in_reply_to)], limit=1, context=context)
483 message = self.pool.get('mail.message').browse(cr, uid, message_ids[0], context=context)
484 _logger.debug('Routing mail with Message-Id %s: direct reply to a private message: %s, custom_values: %s, uid: %s',
485 message_id, message.id, custom_values, uid)
486 return [(message.model, message.res_id, custom_values, uid)]
488 # 2. Look for a matching mail.alias entry
489 # Delivered-To is a safe bet in most modern MTAs, but we have to fallback on To + Cc values
490 # for all the odd MTAs out there, as there is no standard header for the envelope's `rcpt_to` value.
492 ','.join([decode_header(message, 'Delivered-To'),
493 decode_header(message, 'To'),
494 decode_header(message, 'Cc'),
495 decode_header(message, 'Resent-To'),
496 decode_header(message, 'Resent-Cc')])
497 local_parts = [e.split('@')[0] for e in tools.email_split(rcpt_tos)]
499 mail_alias = self.pool.get('mail.alias')
500 alias_ids = mail_alias.search(cr, uid, [('alias_name', 'in', local_parts)])
503 for alias in mail_alias.browse(cr, uid, alias_ids, context=context):
504 user_id = alias.alias_user_id.id
506 user_id = self._message_find_user_id(cr, uid, message, context=context)
507 routes.append((alias.alias_model_id.model, alias.alias_force_thread_id, \
508 eval(alias.alias_defaults), user_id))
509 _logger.debug('Routing mail with Message-Id %s: direct alias match: %r', message_id, routes)
512 # 3. Fallback to the provided parameters, if they work
513 model_pool = self.pool.get(model)
515 # Legacy: fallback to matching [ID] in the Subject
516 match = tools.res_re.search(decode_header(message, 'Subject'))
517 thread_id = match and match.group(1)
518 assert thread_id and hasattr(model_pool, 'message_update') or hasattr(model_pool, 'message_new'), \
519 "No possible route found for incoming message with Message-Id %s. " \
520 "Create an appropriate mail.alias or force the destination model." % message_id
521 if thread_id and not model_pool.exists(cr, uid, thread_id):
522 _logger.warning('Received mail reply to missing document %s! Ignoring and creating new document instead for Message-Id %s',
523 thread_id, message_id)
525 _logger.debug('Routing mail with Message-Id %s: fallback to model:%s, thread_id:%s, custom_values:%s, uid:%s',
526 message_id, model, thread_id, custom_values, uid)
527 return [(model, thread_id, custom_values, uid)]
529 def message_process(self, cr, uid, model, message, custom_values=None,
530 save_original=False, strip_attachments=False,
531 thread_id=None, context=None):
532 """ Process an incoming RFC2822 email message, relying on
533 ``mail.message.parse()`` for the parsing operation,
534 and ``message_route()`` to figure out the target model.
536 Once the target model is known, its ``message_new`` method
537 is called with the new message (if the thread record did not exist)
538 or its ``message_update`` method (if it did).
540 There is a special case where the target model is False: a reply
541 to a private message. In this case, we skip the message_new /
542 message_update step, to just post a new message using mail_thread
545 :param string model: the fallback model to use if the message
546 does not match any of the currently configured mail aliases
547 (may be None if a matching alias is supposed to be present)
548 :param message: source of the RFC2822 message
549 :type message: string or xmlrpclib.Binary
550 :type dict custom_values: optional dictionary of field values
551 to pass to ``message_new`` if a new record needs to be created.
552 Ignored if the thread record already exists, and also if a
553 matching mail.alias was found (aliases define their own defaults)
554 :param bool save_original: whether to keep a copy of the original
555 email source attached to the message after it is imported.
556 :param bool strip_attachments: whether to strip all attachments
557 before processing the message, in order to save some space.
558 :param int thread_id: optional ID of the record/thread from ``model``
559 to which this mail should be attached. When provided, this
560 overrides the automatic detection based on the message
566 # extract message bytes - we are forced to pass the message as binary because
567 # we don't know its encoding until we parse its headers and hence can't
568 # convert it to utf-8 for transport between the mailgate script and here.
569 if isinstance(message, xmlrpclib.Binary):
570 message = str(message.data)
571 # Warning: message_from_string doesn't always work correctly on unicode,
572 # we must use utf-8 strings here :-(
573 if isinstance(message, unicode):
574 message = message.encode('utf-8')
575 msg_txt = email.message_from_string(message)
576 routes = self.message_route(cr, uid, msg_txt, model,
577 thread_id, custom_values,
579 msg = self.message_parse(cr, uid, msg_txt, save_original=save_original, context=context)
580 if strip_attachments:
581 msg.pop('attachments', None)
583 # postpone setting msg.partner_ids after message_post, to avoid double notifications
584 partner_ids = msg.pop('partner_ids', [])
587 for model, thread_id, custom_values, user_id in routes:
588 if self._name != model:
589 context.update({'thread_model': model})
591 model_pool = self.pool.get(model)
592 assert thread_id and hasattr(model_pool, 'message_update') or hasattr(model_pool, 'message_new'), \
593 "Undeliverable mail with Message-Id %s, model %s does not accept incoming emails" % \
594 (msg['message_id'], model)
596 # disabled subscriptions during message_new/update to avoid having the system user running the
597 # email gateway become a follower of all inbound messages
598 nosub_ctx = dict(context, mail_create_nosubscribe=True)
599 if thread_id and hasattr(model_pool, 'message_update'):
600 model_pool.message_update(cr, user_id, [thread_id], msg, context=nosub_ctx)
602 thread_id = model_pool.message_new(cr, user_id, msg, custom_values, context=nosub_ctx)
604 assert thread_id == 0, "Posting a message without model should be with a null res_id, to create a private message."
605 model_pool = self.pool.get('mail.thread')
606 new_msg_id = model_pool.message_post_user_api(cr, uid, [thread_id], context=context, content_subtype='html', **msg)
608 # when posting an incoming email to a document: subscribe the author, if a partner, as follower
609 if model and thread_id and msg.get('author_id'):
610 model_pool.message_subscribe(cr, uid, [thread_id], [msg.get('author_id')], context=context)
613 # postponed after message_post, because this is an external message and we don't want to create
614 # duplicate emails due to notifications
615 self.pool.get('mail.message').write(cr, uid, [new_msg_id], {'partner_ids': partner_ids}, context=context)
619 def message_new(self, cr, uid, msg_dict, custom_values=None, context=None):
620 """Called by ``message_process`` when a new message is received
621 for a given thread model, if the message did not belong to
623 The default behavior is to create a new record of the corresponding
624 model (based on some very basic info extracted from the message).
625 Additional behavior may be implemented by overriding this method.
627 :param dict msg_dict: a map containing the email details and
628 attachments. See ``message_process`` and
629 ``mail.message.parse`` for details.
630 :param dict custom_values: optional dictionary of additional
631 field values to pass to create()
632 when creating the new thread record.
633 Be careful, these values may override
634 any other values coming from the message.
635 :param dict context: if a ``thread_model`` value is present
636 in the context, its value will be used
637 to determine the model of the record
638 to create (instead of the current model).
640 :return: the id of the newly created thread object
644 model = context.get('thread_model') or self._name
645 model_pool = self.pool.get(model)
646 fields = model_pool.fields_get(cr, uid, context=context)
647 data = model_pool.default_get(cr, uid, fields, context=context)
648 if 'name' in fields and not data.get('name'):
649 data['name'] = msg_dict.get('subject', '')
650 if custom_values and isinstance(custom_values, dict):
651 data.update(custom_values)
652 res_id = model_pool.create(cr, uid, data, context=context)
655 def message_update(self, cr, uid, ids, msg_dict, update_vals=None, context=None):
656 """Called by ``message_process`` when a new message is received
657 for an existing thread. The default behavior is to update the record
658 with update_vals taken from the incoming email.
659 Additional behavior may be implemented by overriding this
661 :param dict msg_dict: a map containing the email details and
662 attachments. See ``message_process`` and
663 ``mail.message.parse()`` for details.
664 :param dict update_vals: a dict containing values to update records
665 given their ids; if the dict is None or is
666 void, no write operation is performed.
669 self.write(cr, uid, ids, update_vals, context=context)
672 def _message_extract_payload(self, message, save_original=False):
673 """Extract body as HTML and attachments from the mail message"""
677 attachments.append(('original_email.eml', message.as_string()))
678 if not message.is_multipart() or 'text/' in message.get('content-type', ''):
679 encoding = message.get_content_charset()
680 body = message.get_payload(decode=True)
681 body = tools.ustr(body, encoding, errors='replace')
682 if message.get_content_type() == 'text/plain':
683 # text/plain -> <pre/>
684 body = tools.append_content_to_html(u'', body, preserve=True)
686 alternative = (message.get_content_type() == 'multipart/alternative')
687 for part in message.walk():
688 if part.get_content_maintype() == 'multipart':
689 continue # skip container
690 filename = part.get_filename() # None if normal part
691 encoding = part.get_content_charset() # None if attachment
692 # 1) Explicit Attachments -> attachments
693 if filename or part.get('content-disposition', '').strip().startswith('attachment'):
694 attachments.append((filename or 'attachment', part.get_payload(decode=True)))
696 # 2) text/plain -> <pre/>
697 if part.get_content_type() == 'text/plain' and (not alternative or not body):
698 body = tools.append_content_to_html(body, tools.ustr(part.get_payload(decode=True),
699 encoding, errors='replace'), preserve=True)
700 # 3) text/html -> raw
701 elif part.get_content_type() == 'text/html':
702 html = tools.ustr(part.get_payload(decode=True), encoding, errors='replace')
706 body = tools.append_content_to_html(body, html, plaintext=False)
707 # 4) Anything else -> attachment
709 attachments.append((filename or 'attachment', part.get_payload(decode=True)))
710 return body, attachments
712 def message_parse(self, cr, uid, message, save_original=False, context=None):
713 """Parses a string or email.message.Message representing an
714 RFC-2822 email, and returns a generic dict holding the
717 :param message: the message to parse
718 :type message: email.message.Message | string | unicode
719 :param bool save_original: whether the returned dict
720 should include an ``original`` attachment containing
721 the source of the message
723 :return: A dict with the following structure, where each
724 field may not be present if missing in original
727 { 'message_id': msg_id,
732 'body': unified_body,
733 'attachments': [('file1', 'bytes'),
741 if not isinstance(message, Message):
742 if isinstance(message, unicode):
743 # Warning: message_from_string doesn't always work correctly on unicode,
744 # we must use utf-8 strings here :-(
745 message = message.encode('utf-8')
746 message = email.message_from_string(message)
748 message_id = message['message-id']
750 # Very unusual situation, be we should be fault-tolerant here
751 message_id = "<%s@localhost>" % time.time()
752 _logger.debug('Parsing Message without message-id, generating a random one: %s', message_id)
753 msg_dict['message_id'] = message_id
755 if 'Subject' in message:
756 msg_dict['subject'] = decode(message.get('Subject'))
758 # Envelope fields not stored in mail.message but made available for message_new()
759 msg_dict['from'] = decode(message.get('from'))
760 msg_dict['to'] = decode(message.get('to'))
761 msg_dict['cc'] = decode(message.get('cc'))
763 if 'From' in message:
764 author_ids = self._message_find_partners(cr, uid, message, ['From'], context=context)
766 msg_dict['author_id'] = author_ids[0]
768 msg_dict['email_from'] = message.get('from')
769 partner_ids = self._message_find_partners(cr, uid, message, ['From', 'To', 'Cc'], context=context)
770 msg_dict['partner_ids'] = [(4, partner_id) for partner_id in partner_ids]
772 if 'Date' in message:
774 date_hdr = decode(message.get('Date'))
775 parsed_date = dateutil.parser.parse(date_hdr, fuzzy=True)
776 if parsed_date.utcoffset() is None:
777 # naive datetime, so we arbitrarily decide to make it
778 # UTC, there's no better choice. Should not happen,
779 # as RFC2822 requires timezone offset in Date headers.
780 stored_date = parsed_date.replace(tzinfo=pytz.utc)
782 stored_date = parsed_date.astimezone(pytz.utc)
784 _logger.warning('Failed to parse Date header %r in incoming mail '
785 'with message-id %r, assuming current date/time.',
786 message.get('Date'), message_id)
787 stored_date = datetime.datetime.now()
788 msg_dict['date'] = stored_date.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT)
790 if 'In-Reply-To' in message:
791 parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', '=', decode(message['In-Reply-To']))])
793 msg_dict['parent_id'] = parent_ids[0]
795 if 'References' in message and 'parent_id' not in msg_dict:
796 parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', 'in',
797 [x.strip() for x in decode(message['References']).split()])])
799 msg_dict['parent_id'] = parent_ids[0]
801 msg_dict['body'], msg_dict['attachments'] = self._message_extract_payload(message)
804 #------------------------------------------------------
806 #------------------------------------------------------
808 def log(self, cr, uid, id, message, secondary=False, context=None):
809 _logger.warning("log() is deprecated. As this module inherit from "\
810 "mail.thread, the message will be managed by this "\
811 "module instead of by the res.log mechanism. Please "\
812 "use mail_thread.message_post() instead of the "\
813 "now deprecated res.log.")
814 self.message_post(cr, uid, [id], message, context=context)
816 def message_post(self, cr, uid, thread_id, body='', subject=None, type='notification',
817 subtype=None, parent_id=False, attachments=None, context=None, **kwargs):
818 """ Post a new message in an existing thread, returning the new
819 mail.message ID. Extra keyword arguments will be used as default
820 column values for the new mail.message record.
821 Auto link messages for same id and object
822 :param int thread_id: thread ID to post into, or list with one ID;
823 if False/0, mail.message model will also be set as False
824 :param str body: body of the message, usually raw HTML that will
826 :param str subject: optional subject
827 :param str type: mail_message.type
828 :param int parent_id: optional ID of parent message in this thread
829 :param tuple(str,str) attachments or list id: list of attachment tuples in the form
830 ``(name,content)``, where content is NOT base64 encoded
831 :return: ID of newly created mail.message
835 if attachments is None:
838 assert (not thread_id) or isinstance(thread_id, (int, long)) or \
839 (isinstance(thread_id, (list, tuple)) and len(thread_id) == 1), "Invalid thread_id; should be 0, False, an ID or a list with one ID"
840 if isinstance(thread_id, (list, tuple)):
841 thread_id = thread_id and thread_id[0]
842 mail_message = self.pool.get('mail.message')
843 model = context.get('thread_model', self._name) if thread_id else False
845 attachment_ids = kwargs.pop('attachment_ids', [])
846 for name, content in attachments:
847 if isinstance(content, unicode):
848 content = content.encode('utf-8')
851 'datas': base64.b64encode(str(content)),
854 'res_model': context.get('thread_model') or self._name,
857 attachment_ids.append((0, 0, data_attach))
861 s_data = subtype.split('.')
863 s_data = ('mail', s_data[0])
864 ref = self.pool.get('ir.model.data').get_object_reference(cr, uid, s_data[0], s_data[1])
865 subtype_id = ref and ref[1] or False
869 # _mail_flat_thread: automatically set free messages to the first posted message
870 if self._mail_flat_thread and not parent_id and thread_id:
871 message_ids = mail_message.search(cr, uid, ['&', ('res_id', '=', thread_id), ('model', '=', model)], context=context, order="id ASC", limit=1)
872 parent_id = message_ids and message_ids[0] or False
873 # 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
875 message_ids = mail_message.search(cr, SUPERUSER_ID, [('id', '=', parent_id), ('parent_id', '!=', False)], context=context)
876 # avoid loops when finding ancestors
879 message = mail_message.browse(cr, SUPERUSER_ID, message_ids[0], context=context)
880 while (message.parent_id and message.parent_id.id not in processed_list):
881 processed_list.append(message.parent_id.id)
882 message = message.parent_id
883 parent_id = message.id
888 'res_id': thread_id or False,
890 'subject': subject or False,
892 'parent_id': parent_id,
893 'attachment_ids': attachment_ids,
894 'subtype_id': subtype_id,
897 # Avoid warnings about non-existing fields
898 for x in ('from', 'to', 'cc'):
901 return mail_message.create(cr, uid, values, context=context)
903 def message_post_user_api(self, cr, uid, thread_id, body='', parent_id=False,
904 attachment_ids=None, extra_emails=None, content_subtype='plaintext',
905 context=None, **kwargs):
906 """ Wrapper on message_post, used for user input :
908 - quick reply in Chatter (refer to mail.js), not
909 the mail.compose.message wizard
910 The purpose is to perform some pre- and post-processing:
911 - if body is plaintext: convert it into html
912 - if parent_id: handle reply to a previous message by adding the
913 parent partners to the message
914 - type and subtype: comment and mail.mt_comment by default
915 - attachment_ids: supposed not attached to any document; attach them
916 to the related document. Should only be set by Chatter.
917 - extra_email: [ 'Fabien <fpi@openerp.com>', 'al@openerp.com' ]
919 partner_obj = self.pool.get('res.partner')
920 mail_message_obj = self.pool.get('mail.message')
921 ir_attachment = self.pool.get('ir.attachment')
922 extra_emails = extra_emails or []
924 # 1.A.1: pre-process partners and incoming extra_emails
925 partner_ids = set([])
926 for email in extra_emails:
927 partner_id = partner_obj.find_or_create(cr, uid, email, context=context)
928 # link mail with this from mail to the new partner id
929 partner_msg_ids = mail_message_obj.search(cr, SUPERUSER_ID, [('email_from', '=', email), ('author_id', '=', False)], context=context)
930 if partner_id and partner_msg_ids:
931 mail_message_obj.write(cr, SUPERUSER_ID, partner_msg_ids, {'email_from': None, 'author_id': partner_id}, context=context)
932 partner_ids.add((4, partner_id))
934 self.message_subscribe(cr, uid, [thread_id], [item[1] for item in partner_ids], context=context)
936 # 1.A.2: add recipients of parent message
938 parent_message = mail_message_obj.browse(cr, uid, parent_id, context=context)
939 partner_ids |= set([(4, partner.id) for partner in parent_message.partner_ids])
940 # TDE FIXME HACK: mail.thread -> private message
941 if self._name == 'mail.thread' and parent_message.author_id.id:
942 partner_ids.add((4, parent_message.author_id.id))
944 # 1.A.3: add specified recipients
945 partner_ids |= set(kwargs.pop('partner_ids', []))
947 # 1.B: handle body, message_type and message_subtype
948 if content_subtype == 'plaintext':
949 body = tools.plaintext2html(body)
950 msg_type = kwargs.pop('type', 'comment')
951 msg_subtype = kwargs.pop('subtype', 'mail.mt_comment')
953 # 2. Pre-processing: attachments
954 # HACK TDE FIXME: Chatter: attachments linked to the document (not done JS-side), load the message
956 # TDE FIXME (?): when posting a private message, we use mail.thread as a model
957 # However, attaching doc to mail.thread is not possible, mail.thread does not have any table
959 if model == 'mail.thread':
961 filtered_attachment_ids = ir_attachment.search(cr, SUPERUSER_ID, [
962 ('res_model', '=', 'mail.compose.message'),
964 ('create_uid', '=', uid),
965 ('id', 'in', attachment_ids)], context=context)
966 if filtered_attachment_ids:
967 if thread_id and model:
968 ir_attachment.write(cr, SUPERUSER_ID, attachment_ids, {'res_model': model, 'res_id': thread_id}, context=context)
971 attachment_ids = [(4, id) for id in attachment_ids]
974 return self.message_post(cr, uid, thread_id=thread_id, body=body,
975 type=msg_type, subtype=msg_subtype, parent_id=parent_id,
976 attachment_ids=attachment_ids, partner_ids=partner_ids, context=context, **kwargs)
978 #------------------------------------------------------
980 #------------------------------------------------------
982 def message_get_subscription_data(self, cr, uid, ids, context=None):
983 """ Wrapper to get subtypes data. """
984 return self._get_subscription_data(cr, uid, ids, None, None, context=context)
986 def message_subscribe_users(self, cr, uid, ids, user_ids=None, subtype_ids=None, context=None):
987 """ Wrapper on message_subscribe, using users. If user_ids is not
988 provided, subscribe uid instead. """
991 partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
992 return self.message_subscribe(cr, uid, ids, partner_ids, subtype_ids=subtype_ids, context=context)
994 def message_subscribe(self, cr, uid, ids, partner_ids, subtype_ids=None, context=None):
995 """ Add partners to the records followers. """
996 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
997 if set(partner_ids) == set([user_pid]):
998 self.check_access_rights(cr, uid, 'read')
1000 self.check_access_rights(cr, uid, 'write')
1002 self.write(cr, SUPERUSER_ID, ids, {'message_follower_ids': [(4, pid) for pid in partner_ids]}, context=context)
1003 # if subtypes are not specified (and not set to a void list), fetch default ones
1004 if subtype_ids is None:
1005 subtype_obj = self.pool.get('mail.message.subtype')
1006 subtype_ids = subtype_obj.search(cr, uid, [('default', '=', True), '|', ('res_model', '=', self._name), ('res_model', '=', False)], context=context)
1007 # update the subscriptions
1008 fol_obj = self.pool.get('mail.followers')
1009 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids), ('partner_id', 'in', partner_ids)], context=context)
1010 fol_obj.write(cr, SUPERUSER_ID, fol_ids, {'subtype_ids': [(6, 0, subtype_ids)]}, context=context)
1013 def message_unsubscribe_users(self, cr, uid, ids, user_ids=None, context=None):
1014 """ Wrapper on message_subscribe, using users. If user_ids is not
1015 provided, unsubscribe uid instead. """
1016 if user_ids is None:
1018 partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
1019 return self.message_unsubscribe(cr, uid, ids, partner_ids, context=context)
1021 def message_unsubscribe(self, cr, uid, ids, partner_ids, context=None):
1022 """ Remove partners from the records followers. """
1023 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
1024 if set(partner_ids) == set([user_pid]):
1025 self.check_access_rights(cr, uid, 'read')
1027 self.check_access_rights(cr, uid, 'write')
1028 return self.write(cr, SUPERUSER_ID, ids, {'message_follower_ids': [(3, pid) for pid in partner_ids]}, context=context)
1030 def message_subscribe_from_parent(self, cr, uid, ids, updated_fields, context=None):
1032 1. fetch project subtype related to task (parent_id.res_model = 'project.task')
1033 2. for each project subtype: subscribe the follower to the task
1035 subtype_obj = self.pool.get('mail.message.subtype')
1036 follower_obj = self.pool.get('mail.followers')
1038 # fetch related record subtypes
1039 related_subtype_ids = subtype_obj.search(cr, uid, ['|', ('res_model', '=', False), ('parent_id.res_model', '=', self._name)], context=context)
1040 subtypes = subtype_obj.browse(cr, uid, related_subtype_ids, context=context)
1041 default_subtypes = [subtype for subtype in subtypes if subtype.res_model == False]
1042 related_subtypes = [subtype for subtype in subtypes if subtype.res_model != False]
1043 relation_fields = set([subtype.relation_field for subtype in subtypes if subtype.relation_field != False])
1044 if not related_subtypes or not any(relation in updated_fields for relation in relation_fields):
1047 for record in self.browse(cr, uid, ids, context=context):
1048 new_followers = dict()
1049 parent_res_id = False
1050 parent_model = False
1051 for subtype in related_subtypes:
1052 if not subtype.relation_field or not subtype.parent_id:
1054 if not subtype.relation_field in self._columns or not getattr(record, subtype.relation_field, False):
1056 parent_res_id = getattr(record, subtype.relation_field).id
1057 parent_model = subtype.res_model
1058 follower_ids = follower_obj.search(cr, SUPERUSER_ID, [
1059 ('res_model', '=', parent_model),
1060 ('res_id', '=', parent_res_id),
1061 ('subtype_ids', 'in', [subtype.id])
1063 for follower in follower_obj.browse(cr, SUPERUSER_ID, follower_ids, context=context):
1064 new_followers.setdefault(follower.partner_id.id, set()).add(subtype.parent_id.id)
1066 if not parent_res_id or not parent_model:
1069 for subtype in default_subtypes:
1070 follower_ids = follower_obj.search(cr, SUPERUSER_ID, [
1071 ('res_model', '=', parent_model),
1072 ('res_id', '=', parent_res_id),
1073 ('subtype_ids', 'in', [subtype.id])
1075 for follower in follower_obj.browse(cr, SUPERUSER_ID, follower_ids, context=context):
1076 new_followers.setdefault(follower.partner_id.id, set()).add(subtype.id)
1078 for pid, subtypes in new_followers.items():
1079 self.message_subscribe(cr, uid, [record.id], [pid], list(subtypes), context=context)
1082 #------------------------------------------------------
1084 #------------------------------------------------------
1086 def message_mark_as_unread(self, cr, uid, ids, context=None):
1087 """ Set as unread. """
1088 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1090 UPDATE mail_notification SET
1093 message_id IN (SELECT id from mail_message where res_id=any(%s) and model=%s limit 1) and
1095 ''', (ids, self._name, partner_id))
1098 def message_mark_as_read(self, cr, uid, ids, context=None):
1099 """ Set as read. """
1100 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1102 UPDATE mail_notification SET
1105 message_id IN (SELECT id FROM mail_message WHERE res_id=ANY(%s) AND model=%s) AND
1107 ''', (ids, self._name, partner_id))
1110 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: