Merge branch 'doc' into 'master'
[mailman.git] / src / mailman / mta / connection.py
blobcd4759a06912fc7609d1078d2968835bdd287df0
1 # Copyright (C) 2009-2023 by the Free Software Foundation, Inc.
3 # This file is part of GNU Mailman.
5 # GNU Mailman is free software: you can redistribute it and/or modify it under
6 # the terms of the GNU General Public License as published by the Free
7 # Software Foundation, either version 3 of the License, or (at your option)
8 # any later version.
10 # GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
11 # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
12 # FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
13 # more details.
15 # You should have received a copy of the GNU General Public License along with
16 # GNU Mailman. If not, see <https://www.gnu.org/licenses/>.
18 """MTA connections."""
20 import ssl
21 import enum
22 import socket
23 import logging
24 import smtplib
26 from contextlib import suppress
27 from email.message import Message
28 from lazr.config import as_boolean
29 from mailman.config import config
30 from mailman.interfaces.configuration import InvalidConfigurationError
31 from public import public
34 log = logging.getLogger('mailman.smtp')
37 @public
38 class SecureMode(enum.Enum):
39 INSECURE = 'smtp'
40 IMPLICIT = 'smtps'
41 STARTTLS = 'starttls'
42 # STARTTLS can be invoked prior to any message submission, but we don't do
43 # that -- we invoke immediately.
46 @public
47 def as_SecureMode(s):
48 """
49 Convert a string to an enum value. Accepts any case.
50 """
51 s = s.lower()
53 try:
54 return SecureMode(s)
55 except ValueError:
56 raise InvalidConfigurationError('smtp_secure_mode', repr(s))
59 class Connection:
60 """Manage a connection to the SMTP server."""
61 def __init__(self, host, port, sessions_per_connection,
62 smtp_user=None, smtp_pass=None,
63 secure_mode=SecureMode.INSECURE,
64 verify_cert=True, verify_hostname=True):
65 """Create a connection manager.
67 :param host: The host name of the SMTP server to connect to.
68 :type host: string
69 :param port: The port number of the SMTP server to connect to.
70 :type port: integer
71 :param sessions_per_connection: The number of SMTP sessions per
72 connection to the SMTP server. After this number of sessions
73 has been reached, the connection is closed and a new one is
74 opened. Set to zero for an unlimited number of sessions per
75 connection (i.e. your MTA has no limit).
76 :type sessions_per_connection: integer
77 :param smtp_user: Optional SMTP authentication user name. If given,
78 `smtp_pass` must also be given.
79 :type smtp_user: str
80 :param smtp_pass: Optional SMTP authentication password. If given,
81 `smtp_user` must also be given.
82 :type smtp_pass: str
83 :param secure_mode: Whether to use implicit TLS (SMTPS), STARTTLS, or
84 an insecure connection.
85 :type secure_mode: SecureMode
86 :param verify_cert: Whether to require a server cert and verify it.
87 Verification in this context means that the server needs to supply
88 a valid certificate signed by a CA from a set of the system's
89 default CA certs.
90 :type verify_cert: bool
91 :param verify_hostname: Whether to check that the server certificate
92 specifies the hostname as passed to this constructor.
93 RFC 2818 and RFC 6125 rules are followed.
94 :type verify_hostname: bool
96 With the exception of the parameters specified here, this class
97 uses the defaults provided by your version of the Python 'ssl'
98 module.
100 If either of smtp_pass or smtp_user is omitted, the other will
101 be ignored. If secure_mode is INSECURE, verify_hostname and
102 verify_cert will be ignored. If secure_mode is not INSECURE,
103 verify_hostname will be ignored unless verify_cert is true.
105 self._host = host
106 self._port = port
107 self._sessions_per_connection = sessions_per_connection
108 self.secure_mode = secure_mode
109 self.verify_cert = verify_cert
110 self.verify_hostname = verify_hostname and verify_cert
111 self._username = smtp_user
112 self._password = smtp_pass
114 self._session_count = None
115 self._connection = None
116 if self.secure_mode == SecureMode.INSECURE:
117 self._tls_context = None
118 else:
119 self._tls_context = self._get_tls_context(self.verify_cert,
120 self.verify_hostname)
122 def sendmail(self, envsender, recipients, msg):
123 """Mimic `smtplib.SMTP.sendmail`."""
124 if as_boolean(config.devmode.enabled):
125 # Force the recipients to the specified address, but still deliver
126 # to the same number of recipients.
127 recipients = [config.devmode.recipient] * len(recipients)
128 if self._connection is None:
129 self._connect()
130 self._login()
131 # We accept a string, bytes or a Message object for the msg argument.
132 # A string is converted to ascii bytes with errors replaced and
133 # passed to smtplib.SMTP.sendmail. Bytes are passed as is and a
134 # Message is passed to smtplib.SMTP.send_message. Passing a Message
135 # object is preferred because of the treatment of line endings.
136 if isinstance(msg, str):
137 msg = msg.encode('ascii', 'replace')
138 assert isinstance(msg, bytes) or isinstance(msg, Message), \
139 'Connection.sendmail received an invalid msg arg.'
140 try:
141 log.debug('envsender: %s, recipients: %s, size(msg): %s',
142 envsender, recipients, len(msg))
143 if isinstance(msg, Message):
144 results = self._connection.send_message(msg, envsender,
145 recipients)
146 else:
147 results = self._connection.sendmail(envsender, recipients, msg)
148 except smtplib.SMTPException:
149 # For safety, close this connection. The next send attempt will
150 # automatically re-open it. Pass the exception on up.
151 self.quit()
152 raise
153 # This session has been successfully completed.
154 self._session_count -= 1
155 # By testing exactly for equality to 0, we automatically handle the
156 # case for SMTP_MAX_SESSIONS_PER_CONNECTION <= 0 meaning never close
157 # the connection. We won't worry about wraparound <wink>.
158 if self._session_count == 0:
159 self.quit()
160 return results
162 def quit(self):
163 """Mimic `smtplib.SMTP.quit`."""
164 if self._connection is None:
165 return
166 with suppress(smtplib.SMTPException):
167 self._connection.quit()
168 self._connection = None
170 def _connect(self):
171 """Open a new connection."""
172 try:
173 if self.secure_mode == SecureMode.IMPLICIT:
174 log.debug('Connecting to %s:%s with implicit TLS',
175 self._host, self._port)
176 self._connection = smtplib.SMTP_SSL(self._host, self._port,
177 context=self._tls_context)
178 else:
179 log.debug('Connecting to %s:%s', self._host, self._port)
180 self._connection = smtplib.SMTP(self._host, self._port)
181 if self.secure_mode == SecureMode.STARTTLS:
182 log.debug('Starttls')
183 self._connection.starttls(context=self._tls_context)
184 except (socket.error, IOError, smtplib.SMTPException):
185 self.quit()
186 raise
187 except Exception as error:
188 # This exception is kept here intentionally to make sure we log
189 # only when an exception other than 3 caught above happens and
190 # can't be handled.
191 # If ANYTHING fails here, after ensuring
192 # connection is closed, we'll let the exception bubble up so a
193 # message in process will be shunted.
194 log.error('while connecting to SMTP: ' + str(error))
195 self.quit()
196 raise
197 self._session_count = self._sessions_per_connection
199 def _login(self):
200 """Send login if both username and password are specified."""
201 if self._username is not None and self._password is not None:
202 log.debug('logging in')
203 try:
204 self._connection.login(self._username, self._password)
205 except smtplib.SMTPException:
206 # Ensure connection is closed and pass to BaseDelivery.
207 self.quit()
208 raise
210 def _get_tls_context(self, verify_cert, verify_hostname):
211 """Create and return a new SSLContext."""
212 ssl_context = ssl.create_default_context()
213 ssl_context.check_hostname = verify_hostname
214 if verify_cert:
215 ssl_context.verify_mode = ssl.CERT_REQUIRED
216 else:
217 ssl_context.verify_mode = ssl.CERT_NONE
218 return ssl_context