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