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
32 from mail_message import decode
33 from openerp import SUPERUSER_ID
34 from osv import osv, fields
35 from tools.safe_eval import safe_eval as eval
37 _logger = logging.getLogger(__name__)
40 def decode_header(message, header, separator=' '):
41 return separator.join(map(decode, message.get_all(header, [])))
44 class mail_thread(osv.AbstractModel):
45 ''' mail_thread model is meant to be inherited by any model that needs to
46 act as a discussion topic on which messages can be attached. Public
47 methods are prefixed with ``message_`` in order to avoid name
48 collisions with methods of the models that will inherit from this class.
50 ``mail.thread`` defines fields used to handle and display the
51 communication history. ``mail.thread`` also manages followers of
52 inheriting classes. All features and expected behavior are managed
53 by mail.thread. Widgets has been designed for the 7.0 and following
56 Inheriting classes are not required to implement any method, as the
57 default implementation will work for any model. However it is common
58 to override at least the ``message_new`` and ``message_update``
59 methods (calling ``super``) to add model-specific behavior at
60 creation and update of a thread when processing incoming emails.
63 - _mail_flat_thread: if set to True, all messages without parent_id
64 are automatically attached to the first message posted on the
65 ressource. If set to False, the display of Chatter is done using
66 threads, and no parent_id is automatically set.
69 _description = 'Email Thread'
70 _mail_flat_thread = True
72 def _get_message_data(self, cr, uid, ids, name, args, context=None):
74 - message_unread: has uid unread message for the document
75 - message_summary: html snippet summarizing the Chatter for kanban views """
76 res = dict((id, dict(message_unread=False, message_summary='')) for id in ids)
77 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
79 # search for unread messages, directly in SQL to improve performances
80 cr.execute(""" SELECT m.res_id FROM mail_message m
81 RIGHT JOIN mail_notification n
82 ON (n.message_id = m.id AND n.partner_id = %s AND n.read = False)
83 WHERE m.model = %s AND m.res_id in %s""",
84 (user_pid, self._name, tuple(ids),))
85 msg_ids = [result[0] for result in cr.fetchall()]
86 for msg_id in msg_ids:
87 res[msg_id]['message_unread'] = True
89 for thread in self.browse(cr, uid, ids, context=context):
90 cls = res[thread.id]['message_unread'] and ' class="oe_kanban_mail_new"' or ''
91 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_comment_ids), len(thread.message_follower_ids))
95 def _get_subscription_data(self, cr, uid, ids, name, args, context=None):
97 - message_subtype_data: data about document subtypes: which are
98 available, which are followed if any """
99 res = dict((id, dict(message_subtype_data='')) for id in ids)
100 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
102 # find current model subtypes, add them to a dictionary
103 subtype_obj = self.pool.get('mail.message.subtype')
104 subtype_ids = subtype_obj.search(cr, uid, ['|', ('res_model', '=', self._name), ('res_model', '=', False)], context=context)
105 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))
107 res[id]['message_subtype_data'] = subtype_dict.copy()
109 # find the document followers, update the data
110 fol_obj = self.pool.get('mail.followers')
111 fol_ids = fol_obj.search(cr, uid, [
112 ('partner_id', '=', user_pid),
113 ('res_id', 'in', ids),
114 ('res_model', '=', self._name),
116 for fol in fol_obj.browse(cr, uid, fol_ids, context=context):
117 thread_subtype_dict = res[fol.res_id]['message_subtype_data']
118 for subtype in fol.subtype_ids:
119 thread_subtype_dict[subtype.name]['followed'] = True
120 res[fol.res_id]['message_subtype_data'] = thread_subtype_dict
124 def _search_unread(self, cr, uid, obj=None, name=None, domain=None, context=None):
125 partner_id = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
127 notif_obj = self.pool.get('mail.notification')
128 notif_ids = notif_obj.search(cr, uid, [
129 ('partner_id', '=', partner_id),
130 ('message_id.model', '=', self._name),
133 for notif in notif_obj.browse(cr, uid, notif_ids, context=context):
134 res[notif.message_id.res_id] = True
135 return [('id', 'in', res.keys())]
137 def _get_followers(self, cr, uid, ids, name, arg, context=None):
138 fol_obj = self.pool.get('mail.followers')
139 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids)])
140 res = dict((id, dict(message_follower_ids=[], message_is_follower=False)) for id in ids)
141 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
142 for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids):
143 res[fol.res_id]['message_follower_ids'].append(fol.partner_id.id)
144 if fol.partner_id.id == user_pid:
145 res[fol.res_id]['message_is_follower'] = True
148 def _set_followers(self, cr, uid, id, name, value, arg, context=None):
151 partner_obj = self.pool.get('res.partner')
152 fol_obj = self.pool.get('mail.followers')
154 # read the old set of followers, and determine the new set of followers
155 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', '=', id)])
156 old = set(fol.partner_id.id for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids))
159 for command in value or []:
160 if isinstance(command, (int, long)):
162 elif command[0] == 0:
163 new.add(partner_obj.create(cr, uid, command[2], context=context))
164 elif command[0] == 1:
165 partner_obj.write(cr, uid, [command[1]], command[2], context=context)
167 elif command[0] == 2:
168 partner_obj.unlink(cr, uid, [command[1]], context=context)
169 new.discard(command[1])
170 elif command[0] == 3:
171 new.discard(command[1])
172 elif command[0] == 4:
174 elif command[0] == 5:
176 elif command[0] == 6:
177 new = set(command[2])
179 # remove partners that are no longer followers
180 fol_ids = fol_obj.search(cr, SUPERUSER_ID,
181 [('res_model', '=', self._name), ('res_id', '=', id), ('partner_id', 'not in', list(new))])
182 fol_obj.unlink(cr, SUPERUSER_ID, fol_ids)
185 for partner_id in new - old:
186 fol_obj.create(cr, SUPERUSER_ID, {'res_model': self._name, 'res_id': id, 'partner_id': partner_id})
188 def _search_followers(self, cr, uid, obj, name, args, context):
189 fol_obj = self.pool.get('mail.followers')
191 for field, operator, value in args:
193 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('partner_id', operator, value)])
194 res_ids = [fol.res_id for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids)]
195 res.append(('id', 'in', res_ids))
199 'message_is_follower': fields.function(_get_followers,
200 type='boolean', string='Is a Follower', multi='_get_followers,'),
201 'message_follower_ids': fields.function(_get_followers, fnct_inv=_set_followers,
202 fnct_search=_search_followers, type='many2many',
203 obj='res.partner', string='Followers', multi='_get_followers'),
204 'message_comment_ids': fields.one2many('mail.message', 'res_id',
205 domain=lambda self: [('model', '=', self._name), ('type', 'in', ('comment', 'email'))],
206 string='Comments and emails',
207 help="Comments and emails"),
208 'message_ids': fields.one2many('mail.message', 'res_id',
209 domain=lambda self: [('model', '=', self._name)],
211 help="Messages and communication history"),
212 'message_unread': fields.function(_get_message_data, fnct_search=_search_unread,
213 type='boolean', string='Unread Messages', multi="_get_message_data",
214 help="If checked new messages require your attention."),
215 'message_summary': fields.function(_get_message_data, method=True,
216 type='text', string='Summary', multi="_get_message_data",
217 help="Holds the Chatter summary (number of messages, ...). "\
218 "This summary is directly in html format in order to "\
219 "be inserted in kanban views."),
222 #------------------------------------------------------
223 # Automatic subscription when creating
224 #------------------------------------------------------
226 def create(self, cr, uid, vals, context=None):
227 """ Override to subscribe the current user. """
228 thread_id = super(mail_thread, self).create(cr, uid, vals, context=context)
229 self.message_subscribe_users(cr, uid, [thread_id], [uid], context=context)
232 def unlink(self, cr, uid, ids, context=None):
233 """ Override unlink to delete messages and followers. This cannot be
234 cascaded, because link is done through (res_model, res_id). """
235 msg_obj = self.pool.get('mail.message')
236 fol_obj = self.pool.get('mail.followers')
237 # delete messages and notifications
238 msg_ids = msg_obj.search(cr, uid, [('model', '=', self._name), ('res_id', 'in', ids)], context=context)
239 msg_obj.unlink(cr, uid, msg_ids, context=context)
241 fol_ids = fol_obj.search(cr, uid, [('res_model', '=', self._name), ('res_id', 'in', ids)], context=context)
242 fol_obj.unlink(cr, uid, fol_ids, context=context)
243 return super(mail_thread, self).unlink(cr, uid, ids, context=context)
245 def copy(self, cr, uid, id, default=None, context=None):
246 default = default or {}
247 default['message_ids'] = []
248 default['message_comment_ids'] = []
249 default['message_follower_ids'] = []
250 return super(mail_thread, self).copy(cr, uid, id, default=default, context=context)
252 #------------------------------------------------------
253 # mail.message wrappers and tools
254 #------------------------------------------------------
256 def _needaction_domain_get(self, cr, uid, context=None):
258 return [('message_unread', '=', True)]
261 #------------------------------------------------------
263 #------------------------------------------------------
265 def message_capable_models(self, cr, uid, context=None):
266 """ Used by the plugin addon, based for plugin_outlook and others. """
268 for model_name in self.pool.obj_list():
269 model = self.pool.get(model_name)
270 if 'mail.thread' in getattr(model, '_inherit', []):
271 ret_dict[model_name] = model._description
274 def _message_find_partners(self, cr, uid, message, header_fields=['From'], context=None):
275 """ Find partners related to some header fields of the message. """
276 s = ', '.join([decode(message.get(h)) for h in header_fields if message.get(h)])
277 return [partner_id for email in tools.email_split(s)
278 for partner_id in self.pool.get('res.partner').search(cr, uid, [('email', 'ilike', email)], context=context)]
280 def _message_find_user_id(self, cr, uid, message, context=None):
281 from_local_part = tools.email_split(decode(message.get('From')))[0]
282 # FP Note: canonification required, the minimu: .lower()
283 user_ids = self.pool.get('res.users').search(cr, uid, ['|',
284 ('login', '=', from_local_part),
285 ('email', '=', from_local_part)], context=context)
286 return user_ids[0] if user_ids else uid
288 def message_route(self, cr, uid, message, model=None, thread_id=None,
289 custom_values=None, context=None):
290 """Attempt to figure out the correct target model, thread_id,
291 custom_values and user_id to use for an incoming message.
292 Multiple values may be returned, if a message had multiple
293 recipients matching existing mail.aliases, for example.
295 The following heuristics are used, in this order:
296 1. If the message replies to an existing thread_id, and
297 properly contains the thread model in the 'In-Reply-To'
298 header, use this model/thread_id pair, and ignore
299 custom_value (not needed as no creation will take place)
300 2. Look for a mail.alias entry matching the message
301 recipient, and use the corresponding model, thread_id,
302 custom_values and user_id.
303 3. Fallback to the ``model``, ``thread_id`` and ``custom_values``
305 4. If all the above fails, raise an exception.
307 :param string message: an email.message instance
308 :param string model: the fallback model to use if the message
309 does not match any of the currently configured mail aliases
310 (may be None if a matching alias is supposed to be present)
311 :type dict custom_values: optional dictionary of default field values
312 to pass to ``message_new`` if a new record needs to be created.
313 Ignored if the thread record already exists, and also if a
314 matching mail.alias was found (aliases define their own defaults)
315 :param int thread_id: optional ID of the record/thread from ``model``
316 to which this mail should be attached. Only used if the message
317 does not reply to an existing thread and does not match any mail alias.
318 :return: list of [model, thread_id, custom_values, user_id]
320 assert isinstance(message, Message), 'message must be an email.message.Message at this point'
321 message_id = message.get('Message-Id')
322 references = decode_header(message, 'References')
323 in_reply_to = decode_header(message, 'In-Reply-To')
325 # 1. Verify if this is a reply to an existing thread
326 thread_references = references or in_reply_to
327 ref_match = thread_references and tools.reference_re.search(thread_references)
329 thread_id = int(ref_match.group(1))
330 model = ref_match.group(2) or model
331 model_pool = self.pool.get(model)
332 if thread_id and model and model_pool and model_pool.exists(cr, uid, thread_id) \
333 and hasattr(model_pool, 'message_update'):
334 _logger.debug('Routing mail with Message-Id %s: direct reply to model: %s, thread_id: %s, custom_values: %s, uid: %s',
335 message_id, model, thread_id, custom_values, uid)
336 return [(model, thread_id, custom_values, uid)]
338 # Verify this is a reply to a private message
339 message_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', '=', in_reply_to)], limit=1, context=context)
341 message = self.pool.get('mail.message').browse(cr, uid, message_ids[0], context=context)
342 _logger.debug('Routing mail with Message-Id %s: direct reply to a private message: %s, custom_values: %s, uid: %s',
343 message_id, message.id, custom_values, uid)
344 return [(message.model, message.res_id, custom_values, uid)]
346 # 2. Look for a matching mail.alias entry
347 # Delivered-To is a safe bet in most modern MTAs, but we have to fallback on To + Cc values
348 # for all the odd MTAs out there, as there is no standard header for the envelope's `rcpt_to` value.
349 rcpt_tos = decode_header(message, 'Delivered-To') or \
350 ','.join([decode_header(message, 'To'),
351 decode_header(message, 'Cc'),
352 decode_header(message, 'Resent-To'),
353 decode_header(message, 'Resent-Cc')])
354 local_parts = [e.split('@')[0] for e in tools.email_split(rcpt_tos)]
356 mail_alias = self.pool.get('mail.alias')
357 alias_ids = mail_alias.search(cr, uid, [('alias_name', 'in', local_parts)])
360 for alias in mail_alias.browse(cr, uid, alias_ids, context=context):
361 user_id = alias.alias_user_id.id
363 user_id = self._message_find_user_id(cr, uid, message, context=context)
364 routes.append((alias.alias_model_id.model, alias.alias_force_thread_id, \
365 eval(alias.alias_defaults), user_id))
366 _logger.debug('Routing mail with Message-Id %s: direct alias match: %r', message_id, routes)
369 # 3. Fallback to the provided parameters, if they work
370 model_pool = self.pool.get(model)
372 # Legacy: fallback to matching [ID] in the Subject
373 match = tools.res_re.search(decode_header(message, 'Subject'))
374 thread_id = match and match.group(1)
375 assert thread_id and hasattr(model_pool, 'message_update') or hasattr(model_pool, 'message_new'), \
376 "No possible route found for incoming message with Message-Id %s. " \
377 "Create an appropriate mail.alias or force the destination model." % message_id
378 if thread_id and not model_pool.exists(cr, uid, thread_id):
379 _logger.warning('Received mail reply to missing document %s! Ignoring and creating new document instead for Message-Id %s',
380 thread_id, message_id)
382 _logger.debug('Routing mail with Message-Id %s: fallback to model:%s, thread_id:%s, custom_values:%s, uid:%s',
383 message_id, model, thread_id, custom_values, uid)
384 return [(model, thread_id, custom_values, uid)]
386 def message_process(self, cr, uid, model, message, custom_values=None,
387 save_original=False, strip_attachments=False,
388 thread_id=None, context=None):
389 """ Process an incoming RFC2822 email message, relying on
390 ``mail.message.parse()`` for the parsing operation,
391 and ``message_route()`` to figure out the target model.
393 Once the target model is known, its ``message_new`` method
394 is called with the new message (if the thread record did not exist)
395 or its ``message_update`` method (if it did).
397 There is a special case where the target model is False: a reply
398 to a private message. In this case, we skip the message_new /
399 message_update step, to just post a new message using mail_thread
402 :param string model: the fallback model to use if the message
403 does not match any of the currently configured mail aliases
404 (may be None if a matching alias is supposed to be present)
405 :param message: source of the RFC2822 message
406 :type message: string or xmlrpclib.Binary
407 :type dict custom_values: optional dictionary of field values
408 to pass to ``message_new`` if a new record needs to be created.
409 Ignored if the thread record already exists, and also if a
410 matching mail.alias was found (aliases define their own defaults)
411 :param bool save_original: whether to keep a copy of the original
412 email source attached to the message after it is imported.
413 :param bool strip_attachments: whether to strip all attachments
414 before processing the message, in order to save some space.
415 :param int thread_id: optional ID of the record/thread from ``model``
416 to which this mail should be attached. When provided, this
417 overrides the automatic detection based on the message
423 # extract message bytes - we are forced to pass the message as binary because
424 # we don't know its encoding until we parse its headers and hence can't
425 # convert it to utf-8 for transport between the mailgate script and here.
426 if isinstance(message, xmlrpclib.Binary):
427 message = str(message.data)
428 # Warning: message_from_string doesn't always work correctly on unicode,
429 # we must use utf-8 strings here :-(
430 if isinstance(message, unicode):
431 message = message.encode('utf-8')
432 msg_txt = email.message_from_string(message)
433 routes = self.message_route(cr, uid, msg_txt, model,
434 thread_id, custom_values,
436 msg = self.message_parse(cr, uid, msg_txt, save_original=save_original, context=context)
437 if strip_attachments:
438 msg.pop('attachments', None)
440 for model, thread_id, custom_values, user_id in routes:
441 if self._name != model:
442 context.update({'thread_model': model})
444 model_pool = self.pool.get(model)
445 assert thread_id and hasattr(model_pool, 'message_update') or hasattr(model_pool, 'message_new'), \
446 "Undeliverable mail with Message-Id %s, model %s does not accept incoming emails" % \
447 (msg['message_id'], model)
448 if thread_id and hasattr(model_pool, 'message_update'):
449 model_pool.message_update(cr, user_id, [thread_id], msg, context=context)
451 thread_id = model_pool.message_new(cr, user_id, msg, custom_values, context=context)
453 assert thread_id == 0, "Posting a message without model should be with a null res_id, to create a private message."
454 model_pool = self.pool.get('mail.thread')
455 model_pool.message_post_user_api(cr, uid, [thread_id], context=context, content_subtype='html', **msg)
458 def message_new(self, cr, uid, msg_dict, custom_values=None, context=None):
459 """Called by ``message_process`` when a new message is received
460 for a given thread model, if the message did not belong to
462 The default behavior is to create a new record of the corresponding
463 model (based on some very basic info extracted from the message).
464 Additional behavior may be implemented by overriding this method.
466 :param dict msg_dict: a map containing the email details and
467 attachments. See ``message_process`` and
468 ``mail.message.parse`` for details.
469 :param dict custom_values: optional dictionary of additional
470 field values to pass to create()
471 when creating the new thread record.
472 Be careful, these values may override
473 any other values coming from the message.
474 :param dict context: if a ``thread_model`` value is present
475 in the context, its value will be used
476 to determine the model of the record
477 to create (instead of the current model).
479 :return: the id of the newly created thread object
483 model = context.get('thread_model') or self._name
484 model_pool = self.pool.get(model)
485 fields = model_pool.fields_get(cr, uid, context=context)
486 data = model_pool.default_get(cr, uid, fields, context=context)
487 if 'name' in fields and not data.get('name'):
488 data['name'] = msg_dict.get('subject', '')
489 if custom_values and isinstance(custom_values, dict):
490 data.update(custom_values)
491 res_id = model_pool.create(cr, uid, data, context=context)
494 def message_update(self, cr, uid, ids, msg_dict, update_vals=None, context=None):
495 """Called by ``message_process`` when a new message is received
496 for an existing thread. The default behavior is to update the record
497 with update_vals taken from the incoming email.
498 Additional behavior may be implemented by overriding this
500 :param dict msg_dict: a map containing the email details and
501 attachments. See ``message_process`` and
502 ``mail.message.parse()`` for details.
503 :param dict update_vals: a dict containing values to update records
504 given their ids; if the dict is None or is
505 void, no write operation is performed.
508 self.write(cr, uid, ids, update_vals, context=context)
511 def _message_extract_payload(self, message, save_original=False):
512 """Extract body as HTML and attachments from the mail message"""
516 attachments.append(('original_email.eml', message.as_string()))
517 if not message.is_multipart() or 'text/' in message.get('content-type', ''):
518 encoding = message.get_content_charset()
519 body = message.get_payload(decode=True)
520 body = tools.ustr(body, encoding, errors='replace')
521 if message.get_content_type() == 'text/plain':
522 # text/plain -> <pre/>
523 body = tools.append_content_to_html(u'', body, preserve=True)
525 alternative = (message.get_content_type() == 'multipart/alternative')
526 for part in message.walk():
527 if part.get_content_maintype() == 'multipart':
528 continue # skip container
529 filename = part.get_filename() # None if normal part
530 encoding = part.get_content_charset() # None if attachment
531 # 1) Explicit Attachments -> attachments
532 if filename or part.get('content-disposition', '').strip().startswith('attachment'):
533 attachments.append((filename or 'attachment', part.get_payload(decode=True)))
535 # 2) text/plain -> <pre/>
536 if part.get_content_type() == 'text/plain' and (not alternative or not body):
537 body = tools.append_content_to_html(body, tools.ustr(part.get_payload(decode=True),
538 encoding, errors='replace'), preserve=True)
539 # 3) text/html -> raw
540 elif part.get_content_type() == 'text/html':
541 html = tools.ustr(part.get_payload(decode=True), encoding, errors='replace')
545 body = tools.append_content_to_html(body, html, plaintext=False)
546 # 4) Anything else -> attachment
548 attachments.append((filename or 'attachment', part.get_payload(decode=True)))
549 return body, attachments
551 def message_parse(self, cr, uid, message, save_original=False, context=None):
552 """Parses a string or email.message.Message representing an
553 RFC-2822 email, and returns a generic dict holding the
556 :param message: the message to parse
557 :type message: email.message.Message | string | unicode
558 :param bool save_original: whether the returned dict
559 should include an ``original`` attachment containing
560 the source of the message
562 :return: A dict with the following structure, where each
563 field may not be present if missing in original
566 { 'message_id': msg_id,
571 'body': unified_body,
572 'attachments': [('file1', 'bytes'),
580 if not isinstance(message, Message):
581 if isinstance(message, unicode):
582 # Warning: message_from_string doesn't always work correctly on unicode,
583 # we must use utf-8 strings here :-(
584 message = message.encode('utf-8')
585 message = email.message_from_string(message)
587 message_id = message['message-id']
589 # Very unusual situation, be we should be fault-tolerant here
590 message_id = "<%s@localhost>" % time.time()
591 _logger.debug('Parsing Message without message-id, generating a random one: %s', message_id)
592 msg_dict['message_id'] = message_id
594 if 'Subject' in message:
595 msg_dict['subject'] = decode(message.get('Subject'))
597 # Envelope fields not stored in mail.message but made available for message_new()
598 msg_dict['from'] = decode(message.get('from'))
599 msg_dict['to'] = decode(message.get('to'))
600 msg_dict['cc'] = decode(message.get('cc'))
602 if 'From' in message:
603 author_ids = self._message_find_partners(cr, uid, message, ['From'], context=context)
605 msg_dict['author_id'] = author_ids[0]
607 msg_dict['email_from'] = message.get('from')
608 partner_ids = self._message_find_partners(cr, uid, message, ['From', 'To', 'Cc'], context=context)
609 msg_dict['partner_ids'] = [(4, partner_id) for partner_id in partner_ids]
611 if 'Date' in message:
612 date_hdr = decode(message.get('Date'))
613 # convert from email timezone to server timezone
614 date_server_datetime = dateutil.parser.parse(date_hdr).astimezone(pytz.timezone(tools.get_server_timezone()))
615 date_server_datetime_str = date_server_datetime.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT)
616 msg_dict['date'] = date_server_datetime_str
618 if 'In-Reply-To' in message:
619 parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', '=', decode(message['In-Reply-To']))])
621 msg_dict['parent_id'] = parent_ids[0]
623 if 'References' in message and 'parent_id' not in msg_dict:
624 parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', 'in',
625 [x.strip() for x in decode(message['References']).split()])])
627 msg_dict['parent_id'] = parent_ids[0]
629 msg_dict['body'], msg_dict['attachments'] = self._message_extract_payload(message)
632 #------------------------------------------------------
634 #------------------------------------------------------
636 def log(self, cr, uid, id, message, secondary=False, context=None):
637 _logger.warning("log() is deprecated. As this module inherit from "\
638 "mail.thread, the message will be managed by this "\
639 "module instead of by the res.log mechanism. Please "\
640 "use mail_thread.message_post() instead of the "\
641 "now deprecated res.log.")
642 self.message_post(cr, uid, [id], message, context=context)
644 def message_post(self, cr, uid, thread_id, body='', subject=None, type='notification',
645 subtype=None, parent_id=False, attachments=None, context=None, **kwargs):
646 """ Post a new message in an existing thread, returning the new
647 mail.message ID. Extra keyword arguments will be used as default
648 column values for the new mail.message record.
649 Auto link messages for same id and object
650 :param int thread_id: thread ID to post into, or list with one ID;
651 if False/0, mail.message model will also be set as False
652 :param str body: body of the message, usually raw HTML that will
654 :param str subject: optional subject
655 :param str type: mail_message.type
656 :param int parent_id: optional ID of parent message in this thread
657 :param tuple(str,str) attachments or list id: list of attachment tuples in the form
658 ``(name,content)``, where content is NOT base64 encoded
659 :return: ID of newly created mail.message
663 if attachments is None:
666 assert (not thread_id) or isinstance(thread_id, (int, long)) or \
667 (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"
668 if isinstance(thread_id, (list, tuple)):
669 thread_id = thread_id and thread_id[0]
670 mail_message = self.pool.get('mail.message')
671 model = context.get('thread_model', self._name) if thread_id else False
674 for name, content in attachments:
675 if isinstance(content, unicode):
676 content = content.encode('utf-8')
679 'datas': base64.b64encode(str(content)),
682 'res_model': context.get('thread_model') or self._name,
685 attachment_ids.append((0, 0, data_attach))
689 s_data = subtype.split('.')
691 s_data = ('mail', s_data[0])
692 ref = self.pool.get('ir.model.data').get_object_reference(cr, uid, s_data[0], s_data[1])
693 subtype_id = ref and ref[1] or False
697 # _mail_flat_thread: automatically set free messages to the first posted message
698 if self._mail_flat_thread and not parent_id and thread_id:
699 message_ids = mail_message.search(cr, uid, ['&', ('res_id', '=', thread_id), ('model', '=', model)], context=context, order="id ASC", limit=1)
700 parent_id = message_ids and message_ids[0] or False
701 # 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
703 message_ids = mail_message.search(cr, SUPERUSER_ID, [('id', '=', parent_id), ('parent_id', '!=', False)], context=context)
704 # avoid loops when finding ancestors
707 message = mail_message.browse(cr, SUPERUSER_ID, message_ids[0], context=context)
708 while (message.parent_id and message.parent_id.id not in processed_list):
709 processed_list.append(message.parent_id.id)
710 message = message.parent_id
711 parent_id = message.id
716 'res_id': thread_id or False,
718 'subject': subject or False,
720 'parent_id': parent_id,
721 'attachment_ids': attachment_ids,
722 'subtype_id': subtype_id,
725 # Avoid warnings about non-existing fields
726 for x in ('from', 'to', 'cc'):
729 return mail_message.create(cr, uid, values, context=context)
731 def message_post_user_api(self, cr, uid, thread_id, body='', subject=False, parent_id=False,
732 attachment_ids=None, context=None, content_subtype='plaintext', **kwargs):
733 """ Wrapper on message_post, used for user input :
735 - quick reply in Chatter (refer to mail.js), not
736 the mail.compose.message wizard
737 The purpose is to perform some pre- and post-processing:
738 - if body is plaintext: convert it into html
739 - if parent_id: handle reply to a previous message by adding the
740 parent partners to the message
741 - type and subtype: comment and mail.mt_comment by default
742 - attachment_ids: supposed not attached to any document; attach them
743 to the related document. Should only be set by Chatter.
745 ir_attachment = self.pool.get('ir.attachment')
746 mail_message = self.pool.get('mail.message')
748 # 1. Pre-processing: body, partner_ids, type and subtype
749 if content_subtype == 'plaintext':
750 body = tools.plaintext2html(body)
752 partner_ids = kwargs.pop('partner_ids', [])
754 parent_message = self.pool.get('mail.message').browse(cr, uid, parent_id, context=context)
755 partner_ids += [(4, partner.id) for partner in parent_message.partner_ids]
756 # TDE FIXME HACK: mail.thread -> private message
757 if self._name == 'mail.thread' and parent_message.author_id.id:
758 partner_ids.append((4, parent_message.author_id.id))
760 message_type = kwargs.pop('type', 'comment')
761 message_subtype = kwargs.pop('subtype', 'mail.mt_comment')
764 new_message_id = self.message_post(cr, uid, thread_id=thread_id, body=body, subject=subject, type=message_type,
765 subtype=message_subtype, parent_id=parent_id, context=context, partner_ids=partner_ids, **kwargs)
768 # HACK TDE FIXME: Chatter: attachments linked to the document (not done JS-side), load the message
770 filtered_attachment_ids = ir_attachment.search(cr, SUPERUSER_ID, [
771 ('res_model', '=', 'mail.compose.message'),
773 ('create_uid', '=', uid),
774 ('id', 'in', attachment_ids)], context=context)
775 if filtered_attachment_ids:
776 ir_attachment.write(cr, SUPERUSER_ID, attachment_ids, {'res_model': self._name, 'res_id': thread_id}, context=context)
777 mail_message.write(cr, SUPERUSER_ID, [new_message_id], {'attachment_ids': [(6, 0, [pid for pid in attachment_ids])]}, context=context)
779 return new_message_id
781 #------------------------------------------------------
783 #------------------------------------------------------
785 def message_get_subscription_data(self, cr, uid, ids, context=None):
786 """ Wrapper to get subtypes data. """
787 return self._get_subscription_data(cr, uid, ids, None, None, context=context)
789 def message_subscribe_users(self, cr, uid, ids, user_ids=None, subtype_ids=None, context=None):
790 """ Wrapper on message_subscribe, using users. If user_ids is not
791 provided, subscribe uid instead. """
794 partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
795 return self.message_subscribe(cr, uid, ids, partner_ids, subtype_ids=subtype_ids, context=context)
797 def message_subscribe(self, cr, uid, ids, partner_ids, subtype_ids=None, context=None):
798 """ Add partners to the records followers. """
799 self.write(cr, uid, ids, {'message_follower_ids': [(4, pid) for pid in partner_ids]}, context=context)
800 # if subtypes are not specified (and not set to a void list), fetch default ones
801 if subtype_ids is None:
802 subtype_obj = self.pool.get('mail.message.subtype')
803 subtype_ids = subtype_obj.search(cr, uid, [('default', '=', True), '|', ('res_model', '=', self._name), ('res_model', '=', False)], context=context)
804 # update the subscriptions
805 fol_obj = self.pool.get('mail.followers')
806 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids), ('partner_id', 'in', partner_ids)], context=context)
807 fol_obj.write(cr, SUPERUSER_ID, fol_ids, {'subtype_ids': [(6, 0, subtype_ids)]}, context=context)
810 def message_unsubscribe_users(self, cr, uid, ids, user_ids=None, context=None):
811 """ Wrapper on message_subscribe, using users. If user_ids is not
812 provided, unsubscribe uid instead. """
815 partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
816 return self.message_unsubscribe(cr, uid, ids, partner_ids, context=context)
818 def message_unsubscribe(self, cr, uid, ids, partner_ids, context=None):
819 """ Remove partners from the records followers. """
820 return self.write(cr, uid, ids, {'message_follower_ids': [(3, pid) for pid in partner_ids]}, context=context)
822 #------------------------------------------------------
824 #------------------------------------------------------
826 def message_mark_as_unread(self, cr, uid, ids, context=None):
827 """ Set as unread. """
828 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
830 UPDATE mail_notification SET
833 message_id IN (SELECT id from mail_message where res_id=any(%s) and model=%s limit 1) and
835 ''', (ids, self._name, partner_id))
838 def message_mark_as_read(self, cr, uid, ids, context=None):
840 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
842 UPDATE mail_notification SET
845 message_id IN (SELECT id FROM mail_message WHERE res_id=ANY(%s) AND model=%s) AND
847 ''', (ids, self._name, partner_id))
850 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: