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 or n.read IS NULL))
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_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_message_unread(self, cr, uid, obj=None, name=None, domain=None, context=None):
125 return [('message_ids.to_read', '=', True)]
127 def _get_followers(self, cr, uid, ids, name, arg, context=None):
128 fol_obj = self.pool.get('mail.followers')
129 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids)])
130 res = dict((id, dict(message_follower_ids=[], message_is_follower=False)) for id in ids)
131 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
132 for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids):
133 res[fol.res_id]['message_follower_ids'].append(fol.partner_id.id)
134 if fol.partner_id.id == user_pid:
135 res[fol.res_id]['message_is_follower'] = True
138 def _set_followers(self, cr, uid, id, name, value, arg, context=None):
141 partner_obj = self.pool.get('res.partner')
142 fol_obj = self.pool.get('mail.followers')
144 # read the old set of followers, and determine the new set of followers
145 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', '=', id)])
146 old = set(fol.partner_id.id for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids))
149 for command in value or []:
150 if isinstance(command, (int, long)):
152 elif command[0] == 0:
153 new.add(partner_obj.create(cr, uid, command[2], context=context))
154 elif command[0] == 1:
155 partner_obj.write(cr, uid, [command[1]], command[2], context=context)
157 elif command[0] == 2:
158 partner_obj.unlink(cr, uid, [command[1]], context=context)
159 new.discard(command[1])
160 elif command[0] == 3:
161 new.discard(command[1])
162 elif command[0] == 4:
164 elif command[0] == 5:
166 elif command[0] == 6:
167 new = set(command[2])
169 # remove partners that are no longer followers
170 fol_ids = fol_obj.search(cr, SUPERUSER_ID,
171 [('res_model', '=', self._name), ('res_id', '=', id), ('partner_id', 'not in', list(new))])
172 fol_obj.unlink(cr, SUPERUSER_ID, fol_ids)
175 for partner_id in new - old:
176 fol_obj.create(cr, SUPERUSER_ID, {'res_model': self._name, 'res_id': id, 'partner_id': partner_id})
178 def _search_followers(self, cr, uid, obj, name, args, context):
179 fol_obj = self.pool.get('mail.followers')
181 for field, operator, value in args:
183 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('partner_id', operator, value)])
184 res_ids = [fol.res_id for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids)]
185 res.append(('id', 'in', res_ids))
189 'message_is_follower': fields.function(_get_followers,
190 type='boolean', string='Is a Follower', multi='_get_followers,'),
191 'message_follower_ids': fields.function(_get_followers, fnct_inv=_set_followers,
192 fnct_search=_search_followers, type='many2many',
193 obj='res.partner', string='Followers', multi='_get_followers'),
194 'message_ids': fields.one2many('mail.message', 'res_id',
195 domain=lambda self: [('model', '=', self._name)],
198 help="Messages and communication history"),
199 'message_unread': fields.function(_get_message_data,
200 fnct_search=_search_message_unread, multi="_get_message_data",
201 type='boolean', string='Unread Messages',
202 help="If checked new messages require your attention."),
203 'message_summary': fields.function(_get_message_data, method=True,
204 type='text', string='Summary', multi="_get_message_data",
205 help="Holds the Chatter summary (number of messages, ...). "\
206 "This summary is directly in html format in order to "\
207 "be inserted in kanban views."),
210 #------------------------------------------------------
211 # Automatic subscription when creating
212 #------------------------------------------------------
214 def create(self, cr, uid, vals, context=None):
215 """ Override to subscribe the current user. """
218 thread_id = super(mail_thread, self).create(cr, uid, vals, context=context)
219 if not context.get('mail_nosubscribe'):
220 self.message_subscribe_users(cr, uid, [thread_id], [uid], context=context)
223 def unlink(self, cr, uid, ids, context=None):
224 """ Override unlink to delete messages and followers. This cannot be
225 cascaded, because link is done through (res_model, res_id). """
226 msg_obj = self.pool.get('mail.message')
227 fol_obj = self.pool.get('mail.followers')
228 # delete messages and notifications
229 msg_ids = msg_obj.search(cr, uid, [('model', '=', self._name), ('res_id', 'in', ids)], context=context)
230 msg_obj.unlink(cr, uid, msg_ids, context=context)
232 res = super(mail_thread, self).unlink(cr, uid, ids, context=context)
234 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids)], context=context)
235 fol_obj.unlink(cr, SUPERUSER_ID, fol_ids, context=context)
238 def copy(self, cr, uid, id, default=None, context=None):
239 default = default or {}
240 default['message_ids'] = []
241 default['message_follower_ids'] = []
242 return super(mail_thread, self).copy(cr, uid, id, default=default, context=context)
244 #------------------------------------------------------
245 # mail.message wrappers and tools
246 #------------------------------------------------------
248 def _needaction_domain_get(self, cr, uid, context=None):
250 return [('message_unread', '=', True)]
253 #------------------------------------------------------
255 #------------------------------------------------------
257 def message_capable_models(self, cr, uid, context=None):
258 """ Used by the plugin addon, based for plugin_outlook and others. """
260 for model_name in self.pool.obj_list():
261 model = self.pool.get(model_name)
262 if 'mail.thread' in getattr(model, '_inherit', []):
263 ret_dict[model_name] = model._description
266 def _message_find_partners(self, cr, uid, message, header_fields=['From'], context=None):
267 """ Find partners related to some header fields of the message. """
268 s = ', '.join([decode(message.get(h)) for h in header_fields if message.get(h)])
269 return [partner_id for email in tools.email_split(s)
270 for partner_id in self.pool.get('res.partner').search(cr, uid, [('email', 'ilike', email)], context=context)]
272 def _message_find_user_id(self, cr, uid, message, context=None):
273 from_local_part = tools.email_split(decode(message.get('From')))[0]
274 # FP Note: canonification required, the minimu: .lower()
275 user_ids = self.pool.get('res.users').search(cr, uid, ['|',
276 ('login', '=', from_local_part),
277 ('email', '=', from_local_part)], context=context)
278 return user_ids[0] if user_ids else uid
280 def message_route(self, cr, uid, message, model=None, thread_id=None,
281 custom_values=None, context=None):
282 """Attempt to figure out the correct target model, thread_id,
283 custom_values and user_id to use for an incoming message.
284 Multiple values may be returned, if a message had multiple
285 recipients matching existing mail.aliases, for example.
287 The following heuristics are used, in this order:
288 1. If the message replies to an existing thread_id, and
289 properly contains the thread model in the 'In-Reply-To'
290 header, use this model/thread_id pair, and ignore
291 custom_value (not needed as no creation will take place)
292 2. Look for a mail.alias entry matching the message
293 recipient, and use the corresponding model, thread_id,
294 custom_values and user_id.
295 3. Fallback to the ``model``, ``thread_id`` and ``custom_values``
297 4. If all the above fails, raise an exception.
299 :param string message: an email.message instance
300 :param string model: the fallback model to use if the message
301 does not match any of the currently configured mail aliases
302 (may be None if a matching alias is supposed to be present)
303 :type dict custom_values: optional dictionary of default field values
304 to pass to ``message_new`` if a new record needs to be created.
305 Ignored if the thread record already exists, and also if a
306 matching mail.alias was found (aliases define their own defaults)
307 :param int thread_id: optional ID of the record/thread from ``model``
308 to which this mail should be attached. Only used if the message
309 does not reply to an existing thread and does not match any mail alias.
310 :return: list of [model, thread_id, custom_values, user_id]
312 assert isinstance(message, Message), 'message must be an email.message.Message at this point'
313 message_id = message.get('Message-Id')
314 references = decode_header(message, 'References')
315 in_reply_to = decode_header(message, 'In-Reply-To')
317 # 1. Verify if this is a reply to an existing thread
318 thread_references = references or in_reply_to
319 ref_match = thread_references and tools.reference_re.search(thread_references)
321 thread_id = int(ref_match.group(1))
322 model = ref_match.group(2) or model
323 model_pool = self.pool.get(model)
324 if thread_id and model and model_pool and model_pool.exists(cr, uid, thread_id) \
325 and hasattr(model_pool, 'message_update'):
326 _logger.debug('Routing mail with Message-Id %s: direct reply to model: %s, thread_id: %s, custom_values: %s, uid: %s',
327 message_id, model, thread_id, custom_values, uid)
328 return [(model, thread_id, custom_values, uid)]
330 # Verify this is a reply to a private message
331 message_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', '=', in_reply_to)], limit=1, context=context)
333 message = self.pool.get('mail.message').browse(cr, uid, message_ids[0], context=context)
334 _logger.debug('Routing mail with Message-Id %s: direct reply to a private message: %s, custom_values: %s, uid: %s',
335 message_id, message.id, custom_values, uid)
336 return [(message.model, message.res_id, custom_values, uid)]
338 # 2. Look for a matching mail.alias entry
339 # Delivered-To is a safe bet in most modern MTAs, but we have to fallback on To + Cc values
340 # for all the odd MTAs out there, as there is no standard header for the envelope's `rcpt_to` value.
341 rcpt_tos = decode_header(message, 'Delivered-To') or \
342 ','.join([decode_header(message, 'To'),
343 decode_header(message, 'Cc'),
344 decode_header(message, 'Resent-To'),
345 decode_header(message, 'Resent-Cc')])
346 local_parts = [e.split('@')[0] for e in tools.email_split(rcpt_tos)]
348 mail_alias = self.pool.get('mail.alias')
349 alias_ids = mail_alias.search(cr, uid, [('alias_name', 'in', local_parts)])
352 for alias in mail_alias.browse(cr, uid, alias_ids, context=context):
353 user_id = alias.alias_user_id.id
355 user_id = self._message_find_user_id(cr, uid, message, context=context)
356 routes.append((alias.alias_model_id.model, alias.alias_force_thread_id, \
357 eval(alias.alias_defaults), user_id))
358 _logger.debug('Routing mail with Message-Id %s: direct alias match: %r', message_id, routes)
361 # 3. Fallback to the provided parameters, if they work
362 model_pool = self.pool.get(model)
364 # Legacy: fallback to matching [ID] in the Subject
365 match = tools.res_re.search(decode_header(message, 'Subject'))
366 thread_id = match and match.group(1)
367 assert thread_id and hasattr(model_pool, 'message_update') or hasattr(model_pool, 'message_new'), \
368 "No possible route found for incoming message with Message-Id %s. " \
369 "Create an appropriate mail.alias or force the destination model." % message_id
370 if thread_id and not model_pool.exists(cr, uid, thread_id):
371 _logger.warning('Received mail reply to missing document %s! Ignoring and creating new document instead for Message-Id %s',
372 thread_id, message_id)
374 _logger.debug('Routing mail with Message-Id %s: fallback to model:%s, thread_id:%s, custom_values:%s, uid:%s',
375 message_id, model, thread_id, custom_values, uid)
376 return [(model, thread_id, custom_values, uid)]
378 def message_process(self, cr, uid, model, message, custom_values=None,
379 save_original=False, strip_attachments=False,
380 thread_id=None, context=None):
381 """ Process an incoming RFC2822 email message, relying on
382 ``mail.message.parse()`` for the parsing operation,
383 and ``message_route()`` to figure out the target model.
385 Once the target model is known, its ``message_new`` method
386 is called with the new message (if the thread record did not exist)
387 or its ``message_update`` method (if it did).
389 There is a special case where the target model is False: a reply
390 to a private message. In this case, we skip the message_new /
391 message_update step, to just post a new message using mail_thread
394 :param string model: the fallback model to use if the message
395 does not match any of the currently configured mail aliases
396 (may be None if a matching alias is supposed to be present)
397 :param message: source of the RFC2822 message
398 :type message: string or xmlrpclib.Binary
399 :type dict custom_values: optional dictionary of field values
400 to pass to ``message_new`` if a new record needs to be created.
401 Ignored if the thread record already exists, and also if a
402 matching mail.alias was found (aliases define their own defaults)
403 :param bool save_original: whether to keep a copy of the original
404 email source attached to the message after it is imported.
405 :param bool strip_attachments: whether to strip all attachments
406 before processing the message, in order to save some space.
407 :param int thread_id: optional ID of the record/thread from ``model``
408 to which this mail should be attached. When provided, this
409 overrides the automatic detection based on the message
415 # extract message bytes - we are forced to pass the message as binary because
416 # we don't know its encoding until we parse its headers and hence can't
417 # convert it to utf-8 for transport between the mailgate script and here.
418 if isinstance(message, xmlrpclib.Binary):
419 message = str(message.data)
420 # Warning: message_from_string doesn't always work correctly on unicode,
421 # we must use utf-8 strings here :-(
422 if isinstance(message, unicode):
423 message = message.encode('utf-8')
424 msg_txt = email.message_from_string(message)
425 routes = self.message_route(cr, uid, msg_txt, model,
426 thread_id, custom_values,
428 msg = self.message_parse(cr, uid, msg_txt, save_original=save_original, context=context)
429 if strip_attachments:
430 msg.pop('attachments', None)
432 for model, thread_id, custom_values, user_id in routes:
433 if self._name != model:
434 context.update({'thread_model': model})
436 model_pool = self.pool.get(model)
437 assert thread_id and hasattr(model_pool, 'message_update') or hasattr(model_pool, 'message_new'), \
438 "Undeliverable mail with Message-Id %s, model %s does not accept incoming emails" % \
439 (msg['message_id'], model)
440 if thread_id and hasattr(model_pool, 'message_update'):
441 model_pool.message_update(cr, user_id, [thread_id], msg, context=context)
443 thread_id = model_pool.message_new(cr, user_id, msg, custom_values, context=context)
445 assert thread_id == 0, "Posting a message without model should be with a null res_id, to create a private message."
446 model_pool = self.pool.get('mail.thread')
447 model_pool.message_post_user_api(cr, uid, [thread_id], context=context, content_subtype='html', **msg)
450 def message_new(self, cr, uid, msg_dict, custom_values=None, context=None):
451 """Called by ``message_process`` when a new message is received
452 for a given thread model, if the message did not belong to
454 The default behavior is to create a new record of the corresponding
455 model (based on some very basic info extracted from the message).
456 Additional behavior may be implemented by overriding this method.
458 :param dict msg_dict: a map containing the email details and
459 attachments. See ``message_process`` and
460 ``mail.message.parse`` for details.
461 :param dict custom_values: optional dictionary of additional
462 field values to pass to create()
463 when creating the new thread record.
464 Be careful, these values may override
465 any other values coming from the message.
466 :param dict context: if a ``thread_model`` value is present
467 in the context, its value will be used
468 to determine the model of the record
469 to create (instead of the current model).
471 :return: the id of the newly created thread object
475 model = context.get('thread_model') or self._name
476 model_pool = self.pool.get(model)
477 fields = model_pool.fields_get(cr, uid, context=context)
478 data = model_pool.default_get(cr, uid, fields, context=context)
479 if 'name' in fields and not data.get('name'):
480 data['name'] = msg_dict.get('subject', '')
481 if custom_values and isinstance(custom_values, dict):
482 data.update(custom_values)
483 res_id = model_pool.create(cr, uid, data, context=context)
486 def message_update(self, cr, uid, ids, msg_dict, update_vals=None, context=None):
487 """Called by ``message_process`` when a new message is received
488 for an existing thread. The default behavior is to update the record
489 with update_vals taken from the incoming email.
490 Additional behavior may be implemented by overriding this
492 :param dict msg_dict: a map containing the email details and
493 attachments. See ``message_process`` and
494 ``mail.message.parse()`` for details.
495 :param dict update_vals: a dict containing values to update records
496 given their ids; if the dict is None or is
497 void, no write operation is performed.
500 self.write(cr, uid, ids, update_vals, context=context)
503 def _message_extract_payload(self, message, save_original=False):
504 """Extract body as HTML and attachments from the mail message"""
508 attachments.append(('original_email.eml', message.as_string()))
509 if not message.is_multipart() or 'text/' in message.get('content-type', ''):
510 encoding = message.get_content_charset()
511 body = message.get_payload(decode=True)
512 body = tools.ustr(body, encoding, errors='replace')
513 if message.get_content_type() == 'text/plain':
514 # text/plain -> <pre/>
515 body = tools.append_content_to_html(u'', body, preserve=True)
517 alternative = (message.get_content_type() == 'multipart/alternative')
518 for part in message.walk():
519 if part.get_content_maintype() == 'multipart':
520 continue # skip container
521 filename = part.get_filename() # None if normal part
522 encoding = part.get_content_charset() # None if attachment
523 # 1) Explicit Attachments -> attachments
524 if filename or part.get('content-disposition', '').strip().startswith('attachment'):
525 attachments.append((filename or 'attachment', part.get_payload(decode=True)))
527 # 2) text/plain -> <pre/>
528 if part.get_content_type() == 'text/plain' and (not alternative or not body):
529 body = tools.append_content_to_html(body, tools.ustr(part.get_payload(decode=True),
530 encoding, errors='replace'), preserve=True)
531 # 3) text/html -> raw
532 elif part.get_content_type() == 'text/html':
533 html = tools.ustr(part.get_payload(decode=True), encoding, errors='replace')
537 body = tools.append_content_to_html(body, html, plaintext=False)
538 # 4) Anything else -> attachment
540 attachments.append((filename or 'attachment', part.get_payload(decode=True)))
541 return body, attachments
543 def message_parse(self, cr, uid, message, save_original=False, context=None):
544 """Parses a string or email.message.Message representing an
545 RFC-2822 email, and returns a generic dict holding the
548 :param message: the message to parse
549 :type message: email.message.Message | string | unicode
550 :param bool save_original: whether the returned dict
551 should include an ``original`` attachment containing
552 the source of the message
554 :return: A dict with the following structure, where each
555 field may not be present if missing in original
558 { 'message_id': msg_id,
563 'body': unified_body,
564 'attachments': [('file1', 'bytes'),
572 if not isinstance(message, Message):
573 if isinstance(message, unicode):
574 # Warning: message_from_string doesn't always work correctly on unicode,
575 # we must use utf-8 strings here :-(
576 message = message.encode('utf-8')
577 message = email.message_from_string(message)
579 message_id = message['message-id']
581 # Very unusual situation, be we should be fault-tolerant here
582 message_id = "<%s@localhost>" % time.time()
583 _logger.debug('Parsing Message without message-id, generating a random one: %s', message_id)
584 msg_dict['message_id'] = message_id
586 if 'Subject' in message:
587 msg_dict['subject'] = decode(message.get('Subject'))
589 # Envelope fields not stored in mail.message but made available for message_new()
590 msg_dict['from'] = decode(message.get('from'))
591 msg_dict['to'] = decode(message.get('to'))
592 msg_dict['cc'] = decode(message.get('cc'))
594 if 'From' in message:
595 author_ids = self._message_find_partners(cr, uid, message, ['From'], context=context)
597 msg_dict['author_id'] = author_ids[0]
599 msg_dict['email_from'] = message.get('from')
600 partner_ids = self._message_find_partners(cr, uid, message, ['From', 'To', 'Cc'], context=context)
601 msg_dict['partner_ids'] = [(4, partner_id) for partner_id in partner_ids]
603 if 'Date' in message:
604 date_hdr = decode(message.get('Date'))
605 # convert from email timezone to server timezone
606 date_server_datetime = dateutil.parser.parse(date_hdr).astimezone(pytz.timezone(tools.get_server_timezone()))
607 date_server_datetime_str = date_server_datetime.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT)
608 msg_dict['date'] = date_server_datetime_str
610 if 'In-Reply-To' in message:
611 parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', '=', decode(message['In-Reply-To']))])
613 msg_dict['parent_id'] = parent_ids[0]
615 if 'References' in message and 'parent_id' not in msg_dict:
616 parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', 'in',
617 [x.strip() for x in decode(message['References']).split()])])
619 msg_dict['parent_id'] = parent_ids[0]
621 msg_dict['body'], msg_dict['attachments'] = self._message_extract_payload(message)
624 #------------------------------------------------------
626 #------------------------------------------------------
628 def log(self, cr, uid, id, message, secondary=False, context=None):
629 _logger.warning("log() is deprecated. As this module inherit from "\
630 "mail.thread, the message will be managed by this "\
631 "module instead of by the res.log mechanism. Please "\
632 "use mail_thread.message_post() instead of the "\
633 "now deprecated res.log.")
634 self.message_post(cr, uid, [id], message, context=context)
636 def message_post(self, cr, uid, thread_id, body='', subject=None, type='notification',
637 subtype=None, parent_id=False, attachments=None, context=None, **kwargs):
638 """ Post a new message in an existing thread, returning the new
639 mail.message ID. Extra keyword arguments will be used as default
640 column values for the new mail.message record.
641 Auto link messages for same id and object
642 :param int thread_id: thread ID to post into, or list with one ID;
643 if False/0, mail.message model will also be set as False
644 :param str body: body of the message, usually raw HTML that will
646 :param str subject: optional subject
647 :param str type: mail_message.type
648 :param int parent_id: optional ID of parent message in this thread
649 :param tuple(str,str) attachments or list id: list of attachment tuples in the form
650 ``(name,content)``, where content is NOT base64 encoded
651 :return: ID of newly created mail.message
655 if attachments is None:
658 assert (not thread_id) or isinstance(thread_id, (int, long)) or \
659 (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"
660 if isinstance(thread_id, (list, tuple)):
661 thread_id = thread_id and thread_id[0]
662 mail_message = self.pool.get('mail.message')
663 model = context.get('thread_model', self._name) if thread_id else False
666 for name, content in attachments:
667 if isinstance(content, unicode):
668 content = content.encode('utf-8')
671 'datas': base64.b64encode(str(content)),
674 'res_model': context.get('thread_model') or self._name,
677 attachment_ids.append((0, 0, data_attach))
681 s_data = subtype.split('.')
683 s_data = ('mail', s_data[0])
684 ref = self.pool.get('ir.model.data').get_object_reference(cr, uid, s_data[0], s_data[1])
685 subtype_id = ref and ref[1] or False
689 # _mail_flat_thread: automatically set free messages to the first posted message
690 if self._mail_flat_thread and not parent_id and thread_id:
691 message_ids = mail_message.search(cr, uid, ['&', ('res_id', '=', thread_id), ('model', '=', model)], context=context, order="id ASC", limit=1)
692 parent_id = message_ids and message_ids[0] or False
693 # 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
695 message_ids = mail_message.search(cr, SUPERUSER_ID, [('id', '=', parent_id), ('parent_id', '!=', False)], context=context)
696 # avoid loops when finding ancestors
699 message = mail_message.browse(cr, SUPERUSER_ID, message_ids[0], context=context)
700 while (message.parent_id and message.parent_id.id not in processed_list):
701 processed_list.append(message.parent_id.id)
702 message = message.parent_id
703 parent_id = message.id
708 'res_id': thread_id or False,
710 'subject': subject or False,
712 'parent_id': parent_id,
713 'attachment_ids': attachment_ids,
714 'subtype_id': subtype_id,
717 # Avoid warnings about non-existing fields
718 for x in ('from', 'to', 'cc'):
721 return mail_message.create(cr, uid, values, context=context)
723 def message_post_user_api(self, cr, uid, thread_id, body='', subject=False, parent_id=False,
724 attachment_ids=None, context=None, content_subtype='plaintext', **kwargs):
725 """ Wrapper on message_post, used for user input :
727 - quick reply in Chatter (refer to mail.js), not
728 the mail.compose.message wizard
729 The purpose is to perform some pre- and post-processing:
730 - if body is plaintext: convert it into html
731 - if parent_id: handle reply to a previous message by adding the
732 parent partners to the message
733 - type and subtype: comment and mail.mt_comment by default
734 - attachment_ids: supposed not attached to any document; attach them
735 to the related document. Should only be set by Chatter.
737 ir_attachment = self.pool.get('ir.attachment')
738 mail_message = self.pool.get('mail.message')
740 # 1. Pre-processing: body, partner_ids, type and subtype
741 if content_subtype == 'plaintext':
742 body = tools.plaintext2html(body)
744 partner_ids = kwargs.pop('partner_ids', [])
746 parent_message = self.pool.get('mail.message').browse(cr, uid, parent_id, context=context)
747 partner_ids += [(4, partner.id) for partner in parent_message.partner_ids]
748 # TDE FIXME HACK: mail.thread -> private message
749 if self._name == 'mail.thread' and parent_message.author_id.id:
750 partner_ids.append((4, parent_message.author_id.id))
752 message_type = kwargs.pop('type', 'comment')
753 message_subtype = kwargs.pop('subtype', 'mail.mt_comment')
756 new_message_id = self.message_post(cr, uid, thread_id=thread_id, body=body, subject=subject, type=message_type,
757 subtype=message_subtype, parent_id=parent_id, context=context, partner_ids=partner_ids, **kwargs)
760 # HACK TDE FIXME: Chatter: attachments linked to the document (not done JS-side), load the message
762 # TDE FIXME (?): when posting a private message, we use mail.thread as a model
763 # However, attaching doc to mail.thread is not possible, mail.thread does not have any table
765 if model == 'mail.thread':
767 filtered_attachment_ids = ir_attachment.search(cr, SUPERUSER_ID, [
768 ('res_model', '=', 'mail.compose.message'),
770 ('create_uid', '=', uid),
771 ('id', 'in', attachment_ids)], context=context)
772 if filtered_attachment_ids:
773 if thread_id and model:
774 ir_attachment.write(cr, SUPERUSER_ID, attachment_ids, {'res_model': model, 'res_id': thread_id}, context=context)
775 mail_message.write(cr, SUPERUSER_ID, [new_message_id], {'attachment_ids': [(6, 0, [pid for pid in attachment_ids])]}, context=context)
777 return new_message_id
779 #------------------------------------------------------
781 #------------------------------------------------------
783 def message_get_subscription_data(self, cr, uid, ids, context=None):
784 """ Wrapper to get subtypes data. """
785 return self._get_subscription_data(cr, uid, ids, None, None, context=context)
787 def message_subscribe_users(self, cr, uid, ids, user_ids=None, subtype_ids=None, context=None):
788 """ Wrapper on message_subscribe, using users. If user_ids is not
789 provided, subscribe uid instead. """
792 partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
793 return self.message_subscribe(cr, uid, ids, partner_ids, subtype_ids=subtype_ids, context=context)
795 def message_subscribe(self, cr, uid, ids, partner_ids, subtype_ids=None, context=None):
796 """ Add partners to the records followers. """
797 self.check_access_rights(cr, uid, 'read')
798 self.write(cr, SUPERUSER_ID, ids, {'message_follower_ids': [(4, pid) for pid in partner_ids]}, context=context)
799 # if subtypes are not specified (and not set to a void list), fetch default ones
800 if subtype_ids is None:
801 subtype_obj = self.pool.get('mail.message.subtype')
802 subtype_ids = subtype_obj.search(cr, SUPERUSER_ID, [('default', '=', True), '|', ('res_model', '=', self._name), ('res_model', '=', False)], context=context)
803 # update the subscriptions
804 fol_obj = self.pool.get('mail.followers')
805 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids), ('partner_id', 'in', partner_ids)], context=context)
806 fol_obj.write(cr, SUPERUSER_ID, fol_ids, {'subtype_ids': [(6, 0, subtype_ids)]}, context=context)
809 def message_unsubscribe_users(self, cr, uid, ids, user_ids=None, context=None):
810 """ Wrapper on message_subscribe, using users. If user_ids is not
811 provided, unsubscribe uid instead. """
814 partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
815 return self.message_unsubscribe(cr, uid, ids, partner_ids, context=context)
817 def message_unsubscribe(self, cr, uid, ids, partner_ids, context=None):
818 """ Remove partners from the records followers. """
819 self.check_access_rights(cr, uid, 'read')
820 return self.write(cr, SUPERUSER_ID, 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: