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 ##############################################################################
23 from collections import defaultdict
26 from functools import partial
30 from openerp import tools
33 from mako.template import Template as MakoTemplate
35 from email.message import Message
36 from mail_message import decode
37 from openerp import SUPERUSER_ID
38 from openerp.osv import fields, osv
39 from openerp.osv.orm import browse_record
40 from openerp.tools.safe_eval import safe_eval as eval
41 from tools.translate import _
43 _logger = logging.getLogger(__name__)
46 def decode_header(message, header, separator=' '):
47 return separator.join(map(decode, message.get_all(header, [])))
50 class mail_thread(osv.AbstractModel):
51 ''' mail_thread model is meant to be inherited by any model that needs to
52 act as a discussion topic on which messages can be attached. Public
53 methods are prefixed with ``message_`` in order to avoid name
54 collisions with methods of the models that will inherit from this class.
56 ``mail.thread`` defines fields used to handle and display the
57 communication history. ``mail.thread`` also manages followers of
58 inheriting classes. All features and expected behavior are managed
59 by mail.thread. Widgets has been designed for the 7.0 and following
62 Inheriting classes are not required to implement any method, as the
63 default implementation will work for any model. However it is common
64 to override at least the ``message_new`` and ``message_update``
65 methods (calling ``super``) to add model-specific behavior at
66 creation and update of a thread when processing incoming emails.
69 - _mail_flat_thread: if set to True, all messages without parent_id
70 are automatically attached to the first message posted on the
71 ressource. If set to False, the display of Chatter is done using
72 threads, and no parent_id is automatically set.
75 _description = 'Email Thread'
76 _mail_flat_thread = True
78 <span>${updated_fields}</span>
81 <li><span>${chg[0]}</span>: ${chg[1]} -> ${chg[2]}</li>
86 def _get_message_data(self, cr, uid, ids, name, args, context=None):
88 - message_unread: has uid unread message for the document
89 - message_summary: html snippet summarizing the Chatter for kanban views """
90 res = dict((id, dict(message_unread=False, message_summary='')) for id in ids)
91 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
93 # search for unread messages, directly in SQL to improve performances
94 cr.execute(""" SELECT m.res_id FROM mail_message m
95 RIGHT JOIN mail_notification n
96 ON (n.message_id = m.id AND n.partner_id = %s AND (n.read = False or n.read IS NULL))
97 WHERE m.model = %s AND m.res_id in %s""",
98 (user_pid, self._name, tuple(ids),))
99 msg_ids = [result[0] for result in cr.fetchall()]
100 for msg_id in msg_ids:
101 res[msg_id]['message_unread'] = True
103 for thread in self.browse(cr, uid, ids, context=context):
104 cls = res[thread.id]['message_unread'] and ' class="oe_kanban_mail_new"' or ''
105 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))
109 def _get_subscription_data(self, cr, uid, ids, name, args, context=None):
111 - message_subtype_data: data about document subtypes: which are
112 available, which are followed if any """
113 res = dict((id, dict(message_subtype_data='')) for id in ids)
114 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
116 # find current model subtypes, add them to a dictionary
117 subtype_obj = self.pool.get('mail.message.subtype')
118 subtype_ids = subtype_obj.search(cr, uid, ['|', ('res_model', '=', self._name), ('res_model', '=', False)], context=context)
119 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))
121 res[id]['message_subtype_data'] = subtype_dict.copy()
123 # find the document followers, update the data
124 fol_obj = self.pool.get('mail.followers')
125 fol_ids = fol_obj.search(cr, uid, [
126 ('partner_id', '=', user_pid),
127 ('res_id', 'in', ids),
128 ('res_model', '=', self._name),
130 for fol in fol_obj.browse(cr, uid, fol_ids, context=context):
131 thread_subtype_dict = res[fol.res_id]['message_subtype_data']
132 for subtype in fol.subtype_ids:
133 thread_subtype_dict[subtype.name]['followed'] = True
134 res[fol.res_id]['message_subtype_data'] = thread_subtype_dict
138 def _search_message_unread(self, cr, uid, obj=None, name=None, domain=None, context=None):
139 return [('message_ids.to_read', '=', True)]
141 def _get_followers(self, cr, uid, ids, name, arg, context=None):
142 fol_obj = self.pool.get('mail.followers')
143 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids)])
144 res = dict((id, dict(message_follower_ids=[], message_is_follower=False)) for id in ids)
145 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
146 for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids):
147 res[fol.res_id]['message_follower_ids'].append(fol.partner_id.id)
148 if fol.partner_id.id == user_pid:
149 res[fol.res_id]['message_is_follower'] = True
152 def _set_followers(self, cr, uid, id, name, value, arg, context=None):
155 partner_obj = self.pool.get('res.partner')
156 fol_obj = self.pool.get('mail.followers')
158 # read the old set of followers, and determine the new set of followers
159 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', '=', id)])
160 old = set(fol.partner_id.id for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids))
163 for command in value or []:
164 if isinstance(command, (int, long)):
166 elif command[0] == 0:
167 new.add(partner_obj.create(cr, uid, command[2], context=context))
168 elif command[0] == 1:
169 partner_obj.write(cr, uid, [command[1]], command[2], context=context)
171 elif command[0] == 2:
172 partner_obj.unlink(cr, uid, [command[1]], context=context)
173 new.discard(command[1])
174 elif command[0] == 3:
175 new.discard(command[1])
176 elif command[0] == 4:
178 elif command[0] == 5:
180 elif command[0] == 6:
181 new = set(command[2])
183 # remove partners that are no longer followers
184 fol_ids = fol_obj.search(cr, SUPERUSER_ID,
185 [('res_model', '=', self._name), ('res_id', '=', id), ('partner_id', 'not in', list(new))])
186 fol_obj.unlink(cr, SUPERUSER_ID, fol_ids)
189 for partner_id in new - old:
190 fol_obj.create(cr, SUPERUSER_ID, {'res_model': self._name, 'res_id': id, 'partner_id': partner_id})
192 def _search_followers(self, cr, uid, obj, name, args, context):
193 fol_obj = self.pool.get('mail.followers')
195 for field, operator, value in args:
197 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('partner_id', operator, value)])
198 res_ids = [fol.res_id for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids)]
199 res.append(('id', 'in', res_ids))
203 'message_is_follower': fields.function(_get_followers,
204 type='boolean', string='Is a Follower', multi='_get_followers,'),
205 'message_follower_ids': fields.function(_get_followers, fnct_inv=_set_followers,
206 fnct_search=_search_followers, type='many2many',
207 obj='res.partner', string='Followers', multi='_get_followers'),
208 'message_ids': fields.one2many('mail.message', 'res_id',
209 domain=lambda self: [('model', '=', self._name)],
212 help="Messages and communication history"),
213 'message_unread': fields.function(_get_message_data,
214 fnct_search=_search_message_unread, multi="_get_message_data",
215 type='boolean', string='Unread Messages',
216 help="If checked new messages require your attention."),
217 'message_summary': fields.function(_get_message_data, method=True,
218 type='text', string='Summary', multi="_get_message_data",
219 help="Holds the Chatter summary (number of messages, ...). "\
220 "This summary is directly in html format in order to "\
221 "be inserted in kanban views."),
224 #------------------------------------------------------
225 # Automatic subscription when creating
226 #------------------------------------------------------
228 def create(self, cr, uid, vals, context=None):
229 """ Override to subscribe the current user. """
232 thread_id = super(mail_thread, self).create(cr, uid, vals, context=context)
233 if not context.get('mail_nosubscribe'):
234 self.message_subscribe_users(cr, uid, [thread_id], [uid], context=context)
237 def unlink(self, cr, uid, ids, context=None):
238 """ Override unlink to delete messages and followers. This cannot be
239 cascaded, because link is done through (res_model, res_id). """
240 msg_obj = self.pool.get('mail.message')
241 fol_obj = self.pool.get('mail.followers')
242 # delete messages and notifications
243 msg_ids = msg_obj.search(cr, uid, [('model', '=', self._name), ('res_id', 'in', ids)], context=context)
244 msg_obj.unlink(cr, uid, msg_ids, context=context)
246 res = super(mail_thread, self).unlink(cr, uid, ids, context=context)
248 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids)], context=context)
249 fol_obj.unlink(cr, SUPERUSER_ID, fol_ids, context=context)
252 def copy(self, cr, uid, id, default=None, context=None):
253 default = default or {}
254 default['message_ids'] = []
255 default['message_follower_ids'] = []
256 return super(mail_thread, self).copy(cr, uid, id, default=default, context=context)
258 #------------------------------------------------------
259 # Automatically log tracked fields
260 #------------------------------------------------------
262 def write(self, cr, uid, ids, values, context=None):
266 #import pudb;pudb.set_trace()
269 if f._type == 'boolean':
271 return f._symbol_set[1](False)
273 def convert_for_comparison(v, f):
274 # It will convert value for comparison between current and new.
276 return false_value(f)
277 if isinstance(v, browse_record):
281 tracked = dict((n, f) for n, f in self._all_columns.items() if getattr(f.column, 'tracked', False))
282 to_log = [k for k in values if k in tracked]
285 changes = defaultdict(list)
287 for record in self.browse(cr, uid, ids, context):
289 column = tracked[tl].column
290 current = convert_for_comparison(record[tl], column)
291 new = convert_for_comparison(values[tl], column)
293 changes[record].append(tl)
296 result = super(mail_thread, self).write(cr, uid, ids, values, context=context)
298 updated_fields = _('Updated Fields:')
300 Trans = self.pool['ir.translation']
303 model = c.parent_model or self._name
304 lang = context.get('lang')
305 return Trans._get_source(cr, uid, '{0},{1}'.format(model, c.name), 'field', lang, ci.column.string)
307 def get_subtype(model, record):
308 # it will return subtype name(xml_id) for stage.
309 record_model = self.pool[model].browse(cr, SUPERUSER_ID, record)
310 if record_model.__hasattr__('subtype'):
311 return record_model.subtype
314 for record, changed_fields in changes.items():
315 # TODO tpl changed_fields
318 for f in changed_fields:
319 to = self.browse(cr, uid, ids[0], context)[f]
321 if ci.column._type == "many2one":
323 to = to.name_get()[0][1]
326 if isinstance(from_, browse_record):
327 from_ = from_.name_get()[0][1]
329 subtype = get_subtype(ci.column._obj,values[f])
330 chg.append((_t(ci), from_, to))
332 message = MakoTemplate(self._TRACK_TEMPLATE).render_unicode(updated_fields=updated_fields,
335 record.message_post(message,subtype=subtype)
339 #------------------------------------------------------
340 # mail.message wrappers and tools
341 #------------------------------------------------------
343 def _needaction_domain_get(self, cr, uid, context=None):
345 return [('message_unread', '=', True)]
348 #------------------------------------------------------
350 #------------------------------------------------------
352 def message_capable_models(self, cr, uid, context=None):
353 """ Used by the plugin addon, based for plugin_outlook and others. """
355 for model_name in self.pool.obj_list():
356 model = self.pool.get(model_name)
357 if 'mail.thread' in getattr(model, '_inherit', []):
358 ret_dict[model_name] = model._description
361 def _message_find_partners(self, cr, uid, message, header_fields=['From'], context=None):
362 """ Find partners related to some header fields of the message. """
363 s = ', '.join([decode(message.get(h)) for h in header_fields if message.get(h)])
364 return [partner_id for email in tools.email_split(s)
365 for partner_id in self.pool.get('res.partner').search(cr, uid, [('email', 'ilike', email)], context=context)]
367 def _message_find_user_id(self, cr, uid, message, context=None):
368 from_local_part = tools.email_split(decode(message.get('From')))[0]
369 # FP Note: canonification required, the minimu: .lower()
370 user_ids = self.pool.get('res.users').search(cr, uid, ['|',
371 ('login', '=', from_local_part),
372 ('email', '=', from_local_part)], context=context)
373 return user_ids[0] if user_ids else uid
375 def message_route(self, cr, uid, message, model=None, thread_id=None,
376 custom_values=None, context=None):
377 """Attempt to figure out the correct target model, thread_id,
378 custom_values and user_id to use for an incoming message.
379 Multiple values may be returned, if a message had multiple
380 recipients matching existing mail.aliases, for example.
382 The following heuristics are used, in this order:
383 1. If the message replies to an existing thread_id, and
384 properly contains the thread model in the 'In-Reply-To'
385 header, use this model/thread_id pair, and ignore
386 custom_value (not needed as no creation will take place)
387 2. Look for a mail.alias entry matching the message
388 recipient, and use the corresponding model, thread_id,
389 custom_values and user_id.
390 3. Fallback to the ``model``, ``thread_id`` and ``custom_values``
392 4. If all the above fails, raise an exception.
394 :param string message: an email.message instance
395 :param string model: the fallback model to use if the message
396 does not match any of the currently configured mail aliases
397 (may be None if a matching alias is supposed to be present)
398 :type dict custom_values: optional dictionary of default field values
399 to pass to ``message_new`` if a new record needs to be created.
400 Ignored if the thread record already exists, and also if a
401 matching mail.alias was found (aliases define their own defaults)
402 :param int thread_id: optional ID of the record/thread from ``model``
403 to which this mail should be attached. Only used if the message
404 does not reply to an existing thread and does not match any mail alias.
405 :return: list of [model, thread_id, custom_values, user_id]
407 assert isinstance(message, Message), 'message must be an email.message.Message at this point'
408 message_id = message.get('Message-Id')
409 references = decode_header(message, 'References')
410 in_reply_to = decode_header(message, 'In-Reply-To')
412 # 1. Verify if this is a reply to an existing thread
413 thread_references = references or in_reply_to
414 ref_match = thread_references and tools.reference_re.search(thread_references)
416 thread_id = int(ref_match.group(1))
417 model = ref_match.group(2) or model
418 model_pool = self.pool.get(model)
419 if thread_id and model and model_pool and model_pool.exists(cr, uid, thread_id) \
420 and hasattr(model_pool, 'message_update'):
421 _logger.debug('Routing mail with Message-Id %s: direct reply to model: %s, thread_id: %s, custom_values: %s, uid: %s',
422 message_id, model, thread_id, custom_values, uid)
423 return [(model, thread_id, custom_values, uid)]
425 # Verify this is a reply to a private message
426 message_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', '=', in_reply_to)], limit=1, context=context)
428 message = self.pool.get('mail.message').browse(cr, uid, message_ids[0], context=context)
429 _logger.debug('Routing mail with Message-Id %s: direct reply to a private message: %s, custom_values: %s, uid: %s',
430 message_id, message.id, custom_values, uid)
431 return [(message.model, message.res_id, custom_values, uid)]
433 # 2. Look for a matching mail.alias entry
434 # Delivered-To is a safe bet in most modern MTAs, but we have to fallback on To + Cc values
435 # for all the odd MTAs out there, as there is no standard header for the envelope's `rcpt_to` value.
436 rcpt_tos = decode_header(message, 'Delivered-To') or \
437 ','.join([decode_header(message, 'To'),
438 decode_header(message, 'Cc'),
439 decode_header(message, 'Resent-To'),
440 decode_header(message, 'Resent-Cc')])
441 local_parts = [e.split('@')[0] for e in tools.email_split(rcpt_tos)]
443 mail_alias = self.pool.get('mail.alias')
444 alias_ids = mail_alias.search(cr, uid, [('alias_name', 'in', local_parts)])
447 for alias in mail_alias.browse(cr, uid, alias_ids, context=context):
448 user_id = alias.alias_user_id.id
450 user_id = self._message_find_user_id(cr, uid, message, context=context)
451 routes.append((alias.alias_model_id.model, alias.alias_force_thread_id, \
452 eval(alias.alias_defaults), user_id))
453 _logger.debug('Routing mail with Message-Id %s: direct alias match: %r', message_id, routes)
456 # 3. Fallback to the provided parameters, if they work
457 model_pool = self.pool.get(model)
459 # Legacy: fallback to matching [ID] in the Subject
460 match = tools.res_re.search(decode_header(message, 'Subject'))
461 thread_id = match and match.group(1)
462 assert thread_id and hasattr(model_pool, 'message_update') or hasattr(model_pool, 'message_new'), \
463 "No possible route found for incoming message with Message-Id %s. " \
464 "Create an appropriate mail.alias or force the destination model." % message_id
465 if thread_id and not model_pool.exists(cr, uid, thread_id):
466 _logger.warning('Received mail reply to missing document %s! Ignoring and creating new document instead for Message-Id %s',
467 thread_id, message_id)
469 _logger.debug('Routing mail with Message-Id %s: fallback to model:%s, thread_id:%s, custom_values:%s, uid:%s',
470 message_id, model, thread_id, custom_values, uid)
471 return [(model, thread_id, custom_values, uid)]
473 def message_process(self, cr, uid, model, message, custom_values=None,
474 save_original=False, strip_attachments=False,
475 thread_id=None, context=None):
476 """ Process an incoming RFC2822 email message, relying on
477 ``mail.message.parse()`` for the parsing operation,
478 and ``message_route()`` to figure out the target model.
480 Once the target model is known, its ``message_new`` method
481 is called with the new message (if the thread record did not exist)
482 or its ``message_update`` method (if it did).
484 There is a special case where the target model is False: a reply
485 to a private message. In this case, we skip the message_new /
486 message_update step, to just post a new message using mail_thread
489 :param string model: the fallback model to use if the message
490 does not match any of the currently configured mail aliases
491 (may be None if a matching alias is supposed to be present)
492 :param message: source of the RFC2822 message
493 :type message: string or xmlrpclib.Binary
494 :type dict custom_values: optional dictionary of field values
495 to pass to ``message_new`` if a new record needs to be created.
496 Ignored if the thread record already exists, and also if a
497 matching mail.alias was found (aliases define their own defaults)
498 :param bool save_original: whether to keep a copy of the original
499 email source attached to the message after it is imported.
500 :param bool strip_attachments: whether to strip all attachments
501 before processing the message, in order to save some space.
502 :param int thread_id: optional ID of the record/thread from ``model``
503 to which this mail should be attached. When provided, this
504 overrides the automatic detection based on the message
510 # extract message bytes - we are forced to pass the message as binary because
511 # we don't know its encoding until we parse its headers and hence can't
512 # convert it to utf-8 for transport between the mailgate script and here.
513 if isinstance(message, xmlrpclib.Binary):
514 message = str(message.data)
515 # Warning: message_from_string doesn't always work correctly on unicode,
516 # we must use utf-8 strings here :-(
517 if isinstance(message, unicode):
518 message = message.encode('utf-8')
519 msg_txt = email.message_from_string(message)
520 routes = self.message_route(cr, uid, msg_txt, model,
521 thread_id, custom_values,
523 msg = self.message_parse(cr, uid, msg_txt, save_original=save_original, context=context)
524 if strip_attachments:
525 msg.pop('attachments', None)
527 for model, thread_id, custom_values, user_id in routes:
528 if self._name != model:
529 context.update({'thread_model': model})
531 model_pool = self.pool.get(model)
532 assert thread_id and hasattr(model_pool, 'message_update') or hasattr(model_pool, 'message_new'), \
533 "Undeliverable mail with Message-Id %s, model %s does not accept incoming emails" % \
534 (msg['message_id'], model)
535 if thread_id and hasattr(model_pool, 'message_update'):
536 model_pool.message_update(cr, user_id, [thread_id], msg, context=context)
538 thread_id = model_pool.message_new(cr, user_id, msg, custom_values, context=context)
540 assert thread_id == 0, "Posting a message without model should be with a null res_id, to create a private message."
541 model_pool = self.pool.get('mail.thread')
542 model_pool.message_post_user_api(cr, uid, [thread_id], context=context, content_subtype='html', **msg)
545 def message_new(self, cr, uid, msg_dict, custom_values=None, context=None):
546 """Called by ``message_process`` when a new message is received
547 for a given thread model, if the message did not belong to
549 The default behavior is to create a new record of the corresponding
550 model (based on some very basic info extracted from the message).
551 Additional behavior may be implemented by overriding this method.
553 :param dict msg_dict: a map containing the email details and
554 attachments. See ``message_process`` and
555 ``mail.message.parse`` for details.
556 :param dict custom_values: optional dictionary of additional
557 field values to pass to create()
558 when creating the new thread record.
559 Be careful, these values may override
560 any other values coming from the message.
561 :param dict context: if a ``thread_model`` value is present
562 in the context, its value will be used
563 to determine the model of the record
564 to create (instead of the current model).
566 :return: the id of the newly created thread object
570 model = context.get('thread_model') or self._name
571 model_pool = self.pool.get(model)
572 fields = model_pool.fields_get(cr, uid, context=context)
573 data = model_pool.default_get(cr, uid, fields, context=context)
574 if 'name' in fields and not data.get('name'):
575 data['name'] = msg_dict.get('subject', '')
576 if custom_values and isinstance(custom_values, dict):
577 data.update(custom_values)
578 res_id = model_pool.create(cr, uid, data, context=context)
581 def message_update(self, cr, uid, ids, msg_dict, update_vals=None, context=None):
582 """Called by ``message_process`` when a new message is received
583 for an existing thread. The default behavior is to update the record
584 with update_vals taken from the incoming email.
585 Additional behavior may be implemented by overriding this
587 :param dict msg_dict: a map containing the email details and
588 attachments. See ``message_process`` and
589 ``mail.message.parse()`` for details.
590 :param dict update_vals: a dict containing values to update records
591 given their ids; if the dict is None or is
592 void, no write operation is performed.
595 self.write(cr, uid, ids, update_vals, context=context)
598 def _message_extract_payload(self, message, save_original=False):
599 """Extract body as HTML and attachments from the mail message"""
603 attachments.append(('original_email.eml', message.as_string()))
604 if not message.is_multipart() or 'text/' in message.get('content-type', ''):
605 encoding = message.get_content_charset()
606 body = message.get_payload(decode=True)
607 body = tools.ustr(body, encoding, errors='replace')
608 if message.get_content_type() == 'text/plain':
609 # text/plain -> <pre/>
610 body = tools.append_content_to_html(u'', body, preserve=True)
612 alternative = (message.get_content_type() == 'multipart/alternative')
613 for part in message.walk():
614 if part.get_content_maintype() == 'multipart':
615 continue # skip container
616 filename = part.get_filename() # None if normal part
617 encoding = part.get_content_charset() # None if attachment
618 # 1) Explicit Attachments -> attachments
619 if filename or part.get('content-disposition', '').strip().startswith('attachment'):
620 attachments.append((filename or 'attachment', part.get_payload(decode=True)))
622 # 2) text/plain -> <pre/>
623 if part.get_content_type() == 'text/plain' and (not alternative or not body):
624 body = tools.append_content_to_html(body, tools.ustr(part.get_payload(decode=True),
625 encoding, errors='replace'), preserve=True)
626 # 3) text/html -> raw
627 elif part.get_content_type() == 'text/html':
628 html = tools.ustr(part.get_payload(decode=True), encoding, errors='replace')
632 body = tools.append_content_to_html(body, html, plaintext=False)
633 # 4) Anything else -> attachment
635 attachments.append((filename or 'attachment', part.get_payload(decode=True)))
636 return body, attachments
638 def message_parse(self, cr, uid, message, save_original=False, context=None):
639 """Parses a string or email.message.Message representing an
640 RFC-2822 email, and returns a generic dict holding the
643 :param message: the message to parse
644 :type message: email.message.Message | string | unicode
645 :param bool save_original: whether the returned dict
646 should include an ``original`` attachment containing
647 the source of the message
649 :return: A dict with the following structure, where each
650 field may not be present if missing in original
653 { 'message_id': msg_id,
658 'body': unified_body,
659 'attachments': [('file1', 'bytes'),
667 if not isinstance(message, Message):
668 if isinstance(message, unicode):
669 # Warning: message_from_string doesn't always work correctly on unicode,
670 # we must use utf-8 strings here :-(
671 message = message.encode('utf-8')
672 message = email.message_from_string(message)
674 message_id = message['message-id']
676 # Very unusual situation, be we should be fault-tolerant here
677 message_id = "<%s@localhost>" % time.time()
678 _logger.debug('Parsing Message without message-id, generating a random one: %s', message_id)
679 msg_dict['message_id'] = message_id
681 if 'Subject' in message:
682 msg_dict['subject'] = decode(message.get('Subject'))
684 # Envelope fields not stored in mail.message but made available for message_new()
685 msg_dict['from'] = decode(message.get('from'))
686 msg_dict['to'] = decode(message.get('to'))
687 msg_dict['cc'] = decode(message.get('cc'))
689 if 'From' in message:
690 author_ids = self._message_find_partners(cr, uid, message, ['From'], context=context)
692 msg_dict['author_id'] = author_ids[0]
694 msg_dict['email_from'] = message.get('from')
695 partner_ids = self._message_find_partners(cr, uid, message, ['From', 'To', 'Cc'], context=context)
696 msg_dict['partner_ids'] = [(4, partner_id) for partner_id in partner_ids]
698 if 'Date' in message:
699 date_hdr = decode(message.get('Date'))
700 # convert from email timezone to server timezone
701 date_server_datetime = dateutil.parser.parse(date_hdr).astimezone(pytz.timezone(tools.get_server_timezone()))
702 date_server_datetime_str = date_server_datetime.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT)
703 msg_dict['date'] = date_server_datetime_str
705 if 'In-Reply-To' in message:
706 parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', '=', decode(message['In-Reply-To']))])
708 msg_dict['parent_id'] = parent_ids[0]
710 if 'References' in message and 'parent_id' not in msg_dict:
711 parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', 'in',
712 [x.strip() for x in decode(message['References']).split()])])
714 msg_dict['parent_id'] = parent_ids[0]
716 msg_dict['body'], msg_dict['attachments'] = self._message_extract_payload(message)
719 #------------------------------------------------------
721 #------------------------------------------------------
723 def log(self, cr, uid, id, message, secondary=False, context=None):
724 _logger.warning("log() is deprecated. As this module inherit from "\
725 "mail.thread, the message will be managed by this "\
726 "module instead of by the res.log mechanism. Please "\
727 "use mail_thread.message_post() instead of the "\
728 "now deprecated res.log.")
729 self.message_post(cr, uid, [id], message, context=context)
731 def message_post(self, cr, uid, thread_id, body='', subject=None, type='notification',
732 subtype=None, parent_id=False, attachments=None, context=None, **kwargs):
733 """ Post a new message in an existing thread, returning the new
734 mail.message ID. Extra keyword arguments will be used as default
735 column values for the new mail.message record.
736 Auto link messages for same id and object
737 :param int thread_id: thread ID to post into, or list with one ID;
738 if False/0, mail.message model will also be set as False
739 :param str body: body of the message, usually raw HTML that will
741 :param str subject: optional subject
742 :param str type: mail_message.type
743 :param int parent_id: optional ID of parent message in this thread
744 :param tuple(str,str) attachments or list id: list of attachment tuples in the form
745 ``(name,content)``, where content is NOT base64 encoded
746 :return: ID of newly created mail.message
750 if attachments is None:
753 assert (not thread_id) or isinstance(thread_id, (int, long)) or \
754 (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"
755 if isinstance(thread_id, (list, tuple)):
756 thread_id = thread_id and thread_id[0]
757 mail_message = self.pool.get('mail.message')
758 model = context.get('thread_model', self._name) if thread_id else False
761 for name, content in attachments:
762 if isinstance(content, unicode):
763 content = content.encode('utf-8')
766 'datas': base64.b64encode(str(content)),
769 'res_model': context.get('thread_model') or self._name,
772 attachment_ids.append((0, 0, data_attach))
776 s_data = subtype.split('.')
778 s_data = ('mail', s_data[0])
779 ref = self.pool.get('ir.model.data').get_object_reference(cr, uid, s_data[0], s_data[1])
780 subtype_id = ref and ref[1] or False
784 # _mail_flat_thread: automatically set free messages to the first posted message
785 if self._mail_flat_thread and not parent_id and thread_id:
786 message_ids = mail_message.search(cr, uid, ['&', ('res_id', '=', thread_id), ('model', '=', model)], context=context, order="id ASC", limit=1)
787 parent_id = message_ids and message_ids[0] or False
788 # 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
790 message_ids = mail_message.search(cr, SUPERUSER_ID, [('id', '=', parent_id), ('parent_id', '!=', False)], context=context)
791 # avoid loops when finding ancestors
794 message = mail_message.browse(cr, SUPERUSER_ID, message_ids[0], context=context)
795 while (message.parent_id and message.parent_id.id not in processed_list):
796 processed_list.append(message.parent_id.id)
797 message = message.parent_id
798 parent_id = message.id
803 'res_id': thread_id or False,
805 'subject': subject or False,
807 'parent_id': parent_id,
808 'attachment_ids': attachment_ids,
809 'subtype_id': subtype_id,
812 # Avoid warnings about non-existing fields
813 for x in ('from', 'to', 'cc'):
816 return mail_message.create(cr, uid, values, context=context)
818 def message_post_user_api(self, cr, uid, thread_id, body='', subject=False, parent_id=False,
819 attachment_ids=None, context=None, content_subtype='plaintext', **kwargs):
820 """ Wrapper on message_post, used for user input :
822 - quick reply in Chatter (refer to mail.js), not
823 the mail.compose.message wizard
824 The purpose is to perform some pre- and post-processing:
825 - if body is plaintext: convert it into html
826 - if parent_id: handle reply to a previous message by adding the
827 parent partners to the message
828 - type and subtype: comment and mail.mt_comment by default
829 - attachment_ids: supposed not attached to any document; attach them
830 to the related document. Should only be set by Chatter.
832 ir_attachment = self.pool.get('ir.attachment')
833 mail_message = self.pool.get('mail.message')
835 # 1. Pre-processing: body, partner_ids, type and subtype
836 if content_subtype == 'plaintext':
837 body = tools.plaintext2html(body)
839 partner_ids = kwargs.pop('partner_ids', [])
841 parent_message = self.pool.get('mail.message').browse(cr, uid, parent_id, context=context)
842 partner_ids += [(4, partner.id) for partner in parent_message.partner_ids]
843 # TDE FIXME HACK: mail.thread -> private message
844 if self._name == 'mail.thread' and parent_message.author_id.id:
845 partner_ids.append((4, parent_message.author_id.id))
847 message_type = kwargs.pop('type', 'comment')
848 message_subtype = kwargs.pop('subtype', 'mail.mt_comment')
851 new_message_id = self.message_post(cr, uid, thread_id=thread_id, body=body, subject=subject, type=message_type,
852 subtype=message_subtype, parent_id=parent_id, context=context, partner_ids=partner_ids, **kwargs)
855 # HACK TDE FIXME: Chatter: attachments linked to the document (not done JS-side), load the message
857 # TDE FIXME (?): when posting a private message, we use mail.thread as a model
858 # However, attaching doc to mail.thread is not possible, mail.thread does not have any table
860 if model == 'mail.thread':
862 filtered_attachment_ids = ir_attachment.search(cr, SUPERUSER_ID, [
863 ('res_model', '=', 'mail.compose.message'),
865 ('create_uid', '=', uid),
866 ('id', 'in', attachment_ids)], context=context)
867 if filtered_attachment_ids:
868 if thread_id and model:
869 ir_attachment.write(cr, SUPERUSER_ID, attachment_ids, {'res_model': model, 'res_id': thread_id}, context=context)
870 mail_message.write(cr, SUPERUSER_ID, [new_message_id], {'attachment_ids': [(6, 0, [pid for pid in attachment_ids])]}, context=context)
872 return new_message_id
874 #------------------------------------------------------
876 #------------------------------------------------------
878 def message_get_subscription_data(self, cr, uid, ids, context=None):
879 """ Wrapper to get subtypes data. """
880 return self._get_subscription_data(cr, uid, ids, None, None, context=context)
882 def message_subscribe_users(self, cr, uid, ids, user_ids=None, subtype_ids=None, context=None):
883 """ Wrapper on message_subscribe, using users. If user_ids is not
884 provided, subscribe uid instead. """
887 partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
888 return self.message_subscribe(cr, uid, ids, partner_ids, subtype_ids=subtype_ids, context=context)
890 def message_subscribe(self, cr, uid, ids, partner_ids, subtype_ids=None, context=None):
891 """ Add partners to the records followers. """
892 self.check_access_rights(cr, uid, 'read')
893 self.write(cr, SUPERUSER_ID, ids, {'message_follower_ids': [(4, pid) for pid in partner_ids]}, context=context)
894 # if subtypes are not specified (and not set to a void list), fetch default ones
895 if subtype_ids is None:
896 subtype_obj = self.pool.get('mail.message.subtype')
897 subtype_ids = subtype_obj.search(cr, uid, [('default', '=', True), '|', ('res_model', '=', self._name), ('res_model', '=', False)], context=context)
898 # update the subscriptions
899 fol_obj = self.pool.get('mail.followers')
900 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids), ('partner_id', 'in', partner_ids)], context=context)
901 fol_obj.write(cr, SUPERUSER_ID, fol_ids, {'subtype_ids': [(6, 0, subtype_ids)]}, context=context)
904 def message_unsubscribe_users(self, cr, uid, ids, user_ids=None, context=None):
905 """ Wrapper on message_subscribe, using users. If user_ids is not
906 provided, unsubscribe uid instead. """
909 partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
910 return self.message_unsubscribe(cr, uid, ids, partner_ids, context=context)
912 def message_unsubscribe(self, cr, uid, ids, partner_ids, context=None):
913 """ Remove partners from the records followers. """
914 self.check_access_rights(cr, uid, 'read')
915 return self.write(cr, SUPERUSER_ID, ids, {'message_follower_ids': [(3, pid) for pid in partner_ids]}, context=context)
917 #------------------------------------------------------
919 #------------------------------------------------------
921 def message_mark_as_unread(self, cr, uid, ids, context=None):
922 """ Set as unread. """
923 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
925 UPDATE mail_notification SET
928 message_id IN (SELECT id from mail_message where res_id=any(%s) and model=%s limit 1) and
930 ''', (ids, self._name, partner_id))
933 def message_mark_as_read(self, cr, uid, ids, context=None):
935 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
937 UPDATE mail_notification SET
940 message_id IN (SELECT id FROM mail_message WHERE res_id=ANY(%s) AND model=%s) AND
942 ''', (ids, self._name, partner_id))
945 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: