diff --git a/addons/mail/__init__.py b/addons/mail/__init__.py index 8cfe6029856..4787883ef9c 100644 --- a/addons/mail/__init__.py +++ b/addons/mail/__init__.py @@ -2,7 +2,7 @@ ############################################################################## # # OpenERP, Open Source Management Solution -# Copyright (C) 2004-2010 Tiny SPRL (). +# Copyright (C) 2009-Today OpenERP SA (). # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as @@ -19,8 +19,8 @@ # ############################################################################## -import email_message -import email_thread +import mail_message +import mail_thread import res_partner import wizard diff --git a/addons/mail/__openerp__.py b/addons/mail/__openerp__.py index 11cf3bb1f1a..a636cee6903 100644 --- a/addons/mail/__openerp__.py +++ b/addons/mail/__openerp__.py @@ -20,29 +20,35 @@ ############################################################################## { - 'name': 'Email System', + 'name': 'Email Subsystem', 'version': '1.0', 'category': 'Tools', 'description': """ -The generic email system allows to send and receive emails. -=================================================================== +A generic email subsystem with message storage and queuing +========================================================== - * SMTP Server Configuration - * Provide API for Sending Messages - * Store all emails releated messages""", + * Uses the global Outgoing Mail Servers for sending mail + * Provides an API for sending messages and archiving them, + grouped by conversation + * Includes queuing mechanism with automated configurable + scheduler-based processing + * Includes a generic mail composition wizard, including + a simple mechanism for mass-mailing with the use of + basic templates - see ``email_template`` module for + more features + + """, 'author': 'OpenERP SA', 'website': 'http://www.openerp.com', 'depends': ['base', 'base_tools'], - 'init_xml': [], - 'update_xml': [ - "wizard/email_compose_message_view.xml", - "email_view.xml", - "email_thread_view.xml", + 'data': [ + "wizard/mail_compose_message_view.xml", + "mail_view.xml", + "mail_thread_view.xml", "res_partner_view.xml", 'security/ir.model.access.csv', - 'email_data.xml', + 'mail_data.xml', ], - 'demo_xml': [], 'installable': True, 'active': False, 'certificate': '001056784984222247309', diff --git a/addons/mail/email_message.py b/addons/mail/email_message.py deleted file mode 100644 index 8f6d0e7555f..00000000000 --- a/addons/mail/email_message.py +++ /dev/null @@ -1,538 +0,0 @@ -# -*- coding: utf-8 -*- -############################################################################## -# -# OpenERP, Open Source Management Solution -# Copyright (C) 2004-2010 Tiny SPRL () -# -# 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 -# -############################################################################## - -from osv import osv -from osv import fields -from tools.translate import _ -import tools -import netsvc -import base64 -import time -import logging -import re -import email -from email.header import decode_header -#import binascii -#import email -#from email.header import decode_header -#from email.utils import parsedate -#import base64 -#import re -#import logging -#import xmlrpclib - -#import re -#import smtplib -#import base64 -#from email import Encoders -#from email.mime.base import MIMEBase -#from email.mime.multipart import MIMEMultipart -#from email.mime.text import MIMEText -#from email.header import decode_header, Header -#from email.utils import formatdate -#import netsvc -#import datetime -#import tools -#import logging - -LOGGER = netsvc.Logger() -_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) - -class email_message_common(osv.osv_memory): - _name = 'email.message.common' - _columns = { - 'subject':fields.char('Subject', size=512), - 'model': fields.char('Object Name', size=128, select=1), - 'res_id': fields.integer('Resource ID', select=1), - 'date': fields.datetime('Date'), - 'user_id': fields.many2one('res.users', 'User Responsible'), - 'email_from': fields.char('From', size=128, help='Email From'), - 'email_to': fields.char('To', size=256, help='Email Recipients'), - 'email_cc': fields.char('Cc', size=256, help='Carbon Copy Email Recipients'), - 'email_bcc': fields.char('Bcc', size=256, help='Blind Carbon Copy Email Recipients'), - 'email_reply_to':fields.char('Reply-To', size=256), - 'headers': fields.text('x_headers'), - 'message_id': fields.char('Message Id', size=256, help='Message Id on Email.', select=1), - 'references': fields.text('References', help='References emails.'), - 'body_text': fields.text('Description'), - 'body_html': fields.text('HTML', help="Contains HTML version of email"), - 'original': fields.text('Original Email'), - } - _rec_name = 'subject' - - _sql_constraints = [] -email_message_common() - -class email_message(osv.osv): - ''' - Email Message - ''' - _inherit = 'email.message.common' - _name = 'email.message' - _description = 'Email Message' - _order = 'date desc' - - def _check_email_recipients(self, cr, uid, ids, context=None): - ''' - checks email_to, email_cc, email_bcc - ''' - for message in self.browse(cr, uid, ids, context=context): - if not (message.email_to or message.email_cc or message.email_bcc) and message.history: - return False - return True - - _constraints = [ - (_check_email_recipients, 'No recipients were specified. Please enter a recipient!', ['email_to', 'email_cc', 'email_bcc']), - ] - - def open_document(self, cr, uid, ids, context=None): - """ To Open Document - @param self: The object pointer. - @param cr: A database cursor - @param uid: ID of the user currently logged in - @param ids: the ID of messages - @param context: A standard dictionary - """ - action_data = False - if ids: - message_id = ids[0] - mailgate_data = self.browse(cr, uid, message_id, context=context) - model = mailgate_data.model - res_id = mailgate_data.res_id - - action_pool = self.pool.get('ir.actions.act_window') - action_ids = action_pool.search(cr, uid, [('res_model', '=', model)]) - if action_ids: - action_data = action_pool.read(cr, uid, action_ids[0], context=context) - action_data.update({ - 'domain' : "[('id','=',%d)]"%(res_id), - 'nodestroy': True, - 'context': {} - }) - return action_data - - def open_attachment(self, cr, uid, ids, context=None): - """ To Open attachments - @param self: The object pointer. - @param cr: A database cursor - @param uid: ID of the user currently logged in - @param ids: the ID of messages - @param context: A standard dictionary - """ - action_data = False - action_pool = self.pool.get('ir.actions.act_window') - message_pool = self.browse(cr, uid, ids, context=context)[0] - att_ids = [x.id for x in message_pool.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 truncate_data(self, cr, uid, data, context=None): - data_list = data and data.split('\n') or [] - if len(data_list) > 3: - res = '\n\t'.join(data_list[:3]) + '...' - else: - res = '\n\t'.join(data_list) - return res - - 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.history: - 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: - msg_txt += self.truncate_data(cr, uid, message.body, context=context) - 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', 'Partner'), - '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'), - 'history': fields.boolean('History', readonly=True), - 'state':fields.selection([ - ('outgoing', 'Outgoing'), - ('sent', 'Sent'), - ('received', 'Received'), - ('exception', 'Exception'), - ('cancel', 'Cancelled'), - ], 'State', readonly=True), - 'auto_delete': fields.boolean('Auto Delete', help="Permanently delete emails after sending"), - 'smtp_server_id':fields.many2one('ir.mail_server', 'SMTP Server'), - } - - _defaults = { - } - - def init(self, cr): - cr.execute("""SELECT indexname - FROM pg_indexes - WHERE indexname = 'email_message_res_id_model_idx'""") - if not cr.fetchone(): - cr.execute("""CREATE INDEX email_message_res_id_model_idx - ON email_message (model, res_id)""") - - def schedule_with_attach(self, cr, uid, email_from, email_to, subject, body, model=False, email_cc=None, email_bcc=None, reply_to=False, attach=None, - message_id=False, references=False, openobject_id=False, debug=False, subtype='plain', x_headers={}, priority='3', smtp_server_id=False, context=None, auto_delete=False): - if context is None: - context = {} - if attach is None: - attach = {} - attachment_obj = self.pool.get('ir.attachment') - if email_to and type(email_to) != list: - email_to = [email_to] - if email_cc and type(email_cc) != list: - email_cc = [email_cc] - if email_bcc and type(email_bcc) != list: - email_bcc = [email_bcc] - - msg_vals = { - 'subject': subject, - 'model': model or '', - 'date': time.strftime('%Y-%m-%d %H:%M:%S'), - 'user_id': uid, - 'body': body, - '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, - 'res_id': openobject_id, - 'message_id': message_id, - 'references': references or '', - 'sub_type': subtype or '', - 'headers': x_headers or False, - 'priority': priority, - 'debug': debug, - 'history': True, - 'smtp_server_id': smtp_server_id, - 'state': 'outgoing', - 'auto_delete': auto_delete - } - email_msg_id = self.create(cr, uid, msg_vals, context) - attachment_ids = [] - for fname, fcontent in attach.items(): - attachment_data = { - 'name': fname, - 'subject': (subject or '') + _(' (Email Attachment)'), - 'datas': fcontent, - 'datas_fname': fname, - 'body': subject or _('No Description'), - 'res_model':'email.message', - '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)) - self.write(cr, uid, email_msg_id, - { 'attachment_ids': [[6, 0, attachment_ids]] }, context) - return email_msg_id - - def process_retry(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): - if ids is None: - ids = [] - 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) - try: - res = self.send_email(cr, uid, ids, auto_commit=True, context=context) - except Exception, error: - logger = netsvc.Logger() - msg = _("Sending of Mail failed. Error: %s") % (error) - logger.notifyChannel("email", netsvc.LOG_ERROR, msg) - return False - return res - - def _decode_header(self, text): - """Returns unicode() string conversion of the the given encoded smtp header""" - if text: - text = decode_header(text.replace('\r', '')) - return ''.join([tools.ustr(x[0], x[1]) for x in text]) - - def to_email(self, text): - return re.findall(r'([^ ,<@]+@[^> ,]+)', text) - - def parse_message(self, message): - """Return Dictionary Object after parse EML Message String - @param message: email.message.Message object or string or unicode object - """ - 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 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'] = self._decode_header(msg_txt.get('Subject')) - - if 'Content-Type' in fields: - msg['content-type'] = msg_txt.get('Content-Type') - - if 'From' in fields: - msg['from'] = self._decode_header(msg_txt.get('From') or msg_txt.get_unixfrom()) - - if 'Delivered-To' in fields: - msg['to'] = self._decode_header(msg_txt.get('Delivered-To')) - - if 'CC' in fields: - msg['cc'] = self._decode_header(msg_txt.get('CC')) - - if 'Reply-To' in fields: - msg['reply'] = self._decode_header(msg_txt.get('Reply-To')) - - if 'Date' in fields: - msg['date'] = self._decode_header(msg_txt.get('Date')) - - 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') - - if 'X-Priority' in fields: - msg['priority'] = priorities[msg_txt.get('X-Priority')] - else: - msg['priority'] = priorities['3 (Normal)'] - - msg['headers'] = {} - 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['sub_type'] = 'html' - body = tools.html2plaintext(body) - else: - msg['sub_type'] = 'plain' - msg['body'] = 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['sub_type'] = 'alternative' - else: - msg['sub_type'] = '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 - 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'] = body - msg['attachments'] = attachments - return msg - - def send_email(self, cr, uid, ids, auto_commit=False, context=None): - """ - send email message - """ - if context is None: - context = {} - smtp_server_obj = self.pool.get('ir.mail_server') - attachment_pool = self.pool.get('ir.attachment') - self.write(cr, uid, ids, {'state':'outgoing'}, context) - for message in self.browse(cr, uid, ids, context): - try: - smtp_server = message.smtp_server_id - if not smtp_server: - smtp_ids = smtp_server_obj.search(cr, uid, []) - if smtp_ids: - smtp_server = smtp_server_obj.browse(cr, uid, smtp_ids, context)[0] - attachments = [] - for attach in message.attachment_ids: - attachments.append((attach.datas_fname, base64.b64decode(attach.datas))) - if message.state in ['outgoing', 'exception']: - msg = smtp_server_obj.pack_message(cr, uid, message.email_from, - message.email_to and message.email_to.split(',') or [], message.subject, message.body, - email_cc=message.email_cc and message.email_cc.split(',') or [], - email_bcc=message.email_bcc and message.email_bcc.split(',') or [], - reply_to=message.reply_to, - attach=attachments, message_id=message.message_id, references = message.references, - openobject_id=message.res_id, - subtype=message.sub_type, - x_headers=message.headers and eval(message.headers) or {}, - priority=message.priority) - res = smtp_server_obj.send_email(cr, uid, - msg, - mail_server_id = message.smtp_server_id.id or None, - smtp_server=smtp_server and smtp_server.smtp_host or None, - smtp_port=smtp_server and smtp_server.smtp_port or None, - smtp_user=smtp_server and smtp_server.smtp_user or None, - smtp_password=smtp_server and smtp_server.smtp_pass or None, - ssl=smtp_server and smtp_server.smtp_ssl or False, - tls=smtp_server and smtp_server.smtp_tls, - debug=message.debug) - if res: - self.write(cr, uid, [message.id], {'state':'sent', 'message_id': res}, context) - else: - self.write(cr, uid, [message.id], {'state':'exception'}, context) - else: - raise osv.except_osv(_('Error !'), _('No messages in outgoing or exception state!')) - - #if auto_delete=True then delete that sent messages as well as attachments - message_data = self.read(cr, uid, message.id, ['state', 'auto_delete', 'attachment_ids']) - if message_data['state'] == 'sent' and message_data['auto_delete'] == True: - self.unlink(cr, uid, [message.id], context=context) - if message_data['attachment_ids']: - attachment_pool.unlink(cr, uid, message_data['attachment_ids'], context=context) - - if auto_commit == True: - cr.commit() - - except Exception, error: - logger = netsvc.Logger() - logger.notifyChannel("email-template", netsvc.LOG_ERROR, _("Sending of Mail %s failed. Probable Reason:Could not login to server\nError: %s") % (message.id, error)) - self.write(cr, uid, [message.id], {'state':'exception'}, context) - return False - return True - - def do_cancel(self, cr, uid, ids, context=None): - ''' - Cancel the email to be send - ''' - self.write(cr, uid, ids, {'state':'cancel'}, context) - return True -# OLD Code. -# def send_all_mail(self, cr, uid, ids=None, context=None): -# if ids is None: -# ids = [] -# if context is None: -# context = {} -# filters = [('folder', '=', 'outbox'), ('state', '!=', 'sending')] -# if 'filters' in context.keys(): -# for each_filter in context['filters']: -# filters.append(each_filter) -# ids = self.search(cr, uid, filters, context=context) -# self.write(cr, uid, ids, {'state':'sending'}, context) -# self.send_this_mail(cr, uid, ids, context) -# return True -# -# def send_this_mail(self, cr, uid, ids=None, context=None): -# #previous method to send email (link with email account can be found at the revision 4172 and below -# result = True -# attachment_pool = self.pool.get('ir.attachment') -# for id in (ids or []): -# try: -# account_obj = self.pool.get('email.smtp_server') -# values = self.read(cr, uid, id, [], context) -# payload = {} -# if values['attachments_ids']: -# for attid in values['attachments_ids']: -# attachment = attachment_pool.browse(cr, uid, attid, context)#,['datas_fname','datas']) -# payload[attachment.datas_fname] = attachment.datas -# result = account_obj.send_email(cr, uid, -# [values['account_id'][0]], -# {'To':values.get('email_to') or u'', -# 'CC':values.get('email_cc') or u'', -# 'BCC':values.get('email_bcc') or u'', -# 'Reply-To':values.get('reply_to') or u''}, -# values['subject'] or u'', -# {'text':values.get('body_text') or u'', 'html':values.get('body_html') or u''}, -# payload=payload, -# message_id=values['message_id'], -# context=context) -# if result == True: -# account = account_obj.browse(cr, uid, values['account_id'][0], context=context) -# if account.auto_delete: -# self.write(cr, uid, id, {'folder': 'trash'}, context=context) -# self.unlink(cr, uid, [id], context=context) -# # Remove attachments for this mail -# attachment_pool.unlink(cr, uid, values['attachments_ids'], context=context) -# else: -# self.write(cr, uid, id, {'folder':'sent', 'state':'na', 'date_mail':time.strftime("%Y-%m-%d %H:%M:%S")}, context) -# else: -# error = result['error_msg'] -# -# except Exception, error: -# logger = netsvc.Logger() -# logger.notifyChannel("email-template", netsvc.LOG_ERROR, _("Sending of Mail %s failed. Probable Reason:Could not login to server\nError: %s") % (id, error)) -# self.write(cr, uid, id, {'state':'na'}, context) -# return result - -email_message() - -# vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: diff --git a/addons/mail/email_thread.py b/addons/mail/email_thread.py deleted file mode 100644 index 236a0b51b5c..00000000000 --- a/addons/mail/email_thread.py +++ /dev/null @@ -1,388 +0,0 @@ -# -*- coding: utf-8 -*- -############################################################################## -# -# OpenERP, Open Source Management Solution -# Copyright (C) 2004-2010 Tiny SPRL () -# -# 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 -# -############################################################################## - -from osv import osv, fields -import time -import tools -import binascii -import email - -from email.utils import parsedate - -from tools.translate import _ -import logging -import xmlrpclib - -_logger = logging.getLogger('mail') - -class email_thread(osv.osv): - ''' - Email Thread - ''' - _name = 'email.thread' - _description = 'Email Thread' - - _columns = { - 'message_ids': fields.one2many('email.message', 'res_id', 'Messages', readonly=True), - } - - def copy(self, cr, uid, id, default=None, context=None): - """ - Overrides orm copy method. - @param self: the object pointer - @param cr: the current row, from the database cursor, - @param uid: the current user’s ID for security checks, - @param id: Id of mailgate thread - @param default: Dictionary of default values for copy. - @param context: A standard dictionary for contextual values - """ - if default is None: - default = {} - - default.update({ - 'message_ids': [], - }) - return super(email_thread, self).copy(cr, uid, id, default, context=context) - - def message_new(self, cr, uid, msg, context=None): - """ - Called by process_email() to create a new record - corresponding to an incoming message for a new thread. - @param msg: Dictionary Object to contain email message data - """ - if context is None: - context = {} - model = context.get('thread_model', False) - if not model: - model = self._name - model_pool = self.pool.get(model) - fields = model_pool.fields_get(cr, uid, context=context) - data = model_pool.default_get(cr, uid, fields, context=context) - if 'name' in fields and not data.get('name', False): - data['name'] = msg.get('from','') - res_id = model_pool.create(cr, uid, data, context=context) - - attachments = msg.get('attachments', {}) - self.history(cr, uid, [res_id], _('receive'), history=True, - subject = msg.get('subject'), - email = msg.get('to'), - details = msg.get('body'), - email_from = msg.get('from'), - email_cc = msg.get('cc'), - message_id = msg.get('message-id'), - references = msg.get('references', False) or msg.get('in-reply-to', False), - attach = attachments, - email_date = msg.get('date'), - body_html= msg.get('body_html', False), - sub_type = msg.get('sub_type', False), - headers = msg.get('headers', False), - reply = msg.get('reply', False), - priority = msg.get('priority'), - context = context) - return res_id - - def message_update(self, cr, uid, ids, msg, vals={}, default_act=None, context=None): - """ - Called by process_email() to add a new incoming message for an existing thread - @param msg: Dictionary Object to contain email message data - """ - if context is None: - context = {} - model = context.get('thread_model', False) - if not model: - model = self._name - model_pool = self.pool.get(model) - attachments = msg.get('attachments', {}) - self.history(cr, uid, ids, _('receive'), history=True, - subject = msg.get('subject'), - email = msg.get('to'), - details = msg.get('body'), - email_from = msg.get('from'), - email_cc = msg.get('cc'), - message_id = msg.get('message-id'), - references = msg.get('references', False) or msg.get('in-reply-to', False), - attach = attachments, - email_date = msg.get('date'), - body_html= msg.get('body_html', False), - sub_type = msg.get('sub_type', False), - headers = msg.get('headers', False), - reply = msg.get('reply', False), - priority = msg.get('priority'), - context = context) - return True - - def thread_followers(self, cr, uid, ids, context=None): - """ Get a list of emails of the people following this thread - """ - res = {} - if isinstance(ids, (str, int, long)): - ids = [long(ids)] - for thread in self.browse(cr, uid, ids, context=context): - l = [] - for message in thread.message_ids: - l.append((message.user_id and message.user_id.email) or '') - l.append(message.email_from or '') - l.append(message.email_cc or '') - res[thread.id] = l - return res - - def history(self, cr, uid, threads, keyword, history=False, subject=None, \ - details=None, email=False, email_from=False, email_cc=None, \ - email_bcc=None, reply=None, email_date=None, message_id=False, \ - references=None, attach=None, body_html=None, sub_type=None, \ - headers=None, priority=None, context=None): - """ - @param self: The object pointer - @param cr: the current row, from the database cursor, - @param uid: the current user’s ID for security checks, - @param threads: a browse record list - @param keyword: Thread action keyword e.g.: If thread is closed "Close" keyword is used - @param history: Value True/False, If True it makes entry as a Emails Messages otherwise Log Messages - @param email: Email-To / Recipient address - @param email_from: Email From / Sender address if any - @param email_cc: Comma-Separated list of Carbon Copy Emails To addresse if any - @param email_bcc: Comma-Separated list of Blind Carbon Copy Emails To addresses if any - @param email_date: Email Date string if different from now, in server Timezone - @param details: Description, Details of thread history if any - @param attach: Attachment sent in email - @param context: A standard dictionary for contextual values""" - if context is None: - context = {} - if attach is None: - attach = {} - - if email_date: - edate = parsedate(email_date) - if edate is not None: - email_date = time.strftime('%Y-%m-%d %H:%M:%S', edate) - - # The script sends the ids of the threads and not the object list - - if all(isinstance(thread_id, (int, long)) for thread_id in threads): - model = context.get('thread_model', False) - if not model: - model = self._name - model_pool = self.pool.get(model) - threads = model_pool.browse(cr, uid, threads, context=context) - - att_obj = self.pool.get('ir.attachment') - obj = self.pool.get('email.message') - - for thread in threads: - attachments = [] - for fname, fcontent in attach.items(): - if isinstance(fcontent, unicode): - fcontent = fcontent.encode('utf-8') - data_attach = { - 'name': fname, - 'datas': binascii.b2a_base64(str(fcontent)), - 'datas_fname': fname, - 'description': _('Mail attachment'), - 'res_model': thread._name, - 'res_id': thread.id, - } - attachments.append(att_obj.create(cr, uid, data_attach)) - - partner_id = hasattr(thread, 'partner_id') and (thread.partner_id and thread.partner_id.id or False) or False - if not partner_id and thread._name == 'res.partner': - partner_id = thread.id - data = { - 'subject': keyword, - 'user_id': uid, - 'model' : thread._name, - 'partner_id': partner_id, - 'res_id': thread.id, - 'date': time.strftime('%Y-%m-%d %H:%M:%S'), - 'message_id': message_id, - 'body': details or (hasattr(thread, 'description') and thread.description or False), - 'attachment_ids': [(6, 0, attachments)] - } - - if history: - for param in (email, email_cc, email_bcc): - if isinstance(param, list): - param = ", ".join(param) - - data = { - 'subject': subject or _('History'), - 'history': True, - 'user_id': uid, - 'model' : thread._name, - 'res_id': thread.id, - 'date': email_date or time.strftime('%Y-%m-%d %H:%M:%S'), - 'body': details, - 'email_to': email, - 'email_from': email_from or \ - (hasattr(thread, 'user_id') and thread.user_id and thread.user_id.address_id and \ - thread.user_id.address_id.email), - 'email_cc': email_cc, - 'email_bcc': email_bcc, - 'partner_id': partner_id, - 'references': references, - 'message_id': message_id, - 'attachment_ids': [(6, 0, attachments)], - 'state' : 'received', - 'body_html': body_html, - 'sub_type': sub_type, - 'headers': headers, - 'reply_to': reply, - 'priority': priority - } - obj.create(cr, uid, data, context=context) - return True - - def email_forward(self, cr, uid, model, res_ids, msg, email_error=False, context=None): - """Sends an email to all people following the thread - @param res_id: Id of the record of OpenObject model created from the email message - @param msg: email.message.Message object to forward - @param email_error: Default Email address in case of any Problem - """ - model_pool = self.pool.get(model) - smtp_server_obj = self.pool.get('ir.mail_server') - email_message_obj = self.pool.get('email.message') - _decode_header = email_message_obj._decode_header - for res in model_pool.browse(cr, uid, res_ids, context=context): - if hasattr(model_pool, 'thread_followers'): - self.thread_followers = model_pool.thread_followers - thread_followers = self.thread_followers(cr, uid, [res.id])[res.id] - message_followers_emails = email_message_obj.to_email(','.join(filter(None, thread_followers))) - message_recipients = email_message_obj.to_email(','.join(filter(None, - [_decode_header(msg['from']), - _decode_header(msg['to']), - _decode_header(msg['cc'])]))) - message_forward = [i for i in message_followers_emails if (i and (i not in message_recipients))] - - if message_forward: - # TODO: we need an interface for this for all types of objects, not just leads - if hasattr(res, 'section_id'): - del msg['reply-to'] - msg['reply-to'] = res.section_id.reply_to - - smtp_from = email_message_obj.to_email(msg['from']) - msg['from'] = smtp_from - msg['to'] = message_forward - msg['message-id'] = tools.generate_tracking_message_id(res.id) - if not smtp_server_obj.send_email(cr, uid, msg) and email_error: - subj = msg['subject'] - del msg['subject'], msg['to'], msg['cc'], msg['bcc'] - msg['subject'] = _('[OpenERP-Forward-Failed] %s') % subj - msg['to'] = email_error - smtp_server_obj.send_email(cr, uid, msg) - return True - - def process_email(self, cr, uid, model, message, custom_values=None, attach=True, context=None): - """This function Processes email and create record for given OpenERP model - @param self: The object pointer - @param cr: the current row, from the database cursor, - @param uid: the current user’s ID for security checks, - @param model: OpenObject Model - @param message: Email details, passed as a string or an xmlrpclib.Binary - @param attach: Email attachments - @param context: A standard dictionary for contextual values""" - - # extract message bytes, we are forced to pass the message as binary because - # we don't know its encoding until we parse its headers and hence can't - # convert it to utf-8 for transport between the mailgate script and here. - if isinstance(message, xmlrpclib.Binary): - message = str(message.data) - - if context is None: - context = {} - - if custom_values is None or not isinstance(custom_values, dict): - custom_values = {} - - model_pool = self.pool.get(model) - if self._name != model: - context.update({'thread_model':model}) - - email_message_pool = self.pool.get('email.message') - res_id = False - - # Parse 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) - msg = email_message_pool.parse_message(msg_txt) - - # Create New Record into particular model - def create_record(msg): - if hasattr(model_pool, 'message_new'): - new_res_id = model_pool.message_new(cr, uid, msg, context=context) - if custom_values: - model_pool.write(cr, uid, [new_res_id], custom_values, context=context) - return new_res_id - - res_id = False - if msg.get('references') or msg.get('in-reply-to'): - references = msg.get('references') or msg.get('in-reply-to') - if '\r\n' in references: - references = references.split('\r\n') - else: - references = references.split(' ') - for ref in references: - ref = ref.strip() - res_id = tools.reference_re.search(ref) - if res_id: - res_id = res_id.group(1) - else: - res_id = tools.res_re.search(msg['subject']) - if res_id: - res_id = res_id.group(1) - if res_id: - res_id = int(res_id) - if model_pool.exists(cr, uid, res_id): - if hasattr(model_pool, 'message_update'): - model_pool.message_update(cr, uid, [res_id], msg, {}, context=context) - - if not res_id: - res_id = create_record(msg) - - #To forward the email to other followers - self.email_forward(cr, uid, model, [res_id], msg_txt) - return res_id - - def get_partner(self, cr, uid, from_email, context=None): - """This function returns partner Id based on email passed - @param self: The object pointer - @param cr: the current row, from the database cursor, - @param uid: the current user’s ID for security checks - @param from_email: email address based on that function will search for the correct - """ - address_pool = self.pool.get('res.partner.address') - email_message_pool = self.pool.get('email.message') - res = { - 'partner_address_id': False, - 'partner_id': False - } - from_email = email_message_pool.to_email(from_email)[0] - address_ids = address_pool.search(cr, uid, [('email', 'like', from_email)]) - if address_ids: - address = address_pool.browse(cr, uid, address_ids[0]) - res['partner_address_id'] = address_ids[0] - res['partner_id'] = address.partner_id.id - - return res - -email_thread() -# vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: diff --git a/addons/mail/i18n/mail_gateway.pot b/addons/mail/i18n/mail.pot similarity index 100% rename from addons/mail/i18n/mail_gateway.pot rename to addons/mail/i18n/mail.pot diff --git a/addons/mail/email_data.xml b/addons/mail/mail_data.xml similarity index 83% rename from addons/mail/email_data.xml rename to addons/mail/mail_data.xml index 8423222ea9b..f0328bfa779 100644 --- a/addons/mail/email_data.xml +++ b/addons/mail/mail_data.xml @@ -2,13 +2,13 @@ - Email scheduler + Outgoing emails robot 1 hours -1 - + diff --git a/addons/mail/mail_message.py b/addons/mail/mail_message.py new file mode 100644 index 00000000000..f4874f8dad8 --- /dev/null +++ b/addons/mail/mail_message.py @@ -0,0 +1,494 @@ +# -*- coding: utf-8 -*- +############################################################################## +# +# OpenERP, Open Source Management Solution +# Copyright (C) 2010-2011 OpenERP SA () +# +# 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 +# +############################################################################## + +import base64 +import email +import logging +import re +import time +from email.header import decode_header + +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'), + '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='Response address for the message'), + 'headers': fields.text('Message headers', help="Full message headers, e.g. SMTP session headers", readonly=1), + '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"), + 'original': fields.text('Original', help="Original version of the message, before being imported by the system", readonly=1), + } + + _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: + msg_txt += truncate_text(message.body) + 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', 'Exception'), + ('cancel', 'Cancelled'), + ], 'State', readonly=True), + 'auto_delete': fields.boolean('Auto Delete', help="Permanently delete this email after sending it"), + } + + 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 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 list attachments: list of (filename, filecontents) pairs, 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 == 'plain' 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.items(): + 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): + """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 + :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': 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 = {} + + # save original, we need to be able to read the original email sometimes + msg['original'] = message + + 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 '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: + msg['date'] = decode(msg_txt.get('Date')) + + 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'] = {} + 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) + else: + msg['subtype'] = 'plain' + msg['body'] = 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 + 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'] = body + msg['attachments'] = attachments + 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: diff --git a/addons/mail/mail_thread.py b/addons/mail/mail_thread.py new file mode 100644 index 00000000000..d024fa15fea --- /dev/null +++ b/addons/mail/mail_thread.py @@ -0,0 +1,421 @@ +# -*- coding: utf-8 -*- +############################################################################## +# +# OpenERP, Open Source Management Solution +# Copyright (C) 2009-Today OpenERP SA () +# +# 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 +# +############################################################################## + +import time +import tools +import binascii +import email +from email.utils import parsedate + +import logging +import xmlrpclib +from osv import osv, fields +from tools.translate import _ +from mail_message import decode, to_email + +_logger = logging.getLogger('mail') + +class mail_thread(osv.osv): + '''Mixin model, meant to be inherited by any model that needs to + act as a discussion topic on which messages can be attached. + + mail.thread adds a one2many of mail.messages, acting as thread + history, and a few methods that may be overridden to implement + specific behavior. + ''' + _name = 'mail.thread' + _description = 'Email Thread' + + _columns = { + 'message_ids': fields.one2many('mail.message', 'res_id', 'Messages', readonly=True), + } + + def thread_followers(self, cr, uid, ids, context=None): + """Returns a list of email addresses of the people following + this thread, including the sender of each mail, and the + people who were in CC of the messages, if any. + """ + res = {} + if isinstance(ids, (str, int, long)): + ids = [long(ids)] + for thread in self.browse(cr, uid, ids, context=context): + l = set() + for message in thread.message_ids: + l.add((message.user_id and message.user_id.email) or '') + l.add(message.email_from or '') + l.add(message.email_cc or '') + res[thread.id] = filter(None, l) + return res + + def copy(self, cr, uid, id, default=None, context=None): + """Overrides default copy method to empty the thread of + messages attached to this record, as the copied object + will have its own thread and does not have to share it. + """ + if default is None: + default = {} + default.update({ + 'message_ids': [], + }) + return super(mail_thread, self).copy(cr, uid, id, default, context=context) + + def message_new(self, cr, uid, msg_dict, custom_values=None, context=None): + """Called by ``process_email`` when a new message is received + without referencing an existing thread. The default + behavior is to create a new record of the corresponding + model, then call ``append_mail()`` to attach a new + mail.message to the newly created record. + Additional behavior may be implemented by overriding this method. + + :param dict msg_dict: a map containing the email details and + attachments. See ``process_email`` and + ``mail.message.parse()`` for details. + :param dict custom_values: optional dictionary of additional + field values to pass to create() + when creating the new thread record. + Be careful, these values may override + any other values coming from the message. + :param dict context: if a ``thread_model`` value is present + in the context, its value will be used + to determine the model of the record + to create (instead of the current model). + :rtype: int + :return: the id of the newly created thread object + """ + if context is None: + context = {} + model = context.get('thread_model') or self._name + model_pool = self.pool.get(model) + fields = model_pool.fields_get(cr, uid, context=context) + data = model_pool.default_get(cr, uid, fields, context=context) + if 'name' in fields and not data.get('name'): + data['name'] = msg_dict.get('from','') + if custom_values and isinstance(custom_values, dict): + data.update(custom_values) + res_id = model_pool.create(cr, uid, data, context=context) + self.append_mail(cr, uid, ids, msg_dict, context=context) + return res_id + + def message_update(self, cr, uid, ids, msg_dict, vals={}, default_act=None, context=None): + """Called by ``process_email`` when a new message is received + for an existing thread. The default behavior is to create a + new mail.message in the given thread by calling + ``append_mail()``. + Additional behavior may be implemented by overriding this + method. + + :param dict msg_dict: a map containing the email details and + attachments. See ``process_email`` and + ``mail.message.parse()`` for details. + :param dict context: if a ``thread_model`` value is present + in the context, its value will be used + to determine the model of the thread to + update (instead of the current model). + """ + return self.append_mail(cr, uid, ids, msg_dict, context=context) + + def append_mail(self, cr, uid, ids, msg_dict, context=None): + """Creates a new mail.message attached to the given threads, + with the contents of msg_dict, by calling ``history`` with + the mail details. All attachments in msg_dict will be + attached to the thread record as well as to the actual + message. + + :param dict msg_dict: a map containing the email details and + attachments. See ``process_email`` and + ``mail.message.parse()`` for details. + :param dict context: if a ``thread_model`` value is present + in the context, its value will be used + to determine the model of the thread to + update (instead of the current model). + """ + return self.history(cr, uid, ids, + subject = msg_dict.get('subject'), + body_text = msg_dict.get('body'), + email_to = msg_dict.get('to'), + email_from = msg_dict.get('from'), + email_cc = msg_dict.get('cc'), + email_bcc = msg_dict.get('bcc'), + reply_to = msg_dict.get('reply'), + email_date = msg_dict.get('date'), + message_id = msg_dict.get('message-id'), + references = msg_dict.get('references')\ + or msg_dict.get('in-reply-to'), + attachments = attachments, + body_html= msg_dict.get('body_html'), + subtype = msg_dict.get('subtype'), + headers = msg_dict.get('headers'), + original = msg_dict.get('original'), + context = context) + + def history(self, cr, uid, threads, subject, body_text=None, email_to=False, + email_from=False, email_cc=None, email_bcc=None, reply_to=None, + email_date=None, message_id=False, references=None, + attachments=None, body_html=None, subtype=None, headers=None, + original=None, context=None): + """Creates a new mail.message attached to the current mail.thread, + containing all the details passed as parameters. All attachments + will be attached to the thread record as well as to the actual + message. + + :param threads: list of thread ids, or list of browse_records representing + threads to which a new message should be attached + :param subject: Thread action keyword e.g.: If thread is closed "Close" keyword is used + :param email_to: Email-To / Recipient address + :param email_from: Email From / Sender address if any + :param email_cc: Comma-Separated list of Carbon Copy Emails To addresse if any + :param email_bcc: Comma-Separated list of Blind Carbon Copy Emails To addresses if any + :param reply_to: reply_to header + :param email_date: email date string if different from now, in server timezone + :param message_id: optional email identifier + :param references: optional email references + :param body_text: plaintext contents of the mail or log message + :param body_html: html contents of the mail or log message + :param subtype: optional type of message: 'plain' or 'html', corresponding to the main + body contents (body_text or body_html). + :param headers: mail headers to store + :param dict attachments: map of attachment filenames to binary contents, if any. + :param str original: optional full source of the RFC2822 email, for reference + :param dict context: if a ``thread_model`` value is present + in the context, its value will be used + to determine the model of the thread to + update (instead of the current model). + """ + if context is None: + context = {} + if attachments is None: + attachments = {} + + if email_date: + edate = parsedate(email_date) + if edate is not None: + email_date = time.strftime('%Y-%m-%d %H:%M:%S', edate) + + if all(isinstance(thread_id, (int, long)) for thread_id in threads): + model = context.get('thread_model') or self._name + model_pool = self.pool.get(model) + threads = model_pool.browse(cr, uid, threads, context=context) + + ir_attachment = self.pool.get('ir.attachment') + mail_message = self.pool.get('mail.message') + + for thread in threads: + to_attach = [] + for fname, fcontent in attachments.items(): + if isinstance(fcontent, unicode): + fcontent = fcontent.encode('utf-8') + data_attach = { + 'name': fname, + 'datas': binascii.b2a_base64(str(fcontent)), + 'datas_fname': fname, + 'description': _('Mail attachment'), + 'res_model': thread._name, + 'res_id': thread.id, + } + to_attach.append(ir_attachment.create(cr, uid, data_attach, context=context)) + + partner_id = hasattr(thread, 'partner_id') and (thread.partner_id and thread.partner_id.id or False) or False + if not partner_id and thread._name == 'res.partner': + partner_id = thread.id + data = { + 'subject': subject, + 'user_id': uid, + 'model' : thread._name, + 'partner_id': partner_id, + 'res_id': thread.id, + 'date': time.strftime('%Y-%m-%d %H:%M:%S'), + 'message_id': message_id, + 'body_text': body_text or (hasattr(thread, 'description') and thread.description or False), + 'attachment_ids': [(6, 0, to_attach)] + } + + if email_from: + for param in (email_to, email_cc, email_bcc): + if isinstance(param, list): + param = ", ".join(param) + data = { + 'subject': subject or _('History'), + 'user_id': uid, + 'model' : thread._name, + 'res_id': thread.id, + 'date': email_date or time.strftime('%Y-%m-%d %H:%M:%S'), + 'body_text': body_text, + 'email_to': email_to, + 'email_from': email_from or \ + (hasattr(thread, 'user_id') and thread.user_id and thread.user_id.address_id and \ + thread.user_id.address_id.email), + 'email_cc': email_cc, + 'email_bcc': email_bcc, + 'partner_id': partner_id, + 'references': references, + 'message_id': message_id, + 'attachment_ids': [(6, 0, to_attach)], + 'state' : 'received', + 'body_html': body_html, + 'subtype': subtype, + 'headers': headers, + 'reply_to': reply_to, + 'original': original, + } + mail_message.create(cr, uid, data, context=context) + return True + + def process_email(self, cr, uid, model, message, custom_values=None, context=None): + """Process an incoming RFC2822 email message related to the + given thread model, relying on ``mail.message.parse()`` + for the parsing operation, and then calling ``message_new`` + (if the thread record did not exist) or ``message_update`` + (if it did), then calling ``email_forward()`` to automatically + notify other people that should receive this email. + + :param string model: the thread model for which a new message + must be processed + :param message: source of the RFC2822 mail + :type message: string or xmlrpclib.Binary + :type dict custom_value: optional dictionary of field values + to pass to ``message_new`` if a new + record needs to be created. Ignored + if the thread record already exists. + """ + # extract message bytes - we are forced to pass the message as binary because + # we don't know its encoding until we parse its headers and hence can't + # convert it to utf-8 for transport between the mailgate script and here. + if isinstance(message, xmlrpclib.Binary): + message = str(message.data) + + model_pool = self.pool.get(model) + if self._name != model: + if context is None: context = {} + context.update({'thread_model':model}) + + mail_message = self.pool.get('mail.message') + res_id = False + + # Parse 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) + msg = mail_message.parse_message(msg_txt) + + # Create New Record into particular model + def create_record(msg): + if hasattr(model_pool, 'message_new'): + return model_pool.message_new(cr, uid, msg, + custom_values, + context=context) + res_id = False + if msg.get('references') or msg.get('in-reply-to'): + references = msg.get('references') or msg.get('in-reply-to') + if '\r\n' in references: + references = references.split('\r\n') + else: + references = references.split(' ') + for ref in references: + ref = ref.strip() + res_id = tools.reference_re.search(ref) + if res_id: + res_id = res_id.group(1) + else: + res_id = tools.res_re.search(msg['subject']) + if res_id: + res_id = res_id.group(1) + if res_id: + res_id = int(res_id) + if model_pool.exists(cr, uid, res_id): + if hasattr(model_pool, 'message_update'): + model_pool.message_update(cr, uid, [res_id], msg, {}, context=context) + if not res_id: + res_id = create_record(msg) + #To forward the email to other followers + self.email_forward(cr, uid, model, [res_id], msg_txt, context=context) + return res_id + + def email_forward(self, cr, uid, model, thread_ids, msg, email_error=False, context=None): + """Sends an email to all people following the given threads. + The emails are forwarded immediately, not queued for sending, + and not archived. + + :param str model: thread model + :param list thread_ids: ids of the thread records + :param msg: email.message.Message object to forward + :param email_error: optional email address to notify in case + of any delivery error during the forward. + :return: True + """ + model_pool = self.pool.get(model) + smtp_server_obj = self.pool.get('ir.mail_server') + mail_message = self.pool.get('mail.message') + for res in model_pool.browse(cr, uid, thread_ids, context=context): + if hasattr(model_pool, 'thread_followers'): + self.thread_followers = model_pool.thread_followers + thread_followers = self.thread_followers(cr, uid, [res.id])[res.id] + message_followers_emails = mail_message.to_email(','.join(filter(None, thread_followers))) + message_recipients = mail_message.to_email(','.join(filter(None, + [decode(msg['from']), + decode(msg['to']), + decode(msg['cc'])]))) + message_forward = [i for i in message_followers_emails if (i and (i not in message_recipients))] + if message_forward: + # TODO: we need an interface for this for all types of objects, not just leads + if hasattr(res, 'section_id'): + del msg['reply-to'] + msg['reply-to'] = res.section_id.reply_to + + smtp_from = mail_message.to_email(msg['from']) + msg['from'] = smtp_from + msg['to'] = message_forward + msg['message-id'] = tools.generate_tracking_message_id(res.id) + if not smtp_server_obj.send_email(cr, uid, msg) and email_error: + subj = msg['subject'] + del msg['subject'], msg['to'], msg['cc'], msg['bcc'] + msg['subject'] = _('[OpenERP-Forward-Failed] %s') % subj + msg['to'] = email_error + smtp_server_obj.send_email(cr, uid, msg) + return True + + def get_partner(self, cr, uid, email, context=None): + """Attempts to return the id of a partner address matching + the given ``email``, and the corresponding partner. + + :param email: email address for which a partner + should be searched for. + :rtype: dict + :return: a map of the following form:: + + { 'partner_address_id': id or False, + 'partner_id': pid or False } + """ + address_pool = self.pool.get('res.partner.address') + res = { + 'partner_address_id': False, + 'partner_id': False + } + email = to_email(email)[0] + address_ids = address_pool.search(cr, uid, [('email', '=', email)]) + if address_ids: + address = address_pool.browse(cr, uid, address_ids[0]) + res['partner_address_id'] = address_ids[0] + res['partner_id'] = address.partner_id.id + return res + +# vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: diff --git a/addons/mail/email_thread_view.xml b/addons/mail/mail_thread_view.xml similarity index 67% rename from addons/mail/email_thread_view.xml rename to addons/mail/mail_thread_view.xml index 5f8b25d29a0..76ca6203952 100644 --- a/addons/mail/email_thread_view.xml +++ b/addons/mail/mail_thread_view.xml @@ -3,22 +3,21 @@ - email.thread.form - email.thread + mail.thread.form + mail.thread form -
- + + - + - + - @@ -41,38 +40,24 @@ - email.thread.tree - email.thread + mail.thread.tree + mail.thread tree - + - + - Mailgateway Threads - email.thread + Email Threads + mail.thread tree,form form - - - - tree - - - - - - form - - - - tree diff --git a/addons/mail/email_view.xml b/addons/mail/mail_view.xml similarity index 55% rename from addons/mail/email_view.xml rename to addons/mail/mail_view.xml index 3681202da07..609137a2316 100644 --- a/addons/mail/email_view.xml +++ b/addons/mail/mail_view.xml @@ -2,77 +2,70 @@ - + - email.message.form - email.message - form - + mail.message.form + mail.message + form + - - + + - - + - + - - - -