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