summaryrefslogtreecommitdiff
path: root/src/mailman/mta/connection.py
blob: d6824bccf76043bcbb472b3027dbf446b1e0da46 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
# Copyright (C) 2009-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/>.

"""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')


@implementer(ISMTPConnection)
class Connection:

    def __init__(self, host, port, sessions_per_connection,
                 smtp_user=None, smtp_pass=None):
        """Create a connection manager.

        :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 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.
        """
        self._host = host
        self._port = port
        self._sessions_per_connection = sessions_per_connection
        self._username = smtp_user
        self._password = smtp_pass
        self._session_count = None
        self._connection = None

    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)

    def sendmail(self, envsender, recipients, msgtext):
        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))
            results = self._connection.sendmail(envsender, recipients, msgtext)
        except smtplib.SMTPException:
            # For safety, close this connection.  The next send attempt will
            # automatically re-open it.  Pass the exception on up.
            self.quit()
            raise
        # This session has been successfully completed.
        self._session_count -= 1
        # By testing exactly for equality to 0, we automatically handle the
        # case for SMTP_MAX_SESSIONS_PER_CONNECTION <= 0 meaning never close
        # the connection.  We won't worry about wraparound <wink>.
        if self._session_count == 0:
            self.quit()
        return results

    def quit(self):
        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))