[MERGE]: Merged with trunk-addons.
[odoo/odoo.git] / addons / mail / mail_thread.py
1 # -*- coding: utf-8 -*-
2 ##############################################################################
3 #
4 #    OpenERP, Open Source Management Solution
5 #    Copyright (C) 2009-Today OpenERP SA (<http://www.openerp.com>)
6 #
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
11 #
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
16 #
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/>
19 #
20 ##############################################################################
21
22 import time
23 import tools
24 import base64
25 import email
26 from email.utils import parsedate
27
28 import logging
29 import xmlrpclib
30 from osv import osv, fields
31 from tools.translate import _
32 from mail_message import decode, to_email
33
34 _logger = logging.getLogger('mail')
35
36 class mail_thread(osv.osv):
37     '''Mixin model, meant to be inherited by any model that needs to
38        act as a discussion topic on which messages can be attached.
39        Public methods are prefixed with ``message_`` in order to avoid
40        name collisions with methods of the models that will inherit
41        from this mixin.
42
43        ``mail.thread`` adds a one2many of mail.messages, acting as the
44        thread's history, and a few methods that may be overridden to
45        implement model-specific behavior upon arrival of new messages.
46
47        Inheriting classes are not required to implement any method, as the
48        default implementation will work for any model. However it is common
49        to override at least the ``message_new`` and ``message_update``
50        methods (calling ``super``) to add model-specific behavior at
51        creation and update of a thread.
52
53     '''
54     _name = 'mail.thread'
55     _description = 'Email Thread'
56
57     _columns = {
58         'message_ids': fields.one2many('mail.message', 'res_id', 'Messages', readonly=True),
59     }
60
61     def message_thread_followers(self, cr, uid, ids, context=None):
62         """Returns a list of email addresses of the people following
63            this thread, including the sender of each mail, and the
64            people who were in CC of the messages, if any.
65         """
66         res = {}
67         if isinstance(ids, (str, int, long)):
68             ids = [long(ids)]
69         for thread in self.browse(cr, uid, ids, context=context):
70             l = set()
71             for message in thread.message_ids:
72                 l.add((message.user_id and message.user_id.user_email) or '')
73                 l.add(message.email_from or '')
74                 l.add(message.email_cc or '')
75             res[thread.id] = filter(None, l)
76         return res
77
78     def copy(self, cr, uid, id, default=None, context=None):
79         """Overrides default copy method to empty the thread of
80            messages attached to this record, as the copied object
81            will have its own thread and does not have to share it.
82         """
83         if default is None:
84             default = {}
85         default.update({
86             'message_ids': [],
87         })
88         return super(mail_thread, self).copy(cr, uid, id, default, context=context)
89
90     def message_new(self, cr, uid, msg_dict, custom_values=None, context=None):
91         """Called by ``message_process`` when a new message is received
92            for a given thread model, if the message did not belong to 
93            an existing thread.
94            The default behavior is to create a new record of the corresponding
95            model (based on some very basic info extracted from the message),
96            then attach the message to the newly created record
97            (by calling ``message_append_dict``).
98            Additional behavior may be implemented by overriding this method.
99
100            :param dict msg_dict: a map containing the email details and
101                                  attachments. See ``message_process`` and
102                                 ``mail.message.parse`` for details.
103            :param dict custom_values: optional dictionary of additional
104                                       field values to pass to create()
105                                       when creating the new thread record.
106                                       Be careful, these values may override
107                                       any other values coming from the message.
108            :param dict context: if a ``thread_model`` value is present
109                                 in the context, its value will be used
110                                 to determine the model of the record
111                                 to create (instead of the current model).
112            :rtype: int
113            :return: the id of the newly created thread object
114         """
115         if context is None:
116             context = {}
117         model = context.get('thread_model') or self._name
118         model_pool = self.pool.get(model)
119         fields = model_pool.fields_get(cr, uid, context=context)
120         data = model_pool.default_get(cr, uid, fields, context=context)
121         if 'name' in fields and not data.get('name'):
122             data['name'] = msg_dict.get('from','')
123         if custom_values and isinstance(custom_values, dict):
124             data.update(custom_values)
125         res_id = model_pool.create(cr, uid, data, context=context)
126         self.message_append_dict(cr, uid, [res_id], msg_dict, context=context)
127         return res_id
128
129     def message_update(self, cr, uid, ids, msg_dict, vals={}, default_act=None, context=None):
130         """Called by ``message_process`` when a new message is received
131            for an existing thread. The default behavior is to create a
132            new mail.message in the given thread (by calling
133            ``message_append_dict``)
134            Additional behavior may be implemented by overriding this
135            method.
136
137            :param dict msg_dict: a map containing the email details and
138                                 attachments. See ``message_process`` and
139                                 ``mail.message.parse()`` for details.
140            :param dict context: if a ``thread_model`` value is present
141                                 in the context, its value will be used
142                                 to determine the model of the thread to
143                                 update (instead of the current model).
144         """
145         return self.message_append_dict(cr, uid, ids, msg_dict, context=context)
146
147     def message_append(self, cr, uid, threads, subject, body_text=None, email_to=False,
148                 email_from=False, email_cc=None, email_bcc=None, reply_to=None,
149                 email_date=None, message_id=False, references=None,
150                 attachments=None, body_html=None, subtype=None, headers=None,
151                 original=None, context=None):
152         """Creates a new mail.message attached to the current mail.thread,
153            containing all the details passed as parameters.  All attachments
154            will be attached to the thread record as well as to the actual
155            message.
156            If only the ``threads`` and ``subject`` parameters are provided,
157            a *event log* message is created, without the usual envelope
158            attributes (sender, recipients, etc.). 
159
160         :param threads: list of thread ids, or list of browse_records representing
161                         threads to which a new message should be attached
162         :param subject: subject of the message, or description of the event if this
163                         is an *event log* entry.
164         :param email_to: Email-To / Recipient address
165         :param email_from: Email From / Sender address if any
166         :param email_cc: Comma-Separated list of Carbon Copy Emails To addresse if any
167         :param email_bcc: Comma-Separated list of Blind Carbon Copy Emails To addresses if any
168         :param reply_to: reply_to header
169         :param email_date: email date string if different from now, in server timezone
170         :param message_id: optional email identifier
171         :param references: optional email references
172         :param body_text: plaintext contents of the mail or log message
173         :param body_html: html contents of the mail or log message
174         :param subtype: optional type of message: 'plain' or 'html', corresponding to the main
175                         body contents (body_text or body_html).
176         :param headers: mail headers to store
177         :param dict attachments: map of attachment filenames to binary contents, if any.
178         :param str original: optional full source of the RFC2822 email, for reference
179         :param dict context: if a ``thread_model`` value is present
180                              in the context, its value will be used
181                              to determine the model of the thread to
182                              update (instead of the current model).
183         """
184         if context is None:
185             context = {}
186         if attachments is None:
187             attachments = {}
188
189         if email_date:
190             edate = parsedate(email_date)
191             if edate is not None:
192                 email_date = time.strftime('%Y-%m-%d %H:%M:%S', edate)
193
194         if all(isinstance(thread_id, (int, long)) for thread_id in threads):
195             model = context.get('thread_model') or self._name
196             model_pool = self.pool.get(model)
197             threads = model_pool.browse(cr, uid, threads, context=context)
198
199         ir_attachment = self.pool.get('ir.attachment')
200         mail_message = self.pool.get('mail.message')
201
202         for thread in threads:
203             to_attach = []
204             for fname, fcontent in attachments.items():
205                 if isinstance(fcontent, unicode):
206                     fcontent = fcontent.encode('utf-8')
207                 data_attach = {
208                     'name': fname,
209                     'datas': base64.b64encode(str(fcontent)),
210                     'datas_fname': fname,
211                     'description': _('Mail attachment'),
212                     'res_model': thread._name,
213                     'res_id': thread.id,
214                 }
215                 to_attach.append(ir_attachment.create(cr, uid, data_attach, context=context))
216
217             partner_id = hasattr(thread, 'partner_id') and (thread.partner_id and thread.partner_id.id or False) or False
218             if not partner_id and thread._name == 'res.partner':
219                 partner_id = thread.id
220             data = {
221                 'subject': subject,
222                 'user_id': uid,
223                 'model' : thread._name,
224                 'partner_id': partner_id,
225                 'res_id': thread.id,
226                 'date': time.strftime('%Y-%m-%d %H:%M:%S'),
227                 'message_id': message_id,
228                 'body_text': body_text or (hasattr(thread, 'description') and thread.description or False),
229                 'attachment_ids': [(6, 0, to_attach)],
230                 'state' : 'received',
231             }
232
233             if email_from:
234                 for param in (email_to, email_cc, email_bcc):
235                     if isinstance(param, list):
236                         param = ", ".join(param)
237                 data = {
238                     'subject': subject or _('History'),
239                     'user_id': uid,
240                     'model' : thread._name,
241                     'res_id': thread.id,
242                     'date': email_date or time.strftime('%Y-%m-%d %H:%M:%S'),
243                     'body_text': body_text,
244                     'email_to': email_to,
245                     'email_from': email_from or \
246                         (hasattr(thread, 'user_id') and thread.user_id and thread.user_id.user_email),
247                     'email_cc': email_cc,
248                     'email_bcc': email_bcc,
249                     'partner_id': partner_id,
250                     'references': references,
251                     'message_id': message_id,
252                     'attachment_ids': [(6, 0, to_attach)],
253                     'state' : 'received',
254                     'body_html': body_html,
255                     'subtype': subtype,
256                     'headers': headers,
257                     'reply_to': reply_to,
258                     'original': original,
259                 }
260             mail_message.create(cr, uid, data, context=context)
261         return True
262
263     def message_append_dict(self, cr, uid, ids, msg_dict, context=None):
264         """Creates a new mail.message attached to the given threads (``ids``),
265            with the contents of ``msg_dict``, by calling ``message_append``
266            with the mail details. All attachments in msg_dict will be
267            attached to the object record as well as to the actual
268            mail message.
269
270            :param dict msg_dict: a map containing the email details and
271                                  attachments. See ``message_process()`` and
272                                 ``mail.message.parse()`` for details on
273                                 the dict structure.
274            :param dict context: if a ``thread_model`` value is present
275                                 in the context, its value will be used
276                                 to determine the model of the thread to
277                                 update (instead of the current model).
278         """
279         return self.message_append(cr, uid, ids,
280                             subject = msg_dict.get('subject'),
281                             body_text = msg_dict.get('body_text'),
282                             email_to = msg_dict.get('to'),
283                             email_from = msg_dict.get('from'),
284                             email_cc = msg_dict.get('cc'),
285                             email_bcc = msg_dict.get('bcc'),
286                             reply_to = msg_dict.get('reply'),
287                             email_date = msg_dict.get('date'),
288                             message_id = msg_dict.get('message-id'),
289                             references = msg_dict.get('references')\
290                                       or msg_dict.get('in-reply-to'),
291                             attachments = msg_dict.get('attachments'),
292                             body_html= msg_dict.get('body_html'),
293                             subtype = msg_dict.get('subtype'),
294                             headers = msg_dict.get('headers'),
295                             original = msg_dict.get('original'),
296                             context = context)
297
298
299     def message_process(self, cr, uid, model, message, custom_values=None,
300                         save_original=False, strip_attachments=False,
301                         context=None):
302         """Process an incoming RFC2822 email message related to the
303            given thread model, relying on ``mail.message.parse()``
304            for the parsing operation, and then calling ``message_new``
305            (if the thread record did not exist) or ``message_update``
306            (if it did), then calling ``message_forward`` to automatically
307            notify other people that should receive this message.
308
309            :param string model: the thread model for which a new message
310                                 must be processed
311            :param message: source of the RFC2822 mail
312            :type message: string or xmlrpclib.Binary
313            :type dict custom_values: optional dictionary of field values
314                                     to pass to ``message_new`` if a new
315                                     record needs to be created. Ignored
316                                     if the thread record already exists.
317            :param bool save_original: whether to keep a copy of the original
318                email source attached to the message after it is imported.
319            :param bool strip_attachments: whether to strip all attachments
320                before processing the message, in order to save some space.
321         """
322         # extract message bytes - we are forced to pass the message as binary because
323         # we don't know its encoding until we parse its headers and hence can't
324         # convert it to utf-8 for transport between the mailgate script and here.
325         if isinstance(message, xmlrpclib.Binary):
326             message = str(message.data)
327
328         model_pool = self.pool.get(model)
329         if self._name != model:
330             if context is None: context = {}
331             context.update({'thread_model': model})
332
333         mail_message = self.pool.get('mail.message')
334         res_id = False
335
336         # Parse Message
337         # Warning: message_from_string doesn't always work correctly on unicode,
338         # we must use utf-8 strings here :-(
339         if isinstance(message, unicode):
340             message = message.encode('utf-8')
341         msg_txt = email.message_from_string(message)
342         msg = mail_message.parse_message(msg_txt, save_original=save_original)
343
344         if strip_attachments and 'attachments' in msg:
345             del msg['attachments']
346
347         # Create New Record into particular model
348         def create_record(msg):
349             if hasattr(model_pool, 'message_new'):
350                 return model_pool.message_new(cr, uid, msg,
351                                               custom_values,
352                                               context=context)
353         res_id = False
354         if msg.get('references') or msg.get('in-reply-to'):
355             references = msg.get('references') or msg.get('in-reply-to')
356             if '\r\n' in references:
357                 references = references.split('\r\n')
358             else:
359                 references = references.split(' ')
360             for ref in references:
361                 ref = ref.strip()
362                 res_id = tools.reference_re.search(ref)
363                 if res_id:
364                     res_id = res_id.group(1)
365                 else:
366                     res_id = tools.res_re.search(msg['subject'])
367                     if res_id:
368                         res_id = res_id.group(1)
369                 if res_id:
370                     res_id = int(res_id)
371                     if model_pool.exists(cr, uid, res_id):
372                         if hasattr(model_pool, 'message_update'):
373                             model_pool.message_update(cr, uid, [res_id], msg, {}, context=context)
374                     else:
375                         # referenced thread was not found, we'll have to create a new one
376                         res_id = False
377         if not res_id:
378             res_id = create_record(msg)
379         #To forward the email to other followers
380         self.message_forward(cr, uid, model, [res_id], msg_txt, context=context)
381         return res_id
382
383     # for backwards-compatibility with old scripts
384     process_email = message_process
385
386     def message_forward(self, cr, uid, model, thread_ids, msg, email_error=False, context=None):
387         """Sends an email to all people following the given threads.
388            The emails are forwarded immediately, not queued for sending,
389            and not archived.
390
391         :param str model: thread model
392         :param list thread_ids: ids of the thread records
393         :param msg: email.message.Message object to forward
394         :param email_error: optional email address to notify in case
395                             of any delivery error during the forward.
396         :return: True
397         """
398         model_pool = self.pool.get(model)
399         smtp_server_obj = self.pool.get('ir.mail_server')
400         mail_message = self.pool.get('mail.message')
401         for res in model_pool.browse(cr, uid, thread_ids, context=context):
402             if hasattr(model_pool, 'message_thread_followers'):
403                 followers = model_pool.message_thread_followers(cr, uid, [res.id])[res.id]
404             else:
405                 followers = self.message_thread_followers(cr, uid, [res.id])[res.id]
406             message_followers_emails = to_email(','.join(filter(None, followers)))
407             message_recipients = to_email(','.join(filter(None,
408                                                                        [decode(msg['from']),
409                                                                         decode(msg['to']),
410                                                                         decode(msg['cc'])])))
411             forward_to = [i for i in message_followers_emails if (i and (i not in message_recipients))]
412             if forward_to:
413                 # TODO: we need an interface for this for all types of objects, not just leads
414                 if hasattr(res, 'section_id'):
415                     del msg['reply-to']
416                     msg['reply-to'] = res.section_id.reply_to
417
418                 smtp_from, = to_email(msg['from'])
419                 msg['from'] = smtp_from
420                 msg['to'] =  ", ".join(forward_to)
421                 msg['message-id'] = tools.generate_tracking_message_id(res.id)
422                 if not smtp_server_obj.send_email(cr, uid, msg) and email_error:
423                     subj = msg['subject']
424                     del msg['subject'], msg['to'], msg['cc'], msg['bcc']
425                     msg['subject'] = _('[OpenERP-Forward-Failed] %s') % subj
426                     msg['to'] = email_error
427                     smtp_server_obj.send_email(cr, uid, msg)
428         return True
429
430     def message_partner_by_email(self, cr, uid, email, context=None):
431         """Attempts to return the id of a partner address matching
432            the given ``email``, and the corresponding partner id.
433            Can be used by classes using the ``mail.thread`` mixin
434            to lookup the partner and use it in their implementation
435            of ``message_new`` to link the new record with a
436            corresponding partner.
437            The keys used in the returned dict are meant to map
438            to usual names for relationships towards a partner
439            and one of its addresses.
440            
441            :param email: email address for which a partner
442                          should be searched for.
443            :rtype: dict
444            :return: a map of the following form::
445
446                       { 'partner_address_id': id or False,
447                         'partner_id': pid or False }
448         """
449         address_pool = self.pool.get('res.partner.address')
450         res = {
451             'partner_address_id': False,
452             'partner_id': False
453         }
454         if email:
455             email = to_email(email)[0]
456             address_ids = address_pool.search(cr, uid, [('email', '=', email)])
457             if address_ids:
458                 address = address_pool.browse(cr, uid, address_ids[0])
459                 res['partner_address_id'] = address_ids[0]
460                 res['partner_id'] = address.partner_id.id
461         return res
462
463 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: