From ba1faa1a761ff585c406ffed8e70b1f86555cf43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Mon, 28 Mar 2016 22:40:37 +0200 Subject: [PATCH] Add sendmail datamanager issue5431 review17981002 --- CHANGELOG | 1 + doc/ref/index.rst | 1 + doc/ref/sendmail.rst | 44 ++++++++++++++ trytond/ir/cron.py | 10 +--- trytond/sendmail.py | 104 +++++++++++++++++++++++++++++++++ trytond/tests/test_sendmail.py | 101 ++++++++++++++++++++++++++++++++ trytond/tools/misc.py | 24 ++------ 7 files changed, 259 insertions(+), 26 deletions(-) create mode 100644 doc/ref/sendmail.rst create mode 100644 trytond/sendmail.py create mode 100644 trytond/tests/test_sendmail.py diff --git a/CHANGELOG b/CHANGELOG index 2563c9e598..22d35272f5 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,4 @@ +* Add sendmail module to send transactional email * Support Two-Phase Commit in Transaction * Allow Report to generate text plain, XML, HTML and XHTML * Add workflow graph on ir.model diff --git a/doc/ref/index.rst b/doc/ref/index.rst index 30ff4e0e2e..4203034818 100644 --- a/doc/ref/index.rst +++ b/doc/ref/index.rst @@ -14,3 +14,4 @@ API Reference tools/singleton pool rpc + sendmail diff --git a/doc/ref/sendmail.rst b/doc/ref/sendmail.rst new file mode 100644 index 0000000000..4b77b31f2b --- /dev/null +++ b/doc/ref/sendmail.rst @@ -0,0 +1,44 @@ +.. _ref-sendmail: +.. module:: trytond.sendmail + +======== +Sendmail +======== + +.. method:: sendmail_transactional(from_addr, to_addrs, msg[, transaction[, datamanager]]) + +Send email message only if the current transaction is successfully committed. +The required arguments are an `RFC 822`_ from-address string, a list of `RFC +822`_ to-address strings (a bare string will be treated as a list with 1 +address), and an email message. +The caller may pass a :class:`Transaction` instance to join otherwise the +current one will be joined. A specific data manager can be specified otherwise +the default :class:`SMTPDataManager` will be used for sending email. + +.. warning:: + + An SMTP failure will be only logged without raising any exception. + +.. method:: sendmail(from_addr, to_addrs, msg[, server]) + +Send email message like :meth:`sendmail_transactional` but directly without +caring about the transaction. +The caller may pass a server instance from `smtplib`_. + +.. method:: get_smtp_server([uri]) + +Return a SMTP instance from `smtplib`_ using the `uri` or the one defined in +the `email` section of the :ref:`configuration `. + + +.. class:: SMTPDataManager([uri]) + +A :class:`SMTPDataManager` implements a data manager which send queued email at +commit. An option optional `uri` can be passed to configure the SMTP connection. + +.. method:: SMTPDataManager.put(from_addr, to_addrs, msg) + + Queue the email message to send. + +.. _`RFC 822`: https://tools.ietf.org/html/rfc822.html +.. _`smtplib`: https://docs.python.org/2/library/smtplib.html diff --git a/trytond/ir/cron.py b/trytond/ir/cron.py index 59cd6f3dc0..9e384fcdaa 100644 --- a/trytond/ir/cron.py +++ b/trytond/ir/cron.py @@ -10,12 +10,12 @@ from email.header import Header from ast import literal_eval from ..model import ModelView, ModelSQL, fields, dualmethod -from ..tools import get_smtp_server from ..transaction import Transaction from ..pool import Pool from .. import backend from ..config import config from ..cache import Cache +from ..sendmail import sendmail __all__ = [ 'Cron', @@ -160,13 +160,7 @@ class Cron(ModelSQL, ModelView): if not to_addr: logger.error(msg.as_string()) else: - try: - server = get_smtp_server() - server.sendmail(from_addr, to_addr, msg.as_string()) - server.quit() - except Exception: - logger.error('Unable to deliver email:\n %s', - msg.as_string(), exc_info=True) + sendmail(from_addr, to_addr, msg) @dualmethod @ModelView.button diff --git a/trytond/sendmail.py b/trytond/sendmail.py new file mode 100644 index 0000000000..cdc5d6ef28 --- /dev/null +++ b/trytond/sendmail.py @@ -0,0 +1,104 @@ +# This file is part of Tryton. The COPYRIGHT file at the top level of +# this repository contains the full copyright notices and license terms. +import logging +import smtplib +import urllib +from email.message import Message + +from .config import config, parse_uri +from .transaction import Transaction + +__all__ = ['sendmail_transactional', 'sendmail', 'SMTPDataManager'] +logger = logging.getLogger(__name__) + + +def sendmail_transactional( + from_addr, to_addrs, msg, transaction=None, datamanager=None): + if transaction is None: + transaction = Transaction() + assert isinstance(transaction, Transaction), transaction + if datamanager is None: + datamanager = SMTPDataManager() + datamanager = transaction.join(datamanager) + datamanager.put(from_addr, to_addrs, msg) + + +def sendmail(from_addr, to_addrs, msg, server=None): + if server is None: + server = get_smtp_server() + quit = True + else: + quit = False + try: + senderrs = server.sendmail(from_addr, to_addrs, msg.as_string()) + except smtplib.SMTPException: + logger.error('fail to send email', exc_info=True) + if senderrs: + logger.warn('fail to send email to %s', senderrs) + if quit: + server.quit() + + +def get_smtp_server(uri=None): + if uri is None: + uri = config.get('email', 'uri') + uri = parse_uri(uri) + if uri.scheme.startswith('smtps'): + server = smtplib.SMTP_SSL(uri.hostname, uri.port) + else: + server = smtplib.SMTP(uri.hostname, uri.port) + + if 'tls' in uri.scheme: + server.starttls() + + if uri.username and uri.password: + server.login( + urllib.unquote_plus(uri.username), + urllib.unquote_plus(uri.password)) + return server + + +class SMTPDataManager(object): + + def __init__(self, uri=None): + self.uri = uri + self.queue = [] + self._server = None + + def put(self, from_addr, to_addrs, msg): + assert isinstance(msg, Message), msg + self.queue.append((from_addr, to_addrs, msg)) + + def __eq__(self, other): + if not isinstance(other, SMTPDataManager): + return NotImplemented + return self.uri == other.uri + + def abort(self, trans): + self._finish() + + def tpc_begin(self, trans): + pass + + def commit(self, trans): + pass + + def tpc_vote(self, trans): + if self._server is None: + self._server = get_smtp_server(self.uri) + + def tpc_finish(self, trans): + if self._server is not None: + for from_addr, to_addrs, msg in self.queue: + sendmail(from_addr, to_addrs, msg, server=self._server) + self._server.quit() + self._finish() + + def tpc_abort(self, trans): + if self._server: + self._server.close() + self._finish() + + def _finish(self): + self._server = None + self.queue = [] diff --git a/trytond/tests/test_sendmail.py b/trytond/tests/test_sendmail.py new file mode 100644 index 0000000000..5eb42c9c58 --- /dev/null +++ b/trytond/tests/test_sendmail.py @@ -0,0 +1,101 @@ +# This file is part of Tryton. The COPYRIGHT file at the top level of +# this repository contains the full copyright notices and license terms. +import unittest +import smtplib +from email.message import Message +from mock import Mock, patch, call + +from trytond.sendmail import ( + sendmail_transactional, sendmail, SMTPDataManager, get_smtp_server) +from trytond.transaction import Transaction +from .test_tryton import with_transaction + + +class SendmailTestCase(unittest.TestCase): + 'Test sendmail' + + @with_transaction() + def test_sendmail_transactional(self): + 'Test sendmail_transactional' + message = Mock() + datamanager = Mock() + sendmail_transactional( + 'tryton@example.com', 'foo@example.com', message, + datamanager=datamanager) + + datamanager.put.assert_called() + + def test_sendmail(self): + 'Test sendmail' + message = Mock() + server = Mock() + sendmail( + 'tryton@example.com', 'foo@example.com', message, server=server) + server.sendmail.assert_called( + 'tryton@example.com', 'foo@example.com', message) + server.quit.assert_not_called() + + def test_get_smtp_server(self): + 'Test get_smtp_server' + with patch.object(smtplib, 'SMTP') as SMTP: + SMTP.return_value = server = Mock() + self.assertEqual(get_smtp_server('smtp://localhost:25'), server) + SMTP.assert_called('localhost', 25) + + with patch.object(smtplib, 'SMTP') as SMTP: + SMTP.return_value = server = Mock() + self.assertEqual( + get_smtp_server('smtp://foo:bar@localhost:25'), server) + SMTP.assert_called('localhost', 25) + server.login.assert_called('foo', 'bar') + + with patch.object(smtplib, 'SMTP_SSL') as SMTP: + SMTP.return_value = server = Mock() + self.assertEqual( + get_smtp_server('smtps://localhost:25'), server) + SMTP.assert_called('localhost', 25) + + with patch.object(smtplib, 'SMTP') as SMTP: + SMTP.return_value = server = Mock() + self.assertEqual( + get_smtp_server('smtp+tls://localhost:25'), server) + SMTP.assert_called('localhost', 25) + server.starttls.assert_called() + + @patch('trytond.sendmail.get_smtp_server') + @with_transaction() + def test_SMTPDataManager(self, get_smtp_server): + 'Test SMTPDataManager' + transaction = Transaction() + get_smtp_server.return_value = server = Mock() + + datamanager = transaction.join(SMTPDataManager()) + + # multiple join must return the same + self.assertEqual(transaction.join(SMTPDataManager()), datamanager) + + msg1 = Mock(Message) + msg2 = Mock(Message) + datamanager.put('foo@example.com', 'bar@example.com', msg1) + datamanager.put('bar@example.com', 'foo@example.com', msg2) + + transaction.commit() + + server.sendmail.assert_has_calls([ + call('foo@example.com', 'bar@example.com', msg1.as_string()), + call('bar@example.com', 'foo@example.com', msg2.as_string()), + ]) + server.quit.assert_called_once_with() + self.assertFalse(datamanager.queue) + + server.reset_mock() + + datamanager.put('foo@example.com', 'bar@example.com', Mock(Message)) + transaction.rollback() + + server.sendmail.assert_not_called() + self.assertFalse(datamanager.queue) + + +def suite(): + return unittest.TestLoader().loadTestsFromTestCase(SendmailTestCase) diff --git a/trytond/tools/misc.py b/trytond/tools/misc.py index acf5c20b31..b1ada39f30 100644 --- a/trytond/tools/misc.py +++ b/trytond/tools/misc.py @@ -7,18 +7,16 @@ Miscelleanous tools used by tryton import os import sys import subprocess -import smtplib from array import array from itertools import islice import types -import urllib import io +import warnings from sql import Literal from sql.operators import Or from trytond.const import OPERATORS -from trytond.config import config, parse_uri def find_in_path(name): @@ -106,21 +104,11 @@ def get_smtp_server(): :return: A SMTP instance. The quit() method must be call when all the calls to sendmail() have been made. """ - uri = parse_uri(config.get('email', 'uri')) - if uri.scheme.startswith('smtps'): - smtp_server = smtplib.SMTP_SSL(uri.hostname, uri.port) - else: - smtp_server = smtplib.SMTP(uri.hostname, uri.port) - - if 'tls' in uri.scheme: - smtp_server.starttls() - - if uri.username and uri.password: - smtp_server.login( - urllib.unquote_plus(uri.username), - urllib.unquote_plus(uri.password)) - - return smtp_server + from ..sendmail import _get_smtp_server + warnings.warn( + 'get_smtp_server is deprecated use trytond.sendmail', + DeprecationWarning) + return _get_smtp_server() def memoize(maxsize):