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)
78 # search for unread messages, by reading directly mail.notification, as SUPERUSER
79 notif_obj = self.pool.get('mail.notification')
80 notif_ids = notif_obj.search(cr, SUPERUSER_ID, [
81 ('partner_id.user_ids', 'in', [uid]),
82 ('message_id.res_id', 'in', ids),
83 ('message_id.model', '=', self._name),
86 for notif in notif_obj.browse(cr, SUPERUSER_ID, notif_ids, context=context):
87 res[notif.message_id.res_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')
323 # 1. Verify if this is a reply to an existing thread
324 references = decode_header(message, 'References') or decode_header(message, 'In-Reply-To')
325 ref_match = references and tools.reference_re.search(references)
327 thread_id = int(ref_match.group(1))
328 model = ref_match.group(2) or model
329 model_pool = self.pool.get(model)
330 if thread_id and model and model_pool and model_pool.exists(cr, uid, thread_id) \
331 and hasattr(model_pool, 'message_update'):
332 _logger.debug('Routing mail with Message-Id %s: direct reply to model: %s, thread_id: %s, custom_values: %s, uid: %s',
333 message_id, model, thread_id, custom_values, uid)
334 return [(model, thread_id, custom_values, uid)]
336 # 2. Look for a matching mail.alias entry
337 # Delivered-To is a safe bet in most modern MTAs, but we have to fallback on To + Cc values
338 # for all the odd MTAs out there, as there is no standard header for the envelope's `rcpt_to` value.
339 rcpt_tos = decode_header(message, 'Delivered-To') or \
340 ','.join([decode_header(message, 'To'),
341 decode_header(message, 'Cc'),
342 decode_header(message, 'Resent-To'),
343 decode_header(message, 'Resent-Cc')])
344 local_parts = [e.split('@')[0] for e in tools.email_split(rcpt_tos)]
346 mail_alias = self.pool.get('mail.alias')
347 alias_ids = mail_alias.search(cr, uid, [('alias_name', 'in', local_parts)])
350 for alias in mail_alias.browse(cr, uid, alias_ids, context=context):
351 user_id = alias.alias_user_id.id
353 user_id = self._message_find_user_id(cr, uid, message, context=context)
354 routes.append((alias.alias_model_id.model, alias.alias_force_thread_id, \
355 eval(alias.alias_defaults), user_id))
356 _logger.debug('Routing mail with Message-Id %s: direct alias match: %r', message_id, routes)
359 # 3. Fallback to the provided parameters, if they work
360 model_pool = self.pool.get(model)
362 # Legacy: fallback to matching [ID] in the Subject
363 match = tools.res_re.search(decode_header(message, 'Subject'))
364 thread_id = match and match.group(1)
365 assert thread_id and hasattr(model_pool, 'message_update') or hasattr(model_pool, 'message_new'), \
366 "No possible route found for incoming message with Message-Id %s. " \
367 "Create an appropriate mail.alias or force the destination model." % message_id
368 if thread_id and not model_pool.exists(cr, uid, thread_id):
369 _logger.warning('Received mail reply to missing document %s! Ignoring and creating new document instead for Message-Id %s',
370 thread_id, message_id)
372 _logger.debug('Routing mail with Message-Id %s: fallback to model:%s, thread_id:%s, custom_values:%s, uid:%s',
373 message_id, model, thread_id, custom_values, uid)
374 return [(model, thread_id, custom_values, uid)]
376 def message_process(self, cr, uid, model, message, custom_values=None,
377 save_original=False, strip_attachments=False,
378 thread_id=None, context=None):
379 """Process an incoming RFC2822 email message, relying on
380 ``mail.message.parse()`` for the parsing operation,
381 and ``message_route()`` to figure out the target model.
383 Once the target model is known, its ``message_new`` method
384 is called with the new message (if the thread record did not exist)
385 or its ``message_update`` method (if it did).
387 :param string model: the fallback model to use if the message
388 does not match any of the currently configured mail aliases
389 (may be None if a matching alias is supposed to be present)
390 :param message: source of the RFC2822 message
391 :type message: string or xmlrpclib.Binary
392 :type dict custom_values: optional dictionary of field values
393 to pass to ``message_new`` if a new record needs to be created.
394 Ignored if the thread record already exists, and also if a
395 matching mail.alias was found (aliases define their own defaults)
396 :param bool save_original: whether to keep a copy of the original
397 email source attached to the message after it is imported.
398 :param bool strip_attachments: whether to strip all attachments
399 before processing the message, in order to save some space.
400 :param int thread_id: optional ID of the record/thread from ``model``
401 to which this mail should be attached. When provided, this
402 overrides the automatic detection based on the message
408 # extract message bytes - we are forced to pass the message as binary because
409 # we don't know its encoding until we parse its headers and hence can't
410 # convert it to utf-8 for transport between the mailgate script and here.
411 if isinstance(message, xmlrpclib.Binary):
412 message = str(message.data)
413 # Warning: message_from_string doesn't always work correctly on unicode,
414 # we must use utf-8 strings here :-(
415 if isinstance(message, unicode):
416 message = message.encode('utf-8')
417 msg_txt = email.message_from_string(message)
418 routes = self.message_route(cr, uid, msg_txt, model,
419 thread_id, custom_values,
421 msg = self.message_parse(cr, uid, msg_txt, save_original=save_original, context=context)
422 if strip_attachments:
423 msg.pop('attachments', None)
425 for model, thread_id, custom_values, user_id in routes:
426 if self._name != model:
427 context.update({'thread_model': model})
428 model_pool = self.pool.get(model)
429 assert thread_id and hasattr(model_pool, 'message_update') or hasattr(model_pool, 'message_new'), \
430 "Undeliverable mail with Message-Id %s, model %s does not accept incoming emails" % \
431 (msg['message_id'], model)
432 if thread_id and hasattr(model_pool, 'message_update'):
433 model_pool.message_update(cr, user_id, [thread_id], msg, context=context)
435 thread_id = model_pool.message_new(cr, user_id, msg, custom_values, context=context)
436 model_pool.message_post(cr, uid, [thread_id], context=context, **msg)
439 def message_new(self, cr, uid, msg_dict, custom_values=None, context=None):
440 """Called by ``message_process`` when a new message is received
441 for a given thread model, if the message did not belong to
443 The default behavior is to create a new record of the corresponding
444 model (based on some very basic info extracted from the message).
445 Additional behavior may be implemented by overriding this method.
447 :param dict msg_dict: a map containing the email details and
448 attachments. See ``message_process`` and
449 ``mail.message.parse`` for details.
450 :param dict custom_values: optional dictionary of additional
451 field values to pass to create()
452 when creating the new thread record.
453 Be careful, these values may override
454 any other values coming from the message.
455 :param dict context: if a ``thread_model`` value is present
456 in the context, its value will be used
457 to determine the model of the record
458 to create (instead of the current model).
460 :return: the id of the newly created thread object
464 model = context.get('thread_model') or self._name
465 model_pool = self.pool.get(model)
466 fields = model_pool.fields_get(cr, uid, context=context)
467 data = model_pool.default_get(cr, uid, fields, context=context)
468 if 'name' in fields and not data.get('name'):
469 data['name'] = msg_dict.get('subject', '')
470 if custom_values and isinstance(custom_values, dict):
471 data.update(custom_values)
472 res_id = model_pool.create(cr, uid, data, context=context)
475 def message_update(self, cr, uid, ids, msg_dict, update_vals=None, context=None):
476 """Called by ``message_process`` when a new message is received
477 for an existing thread. The default behavior is to update the record
478 with update_vals taken from the incoming email.
479 Additional behavior may be implemented by overriding this
481 :param dict msg_dict: a map containing the email details and
482 attachments. See ``message_process`` and
483 ``mail.message.parse()`` for details.
484 :param dict update_vals: a dict containing values to update records
485 given their ids; if the dict is None or is
486 void, no write operation is performed.
489 self.write(cr, uid, ids, update_vals, context=context)
492 def _message_extract_payload(self, message, save_original=False):
493 """Extract body as HTML and attachments from the mail message"""
497 attachments.append(('original_email.eml', message.as_string()))
498 if not message.is_multipart() or 'text/' in message.get('content-type', ''):
499 encoding = message.get_content_charset()
500 body = message.get_payload(decode=True)
501 body = tools.ustr(body, encoding, errors='replace')
502 if message.get_content_type() == 'text/plain':
503 # text/plain -> <pre/>
504 body = tools.append_content_to_html(u'', body)
506 alternative = (message.get_content_type() == 'multipart/alternative')
507 for part in message.walk():
508 if part.get_content_maintype() == 'multipart':
509 continue # skip container
510 filename = part.get_filename() # None if normal part
511 encoding = part.get_content_charset() # None if attachment
512 # 1) Explicit Attachments -> attachments
513 if filename or part.get('content-disposition', '').strip().startswith('attachment'):
514 attachments.append((filename or 'attachment', part.get_payload(decode=True)))
516 # 2) text/plain -> <pre/>
517 if part.get_content_type() == 'text/plain' and (not alternative or not body):
518 body = tools.append_content_to_html(body, tools.ustr(part.get_payload(decode=True),
519 encoding, errors='replace'))
520 # 3) text/html -> raw
521 elif part.get_content_type() == 'text/html':
522 html = tools.ustr(part.get_payload(decode=True), encoding, errors='replace')
526 body = tools.append_content_to_html(body, html, plaintext=False)
527 # 4) Anything else -> attachment
529 attachments.append((filename or 'attachment', part.get_payload(decode=True)))
530 return body, attachments
532 def message_parse(self, cr, uid, message, save_original=False, context=None):
533 """Parses a string or email.message.Message representing an
534 RFC-2822 email, and returns a generic dict holding the
537 :param message: the message to parse
538 :type message: email.message.Message | string | unicode
539 :param bool save_original: whether the returned dict
540 should include an ``original`` attachment containing
541 the source of the message
543 :return: A dict with the following structure, where each
544 field may not be present if missing in original
547 { 'message_id': msg_id,
552 'body': unified_body,
553 'attachments': [('file1', 'bytes'),
559 'subtype': 'mail.mt_comment',
562 if not isinstance(message, Message):
563 if isinstance(message, unicode):
564 # Warning: message_from_string doesn't always work correctly on unicode,
565 # we must use utf-8 strings here :-(
566 message = message.encode('utf-8')
567 message = email.message_from_string(message)
569 message_id = message['message-id']
571 # Very unusual situation, be we should be fault-tolerant here
572 message_id = "<%s@localhost>" % time.time()
573 _logger.debug('Parsing Message without message-id, generating a random one: %s', message_id)
574 msg_dict['message_id'] = message_id
576 if 'Subject' in message:
577 msg_dict['subject'] = decode(message.get('Subject'))
579 # Envelope fields not stored in mail.message but made available for message_new()
580 msg_dict['from'] = decode(message.get('from'))
581 msg_dict['to'] = decode(message.get('to'))
582 msg_dict['cc'] = decode(message.get('cc'))
584 if 'From' in message:
585 author_ids = self._message_find_partners(cr, uid, message, ['From'], context=context)
587 msg_dict['author_id'] = author_ids[0]
589 msg_dict['email_from'] = message.get('from')
590 partner_ids = self._message_find_partners(cr, uid, message, ['From', 'To', 'Cc'], context=context)
591 msg_dict['partner_ids'] = partner_ids
593 if 'Date' in message:
594 date_hdr = decode(message.get('Date'))
595 # convert from email timezone to server timezone
596 date_server_datetime = dateutil.parser.parse(date_hdr).astimezone(pytz.timezone(tools.get_server_timezone()))
597 date_server_datetime_str = date_server_datetime.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT)
598 msg_dict['date'] = date_server_datetime_str
600 if 'In-Reply-To' in message:
601 parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', '=', decode(message['In-Reply-To']))])
603 msg_dict['parent_id'] = parent_ids[0]
605 if 'References' in message and 'parent_id' not in msg_dict:
606 parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', 'in',
607 [x.strip() for x in decode(message['References']).split()])])
609 msg_dict['parent_id'] = parent_ids[0]
611 msg_dict['body'], msg_dict['attachments'] = self._message_extract_payload(message)
614 #------------------------------------------------------
616 #------------------------------------------------------
618 def log(self, cr, uid, id, message, secondary=False, context=None):
619 _logger.warning("log() is deprecated. As this module inherit from "\
620 "mail.thread, the message will be managed by this "\
621 "module instead of by the res.log mechanism. Please "\
622 "use mail_thread.message_post() instead of the "\
623 "now deprecated res.log.")
624 self.message_post(cr, uid, [id], message, context=context)
626 def message_post(self, cr, uid, thread_id, body='', subject=None, type='notification',
627 subtype=None, parent_id=False, attachments=None, context=None, **kwargs):
628 """ Post a new message in an existing thread, returning the new
629 mail.message ID. Extra keyword arguments will be used as default
630 column values for the new mail.message record.
631 Auto link messages for same id and object
632 :param int thread_id: thread ID to post into, or list with one ID
633 :param str body: body of the message, usually raw HTML that will
635 :param str subject: optional subject
636 :param str type: mail_message.type
637 :param int parent_id: optional ID of parent message in this thread
638 :param tuple(str,str) attachments or list id: list of attachment tuples in the form
639 ``(name,content)``, where content is NOT base64 encoded
640 :return: ID of newly created mail.message
642 context = context or {}
643 attachments = attachments or []
644 assert (not thread_id) or isinstance(thread_id, (int, long)) or \
645 (isinstance(thread_id, (list, tuple)) and len(thread_id) == 1), "Invalid thread_id"
646 if isinstance(thread_id, (list, tuple)):
647 thread_id = thread_id and thread_id[0]
648 mail_message = self.pool.get('mail.message')
649 model = context.get('thread_model', self._name) if thread_id else False
652 for name, content in attachments:
653 if isinstance(content, unicode):
654 content = content.encode('utf-8')
657 'datas': base64.b64encode(str(content)),
660 'res_model': context.get('thread_model') or self._name,
663 attachment_ids.append((0, 0, data_attach))
667 s_data = subtype.split('.')
669 s_data = ('mail', s_data[0])
670 ref = self.pool.get('ir.model.data').get_object_reference(cr, uid, s_data[0], s_data[1])
671 subtype_id = ref and ref[1] or False
675 # _mail_flat_thread: automatically set free messages to the first posted message
676 if self._mail_flat_thread and not parent_id and thread_id:
677 message_ids = mail_message.search(cr, uid, ['&', ('res_id', '=', thread_id), ('model', '=', model)], context=context, order="id ASC", limit=1)
678 parent_id = message_ids and message_ids[0] or False
679 # 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
681 message_ids = mail_message.search(cr, SUPERUSER_ID, [('id', '=', parent_id), ('parent_id', '!=', False)], context=context)
682 # avoid loops when finding ancestors
685 message = mail_message.browse(cr, SUPERUSER_ID, message_ids[0], context=context)
686 while (message.parent_id and message.parent_id.id not in processed_list):
687 processed_list.append(message.parent_id.id)
688 message = message.parent_id
689 parent_id = message.id
694 'res_id': thread_id or False,
696 'subject': subject or False,
698 'parent_id': parent_id,
699 'attachment_ids': attachment_ids,
700 'subtype_id': subtype_id,
703 # Avoid warnings about non-existing fields
704 for x in ('from', 'to', 'cc'):
707 return mail_message.create(cr, uid, values, context=context)
709 def message_post_api(self, cr, uid, thread_id, body='', subject=False, parent_id=False, attachment_ids=None, context=None):
710 """ Wrapper on message_post, used only in Chatter (JS). The purpose is
711 to handle attachments.
712 # TDE FIXME: body is plaintext: convert it into html
714 new_message_id = self.message_post(cr, uid, thread_id=thread_id, body=body, subject=subject, type='comment',
715 subtype='mail.mt_comment', parent_id=parent_id, context=context)
717 # HACK FIXME: Chatter: attachments linked to the document (not done JS-side), load the message
719 ir_attachment = self.pool.get('ir.attachment')
720 mail_message = self.pool.get('mail.message')
721 filtered_attachment_ids = ir_attachment.search(cr, SUPERUSER_ID, [
722 ('res_model', '=', 'mail.compose.message'),
724 ('create_uid', '=', uid),
725 ('id', 'in', attachment_ids)], context=context)
726 if filtered_attachment_ids:
727 ir_attachment.write(cr, SUPERUSER_ID, attachment_ids, {'res_model': self._name, 'res_id': thread_id}, context=context)
728 mail_message.write(cr, SUPERUSER_ID, [new_message_id], {'attachment_ids': [(6, 0, [pid for pid in attachment_ids])]}, context=context)
730 return new_message_id
732 #------------------------------------------------------
734 #------------------------------------------------------
736 def message_get_subscription_data(self, cr, uid, ids, context=None):
737 """ Wrapper to get subtypes data. """
738 return self._get_subscription_data(cr, uid, ids, None, None, context=context)
740 def message_subscribe_users(self, cr, uid, ids, user_ids=None, subtype_ids=None, context=None):
741 """ Wrapper on message_subscribe, using users. If user_ids is not
742 provided, subscribe uid instead. """
745 partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
746 return self.message_subscribe(cr, uid, ids, partner_ids, subtype_ids=subtype_ids, context=context)
748 def message_subscribe(self, cr, uid, ids, partner_ids, subtype_ids=None, context=None):
749 """ Add partners to the records followers. """
750 self.write(cr, uid, ids, {'message_follower_ids': [(4, pid) for pid in partner_ids]}, context=context)
751 # if subtypes are not specified (and not set to a void list), fetch default ones
752 if subtype_ids is None:
753 subtype_obj = self.pool.get('mail.message.subtype')
754 subtype_ids = subtype_obj.search(cr, uid, [('default', '=', True), '|', ('res_model', '=', self._name), ('res_model', '=', False)], context=context)
755 # update the subscriptions
756 fol_obj = self.pool.get('mail.followers')
757 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids), ('partner_id', 'in', partner_ids)], context=context)
758 fol_obj.write(cr, SUPERUSER_ID, fol_ids, {'subtype_ids': [(6, 0, subtype_ids)]}, context=context)
761 def message_unsubscribe_users(self, cr, uid, ids, user_ids=None, context=None):
762 """ Wrapper on message_subscribe, using users. If user_ids is not
763 provided, unsubscribe uid instead. """
766 partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
767 return self.message_unsubscribe(cr, uid, ids, partner_ids, context=context)
769 def message_unsubscribe(self, cr, uid, ids, partner_ids, context=None):
770 """ Remove partners from the records followers. """
771 return self.write(cr, uid, ids, {'message_follower_ids': [(3, pid) for pid in partner_ids]}, context=context)
773 #------------------------------------------------------
775 #------------------------------------------------------
777 def message_mark_as_unread(self, cr, uid, ids, context=None):
778 """ Set as unread. """
779 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
781 UPDATE mail_notification SET
784 message_id IN (SELECT id from mail_message where res_id=any(%s) and model=%s limit 1) and
786 ''', (ids, self._name, partner_id))
789 def message_mark_as_read(self, cr, uid, ids, context=None):
791 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
793 UPDATE mail_notification SET
796 message_id IN (SELECT id FROM mail_message WHERE res_id=ANY(%s) AND model=%s) AND
798 ''', (ids, self._name, partner_id))
801 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: