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 ##############################################################################
28 from email.utils import parsedate
29 from email.message import Message
30 from operator import itemgetter
32 from osv import osv, fields
33 from mail_message import decode, to_email
35 from tools.translate import _
36 from tools.safe_eval import safe_eval as eval
38 _logger = logging.getLogger(__name__)
40 class mail_thread(osv.Model):
41 '''Mixin model, meant to be inherited by any model that needs to
42 act as a discussion topic on which messages can be attached.
43 Public methods are prefixed with ``message_`` in order to avoid
44 name collisions with methods of the models that will inherit
47 ``mail.thread`` is designed to work without adding any field
48 to the extended models. All functionalities and expected behavior
49 are managed by mail.thread, using model name and record ids.
50 A widget has been designed for the 6.1 and following version of OpenERP
51 web-client. However, due to technical limitations, ``mail.thread``
52 adds a simulated one2many field, to display the web widget by
53 overriding the default field displayed. Using this field
54 is not recommanded has it will disappeear in future version
55 of OpenERP, leading to a pure mixin class.
57 Inheriting classes are not required to implement any method, as the
58 default implementation will work for any model. However it is common
59 to override at least the ``message_new`` and ``message_update``
60 methods (calling ``super``) to add model-specific behavior at
61 creation and update of a thread; and ``message_get_subscribers``
62 to manage more precisely the social aspect of the thread through
66 _description = 'Email Thread'
68 def _get_message_ids(self, cr, uid, ids, name, args, context=None):
71 message_ids = self.message_search(cr, uid, [id], context=context)
72 subscriber_ids = self.message_get_subscribers(cr, uid, [id], context=context)
74 'message_ids': message_ids,
75 'message_summary': "<span><span class='oe_e'>9</span> %d</span> <span><span class='oe_e'>+</span> %d</span>" % (len(message_ids), len(subscriber_ids)),
79 def _search_message_ids(self, cr, uid, obj, name, args, context=None):
80 msg_obj = self.pool.get('mail.message')
81 msg_ids = msg_obj.search(cr, uid, ['&', ('res_id', 'in', args[0][2]), ('model', '=', self._name)], context=context)
82 return [('id', 'in', msg_ids)]
85 'message_ids': fields.function(_get_message_ids,
86 fnct_search=_search_message_ids,
87 type='one2many', obj='mail.message', _fields_id = 'res_id',
88 string='Temp messages', multi="_get_message_ids",
89 help="Functional field holding messages related to the current document."),
90 'message_state': fields.boolean('Read',
91 help="When checked, new messages require your attention."),
92 'message_summary': fields.function(_get_message_ids, method=True,
93 type='text', string='Summary', multi="_get_message_ids",
94 help="Holds the Chatter summary (number of messages, ...). "\
95 "This summary is directly in html format in order to "\
96 "be inserted in kanban views."),
100 'message_state': True,
103 #------------------------------------------------------
104 # Automatic subscription when creating/reading
105 #------------------------------------------------------
107 def create(self, cr, uid, vals, context=None):
108 """Automatically subscribe the creator """
109 thread_id = super(mail_thread, self).create(cr, uid, vals, context=context)
111 self.message_subscribe(cr, uid, [thread_id], [uid], context=context)
114 def write(self, cr, uid, ids, vals, context=None):
115 """Automatically subscribe the writer"""
116 if isinstance(ids, (int, long)):
118 write_res = super(mail_thread, self).write(cr, uid, ids, vals, context=context);
120 self.message_subscribe(cr, uid, ids, [uid], context=context)
123 def unlink(self, cr, uid, ids, context=None):
124 """Override unlink, to automatically delete
127 that are linked with res_model and res_id, not through
128 a foreign key with a 'cascade' ondelete attribute.
129 Notifications will be deleted with messages
131 subscr_obj = self.pool.get('mail.subscription')
132 msg_obj = self.pool.get('mail.message')
133 # delete subscriptions
134 subscr_to_del_ids = subscr_obj.search(cr, uid, [('res_model', '=', self._name), ('res_id', 'in', ids)], context=context)
135 subscr_obj.unlink(cr, uid, subscr_to_del_ids, context=context)
136 # delete messages and notifications
137 msg_to_del_ids = msg_obj.search(cr, uid, [('model', '=', self._name), ('res_id', 'in', ids)], context=context)
138 msg_obj.unlink(cr, uid, msg_to_del_ids, context=context)
140 return super(mail_thread, self).unlink(cr, uid, ids, context=context)
142 #------------------------------------------------------
143 # mail.message wrappers and tools
144 #------------------------------------------------------
146 def message_create(self, cr, uid, thread_id, vals, context=None):
147 """ OpenChatter: wrapper of mail.message create method
148 - creates the mail.message
149 - automatically subscribe the message writer
150 - push the message to subscribed users
155 message_obj = self.pool.get('mail.message')
156 notification_obj = self.pool.get('mail.notification')
157 body = vals.get('body_html', '') if vals.get('content_subtype') == 'html' else vals.get('body_text', '')
159 # automatically subscribe the writer of the message
161 self.message_subscribe(cr, uid, [thread_id], [vals['user_id']], context=context)
164 msg_id = message_obj.create(cr, uid, vals, context=context)
166 # Set as unread if writer is not the document responsible
167 self.message_create_set_unread(cr, uid, [thread_id], context=context)
169 # special: if install mode, do not push demo data
170 if context.get('install_mode', False):
173 # get users that will get a notification pushed
174 user_to_push_ids = self.message_get_user_ids_to_notify(cr, uid, [thread_id], vals, context=context)
175 for id in user_to_push_ids:
176 notification_obj.create(cr, uid, {'user_id': id, 'message_id': msg_id}, context=context)
178 # create the email to send
179 email_id = self.message_create_notify_by_email(cr, uid, vals, user_to_push_ids, context=context)
183 def message_get_user_ids_to_notify(self, cr, uid, thread_ids, new_msg_vals, context=None):
184 subscription_obj = self.pool.get('mail.subscription')
186 body = new_msg_vals.get('body_html', '') if new_msg_vals.get('content_subtype') == 'html' else new_msg_vals.get('body_text', '')
189 notif_user_ids = self.message_get_subscribers(cr, uid, thread_ids, context=context)
191 # add users requested via parsing message (@login)
192 notif_user_ids += self.message_parse_users(cr, uid, body, context=context)
194 # add users requested to perform an action (need_action mechanism)
195 if hasattr(self, 'get_needaction_user_ids'):
196 user_ids_dict = self.get_needaction_user_ids(cr, uid, thread_ids, context=context)
197 for id, user_ids in user_ids_dict.iteritems():
198 notif_user_ids += user_ids
200 # add users notified of the parent messages (because: if parent message contains @login, login must receive the replies)
201 if new_msg_vals.get('parent_id'):
202 notif_obj = self.pool.get('mail.notification')
203 parent_notif_ids = notif_obj.search(cr, uid, [('message_id', '=', new_msg_vals.get('parent_id'))], context=context)
204 parent_notifs = notif_obj.read(cr, uid, parent_notif_ids, context=context)
205 notif_user_ids += [parent_notif['user_id'][0] for parent_notif in parent_notifs]
207 # remove duplicate entries
208 notif_user_ids = list(set(notif_user_ids))
209 return notif_user_ids
211 def message_parse_users(self, cr, uid, string, context=None):
212 """Parse message content
213 - if find @login -(^|\s)@((\w|@|\.)*)-: returns the related ids
214 this supports login that are emails (such as @raoul@grobedon.net)
216 regex = re.compile('(^|\s)@((\w|@|\.)*)')
217 login_lst = [item[1] for item in regex.findall(string)]
218 if not login_lst: return []
219 user_ids = self.pool.get('res.users').search(cr, uid, [('login', 'in', login_lst)], context=context)
222 #------------------------------------------------------
223 # Generic message api
224 #------------------------------------------------------
226 def message_capable_models(self, cr, uid, context=None):
228 for model_name in self.pool.obj_list():
229 model = self.pool.get(model_name)
230 if 'mail.thread' in getattr(model, '_inherit', []):
231 ret_dict[model_name] = model._description
234 def message_append(self, cr, uid, threads, subject, body_text=None, body_html=None,
235 type='email', email_date=None, parent_id=False,
236 content_subtype='plain', state=None,
237 partner_ids=None, email_from=False, email_to=False,
238 email_cc=None, email_bcc=None, reply_to=None,
239 headers=None, message_id=False, references=None,
240 attachments=None, original=None, context=None):
241 """ Creates a new mail.message through message_create. The new message
242 is attached to the current mail.thread, containing all the details
243 passed as parameters. All attachments will be attached to the
244 thread record as well as to the actual message.
246 This method calls message_create that will handle management of
247 subscription and notifications, and effectively create the message.
249 If ``email_from`` is not set or ``type`` not set as 'email',
250 a note message is created (comment or system notification),
251 without the usual envelope attributes (sender, recipients, etc.).
253 :param threads: list of thread ids, or list of browse_records
254 representing threads to which a new message should be attached
255 :param subject: subject of the message, or description of the event;
256 this is totally optional as subjects are not important except
257 for specific messages (blog post, job offers) or for emails
258 :param body_text: plaintext contents of the mail or log message
259 :param body_html: html contents of the mail or log message
260 :param type: type of message: 'email', 'comment', 'notification';
262 :param email_date: email date string if different from now, in
264 :param parent_id: id of the parent message (threaded messaging model)
265 :param content_subtype: optional content_subtype of message: 'plain'
266 or 'html', corresponding to the main body contents (body_text or
268 :param state: state of message
269 :param partner_ids: destination partners of the message, in addition
270 to the now fully optional email_to; this method is supposed to
271 received a list of ids is not None. The specific many2many
272 instruction will be generated by this method.
273 :param email_from: Email From / Sender address if any
274 :param email_to: Email-To / Recipient address
275 :param email_cc: Comma-Separated list of Carbon Copy Emails To
277 :param email_bcc: Comma-Separated list of Blind Carbon Copy Emails To
279 :param reply_to: reply_to header
280 :param headers: mail headers to store
281 :param message_id: optional email identifier
282 :param references: optional email references
283 :param dict attachments: map of attachment filenames to binary
285 :param str original: optional full source of the RFC2822 email, for
287 :param dict context: if a ``thread_model`` value is present in the
288 context, its value will be used to determine the model of the
289 thread to update (instead of the current model).
293 if attachments is None:
297 edate = parsedate(email_date)
298 if edate is not None:
299 email_date = time.strftime('%Y-%m-%d %H:%M:%S', edate)
301 if all(isinstance(thread_id, (int, long)) for thread_id in threads):
302 model = context.get('thread_model') or self._name
303 model_pool = self.pool.get(model)
304 threads = model_pool.browse(cr, uid, threads, context=context)
306 ir_attachment = self.pool.get('ir.attachment')
307 mail_message = self.pool.get('mail.message')
310 for thread in threads:
312 for attachment in attachments:
313 fname, fcontent = attachment
314 if isinstance(fcontent, unicode):
315 fcontent = fcontent.encode('utf-8')
318 'datas': base64.b64encode(str(fcontent)),
319 'datas_fname': fname,
320 'description': _('Mail attachment'),
321 'res_model': thread._name,
324 to_attach.append(ir_attachment.create(cr, uid, data_attach, context=context))
325 # find related partner: partner_id column in thread object, or self is res.partner model
326 partner_id = ('partner_id' in thread._columns.keys()) and (thread.partner_id and thread.partner_id.id or False) or False
327 if not partner_id and thread._name == 'res.partner':
328 partner_id = thread.id
329 # destination partners
330 if partner_ids is None:
332 mail_partner_ids = [(6, 0, partner_ids)]
336 'body_text': body_text or (hasattr(thread, 'description') and thread.description or ''),
337 'body_html': body_html or '',
338 'parent_id': parent_id,
339 'date': email_date or fields.datetime.now(),
341 'content_subtype': content_subtype,
343 'message_id': message_id,
344 'partner_ids': mail_partner_ids,
345 'attachment_ids': [(6, 0, to_attach)],
347 'model' : thread._name,
349 'partner_id': partner_id,
352 if email_from or type == 'email':
353 for param in (email_to, email_cc, email_bcc):
354 if isinstance(param, list):
355 param = ", ".join(param)
357 'email_to': email_to,
358 'email_from': email_from or \
359 (hasattr(thread, 'user_id') and thread.user_id and thread.user_id.user_email),
360 'email_cc': email_cc,
361 'email_bcc': email_bcc,
362 'references': references,
364 'reply_to': reply_to,
365 'original': original, })
367 new_msg_ids.append(self.message_create(cr, uid, thread.id, data, context=context))
370 def message_append_dict(self, cr, uid, ids, msg_dict, context=None):
371 """Creates a new mail.message attached to the given threads (``ids``),
372 with the contents of ``msg_dict``, by calling ``message_append``
373 with the mail details. All attachments in msg_dict will be
374 attached to the object record as well as to the actual
377 :param dict msg_dict: a map containing the email details and
378 attachments. See ``message_process()`` and
379 ``mail.message.parse()`` for details on
381 :param dict context: if a ``thread_model`` value is present
382 in the context, its value will be used
383 to determine the model of the thread to
384 update (instead of the current model).
386 return self.message_append(cr, uid, ids,
387 subject = msg_dict.get('subject'),
388 body_text = msg_dict.get('body_text'),
389 body_html= msg_dict.get('body_html'),
390 parent_id = msg_dict.get('parent_id', False),
391 type = msg_dict.get('type', 'email'),
392 content_subtype = msg_dict.get('content_subtype'),
393 state = msg_dict.get('state'),
394 partner_ids = msg_dict.get('partner_ids'),
395 email_from = msg_dict.get('from', msg_dict.get('email_from')),
396 email_to = msg_dict.get('to', msg_dict.get('email_to')),
397 email_cc = msg_dict.get('cc', msg_dict.get('email_cc')),
398 email_bcc = msg_dict.get('bcc', msg_dict.get('email_bcc')),
399 reply_to = msg_dict.get('reply', msg_dict.get('reply_to')),
400 email_date = msg_dict.get('date'),
401 message_id = msg_dict.get('message-id', msg_dict.get('message_id')),
402 references = msg_dict.get('references')\
403 or msg_dict.get('in-reply-to'),
404 attachments = msg_dict.get('attachments'),
405 headers = msg_dict.get('headers'),
406 original = msg_dict.get('original'),
409 #------------------------------------------------------
411 #------------------------------------------------------
413 def _message_search_ancestor_ids(self, cr, uid, ids, child_ids, ancestor_ids, context=None):
414 """ Given message child_ids ids, find their ancestors until ancestor_ids
415 using their parent_id relationship.
417 :param child_ids: the first nodes of the search
418 :param ancestor_ids: list of ancestors. When the search reach an
421 def _get_parent_ids(message_list, ancestor_ids, child_ids):
422 """ Tool function: return the list of parent_ids of messages
423 contained in message_list. Parents that are in ancestor_ids
424 or in child_ids are not returned. """
425 return [message['parent_id'][0] for message in message_list
426 if message['parent_id']
427 and message['parent_id'][0] not in ancestor_ids
428 and message['parent_id'][0] not in child_ids
431 message_obj = self.pool.get('mail.message')
432 messages_temp = message_obj.read(cr, uid, child_ids, ['id', 'parent_id'], context=context)
433 parent_ids = _get_parent_ids(messages_temp, ancestor_ids, child_ids)
434 child_ids += parent_ids
435 cur_iter = 0; max_iter = 100; # avoid infinite loop
436 while (parent_ids and (cur_iter < max_iter)):
438 messages_temp = message_obj.read(cr, uid, parent_ids, ['id', 'parent_id'], context=context)
439 parent_ids = _get_parent_ids(messages_temp, ancestor_ids, child_ids)
440 child_ids += parent_ids
441 if (cur_iter > max_iter):
442 _logger.warning("Possible infinite loop in _message_search_ancestor_ids. "\
443 "Note that this algorithm is intended to check for cycle in "\
444 "message graph, leading to a curious error. Have fun.")
447 def message_search_get_domain(self, cr, uid, ids, context=None):
448 """ OpenChatter feature: get the domain to search the messages related
449 to a document. mail.thread defines the default behavior as
450 being messages with model = self._name, id in ids.
451 This method should be overridden if a model has to implement a
454 return ['&', ('res_id', 'in', ids), ('model', '=', self._name)]
456 def message_search(self, cr, uid, ids, fetch_ancestors=False, ancestor_ids=None,
457 limit=100, offset=0, domain=None, count=False, context=None):
458 """ OpenChatter feature: return thread messages ids according to the
459 search domain given by ``message_search_get_domain``.
461 It is possible to add in the search the parent of messages by
462 setting the fetch_ancestors flag to True. In that case, using
463 the parent_id relationship, the method returns the id list according
464 to the search domain, but then calls ``_message_search_ancestor_ids``
465 that will add to the list the ancestors ids. The search is limited
466 to parent messages having an id in ancestor_ids or having
467 parent_id set to False.
469 If ``count==True``, the number of ids is returned instead of the
470 id list. The count is done by hand instead of passing it as an
471 argument to the search call because we might want to perform
472 a research including parent messages until some ancestor_ids.
474 :param fetch_ancestors: performs an ascended search; will add
475 to fetched msgs all their parents until
477 :param ancestor_ids: used when fetching ancestors
478 :param domain: domain to add to the search; especially child_of
479 is interesting when dealing with threaded display.
480 Note that the added domain is anded with the
482 :param limit, offset, count, context: as usual
484 search_domain = self.message_search_get_domain(cr, uid, ids, context=context)
486 search_domain += domain
487 message_obj = self.pool.get('mail.message')
488 message_res = message_obj.search(cr, uid, search_domain, limit=limit, offset=offset, count=count, context=context)
489 if not count and fetch_ancestors:
490 message_res += self._message_search_ancestor_ids(cr, uid, ids, message_res, ancestor_ids, context=context)
493 def message_read(self, cr, uid, ids, fetch_ancestors=False, ancestor_ids=None,
494 limit=100, offset=0, domain=None, context=None):
495 """ OpenChatter feature: read the messages related to some threads.
496 This method is used mainly the Chatter widget, to directly have
497 read result instead of searching then reading.
499 Please see message_search for more information about the parameters.
501 message_ids = self.message_search(cr, uid, ids, fetch_ancestors, ancestor_ids,
502 limit, offset, domain, context=context)
503 messages = self.pool.get('mail.message').read(cr, uid, message_ids, context=context)
505 """ Retrieve all attachments names """
506 map_id_to_name = dict((attachment_id, '') for message in messages for attachment_id in message['attachment_ids'])
509 for attach_id in msg["attachment_ids"]:
510 map_id_to_name[attach_id] = '' # use empty string as a placeholder
512 ids = map_id_to_name.keys()
513 names = self.pool.get('ir.attachment').name_get(cr, uid, ids, context=context)
515 # convert the list of tuples into a dictionnary
517 map_id_to_name[name[0]] = name[1]
519 # give corresponding ids and names to each message
521 msg["attachments"] = []
523 for attach_id in msg["attachment_ids"]:
524 msg["attachments"].append({'id': attach_id, 'name': map_id_to_name[attach_id]})
526 # Set the threads as read
527 self.message_check_and_set_read(cr, uid, ids, context=context)
528 # Sort and return the messages
529 messages = sorted(messages, key=lambda d: (-d['id']))
532 def message_get_pushed_messages(self, cr, uid, ids, fetch_ancestors=False, ancestor_ids=None,
533 limit=100, offset=0, msg_search_domain=[], context=None):
534 """ OpenChatter: wall: get the pushed notifications and used them
535 to fetch messages to display on the wall.
537 :param fetch_ancestors: performs an ascended search; will add
538 to fetched msgs all their parents until
540 :param ancestor_ids: used when fetching ancestors
541 :param domain: domain to add to the search; especially child_of
542 is interesting when dealing with threaded display
543 :param ascent: performs an ascended search; will add to fetched msgs
544 all their parents until root_ids
545 :param root_ids: for ascent search
546 :return: list of mail.messages sorted by date
548 notification_obj = self.pool.get('mail.notification')
549 msg_obj = self.pool.get('mail.message')
550 # update message search
551 for arg in msg_search_domain:
552 if isinstance(arg, (tuple, list)):
553 arg[0] = 'message_id.' + arg[0]
554 # compose final domain
555 domain = [('user_id', '=', uid)] + msg_search_domain
557 notification_ids = notification_obj.search(cr, uid, domain, limit=limit, offset=offset, context=context)
558 notifications = notification_obj.browse(cr, uid, notification_ids, context=context)
559 msg_ids = [notification.message_id.id for notification in notifications]
561 msg_ids = msg_obj.search(cr, uid, [('id', 'in', msg_ids)], context=context)
562 if (fetch_ancestors): msg_ids = self._message_search_ancestor_ids(cr, uid, ids, msg_ids, ancestor_ids, context=context)
563 msgs = msg_obj.read(cr, uid, msg_ids, context=context)
566 def _message_find_user_id(self, cr, uid, message, context=None):
567 from_local_part = to_email(decode(message.get('From')))[0]
568 user_ids = self.pool.get('res.users').search(cr, uid, [('login', '=', from_local_part)], context=context)
569 return user_ids[0] if user_ids else uid
571 #------------------------------------------------------
573 #------------------------------------------------------
574 # message_process will call either message_new or message_update.
576 def message_route(self, cr, uid, message, model=None, thread_id=None,
577 custom_values=None, context=None):
578 """Attempt to figure out the correct target model, thread_id,
579 custom_values and user_id to use for an incoming message.
581 The following heuristics are used, in this order:
582 1. If the message replies to an existing thread_id, and
583 properly contains the thread model in the 'In-Reply-To'
584 header, use this model/thread_id pair, and ignore
585 custom_value (not needed as no creation will take place)
586 2. Look for a mail.alias entry matching the message
587 recipient, and use the corresponding model, thread_id,
588 custom_values and user_id.
589 3. Fallback to the ``model``, ``thread_id`` and ``custom_values``
591 4. If all the above fails, raise an exception.
593 :param string message: an email.message instance
594 :param string model: the fallback model to use if the message
595 does not match any of the currently configured mail aliases
596 (may be None if a matching alias is supposed to be present)
597 :type dict custom_values: optional dictionary of default field values
598 to pass to ``message_new`` if a new record needs to be created.
599 Ignored if the thread record already exists, and also if a
600 matching mail.alias was found (aliases define their own defaults)
601 :param int thread_id: optional ID of the record/thread from ``model``
602 to which this mail should be attached. Only used if the message
603 does not reply to an existing thread and does not match any mail alias.
604 :return: model, thread_id, custom_values, user_id
606 assert isinstance(message, Message), 'message must be an email.message.Message at this point'
607 message_id = message.get('Message-Id')
609 # 1. Verify if this is a reply to an existing thread
610 references = message.get('References') or message.get('In-Reply-To')
611 ref_match = references and tools.reference_re.search(references)
613 thread_id = int(ref_match.group(1))
614 model = ref_match.group(2) or model
615 model_pool = self.pool.get(model)
616 if thread_id and model and model_pool and model_pool.exists(cr, uid, thread_id) \
617 and hasattr(model_pool, 'message_update'):
618 _logger.debug('Routing mail with Message-Id %s: direct reply to model:%s, thread_id:%s, custom_values:%s, uid:%s',
619 message_id, model, thread_id, custom_values, uid)
620 return model, thread_id, custom_values, uid
622 # 2. Look for a matching mail.alias entry
623 # Delivered-To is a safe bet in most modern MTAs, but we have to fallback on To + Cc values
624 # for all the odd MTAs out there, as there is no standard header for the envelope's `rcpt_to` value.
625 rcpt_to = message.get_all('Delivered-To', []) or (message.get_all('To', []) + message.get_all('Cc', []))
626 local_parts = [e.split('@')[0] for e in to_email(u','.join(map(decode,rcpt_to)))]
628 mail_alias = self.pool.get('mail.alias')
629 alias_ids = mail_alias.search(cr, uid, [('alias_name', 'in', local_parts)])
631 if len(alias_ids) > 1:
632 _logger.warning('Multiple mail.aliases match for mail with Message-Id %s, keeping first one only: %s',
633 message_id, alias_ids)
634 alias = mail_alias.browse(cr, uid, alias_ids[0], context=context)
635 user_id = alias.alias_user_id.id
637 user_id = self._message_find_user_id(cr, uid, message, context=context)
638 _logger.debug('Routing mail with Message-Id %s: direct alias match: model:%s, thread_id:%s, custom_values:%s, uid:%s',
639 message_id, model, thread_id, custom_values, uid)
640 return alias.alias_model_id.model, alias.alias_force_thread_id, \
641 eval(alias.alias_defaults), user_id
643 # 3. Fallback to the provided parameters, if they work
644 model_pool = self.pool.get(model)
645 assert thread_id and hasattr(model_pool, 'message_update') or hasattr(model_pool, 'message_new'), \
646 "No possible route found for incoming message with Message-Id %s. " \
647 "Create an appropriate mail.alias or force the destination model."
648 if not model_pool.exists(cr, uid, thread_id):
649 _logger.warning('Received mail reply to missing document %s! Ignoring and creating new document instead for Message-Id %s',
650 thread_id, message_id)
652 _logger.debug('Routing mail with Message-Id %s: fallback to model:%s, thread_id:%s, custom_values:%s, uid:%s',
653 message_id, model, thread_id, custom_values, uid)
654 return model, thread_id, custom_values, uid
659 def message_process(self, cr, uid, model, message, custom_values=None,
660 save_original=False, strip_attachments=False,
661 thread_id=None, context=None):
662 """Process an incoming RFC2822 email message, relying on
663 ``mail.message.parse()`` for the parsing operation,
664 and ``message_route()`` to figure out the target model.
666 Once the target model is known, its ``message_new`` method
667 is called with the new message (if the thread record did not exist)
668 or its ``message_update`` method (if it did). Finally,
669 ``message_forward`` is called to automatically notify other
670 people that should receive this message.
672 :param string model: the fallback model to use if the message
673 does not match any of the currently configured mail aliases
674 (may be None if a matching alias is supposed to be present)
675 :param message: source of the RFC2822 message
676 :type message: string or xmlrpclib.Binary
677 :type dict custom_values: optional dictionary of field values
678 to pass to ``message_new`` if a new record needs to be created.
679 Ignored if the thread record already exists, and also if a
680 matching mail.alias was found (aliases define their own defaults)
681 :param bool save_original: whether to keep a copy of the original
682 email source attached to the message after it is imported.
683 :param bool strip_attachments: whether to strip all attachments
684 before processing the message, in order to save some space.
685 :param int thread_id: optional ID of the record/thread from ``model``
686 to which this mail should be attached. When provided, this
687 overrides the automatic detection based on the message
690 if context is None: context = {}
692 # extract message bytes - we are forced to pass the message as binary because
693 # we don't know its encoding until we parse its headers and hence can't
694 # convert it to utf-8 for transport between the mailgate script and here.
695 if isinstance(message, xmlrpclib.Binary):
696 message = str(message.data)
697 # Warning: message_from_string doesn't always work correctly on unicode,
698 # we must use utf-8 strings here :-(
699 if isinstance(message, unicode):
700 message = message.encode('utf-8')
701 msg_txt = email.message_from_string(message)
702 model, thread_id, custom_values, user_id = self.message_route(cr, uid, msg_txt, model,
703 thread_id, custom_values,
705 if self._name != model:
706 context.update({'thread_model': model})
707 msg = self.pool.get('mail.message').parse_message(msg_txt, save_original=save_original, context=context)
708 msg['state'] = 'received'
709 if strip_attachments and 'attachments' in msg:
710 del msg['attachments']
712 model_pool = self.pool.get(model)
713 assert thread_id and hasattr(model_pool, 'message_update') or hasattr(model_pool, 'message_new'), \
714 "Undeliverable mail with Message-Id %s, model %s does not accept incoming emails" % \
715 (msg['message-id'], model)
716 if thread_id and hasattr(model_pool, 'message_update'):
717 model_pool.message_update(cr, user_id, [thread_id], msg, context=context)
719 thread_id = model_pool.message_new(cr, user_id, msg, custom_values, context=context)
721 # Forward the email to other followers
722 self.message_forward(cr, uid, model, [thread_id], msg_txt, context=context)
723 model_pool.message_mark_as_unread(cr, uid, [thread_id], context=context)
726 def message_new(self, cr, uid, msg_dict, custom_values=None, context=None):
727 """Called by ``message_process`` when a new message is received
728 for a given thread model, if the message did not belong to
730 The default behavior is to create a new record of the corresponding
731 model (based on some very basic info extracted from the message),
732 then attach the message to the newly created record
733 (by calling ``message_append_dict``).
734 Additional behavior may be implemented by overriding this method.
736 :param dict msg_dict: a map containing the email details and
737 attachments. See ``message_process`` and
738 ``mail.message.parse`` for details.
739 :param dict custom_values: optional dictionary of additional
740 field values to pass to create()
741 when creating the new thread record.
742 Be careful, these values may override
743 any other values coming from the message.
744 :param dict context: if a ``thread_model`` value is present
745 in the context, its value will be used
746 to determine the model of the record
747 to create (instead of the current model).
749 :return: the id of the newly created thread object
753 model = context.get('thread_model') or self._name
754 model_pool = self.pool.get(model)
755 fields = model_pool.fields_get(cr, uid, context=context)
756 data = model_pool.default_get(cr, uid, fields, context=context)
757 if 'name' in fields and not data.get('name'):
758 data['name'] = msg_dict.get('from', '')
759 if custom_values and isinstance(custom_values, dict):
760 data.update(custom_values)
761 res_id = model_pool.create(cr, uid, data, context=context)
762 self.message_append_dict(cr, uid, [res_id], msg_dict, context=context)
765 def message_update(self, cr, uid, ids, msg_dict, update_vals=None, context=None):
766 """Called by ``message_process`` when a new message is received
767 for an existing thread. The default behavior is to create a
768 new mail.message in the given thread (by calling
769 ``message_append_dict``)
770 Additional behavior may be implemented by overriding this
772 :param dict msg_dict: a map containing the email details and
773 attachments. See ``message_process`` and
774 ``mail.message.parse()`` for details.
775 :param dict update_vals: a dict containing values to update records
776 given their ids; if the dict is None or is
777 void, no write operation is performed.
780 self.write(cr, uid, ids, update_vals, context=context)
781 return self.message_append_dict(cr, uid, ids, msg_dict, context=context)
783 def message_thread_followers(self, cr, uid, ids, context=None):
784 """ Returns a list of email addresses of the people following
785 this thread, including the sender of each mail, and the
786 people who were in CC of the messages, if any.
789 if isinstance(ids, (str, int, long)):
791 for thread in self.browse(cr, uid, ids, context=context):
793 for message in thread.message_ids:
794 l.add((message.user_id and message.user_id.user_email) or '')
795 l.add(message.email_from or '')
796 l.add(message.email_cc or '')
797 res[thread.id] = filter(None, l)
800 def message_forward(self, cr, uid, model, thread_ids, msg, email_error=False, context=None):
801 """Sends an email to all people following the given threads.
802 The emails are forwarded immediately, not queued for sending,
805 :param str model: thread model
806 :param list thread_ids: ids of the thread records
807 :param msg: email.message.Message object to forward
808 :param email_error: optional email address to notify in case
809 of any delivery error during the forward.
812 model_pool = self.pool.get(model)
813 smtp_server_obj = self.pool.get('ir.mail_server')
814 mail_message = self.pool.get('mail.message')
815 for res in model_pool.browse(cr, uid, thread_ids, context=context):
816 if hasattr(model_pool, 'message_thread_followers'):
817 followers = model_pool.message_thread_followers(cr, uid, [res.id])[res.id]
819 followers = self.message_thread_followers(cr, uid, [res.id])[res.id]
820 message_followers_emails = to_email(','.join(filter(None, followers)))
821 message_recipients = to_email(','.join(filter(None,
822 [decode(msg['from']),
824 decode(msg['cc'])])))
825 forward_to = [i for i in message_followers_emails if (i and (i not in message_recipients))]
827 # TODO: we need an interface for this for all types of objects, not just leads
828 if hasattr(res, 'section_id'):
830 msg['reply-to'] = res.section_id.reply_to
832 smtp_from, = to_email(msg['from'])
833 msg['from'] = smtp_from
834 msg['to'] = ", ".join(forward_to)
835 msg['message-id'] = tools.generate_tracking_message_id(res.id)
836 if not smtp_server_obj.send_email(cr, uid, msg) and email_error:
837 subj = msg['subject']
838 del msg['subject'], msg['to'], msg['cc'], msg['bcc']
839 msg['subject'] = _('[OpenERP-Forward-Failed] %s') % subj
840 msg['to'] = email_error
841 smtp_server_obj.send_email(cr, uid, msg)
844 def message_partner_by_email(self, cr, uid, email, context=None):
845 """Attempts to return the id of a partner address matching
846 the given ``email``, and the corresponding partner id.
847 Can be used by classes using the ``mail.thread`` mixin
848 to lookup the partner and use it in their implementation
849 of ``message_new`` to link the new record with a
850 corresponding partner.
851 The keys used in the returned dict are meant to map
852 to usual names for relationships towards a partner
853 and one of its addresses.
855 :param email: email address for which a partner
856 should be searched for.
858 :return: a map of the following form::
860 { 'partner_address_id': id or False,
861 'partner_id': pid or False }
863 partner_pool = self.pool.get('res.partner')
864 res = {'partner_id': False}
866 email = to_email(email)[0]
867 contact_ids = partner_pool.search(cr, uid, [('email', '=', email)])
869 contact = partner_pool.browse(cr, uid, contact_ids[0])
870 res['partner_id'] = contact.id
873 # for backwards-compatibility with old scripts
874 process_email = message_process
876 #------------------------------------------------------
878 #------------------------------------------------------
880 def log(self, cr, uid, id, message, secondary=False, context=None):
881 _logger.warning("log() is deprecated. As this module inherit from \
882 mail.thread, the message will be managed by this \
883 module instead of by the res.log mechanism. Please \
884 use the mail.thread OpenChatter API instead of the \
885 now deprecated res.log.")
886 self.message_append_note(cr, uid, [id], 'res.log', message, context=context)
888 def message_append_note(self, cr, uid, ids, subject=None, body=None, parent_id=False,
889 type='notification', content_subtype='html', context=None):
890 if content_subtype == 'html':
896 return self.message_append(cr, uid, ids, subject, body_html, body_text,
897 type, parent_id=parent_id,
898 content_subtype=content_subtype, context=context)
900 #------------------------------------------------------
901 # Subscription mechanism
902 #------------------------------------------------------
904 def message_get_subscribers(self, cr, uid, ids, context=None):
905 """ Returns the current document followers. Basically this method
906 checks in mail.subscription for entries with matching res_model,
908 This method can be overriden to add implicit subscribers, such
909 as project managers, by adding their user_id to the list of
910 ids returned by this method.
912 subscr_obj = self.pool.get('mail.subscription')
913 subscr_ids = subscr_obj.search(cr, uid, ['&', ('res_model', '=', self._name), ('res_id', 'in', ids)], context=context)
914 return [sub['user_id'][0] for sub in subscr_obj.read(cr, uid, subscr_ids, ['user_id'], context=context)]
916 def message_read_subscribers(self, cr, uid, ids, fields=['id', 'name', 'image_small'], context=None):
917 """ Returns the current document followers as a read result. Used
918 mainly for Chatter having only one method to call to have
921 user_ids = self.message_get_subscribers(cr, uid, ids, context=context)
922 return self.pool.get('res.users').read(cr, uid, user_ids, fields=fields, context=context)
924 def message_is_subscriber(self, cr, uid, ids, user_id = None, context=None):
925 """ Check if uid or user_id (if set) is a subscriber to the current
928 :param user_id: if set, check is done on user_id; if not set
931 sub_user_id = uid if user_id is None else user_id
932 if sub_user_id in self.message_get_subscribers(cr, uid, ids, context=context):
936 def message_subscribe(self, cr, uid, ids, user_ids = None, context=None):
937 """ Subscribe the user (or user_ids) to the current document.
939 :param user_ids: a list of user_ids; if not set, subscribe
942 subscription_obj = self.pool.get('mail.subscription')
943 to_subscribe_uids = [uid] if user_ids is None else user_ids
946 already_subscribed_user_ids = self.message_get_subscribers(cr, uid, [id], context=context)
947 for user_id in to_subscribe_uids:
948 if user_id in already_subscribed_user_ids: continue
949 create_ids.append(subscription_obj.create(cr, uid, {'res_model': self._name, 'res_id': id, 'user_id': user_id}, context=context))
952 def message_unsubscribe(self, cr, uid, ids, user_ids = None, context=None):
953 """ Unsubscribe the user (or user_ids) from the current document.
955 :param user_ids: a list of user_ids; if not set, subscribe
958 # Trying to unsubscribe somebody not in subscribers: returns False
959 # if special management is needed; allows to know that an automatically
960 # subscribed user tries to unsubscribe and allows to warn him
961 to_unsubscribe_uids = [uid] if user_ids is None else user_ids
962 subscription_obj = self.pool.get('mail.subscription')
963 to_delete_sub_ids = subscription_obj.search(cr, uid,
964 ['&', '&', ('res_model', '=', self._name), ('res_id', 'in', ids), ('user_id', 'in', to_unsubscribe_uids)], context=context)
965 if not to_delete_sub_ids:
967 return subscription_obj.unlink(cr, uid, to_delete_sub_ids, context=context)
969 #------------------------------------------------------
971 #------------------------------------------------------
973 def message_create_notify_by_email(self, cr, uid, new_msg_values, user_to_notify_ids, context=None):
974 """ When creating a new message and pushing notifications, emails
975 must be send if users have chosen to receive notifications
976 by email via the notification_email_pref field.
978 ``notification_email_pref`` can have 3 values :
979 - all: receive all notification by email (for example for shared
981 - to_me: messages send directly to me (@login, messages on res.users)
982 - never: never receive notifications
983 Note that an user should never receive notifications for messages
986 :param new_msg_values: dictionary of message values, those that
987 are given to the create method
988 :param user_to_notify_ids: list of user_ids, user that will
989 receive a notification on their Wall
991 message_obj = self.pool.get('mail.message')
992 res_users_obj = self.pool.get('res.users')
993 body = new_msg_values.get('body_html', '') if new_msg_values.get('content_subtype') == 'html' else new_msg_values.get('body_text', '')
995 # remove message writer
996 if user_to_notify_ids.count(new_msg_values.get('user_id')) > 0:
997 user_to_notify_ids.remove(new_msg_values.get('user_id'))
999 # get user_ids directly asked
1000 user_to_push_from_parse_ids = self.message_parse_users(cr, uid, body, context=context)
1002 # try to find an email_to
1004 for user in res_users_obj.browse(cr, uid, user_to_notify_ids, context=context):
1005 if not user.notification_email_pref == 'all' and \
1006 not (user.notification_email_pref == 'to_me' and user.id in user_to_push_from_parse_ids):
1008 if not user.user_email:
1010 email_to = '%s, %s' % (email_to, user.user_email)
1011 email_to = email_to.lstrip(', ')
1013 # did not find any email address: not necessary to create an email
1017 # try to find an email_from
1018 current_user = res_users_obj.browse(cr, uid, [uid], context=context)[0]
1019 email_from = new_msg_values.get('email_from')
1021 email_from = current_user.user_email
1023 # get email content, create it (with mail_message.create)
1024 email_values = self.message_create_notify_get_email_dict(cr, uid, new_msg_values, email_from, email_to, context)
1025 email_id = message_obj.create(cr, uid, email_values, context=context)
1028 def message_create_notify_get_email_dict(self, cr, uid, new_msg_values, email_from, email_to, context=None):
1029 values = dict(new_msg_values)
1031 body_html = new_msg_values.get('body_html', '')
1033 body_html += '\n\n----------\nThis email was send automatically by OpenERP, because you have subscribed to a document.'
1034 body_text = new_msg_values.get('body_text', '')
1036 body_text += '\n\n----------\nThis email was send automatically by OpenERP, because you have subscribed to a document.'
1039 'state': 'outgoing',
1040 'email_from': email_from,
1041 'email_to': email_to,
1042 'subject': 'New message',
1043 'content_subtype': new_msg_values.get('content_subtype', 'plain'),
1044 'body_html': body_html,
1045 'body_text': body_text,
1046 'auto_delete': True,
1052 def message_remove_pushed_notifications(self, cr, uid, ids, msg_ids, remove_childs=True, context=None):
1053 notif_obj = self.pool.get('mail.notification')
1054 msg_obj = self.pool.get('mail.message')
1056 notif_msg_ids = msg_obj.search(cr, uid, [('id', 'child_of', msg_ids)], context=context)
1058 notif_msg_ids = msg_ids
1059 to_del_notif_ids = notif_obj.search(cr, uid, ['&', ('user_id', '=', uid), ('message_id', 'in', notif_msg_ids)], context=context)
1060 return notif_obj.unlink(cr, uid, to_del_notif_ids, context=context)
1062 #------------------------------------------------------
1064 #------------------------------------------------------
1066 def message_create_set_unread(self, cr, uid, ids, context=None):
1067 """ When creating a new message, set as unread if uid is not the
1068 object responsible. """
1069 for obj in self.browse(cr, uid, ids, context=context):
1070 if obj.message_state and hasattr(obj, 'user_id') and (not obj.user_id or obj.user_id.id != uid):
1071 self.message_mark_as_unread(cr, uid, [obj.id], context=context)
1073 def message_check_and_set_unread(self, cr, uid, ids, context=None):
1074 """ Set unread if uid is the object responsible or if the object has
1076 for obj in self.browse(cr, uid, ids, context=context):
1077 if obj.message_state and hasattr(obj, 'user_id') and (not obj.user_id or obj.user_id.id == uid):
1078 self.message_mark_as_unread(cr, uid, [obj.id], context=context)
1080 def message_mark_as_unread(self, cr, uid, ids, context=None):
1081 """ Set as unread. """
1082 return self.write(cr, uid, ids, {'message_state': False}, context=context)
1084 def message_check_and_set_read(self, cr, uid, ids, context=None):
1085 """ Set read if uid is the object responsible. """
1086 for obj in self.browse(cr, uid, ids, context=context):
1087 if not obj.message_state and hasattr(obj, 'user_id') and obj.user_id and obj.user_id.id == uid:
1088 self.message_mark_as_read(cr, uid, [obj.id], context=context)
1090 def message_mark_as_read(self, cr, uid, ids, context=None):
1091 """ Set as read. """
1092 return self.write(cr, uid, ids, {'message_state': True}, context=context)
1095 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: