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