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
31 from osv import osv, fields
32 from mail_message import decode, to_email
34 from tools.translate import _
35 from tools.safe_eval import safe_eval as eval
37 _logger = logging.getLogger(__name__)
40 def decode_header(message, header, separator=' '):
41 return separator.join(map(decode,message.get_all(header, [])))
44 class mail_thread(osv.Model):
45 '''Mixin model, meant to be inherited by any model that needs to
46 act as a discussion topic on which messages can be attached.
47 Public methods are prefixed with ``message_`` in order to avoid
48 name collisions with methods of the models that will inherit
51 ``mail.thread`` is designed to work without adding any field
52 to the extended models. All functionalities and expected behavior
53 are managed by mail.thread, using model name and record ids.
54 A widget has been designed for the 6.1 and following version of OpenERP
55 web-client. However, due to technical limitations, ``mail.thread``
56 adds a simulated one2many field, to display the web widget by
57 overriding the default field displayed. Using this field
58 is not recommanded has it will disappeear in future version
59 of OpenERP, leading to a pure mixin class.
61 Inheriting classes are not required to implement any method, as the
62 default implementation will work for any model. However it is common
63 to override at least the ``message_new`` and ``message_update``
64 methods (calling ``super``) to add model-specific behavior at
65 creation and update of a thread; and ``message_get_subscribers``
66 to manage more precisely the social aspect of the thread through
70 _description = 'Email Thread'
72 def _get_message_ids(self, cr, uid, ids, name, args, context=None):
75 message_ids = self.message_search(cr, uid, [id], context=context)
76 subscriber_ids = self.message_get_subscribers(cr, uid, [id], context=context)
78 'message_ids': message_ids,
79 '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)),
83 def _search_message_ids(self, cr, uid, obj, name, args, context=None):
84 msg_obj = self.pool.get('mail.message')
85 msg_ids = msg_obj.search(cr, uid, ['&', ('res_id', 'in', args[0][2]), ('model', '=', self._name)], context=context)
86 return [('id', 'in', msg_ids)]
89 'message_ids': fields.function(_get_message_ids,
90 fnct_search=_search_message_ids,
91 type='one2many', obj='mail.message', _fields_id = 'res_id',
92 string='Temp messages', multi="_get_message_ids",
93 help="Functional field holding messages related to the current document."),
94 'message_state': fields.boolean('Read',
95 help="When checked, new messages require your attention."),
96 'message_summary': fields.function(_get_message_ids, method=True,
97 type='text', string='Summary', multi="_get_message_ids",
98 help="Holds the Chatter summary (number of messages, ...). "\
99 "This summary is directly in html format in order to "\
100 "be inserted in kanban views."),
104 'message_state': True,
107 #------------------------------------------------------
108 # Automatic subscription when creating/reading
109 #------------------------------------------------------
111 def create(self, cr, uid, vals, context=None):
112 """Automatically subscribe the creator """
113 thread_id = super(mail_thread, self).create(cr, uid, vals, context=context)
115 self.message_subscribe(cr, uid, [thread_id], [uid], context=context)
118 def write(self, cr, uid, ids, vals, context=None):
119 """Automatically subscribe the writer"""
120 if isinstance(ids, (int, long)):
122 write_res = super(mail_thread, self).write(cr, uid, ids, vals, context=context);
124 self.message_subscribe(cr, uid, ids, [uid], context=context)
127 def unlink(self, cr, uid, ids, context=None):
128 """Override unlink, to automatically delete
131 that are linked with res_model and res_id, not through
132 a foreign key with a 'cascade' ondelete attribute.
133 Notifications will be deleted with messages
135 subscr_obj = self.pool.get('mail.subscription')
136 msg_obj = self.pool.get('mail.message')
137 # delete subscriptions
138 subscr_to_del_ids = subscr_obj.search(cr, uid, [('res_model', '=', self._name), ('res_id', 'in', ids)], context=context)
139 subscr_obj.unlink(cr, uid, subscr_to_del_ids, context=context)
140 # delete messages and notifications
141 msg_to_del_ids = msg_obj.search(cr, uid, [('model', '=', self._name), ('res_id', 'in', ids)], context=context)
142 msg_obj.unlink(cr, uid, msg_to_del_ids, context=context)
144 return super(mail_thread, self).unlink(cr, uid, ids, context=context)
146 #------------------------------------------------------
147 # mail.message wrappers and tools
148 #------------------------------------------------------
150 def message_create(self, cr, uid, thread_id, vals, context=None):
151 """ OpenChatter: wrapper of mail.message create method
152 - creates the mail.message
153 - automatically subscribe the message writer
154 - push the message to subscribed users
159 message_obj = self.pool.get('mail.message')
160 notification_obj = self.pool.get('mail.notification')
162 # automatically subscribe the writer of the message
163 if vals.get('user_id'):
164 self.message_subscribe(cr, uid, [thread_id], [vals['user_id']], context=context)
167 msg_id = message_obj.create(cr, uid, vals, context=context)
169 # Set as unread if writer is not the document responsible
170 self.message_create_set_unread(cr, uid, [thread_id], context=context)
172 # special: if install mode, do not push demo data
173 if context.get('install_mode', False):
176 # get users that will get a notification pushed
177 user_to_push_ids = self.message_get_user_ids_to_notify(cr, uid, [thread_id], vals, context=context)
178 for id in user_to_push_ids:
179 notification_obj.create(cr, uid, {'user_id': id, 'message_id': msg_id}, context=context)
181 # create the email to send
182 self.message_create_notify_by_email(cr, uid, vals, user_to_push_ids, context=context)
186 def message_get_user_ids_to_notify(self, cr, uid, thread_ids, new_msg_vals, context=None):
188 body = new_msg_vals.get('body_html', '') if new_msg_vals.get('content_subtype') == 'html' else new_msg_vals.get('body_text', '')
191 notif_user_ids = self.message_get_subscribers(cr, uid, thread_ids, context=context)
193 # add users requested via parsing message (@login)
194 notif_user_ids += self.message_parse_users(cr, uid, body, context=context)
196 # add users requested to perform an action (need_action mechanism)
197 if hasattr(self, 'get_needaction_user_ids'):
198 user_ids_dict = self.get_needaction_user_ids(cr, uid, thread_ids, context=context)
199 for id, user_ids in user_ids_dict.iteritems():
200 notif_user_ids += user_ids
202 # add users notified of the parent messages (because: if parent message contains @login, login must receive the replies)
203 if new_msg_vals.get('parent_id'):
204 notif_obj = self.pool.get('mail.notification')
205 parent_notif_ids = notif_obj.search(cr, uid, [('message_id', '=', new_msg_vals.get('parent_id'))], context=context)
206 parent_notifs = notif_obj.read(cr, uid, parent_notif_ids, context=context)
207 notif_user_ids += [parent_notif['user_id'][0] for parent_notif in parent_notifs]
209 # remove duplicate entries
210 notif_user_ids = list(set(notif_user_ids))
211 return notif_user_ids
213 def message_parse_users(self, cr, uid, string, context=None):
214 """Parse message content
215 - if find @login -(^|\s)@((\w|@|\.)*)-: returns the related ids
216 this supports login that are emails (such as @raoul@grobedon.net)
218 regex = re.compile('(^|\s)@((\w|@|\.)*)')
219 login_lst = [item[1] for item in regex.findall(string)]
220 if not login_lst: return []
221 user_ids = self.pool.get('res.users').search(cr, uid, [('login', 'in', login_lst)], context=context)
224 #------------------------------------------------------
225 # Generic message api
226 #------------------------------------------------------
228 def message_capable_models(self, cr, uid, context=None):
230 for model_name in self.pool.obj_list():
231 model = self.pool.get(model_name)
232 if 'mail.thread' in getattr(model, '_inherit', []):
233 ret_dict[model_name] = model._description
236 def message_append(self, cr, uid, threads, subject, body_text=None, body_html=None,
237 type='email', email_date=None, parent_id=False,
238 content_subtype='plain', state=None,
239 partner_ids=None, email_from=False, email_to=False,
240 email_cc=None, email_bcc=None, reply_to=None,
241 headers=None, message_id=False, references=None,
242 attachments=None, original=None, context=None):
243 """ Creates a new mail.message through message_create. The new message
244 is attached to the current mail.thread, containing all the details
245 passed as parameters. All attachments will be attached to the
246 thread record as well as to the actual message.
248 This method calls message_create that will handle management of
249 subscription and notifications, and effectively create the message.
251 If ``email_from`` is not set or ``type`` not set as 'email',
252 a note message is created (comment or system notification),
253 without the usual envelope attributes (sender, recipients, etc.).
255 :param threads: list of thread ids, or list of browse_records
256 representing threads to which a new message should be attached
257 :param subject: subject of the message, or description of the event;
258 this is totally optional as subjects are not important except
259 for specific messages (blog post, job offers) or for emails
260 :param body_text: plaintext contents of the mail or log message
261 :param body_html: html contents of the mail or log message
262 :param type: type of message: 'email', 'comment', 'notification';
264 :param email_date: email date string if different from now, in
266 :param parent_id: id of the parent message (threaded messaging model)
267 :param content_subtype: optional content_subtype of message: 'plain'
268 or 'html', corresponding to the main body contents (body_text or
270 :param state: state of message
271 :param partner_ids: destination partners of the message, in addition
272 to the now fully optional email_to; this method is supposed to
273 received a list of ids is not None. The specific many2many
274 instruction will be generated by this method.
275 :param email_from: Email From / Sender address if any
276 :param email_to: Email-To / Recipient address
277 :param email_cc: Comma-Separated list of Carbon Copy Emails To
279 :param email_bcc: Comma-Separated list of Blind Carbon Copy Emails To
281 :param reply_to: reply_to header
282 :param headers: mail headers to store
283 :param message_id: optional email identifier
284 :param references: optional email references
285 :param dict attachments: map of attachment filenames to binary
287 :param str original: optional full source of the RFC2822 email, for
289 :param dict context: if a ``thread_model`` value is present in the
290 context, its value will be used to determine the model of the
291 thread to update (instead of the current model).
295 if attachments is None:
299 edate = parsedate(email_date)
300 if edate is not None:
301 email_date = time.strftime('%Y-%m-%d %H:%M:%S', edate)
303 if all(isinstance(thread_id, (int, long)) for thread_id in threads):
304 model = context.get('thread_model') or self._name
305 model_pool = self.pool.get(model)
306 threads = model_pool.browse(cr, uid, threads, context=context)
308 ir_attachment = self.pool.get('ir.attachment')
311 for thread in threads:
313 for attachment in attachments:
314 fname, fcontent = attachment
315 if isinstance(fcontent, unicode):
316 fcontent = fcontent.encode('utf-8')
319 'datas': base64.b64encode(str(fcontent)),
320 'datas_fname': fname,
321 'description': _('Mail attachment'),
322 'res_model': thread._name,
325 to_attach.append(ir_attachment.create(cr, uid, data_attach, context=context))
326 # find related partner: partner_id column in thread object, or self is res.partner model
327 partner_id = ('partner_id' in thread._columns.keys()) and (thread.partner_id and thread.partner_id.id or False) or False
328 if not partner_id and thread._name == 'res.partner':
329 partner_id = thread.id
330 # destination partners
331 if partner_ids is None:
333 mail_partner_ids = [(6, 0, partner_ids)]
337 'body_text': body_text or (hasattr(thread, 'description') and thread.description or ''),
338 'body_html': body_html or '',
339 'parent_id': parent_id,
340 'date': email_date or fields.datetime.now(),
342 'content_subtype': content_subtype,
344 'message_id': message_id,
345 'partner_ids': mail_partner_ids,
346 'attachment_ids': [(6, 0, to_attach)],
348 'model' : thread._name,
350 'partner_id': partner_id,
353 if email_from or type == 'email':
354 for param in (email_to, email_cc, email_bcc):
355 if isinstance(param, list):
356 param = ", ".join(param)
358 'email_to': email_to,
359 'email_from': email_from or \
360 (hasattr(thread, 'user_id') and thread.user_id and thread.user_id.user_email),
361 'email_cc': email_cc,
362 'email_bcc': email_bcc,
363 'references': references,
365 'reply_to': reply_to,
366 'original': original, })
368 new_msg_ids.append(self.message_create(cr, uid, thread.id, data, context=context))
371 def message_append_dict(self, cr, uid, ids, msg_dict, context=None):
372 """Creates a new mail.message attached to the given threads (``ids``),
373 with the contents of ``msg_dict``, by calling ``message_append``
374 with the mail details. All attachments in msg_dict will be
375 attached to the object record as well as to the actual
378 :param dict msg_dict: a map containing the email details and
379 attachments. See ``message_process()`` and
380 ``mail.message.parse()`` for details on
382 :param dict context: if a ``thread_model`` value is present
383 in the context, its value will be used
384 to determine the model of the thread to
385 update (instead of the current model).
387 return self.message_append(cr, uid, ids,
388 subject = msg_dict.get('subject'),
389 body_text = msg_dict.get('body_text'),
390 body_html= msg_dict.get('body_html'),
391 parent_id = msg_dict.get('parent_id', False),
392 type = msg_dict.get('type', 'email'),
393 content_subtype = msg_dict.get('content_subtype'),
394 state = msg_dict.get('state'),
395 partner_ids = msg_dict.get('partner_ids'),
396 email_from = msg_dict.get('from', msg_dict.get('email_from')),
397 email_to = msg_dict.get('to', msg_dict.get('email_to')),
398 email_cc = msg_dict.get('cc', msg_dict.get('email_cc')),
399 email_bcc = msg_dict.get('bcc', msg_dict.get('email_bcc')),
400 reply_to = msg_dict.get('reply', msg_dict.get('reply_to')),
401 email_date = msg_dict.get('date'),
402 message_id = msg_dict.get('message-id', msg_dict.get('message_id')),
403 references = msg_dict.get('references')\
404 or msg_dict.get('in-reply-to'),
405 attachments = msg_dict.get('attachments'),
406 headers = msg_dict.get('headers'),
407 original = msg_dict.get('original'),
410 #------------------------------------------------------
412 #------------------------------------------------------
414 def _message_search_ancestor_ids(self, cr, uid, ids, child_ids, ancestor_ids, context=None):
415 """ Given message child_ids ids, find their ancestors until ancestor_ids
416 using their parent_id relationship.
418 :param child_ids: the first nodes of the search
419 :param ancestor_ids: list of ancestors. When the search reach an
422 def _get_parent_ids(message_list, ancestor_ids, child_ids):
423 """ Tool function: return the list of parent_ids of messages
424 contained in message_list. Parents that are in ancestor_ids
425 or in child_ids are not returned. """
426 return [message['parent_id'][0] for message in message_list
427 if message['parent_id']
428 and message['parent_id'][0] not in ancestor_ids
429 and message['parent_id'][0] not in child_ids
432 message_obj = self.pool.get('mail.message')
433 messages_temp = message_obj.read(cr, uid, child_ids, ['id', 'parent_id'], context=context)
434 parent_ids = _get_parent_ids(messages_temp, ancestor_ids, child_ids)
435 child_ids += parent_ids
436 cur_iter = 0; max_iter = 100; # avoid infinite loop
437 while (parent_ids and (cur_iter < max_iter)):
439 messages_temp = message_obj.read(cr, uid, parent_ids, ['id', 'parent_id'], context=context)
440 parent_ids = _get_parent_ids(messages_temp, ancestor_ids, child_ids)
441 child_ids += parent_ids
442 if (cur_iter > max_iter):
443 _logger.warning("Possible infinite loop in _message_search_ancestor_ids. "\
444 "Note that this algorithm is intended to check for cycle in "\
445 "message graph, leading to a curious error. Have fun.")
448 def message_search_get_domain(self, cr, uid, ids, context=None):
449 """ OpenChatter feature: get the domain to search the messages related
450 to a document. mail.thread defines the default behavior as
451 being messages with model = self._name, id in ids.
452 This method should be overridden if a model has to implement a
455 return ['&', ('res_id', 'in', ids), ('model', '=', self._name)]
457 def message_search(self, cr, uid, ids, fetch_ancestors=False, ancestor_ids=None,
458 limit=100, offset=0, domain=None, count=False, context=None):
459 """ OpenChatter feature: return thread messages ids according to the
460 search domain given by ``message_search_get_domain``.
462 It is possible to add in the search the parent of messages by
463 setting the fetch_ancestors flag to True. In that case, using
464 the parent_id relationship, the method returns the id list according
465 to the search domain, but then calls ``_message_search_ancestor_ids``
466 that will add to the list the ancestors ids. The search is limited
467 to parent messages having an id in ancestor_ids or having
468 parent_id set to False.
470 If ``count==True``, the number of ids is returned instead of the
471 id list. The count is done by hand instead of passing it as an
472 argument to the search call because we might want to perform
473 a research including parent messages until some ancestor_ids.
475 :param fetch_ancestors: performs an ascended search; will add
476 to fetched msgs all their parents until
478 :param ancestor_ids: used when fetching ancestors
479 :param domain: domain to add to the search; especially child_of
480 is interesting when dealing with threaded display.
481 Note that the added domain is anded with the
483 :param limit, offset, count, context: as usual
485 search_domain = self.message_search_get_domain(cr, uid, ids, context=context)
487 search_domain += domain
488 message_obj = self.pool.get('mail.message')
489 message_res = message_obj.search(cr, uid, search_domain, limit=limit, offset=offset, count=count, context=context)
490 if not count and fetch_ancestors:
491 message_res += self._message_search_ancestor_ids(cr, uid, ids, message_res, ancestor_ids, context=context)
494 def message_read(self, cr, uid, ids, fetch_ancestors=False, ancestor_ids=None,
495 limit=100, offset=0, domain=None, context=None):
496 """ OpenChatter feature: read the messages related to some threads.
497 This method is used mainly the Chatter widget, to directly have
498 read result instead of searching then reading.
500 Please see message_search for more information about the parameters.
502 message_ids = self.message_search(cr, uid, ids, fetch_ancestors, ancestor_ids,
503 limit, offset, domain, context=context)
504 messages = self.pool.get('mail.message').read(cr, uid, message_ids, context=context)
506 """ Retrieve all attachments names """
507 map_id_to_name = dict((attachment_id, '') for message in messages for attachment_id in message['attachment_ids'])
509 ids = map_id_to_name.keys()
510 names = self.pool.get('ir.attachment').name_get(cr, uid, ids, context=context)
512 # convert the list of tuples into a dictionnary
514 map_id_to_name[name[0]] = name[1]
516 # give corresponding ids and names to each message
518 msg["attachments"] = []
520 for attach_id in msg["attachment_ids"]:
521 msg["attachments"].append({'id': attach_id, 'name': map_id_to_name[attach_id]})
523 # Set the threads as read
524 self.message_check_and_set_read(cr, uid, ids, context=context)
525 # Sort and return the messages
526 messages = sorted(messages, key=lambda d: (-d['id']))
529 def message_get_pushed_messages(self, cr, uid, ids, fetch_ancestors=False, ancestor_ids=None,
530 limit=100, offset=0, msg_search_domain=[], context=None):
531 """ OpenChatter: wall: get the pushed notifications and used them
532 to fetch messages to display on the wall.
534 :param fetch_ancestors: performs an ascended search; will add
535 to fetched msgs all their parents until
537 :param ancestor_ids: used when fetching ancestors
538 :param domain: domain to add to the search; especially child_of
539 is interesting when dealing with threaded display
540 :param ascent: performs an ascended search; will add to fetched msgs
541 all their parents until root_ids
542 :param root_ids: for ascent search
543 :return: list of mail.messages sorted by date
545 notification_obj = self.pool.get('mail.notification')
546 msg_obj = self.pool.get('mail.message')
547 # update message search
548 for arg in msg_search_domain:
549 if isinstance(arg, (tuple, list)):
550 arg[0] = 'message_id.' + arg[0]
551 # compose final domain
552 domain = [('user_id', '=', uid)] + msg_search_domain
554 notification_ids = notification_obj.search(cr, uid, domain, limit=limit, offset=offset, context=context)
555 notifications = notification_obj.browse(cr, uid, notification_ids, context=context)
556 msg_ids = [notification.message_id.id for notification in notifications]
558 msg_ids = msg_obj.search(cr, uid, [('id', 'in', msg_ids)], context=context)
559 if (fetch_ancestors): msg_ids = self._message_search_ancestor_ids(cr, uid, ids, msg_ids, ancestor_ids, context=context)
560 msgs = msg_obj.read(cr, uid, msg_ids, context=context)
563 def _message_find_user_id(self, cr, uid, message, context=None):
564 from_local_part = to_email(decode(message.get('From')))[0]
565 user_ids = self.pool.get('res.users').search(cr, uid, [('login', '=', from_local_part)], context=context)
566 return user_ids[0] if user_ids else uid
568 #------------------------------------------------------
570 #------------------------------------------------------
571 # message_process will call either message_new or message_update.
573 def message_route(self, cr, uid, message, model=None, thread_id=None,
574 custom_values=None, context=None):
575 """Attempt to figure out the correct target model, thread_id,
576 custom_values and user_id to use for an incoming message.
577 Multiple values may be returned, if a message had multiple
578 recipients matching existing mail.aliases, for example.
580 The following heuristics are used, in this order:
581 1. If the message replies to an existing thread_id, and
582 properly contains the thread model in the 'In-Reply-To'
583 header, use this model/thread_id pair, and ignore
584 custom_value (not needed as no creation will take place)
585 2. Look for a mail.alias entry matching the message
586 recipient, and use the corresponding model, thread_id,
587 custom_values and user_id.
588 3. Fallback to the ``model``, ``thread_id`` and ``custom_values``
590 4. If all the above fails, raise an exception.
592 :param string message: an email.message instance
593 :param string model: the fallback model to use if the message
594 does not match any of the currently configured mail aliases
595 (may be None if a matching alias is supposed to be present)
596 :type dict custom_values: optional dictionary of default field values
597 to pass to ``message_new`` if a new record needs to be created.
598 Ignored if the thread record already exists, and also if a
599 matching mail.alias was found (aliases define their own defaults)
600 :param int thread_id: optional ID of the record/thread from ``model``
601 to which this mail should be attached. Only used if the message
602 does not reply to an existing thread and does not match any mail alias.
603 :return: list of [model, thread_id, custom_values, user_id]
605 assert isinstance(message, Message), 'message must be an email.message.Message at this point'
606 message_id = message.get('Message-Id')
608 # 1. Verify if this is a reply to an existing thread
609 references = decode_header(message, 'References') or decode_header(message, 'In-Reply-To')
610 ref_match = references and tools.reference_re.search(references)
612 thread_id = int(ref_match.group(1))
613 model = ref_match.group(2) or model
614 model_pool = self.pool.get(model)
615 if thread_id and model and model_pool and model_pool.exists(cr, uid, thread_id) \
616 and hasattr(model_pool, 'message_update'):
617 _logger.debug('Routing mail with Message-Id %s: direct reply to model: %s, thread_id: %s, custom_values: %s, uid: %s',
618 message_id, model, thread_id, custom_values, uid)
619 return [(model, thread_id, custom_values, uid)]
621 # 2. Look for a matching mail.alias entry
622 # Delivered-To is a safe bet in most modern MTAs, but we have to fallback on To + Cc values
623 # for all the odd MTAs out there, as there is no standard header for the envelope's `rcpt_to` value.
624 rcpt_tos = decode_header(message, 'Delivered-To') or \
625 ','.join([decode_header(message, 'To'),
626 decode_header(message, 'Cc'),
627 decode_header(message, 'Resent-To'),
628 decode_header(message, 'Resent-Cc')])
629 local_parts = [e.split('@')[0] for e in to_email(rcpt_tos)]
631 mail_alias = self.pool.get('mail.alias')
632 alias_ids = mail_alias.search(cr, uid, [('alias_name', 'in', local_parts)])
635 for alias in mail_alias.browse(cr, uid, alias_ids, context=context):
636 user_id = alias.alias_user_id.id
638 user_id = self._message_find_user_id(cr, uid, message, context=context)
639 routes.append((alias.alias_model_id.model, alias.alias_force_thread_id, \
640 eval(alias.alias_defaults), user_id))
641 _logger.debug('Routing mail with Message-Id %s: direct alias match: %r', message_id, routes)
644 # 3. Fallback to the provided parameters, if they work
645 model_pool = self.pool.get(model)
647 # Legacy: fallback to matching [ID] in the Subject
648 match = tools.res_re.search(decode_header(message, 'Subject'))
649 thread_id = match and match.group(1)
650 assert thread_id and hasattr(model_pool, 'message_update') or hasattr(model_pool, 'message_new'), \
651 "No possible route found for incoming message with Message-Id %s. " \
652 "Create an appropriate mail.alias or force the destination model." % message_id
653 if thread_id and not model_pool.exists(cr, uid, thread_id):
654 _logger.warning('Received mail reply to missing document %s! Ignoring and creating new document instead for Message-Id %s',
655 thread_id, message_id)
657 _logger.debug('Routing mail with Message-Id %s: fallback to model:%s, thread_id:%s, custom_values:%s, uid:%s',
658 message_id, model, thread_id, custom_values, uid)
659 return [(model, thread_id, custom_values, uid)]
662 def message_process(self, cr, uid, model, message, custom_values=None,
663 save_original=False, strip_attachments=False,
664 thread_id=None, context=None):
665 """Process an incoming RFC2822 email message, relying on
666 ``mail.message.parse()`` for the parsing operation,
667 and ``message_route()`` to figure out the target model.
669 Once the target model is known, its ``message_new`` method
670 is called with the new message (if the thread record did not exist)
671 or its ``message_update`` method (if it did). Finally,
672 ``message_forward`` is called to automatically notify other
673 people that should receive this message.
675 :param string model: the fallback model to use if the message
676 does not match any of the currently configured mail aliases
677 (may be None if a matching alias is supposed to be present)
678 :param message: source of the RFC2822 message
679 :type message: string or xmlrpclib.Binary
680 :type dict custom_values: optional dictionary of field values
681 to pass to ``message_new`` if a new record needs to be created.
682 Ignored if the thread record already exists, and also if a
683 matching mail.alias was found (aliases define their own defaults)
684 :param bool save_original: whether to keep a copy of the original
685 email source attached to the message after it is imported.
686 :param bool strip_attachments: whether to strip all attachments
687 before processing the message, in order to save some space.
688 :param int thread_id: optional ID of the record/thread from ``model``
689 to which this mail should be attached. When provided, this
690 overrides the automatic detection based on the message
693 if context is None: context = {}
695 # extract message bytes - we are forced to pass the message as binary because
696 # we don't know its encoding until we parse its headers and hence can't
697 # convert it to utf-8 for transport between the mailgate script and here.
698 if isinstance(message, xmlrpclib.Binary):
699 message = str(message.data)
700 # Warning: message_from_string doesn't always work correctly on unicode,
701 # we must use utf-8 strings here :-(
702 if isinstance(message, unicode):
703 message = message.encode('utf-8')
704 msg_txt = email.message_from_string(message)
705 routes = self.message_route(cr, uid, msg_txt, model,
706 thread_id, custom_values,
708 msg = self.pool.get('mail.message').parse_message(msg_txt, save_original=save_original, context=context)
709 msg['state'] = 'received'
710 if strip_attachments and 'attachments' in msg:
711 del msg['attachments']
712 for model, thread_id, custom_values, user_id in routes:
713 if self._name != model:
714 context.update({'thread_model': model})
715 model_pool = self.pool.get(model)
716 assert thread_id and hasattr(model_pool, 'message_update') or hasattr(model_pool, 'message_new'), \
717 "Undeliverable mail with Message-Id %s, model %s does not accept incoming emails" % \
718 (msg['message-id'], model)
719 if thread_id and hasattr(model_pool, 'message_update'):
720 model_pool.message_update(cr, user_id, [thread_id], msg, context=context)
722 thread_id = model_pool.message_new(cr, user_id, msg, custom_values, context=context)
724 # Forward the email to other followers
725 self.message_forward(cr, uid, model, [thread_id], msg_txt, context=context)
726 model_pool.message_mark_as_unread(cr, uid, [thread_id], context=context)
729 def message_new(self, cr, uid, msg_dict, custom_values=None, context=None):
730 """Called by ``message_process`` when a new message is received
731 for a given thread model, if the message did not belong to
733 The default behavior is to create a new record of the corresponding
734 model (based on some very basic info extracted from the message),
735 then attach the message to the newly created record
736 (by calling ``message_append_dict``).
737 Additional behavior may be implemented by overriding this method.
739 :param dict msg_dict: a map containing the email details and
740 attachments. See ``message_process`` and
741 ``mail.message.parse`` for details.
742 :param dict custom_values: optional dictionary of additional
743 field values to pass to create()
744 when creating the new thread record.
745 Be careful, these values may override
746 any other values coming from the message.
747 :param dict context: if a ``thread_model`` value is present
748 in the context, its value will be used
749 to determine the model of the record
750 to create (instead of the current model).
752 :return: the id of the newly created thread object
756 model = context.get('thread_model') or self._name
757 model_pool = self.pool.get(model)
758 fields = model_pool.fields_get(cr, uid, context=context)
759 data = model_pool.default_get(cr, uid, fields, context=context)
760 if 'name' in fields and not data.get('name'):
761 data['name'] = msg_dict.get('subject', '')
762 if custom_values and isinstance(custom_values, dict):
763 data.update(custom_values)
764 res_id = model_pool.create(cr, uid, data, context=context)
765 self.message_append_dict(cr, uid, [res_id], msg_dict, context=context)
768 def message_update(self, cr, uid, ids, msg_dict, update_vals=None, context=None):
769 """Called by ``message_process`` when a new message is received
770 for an existing thread. The default behavior is to create a
771 new mail.message in the given thread (by calling
772 ``message_append_dict``)
773 Additional behavior may be implemented by overriding this
775 :param dict msg_dict: a map containing the email details and
776 attachments. See ``message_process`` and
777 ``mail.message.parse()`` for details.
778 :param dict update_vals: a dict containing values to update records
779 given their ids; if the dict is None or is
780 void, no write operation is performed.
783 self.write(cr, uid, ids, update_vals, context=context)
784 return self.message_append_dict(cr, uid, ids, msg_dict, context=context)
786 def message_thread_followers(self, cr, uid, ids, context=None):
787 """ Returns a list of email addresses of the people following
788 this thread, including the sender of each mail, and the
789 people who were in CC of the messages, if any.
792 if isinstance(ids, (str, int, long)):
794 for thread in self.browse(cr, uid, ids, context=context):
796 for message in thread.message_ids:
797 l.add((message.user_id and message.user_id.user_email) or '')
798 l.add(message.email_from or '')
799 l.add(message.email_cc or '')
800 res[thread.id] = filter(None, l)
803 def message_forward(self, cr, uid, model, thread_ids, msg, email_error=False, context=None):
804 """Sends an email to all people following the given threads.
805 The emails are forwarded immediately, not queued for sending,
808 :param str model: thread model
809 :param list thread_ids: ids of the thread records
810 :param msg: email.message.Message object to forward
811 :param email_error: optional email address to notify in case
812 of any delivery error during the forward.
815 model_pool = self.pool.get(model)
816 smtp_server_obj = self.pool.get('ir.mail_server')
817 for res in model_pool.browse(cr, uid, thread_ids, context=context):
818 if hasattr(model_pool, 'message_thread_followers'):
819 followers = model_pool.message_thread_followers(cr, uid, [res.id])[res.id]
821 followers = self.message_thread_followers(cr, uid, [res.id])[res.id]
822 message_followers_emails = to_email(','.join(filter(None, followers)))
823 message_recipients = to_email(','.join(filter(None,
824 [decode(msg['from']),
826 decode(msg['cc'])])))
827 forward_to = [i for i in message_followers_emails if (i and (i not in message_recipients))]
829 # TODO: we need an interface for this for all types of objects, not just leads
830 if hasattr(res, 'section_id'):
832 msg['reply-to'] = res.section_id.reply_to
834 smtp_from, = to_email(msg['from'])
835 msg['from'] = smtp_from
836 msg['to'] = ", ".join(forward_to)
837 msg['message-id'] = tools.generate_tracking_message_id(res.id)
838 if not smtp_server_obj.send_email(cr, uid, msg) and email_error:
839 subj = msg['subject']
840 del msg['subject'], msg['to'], msg['cc'], msg['bcc']
841 msg['subject'] = _('[OpenERP-Forward-Failed] %s') % subj
842 msg['to'] = email_error
843 smtp_server_obj.send_email(cr, uid, msg)
846 def message_partner_by_email(self, cr, uid, email, context=None):
847 """Attempts to return the id of a partner address matching
848 the given ``email``, and the corresponding partner id.
849 Can be used by classes using the ``mail.thread`` mixin
850 to lookup the partner and use it in their implementation
851 of ``message_new`` to link the new record with a
852 corresponding partner.
853 The keys used in the returned dict are meant to map
854 to usual names for relationships towards a partner
855 and one of its addresses.
857 :param email: email address for which a partner
858 should be searched for.
860 :return: a map of the following form::
862 { 'partner_address_id': id or False,
863 'partner_id': pid or False }
865 partner_pool = self.pool.get('res.partner')
866 res = {'partner_id': False}
868 email = to_email(email)[0]
869 contact_ids = partner_pool.search(cr, uid, [('email', '=', email)])
871 contact = partner_pool.browse(cr, uid, contact_ids[0])
872 res['partner_id'] = contact.id
875 # for backwards-compatibility with old scripts
876 process_email = message_process
878 #------------------------------------------------------
880 #------------------------------------------------------
882 def log(self, cr, uid, id, message, secondary=False, context=None):
883 _logger.warning("log() is deprecated. As this module inherit from \
884 mail.thread, the message will be managed by this \
885 module instead of by the res.log mechanism. Please \
886 use the mail.thread OpenChatter API instead of the \
887 now deprecated res.log.")
888 self.message_append_note(cr, uid, [id], 'res.log', message, context=context)
890 def message_append_note(self, cr, uid, ids, subject=None, body=None, parent_id=False,
891 type='notification', content_subtype='html', context=None):
892 if content_subtype == 'html':
898 return self.message_append(cr, uid, ids, subject, body_html, body_text,
899 type, parent_id=parent_id,
900 content_subtype=content_subtype, context=context)
902 #------------------------------------------------------
903 # Subscription mechanism
904 #------------------------------------------------------
906 def message_get_subscribers(self, cr, uid, ids, context=None):
907 """ Returns the current document followers. Basically this method
908 checks in mail.subscription for entries with matching res_model,
910 This method can be overriden to add implicit subscribers, such
911 as project managers, by adding their user_id to the list of
912 ids returned by this method.
914 subscr_obj = self.pool.get('mail.subscription')
915 subscr_ids = subscr_obj.search(cr, uid, ['&', ('res_model', '=', self._name), ('res_id', 'in', ids)], context=context)
916 return [sub['user_id'][0] for sub in subscr_obj.read(cr, uid, subscr_ids, ['user_id'], context=context)]
918 def message_read_subscribers(self, cr, uid, ids, fields=['id', 'name', 'image_small'], context=None):
919 """ Returns the current document followers as a read result. Used
920 mainly for Chatter having only one method to call to have
923 user_ids = self.message_get_subscribers(cr, uid, ids, context=context)
924 return self.pool.get('res.users').read(cr, uid, user_ids, fields=fields, context=context)
926 def message_is_subscriber(self, cr, uid, ids, user_id = None, context=None):
927 """ Check if uid or user_id (if set) is a subscriber to the current
930 :param user_id: if set, check is done on user_id; if not set
933 sub_user_id = uid if user_id is None else user_id
934 if sub_user_id in self.message_get_subscribers(cr, uid, ids, context=context):
938 def message_subscribe(self, cr, uid, ids, user_ids = None, context=None):
939 """ Subscribe the user (or user_ids) to the current document.
941 :param user_ids: a list of user_ids; if not set, subscribe
944 subscription_obj = self.pool.get('mail.subscription')
945 to_subscribe_uids = [uid] if user_ids is None else user_ids
948 already_subscribed_user_ids = self.message_get_subscribers(cr, uid, [id], context=context)
949 for user_id in to_subscribe_uids:
950 if user_id in already_subscribed_user_ids: continue
951 create_ids.append(subscription_obj.create(cr, uid, {'res_model': self._name, 'res_id': id, 'user_id': user_id}, context=context))
954 def message_unsubscribe(self, cr, uid, ids, user_ids = None, context=None):
955 """ Unsubscribe the user (or user_ids) from the current document.
957 :param user_ids: a list of user_ids; if not set, subscribe
960 # Trying to unsubscribe somebody not in subscribers: returns False
961 # if special management is needed; allows to know that an automatically
962 # subscribed user tries to unsubscribe and allows to warn him
963 to_unsubscribe_uids = [uid] if user_ids is None else user_ids
964 subscription_obj = self.pool.get('mail.subscription')
965 to_delete_sub_ids = subscription_obj.search(cr, uid,
966 ['&', '&', ('res_model', '=', self._name), ('res_id', 'in', ids), ('user_id', 'in', to_unsubscribe_uids)], context=context)
967 if not to_delete_sub_ids:
969 return subscription_obj.unlink(cr, uid, to_delete_sub_ids, context=context)
971 #------------------------------------------------------
973 #------------------------------------------------------
975 def message_create_notify_by_email(self, cr, uid, new_msg_values, user_to_notify_ids, context=None):
976 """ When creating a new message and pushing notifications, emails
977 must be send if users have chosen to receive notifications
978 by email via the notification_email_pref field.
980 ``notification_email_pref`` can have 3 values :
981 - all: receive all notification by email (for example for shared
983 - to_me: messages send directly to me (@login, messages on res.users)
984 - never: never receive notifications
985 Note that an user should never receive notifications for messages
988 :param new_msg_values: dictionary of message values, those that
989 are given to the create method
990 :param user_to_notify_ids: list of user_ids, user that will
991 receive a notification on their Wall
993 message_obj = self.pool.get('mail.message')
994 res_users_obj = self.pool.get('res.users')
995 body = new_msg_values.get('body_html', '') if new_msg_values.get('content_subtype') == 'html' else new_msg_values.get('body_text', '')
997 # remove message writer
998 if user_to_notify_ids.count(new_msg_values.get('user_id')) > 0:
999 user_to_notify_ids.remove(new_msg_values.get('user_id'))
1001 # get user_ids directly asked
1002 user_to_push_from_parse_ids = self.message_parse_users(cr, uid, body, context=context)
1004 # try to find an email_to
1006 for user in res_users_obj.browse(cr, uid, user_to_notify_ids, context=context):
1007 if not user.notification_email_pref == 'all' and \
1008 not (user.notification_email_pref == 'to_me' and user.id in user_to_push_from_parse_ids):
1010 if not user.user_email:
1012 email_to = '%s, %s' % (email_to, user.user_email)
1013 email_to = email_to.lstrip(', ')
1015 # did not find any email address: not necessary to create an email
1019 # try to find an email_from
1020 current_user = res_users_obj.browse(cr, uid, [uid], context=context)[0]
1021 email_from = new_msg_values.get('email_from')
1023 email_from = current_user.user_email
1025 # get email content, create it (with mail_message.create)
1026 email_values = self.message_create_notify_get_email_dict(cr, uid, new_msg_values, email_from, email_to, context)
1027 email_id = message_obj.create(cr, uid, email_values, context=context)
1030 def message_create_notify_get_email_dict(self, cr, uid, new_msg_values, email_from, email_to, context=None):
1031 values = dict(new_msg_values)
1033 body_html = new_msg_values.get('body_html', '')
1035 body_html += '\n\n----------\nThis email was send automatically by OpenERP, because you have subscribed to a document.'
1036 body_text = new_msg_values.get('body_text', '')
1038 body_text += '\n\n----------\nThis email was send automatically by OpenERP, because you have subscribed to a document.'
1041 'state': 'outgoing',
1042 'email_from': email_from,
1043 'email_to': email_to,
1044 'subject': 'New message',
1045 'content_subtype': new_msg_values.get('content_subtype', 'plain'),
1046 'body_html': body_html,
1047 'body_text': body_text,
1048 'auto_delete': True,
1054 def message_remove_pushed_notifications(self, cr, uid, ids, msg_ids, remove_childs=True, context=None):
1055 notif_obj = self.pool.get('mail.notification')
1056 msg_obj = self.pool.get('mail.message')
1058 notif_msg_ids = msg_obj.search(cr, uid, [('id', 'child_of', msg_ids)], context=context)
1060 notif_msg_ids = msg_ids
1061 to_del_notif_ids = notif_obj.search(cr, uid, ['&', ('user_id', '=', uid), ('message_id', 'in', notif_msg_ids)], context=context)
1062 return notif_obj.unlink(cr, uid, to_del_notif_ids, context=context)
1064 #------------------------------------------------------
1066 #------------------------------------------------------
1068 def message_create_set_unread(self, cr, uid, ids, context=None):
1069 """ When creating a new message, set as unread if uid is not the
1070 object responsible. """
1071 for obj in self.browse(cr, uid, ids, context=context):
1072 if obj.message_state and hasattr(obj, 'user_id') and (not obj.user_id or obj.user_id.id != uid):
1073 self.message_mark_as_unread(cr, uid, [obj.id], context=context)
1075 def message_check_and_set_unread(self, cr, uid, ids, context=None):
1076 """ Set unread if uid is the object responsible or if the object has
1078 for obj in self.browse(cr, uid, ids, context=context):
1079 if obj.message_state and hasattr(obj, 'user_id') and (not obj.user_id or obj.user_id.id == uid):
1080 self.message_mark_as_unread(cr, uid, [obj.id], context=context)
1082 def message_mark_as_unread(self, cr, uid, ids, context=None):
1083 """ Set as unread. """
1084 return self.write(cr, uid, ids, {'message_state': False}, context=context)
1086 def message_check_and_set_read(self, cr, uid, ids, context=None):
1087 """ Set read if uid is the object responsible. """
1088 for obj in self.browse(cr, uid, ids, context=context):
1089 if not obj.message_state and hasattr(obj, 'user_id') and obj.user_id and obj.user_id.id == uid:
1090 self.message_mark_as_read(cr, uid, [obj.id], context=context)
1092 def message_mark_as_read(self, cr, uid, ids, context=None):
1093 """ Set as read. """
1094 return self.write(cr, uid, ids, {'message_state': True}, context=context)
1097 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: