diff options
Diffstat (limited to 'addons/mail/mail_message.py')
| -rw-r--r-- | addons/mail/mail_message.py | 522 |
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: |
