summaryrefslogtreecommitdiff
path: root/src/mailman/mta
diff options
context:
space:
mode:
Diffstat (limited to 'src/mailman/mta')
-rw-r--r--src/mailman/mta/base.py31
-rw-r--r--src/mailman/mta/connection.py103
-rw-r--r--src/mailman/mta/docs/connection.rst29
-rw-r--r--src/mailman/mta/tests/test_base.py71
-rw-r--r--src/mailman/mta/tests/test_connection.py89
5 files changed, 282 insertions, 41 deletions
diff --git a/src/mailman/mta/base.py b/src/mailman/mta/base.py
index 4cc7742e8..b182da027 100644
--- a/src/mailman/mta/base.py
+++ b/src/mailman/mta/base.py
@@ -22,9 +22,12 @@ import socket
import logging
import smtplib
+from lazr.config import as_boolean
from mailman.config import config
+from mailman.interfaces.configuration import InvalidConfigurationError
from mailman.interfaces.mta import IMailTransportAgentDelivery
-from mailman.mta.connection import Connection
+from mailman.mta.connection import (
+ SMTPConnection, SMTPSConnection, STARTTLSConnection)
from public import public
from zope.interface import implementer
@@ -39,12 +42,30 @@ class BaseDelivery:
def __init__(self):
"""Create a basic deliverer."""
+ host = config.mta.smtp_host
+ port = int(config.mta.smtp_port)
username = (config.mta.smtp_user if config.mta.smtp_user else None)
password = (config.mta.smtp_pass if config.mta.smtp_pass else None)
- self._connection = Connection(
- config.mta.smtp_host, int(config.mta.smtp_port),
- int(config.mta.max_sessions_per_connection),
- username, password)
+ protocol = config.mta.smtp_protocol
+ verify_cert = as_boolean(config.mta.smtp_verify_cert)
+ verify_hostname = as_boolean(config.mta.smtp_verify_hostname)
+ max_session_count = int(config.mta.max_sessions_per_connection)
+
+ proto = protocol.lower()
+ if proto == 'smtp':
+ self._connection = SMTPConnection(host, port, max_session_count,
+ username, password)
+ elif proto == 'smtps':
+ self._connection = SMTPSConnection(host, port, max_session_count,
+ verify_cert, verify_hostname,
+ username, password)
+ elif proto == 'starttls':
+ self._connection = STARTTLSConnection(host, port,
+ max_session_count,
+ verify_cert, verify_hostname,
+ username, password)
+ else:
+ raise InvalidConfigurationError('mta.smtp_protocol', protocol)
def _deliver_to_recipients(self, mlist, msg, msgdata, recipients):
"""Low-level delivery to a set of recipients.
diff --git a/src/mailman/mta/connection.py b/src/mailman/mta/connection.py
index 797b979b2..d6824bccf 100644
--- a/src/mailman/mta/connection.py
+++ b/src/mailman/mta/connection.py
@@ -17,21 +17,23 @@
"""MTA connections."""
+import ssl
import logging
import smtplib
from contextlib import suppress
from lazr.config import as_boolean
from mailman.config import config
+from mailman.interfaces.smtp import ISMTPConnection
from public import public
-
+from zope.interface import implementer
log = logging.getLogger('mailman.smtp')
-@public
+@implementer(ISMTPConnection)
class Connection:
- """Manage a connection to the SMTP server."""
+
def __init__(self, host, port, sessions_per_connection,
smtp_user=None, smtp_pass=None):
"""Create a connection manager.
@@ -60,24 +62,20 @@ class Connection:
self._session_count = None
self._connection = None
- def _connect(self):
- """Open a new connection."""
- self._connection = smtplib.SMTP()
- log.debug('Connecting to %s:%s', self._host, self._port)
- self._connection.connect(self._host, self._port)
+ def _login(self):
+ """Send login if both username and password are specified."""
if self._username is not None and self._password is not None:
log.debug('Logging in')
self._connection.login(self._username, self._password)
- self._session_count = self._sessions_per_connection
def sendmail(self, envsender, recipients, msgtext):
- """Mimic `smtplib.SMTP.sendmail`."""
if as_boolean(config.devmode.enabled):
# Force the recipients to the specified address, but still deliver
# to the same number of recipients.
recipients = [config.devmode.recipient] * len(recipients)
if self._connection is None:
self._connect()
+ self._login()
try:
log.debug('envsender: %s, recipients: %s, size(msgtext): %s',
envsender, recipients, len(msgtext))
@@ -97,9 +95,92 @@ class Connection:
return results
def quit(self):
- """Mimic `smtplib.SMTP.quit`."""
if self._connection is None:
return
with suppress(smtplib.SMTPException):
self._connection.quit()
self._connection = None
+
+
+@public
+class SMTPConnection(Connection):
+ """Manage a clear connection to the SMTP server."""
+
+ def _connect(self):
+ """Open a new connection."""
+ log.debug('Connecting to %s:%s', self._host, self._port)
+ self._connection = smtplib.SMTP(self._host, self._port)
+ self._session_count = self._sessions_per_connection
+
+
+class _SSLConnection(Connection):
+ """Manage a SMTP connection with a SSL context(either SMTPS or STARTTLS)"""
+
+ def __init__(self, host, port, sessions_per_connection,
+ verify_cert=False, verify_hostname=False,
+ smtp_user=None, smtp_pass=None):
+ """
+ Create a connection manager with and SSL context.
+
+ :param host: The host name of the SMTP server to connect to.
+ :type host: string
+ :param port: The port number of the SMTP server to connect to.
+ :type port: integer
+ :param sessions_per_connection: The number of SMTP sessions per
+ connection to the SMTP server. After this number of sessions
+ has been reached, the connection is closed and a new one is
+ opened. Set to zero for an unlimited number of sessions per
+ connection (i.e. your MTA has no limit).
+ :type sessions_per_connection: integer
+ :param verify_cert: Whether to require a server cert and verify it.
+ Verification in this context means that the server needs to supply
+ a valid certificate signed by a CA from a set of the system's
+ default CA certs.
+ :type verify_cert: bool
+ :param verify_hostname: Whether to check that the server certificate
+ specifies the hostname as passed to this constructor.
+ RFC 2818 and RFC 6125 rules are followed.
+ :type verify_hostname: bool
+ :param smtp_user: Optional SMTP authentication user name. If given,
+ `smtp_pass` must also be given.
+ :type smtp_user: str
+ :param smtp_pass: Optional SMTP authentication password. If given,
+ `smtp_user` must also be given.
+ """
+ super().__init__(host, port, sessions_per_connection,
+ smtp_user, smtp_pass)
+ self._tls_context = self._get_context(verify_cert, verify_hostname)
+
+ def _get_context(self, verify_cert, verify_hostname):
+ """Create and return a new SSLContext."""
+ ssl_context = ssl.create_default_context()
+ ssl_context.check_hostname = verify_hostname
+ if verify_cert:
+ ssl_context.verify_mode = ssl.CERT_REQUIRED
+ else:
+ ssl_context.verify_mode = ssl.CERT_NONE
+ return ssl_context
+
+
+@public
+class SMTPSConnection(_SSLConnection):
+ """Manage a SMTPS connection."""
+
+ def _connect(self):
+ log.debug('Connecting to %s:%s', self._host, self._port)
+ self._connection = smtplib.SMTP_SSL(self._host, self._port,
+ context=self._tls_context)
+ self._session_count = self._sessions_per_connection
+
+
+@public
+class STARTTLSConnection(_SSLConnection, SMTPConnection):
+ """Manage a plain connection with STARTTLS."""
+
+ def _connect(self):
+ super()._connect()
+ log.debug('Starttls')
+ try:
+ self._connection.starttls(context=self._tls_context)
+ except smtplib.SMTPNotSupportedError as notls:
+ log.error('Starttls failed: ' + str(notls))
diff --git a/src/mailman/mta/docs/connection.rst b/src/mailman/mta/docs/connection.rst
index 8b11605cb..687ec06fb 100644
--- a/src/mailman/mta/docs/connection.rst
+++ b/src/mailman/mta/docs/connection.rst
@@ -3,16 +3,17 @@ MTA connections
===============
Outgoing connections to the outgoing mail transport agent (MTA) are mitigated
-through a ``Connection`` class, which can transparently manage multiple
+through a ``SMTPConnection`` class, which can transparently manage multiple
sessions in a single connection.
- >>> from mailman.mta.connection import Connection
+ >>> from mailman.mta.connection import SMTPConnection, SMTPSConnection
+ >>> from lazr.config import as_boolean
-The number of sessions per connections is specified when the ``Connection``
+The number of sessions per connections is specified when the ``SMTPConnection``
object is created, as is the host and port number of the SMTP server. Zero
means there's an unlimited number of sessions per connection.
- >>> connection = Connection(
+ >>> connection = SMTPConnection(
... config.mta.smtp_host, int(config.mta.smtp_port), 0)
At the start, there have been no connections to the server.
@@ -59,7 +60,7 @@ will authenticate with the mail server after each new connection.
... smtp_pass: testpass
... """)
- >>> connection = Connection(
+ >>> connection = SMTPConnection(
... config.mta.smtp_host, int(config.mta.smtp_port), 0,
... config.mta.smtp_user, config.mta.smtp_pass)
>>> connection.sendmail('anne@example.com', ['bart@example.com'], """\
@@ -75,6 +76,17 @@ will authenticate with the mail server after each new connection.
>>> reset()
>>> config.pop('auth')
+SMTPS and STARTTLS support works through ``SMTPSConnection``
+and ``STARTTLSConnection`` classes. These provide the same interface
+as ``SMTPConnection``.
+::
+
+ >>> connection = SMTPSConnection(
+ ... config.mta.smtp_host, int(config.mta.smtp_port), 0,
+ ... as_boolean(config.mta.smtp_verify_cert),
+ ... as_boolean(config.mta.smtp_verify_hostname),
+ ... config.mta.smtp_user, config.mta.smtp_pass)
+
Sessions per connection
=======================
@@ -86,13 +98,13 @@ created.
The connection count starts at zero.
::
- >>> connection = Connection(
+ >>> connection = SMTPConnection(
... config.mta.smtp_host, int(config.mta.smtp_port), 2)
>>> smtpd.get_connection_count()
0
-We send two messages through the ``Connection`` object. Only one connection
+We send two messages through the ``SMTPConnection`` object. Only one connection
is opened.
::
@@ -168,7 +180,7 @@ No maximum
A value of zero means that there is an unlimited number of sessions per
connection.
- >>> connection = Connection(
+ >>> connection = SMTPConnection(
... config.mta.smtp_host, int(config.mta.smtp_port), 0)
>>> reset()
@@ -230,3 +242,4 @@ about accidental deliveries to unintended recipients.
<BLANKLINE>
>>> config.pop('devmode')
+
diff --git a/src/mailman/mta/tests/test_base.py b/src/mailman/mta/tests/test_base.py
new file mode 100644
index 000000000..52fa63430
--- /dev/null
+++ b/src/mailman/mta/tests/test_base.py
@@ -0,0 +1,71 @@
+# Copyright (C) 2014-2017 by the Free Software Foundation, Inc.
+#
+# This file is part of GNU Mailman.
+#
+# GNU Mailman is free software: you can redistribute it and/or modify it under
+# the terms of the GNU General Public License as published by the Free
+# Software Foundation, either version 3 of the License, or (at your option)
+# any later version.
+#
+# GNU Mailman 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 General Public License for
+# more details.
+#
+# You should have received a copy of the GNU General Public License along with
+# GNU Mailman. If not, see <http://www.gnu.org/licenses/>.
+
+"""Test BaseDelivery."""
+
+import unittest
+
+from mailman.config import config
+from mailman.interfaces.configuration import InvalidConfigurationError
+from mailman.mta.base import BaseDelivery
+from mailman.mta.connection import SMTPSConnection, STARTTLSConnection
+from mailman.testing.layers import SMTPLayer, SMTPSLayer, STARTTLSLayer
+
+
+class BaseDeliveryTester(BaseDelivery):
+ @property
+ def connection(self):
+ return self._connection
+
+
+class TestSMTPSDelivery(unittest.TestCase):
+ layer = SMTPSLayer
+
+ def test_smtps_config(self):
+ config.push('smtps_config', """\
+[mta]
+smtp_protocol: smtps
+""")
+ delivery = BaseDeliveryTester()
+ self.assertIsInstance(delivery.connection, SMTPSConnection)
+ config.pop('smtps_config')
+
+
+class TestSTARTTLSDelivery(unittest.TestCase):
+ layer = STARTTLSLayer
+
+ def test_starttls_config(self):
+ config.push('starttls_config', """\
+[mta]
+smtp_protocol: starttls
+""")
+ delivery = BaseDeliveryTester()
+ self.assertIsInstance(delivery.connection, STARTTLSConnection)
+ config.pop('starttls_config')
+
+
+class TestInvalidDelivery(unittest.TestCase):
+ layer = SMTPLayer
+
+ def test_invalid_config(self):
+ config.push('invalid_config', """\
+[mta]
+smtp_protocol: invalid
+""")
+ with self.assertRaises(InvalidConfigurationError):
+ BaseDeliveryTester()
+ config.pop('invalid_config')
diff --git a/src/mailman/mta/tests/test_connection.py b/src/mailman/mta/tests/test_connection.py
index b742f7abc..566e19daa 100644
--- a/src/mailman/mta/tests/test_connection.py
+++ b/src/mailman/mta/tests/test_connection.py
@@ -20,9 +20,11 @@
import unittest
from mailman.config import config
-from mailman.mta.connection import Connection
-from mailman.testing.layers import SMTPLayer
-from smtplib import SMTP, SMTPAuthenticationError
+from mailman.mta.connection import (
+ SMTPConnection, SMTPSConnection, STARTTLSConnection)
+from mailman.testing.helpers import LogFileMark
+from mailman.testing.layers import SMTPLayer, SMTPSLayer, STARTTLSLayer
+from smtplib import SMTPAuthenticationError
class TestConnection(unittest.TestCase):
@@ -32,7 +34,7 @@ class TestConnection(unittest.TestCase):
# Logging in to the MTA with a bad user name and password produces a
# 571 Bad Authentication error.
with self.assertRaises(SMTPAuthenticationError) as cm:
- connection = Connection(
+ connection = SMTPConnection(
config.mta.smtp_host, int(config.mta.smtp_port), 0,
'baduser', 'badpass')
connection.sendmail('anne@example.com', ['bart@example.com'], """\
@@ -46,7 +48,7 @@ Subject: aardvarks
def test_authentication_good_path(self):
# Logging in with the correct user name and password succeeds.
- connection = Connection(
+ connection = SMTPConnection(
config.mta.smtp_host, int(config.mta.smtp_port), 0,
'testuser', 'testpass')
connection.sendmail('anne@example.com', ['bart@example.com'], """\
@@ -59,27 +61,31 @@ Subject: aardvarks
'AHRlc3R1c2VyAHRlc3RwYXNz')
-class TestConnectionCount(unittest.TestCase):
- layer = SMTPLayer
+class _ConnectionCounter:
+ layer = None
def setUp(self):
- self.connection = Connection(
- config.mta.smtp_host, int(config.mta.smtp_port), 0)
+ self.connection = None
self.msg_text = """\
From: anne@example.com
To: bart@example.com
Subject: aardvarks
-
"""
def test_count_0(self):
# So far, no connections.
- self.assertEqual(SMTPLayer.smtpd.get_connection_count(), 0)
+ unittest.TestCase.assertEqual(self,
+ self.layer.smtpd.get_connection_count(),
+ 0)
+ pass
def test_count_1(self):
self.connection.sendmail(
'anne@example.com', ['bart@example.com'], self.msg_text)
- self.assertEqual(SMTPLayer.smtpd.get_connection_count(), 1)
+ unittest.TestCase.assertEqual(self,
+ self.layer.smtpd.get_connection_count(),
+ 1)
+ pass
def test_count_2(self):
self.connection.sendmail(
@@ -88,7 +94,10 @@ Subject: aardvarks
self.connection.sendmail(
'cate@example.com', ['dave@example.com'], self.msg_text)
self.connection.quit()
- self.assertEqual(SMTPLayer.smtpd.get_connection_count(), 2)
+ unittest.TestCase.assertEqual(self,
+ self.layer.smtpd.get_connection_count(),
+ 2)
+ pass
def test_count_reset(self):
self.connection.sendmail(
@@ -98,7 +107,53 @@ Subject: aardvarks
'cate@example.com', ['dave@example.com'], self.msg_text)
self.connection.quit()
# Issue the fake SMTP command to reset the count.
- client = SMTP()
- client.connect(config.mta.smtp_host, int(config.mta.smtp_port))
- client.docmd('RSET')
- self.assertEqual(SMTPLayer.smtpd.get_connection_count(), 0)
+ self.layer.smtpd.reset()
+ unittest.TestCase.assertEqual(self,
+ self.layer.smtpd.get_connection_count(),
+ 0)
+
+
+class TestSMTPConnectionCount(_ConnectionCounter, unittest.TestCase):
+ layer = SMTPLayer
+
+ def setUp(self):
+ super().setUp()
+ self.connection = SMTPConnection(
+ config.mta.smtp_host, int(config.mta.smtp_port), 0)
+
+
+class TestSMTPSConnectionCount(_ConnectionCounter, unittest.TestCase):
+ layer = SMTPSLayer
+
+ def setUp(self):
+ super().setUp()
+ self.connection = SMTPSConnection(
+ config.mta.smtp_host, int(config.mta.smtp_port), 0)
+
+
+class TestSTARTTLSConnectionCount(_ConnectionCounter, unittest.TestCase):
+ layer = STARTTLSLayer
+
+ def setUp(self):
+ super().setUp()
+ self.connection = STARTTLSConnection(
+ config.mta.smtp_host, int(config.mta.smtp_port), 0)
+
+
+class TestSTARTTLSNotSupported(unittest.TestCase):
+ layer = SMTPLayer
+
+ def test_not_supported(self):
+ connection = STARTTLSConnection(
+ config.mta.smtp_host, int(config.mta.smtp_port), 0)
+ msg_text = """\
+From: anne@example.com
+To: bart@example.com
+Subject: aardvarks
+"""
+ smtp_log = LogFileMark('mailman.smtp')
+ connection.sendmail(
+ 'anne@example.com', ['bart@example.com'], msg_text)
+ lines = smtp_log.read()
+ self.assertIn('Starttls failed', lines)
+ connection.quit()