[REV] message_state: reverted changes and deleted message_state field from mail.threa...
[odoo/odoo.git] / addons / mail / mail_message.py
1 # -*- coding: utf-8 -*-
2 ##############################################################################
3 #
4 #    OpenERP, Open Source Management Solution
5 #    Copyright (C) 2010-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 ast
23 import base64
24 import dateutil.parser
25 import email
26 import logging
27 import re
28 import time
29 import datetime
30 from email.header import decode_header
31 from email.message import Message
32
33 import tools
34 from osv import osv
35 from osv import fields
36 from tools.translate import _
37 from openerp import SUPERUSER_ID
38
39 _logger = logging.getLogger('mail')
40
41 def format_date_tz(date, tz=None):
42     if not date:
43         return 'n/a'
44     format = tools.DEFAULT_SERVER_DATETIME_FORMAT
45     return tools.server_to_local_timestamp(date, format, format, tz)
46
47 def truncate_text(text):
48     lines = text and text.split('\n') or []
49     if len(lines) > 3:
50         res = '\n\t'.join(lines[:3]) + '...'
51     else:
52         res = '\n\t'.join(lines)
53     return res
54
55 def decode(text):
56     """Returns unicode() string conversion of the the given encoded smtp header text"""
57     if text:
58         text = decode_header(text.replace('\r', ''))
59         return ''.join([tools.ustr(x[0], x[1]) for x in text])
60
61 def to_email(text):
62     """Return a list of the email addresses found in ``text``"""
63     if not text: return []
64     return re.findall(r'([^ ,<@]+@[^> ,]+)', text)
65
66 class mail_message_common(osv.osv_memory):
67     """Common abstract class for holding the main attributes of a 
68        message object. It could be reused as parent model for any
69        database model or wizard screen that needs to hold a kind of
70        message"""
71
72     def get_body(self, cr, uid, ids, name, arg, context=None):
73         if context is None:
74             context = {}
75         result = dict.fromkeys(ids, '')
76         for message in self.browse(cr, uid, ids, context=context):
77             if message.subtype == 'html':
78                 result[message.id] = message.body_html
79             else:
80                 result[message.id] = message.body_text
81         return result
82     
83     def search_body(self, cr, uid, obj, name, args, context=None):
84         """will receive:
85            - obj: mail.message object
86            - name: 'body'
87            - args: [('body', 'ilike', 'blah')]"""
88         return ['|', '&', ('subtype', '=', 'html'), ('body_html', args[0][1], args[0][2]), ('body_text', args[0][1], args[0][2])]
89     
90     def get_record_name(self, cr, uid, ids, name, arg, context=None):
91         if context is None:
92             context = {}
93         result = dict.fromkeys(ids, '')
94         for message in self.browse(cr, uid, ids, context=context):
95             if not message.model or not message.res_id:
96                 continue
97             result[message.id] = self.pool.get(message.model).name_get(cr, uid, [message.res_id], context=context)[0][1]
98         return result
99     
100     _name = 'mail.message.common'
101     _rec_name = 'subject'
102     _columns = {
103         'subject': fields.char('Subject', size=512, required=True),
104         'model': fields.char('Related Document Model', size=128, select=1),
105         'res_id': fields.integer('Related Document ID', select=1),
106         'record_name': fields.function(get_record_name, type='string', string='Message Record Name',
107                         help="Name of the record, matching the result of the name_get."),
108         'date': fields.datetime('Date'),
109         'email_from': fields.char('From', size=128, help='Message sender, taken from user preferences.'),
110         'email_to': fields.char('To', size=256, help='Message recipients'),
111         'email_cc': fields.char('Cc', size=256, help='Carbon copy message recipients'),
112         'email_bcc': fields.char('Bcc', size=256, help='Blind carbon copy message recipients'),
113         'reply_to':fields.char('Reply-To', size=256, help='Preferred response address for the message'),
114         'headers': fields.text('Message Headers', readonly=1,
115                         help="Full message headers, e.g. SMTP session headers (usually available on inbound messages only)"),
116         'message_id': fields.char('Message-Id', size=256, help='Message unique identifier', select=1, readonly=1),
117         'references': fields.text('References', help='Message references, such as identifiers of previous messages', readonly=1),
118         'subtype': fields.char('Message Type', size=32, help="Type of message, usually 'html' or 'plain', used to "
119                                                              "select plaintext or rich text contents accordingly", readonly=1),
120         'body_text': fields.text('Text Contents', help="Plain-text version of the message"),
121         'body_html': fields.text('Rich-text Contents', help="Rich-text/HTML version of the message"),
122         'body': fields.function(get_body, fnct_search = search_body, string='Message Content', type='text',
123                         help="Content of the message. This content equals the body_text field for plain-test messages, and body_html for rich-text/HTML messages. This allows having one field if we want to access the content matching the message subtype."),
124         'parent_id': fields.many2one('mail.message', 'Parent Message', help="Parent message, used for displaying as threads with hierarchy",
125                         select=True, ondelete='set null',),
126         'child_ids': fields.one2many('mail.message', 'parent_id', 'Child Messages'),
127     }
128
129     _defaults = {
130         'subtype': 'plain',
131         'date': (lambda *a: fields.datetime.now()),
132     }
133
134 class mail_message(osv.osv):
135     '''Model holding messages: system notification (replacing res.log
136        notifications), comments (for OpenSocial feature) and
137        RFC2822 email messages. This model also provides facilities to
138        parse, queue and send new email messages. Type of messages
139        are differentiated using the 'type' column.
140        
141        The ``display_text`` field will have a slightly different
142        presentation for real emails and for log messages.
143        '''
144
145     _name = 'mail.message'
146     _inherit = 'mail.message.common'
147     _description = 'Mail Message (email, comment, notification)'
148     _order = 'date desc'
149
150     # XXX to review - how to determine action to use?
151     def open_document(self, cr, uid, ids, context=None):
152         action_data = False
153         if ids:
154             msg = self.browse(cr, uid, ids[0], context=context)
155             model = msg.model
156             res_id = msg.res_id
157
158             ir_act_window = self.pool.get('ir.actions.act_window')
159             action_ids = ir_act_window.search(cr, uid, [('res_model', '=', model)])
160             if action_ids:
161                 action_data = ir_act_window.read(cr, uid, action_ids[0], context=context)
162                 action_data.update({
163                     'domain' : "[('id','=',%d)]"%(res_id),
164                     'nodestroy': True,
165                     'context': {}
166                     })
167         return action_data
168
169     # XXX to review - how to determine action to use?
170     def open_attachment(self, cr, uid, ids, context=None):
171         action_data = False
172         action_pool = self.pool.get('ir.actions.act_window')
173         message = self.browse(cr, uid, ids, context=context)[0]
174         att_ids = [x.id for x in message.attachment_ids]
175         action_ids = action_pool.search(cr, uid, [('res_model', '=', 'ir.attachment')])
176         if action_ids:
177             action_data = action_pool.read(cr, uid, action_ids[0], context=context)
178             action_data.update({
179                 'domain': [('id','in',att_ids)],
180                 'nodestroy': True
181                 })
182         return action_data
183
184     def _get_display_text(self, cr, uid, ids, name, arg, context=None):
185         if context is None:
186             context = {}
187         tz = context.get('tz')
188         result = {}
189
190         # Read message as UID 1 to allow viewing author even if from different company
191         for message in self.browse(cr, SUPERUSER_ID, ids):
192             msg_txt = ''
193             if message.email_from:
194                 msg_txt += _('%s wrote on %s: \n Subject: %s \n\t') % (message.email_from or '/', format_date_tz(message.date, tz), message.subject)
195                 if message.body_text:
196                     msg_txt += truncate_text(message.body_text)
197             else:
198                 msg_txt = (message.user_id.name or '/') + _(' on ') + format_date_tz(message.date, tz) + ':\n\t'
199                 msg_txt += (message.subject or '')
200             result[message.id] = msg_txt
201         return result
202     
203     _columns = {
204         'type': fields.selection([
205                         ('email', 'e-mail'),
206                         ('comment', 'Comment'),
207                         ('notification', 'System notification'),
208                         ], 'Type', help="Message type: e-mail for e-mail message, notification for system message, comment for other messages such as user replies"),
209         'partner_id': fields.many2one('res.partner', 'Related partner'),
210         'user_id': fields.many2one('res.users', 'Related User', readonly=1),
211         'attachment_ids': fields.many2many('ir.attachment', 'message_attachment_rel', 'message_id', 'attachment_id', 'Attachments'),
212         'display_text': fields.function(_get_display_text, method=True, type='text', size="512", string='Display Text'),
213         'mail_server_id': fields.many2one('ir.mail_server', 'Outgoing mail server', readonly=1),
214         'state': fields.selection([
215                         ('outgoing', 'Outgoing'),
216                         ('sent', 'Sent'),
217                         ('received', 'Received'),
218                         ('exception', 'Delivery Failed'),
219                         ('cancel', 'Cancelled'),
220                         ], 'Status', readonly=True),
221         'auto_delete': fields.boolean('Auto Delete', help="Permanently delete this email after sending it, to save space"),
222         'original': fields.binary('Original', help="Original version of the message, as it was sent on the network", readonly=1),
223     }
224         
225     _defaults = {
226         'type': 'email',
227         'state': 'received',
228     }
229     
230     #------------------------------------------------------
231     # E-Mail api
232     #------------------------------------------------------
233     
234     def init(self, cr):
235         cr.execute("""SELECT indexname FROM pg_indexes WHERE indexname = 'mail_message_model_res_id_idx'""")
236         if not cr.fetchone():
237             cr.execute("""CREATE INDEX mail_message_model_res_id_idx ON mail_message (model, res_id)""")
238
239     def copy(self, cr, uid, id, default=None, context=None):
240         """Overridden to avoid duplicating fields that are unique to each email"""
241         if default is None:
242             default = {}
243         default.update(message_id=False,original=False,headers=False)
244         return super(mail_message,self).copy(cr, uid, id, default=default, context=context)
245
246     def schedule_with_attach(self, cr, uid, email_from, email_to, subject, body, model=False, email_cc=None,
247                              email_bcc=None, reply_to=False, attachments=None, message_id=False, references=False,
248                              res_id=False, subtype='plain', headers=None, mail_server_id=False, auto_delete=False,
249                              context=None):
250         """Schedule sending a new email message, to be sent the next time the mail scheduler runs, or
251            the next time :meth:`process_email_queue` is called explicitly.
252
253            :param string email_from: sender email address
254            :param list email_to: list of recipient addresses (to be joined with commas) 
255            :param string subject: email subject (no pre-encoding/quoting necessary)
256            :param string body: email body, according to the ``subtype`` (by default, plaintext).
257                                If html subtype is used, the message will be automatically converted
258                                to plaintext and wrapped in multipart/alternative.
259            :param list email_cc: optional list of string values for CC header (to be joined with commas)
260            :param list email_bcc: optional list of string values for BCC header (to be joined with commas)
261            :param string model: optional model name of the document this mail is related to (this will also
262                                 be used to generate a tracking id, used to match any response related to the
263                                 same document)
264            :param int res_id: optional resource identifier this mail is related to (this will also
265                               be used to generate a tracking id, used to match any response related to the
266                               same document)
267            :param string reply_to: optional value of Reply-To header
268            :param string subtype: optional mime subtype for the text body (usually 'plain' or 'html'),
269                                   must match the format of the ``body`` parameter. Default is 'plain',
270                                   making the content part of the mail "text/plain".
271            :param dict attachments: map of filename to filecontents, where filecontents is a string
272                                     containing the bytes of the attachment
273            :param dict headers: optional map of headers to set on the outgoing mail (may override the
274                                 other headers, including Subject, Reply-To, Message-Id, etc.)
275            :param int mail_server_id: optional id of the preferred outgoing mail server for this mail
276            :param bool auto_delete: optional flag to turn on auto-deletion of the message after it has been
277                                     successfully sent (default to False)
278
279         """
280         if context is None:
281             context = {}
282         if attachments is None:
283             attachments = {}
284         attachment_obj = self.pool.get('ir.attachment')
285         for param in (email_to, email_cc, email_bcc):
286             if param and not isinstance(param, list):
287                 param = [param]
288         msg_vals = {
289                 'subject': subject,
290                 'date': time.strftime('%Y-%m-%d %H:%M:%S'),
291                 'user_id': uid,
292                 'model': model,
293                 'res_id': res_id,
294                 'type': 'email',
295                 'body_text': body if subtype != 'html' else False,
296                 'body_html': body if subtype == 'html' else False,
297                 'email_from': email_from,
298                 'email_to': email_to and ','.join(email_to) or '',
299                 'email_cc': email_cc and ','.join(email_cc) or '',
300                 'email_bcc': email_bcc and ','.join(email_bcc) or '',
301                 'reply_to': reply_to,
302                 'message_id': message_id,
303                 'references': references,
304                 'subtype': subtype,
305                 'headers': headers, # serialize the dict on the fly
306                 'mail_server_id': mail_server_id,
307                 'state': 'outgoing',
308                 'auto_delete': auto_delete
309             }
310         email_msg_id = self.create(cr, uid, msg_vals, context)
311         attachment_ids = []
312         for fname, fcontent in attachments.iteritems():
313             attachment_data = {
314                     'name': fname,
315                     'datas_fname': fname,
316                     'datas': fcontent and fcontent.encode('base64'),
317                     'res_model': self._name,
318                     'res_id': email_msg_id,
319             }
320             if context.has_key('default_type'):
321                 del context['default_type']
322             attachment_ids.append(attachment_obj.create(cr, uid, attachment_data, context))
323         if attachment_ids:
324             self.write(cr, uid, email_msg_id, { 'attachment_ids': [(6, 0, attachment_ids)]}, context=context)
325         return email_msg_id
326
327     def mark_outgoing(self, cr, uid, ids, context=None):
328         return self.write(cr, uid, ids, {'state':'outgoing'}, context)
329
330     def process_email_queue(self, cr, uid, ids=None, context=None):
331         """Send immediately queued messages, committing after each
332            message is sent - this is not transactional and should
333            not be called during another transaction!
334
335            :param list ids: optional list of emails ids to send. If passed
336                             no search is performed, and these ids are used
337                             instead.
338            :param dict context: if a 'filters' key is present in context,
339                                 this value will be used as an additional
340                                 filter to further restrict the outgoing
341                                 messages to send (by default all 'outgoing'
342                                 messages are sent).
343         """
344         if context is None:
345             context = {}
346         if not ids:
347             filters = [('state', '=', 'outgoing')]
348             if 'filters' in context:
349                 filters.extend(context['filters'])
350             ids = self.search(cr, uid, filters, context=context)
351         res = None
352         try:
353             # Force auto-commit - this is meant to be called by
354             # the scheduler, and we can't allow rolling back the status
355             # of previously sent emails!
356             res = self.send(cr, uid, ids, auto_commit=True, context=context)
357         except Exception:
358             _logger.exception("Failed processing mail queue")
359         return res
360
361     def parse_message(self, message, save_original=False):
362         """Parses a string or email.message.Message representing an
363            RFC-2822 email, and returns a generic dict holding the
364            message details.
365
366            :param message: the message to parse
367            :type message: email.message.Message | string | unicode
368            :param bool save_original: whether the returned dict
369                should include an ``original`` entry with the base64
370                encoded source of the message.
371            :rtype: dict
372            :return: A dict with the following structure, where each
373                     field may not be present if missing in original
374                     message::
375
376                     { 'message-id': msg_id,
377                       'subject': subject,
378                       'from': from,
379                       'to': to,
380                       'cc': cc,
381                       'headers' : { 'X-Mailer': mailer,
382                                     #.. all X- headers...
383                                   },
384                       'subtype': msg_mime_subtype,
385                       'body_text': plaintext_body
386                       'body_html': html_body,
387                       'attachments': [('file1', 'bytes'),
388                                        ('file2', 'bytes') }
389                        # ...
390                        'original': source_of_email,
391                     }
392         """
393         msg_txt = message
394         if isinstance(message, str):
395             msg_txt = email.message_from_string(message)
396
397         # Warning: message_from_string doesn't always work correctly on unicode,
398         # we must use utf-8 strings here :-(
399         if isinstance(message, unicode):
400             message = message.encode('utf-8')
401             msg_txt = email.message_from_string(message)
402
403         message_id = msg_txt.get('message-id', False)
404         msg = {}
405
406         if save_original:
407             # save original, we need to be able to read the original email sometimes
408             msg['original'] = message.as_string() if isinstance(message, Message) \
409                                                   else message
410             msg['original'] = base64.b64encode(msg['original']) # binary fields are b64
411
412         if not message_id:
413             # Very unusual situation, be we should be fault-tolerant here
414             message_id = time.time()
415             msg_txt['message-id'] = message_id
416             _logger.info('Parsing Message without message-id, generating a random one: %s', message_id)
417
418         fields = msg_txt.keys()
419         msg['id'] = message_id
420         msg['message-id'] = message_id
421
422         if 'Subject' in fields:
423             msg['subject'] = decode(msg_txt.get('Subject'))
424
425         if 'Content-Type' in fields:
426             msg['content-type'] = msg_txt.get('Content-Type')
427
428         if 'From' in fields:
429             msg['from'] = decode(msg_txt.get('From') or msg_txt.get_unixfrom())
430
431         if 'To' in fields:
432             msg['to'] = decode(msg_txt.get('To'))
433
434         if 'Delivered-To' in fields:
435             msg['to'] = decode(msg_txt.get('Delivered-To'))
436
437         if 'CC' in fields:
438             msg['cc'] = decode(msg_txt.get('CC'))
439
440         if 'Cc' in fields:
441             msg['cc'] = decode(msg_txt.get('Cc'))
442
443         if 'Reply-To' in fields:
444             msg['reply'] = decode(msg_txt.get('Reply-To'))
445
446         if 'Date' in fields:
447             date_hdr = decode(msg_txt.get('Date'))
448             msg['date'] = dateutil.parser.parse(date_hdr).strftime("%Y-%m-%d %H:%M:%S")
449
450         if 'Content-Transfer-Encoding' in fields:
451             msg['encoding'] = msg_txt.get('Content-Transfer-Encoding')
452
453         if 'References' in fields:
454             msg['references'] = msg_txt.get('References')
455
456         if 'In-Reply-To' in fields:
457             msg['in-reply-to'] = msg_txt.get('In-Reply-To')
458
459         msg['headers'] = {}
460         msg['subtype'] = 'plain'
461         for item in msg_txt.items():
462             if item[0].startswith('X-'):
463                 msg['headers'].update({item[0]: item[1]})
464         if not msg_txt.is_multipart() or 'text/plain' in msg.get('content-type', ''):
465             encoding = msg_txt.get_content_charset()
466             body = msg_txt.get_payload(decode=True)
467             if 'text/html' in msg.get('content-type', ''):
468                 msg['body_html'] =  body
469                 msg['subtype'] = 'html'
470                 if body:
471                     body = tools.html2plaintext(body)
472             msg['body_text'] = tools.ustr(body, encoding)
473
474         attachments = []
475         if msg_txt.is_multipart() or 'multipart/alternative' in msg.get('content-type', ''):
476             body = ""
477             if 'multipart/alternative' in msg.get('content-type', ''):
478                 msg['subtype'] = 'alternative'
479             else:
480                 msg['subtype'] = 'mixed'
481             for part in msg_txt.walk():
482                 if part.get_content_maintype() == 'multipart':
483                     continue
484
485                 encoding = part.get_content_charset()
486                 filename = part.get_filename()
487                 if part.get_content_maintype()=='text':
488                     content = part.get_payload(decode=True)
489                     if filename:
490                         attachments.append((filename, content))
491                     content = tools.ustr(content, encoding)
492                     if part.get_content_subtype() == 'html':
493                         msg['body_html'] = content
494                         msg['subtype'] = 'html' # html version prevails
495                         body = tools.ustr(tools.html2plaintext(content))
496                         body = body.replace('&#13;', '')
497                     elif part.get_content_subtype() == 'plain':
498                         body = content
499                 elif part.get_content_maintype() in ('application', 'image'):
500                     if filename :
501                         attachments.append((filename,part.get_payload(decode=True)))
502                     else:
503                         res = part.get_payload(decode=True)
504                         body += tools.ustr(res, encoding)
505
506             msg['body_text'] = body
507         msg['attachments'] = attachments
508
509         # for backwards compatibility:
510         msg['body'] = msg['body_text']
511         msg['sub_type'] = msg['subtype'] or 'plain'
512         return msg
513
514     def _postprocess_sent_message(self, cr, uid, message, context=None):
515         """Perform any post-processing necessary after sending ``message``
516         successfully, including deleting it completely along with its
517         attachment if the ``auto_delete`` flag of the message was set.
518         Overridden by subclasses for extra post-processing behaviors. 
519
520         :param browse_record message: the message that was just sent
521         :return: True
522         """
523         if message.auto_delete:
524             self.pool.get('ir.attachment').unlink(cr, uid,
525                                                   [x.id for x in message.attachment_ids \
526                                                         if x.res_model == self._name and \
527                                                            x.res_id == message.id],
528                                                   context=context)
529             message.unlink()
530         return True
531
532     def send(self, cr, uid, ids, auto_commit=False, context=None):
533         """Sends the selected emails immediately, ignoring their current
534            state (mails that have already been sent should not be passed
535            unless they should actually be re-sent).
536            Emails successfully delivered are marked as 'sent', and those
537            that fail to be deliver are marked as 'exception', and the
538            corresponding error message is output in the server logs.
539
540            :param bool auto_commit: whether to force a commit of the message
541                                     status after sending each message (meant
542                                     only for processing by the scheduler),
543                                     should never be True during normal
544                                     transactions (default: False)
545            :return: True
546         """
547         if context is None:
548             context = {}
549         ir_mail_server = self.pool.get('ir.mail_server')
550         self.write(cr, uid, ids, {'state': 'outgoing'}, context=context)
551         for message in self.browse(cr, uid, ids, context=context):
552             try:
553                 attachments = []
554                 for attach in message.attachment_ids:
555                     attachments.append((attach.datas_fname, base64.b64decode(attach.datas)))
556
557                 body = message.body_html if message.subtype == 'html' else message.body_text
558                 body_alternative = None
559                 subtype_alternative = None
560                 if message.subtype == 'html' and message.body_text:
561                     # we have a plain text alternative prepared, pass it to 
562                     # build_message instead of letting it build one
563                     body_alternative = message.body_text
564                     subtype_alternative = 'plain'
565
566                 msg = ir_mail_server.build_email(
567                     email_from=message.email_from,
568                     email_to=to_email(message.email_to),
569                     subject=message.subject,
570                     body=body,
571                     body_alternative=body_alternative,
572                     email_cc=to_email(message.email_cc),
573                     email_bcc=to_email(message.email_bcc),
574                     reply_to=message.reply_to,
575                     attachments=attachments, message_id=message.message_id,
576                     references = message.references,
577                     object_id=message.res_id and ('%s-%s' % (message.res_id,message.model)),
578                     subtype=message.subtype,
579                     subtype_alternative=subtype_alternative,
580                     headers=message.headers and ast.literal_eval(message.headers))
581                 res = ir_mail_server.send_email(cr, uid, msg,
582                                                 mail_server_id=message.mail_server_id.id,
583                                                 context=context)
584                 if res:
585                     message.write({'state':'sent', 'message_id': res})
586                 else:
587                     message.write({'state':'exception'})
588                 message.refresh()
589                 if message.state == 'sent':
590                     self._postprocess_sent_message(cr, uid, message, context=context)
591             except Exception:
592                 _logger.exception('failed sending mail.message %s', message.id)
593                 message.write({'state':'exception'})
594
595             if auto_commit == True:
596                 cr.commit()
597         return True
598
599     def cancel(self, cr, uid, ids, context=None):
600         self.write(cr, uid, ids, {'state':'cancel'}, context=context)
601         return True
602
603 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: