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
37 from openerp.tools.safe_eval import safe_eval as eval
39 _logger = logging.getLogger(__name__)
42 def decode_header(message, header, separator=' '):
43 return separator.join(map(decode, filter(None, message.get_all(header, []))))
46 class mail_thread(osv.AbstractModel):
47 ''' mail_thread model is meant to be inherited by any model that needs to
48 act as a discussion topic on which messages can be attached. Public
49 methods are prefixed with ``message_`` in order to avoid name
50 collisions with methods of the models that will inherit from this class.
52 ``mail.thread`` defines fields used to handle and display the
53 communication history. ``mail.thread`` also manages followers of
54 inheriting classes. All features and expected behavior are managed
55 by mail.thread. Widgets has been designed for the 7.0 and following
58 Inheriting classes are not required to implement any method, as the
59 default implementation will work for any model. However it is common
60 to override at least the ``message_new`` and ``message_update``
61 methods (calling ``super``) to add model-specific behavior at
62 creation and update of a thread when processing incoming emails.
65 - _mail_flat_thread: if set to True, all messages without parent_id
66 are automatically attached to the first message posted on the
67 ressource. If set to False, the display of Chatter is done using
68 threads, and no parent_id is automatically set.
71 _description = 'Email Thread'
72 _mail_flat_thread = True
74 # Automatic logging system if mail installed
77 # 'module.subtype_xml': lambda self, cr, uid, obj, context=None: obj[state] == done,
78 # 'module.subtype_xml2': lambda self, cr, uid, obj, context=None: obj[state] != done,
85 # :param string field: field name
86 # :param module.subtype_xml: xml_id of a mail.message.subtype (i.e. mail.mt_comment)
87 # :param obj: is a browse_record
88 # :param function lambda: returns whether the tracking should record using this subtype
91 def _get_message_data(self, cr, uid, ids, name, args, context=None):
93 - message_unread: has uid unread message for the document
94 - message_summary: html snippet summarizing the Chatter for kanban views """
95 res = dict((id, dict(message_unread=False, message_summary='')) for id in ids)
96 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
98 # search for unread messages, directly in SQL to improve performances
99 cr.execute(""" SELECT m.res_id FROM mail_message m
100 RIGHT JOIN mail_notification n
101 ON (n.message_id = m.id AND n.partner_id = %s AND (n.read = False or n.read IS NULL))
102 WHERE m.model = %s AND m.res_id in %s""",
103 (user_pid, self._name, tuple(ids),))
104 msg_ids = [result[0] for result in cr.fetchall()]
105 for msg_id in msg_ids:
106 res[msg_id]['message_unread'] = True
108 for thread in self.browse(cr, uid, ids, context=context):
109 cls = res[thread.id]['message_unread'] and ' class="oe_kanban_mail_new"' or ''
110 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))
114 def _get_subscription_data(self, cr, uid, ids, name, args, context=None):
116 - message_subtype_data: data about document subtypes: which are
117 available, which are followed if any """
118 res = dict((id, dict(message_subtype_data='')) for id in ids)
119 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
121 # find current model subtypes, add them to a dictionary
122 subtype_obj = self.pool.get('mail.message.subtype')
123 subtype_ids = subtype_obj.search(cr, uid, ['|', ('res_model', '=', self._name), ('res_model', '=', False)], context=context)
124 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))
126 res[id]['message_subtype_data'] = subtype_dict.copy()
128 # find the document followers, update the data
129 fol_obj = self.pool.get('mail.followers')
130 fol_ids = fol_obj.search(cr, uid, [
131 ('partner_id', '=', user_pid),
132 ('res_id', 'in', ids),
133 ('res_model', '=', self._name),
135 for fol in fol_obj.browse(cr, uid, fol_ids, context=context):
136 thread_subtype_dict = res[fol.res_id]['message_subtype_data']
137 for subtype in fol.subtype_ids:
138 thread_subtype_dict[subtype.name]['followed'] = True
139 res[fol.res_id]['message_subtype_data'] = thread_subtype_dict
143 def _search_message_unread(self, cr, uid, obj=None, name=None, domain=None, context=None):
144 return [('message_ids.to_read', '=', True)]
146 def _get_followers(self, cr, uid, ids, name, arg, context=None):
147 fol_obj = self.pool.get('mail.followers')
148 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids)])
149 res = dict((id, dict(message_follower_ids=[], message_is_follower=False)) for id in ids)
150 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
151 for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids):
152 res[fol.res_id]['message_follower_ids'].append(fol.partner_id.id)
153 if fol.partner_id.id == user_pid:
154 res[fol.res_id]['message_is_follower'] = True
157 def _set_followers(self, cr, uid, id, name, value, arg, context=None):
160 partner_obj = self.pool.get('res.partner')
161 fol_obj = self.pool.get('mail.followers')
163 # read the old set of followers, and determine the new set of followers
164 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', '=', id)])
165 old = set(fol.partner_id.id for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids))
168 for command in value or []:
169 if isinstance(command, (int, long)):
171 elif command[0] == 0:
172 new.add(partner_obj.create(cr, uid, command[2], context=context))
173 elif command[0] == 1:
174 partner_obj.write(cr, uid, [command[1]], command[2], context=context)
176 elif command[0] == 2:
177 partner_obj.unlink(cr, uid, [command[1]], context=context)
178 new.discard(command[1])
179 elif command[0] == 3:
180 new.discard(command[1])
181 elif command[0] == 4:
183 elif command[0] == 5:
185 elif command[0] == 6:
186 new = set(command[2])
188 # remove partners that are no longer followers
189 fol_ids = fol_obj.search(cr, SUPERUSER_ID,
190 [('res_model', '=', self._name), ('res_id', '=', id), ('partner_id', 'not in', list(new))])
191 fol_obj.unlink(cr, SUPERUSER_ID, fol_ids)
194 for partner_id in new - old:
195 fol_obj.create(cr, SUPERUSER_ID, {'res_model': self._name, 'res_id': id, 'partner_id': partner_id})
197 def _search_followers(self, cr, uid, obj, name, args, context):
198 fol_obj = self.pool.get('mail.followers')
200 for field, operator, value in args:
202 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('partner_id', operator, value)])
203 res_ids = [fol.res_id for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids)]
204 res.append(('id', 'in', res_ids))
208 'message_is_follower': fields.function(_get_followers,
209 type='boolean', string='Is a Follower', multi='_get_followers,'),
210 'message_follower_ids': fields.function(_get_followers, fnct_inv=_set_followers,
211 fnct_search=_search_followers, type='many2many',
212 obj='res.partner', string='Followers', multi='_get_followers'),
213 'message_ids': fields.one2many('mail.message', 'res_id',
214 domain=lambda self: [('model', '=', self._name)],
217 help="Messages and communication history"),
218 'message_unread': fields.function(_get_message_data,
219 fnct_search=_search_message_unread, multi="_get_message_data",
220 type='boolean', string='Unread Messages',
221 help="If checked new messages require your attention."),
222 'message_summary': fields.function(_get_message_data, method=True,
223 type='text', string='Summary', multi="_get_message_data",
224 help="Holds the Chatter summary (number of messages, ...). "\
225 "This summary is directly in html format in order to "\
226 "be inserted in kanban views."),
229 #------------------------------------------------------
230 # CRUD overrides for automatic subscription and logging
231 #------------------------------------------------------
233 def create(self, cr, uid, values, context=None):
234 """ Chatter override :
236 - subscribe followers of parent
237 - log a creation message
241 thread_id = super(mail_thread, self).create(cr, uid, values, context=context)
243 # subscribe uid unless asked not to
244 if not context.get('mail_create_nosubscribe'):
245 self.message_subscribe_users(cr, uid, [thread_id], [uid], context=context)
246 self.message_subscribe_from_parent(cr, uid, [thread_id], values.keys(), context=context)
248 # automatic logging unless asked not to (mainly for various testing purpose)
249 if not context.get('mail_create_nolog'):
250 self.message_post(cr, uid, thread_id, body='Document created', context=context)
253 def write(self, cr, uid, ids, values, context=None):
254 if isinstance(ids, (int, long)):
256 # Track initial values of tracked fields
257 tracked_fields = self._get_tracked_fields(cr, uid, values.keys(), context=context)
259 initial = self.read(cr, uid, ids, tracked_fields.keys(), context=context)
260 initial_values = dict((item['id'], item) for item in initial)
262 # Perform write, update followers
263 result = super(mail_thread, self).write(cr, uid, ids, values, context=context)
264 self.message_subscribe_from_parent(cr, uid, ids, values.keys(), context=context)
266 # Perform the tracking
268 self.message_track(cr, uid, ids, tracked_fields, initial_values, context=context)
271 def unlink(self, cr, uid, ids, context=None):
272 """ Override unlink to delete messages and followers. This cannot be
273 cascaded, because link is done through (res_model, res_id). """
274 msg_obj = self.pool.get('mail.message')
275 fol_obj = self.pool.get('mail.followers')
276 # delete messages and notifications
277 msg_ids = msg_obj.search(cr, uid, [('model', '=', self._name), ('res_id', 'in', ids)], context=context)
278 msg_obj.unlink(cr, uid, msg_ids, context=context)
280 res = super(mail_thread, self).unlink(cr, uid, ids, context=context)
282 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids)], context=context)
283 fol_obj.unlink(cr, SUPERUSER_ID, fol_ids, context=context)
286 def copy(self, cr, uid, id, default=None, context=None):
287 default = default or {}
288 default['message_ids'] = []
289 default['message_follower_ids'] = []
290 return super(mail_thread, self).copy(cr, uid, id, default=default, context=context)
292 #------------------------------------------------------
293 # Automatically log tracked fields
294 #------------------------------------------------------
296 def _get_tracked_fields(self, cr, uid, updated_fields, context=None):
297 """ Return a structure of tracked fields for the current model.
298 :param list updated_fields: modified field names
299 :return list: a list of (field_name, column_info obj), containing
300 always tracked fields and modified on_change fields
303 for name, column_info in self._all_columns.items():
304 visibility = getattr(column_info.column, 'track_visibility', False)
305 if visibility == 'always' or (visibility == 'onchange' and name in updated_fields) or name in self._track:
309 return self.fields_get(cr, uid, lst, context=context)
311 def message_track(self, cr, uid, ids, tracked_fields, initial_values, context=None):
313 def convert_for_display(value, col_info):
314 if not value and col_info['type'] == 'boolean':
318 if col_info['type'] == 'many2one':
320 if col_info['type'] == 'selection':
321 return dict(col_info['selection'])[value]
324 def format_message(message_description, tracked_values):
326 if message_description:
327 message = '<span>%s</span>' % message_description
328 for name, change in tracked_values.items():
329 message += '<div> • <b>%s</b>: ' % change.get('col_info')
330 if change.get('old_value'):
331 message += '%s → ' % change.get('old_value')
332 message += '%s</div>' % change.get('new_value')
335 if not tracked_fields:
338 for record in self.read(cr, uid, ids, tracked_fields.keys(), context=context):
339 initial = initial_values[record['id']]
343 # generate tracked_values data structure: {'col_name': {col_info, new_value, old_value}}
344 for col_name, col_info in tracked_fields.items():
345 if record[col_name] == initial[col_name] and getattr(self._all_columns[col_name].column, 'track_visibility', None) == 'always':
346 tracked_values[col_name] = dict(col_info=col_info['string'],
347 new_value=convert_for_display(record[col_name], col_info))
348 elif record[col_name] != initial[col_name]:
349 if getattr(self._all_columns[col_name].column, 'track_visibility', None) in ['always', 'onchange']:
350 tracked_values[col_name] = dict(col_info=col_info['string'],
351 old_value=convert_for_display(initial[col_name], col_info),
352 new_value=convert_for_display(record[col_name], col_info))
353 if col_name in tracked_fields:
354 changes.append(col_name)
358 # find subtypes and post messages or log if no subtype found
360 for field, track_info in self._track.items():
361 if field not in changes:
363 for subtype, method in track_info.items():
364 if method(self, cr, uid, record, context):
365 subtypes.append(subtype)
368 for subtype in subtypes:
370 subtype_rec = self.pool.get('ir.model.data').get_object(cr, uid, subtype.split('.')[0], subtype.split('.')[1])
371 except ValueError, e:
372 _logger.debug('subtype %s not found, giving error "%s"' % (subtype, e))
374 message = format_message(subtype_rec.description if subtype_rec.description else subtype_rec.name, tracked_values)
375 self.message_post(cr, uid, record['id'], body=message, subtype=subtype, context=context)
378 message = format_message('', tracked_values)
379 self.message_post(cr, uid, record['id'], body=message, context=context)
382 #------------------------------------------------------
383 # mail.message wrappers and tools
384 #------------------------------------------------------
386 def _needaction_domain_get(self, cr, uid, context=None):
388 return [('message_unread', '=', True)]
391 #------------------------------------------------------
393 #------------------------------------------------------
395 def message_get_reply_to(self, cr, uid, ids, context=None):
396 if not self._inherits.get('mail.alias'):
397 return [False for id in ids]
398 return ["%s@%s" % (record['alias_name'], record['alias_domain'])
399 if record.get('alias_domain') and record.get('alias_name')
401 for record in self.read(cr, uid, ids, ['alias_name', 'alias_domain'], context=context)]
403 #------------------------------------------------------
405 #------------------------------------------------------
407 def message_capable_models(self, cr, uid, context=None):
408 """ Used by the plugin addon, based for plugin_outlook and others. """
410 for model_name in self.pool.obj_list():
411 model = self.pool.get(model_name)
412 if 'mail.thread' in getattr(model, '_inherit', []):
413 ret_dict[model_name] = model._description
416 def _message_find_partners(self, cr, uid, message, header_fields=['From'], context=None):
417 """ Find partners related to some header fields of the message. """
418 s = ', '.join([decode(message.get(h)) for h in header_fields if message.get(h)])
419 return [partner_id for email in tools.email_split(s)
420 for partner_id in self.pool.get('res.partner').search(cr, uid, [('email', 'ilike', email)], limit=1, context=context)]
422 def _message_find_user_id(self, cr, uid, message, context=None):
423 from_local_part = tools.email_split(decode(message.get('From')))[0]
424 # FP Note: canonification required, the minimu: .lower()
425 user_ids = self.pool.get('res.users').search(cr, uid, ['|',
426 ('login', '=', from_local_part),
427 ('email', '=', from_local_part)], context=context)
428 return user_ids[0] if user_ids else uid
430 def message_route(self, cr, uid, message, model=None, thread_id=None,
431 custom_values=None, context=None):
432 """Attempt to figure out the correct target model, thread_id,
433 custom_values and user_id to use for an incoming message.
434 Multiple values may be returned, if a message had multiple
435 recipients matching existing mail.aliases, for example.
437 The following heuristics are used, in this order:
438 1. If the message replies to an existing thread_id, and
439 properly contains the thread model in the 'In-Reply-To'
440 header, use this model/thread_id pair, and ignore
441 custom_value (not needed as no creation will take place)
442 2. Look for a mail.alias entry matching the message
443 recipient, and use the corresponding model, thread_id,
444 custom_values and user_id.
445 3. Fallback to the ``model``, ``thread_id`` and ``custom_values``
447 4. If all the above fails, raise an exception.
449 :param string message: an email.message instance
450 :param string model: the fallback model to use if the message
451 does not match any of the currently configured mail aliases
452 (may be None if a matching alias is supposed to be present)
453 :type dict custom_values: optional dictionary of default field values
454 to pass to ``message_new`` if a new record needs to be created.
455 Ignored if the thread record already exists, and also if a
456 matching mail.alias was found (aliases define their own defaults)
457 :param int thread_id: optional ID of the record/thread from ``model``
458 to which this mail should be attached. Only used if the message
459 does not reply to an existing thread and does not match any mail alias.
460 :return: list of [model, thread_id, custom_values, user_id]
462 assert isinstance(message, Message), 'message must be an email.message.Message at this point'
463 message_id = message.get('Message-Id')
464 references = decode_header(message, 'References')
465 in_reply_to = decode_header(message, 'In-Reply-To')
467 # 1. Verify if this is a reply to an existing thread
468 thread_references = references or in_reply_to
469 ref_match = thread_references and tools.reference_re.search(thread_references)
471 thread_id = int(ref_match.group(1))
472 model = ref_match.group(2) or model
473 model_pool = self.pool.get(model)
474 if thread_id and model and model_pool and model_pool.exists(cr, uid, thread_id) \
475 and hasattr(model_pool, 'message_update'):
476 _logger.debug('Routing mail with Message-Id %s: direct reply to model: %s, thread_id: %s, custom_values: %s, uid: %s',
477 message_id, model, thread_id, custom_values, uid)
478 return [(model, thread_id, custom_values, uid)]
480 # Verify whether this is a reply to a private message
482 message_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', '=', in_reply_to)], limit=1, context=context)
484 message = self.pool.get('mail.message').browse(cr, uid, message_ids[0], context=context)
485 _logger.debug('Routing mail with Message-Id %s: direct reply to a private message: %s, custom_values: %s, uid: %s',
486 message_id, message.id, custom_values, uid)
487 return [(message.model, message.res_id, custom_values, uid)]
489 # 2. Look for a matching mail.alias entry
490 # Delivered-To is a safe bet in most modern MTAs, but we have to fallback on To + Cc values
491 # for all the odd MTAs out there, as there is no standard header for the envelope's `rcpt_to` value.
493 ','.join([decode_header(message, 'Delivered-To'),
494 decode_header(message, 'To'),
495 decode_header(message, 'Cc'),
496 decode_header(message, 'Resent-To'),
497 decode_header(message, 'Resent-Cc')])
498 local_parts = [e.split('@')[0] for e in tools.email_split(rcpt_tos)]
500 mail_alias = self.pool.get('mail.alias')
501 alias_ids = mail_alias.search(cr, uid, [('alias_name', 'in', local_parts)])
504 for alias in mail_alias.browse(cr, uid, alias_ids, context=context):
505 user_id = alias.alias_user_id.id
507 # TDE note: this could cause crashes, because no clue that the user
508 # that send the email has the right to create or modify a new document
509 # Fallback on user_id = uid
510 # Note: recognized partners will be added as followers anyway
511 # user_id = self._message_find_user_id(cr, uid, message, context=context)
513 _logger.debug('No matching user_id for the alias %s', alias.alias_name)
514 routes.append((alias.alias_model_id.model, alias.alias_force_thread_id, \
515 eval(alias.alias_defaults), user_id))
516 _logger.debug('Routing mail with Message-Id %s: direct alias match: %r', message_id, routes)
519 # 3. Fallback to the provided parameters, if they work
520 model_pool = self.pool.get(model)
522 # Legacy: fallback to matching [ID] in the Subject
523 match = tools.res_re.search(decode_header(message, 'Subject'))
524 thread_id = match and match.group(1)
525 assert thread_id and hasattr(model_pool, 'message_update') or hasattr(model_pool, 'message_new'), \
526 "No possible route found for incoming message with Message-Id %s. " \
527 "Create an appropriate mail.alias or force the destination model." % message_id
528 if thread_id and not model_pool.exists(cr, uid, thread_id):
529 _logger.warning('Received mail reply to missing document %s! Ignoring and creating new document instead for Message-Id %s',
530 thread_id, message_id)
532 _logger.debug('Routing mail with Message-Id %s: fallback to model:%s, thread_id:%s, custom_values:%s, uid:%s',
533 message_id, model, thread_id, custom_values, uid)
534 return [(model, thread_id, custom_values, uid)]
536 def message_process(self, cr, uid, model, message, custom_values=None,
537 save_original=False, strip_attachments=False,
538 thread_id=None, context=None):
539 """ Process an incoming RFC2822 email message, relying on
540 ``mail.message.parse()`` for the parsing operation,
541 and ``message_route()`` to figure out the target model.
543 Once the target model is known, its ``message_new`` method
544 is called with the new message (if the thread record did not exist)
545 or its ``message_update`` method (if it did).
547 There is a special case where the target model is False: a reply
548 to a private message. In this case, we skip the message_new /
549 message_update step, to just post a new message using mail_thread
552 :param string model: the fallback model to use if the message
553 does not match any of the currently configured mail aliases
554 (may be None if a matching alias is supposed to be present)
555 :param message: source of the RFC2822 message
556 :type message: string or xmlrpclib.Binary
557 :type dict custom_values: optional dictionary of field values
558 to pass to ``message_new`` if a new record needs to be created.
559 Ignored if the thread record already exists, and also if a
560 matching mail.alias was found (aliases define their own defaults)
561 :param bool save_original: whether to keep a copy of the original
562 email source attached to the message after it is imported.
563 :param bool strip_attachments: whether to strip all attachments
564 before processing the message, in order to save some space.
565 :param int thread_id: optional ID of the record/thread from ``model``
566 to which this mail should be attached. When provided, this
567 overrides the automatic detection based on the message
573 # extract message bytes - we are forced to pass the message as binary because
574 # we don't know its encoding until we parse its headers and hence can't
575 # convert it to utf-8 for transport between the mailgate script and here.
576 if isinstance(message, xmlrpclib.Binary):
577 message = str(message.data)
578 # Warning: message_from_string doesn't always work correctly on unicode,
579 # we must use utf-8 strings here :-(
580 if isinstance(message, unicode):
581 message = message.encode('utf-8')
582 msg_txt = email.message_from_string(message)
583 routes = self.message_route(cr, uid, msg_txt, model,
584 thread_id, custom_values,
586 msg = self.message_parse(cr, uid, msg_txt, save_original=save_original, context=context)
587 if strip_attachments:
588 msg.pop('attachments', None)
590 # postpone setting msg.partner_ids after message_post, to avoid double notifications
591 partner_ids = msg.pop('partner_ids', [])
594 for model, thread_id, custom_values, user_id in routes:
595 if self._name != model:
596 context.update({'thread_model': model})
598 model_pool = self.pool.get(model)
599 assert thread_id and hasattr(model_pool, 'message_update') or hasattr(model_pool, 'message_new'), \
600 "Undeliverable mail with Message-Id %s, model %s does not accept incoming emails" % \
601 (msg['message_id'], model)
603 # disabled subscriptions during message_new/update to avoid having the system user running the
604 # email gateway become a follower of all inbound messages
605 nosub_ctx = dict(context, mail_create_nosubscribe=True)
606 if thread_id and hasattr(model_pool, 'message_update'):
607 model_pool.message_update(cr, user_id, [thread_id], msg, context=nosub_ctx)
609 thread_id = model_pool.message_new(cr, user_id, msg, custom_values, context=nosub_ctx)
611 assert thread_id == 0, "Posting a message without model should be with a null res_id, to create a private message."
612 model_pool = self.pool.get('mail.thread')
613 new_msg_id = model_pool.message_post_user_api(cr, uid, [thread_id], context=context, content_subtype='html', **msg)
615 # when posting an incoming email to a document: subscribe the author, if a partner, as follower
616 if model and thread_id and msg.get('author_id'):
617 model_pool.message_subscribe(cr, uid, [thread_id], [msg.get('author_id')], context=context)
620 # postponed after message_post, because this is an external message and we don't want to create
621 # duplicate emails due to notifications
622 self.pool.get('mail.message').write(cr, uid, [new_msg_id], {'partner_ids': partner_ids}, context=context)
626 def message_new(self, cr, uid, msg_dict, custom_values=None, context=None):
627 """Called by ``message_process`` when a new message is received
628 for a given thread model, if the message did not belong to
630 The default behavior is to create a new record of the corresponding
631 model (based on some very basic info extracted from the message).
632 Additional behavior may be implemented by overriding this method.
634 :param dict msg_dict: a map containing the email details and
635 attachments. See ``message_process`` and
636 ``mail.message.parse`` for details.
637 :param dict custom_values: optional dictionary of additional
638 field values to pass to create()
639 when creating the new thread record.
640 Be careful, these values may override
641 any other values coming from the message.
642 :param dict context: if a ``thread_model`` value is present
643 in the context, its value will be used
644 to determine the model of the record
645 to create (instead of the current model).
647 :return: the id of the newly created thread object
652 if isinstance(custom_values, dict):
653 data = custom_values.copy()
654 model = context.get('thread_model') or self._name
655 model_pool = self.pool.get(model)
656 fields = model_pool.fields_get(cr, uid, context=context)
657 if 'name' in fields and not data.get('name'):
658 data['name'] = msg_dict.get('subject', '')
659 res_id = model_pool.create(cr, uid, data, context=context)
662 def message_update(self, cr, uid, ids, msg_dict, update_vals=None, context=None):
663 """Called by ``message_process`` when a new message is received
664 for an existing thread. The default behavior is to update the record
665 with update_vals taken from the incoming email.
666 Additional behavior may be implemented by overriding this
668 :param dict msg_dict: a map containing the email details and
669 attachments. See ``message_process`` and
670 ``mail.message.parse()`` for details.
671 :param dict update_vals: a dict containing values to update records
672 given their ids; if the dict is None or is
673 void, no write operation is performed.
676 self.write(cr, uid, ids, update_vals, context=context)
679 def _message_extract_payload(self, message, save_original=False):
680 """Extract body as HTML and attachments from the mail message"""
684 attachments.append(('original_email.eml', message.as_string()))
685 if not message.is_multipart() or 'text/' in message.get('content-type', ''):
686 encoding = message.get_content_charset()
687 body = message.get_payload(decode=True)
688 body = tools.ustr(body, encoding, errors='replace')
689 if message.get_content_type() == 'text/plain':
690 # text/plain -> <pre/>
691 body = tools.append_content_to_html(u'', body, preserve=True)
693 alternative = (message.get_content_type() == 'multipart/alternative')
694 for part in message.walk():
695 if part.get_content_maintype() == 'multipart':
696 continue # skip container
697 filename = part.get_filename() # None if normal part
698 encoding = part.get_content_charset() # None if attachment
699 # 1) Explicit Attachments -> attachments
700 if filename or part.get('content-disposition', '').strip().startswith('attachment'):
701 attachments.append((filename or 'attachment', part.get_payload(decode=True)))
703 # 2) text/plain -> <pre/>
704 if part.get_content_type() == 'text/plain' and (not alternative or not body):
705 body = tools.append_content_to_html(body, tools.ustr(part.get_payload(decode=True),
706 encoding, errors='replace'), preserve=True)
707 # 3) text/html -> raw
708 elif part.get_content_type() == 'text/html':
709 html = tools.ustr(part.get_payload(decode=True), encoding, errors='replace')
713 body = tools.append_content_to_html(body, html, plaintext=False)
714 # 4) Anything else -> attachment
716 attachments.append((filename or 'attachment', part.get_payload(decode=True)))
717 return body, attachments
719 def message_parse(self, cr, uid, message, save_original=False, context=None):
720 """Parses a string or email.message.Message representing an
721 RFC-2822 email, and returns a generic dict holding the
724 :param message: the message to parse
725 :type message: email.message.Message | string | unicode
726 :param bool save_original: whether the returned dict
727 should include an ``original`` attachment containing
728 the source of the message
730 :return: A dict with the following structure, where each
731 field may not be present if missing in original
734 { 'message_id': msg_id,
739 'body': unified_body,
740 'attachments': [('file1', 'bytes'),
748 if not isinstance(message, Message):
749 if isinstance(message, unicode):
750 # Warning: message_from_string doesn't always work correctly on unicode,
751 # we must use utf-8 strings here :-(
752 message = message.encode('utf-8')
753 message = email.message_from_string(message)
755 message_id = message['message-id']
757 # Very unusual situation, be we should be fault-tolerant here
758 message_id = "<%s@localhost>" % time.time()
759 _logger.debug('Parsing Message without message-id, generating a random one: %s', message_id)
760 msg_dict['message_id'] = message_id
762 if 'Subject' in message:
763 msg_dict['subject'] = decode(message.get('Subject'))
765 # Envelope fields not stored in mail.message but made available for message_new()
766 msg_dict['from'] = decode(message.get('from'))
767 msg_dict['to'] = decode(message.get('to'))
768 msg_dict['cc'] = decode(message.get('cc'))
770 if 'From' in message:
771 author_ids = self._message_find_partners(cr, uid, message, ['From'], context=context)
773 msg_dict['author_id'] = author_ids[0]
775 msg_dict['email_from'] = message.get('from')
776 partner_ids = self._message_find_partners(cr, uid, message, ['From', 'To', 'Cc'], context=context)
777 msg_dict['partner_ids'] = [(4, partner_id) for partner_id in partner_ids]
779 if 'Date' in message:
781 date_hdr = decode(message.get('Date'))
782 parsed_date = dateutil.parser.parse(date_hdr, fuzzy=True)
783 if parsed_date.utcoffset() is None:
784 # naive datetime, so we arbitrarily decide to make it
785 # UTC, there's no better choice. Should not happen,
786 # as RFC2822 requires timezone offset in Date headers.
787 stored_date = parsed_date.replace(tzinfo=pytz.utc)
789 stored_date = parsed_date.astimezone(pytz.utc)
791 _logger.warning('Failed to parse Date header %r in incoming mail '
792 'with message-id %r, assuming current date/time.',
793 message.get('Date'), message_id)
794 stored_date = datetime.datetime.now()
795 msg_dict['date'] = stored_date.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT)
797 if 'In-Reply-To' in message:
798 parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', '=', decode(message['In-Reply-To']))])
800 msg_dict['parent_id'] = parent_ids[0]
802 if 'References' in message and 'parent_id' not in msg_dict:
803 parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', 'in',
804 [x.strip() for x in decode(message['References']).split()])])
806 msg_dict['parent_id'] = parent_ids[0]
808 msg_dict['body'], msg_dict['attachments'] = self._message_extract_payload(message)
811 #------------------------------------------------------
813 #------------------------------------------------------
815 def log(self, cr, uid, id, message, secondary=False, context=None):
816 _logger.warning("log() is deprecated. As this module inherit from "\
817 "mail.thread, the message will be managed by this "\
818 "module instead of by the res.log mechanism. Please "\
819 "use mail_thread.message_post() instead of the "\
820 "now deprecated res.log.")
821 self.message_post(cr, uid, [id], message, context=context)
823 def message_create_partners_from_emails(self, cr, uid, emails, context=None):
824 """ Convert a list of emails into a list partner_ids and a list
825 new_partner_ids. The return value is non conventional because
826 it is meant to be used by the mail widget.
828 :return dict: partner_ids and new_partner_ids
830 partner_obj = self.pool.get('res.partner')
831 mail_message_obj = self.pool.get('mail.message')
836 m = re.search(r"((.+?)\s*<)?([^<>]+@[^<>]+)>?", email, re.IGNORECASE | re.DOTALL)
837 name = m.group(2) or m.group(0)
839 ids = partner_obj.search(cr, SUPERUSER_ID, [('email', '=', email)], context=context)
841 partner_ids.append(ids[0])
843 partner_id = partner_obj.create(cr, uid, {
844 'name': name or email,
847 new_partner_ids.append(partner_id)
849 # link mail with this from mail to the new partner id
850 message_ids = mail_message_obj.search(cr, SUPERUSER_ID, ['|', ('email_from', '=', email), ('email_from', 'ilike', '<%s>' % email), ('author_id', '=', False)], context=context)
852 mail_message_obj.write(cr, SUPERUSER_ID, message_ids, {'email_from': None, 'author_id': partner_id}, context=context)
854 'partner_ids': partner_ids,
855 'new_partner_ids': new_partner_ids,
858 def message_post(self, cr, uid, thread_id, body='', subject=None, type='notification',
859 subtype=None, parent_id=False, attachments=None, context=None, **kwargs):
860 """ Post a new message in an existing thread, returning the new
861 mail.message ID. Extra keyword arguments will be used as default
862 column values for the new mail.message record.
863 Auto link messages for same id and object
864 :param int thread_id: thread ID to post into, or list with one ID;
865 if False/0, mail.message model will also be set as False
866 :param str body: body of the message, usually raw HTML that will
868 :param str subject: optional subject
869 :param str type: mail_message.type
870 :param int parent_id: optional ID of parent message in this thread
871 :param tuple(str,str) attachments or list id: list of attachment tuples in the form
872 ``(name,content)``, where content is NOT base64 encoded
873 :return: ID of newly created mail.message
877 if attachments is None:
880 assert (not thread_id) or isinstance(thread_id, (int, long)) or \
881 (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"
882 if isinstance(thread_id, (list, tuple)):
883 thread_id = thread_id and thread_id[0]
884 mail_message = self.pool.get('mail.message')
885 model = context.get('thread_model', self._name) if thread_id else False
887 attachment_ids = kwargs.pop('attachment_ids', [])
888 for name, content in attachments:
889 if isinstance(content, unicode):
890 content = content.encode('utf-8')
893 'datas': base64.b64encode(str(content)),
896 'res_model': context.get('thread_model') or self._name,
899 attachment_ids.append((0, 0, data_attach))
903 s_data = subtype.split('.')
905 s_data = ('mail', s_data[0])
906 ref = self.pool.get('ir.model.data').get_object_reference(cr, uid, s_data[0], s_data[1])
907 subtype_id = ref and ref[1] or False
911 # _mail_flat_thread: automatically set free messages to the first posted message
912 if self._mail_flat_thread and not parent_id and thread_id:
913 message_ids = mail_message.search(cr, uid, ['&', ('res_id', '=', thread_id), ('model', '=', model)], context=context, order="id ASC", limit=1)
914 parent_id = message_ids and message_ids[0] or False
915 # 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
917 message_ids = mail_message.search(cr, SUPERUSER_ID, [('id', '=', parent_id), ('parent_id', '!=', False)], context=context)
918 # avoid loops when finding ancestors
921 message = mail_message.browse(cr, SUPERUSER_ID, message_ids[0], context=context)
922 while (message.parent_id and message.parent_id.id not in processed_list):
923 processed_list.append(message.parent_id.id)
924 message = message.parent_id
925 parent_id = message.id
930 'res_id': thread_id or False,
932 'subject': subject or False,
934 'parent_id': parent_id,
935 'attachment_ids': attachment_ids,
936 'subtype_id': subtype_id,
939 # Avoid warnings about non-existing fields
940 for x in ('from', 'to', 'cc'):
943 return mail_message.create(cr, uid, values, context=context)
945 def message_post_user_api(self, cr, uid, thread_id, body='', parent_id=False,
946 attachment_ids=None, content_subtype='plaintext',
947 context=None, **kwargs):
948 """ Wrapper on message_post, used for user input :
950 - quick reply in Chatter (refer to mail.js), not
951 the mail.compose.message wizard
952 The purpose is to perform some pre- and post-processing:
953 - if body is plaintext: convert it into html
954 - if parent_id: handle reply to a previous message by adding the
955 parent partners to the message
956 - type and subtype: comment and mail.mt_comment by default
957 - attachment_ids: supposed not attached to any document; attach them
958 to the related document. Should only be set by Chatter.
960 mail_message_obj = self.pool.get('mail.message')
961 ir_attachment = self.pool.get('ir.attachment')
963 # 1.A.1: add recipients of parent message
964 partner_ids = set([])
966 parent_message = mail_message_obj.browse(cr, uid, parent_id, context=context)
967 partner_ids |= set([(4, partner.id) for partner in parent_message.partner_ids])
968 # TDE FIXME HACK: mail.thread -> private message
969 if self._name == 'mail.thread' and parent_message.author_id.id:
970 partner_ids.add((4, parent_message.author_id.id))
972 # 1.A.2: add specified recipients
973 param_partner_ids = set()
974 for item in kwargs.pop('partner_ids', []):
975 if isinstance(item, (list)):
976 param_partner_ids.add((item[0], item[1]))
977 elif isinstance(item, (int, long)):
978 param_partner_ids.add((4, item))
980 param_partner_ids.add(item)
981 partner_ids |= param_partner_ids
983 # 1.A.3: add parameters recipients as follower
984 # TDE FIXME in 7.1: should check whether this comes from email_list or partner_ids
985 if param_partner_ids and self._name != 'mail.thread':
986 self.message_subscribe(cr, uid, [thread_id], [pid[1] for pid in param_partner_ids], context=context)
988 # 1.B: handle body, message_type and message_subtype
989 if content_subtype == 'plaintext':
990 body = tools.plaintext2html(body)
991 msg_type = kwargs.pop('type', 'comment')
992 msg_subtype = kwargs.pop('subtype', 'mail.mt_comment')
994 # 2. Pre-processing: attachments
995 # HACK TDE FIXME: Chatter: attachments linked to the document (not done JS-side), load the message
997 # TDE FIXME (?): when posting a private message, we use mail.thread as a model
998 # However, attaching doc to mail.thread is not possible, mail.thread does not have any table
1000 if model == 'mail.thread':
1002 filtered_attachment_ids = ir_attachment.search(cr, SUPERUSER_ID, [
1003 ('res_model', '=', 'mail.compose.message'),
1005 ('create_uid', '=', uid),
1006 ('id', 'in', attachment_ids)], context=context)
1007 if filtered_attachment_ids:
1008 if thread_id and model:
1009 ir_attachment.write(cr, SUPERUSER_ID, attachment_ids, {'res_model': model, 'res_id': thread_id}, context=context)
1012 attachment_ids = [(4, id) for id in attachment_ids]
1015 return self.message_post(cr, uid, thread_id=thread_id, body=body,
1016 type=msg_type, subtype=msg_subtype, parent_id=parent_id,
1017 attachment_ids=attachment_ids, partner_ids=list(partner_ids), context=context, **kwargs)
1019 #------------------------------------------------------
1021 #------------------------------------------------------
1023 def message_get_subscription_data(self, cr, uid, ids, context=None):
1024 """ Wrapper to get subtypes data. """
1025 return self._get_subscription_data(cr, uid, ids, None, None, context=context)
1027 def message_subscribe_users(self, cr, uid, ids, user_ids=None, subtype_ids=None, context=None):
1028 """ Wrapper on message_subscribe, using users. If user_ids is not
1029 provided, subscribe uid instead. """
1030 if user_ids is None:
1032 partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
1033 return self.message_subscribe(cr, uid, ids, partner_ids, subtype_ids=subtype_ids, context=context)
1035 def message_subscribe(self, cr, uid, ids, partner_ids, subtype_ids=None, context=None):
1036 """ Add partners to the records followers. """
1037 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
1038 if set(partner_ids) == set([user_pid]):
1039 self.check_access_rights(cr, uid, 'read')
1041 self.check_access_rights(cr, uid, 'write')
1043 self.write(cr, SUPERUSER_ID, ids, {'message_follower_ids': [(4, pid) for pid in partner_ids]}, context=context)
1044 # if subtypes are not specified (and not set to a void list), fetch default ones
1045 if subtype_ids is None:
1046 subtype_obj = self.pool.get('mail.message.subtype')
1047 subtype_ids = subtype_obj.search(cr, uid, [('default', '=', True), '|', ('res_model', '=', self._name), ('res_model', '=', False)], context=context)
1048 # update the subscriptions
1049 fol_obj = self.pool.get('mail.followers')
1050 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids), ('partner_id', 'in', partner_ids)], context=context)
1051 fol_obj.write(cr, SUPERUSER_ID, fol_ids, {'subtype_ids': [(6, 0, subtype_ids)]}, context=context)
1054 def message_unsubscribe_users(self, cr, uid, ids, user_ids=None, context=None):
1055 """ Wrapper on message_subscribe, using users. If user_ids is not
1056 provided, unsubscribe uid instead. """
1057 if user_ids is None:
1059 partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
1060 return self.message_unsubscribe(cr, uid, ids, partner_ids, context=context)
1062 def message_unsubscribe(self, cr, uid, ids, partner_ids, context=None):
1063 """ Remove partners from the records followers. """
1064 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
1065 if set(partner_ids) == set([user_pid]):
1066 self.check_access_rights(cr, uid, 'read')
1068 self.check_access_rights(cr, uid, 'write')
1069 return self.write(cr, SUPERUSER_ID, ids, {'message_follower_ids': [(3, pid) for pid in partner_ids]}, context=context)
1071 def message_subscribe_from_parent(self, cr, uid, ids, updated_fields, context=None):
1073 1. fetch project subtype related to task (parent_id.res_model = 'project.task')
1074 2. for each project subtype: subscribe the follower to the task
1076 subtype_obj = self.pool.get('mail.message.subtype')
1077 follower_obj = self.pool.get('mail.followers')
1079 # fetch related record subtypes
1080 related_subtype_ids = subtype_obj.search(cr, uid, ['|', ('res_model', '=', False), ('parent_id.res_model', '=', self._name)], context=context)
1081 subtypes = subtype_obj.browse(cr, uid, related_subtype_ids, context=context)
1082 default_subtypes = [subtype for subtype in subtypes if subtype.res_model == False]
1083 related_subtypes = [subtype for subtype in subtypes if subtype.res_model != False]
1084 relation_fields = set([subtype.relation_field for subtype in subtypes if subtype.relation_field != False])
1085 if not related_subtypes or not any(relation in updated_fields for relation in relation_fields):
1088 for record in self.browse(cr, uid, ids, context=context):
1089 new_followers = dict()
1090 parent_res_id = False
1091 parent_model = False
1092 for subtype in related_subtypes:
1093 if not subtype.relation_field or not subtype.parent_id:
1095 if not subtype.relation_field in self._columns or not getattr(record, subtype.relation_field, False):
1097 parent_res_id = getattr(record, subtype.relation_field).id
1098 parent_model = subtype.res_model
1099 follower_ids = follower_obj.search(cr, SUPERUSER_ID, [
1100 ('res_model', '=', parent_model),
1101 ('res_id', '=', parent_res_id),
1102 ('subtype_ids', 'in', [subtype.id])
1104 for follower in follower_obj.browse(cr, SUPERUSER_ID, follower_ids, context=context):
1105 new_followers.setdefault(follower.partner_id.id, set()).add(subtype.parent_id.id)
1107 if not parent_res_id or not parent_model:
1110 for subtype in default_subtypes:
1111 follower_ids = follower_obj.search(cr, SUPERUSER_ID, [
1112 ('res_model', '=', parent_model),
1113 ('res_id', '=', parent_res_id),
1114 ('subtype_ids', 'in', [subtype.id])
1116 for follower in follower_obj.browse(cr, SUPERUSER_ID, follower_ids, context=context):
1117 new_followers.setdefault(follower.partner_id.id, set()).add(subtype.id)
1119 for pid, subtypes in new_followers.items():
1120 self.message_subscribe(cr, uid, [record.id], [pid], list(subtypes), context=context)
1123 #------------------------------------------------------
1125 #------------------------------------------------------
1127 def message_mark_as_unread(self, cr, uid, ids, context=None):
1128 """ Set as unread. """
1129 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1131 UPDATE mail_notification SET
1134 message_id IN (SELECT id from mail_message where res_id=any(%s) and model=%s limit 1) and
1136 ''', (ids, self._name, partner_id))
1139 def message_mark_as_read(self, cr, uid, ids, context=None):
1140 """ Set as read. """
1141 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
1143 UPDATE mail_notification SET
1146 message_id IN (SELECT id FROM mail_message WHERE res_id=ANY(%s) AND model=%s) AND
1148 ''', (ids, self._name, partner_id))
1151 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: