summaryrefslogtreecommitdiff
path: root/addons/mail/mail_message.py
diff options
context:
space:
mode:
Diffstat (limited to 'addons/mail/mail_message.py')
-rw-r--r--addons/mail/mail_message.py522
1 files changed, 522 insertions, 0 deletions
diff --git a/addons/mail/mail_message.py b/addons/mail/mail_message.py
new file mode 100644
index 00000000000..621eef59da5
--- /dev/null
+++ b/addons/mail/mail_message.py
@@ -0,0 +1,522 @@
+# -*- coding: utf-8 -*-
+##############################################################################
+#
+# OpenERP, Open Source Management Solution
+# Copyright (C) 2010-2011 OpenERP SA (<http://www.openerp.com>)
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>
+#
+##############################################################################
+
+import base64
+import dateutil.parser
+import email
+import logging
+import re
+import time
+from email.header import decode_header
+from email.message import Message
+
+import tools
+from osv import osv
+from osv import fields
+from tools.translate import _
+from tools.safe_eval import literal_eval
+
+_logger = logging.getLogger('mail')
+
+def format_date_tz(date, tz=None):
+ if not date:
+ return 'n/a'
+ format = tools.DEFAULT_SERVER_DATETIME_FORMAT
+ return tools.server_to_local_timestamp(date, format, format, tz)
+
+def truncate_text(text):
+ lines = text and text.split('\n') or []
+ if len(lines) > 3:
+ res = '\n\t'.join(lines[:3]) + '...'
+ else:
+ res = '\n\t'.join(lines)
+ return res
+
+def decode(text):
+ """Returns unicode() string conversion of the the given encoded smtp header text"""
+ if text:
+ text = decode_header(text.replace('\r', ''))
+ return ''.join([tools.ustr(x[0], x[1]) for x in text])
+
+def to_email(text):
+ """Return a list of the email addresses found in ``text``"""
+ if not text: return []
+ return re.findall(r'([^ ,<@]+@[^> ,]+)', text)
+
+class mail_message_common(osv.osv_memory):
+ """Common abstract class for holding the main attributes of a
+ message object. It could be reused as parent model for any
+ database model or wizard screen that needs to hold a kind of
+ message"""
+
+ _name = 'mail.message.common'
+ _rec_name = 'subject'
+ _columns = {
+ 'subject': fields.char('Subject', size=512, required=True),
+ 'model': fields.char('Related Document model', size=128, select=1, readonly=1),
+ 'res_id': fields.integer('Related Document ID', select=1, readonly=1),
+ 'date': fields.datetime('Date'),
+ 'email_from': fields.char('From', size=128, help='Message sender, taken from user preferences'),
+ 'email_to': fields.char('To', size=256, help='Message recipients'),
+ 'email_cc': fields.char('Cc', size=256, help='Carbon copy message recipients'),
+ 'email_bcc': fields.char('Bcc', size=256, help='Blind carbon copy message recipients'),
+ 'reply_to':fields.char('Reply-To', size=256, help='Preferred response address for the message'),
+ 'headers': fields.text('Message headers', readonly=1,
+ help="Full message headers, e.g. SMTP session headers "
+ "(usually available on inbound messages only)"),
+ 'message_id': fields.char('Message-Id', size=256, help='Message unique identifier', select=1, readonly=1),
+ 'references': fields.text('References', help='Message references, such as identifiers of previous messages', readonly=1),
+ 'subtype': fields.char('Message type', size=32, help="Type of message, usually 'html' or 'plain', used to "
+ "select plaintext or rich text contents accordingly", readonly=1),
+ 'body_text': fields.text('Text contents', help="Plain-text version of the message"),
+ 'body_html': fields.text('Rich-text contents', help="Rich-text/HTML version of the message"),
+ }
+
+ _defaults = {
+ 'subtype': 'plain'
+ }
+
+class mail_message(osv.osv):
+ '''Model holding RFC2822 email messages, and providing facilities
+ to parse, queue and send new messages
+
+ Messages that do not have a value for the email_from column
+ are simple log messages (e.g. document state changes), while
+ actual e-mails have the email_from value set.
+ The ``display_text`` field will have a slightly different
+ presentation for real emails and for log messages.
+ '''
+
+ _name = 'mail.message'
+ _inherit = 'mail.message.common'
+ _description = 'Email Message'
+ _order = 'date desc'
+
+ # XXX to review - how to determine action to use?
+ def open_document(self, cr, uid, ids, context=None):
+ action_data = False
+ if ids:
+ msg = self.browse(cr, uid, ids[0], context=context)
+ model = msg.model
+ res_id = msg.res_id
+
+ ir_act_window = self.pool.get('ir.actions.act_window')
+ action_ids = ir_act_window.search(cr, uid, [('res_model', '=', model)])
+ if action_ids:
+ action_data = ir_act_window.read(cr, uid, action_ids[0], context=context)
+ action_data.update({
+ 'domain' : "[('id','=',%d)]"%(res_id),
+ 'nodestroy': True,
+ 'context': {}
+ })
+ return action_data
+
+ # XXX to review - how to determine action to use?
+ def open_attachment(self, cr, uid, ids, context=None):
+ action_data = False
+ action_pool = self.pool.get('ir.actions.act_window')
+ message = self.browse(cr, uid, ids, context=context)[0]
+ att_ids = [x.id for x in message.attachment_ids]
+ action_ids = action_pool.search(cr, uid, [('res_model', '=', 'ir.attachment')])
+ if action_ids:
+ action_data = action_pool.read(cr, uid, action_ids[0], context=context)
+ action_data.update({
+ 'domain': [('id','in',att_ids)],
+ 'nodestroy': True
+ })
+ return action_data
+
+ def _get_display_text(self, cr, uid, ids, name, arg, context=None):
+ if context is None:
+ context = {}
+ tz = context.get('tz')
+ result = {}
+ for message in self.browse(cr, uid, ids, context=context):
+ msg_txt = ''
+ if message.email_from:
+ msg_txt += _('%s wrote on %s: \n Subject: %s \n\t') % (message.email_from or '/', format_date_tz(message.date, tz), message.subject)
+ if message.body_text:
+ msg_txt += truncate_text(message.body_text)
+ else:
+ msg_txt = (message.user_id.name or '/') + _(' on ') + format_date_tz(message.date, tz) + ':\n\t'
+ msg_txt += message.subject
+ result[message.id] = msg_txt
+ return result
+
+ _columns = {
+ 'partner_id': fields.many2one('res.partner', 'Related partner'),
+ 'user_id': fields.many2one('res.users', 'Related user', readonly=1),
+ 'attachment_ids': fields.many2many('ir.attachment', 'message_attachment_rel', 'message_id', 'attachment_id', 'Attachments'),
+ 'display_text': fields.function(_get_display_text, method=True, type='text', size="512", string='Display Text'),
+ 'mail_server_id': fields.many2one('ir.mail_server', 'Outgoing mail server', readonly=1),
+ 'state': fields.selection([
+ ('outgoing', 'Outgoing'),
+ ('sent', 'Sent'),
+ ('received', 'Received'),
+ ('exception', 'Delivery Failed'),
+ ('cancel', 'Cancelled'),
+ ], 'State', readonly=True),
+ 'auto_delete': fields.boolean('Auto Delete', help="Permanently delete this email after sending it, to save space"),
+ 'original': fields.binary('Original', help="Original version of the message, as it was sent on the network", readonly=1),
+ }
+
+ _defaults = {
+ 'state': 'received',
+ }
+
+ def init(self, cr):
+ cr.execute("""SELECT indexname FROM pg_indexes WHERE indexname = 'mail_message_model_res_id_idx'""")
+ if not cr.fetchone():
+ cr.execute("""CREATE INDEX mail_message_model_res_id_idx ON mail_message (model, res_id)""")
+
+ def copy(self, cr, uid, id, default=None, context=None):
+ """Overridden to avoid duplicating fields that are unique to each email"""
+ if default is None:
+ default = {}
+ default.update(message_id=False,original=False,headers=False)
+ return super(mail_message,self).copy(cr, uid, id, default=default, context=context)
+
+ def schedule_with_attach(self, cr, uid, email_from, email_to, subject, body, model=False, email_cc=None,
+ email_bcc=None, reply_to=False, attachments=None, message_id=False, references=False,
+ res_id=False, subtype='plain', headers=None, mail_server_id=False, auto_delete=False,
+ context=None):
+ """Schedule sending a new email message, to be sent the next time the mail scheduler runs, or
+ the next time :meth:`process_email_queue` is called explicitly.
+
+ :param string email_from: sender email address
+ :param list email_to: list of recipient addresses (to be joined with commas)
+ :param string subject: email subject (no pre-encoding/quoting necessary)
+ :param string body: email body, according to the ``subtype`` (by default, plaintext).
+ If html subtype is used, the message will be automatically converted
+ to plaintext and wrapped in multipart/alternative.
+ :param list email_cc: optional list of string values for CC header (to be joined with commas)
+ :param list email_bcc: optional list of string values for BCC header (to be joined with commas)
+ :param string model: optional model name of the document this mail is related to (this will also
+ be used to generate a tracking id, used to match any response related to the
+ same document)
+ :param int res_id: optional resource identifier this mail is related to (this will also
+ be used to generate a tracking id, used to match any response related to the
+ same document)
+ :param string reply_to: optional value of Reply-To header
+ :param string subtype: optional mime subtype for the text body (usually 'plain' or 'html'),
+ must match the format of the ``body`` parameter. Default is 'plain',
+ making the content part of the mail "text/plain".
+ :param dict attachments: map of filename to filecontents, where filecontents is a string
+ containing the bytes of the attachment
+ :param dict headers: optional map of headers to set on the outgoing mail (may override the
+ other headers, including Subject, Reply-To, Message-Id, etc.)
+ :param int mail_server_id: optional id of the preferred outgoing mail server for this mail
+ :param bool auto_delete: optional flag to turn on auto-deletion of the message after it has been
+ successfully sent (default to False)
+
+ """
+ if context is None:
+ context = {}
+ if attachments is None:
+ attachments = {}
+ attachment_obj = self.pool.get('ir.attachment')
+ for param in (email_to, email_cc, email_bcc):
+ if param and not isinstance(param, list):
+ param = [param]
+ msg_vals = {
+ 'subject': subject,
+ 'date': time.strftime('%Y-%m-%d %H:%M:%S'),
+ 'user_id': uid,
+ 'model': model,
+ 'res_id': res_id,
+ 'body_text': body if subtype != 'html' else False,
+ 'body_html': body if subtype == 'html' else False,
+ 'email_from': email_from,
+ 'email_to': email_to and ','.join(email_to) or '',
+ 'email_cc': email_cc and ','.join(email_cc) or '',
+ 'email_bcc': email_bcc and ','.join(email_bcc) or '',
+ 'reply_to': reply_to,
+ 'message_id': message_id,
+ 'references': references,
+ 'subtype': subtype,
+ 'headers': headers, # serialize the dict on the fly
+ 'mail_server_id': mail_server_id,
+ 'state': 'outgoing',
+ 'auto_delete': auto_delete
+ }
+ email_msg_id = self.create(cr, uid, msg_vals, context)
+ attachment_ids = []
+ for fname, fcontent in attachments.iteritems():
+ attachment_data = {
+ 'name': fname,
+ 'datas_fname': fname,
+ 'datas': fcontent,
+ 'res_model': self._name,
+ 'res_id': email_msg_id,
+ }
+ if context.has_key('default_type'):
+ del context['default_type']
+ attachment_ids.append(attachment_obj.create(cr, uid, attachment_data, context))
+ if attachment_ids:
+ self.write(cr, uid, email_msg_id, { 'attachment_ids': [(6, 0, attachment_ids)]}, context=context)
+ return email_msg_id
+
+ def mark_outgoing(self, cr, uid, ids, context=None):
+ return self.write(cr, uid, ids, {'state':'outgoing'}, context)
+
+ def process_email_queue(self, cr, uid, ids=None, context=None):
+ """Send immediately queued messages, committing after each
+ message is sent - this is not transactional and should
+ not be called during another transaction!
+
+ :param list ids: optional list of emails ids to send. If passed
+ no search is performed, and these ids are used
+ instead.
+ :param dict context: if a 'filters' key is present in context,
+ this value will be used as an additional
+ filter to further restrict the outgoing
+ messages to send (by default all 'outgoing'
+ messages are sent).
+ """
+ if context is None:
+ context = {}
+ if not ids:
+ filters = [('state', '=', 'outgoing')]
+ if 'filters' in context:
+ filters.extend(context['filters'])
+ ids = self.search(cr, uid, filters, context=context)
+ res = None
+ try:
+ # Force auto-commit - this is meant to be called by
+ # the scheduler, and we can't allow rolling back the status
+ # of previously sent emails!
+ res = self.send(cr, uid, ids, auto_commit=True, context=context)
+ except Exception:
+ _logger.exception("Failed processing mail queue")
+ return res
+
+ def parse_message(self, message, save_original=False):
+ """Parses a string or email.message.Message representing an
+ RFC-2822 email, and returns a generic dict holding the
+ message details.
+
+ :param message: the message to parse
+ :type message: email.message.Message | string | unicode
+ :param bool save_original: whether the returned dict
+ should include an ``original`` entry with the base64
+ encoded source of the message.
+ :rtype: dict
+ :return: A dict with the following structure, where each
+ field may not be present if missing in original
+ message::
+
+ { 'message-id': msg_id,
+ 'subject': subject,
+ 'from': from,
+ 'to': to,
+ 'cc': cc,
+ 'headers' : { 'X-Mailer': mailer,
+ #.. all X- headers...
+ },
+ 'subtype': msg_mime_subtype,
+ 'body_text': plaintext_body
+ 'body_html': html_body,
+ 'attachments': { 'file1': 'bytes',
+ 'file2': 'bytes' }
+ # ...
+ 'original': source_of_email,
+ }
+ """
+ msg_txt = message
+ if isinstance(message, str):
+ msg_txt = email.message_from_string(message)
+
+ # Warning: message_from_string doesn't always work correctly on unicode,
+ # we must use utf-8 strings here :-(
+ if isinstance(message, unicode):
+ message = message.encode('utf-8')
+ msg_txt = email.message_from_string(message)
+
+ message_id = msg_txt.get('message-id', False)
+ msg = {}
+
+ if save_original:
+ # save original, we need to be able to read the original email sometimes
+ msg['original'] = message.as_string() if isinstance(message, Message) \
+ else message
+ msg['original'] = base64.b64encode(msg['original']) # binary fields are b64
+
+ if not message_id:
+ # Very unusual situation, be we should be fault-tolerant here
+ message_id = time.time()
+ msg_txt['message-id'] = message_id
+ _logger.info('Parsing Message without message-id, generating a random one: %s', message_id)
+
+ fields = msg_txt.keys()
+ msg['id'] = message_id
+ msg['message-id'] = message_id
+
+ if 'Subject' in fields:
+ msg['subject'] = decode(msg_txt.get('Subject'))
+
+ if 'Content-Type' in fields:
+ msg['content-type'] = msg_txt.get('Content-Type')
+
+ if 'From' in fields:
+ msg['from'] = decode(msg_txt.get('From') or msg_txt.get_unixfrom())
+
+ if 'To' in fields:
+ msg['to'] = decode(msg_txt.get('To'))
+ if 'Delivered-To' in fields:
+ msg['to'] = decode(msg_txt.get('Delivered-To'))
+
+ if 'CC' in fields:
+ msg['cc'] = decode(msg_txt.get('CC'))
+
+ if 'Reply-To' in fields:
+ msg['reply'] = decode(msg_txt.get('Reply-To'))
+
+ if 'Date' in fields:
+ date_hdr = decode(msg_txt.get('Date'))
+ msg['date'] = dateutil.parser.parse(date_hdr).strftime("%Y-%m-%d %H:%M:%S")
+
+ if 'Content-Transfer-Encoding' in fields:
+ msg['encoding'] = msg_txt.get('Content-Transfer-Encoding')
+
+ if 'References' in fields:
+ msg['references'] = msg_txt.get('References')
+
+ if 'In-Reply-To' in fields:
+ msg['in-reply-to'] = msg_txt.get('In-Reply-To')
+
+ msg['headers'] = {}
+ msg['subtype'] = 'plain'
+ for item in msg_txt.items():
+ if item[0].startswith('X-'):
+ msg['headers'].update({item[0]: item[1]})
+ if not msg_txt.is_multipart() or 'text/plain' in msg.get('content-type', ''):
+ encoding = msg_txt.get_content_charset()
+ body = msg_txt.get_payload(decode=True)
+ if 'text/html' in msg.get('content-type', ''):
+ msg['body_html'] = body
+ msg['subtype'] = 'html'
+ body = tools.html2plaintext(body)
+ msg['body_text'] = tools.ustr(body, encoding)
+
+ attachments = {}
+ if msg_txt.is_multipart() or 'multipart/alternative' in msg.get('content-type', ''):
+ body = ""
+ if 'multipart/alternative' in msg.get('content-type', ''):
+ msg['subtype'] = 'alternative'
+ else:
+ msg['subtype'] = 'mixed'
+ for part in msg_txt.walk():
+ if part.get_content_maintype() == 'multipart':
+ continue
+
+ encoding = part.get_content_charset()
+ filename = part.get_filename()
+ if part.get_content_maintype()=='text':
+ content = part.get_payload(decode=True)
+ if filename:
+ attachments[filename] = content
+ content = tools.ustr(content, encoding)
+ if part.get_content_subtype() == 'html':
+ msg['body_html'] = content
+ msg['subtype'] = 'html' # html version prevails
+ body = tools.ustr(tools.html2plaintext(content))
+ elif part.get_content_subtype() == 'plain':
+ body = content
+ elif part.get_content_maintype() in ('application', 'image'):
+ if filename :
+ attachments[filename] = part.get_payload(decode=True)
+ else:
+ res = part.get_payload(decode=True)
+ body += tools.ustr(res, encoding)
+
+ msg['body_text'] = body
+ msg['attachments'] = attachments
+
+ # for backwards compatibility:
+ msg['body'] = msg['body_text']
+ msg['sub_type'] = msg['subtype'] or 'plain'
+ return msg
+
+ def send(self, cr, uid, ids, auto_commit=False, context=None):
+ """Sends the selected emails immediately, ignoring their current
+ state (mails that have already been sent should not be passed
+ unless they should actually be re-sent).
+ Emails successfully delivered are marked as 'sent', and those
+ that fail to be deliver are marked as 'exception', and the
+ corresponding error message is output in the server logs.
+
+ :param bool auto_commit: whether to force a commit of the message
+ status after sending each message (meant
+ only for processing by the scheduler),
+ should never be True during normal
+ transactions (default: False)
+ :return: True
+ """
+ if context is None:
+ context = {}
+ ir_mail_server = self.pool.get('ir.mail_server')
+ self.write(cr, uid, ids, {'state': 'outgoing'}, context=context)
+ for message in self.browse(cr, uid, ids, context=context):
+ try:
+ attachments = []
+ for attach in message.attachment_ids:
+ attachments.append((attach.datas_fname, base64.b64decode(attach.datas)))
+ msg = ir_mail_server.build_email(
+ email_from=message.email_from,
+ email_to=to_email(message.email_to),
+ subject=message.subject,
+ body=message.body_html if message.subtype == 'html' else message.body_text,
+ email_cc=to_email(message.email_cc),
+ email_bcc=to_email(message.email_bcc),
+ reply_to=message.reply_to,
+ attachments=attachments, message_id=message.message_id,
+ references = message.references,
+ object_id=message.res_id and ('%s-%s' % (message.res_id,message.model)),
+ subtype=message.subtype,
+ headers=message.headers and literal_eval(message.headers))
+ res = ir_mail_server.send_email(cr, uid, msg,
+ mail_server_id=message.mail_server_id.id,
+ context=context)
+ if res:
+ message.write({'state':'sent', 'message_id': res})
+ else:
+ message.write({'state':'exception'})
+
+ # if auto_delete=True then delete that sent messages as well as attachments
+ message.refresh()
+ if message.state == 'sent' and message.auto_delete:
+ self.pool.get('ir.attachment').unlink(cr, uid,
+ [x.id for x in message.attachment_ids],
+ context=context)
+ message.unlink()
+ except Exception:
+ _logger.exception('failed sending mail.message %s', message.id)
+ message.write({'state':'exception'})
+
+ if auto_commit == True:
+ cr.commit()
+ return True
+
+ def cancel(self, cr, uid, ids, context=None):
+ self.write(cr, uid, ids, {'state':'cancel'}, context=context)
+ return True
+
+# vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: