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 ##############################################################################
29 from openerp import tools
32 from email.message import Message
33 from mail_message import decode
34 from openerp import SUPERUSER_ID
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 def _get_message_data(self, cr, uid, ids, name, args, context=None):
75 - message_unread: has uid unread message for the document
76 - message_summary: html snippet summarizing the Chatter for kanban views """
77 res = dict((id, dict(message_unread=False, message_summary='')) for id in ids)
78 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
80 # search for unread messages, directly in SQL to improve performances
81 cr.execute(""" SELECT m.res_id FROM mail_message m
82 RIGHT JOIN mail_notification n
83 ON (n.message_id = m.id AND n.partner_id = %s AND (n.read = False or n.read IS NULL))
84 WHERE m.model = %s AND m.res_id in %s""",
85 (user_pid, self._name, tuple(ids),))
86 msg_ids = [result[0] for result in cr.fetchall()]
87 for msg_id in msg_ids:
88 res[msg_id]['message_unread'] = True
90 for thread in self.browse(cr, uid, ids, context=context):
91 cls = res[thread.id]['message_unread'] and ' class="oe_kanban_mail_new"' or ''
92 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))
96 def _get_subscription_data(self, cr, uid, ids, name, args, context=None):
98 - message_subtype_data: data about document subtypes: which are
99 available, which are followed if any """
100 res = dict((id, dict(message_subtype_data='')) for id in ids)
101 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
103 # find current model subtypes, add them to a dictionary
104 subtype_obj = self.pool.get('mail.message.subtype')
105 subtype_ids = subtype_obj.search(cr, uid, ['|', ('res_model', '=', self._name), ('res_model', '=', False)], context=context)
106 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))
108 res[id]['message_subtype_data'] = subtype_dict.copy()
110 # find the document followers, update the data
111 fol_obj = self.pool.get('mail.followers')
112 fol_ids = fol_obj.search(cr, uid, [
113 ('partner_id', '=', user_pid),
114 ('res_id', 'in', ids),
115 ('res_model', '=', self._name),
117 for fol in fol_obj.browse(cr, uid, fol_ids, context=context):
118 thread_subtype_dict = res[fol.res_id]['message_subtype_data']
119 for subtype in fol.subtype_ids:
120 thread_subtype_dict[subtype.name]['followed'] = True
121 res[fol.res_id]['message_subtype_data'] = thread_subtype_dict
125 def _search_message_unread(self, cr, uid, obj=None, name=None, domain=None, context=None):
126 return [('message_ids.to_read', '=', True)]
128 def _get_followers(self, cr, uid, ids, name, arg, context=None):
129 fol_obj = self.pool.get('mail.followers')
130 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids)])
131 res = dict((id, dict(message_follower_ids=[], message_is_follower=False)) for id in ids)
132 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
133 for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids):
134 res[fol.res_id]['message_follower_ids'].append(fol.partner_id.id)
135 if fol.partner_id.id == user_pid:
136 res[fol.res_id]['message_is_follower'] = True
139 def _set_followers(self, cr, uid, id, name, value, arg, context=None):
142 partner_obj = self.pool.get('res.partner')
143 fol_obj = self.pool.get('mail.followers')
145 # read the old set of followers, and determine the new set of followers
146 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', '=', id)])
147 old = set(fol.partner_id.id for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids))
150 for command in value or []:
151 if isinstance(command, (int, long)):
153 elif command[0] == 0:
154 new.add(partner_obj.create(cr, uid, command[2], context=context))
155 elif command[0] == 1:
156 partner_obj.write(cr, uid, [command[1]], command[2], context=context)
158 elif command[0] == 2:
159 partner_obj.unlink(cr, uid, [command[1]], context=context)
160 new.discard(command[1])
161 elif command[0] == 3:
162 new.discard(command[1])
163 elif command[0] == 4:
165 elif command[0] == 5:
167 elif command[0] == 6:
168 new = set(command[2])
170 # remove partners that are no longer followers
171 fol_ids = fol_obj.search(cr, SUPERUSER_ID,
172 [('res_model', '=', self._name), ('res_id', '=', id), ('partner_id', 'not in', list(new))])
173 fol_obj.unlink(cr, SUPERUSER_ID, fol_ids)
176 for partner_id in new - old:
177 fol_obj.create(cr, SUPERUSER_ID, {'res_model': self._name, 'res_id': id, 'partner_id': partner_id})
179 def _search_followers(self, cr, uid, obj, name, args, context):
180 fol_obj = self.pool.get('mail.followers')
182 for field, operator, value in args:
184 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('partner_id', operator, value)])
185 res_ids = [fol.res_id for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids)]
186 res.append(('id', 'in', res_ids))
190 'message_is_follower': fields.function(_get_followers,
191 type='boolean', string='Is a Follower', multi='_get_followers,'),
192 'message_follower_ids': fields.function(_get_followers, fnct_inv=_set_followers,
193 fnct_search=_search_followers, type='many2many',
194 obj='res.partner', string='Followers', multi='_get_followers'),
195 'message_ids': fields.one2many('mail.message', 'res_id',
196 domain=lambda self: [('model', '=', self._name)],
199 help="Messages and communication history"),
200 'message_unread': fields.function(_get_message_data,
201 fnct_search=_search_message_unread, multi="_get_message_data",
202 type='boolean', string='Unread Messages',
203 help="If checked new messages require your attention."),
204 'message_summary': fields.function(_get_message_data, method=True,
205 type='text', string='Summary', multi="_get_message_data",
206 help="Holds the Chatter summary (number of messages, ...). "\
207 "This summary is directly in html format in order to "\
208 "be inserted in kanban views."),
211 #------------------------------------------------------
212 # Automatic subscription when creating
213 #------------------------------------------------------
215 def create(self, cr, uid, vals, context=None):
216 """ Override to subscribe the current user. """
219 thread_id = super(mail_thread, self).create(cr, uid, vals, context=context)
220 if not context.get('mail_nosubscribe'):
221 self.message_subscribe_users(cr, uid, [thread_id], [uid], context=context)
224 def unlink(self, cr, uid, ids, context=None):
225 """ Override unlink to delete messages and followers. This cannot be
226 cascaded, because link is done through (res_model, res_id). """
227 msg_obj = self.pool.get('mail.message')
228 fol_obj = self.pool.get('mail.followers')
229 # delete messages and notifications
230 msg_ids = msg_obj.search(cr, uid, [('model', '=', self._name), ('res_id', 'in', ids)], context=context)
231 msg_obj.unlink(cr, uid, msg_ids, context=context)
233 res = super(mail_thread, self).unlink(cr, uid, ids, context=context)
235 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids)], context=context)
236 fol_obj.unlink(cr, SUPERUSER_ID, fol_ids, context=context)
239 def copy(self, cr, uid, id, default=None, context=None):
240 default = default or {}
241 default['message_ids'] = []
242 default['message_follower_ids'] = []
243 return super(mail_thread, self).copy(cr, uid, id, default=default, context=context)
245 #------------------------------------------------------
246 # mail.message wrappers and tools
247 #------------------------------------------------------
249 def _needaction_domain_get(self, cr, uid, context=None):
251 return [('message_unread', '=', True)]
254 #------------------------------------------------------
256 #------------------------------------------------------
258 def message_capable_models(self, cr, uid, context=None):
259 """ Used by the plugin addon, based for plugin_outlook and others. """
261 for model_name in self.pool.obj_list():
262 model = self.pool.get(model_name)
263 if 'mail.thread' in getattr(model, '_inherit', []):
264 ret_dict[model_name] = model._description
267 def _message_find_partners(self, cr, uid, message, header_fields=['From'], context=None):
268 """ Find partners related to some header fields of the message. """
269 s = ', '.join([decode(message.get(h)) for h in header_fields if message.get(h)])
270 return [partner_id for email in tools.email_split(s)
271 for partner_id in self.pool.get('res.partner').search(cr, uid, [('email', 'ilike', email)], limit=1, context=context)]
273 def _message_find_user_id(self, cr, uid, message, context=None):
274 from_local_part = tools.email_split(decode(message.get('From')))[0]
275 # FP Note: canonification required, the minimu: .lower()
276 user_ids = self.pool.get('res.users').search(cr, uid, ['|',
277 ('login', '=', from_local_part),
278 ('email', '=', from_local_part)], context=context)
279 return user_ids[0] if user_ids else uid
281 def message_route(self, cr, uid, message, model=None, thread_id=None,
282 custom_values=None, context=None):
283 """Attempt to figure out the correct target model, thread_id,
284 custom_values and user_id to use for an incoming message.
285 Multiple values may be returned, if a message had multiple
286 recipients matching existing mail.aliases, for example.
288 The following heuristics are used, in this order:
289 1. If the message replies to an existing thread_id, and
290 properly contains the thread model in the 'In-Reply-To'
291 header, use this model/thread_id pair, and ignore
292 custom_value (not needed as no creation will take place)
293 2. Look for a mail.alias entry matching the message
294 recipient, and use the corresponding model, thread_id,
295 custom_values and user_id.
296 3. Fallback to the ``model``, ``thread_id`` and ``custom_values``
298 4. If all the above fails, raise an exception.
300 :param string message: an email.message instance
301 :param string model: the fallback model to use if the message
302 does not match any of the currently configured mail aliases
303 (may be None if a matching alias is supposed to be present)
304 :type dict custom_values: optional dictionary of default field values
305 to pass to ``message_new`` if a new record needs to be created.
306 Ignored if the thread record already exists, and also if a
307 matching mail.alias was found (aliases define their own defaults)
308 :param int thread_id: optional ID of the record/thread from ``model``
309 to which this mail should be attached. Only used if the message
310 does not reply to an existing thread and does not match any mail alias.
311 :return: list of [model, thread_id, custom_values, user_id]
313 assert isinstance(message, Message), 'message must be an email.message.Message at this point'
314 message_id = message.get('Message-Id')
315 references = decode_header(message, 'References')
316 in_reply_to = decode_header(message, 'In-Reply-To')
318 # 1. Verify if this is a reply to an existing thread
319 thread_references = references or in_reply_to
320 ref_match = thread_references and tools.reference_re.search(thread_references)
322 thread_id = int(ref_match.group(1))
323 model = ref_match.group(2) or model
324 model_pool = self.pool.get(model)
325 if thread_id and model and model_pool and model_pool.exists(cr, uid, thread_id) \
326 and hasattr(model_pool, 'message_update'):
327 _logger.debug('Routing mail with Message-Id %s: direct reply to model: %s, thread_id: %s, custom_values: %s, uid: %s',
328 message_id, model, thread_id, custom_values, uid)
329 return [(model, thread_id, custom_values, uid)]
331 # Verify whether this is a reply to a private message
333 message_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', '=', in_reply_to)], limit=1, context=context)
335 message = self.pool.get('mail.message').browse(cr, uid, message_ids[0], context=context)
336 _logger.debug('Routing mail with Message-Id %s: direct reply to a private message: %s, custom_values: %s, uid: %s',
337 message_id, message.id, custom_values, uid)
338 return [(message.model, message.res_id, custom_values, uid)]
340 # 2. Look for a matching mail.alias entry
341 # Delivered-To is a safe bet in most modern MTAs, but we have to fallback on To + Cc values
342 # for all the odd MTAs out there, as there is no standard header for the envelope's `rcpt_to` value.
344 ','.join([decode_header(message, 'Delivered-To'),
345 decode_header(message, 'To'),
346 decode_header(message, 'Cc'),
347 decode_header(message, 'Resent-To'),
348 decode_header(message, 'Resent-Cc')])
349 local_parts = [e.split('@')[0] for e in tools.email_split(rcpt_tos)]
351 mail_alias = self.pool.get('mail.alias')
352 alias_ids = mail_alias.search(cr, uid, [('alias_name', 'in', local_parts)])
355 for alias in mail_alias.browse(cr, uid, alias_ids, context=context):
356 user_id = alias.alias_user_id.id
358 user_id = self._message_find_user_id(cr, uid, message, context=context)
359 routes.append((alias.alias_model_id.model, alias.alias_force_thread_id, \
360 eval(alias.alias_defaults), user_id))
361 _logger.debug('Routing mail with Message-Id %s: direct alias match: %r', message_id, routes)
364 # 3. Fallback to the provided parameters, if they work
365 model_pool = self.pool.get(model)
367 # Legacy: fallback to matching [ID] in the Subject
368 match = tools.res_re.search(decode_header(message, 'Subject'))
369 thread_id = match and match.group(1)
370 assert thread_id and hasattr(model_pool, 'message_update') or hasattr(model_pool, 'message_new'), \
371 "No possible route found for incoming message with Message-Id %s. " \
372 "Create an appropriate mail.alias or force the destination model." % message_id
373 if thread_id and not model_pool.exists(cr, uid, thread_id):
374 _logger.warning('Received mail reply to missing document %s! Ignoring and creating new document instead for Message-Id %s',
375 thread_id, message_id)
377 _logger.debug('Routing mail with Message-Id %s: fallback to model:%s, thread_id:%s, custom_values:%s, uid:%s',
378 message_id, model, thread_id, custom_values, uid)
379 return [(model, thread_id, custom_values, uid)]
381 def message_process(self, cr, uid, model, message, custom_values=None,
382 save_original=False, strip_attachments=False,
383 thread_id=None, context=None):
384 """ Process an incoming RFC2822 email message, relying on
385 ``mail.message.parse()`` for the parsing operation,
386 and ``message_route()`` to figure out the target model.
388 Once the target model is known, its ``message_new`` method
389 is called with the new message (if the thread record did not exist)
390 or its ``message_update`` method (if it did).
392 There is a special case where the target model is False: a reply
393 to a private message. In this case, we skip the message_new /
394 message_update step, to just post a new message using mail_thread
397 :param string model: the fallback model to use if the message
398 does not match any of the currently configured mail aliases
399 (may be None if a matching alias is supposed to be present)
400 :param message: source of the RFC2822 message
401 :type message: string or xmlrpclib.Binary
402 :type dict custom_values: optional dictionary of field values
403 to pass to ``message_new`` if a new record needs to be created.
404 Ignored if the thread record already exists, and also if a
405 matching mail.alias was found (aliases define their own defaults)
406 :param bool save_original: whether to keep a copy of the original
407 email source attached to the message after it is imported.
408 :param bool strip_attachments: whether to strip all attachments
409 before processing the message, in order to save some space.
410 :param int thread_id: optional ID of the record/thread from ``model``
411 to which this mail should be attached. When provided, this
412 overrides the automatic detection based on the message
418 # extract message bytes - we are forced to pass the message as binary because
419 # we don't know its encoding until we parse its headers and hence can't
420 # convert it to utf-8 for transport between the mailgate script and here.
421 if isinstance(message, xmlrpclib.Binary):
422 message = str(message.data)
423 # Warning: message_from_string doesn't always work correctly on unicode,
424 # we must use utf-8 strings here :-(
425 if isinstance(message, unicode):
426 message = message.encode('utf-8')
427 msg_txt = email.message_from_string(message)
428 routes = self.message_route(cr, uid, msg_txt, model,
429 thread_id, custom_values,
431 msg = self.message_parse(cr, uid, msg_txt, save_original=save_original, context=context)
432 if strip_attachments:
433 msg.pop('attachments', None)
435 # postpone setting msg.partner_ids after message_post, to avoid double notifications
436 partner_ids = msg.pop('partner_ids', [])
439 for model, thread_id, custom_values, user_id in routes:
440 if self._name != model:
441 context.update({'thread_model': model})
443 model_pool = self.pool.get(model)
444 assert thread_id and hasattr(model_pool, 'message_update') or hasattr(model_pool, 'message_new'), \
445 "Undeliverable mail with Message-Id %s, model %s does not accept incoming emails" % \
446 (msg['message_id'], model)
448 # disabled subscriptions during message_new/update to avoid having the system user running the
449 # email gateway become a follower of all inbound messages
450 nosub_ctx = dict(context, mail_nosubscribe=True)
451 if thread_id and hasattr(model_pool, 'message_update'):
452 model_pool.message_update(cr, user_id, [thread_id], msg, context=nosub_ctx)
454 thread_id = model_pool.message_new(cr, user_id, msg, custom_values, context=nosub_ctx)
456 assert thread_id == 0, "Posting a message without model should be with a null res_id, to create a private message."
457 model_pool = self.pool.get('mail.thread')
458 new_msg_id = model_pool.message_post_user_api(cr, uid, [thread_id], context=context, content_subtype='html', **msg)
461 # postponed after message_post, because this is an external message and we don't want to create
462 # duplicate emails due to notifications
463 self.pool.get('mail.message').write(cr, uid, [new_msg_id], {'partner_ids': partner_ids}, context=context)
467 def message_new(self, cr, uid, msg_dict, custom_values=None, context=None):
468 """Called by ``message_process`` when a new message is received
469 for a given thread model, if the message did not belong to
471 The default behavior is to create a new record of the corresponding
472 model (based on some very basic info extracted from the message).
473 Additional behavior may be implemented by overriding this method.
475 :param dict msg_dict: a map containing the email details and
476 attachments. See ``message_process`` and
477 ``mail.message.parse`` for details.
478 :param dict custom_values: optional dictionary of additional
479 field values to pass to create()
480 when creating the new thread record.
481 Be careful, these values may override
482 any other values coming from the message.
483 :param dict context: if a ``thread_model`` value is present
484 in the context, its value will be used
485 to determine the model of the record
486 to create (instead of the current model).
488 :return: the id of the newly created thread object
492 model = context.get('thread_model') or self._name
493 model_pool = self.pool.get(model)
494 fields = model_pool.fields_get(cr, uid, context=context)
495 data = model_pool.default_get(cr, uid, fields, context=context)
496 if 'name' in fields and not data.get('name'):
497 data['name'] = msg_dict.get('subject', '')
498 if custom_values and isinstance(custom_values, dict):
499 data.update(custom_values)
500 res_id = model_pool.create(cr, uid, data, context=context)
503 def message_update(self, cr, uid, ids, msg_dict, update_vals=None, context=None):
504 """Called by ``message_process`` when a new message is received
505 for an existing thread. The default behavior is to update the record
506 with update_vals taken from the incoming email.
507 Additional behavior may be implemented by overriding this
509 :param dict msg_dict: a map containing the email details and
510 attachments. See ``message_process`` and
511 ``mail.message.parse()`` for details.
512 :param dict update_vals: a dict containing values to update records
513 given their ids; if the dict is None or is
514 void, no write operation is performed.
517 self.write(cr, uid, ids, update_vals, context=context)
520 def _message_extract_payload(self, message, save_original=False):
521 """Extract body as HTML and attachments from the mail message"""
525 attachments.append(('original_email.eml', message.as_string()))
526 if not message.is_multipart() or 'text/' in message.get('content-type', ''):
527 encoding = message.get_content_charset()
528 body = message.get_payload(decode=True)
529 body = tools.ustr(body, encoding, errors='replace')
530 if message.get_content_type() == 'text/plain':
531 # text/plain -> <pre/>
532 body = tools.append_content_to_html(u'', body, preserve=True)
534 alternative = (message.get_content_type() == 'multipart/alternative')
535 for part in message.walk():
536 if part.get_content_maintype() == 'multipart':
537 continue # skip container
538 filename = part.get_filename() # None if normal part
539 encoding = part.get_content_charset() # None if attachment
540 # 1) Explicit Attachments -> attachments
541 if filename or part.get('content-disposition', '').strip().startswith('attachment'):
542 attachments.append((filename or 'attachment', part.get_payload(decode=True)))
544 # 2) text/plain -> <pre/>
545 if part.get_content_type() == 'text/plain' and (not alternative or not body):
546 body = tools.append_content_to_html(body, tools.ustr(part.get_payload(decode=True),
547 encoding, errors='replace'), preserve=True)
548 # 3) text/html -> raw
549 elif part.get_content_type() == 'text/html':
550 html = tools.ustr(part.get_payload(decode=True), encoding, errors='replace')
554 body = tools.append_content_to_html(body, html, plaintext=False)
555 # 4) Anything else -> attachment
557 attachments.append((filename or 'attachment', part.get_payload(decode=True)))
558 return body, attachments
560 def message_parse(self, cr, uid, message, save_original=False, context=None):
561 """Parses a string or email.message.Message representing an
562 RFC-2822 email, and returns a generic dict holding the
565 :param message: the message to parse
566 :type message: email.message.Message | string | unicode
567 :param bool save_original: whether the returned dict
568 should include an ``original`` attachment containing
569 the source of the message
571 :return: A dict with the following structure, where each
572 field may not be present if missing in original
575 { 'message_id': msg_id,
580 'body': unified_body,
581 'attachments': [('file1', 'bytes'),
589 if not isinstance(message, Message):
590 if isinstance(message, unicode):
591 # Warning: message_from_string doesn't always work correctly on unicode,
592 # we must use utf-8 strings here :-(
593 message = message.encode('utf-8')
594 message = email.message_from_string(message)
596 message_id = message['message-id']
598 # Very unusual situation, be we should be fault-tolerant here
599 message_id = "<%s@localhost>" % time.time()
600 _logger.debug('Parsing Message without message-id, generating a random one: %s', message_id)
601 msg_dict['message_id'] = message_id
603 if 'Subject' in message:
604 msg_dict['subject'] = decode(message.get('Subject'))
606 # Envelope fields not stored in mail.message but made available for message_new()
607 msg_dict['from'] = decode(message.get('from'))
608 msg_dict['to'] = decode(message.get('to'))
609 msg_dict['cc'] = decode(message.get('cc'))
611 if 'From' in message:
612 author_ids = self._message_find_partners(cr, uid, message, ['From'], context=context)
614 msg_dict['author_id'] = author_ids[0]
616 msg_dict['email_from'] = message.get('from')
617 partner_ids = self._message_find_partners(cr, uid, message, ['From', 'To', 'Cc'], context=context)
618 msg_dict['partner_ids'] = [(4, partner_id) for partner_id in partner_ids]
620 if 'Date' in message:
622 date_hdr = decode(message.get('Date'))
623 parsed_date = dateutil.parser.parse(date_hdr, fuzzy=True)
624 if parsed_date.utcoffset() is None:
625 # naive datetime, so we arbitrarily decide to make it
626 # UTC, there's no better choice. Should not happen,
627 # as RFC2822 requires timezone offset in Date headers.
628 stored_date = parsed_date.replace(tzinfo=pytz.utc)
630 stored_date = parsed_date.astimezone(pytz.utc)
632 _logger.warning('Failed to parse Date header %r in incoming mail '
633 'with message-id %r, assuming current date/time.',
634 message.get('Date'), message_id)
635 stored_date = datetime.datetime.now()
636 msg_dict['date'] = stored_date.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT)
638 if 'In-Reply-To' in message:
639 parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', '=', decode(message['In-Reply-To']))])
641 msg_dict['parent_id'] = parent_ids[0]
643 if 'References' in message and 'parent_id' not in msg_dict:
644 parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', 'in',
645 [x.strip() for x in decode(message['References']).split()])])
647 msg_dict['parent_id'] = parent_ids[0]
649 msg_dict['body'], msg_dict['attachments'] = self._message_extract_payload(message)
652 #------------------------------------------------------
654 #------------------------------------------------------
656 def log(self, cr, uid, id, message, secondary=False, context=None):
657 _logger.warning("log() is deprecated. As this module inherit from "\
658 "mail.thread, the message will be managed by this "\
659 "module instead of by the res.log mechanism. Please "\
660 "use mail_thread.message_post() instead of the "\
661 "now deprecated res.log.")
662 self.message_post(cr, uid, [id], message, context=context)
664 def message_post(self, cr, uid, thread_id, body='', subject=None, type='notification',
665 subtype=None, parent_id=False, attachments=None, context=None, **kwargs):
666 """ Post a new message in an existing thread, returning the new
667 mail.message ID. Extra keyword arguments will be used as default
668 column values for the new mail.message record.
669 Auto link messages for same id and object
670 :param int thread_id: thread ID to post into, or list with one ID;
671 if False/0, mail.message model will also be set as False
672 :param str body: body of the message, usually raw HTML that will
674 :param str subject: optional subject
675 :param str type: mail_message.type
676 :param int parent_id: optional ID of parent message in this thread
677 :param tuple(str,str) attachments or list id: list of attachment tuples in the form
678 ``(name,content)``, where content is NOT base64 encoded
679 :return: ID of newly created mail.message
683 if attachments is None:
686 assert (not thread_id) or isinstance(thread_id, (int, long)) or \
687 (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"
688 if isinstance(thread_id, (list, tuple)):
689 thread_id = thread_id and thread_id[0]
690 mail_message = self.pool.get('mail.message')
691 model = context.get('thread_model', self._name) if thread_id else False
694 for name, content in attachments:
695 if isinstance(content, unicode):
696 content = content.encode('utf-8')
699 'datas': base64.b64encode(str(content)),
702 'res_model': context.get('thread_model') or self._name,
705 attachment_ids.append((0, 0, data_attach))
709 s_data = subtype.split('.')
711 s_data = ('mail', s_data[0])
712 ref = self.pool.get('ir.model.data').get_object_reference(cr, uid, s_data[0], s_data[1])
713 subtype_id = ref and ref[1] or False
717 # _mail_flat_thread: automatically set free messages to the first posted message
718 if self._mail_flat_thread and not parent_id and thread_id:
719 message_ids = mail_message.search(cr, uid, ['&', ('res_id', '=', thread_id), ('model', '=', model)], context=context, order="id ASC", limit=1)
720 parent_id = message_ids and message_ids[0] or False
721 # 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
723 message_ids = mail_message.search(cr, SUPERUSER_ID, [('id', '=', parent_id), ('parent_id', '!=', False)], context=context)
724 # avoid loops when finding ancestors
727 message = mail_message.browse(cr, SUPERUSER_ID, message_ids[0], context=context)
728 while (message.parent_id and message.parent_id.id not in processed_list):
729 processed_list.append(message.parent_id.id)
730 message = message.parent_id
731 parent_id = message.id
736 'res_id': thread_id or False,
738 'subject': subject or False,
740 'parent_id': parent_id,
741 'attachment_ids': attachment_ids,
742 'subtype_id': subtype_id,
745 # Avoid warnings about non-existing fields
746 for x in ('from', 'to', 'cc'):
749 return mail_message.create(cr, uid, values, context=context)
751 def message_post_user_api(self, cr, uid, thread_id, body='', subject=False, parent_id=False,
752 attachment_ids=None, context=None, content_subtype='plaintext',
753 extra_email=[], **kwargs):
754 """ Wrapper on message_post, used for user input :
756 - quick reply in Chatter (refer to mail.js), not
757 the mail.compose.message wizard
758 The purpose is to perform some pre- and post-processing:
759 - if body is plaintext: convert it into html
760 - if parent_id: handle reply to a previous message by adding the
761 parent partners to the message
762 - type and subtype: comment and mail.mt_comment by default
763 - attachment_ids: supposed not attached to any document; attach them
764 to the related document. Should only be set by Chatter.
765 - extra_email: [ 'Fabien <fpi@openerp.com>', 'al@openerp.com' ]
767 ir_attachment = self.pool.get('ir.attachment')
768 mail_message = self.pool.get('mail.message')
770 # 1. Pre-processing: body, partner_ids, type and subtype
771 if content_subtype == 'plaintext':
772 body = tools.plaintext2html(body)
774 for partner in extra_email:
775 part_ids = self.pool.get('res.partner').search(cr, uid, [('email', '=', partner)], context=context)
777 part_ids = [self.pool.get('res.partner').name_create(cr, uid, partner, context=context)[0]]
778 self.message_subscribe(cr, uid, [thread_id], part_ids, context=context)
780 partner_ids = kwargs.pop('partner_ids', [])
782 parent_message = self.pool.get('mail.message').browse(cr, uid, parent_id, context=context)
783 partner_ids += [(4, partner.id) for partner in parent_message.partner_ids]
784 # TDE FIXME HACK: mail.thread -> private message
785 if self._name == 'mail.thread' and parent_message.author_id.id:
786 partner_ids.append((4, parent_message.author_id.id))
788 message_type = kwargs.pop('type', 'comment')
789 message_subtype = kwargs.pop('subtype', 'mail.mt_comment')
792 new_message_id = self.message_post(cr, uid, thread_id=thread_id, body=body, subject=subject, type=message_type,
793 subtype=message_subtype, parent_id=parent_id, context=context, partner_ids=partner_ids, **kwargs)
796 # HACK TDE FIXME: Chatter: attachments linked to the document (not done JS-side), load the message
798 # TDE FIXME (?): when posting a private message, we use mail.thread as a model
799 # However, attaching doc to mail.thread is not possible, mail.thread does not have any table
801 if model == 'mail.thread':
803 filtered_attachment_ids = ir_attachment.search(cr, SUPERUSER_ID, [
804 ('res_model', '=', 'mail.compose.message'),
806 ('create_uid', '=', uid),
807 ('id', 'in', attachment_ids)], context=context)
808 if filtered_attachment_ids:
809 if thread_id and model:
810 ir_attachment.write(cr, SUPERUSER_ID, attachment_ids, {'res_model': model, 'res_id': thread_id}, context=context)
811 mail_message.write(cr, SUPERUSER_ID, [new_message_id], {'attachment_ids': [(6, 0, [pid for pid in attachment_ids])]}, context=context)
813 return new_message_id
815 #------------------------------------------------------
817 #------------------------------------------------------
819 def message_get_subscription_data(self, cr, uid, ids, context=None):
820 """ Wrapper to get subtypes data. """
821 return self._get_subscription_data(cr, uid, ids, None, None, context=context)
823 def message_subscribe_users(self, cr, uid, ids, user_ids=None, subtype_ids=None, context=None):
824 """ Wrapper on message_subscribe, using users. If user_ids is not
825 provided, subscribe uid instead. """
828 partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
829 return self.message_subscribe(cr, uid, ids, partner_ids, subtype_ids=subtype_ids, context=context)
831 def message_subscribe(self, cr, uid, ids, partner_ids, subtype_ids=None, context=None):
832 """ Add partners to the records followers. """
833 self.check_access_rights(cr, uid, 'read')
834 self.write(cr, SUPERUSER_ID, ids, {'message_follower_ids': [(4, pid) for pid in partner_ids]}, context=context)
835 # if subtypes are not specified (and not set to a void list), fetch default ones
836 if subtype_ids is None:
837 subtype_obj = self.pool.get('mail.message.subtype')
838 subtype_ids = subtype_obj.search(cr, uid, [('default', '=', True), '|', ('res_model', '=', self._name), ('res_model', '=', False)], context=context)
839 # update the subscriptions
840 fol_obj = self.pool.get('mail.followers')
841 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids), ('partner_id', 'in', partner_ids)], context=context)
842 fol_obj.write(cr, SUPERUSER_ID, fol_ids, {'subtype_ids': [(6, 0, subtype_ids)]}, context=context)
845 def message_unsubscribe_users(self, cr, uid, ids, user_ids=None, context=None):
846 """ Wrapper on message_subscribe, using users. If user_ids is not
847 provided, unsubscribe uid instead. """
850 partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
851 return self.message_unsubscribe(cr, uid, ids, partner_ids, context=context)
853 def message_unsubscribe(self, cr, uid, ids, partner_ids, context=None):
854 """ Remove partners from the records followers. """
855 self.check_access_rights(cr, uid, 'read')
856 return self.write(cr, SUPERUSER_ID, ids, {'message_follower_ids': [(3, pid) for pid in partner_ids]}, context=context)
858 #------------------------------------------------------
860 #------------------------------------------------------
862 def message_mark_as_unread(self, cr, uid, ids, context=None):
863 """ Set as unread. """
864 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
866 UPDATE mail_notification SET
869 message_id IN (SELECT id from mail_message where res_id=any(%s) and model=%s limit 1) and
871 ''', (ids, self._name, partner_id))
874 def message_mark_as_read(self, cr, uid, ids, context=None):
876 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
878 UPDATE mail_notification SET
881 message_id IN (SELECT id FROM mail_message WHERE res_id=ANY(%s) AND model=%s) AND
883 ''', (ids, self._name, partner_id))
886 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: