[IMP] When trying to launch the Chart of Accounts configuration wizard, there is...
[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 from email.header import decode_header
30 from email.message import Message
31
32 import tools
33 from osv import osv
34 from osv import fields
35 from tools.translate import _
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
384         if 'Delivered-To' in fields:
385             msg['to'] = decode(msg_txt.get('Delivered-To'))
386
387         if 'CC' in fields:
388             msg['cc'] = decode(msg_txt.get('CC'))
389
390         if 'Cc' in fields:
391             msg['cc'] = decode(msg_txt.get('Cc'))
392
393         if 'Reply-To' in fields:
394             msg['reply'] = decode(msg_txt.get('Reply-To'))
395
396         if 'Date' in fields:
397             date_hdr = decode(msg_txt.get('Date'))
398             msg['date'] = dateutil.parser.parse(date_hdr).strftime("%Y-%m-%d %H:%M:%S")
399
400         if 'Content-Transfer-Encoding' in fields:
401             msg['encoding'] = msg_txt.get('Content-Transfer-Encoding')
402
403         if 'References' in fields:
404             msg['references'] = msg_txt.get('References')
405
406         if 'In-Reply-To' in fields:
407             msg['in-reply-to'] = msg_txt.get('In-Reply-To')
408
409         msg['headers'] = {}
410         msg['subtype'] = 'plain'
411         for item in msg_txt.items():
412             if item[0].startswith('X-'):
413                 msg['headers'].update({item[0]: item[1]})
414         if not msg_txt.is_multipart() or 'text/plain' in msg.get('content-type', ''):
415             encoding = msg_txt.get_content_charset()
416             body = msg_txt.get_payload(decode=True)
417             if 'text/html' in msg.get('content-type', ''):
418                 msg['body_html'] =  body
419                 msg['subtype'] = 'html'
420                 body = tools.html2plaintext(body)
421             msg['body_text'] = tools.ustr(body, encoding)
422
423         attachments = []
424         if msg_txt.is_multipart() or 'multipart/alternative' in msg.get('content-type', ''):
425             body = ""
426             if 'multipart/alternative' in msg.get('content-type', ''):
427                 msg['subtype'] = 'alternative'
428             else:
429                 msg['subtype'] = 'mixed'
430             for part in msg_txt.walk():
431                 if part.get_content_maintype() == 'multipart':
432                     continue
433
434                 encoding = part.get_content_charset()
435                 filename = part.get_filename()
436                 if part.get_content_maintype()=='text':
437                     content = part.get_payload(decode=True)
438                     if filename:
439                         attachments.append((filename, content))
440                     content = tools.ustr(content, encoding)
441                     if part.get_content_subtype() == 'html':
442                         msg['body_html'] = content
443                         msg['subtype'] = 'html' # html version prevails
444                         body = tools.ustr(tools.html2plaintext(content))
445                     elif part.get_content_subtype() == 'plain':
446                         body = content
447                 elif part.get_content_maintype() in ('application', 'image'):
448                     if filename :
449                         attachments.append((filename,part.get_payload(decode=True)))
450                     else:
451                         res = part.get_payload(decode=True)
452                         body += tools.ustr(res, encoding)
453
454             msg['body_text'] = body
455         msg['attachments'] = attachments
456
457         # for backwards compatibility:
458         msg['body'] = msg['body_text']
459         msg['sub_type'] = msg['subtype'] or 'plain'
460         return msg
461
462
463     def send(self, cr, uid, ids, auto_commit=False, context=None):
464         """Sends the selected emails immediately, ignoring their current
465            state (mails that have already been sent should not be passed
466            unless they should actually be re-sent).
467            Emails successfully delivered are marked as 'sent', and those
468            that fail to be deliver are marked as 'exception', and the
469            corresponding error message is output in the server logs.
470
471            :param bool auto_commit: whether to force a commit of the message
472                                     status after sending each message (meant
473                                     only for processing by the scheduler),
474                                     should never be True during normal
475                                     transactions (default: False)
476            :return: True
477         """
478         if context is None:
479             context = {}
480         ir_mail_server = self.pool.get('ir.mail_server')
481         self.write(cr, uid, ids, {'state': 'outgoing'}, context=context)
482         for message in self.browse(cr, uid, ids, context=context):
483             try:
484                 attachments = []
485                 for attach in message.attachment_ids:
486                     attachments.append((attach.datas_fname, base64.b64decode(attach.datas)))
487
488                 body = message.body_html if message.subtype == 'html' else message.body_text
489                 body_alternative = None
490                 subtype_alternative = None
491                 if message.subtype == 'html' and message.body_text:
492                     # we have a plain text alternative prepared, pass it to 
493                     # build_message instead of letting it build one
494                     body_alternative = message.body_text
495                     subtype_alternative = 'plain'
496
497                 msg = ir_mail_server.build_email(
498                     email_from=message.email_from,
499                     email_to=to_email(message.email_to),
500                     subject=message.subject,
501                     body=body,
502                     body_alternative=body_alternative,
503                     email_cc=to_email(message.email_cc),
504                     email_bcc=to_email(message.email_bcc),
505                     reply_to=message.reply_to,
506                     attachments=attachments, message_id=message.message_id,
507                     references = message.references,
508                     object_id=message.res_id and ('%s-%s' % (message.res_id,message.model)),
509                     subtype=message.subtype,
510                     subtype_alternative=subtype_alternative,
511                     headers=message.headers and ast.literal_eval(message.headers))
512                 res = ir_mail_server.send_email(cr, uid, msg,
513                                                 mail_server_id=message.mail_server_id.id,
514                                                 context=context)
515                 if res:
516                     message.write({'state':'sent', 'message_id': res})
517                 else:
518                     message.write({'state':'exception'})
519
520                 # if auto_delete=True then delete that sent messages as well as attachments
521                 message.refresh()
522                 if message.state == 'sent' and message.auto_delete:
523                     self.pool.get('ir.attachment').unlink(cr, uid,
524                                                           [x.id for x in message.attachment_ids],
525                                                           context=context)
526                     message.unlink()
527             except Exception:
528                 _logger.exception('failed sending mail.message %s', message.id)
529                 message.write({'state':'exception'})
530
531             if auto_commit == True:
532                 cr.commit()
533         return True
534
535     def cancel(self, cr, uid, ids, context=None):
536         self.write(cr, uid, ids, {'state':'cancel'}, context=context)
537         return True
538
539 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: