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
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 osv import osv, fields
39 from openerp.osv.orm import browse_record
40 from 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_comment_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_unread(self, cr, uid, obj=None, name=None, domain=None, context=None):
139 partner_id = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
141 notif_obj = self.pool.get('mail.notification')
142 notif_ids = notif_obj.search(cr, uid, [
143 ('partner_id', '=', partner_id),
144 ('message_id.model', '=', self._name),
147 for notif in notif_obj.browse(cr, uid, notif_ids, context=context):
148 res[notif.message_id.res_id] = True
149 return [('id', 'in', res.keys())]
151 def _get_followers(self, cr, uid, ids, name, arg, context=None):
152 fol_obj = self.pool.get('mail.followers')
153 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids)])
154 res = dict((id, dict(message_follower_ids=[], message_is_follower=False)) for id in ids)
155 user_pid = self.pool.get('res.users').read(cr, uid, uid, ['partner_id'], context=context)['partner_id'][0]
156 for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids):
157 res[fol.res_id]['message_follower_ids'].append(fol.partner_id.id)
158 if fol.partner_id.id == user_pid:
159 res[fol.res_id]['message_is_follower'] = True
162 def _set_followers(self, cr, uid, id, name, value, arg, context=None):
165 partner_obj = self.pool.get('res.partner')
166 fol_obj = self.pool.get('mail.followers')
168 # read the old set of followers, and determine the new set of followers
169 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', '=', id)])
170 old = set(fol.partner_id.id for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids))
173 for command in value or []:
174 if isinstance(command, (int, long)):
176 elif command[0] == 0:
177 new.add(partner_obj.create(cr, uid, command[2], context=context))
178 elif command[0] == 1:
179 partner_obj.write(cr, uid, [command[1]], command[2], context=context)
181 elif command[0] == 2:
182 partner_obj.unlink(cr, uid, [command[1]], context=context)
183 new.discard(command[1])
184 elif command[0] == 3:
185 new.discard(command[1])
186 elif command[0] == 4:
188 elif command[0] == 5:
190 elif command[0] == 6:
191 new = set(command[2])
193 # remove partners that are no longer followers
194 fol_ids = fol_obj.search(cr, SUPERUSER_ID,
195 [('res_model', '=', self._name), ('res_id', '=', id), ('partner_id', 'not in', list(new))])
196 fol_obj.unlink(cr, SUPERUSER_ID, fol_ids)
199 for partner_id in new - old:
200 fol_obj.create(cr, SUPERUSER_ID, {'res_model': self._name, 'res_id': id, 'partner_id': partner_id})
202 def _search_followers(self, cr, uid, obj, name, args, context):
203 fol_obj = self.pool.get('mail.followers')
205 for field, operator, value in args:
207 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('partner_id', operator, value)])
208 res_ids = [fol.res_id for fol in fol_obj.browse(cr, SUPERUSER_ID, fol_ids)]
209 res.append(('id', 'in', res_ids))
213 'message_is_follower': fields.function(_get_followers,
214 type='boolean', string='Is a Follower', multi='_get_followers,'),
215 'message_follower_ids': fields.function(_get_followers, fnct_inv=_set_followers,
216 fnct_search=_search_followers, type='many2many',
217 obj='res.partner', string='Followers', multi='_get_followers'),
218 'message_comment_ids': fields.one2many('mail.message', 'res_id',
219 domain=lambda self: [('model', '=', self._name), ('type', 'in', ('comment', 'email'))],
220 string='Comments and emails',
221 help="Comments and emails"),
222 'message_ids': fields.one2many('mail.message', 'res_id',
223 domain=lambda self: [('model', '=', self._name)],
225 help="Messages and communication history"),
226 'message_unread': fields.function(_get_message_data, fnct_search=_search_unread,
227 type='boolean', string='Unread Messages', multi="_get_message_data",
228 help="If checked new messages require your attention."),
229 'message_summary': fields.function(_get_message_data, method=True,
230 type='text', string='Summary', multi="_get_message_data",
231 help="Holds the Chatter summary (number of messages, ...). "\
232 "This summary is directly in html format in order to "\
233 "be inserted in kanban views."),
236 #------------------------------------------------------
237 # Automatic subscription when creating
238 #------------------------------------------------------
240 def create(self, cr, uid, vals, context=None):
241 """ Override to subscribe the current user. """
242 thread_id = super(mail_thread, self).create(cr, uid, vals, context=context)
243 self.message_subscribe_users(cr, uid, [thread_id], [uid], context=context)
246 def unlink(self, cr, uid, ids, context=None):
247 """ Override unlink to delete messages and followers. This cannot be
248 cascaded, because link is done through (res_model, res_id). """
249 msg_obj = self.pool.get('mail.message')
250 fol_obj = self.pool.get('mail.followers')
251 # delete messages and notifications
252 msg_ids = msg_obj.search(cr, uid, [('model', '=', self._name), ('res_id', 'in', ids)], context=context)
253 msg_obj.unlink(cr, uid, msg_ids, context=context)
255 fol_ids = fol_obj.search(cr, uid, [('res_model', '=', self._name), ('res_id', 'in', ids)], context=context)
256 fol_obj.unlink(cr, uid, fol_ids, context=context)
257 return super(mail_thread, self).unlink(cr, uid, ids, context=context)
259 def copy(self, cr, uid, id, default=None, context=None):
260 default = default or {}
261 default['message_ids'] = []
262 default['message_comment_ids'] = []
263 default['message_follower_ids'] = []
264 return super(mail_thread, self).copy(cr, uid, id, default=default, context=context)
266 #------------------------------------------------------
267 # Automatically log tracked fields
268 #------------------------------------------------------
270 def write(self, cr, uid, ids, values, context=None):
271 # XXX is translation a good idea?
272 # TODO get user lang if not in context
273 # XXX idea: store a datastructure and render it on demand in the correct language
274 # TODO? use link to record for m2o
275 # TODO handle x2m fields. how?
279 #import pudb;pudb.set_trace()
282 if f._type == 'boolean':
284 return f._symbol_set[1](False)
286 def convert_for_comparison(v, f):
288 return false_value(f)
289 if isinstance(v, browse_record):
293 def convert_for_display(v, f):
295 return false_value(f)
296 if f._type == 'many2one':
297 if not isinstance(v, browse_record):
298 v = self.pool[f.relation].browse(cr, SUPERUSER_ID, v)
299 return v.name_get()[0][1]
300 if f._type == 'selection':
301 # TODO get translated value
305 tracked = dict((n, f) for n, f in self._all_columns.items() if getattr(f.column, 'tracked', False))
306 to_log = [k for k in values if k in tracked]
308 changes = defaultdict(list)
310 for record in self.browse(cr, uid, ids, context):
312 column = tracked[tl].column
313 current = convert_for_comparison(record[tl], column)
314 new = convert_for_comparison(values[tl], column)
316 changes[record].append(tl)
318 result = super(mail_thread, self).write(cr, uid, ids, values, context=context)
320 updated_fields = _('Updated Fields:')
322 Trans = self.pool['ir.translation']
324 model = c.parent_model or self._name
325 lang = context.get('lang')
326 return Trans._get_source(cr, uid, '{0},{1}'.format(model, c.name), 'field', lang, ci.column.string)
328 for record, changed_fields in changes.items():
329 # TODO tpl changed_fields
331 for f in changed_fields:
333 from_ = convert_for_display(record[f], ci.column)
334 to = convert_for_display(values[f], ci.column)
335 chg.append((_t(ci), from_, to))
337 message = MakoTemplate(self._TRACK_TEMPLATE).render_unicode(updated_fields=updated_fields,
340 record.message_post(message)
344 #------------------------------------------------------
345 # mail.message wrappers and tools
346 #------------------------------------------------------
348 def _needaction_domain_get(self, cr, uid, context=None):
350 return [('message_unread', '=', True)]
353 #------------------------------------------------------
355 #------------------------------------------------------
357 def message_capable_models(self, cr, uid, context=None):
358 """ Used by the plugin addon, based for plugin_outlook and others. """
360 for model_name in self.pool.obj_list():
361 model = self.pool.get(model_name)
362 if 'mail.thread' in getattr(model, '_inherit', []):
363 ret_dict[model_name] = model._description
366 def _message_find_partners(self, cr, uid, message, header_fields=['From'], context=None):
367 """ Find partners related to some header fields of the message. """
368 s = ', '.join([decode(message.get(h)) for h in header_fields if message.get(h)])
369 return [partner_id for email in tools.email_split(s)
370 for partner_id in self.pool.get('res.partner').search(cr, uid, [('email', 'ilike', email)], context=context)]
372 def _message_find_user_id(self, cr, uid, message, context=None):
373 from_local_part = tools.email_split(decode(message.get('From')))[0]
374 # FP Note: canonification required, the minimu: .lower()
375 user_ids = self.pool.get('res.users').search(cr, uid, ['|',
376 ('login', '=', from_local_part),
377 ('email', '=', from_local_part)], context=context)
378 return user_ids[0] if user_ids else uid
380 def message_route(self, cr, uid, message, model=None, thread_id=None,
381 custom_values=None, context=None):
382 """Attempt to figure out the correct target model, thread_id,
383 custom_values and user_id to use for an incoming message.
384 Multiple values may be returned, if a message had multiple
385 recipients matching existing mail.aliases, for example.
387 The following heuristics are used, in this order:
388 1. If the message replies to an existing thread_id, and
389 properly contains the thread model in the 'In-Reply-To'
390 header, use this model/thread_id pair, and ignore
391 custom_value (not needed as no creation will take place)
392 2. Look for a mail.alias entry matching the message
393 recipient, and use the corresponding model, thread_id,
394 custom_values and user_id.
395 3. Fallback to the ``model``, ``thread_id`` and ``custom_values``
397 4. If all the above fails, raise an exception.
399 :param string message: an email.message instance
400 :param string model: the fallback model to use if the message
401 does not match any of the currently configured mail aliases
402 (may be None if a matching alias is supposed to be present)
403 :type dict custom_values: optional dictionary of default field values
404 to pass to ``message_new`` if a new record needs to be created.
405 Ignored if the thread record already exists, and also if a
406 matching mail.alias was found (aliases define their own defaults)
407 :param int thread_id: optional ID of the record/thread from ``model``
408 to which this mail should be attached. Only used if the message
409 does not reply to an existing thread and does not match any mail alias.
410 :return: list of [model, thread_id, custom_values, user_id]
412 assert isinstance(message, Message), 'message must be an email.message.Message at this point'
413 message_id = message.get('Message-Id')
414 references = decode_header(message, 'References')
415 in_reply_to = decode_header(message, 'In-Reply-To')
417 # 1. Verify if this is a reply to an existing thread
418 thread_references = references or in_reply_to
419 ref_match = thread_references and tools.reference_re.search(thread_references)
421 thread_id = int(ref_match.group(1))
422 model = ref_match.group(2) or model
423 model_pool = self.pool.get(model)
424 if thread_id and model and model_pool and model_pool.exists(cr, uid, thread_id) \
425 and hasattr(model_pool, 'message_update'):
426 _logger.debug('Routing mail with Message-Id %s: direct reply to model: %s, thread_id: %s, custom_values: %s, uid: %s',
427 message_id, model, thread_id, custom_values, uid)
428 return [(model, thread_id, custom_values, uid)]
430 # Verify this is a reply to a private message
431 message_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', '=', in_reply_to)], limit=1, context=context)
433 message = self.pool.get('mail.message').browse(cr, uid, message_ids[0], context=context)
434 _logger.debug('Routing mail with Message-Id %s: direct reply to a private message: %s, custom_values: %s, uid: %s',
435 message_id, message.id, custom_values, uid)
436 return [(message.model, message.res_id, custom_values, uid)]
438 # 2. Look for a matching mail.alias entry
439 # Delivered-To is a safe bet in most modern MTAs, but we have to fallback on To + Cc values
440 # for all the odd MTAs out there, as there is no standard header for the envelope's `rcpt_to` value.
441 rcpt_tos = decode_header(message, 'Delivered-To') or \
442 ','.join([decode_header(message, 'To'),
443 decode_header(message, 'Cc'),
444 decode_header(message, 'Resent-To'),
445 decode_header(message, 'Resent-Cc')])
446 local_parts = [e.split('@')[0] for e in tools.email_split(rcpt_tos)]
448 mail_alias = self.pool.get('mail.alias')
449 alias_ids = mail_alias.search(cr, uid, [('alias_name', 'in', local_parts)])
452 for alias in mail_alias.browse(cr, uid, alias_ids, context=context):
453 user_id = alias.alias_user_id.id
455 user_id = self._message_find_user_id(cr, uid, message, context=context)
456 routes.append((alias.alias_model_id.model, alias.alias_force_thread_id, \
457 eval(alias.alias_defaults), user_id))
458 _logger.debug('Routing mail with Message-Id %s: direct alias match: %r', message_id, routes)
461 # 3. Fallback to the provided parameters, if they work
462 model_pool = self.pool.get(model)
464 # Legacy: fallback to matching [ID] in the Subject
465 match = tools.res_re.search(decode_header(message, 'Subject'))
466 thread_id = match and match.group(1)
467 assert thread_id and hasattr(model_pool, 'message_update') or hasattr(model_pool, 'message_new'), \
468 "No possible route found for incoming message with Message-Id %s. " \
469 "Create an appropriate mail.alias or force the destination model." % message_id
470 if thread_id and not model_pool.exists(cr, uid, thread_id):
471 _logger.warning('Received mail reply to missing document %s! Ignoring and creating new document instead for Message-Id %s',
472 thread_id, message_id)
474 _logger.debug('Routing mail with Message-Id %s: fallback to model:%s, thread_id:%s, custom_values:%s, uid:%s',
475 message_id, model, thread_id, custom_values, uid)
476 return [(model, thread_id, custom_values, uid)]
478 def message_process(self, cr, uid, model, message, custom_values=None,
479 save_original=False, strip_attachments=False,
480 thread_id=None, context=None):
481 """ Process an incoming RFC2822 email message, relying on
482 ``mail.message.parse()`` for the parsing operation,
483 and ``message_route()`` to figure out the target model.
485 Once the target model is known, its ``message_new`` method
486 is called with the new message (if the thread record did not exist)
487 or its ``message_update`` method (if it did).
489 There is a special case where the target model is False: a reply
490 to a private message. In this case, we skip the message_new /
491 message_update step, to just post a new message using mail_thread
494 :param string model: the fallback model to use if the message
495 does not match any of the currently configured mail aliases
496 (may be None if a matching alias is supposed to be present)
497 :param message: source of the RFC2822 message
498 :type message: string or xmlrpclib.Binary
499 :type dict custom_values: optional dictionary of field values
500 to pass to ``message_new`` if a new record needs to be created.
501 Ignored if the thread record already exists, and also if a
502 matching mail.alias was found (aliases define their own defaults)
503 :param bool save_original: whether to keep a copy of the original
504 email source attached to the message after it is imported.
505 :param bool strip_attachments: whether to strip all attachments
506 before processing the message, in order to save some space.
507 :param int thread_id: optional ID of the record/thread from ``model``
508 to which this mail should be attached. When provided, this
509 overrides the automatic detection based on the message
515 # extract message bytes - we are forced to pass the message as binary because
516 # we don't know its encoding until we parse its headers and hence can't
517 # convert it to utf-8 for transport between the mailgate script and here.
518 if isinstance(message, xmlrpclib.Binary):
519 message = str(message.data)
520 # Warning: message_from_string doesn't always work correctly on unicode,
521 # we must use utf-8 strings here :-(
522 if isinstance(message, unicode):
523 message = message.encode('utf-8')
524 msg_txt = email.message_from_string(message)
525 routes = self.message_route(cr, uid, msg_txt, model,
526 thread_id, custom_values,
528 msg = self.message_parse(cr, uid, msg_txt, save_original=save_original, context=context)
529 if strip_attachments:
530 msg.pop('attachments', None)
532 for model, thread_id, custom_values, user_id in routes:
533 if self._name != model:
534 context.update({'thread_model': model})
536 model_pool = self.pool.get(model)
537 assert thread_id and hasattr(model_pool, 'message_update') or hasattr(model_pool, 'message_new'), \
538 "Undeliverable mail with Message-Id %s, model %s does not accept incoming emails" % \
539 (msg['message_id'], model)
540 if thread_id and hasattr(model_pool, 'message_update'):
541 model_pool.message_update(cr, user_id, [thread_id], msg, context=context)
543 thread_id = model_pool.message_new(cr, user_id, msg, custom_values, context=context)
545 assert thread_id == 0, "Posting a message without model should be with a null res_id, to create a private message."
546 model_pool = self.pool.get('mail.thread')
547 model_pool.message_post_user_api(cr, uid, [thread_id], context=context, content_subtype='html', **msg)
550 def message_new(self, cr, uid, msg_dict, custom_values=None, context=None):
551 """Called by ``message_process`` when a new message is received
552 for a given thread model, if the message did not belong to
554 The default behavior is to create a new record of the corresponding
555 model (based on some very basic info extracted from the message).
556 Additional behavior may be implemented by overriding this method.
558 :param dict msg_dict: a map containing the email details and
559 attachments. See ``message_process`` and
560 ``mail.message.parse`` for details.
561 :param dict custom_values: optional dictionary of additional
562 field values to pass to create()
563 when creating the new thread record.
564 Be careful, these values may override
565 any other values coming from the message.
566 :param dict context: if a ``thread_model`` value is present
567 in the context, its value will be used
568 to determine the model of the record
569 to create (instead of the current model).
571 :return: the id of the newly created thread object
575 model = context.get('thread_model') or self._name
576 model_pool = self.pool.get(model)
577 fields = model_pool.fields_get(cr, uid, context=context)
578 data = model_pool.default_get(cr, uid, fields, context=context)
579 if 'name' in fields and not data.get('name'):
580 data['name'] = msg_dict.get('subject', '')
581 if custom_values and isinstance(custom_values, dict):
582 data.update(custom_values)
583 res_id = model_pool.create(cr, uid, data, context=context)
586 def message_update(self, cr, uid, ids, msg_dict, update_vals=None, context=None):
587 """Called by ``message_process`` when a new message is received
588 for an existing thread. The default behavior is to update the record
589 with update_vals taken from the incoming email.
590 Additional behavior may be implemented by overriding this
592 :param dict msg_dict: a map containing the email details and
593 attachments. See ``message_process`` and
594 ``mail.message.parse()`` for details.
595 :param dict update_vals: a dict containing values to update records
596 given their ids; if the dict is None or is
597 void, no write operation is performed.
600 self.write(cr, uid, ids, update_vals, context=context)
603 def _message_extract_payload(self, message, save_original=False):
604 """Extract body as HTML and attachments from the mail message"""
608 attachments.append(('original_email.eml', message.as_string()))
609 if not message.is_multipart() or 'text/' in message.get('content-type', ''):
610 encoding = message.get_content_charset()
611 body = message.get_payload(decode=True)
612 body = tools.ustr(body, encoding, errors='replace')
613 if message.get_content_type() == 'text/plain':
614 # text/plain -> <pre/>
615 body = tools.append_content_to_html(u'', body, preserve=True)
617 alternative = (message.get_content_type() == 'multipart/alternative')
618 for part in message.walk():
619 if part.get_content_maintype() == 'multipart':
620 continue # skip container
621 filename = part.get_filename() # None if normal part
622 encoding = part.get_content_charset() # None if attachment
623 # 1) Explicit Attachments -> attachments
624 if filename or part.get('content-disposition', '').strip().startswith('attachment'):
625 attachments.append((filename or 'attachment', part.get_payload(decode=True)))
627 # 2) text/plain -> <pre/>
628 if part.get_content_type() == 'text/plain' and (not alternative or not body):
629 body = tools.append_content_to_html(body, tools.ustr(part.get_payload(decode=True),
630 encoding, errors='replace'), preserve=True)
631 # 3) text/html -> raw
632 elif part.get_content_type() == 'text/html':
633 html = tools.ustr(part.get_payload(decode=True), encoding, errors='replace')
637 body = tools.append_content_to_html(body, html, plaintext=False)
638 # 4) Anything else -> attachment
640 attachments.append((filename or 'attachment', part.get_payload(decode=True)))
641 return body, attachments
643 def message_parse(self, cr, uid, message, save_original=False, context=None):
644 """Parses a string or email.message.Message representing an
645 RFC-2822 email, and returns a generic dict holding the
648 :param message: the message to parse
649 :type message: email.message.Message | string | unicode
650 :param bool save_original: whether the returned dict
651 should include an ``original`` attachment containing
652 the source of the message
654 :return: A dict with the following structure, where each
655 field may not be present if missing in original
658 { 'message_id': msg_id,
663 'body': unified_body,
664 'attachments': [('file1', 'bytes'),
672 if not isinstance(message, Message):
673 if isinstance(message, unicode):
674 # Warning: message_from_string doesn't always work correctly on unicode,
675 # we must use utf-8 strings here :-(
676 message = message.encode('utf-8')
677 message = email.message_from_string(message)
679 message_id = message['message-id']
681 # Very unusual situation, be we should be fault-tolerant here
682 message_id = "<%s@localhost>" % time.time()
683 _logger.debug('Parsing Message without message-id, generating a random one: %s', message_id)
684 msg_dict['message_id'] = message_id
686 if 'Subject' in message:
687 msg_dict['subject'] = decode(message.get('Subject'))
689 # Envelope fields not stored in mail.message but made available for message_new()
690 msg_dict['from'] = decode(message.get('from'))
691 msg_dict['to'] = decode(message.get('to'))
692 msg_dict['cc'] = decode(message.get('cc'))
694 if 'From' in message:
695 author_ids = self._message_find_partners(cr, uid, message, ['From'], context=context)
697 msg_dict['author_id'] = author_ids[0]
699 msg_dict['email_from'] = message.get('from')
700 partner_ids = self._message_find_partners(cr, uid, message, ['From', 'To', 'Cc'], context=context)
701 msg_dict['partner_ids'] = [(4, partner_id) for partner_id in partner_ids]
703 if 'Date' in message:
704 date_hdr = decode(message.get('Date'))
705 # convert from email timezone to server timezone
706 date_server_datetime = dateutil.parser.parse(date_hdr).astimezone(pytz.timezone(tools.get_server_timezone()))
707 date_server_datetime_str = date_server_datetime.strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT)
708 msg_dict['date'] = date_server_datetime_str
710 if 'In-Reply-To' in message:
711 parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', '=', decode(message['In-Reply-To']))])
713 msg_dict['parent_id'] = parent_ids[0]
715 if 'References' in message and 'parent_id' not in msg_dict:
716 parent_ids = self.pool.get('mail.message').search(cr, uid, [('message_id', 'in',
717 [x.strip() for x in decode(message['References']).split()])])
719 msg_dict['parent_id'] = parent_ids[0]
721 msg_dict['body'], msg_dict['attachments'] = self._message_extract_payload(message)
724 #------------------------------------------------------
726 #------------------------------------------------------
728 def log(self, cr, uid, id, message, secondary=False, context=None):
729 _logger.warning("log() is deprecated. As this module inherit from "\
730 "mail.thread, the message will be managed by this "\
731 "module instead of by the res.log mechanism. Please "\
732 "use mail_thread.message_post() instead of the "\
733 "now deprecated res.log.")
734 self.message_post(cr, uid, [id], message, context=context)
736 def message_post(self, cr, uid, thread_id, body='', subject=None, type='notification',
737 subtype=None, parent_id=False, attachments=None, context=None, **kwargs):
738 """ Post a new message in an existing thread, returning the new
739 mail.message ID. Extra keyword arguments will be used as default
740 column values for the new mail.message record.
741 Auto link messages for same id and object
742 :param int thread_id: thread ID to post into, or list with one ID;
743 if False/0, mail.message model will also be set as False
744 :param str body: body of the message, usually raw HTML that will
746 :param str subject: optional subject
747 :param str type: mail_message.type
748 :param int parent_id: optional ID of parent message in this thread
749 :param tuple(str,str) attachments or list id: list of attachment tuples in the form
750 ``(name,content)``, where content is NOT base64 encoded
751 :return: ID of newly created mail.message
755 if attachments is None:
758 assert (not thread_id) or isinstance(thread_id, (int, long)) or \
759 (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"
760 if isinstance(thread_id, (list, tuple)):
761 thread_id = thread_id and thread_id[0]
762 mail_message = self.pool.get('mail.message')
763 model = context.get('thread_model', self._name) if thread_id else False
766 for name, content in attachments:
767 if isinstance(content, unicode):
768 content = content.encode('utf-8')
771 'datas': base64.b64encode(str(content)),
774 'res_model': context.get('thread_model') or self._name,
777 attachment_ids.append((0, 0, data_attach))
781 s_data = subtype.split('.')
783 s_data = ('mail', s_data[0])
784 ref = self.pool.get('ir.model.data').get_object_reference(cr, uid, s_data[0], s_data[1])
785 subtype_id = ref and ref[1] or False
789 # _mail_flat_thread: automatically set free messages to the first posted message
790 if self._mail_flat_thread and not parent_id and thread_id:
791 message_ids = mail_message.search(cr, uid, ['&', ('res_id', '=', thread_id), ('model', '=', model)], context=context, order="id ASC", limit=1)
792 parent_id = message_ids and message_ids[0] or False
793 # 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
795 message_ids = mail_message.search(cr, SUPERUSER_ID, [('id', '=', parent_id), ('parent_id', '!=', False)], context=context)
796 # avoid loops when finding ancestors
799 message = mail_message.browse(cr, SUPERUSER_ID, message_ids[0], context=context)
800 while (message.parent_id and message.parent_id.id not in processed_list):
801 processed_list.append(message.parent_id.id)
802 message = message.parent_id
803 parent_id = message.id
808 'res_id': thread_id or False,
810 'subject': subject or False,
812 'parent_id': parent_id,
813 'attachment_ids': attachment_ids,
814 'subtype_id': subtype_id,
817 # Avoid warnings about non-existing fields
818 for x in ('from', 'to', 'cc'):
821 return mail_message.create(cr, uid, values, context=context)
823 def message_post_user_api(self, cr, uid, thread_id, body='', subject=False, parent_id=False,
824 attachment_ids=None, context=None, content_subtype='plaintext', **kwargs):
825 """ Wrapper on message_post, used for user input :
827 - quick reply in Chatter (refer to mail.js), not
828 the mail.compose.message wizard
829 The purpose is to perform some pre- and post-processing:
830 - if body is plaintext: convert it into html
831 - if parent_id: handle reply to a previous message by adding the
832 parent partners to the message
833 - type and subtype: comment and mail.mt_comment by default
834 - attachment_ids: supposed not attached to any document; attach them
835 to the related document. Should only be set by Chatter.
837 ir_attachment = self.pool.get('ir.attachment')
838 mail_message = self.pool.get('mail.message')
840 # 1. Pre-processing: body, partner_ids, type and subtype
841 if content_subtype == 'plaintext':
842 body = tools.plaintext2html(body)
844 partner_ids = kwargs.pop('partner_ids', [])
846 parent_message = self.pool.get('mail.message').browse(cr, uid, parent_id, context=context)
847 partner_ids += [(4, partner.id) for partner in parent_message.partner_ids]
848 # TDE FIXME HACK: mail.thread -> private message
849 if self._name == 'mail.thread' and parent_message.author_id.id:
850 partner_ids.append((4, parent_message.author_id.id))
852 message_type = kwargs.pop('type', 'comment')
853 message_subtype = kwargs.pop('subtype', 'mail.mt_comment')
856 new_message_id = self.message_post(cr, uid, thread_id=thread_id, body=body, subject=subject, type=message_type,
857 subtype=message_subtype, parent_id=parent_id, context=context, partner_ids=partner_ids, **kwargs)
860 # HACK TDE FIXME: Chatter: attachments linked to the document (not done JS-side), load the message
862 # TDE FIXME (?): when posting a private message, we use mail.thread as a model
863 # However, attaching doc to mail.thread is not possible, mail.thread does not have any table
865 if model == 'mail.thread':
867 filtered_attachment_ids = ir_attachment.search(cr, SUPERUSER_ID, [
868 ('res_model', '=', 'mail.compose.message'),
870 ('create_uid', '=', uid),
871 ('id', 'in', attachment_ids)], context=context)
872 if filtered_attachment_ids:
873 if thread_id and model:
874 ir_attachment.write(cr, SUPERUSER_ID, attachment_ids, {'res_model': model, 'res_id': thread_id}, context=context)
875 mail_message.write(cr, SUPERUSER_ID, [new_message_id], {'attachment_ids': [(6, 0, [pid for pid in attachment_ids])]}, context=context)
877 return new_message_id
879 #------------------------------------------------------
881 #------------------------------------------------------
883 def message_get_subscription_data(self, cr, uid, ids, context=None):
884 """ Wrapper to get subtypes data. """
885 return self._get_subscription_data(cr, uid, ids, None, None, context=context)
887 def message_subscribe_users(self, cr, uid, ids, user_ids=None, subtype_ids=None, context=None):
888 """ Wrapper on message_subscribe, using users. If user_ids is not
889 provided, subscribe uid instead. """
892 partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
893 return self.message_subscribe(cr, uid, ids, partner_ids, subtype_ids=subtype_ids, context=context)
895 def message_subscribe(self, cr, uid, ids, partner_ids, subtype_ids=None, context=None):
896 """ Add partners to the records followers. """
897 self.write(cr, uid, ids, {'message_follower_ids': [(4, pid) for pid in partner_ids]}, context=context)
898 # if subtypes are not specified (and not set to a void list), fetch default ones
899 if subtype_ids is None:
900 subtype_obj = self.pool.get('mail.message.subtype')
901 subtype_ids = subtype_obj.search(cr, uid, [('default', '=', True), '|', ('res_model', '=', self._name), ('res_model', '=', False)], context=context)
902 # update the subscriptions
903 fol_obj = self.pool.get('mail.followers')
904 fol_ids = fol_obj.search(cr, SUPERUSER_ID, [('res_model', '=', self._name), ('res_id', 'in', ids), ('partner_id', 'in', partner_ids)], context=context)
905 fol_obj.write(cr, SUPERUSER_ID, fol_ids, {'subtype_ids': [(6, 0, subtype_ids)]}, context=context)
908 def message_unsubscribe_users(self, cr, uid, ids, user_ids=None, context=None):
909 """ Wrapper on message_subscribe, using users. If user_ids is not
910 provided, unsubscribe uid instead. """
913 partner_ids = [user.partner_id.id for user in self.pool.get('res.users').browse(cr, uid, user_ids, context=context)]
914 return self.message_unsubscribe(cr, uid, ids, partner_ids, context=context)
916 def message_unsubscribe(self, cr, uid, ids, partner_ids, context=None):
917 """ Remove partners from the records followers. """
918 return self.write(cr, uid, ids, {'message_follower_ids': [(3, pid) for pid in partner_ids]}, context=context)
920 #------------------------------------------------------
922 #------------------------------------------------------
924 def message_mark_as_unread(self, cr, uid, ids, context=None):
925 """ Set as unread. """
926 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
928 UPDATE mail_notification SET
931 message_id IN (SELECT id from mail_message where res_id=any(%s) and model=%s limit 1) and
933 ''', (ids, self._name, partner_id))
936 def message_mark_as_read(self, cr, uid, ids, context=None):
938 partner_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).partner_id.id
940 UPDATE mail_notification SET
943 message_id IN (SELECT id FROM mail_message WHERE res_id=ANY(%s) AND model=%s) AND
945 ''', (ids, self._name, partner_id))
948 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: