Skip to content

Commit 93d6c56

Browse files
committed
gh-47711: Use connected source address for SMTP address literals
1 parent c6c0608 commit 93d6c56

4 files changed

Lines changed: 84 additions & 6 deletions

File tree

‎Doc/library/smtplib.rst‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@ Protocol) and :rfc:`1869` (SMTP Service Extensions).
3030

3131
If specified, *local_hostname* is used as the FQDN of the local host in the HELO/EHLO
3232
command. Otherwise, the local hostname is found using
33-
:func:`socket.getfqdn`. If the :meth:`connect` call returns anything other
33+
:func:`socket.getfqdn`. If it is not fully qualified, the connected socket's
34+
local address is used as an address literal instead. If the :meth:`connect`
35+
call returns anything other
3436
than a success code, an :exc:`SMTPConnectError` is raised. The optional
3537
*timeout* parameter specifies a timeout in seconds for blocking operations
3638
like the connection attempt (if not specified, the global default timeout

‎Lib/smtplib.py‎

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -257,11 +257,7 @@ def __init__(self, host='', port=0, local_hostname=None,
257257
self.source_address = source_address
258258
self._auth_challenge_count = 0
259259

260-
if host:
261-
(code, msg) = self.connect(host, port)
262-
if code != 220:
263-
self.close()
264-
raise SMTPConnectError(code, msg)
260+
self._find_local_hostname = False
265261
if local_hostname is not None:
266262
self.local_hostname = local_hostname
267263
else:
@@ -273,13 +269,20 @@ def __init__(self, host='', port=0, local_hostname=None,
273269
self.local_hostname = fqdn
274270
else:
275271
# We can't find an fqdn hostname, so use a domain literal
272+
self._find_local_hostname = True
276273
addr = '127.0.0.1'
277274
try:
278275
addr = socket.gethostbyname(socket.gethostname())
279276
except socket.gaierror:
280277
pass
281278
self.local_hostname = '[%s]' % addr
282279

280+
if host:
281+
(code, msg) = self.connect(host, port)
282+
if code != 220:
283+
self.close()
284+
raise SMTPConnectError(code, msg)
285+
283286
def __enter__(self):
284287
return self
285288

@@ -348,6 +351,12 @@ def connect(self, host='localhost', port=0, source_address=None):
348351
self.sock = self._get_socket(host, port, self.timeout)
349352
self.file = None
350353
(code, msg) = self.getreply()
354+
if code == 220 and self._find_local_hostname:
355+
addr = self.sock.getsockname()[0]
356+
if self.sock.family == socket.AF_INET6:
357+
# RFC 5321 section 4.1.3 requires the IPv6 tag and excludes scope IDs.
358+
addr = f"IPv6:{addr.split('%', 1)[0]}"
359+
self.local_hostname = '[%s]' % addr
351360
if self.debuglevel > 0:
352361
self._print_debug('connect:', repr(msg))
353362
return (code, msg)

‎Lib/test/test_smtplib.py‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -696,6 +696,70 @@ def testSendMessageMultipleResentRaises(self):
696696
smtp.send_message(m)
697697
smtp.close()
698698

699+
class LocalHostnameTests(unittest.TestCase):
700+
701+
def test_connected_address_literal(self):
702+
cases = (
703+
(socket.AF_INET, ('192.0.2.1', 1234), '[192.0.2.1]'),
704+
(socket.AF_INET6, ('2001:db8::1', 1234, 0, 0),
705+
'[IPv6:2001:db8::1]'),
706+
(socket.AF_INET6, ('fe80::1%eth0', 1234, 0, 1), '[IPv6:fe80::1]'),
707+
)
708+
for family, address, expected in cases:
709+
for connect_in_init in (True, False):
710+
with self.subTest(family=family, address=address,
711+
connect_in_init=connect_in_init):
712+
sock = Mock(family=family)
713+
sock.getsockname.return_value = address
714+
with mock.patch('smtplib.socket.getfqdn', return_value='host'), \
715+
mock.patch('smtplib.socket.gethostbyname',
716+
return_value='127.0.0.1'), \
717+
mock.patch.object(smtplib.SMTP, '_get_socket',
718+
return_value=sock), \
719+
mock.patch.object(smtplib.SMTP, 'getreply',
720+
return_value=(220, b'OK')):
721+
smtp = smtplib.SMTP('server' if connect_in_init else '')
722+
self.addCleanup(smtp.close)
723+
if not connect_in_init:
724+
smtp.connect('server')
725+
self.assertEqual(smtp.local_hostname, expected)
726+
smtp.ehlo()
727+
sock.sendall.assert_called_with(
728+
('ehlo ' + expected + '\r\n').encode('ascii'))
729+
730+
def test_address_literal_updated_on_reconnect(self):
731+
sock = Mock(family=socket.AF_INET6)
732+
sock.getsockname.side_effect = [
733+
('2001:db8::1', 1234, 0, 0), ('2001:db8::2', 1234, 0, 0)]
734+
with mock.patch('smtplib.socket.getfqdn', return_value='host'), \
735+
mock.patch('smtplib.socket.gethostbyname', return_value='127.0.0.1'), \
736+
mock.patch.object(smtplib.SMTP, '_get_socket', return_value=sock), \
737+
mock.patch.object(smtplib.SMTP, 'getreply', return_value=(220, b'OK')):
738+
smtp = smtplib.SMTP('server')
739+
self.addCleanup(smtp.close)
740+
self.assertEqual(smtp.local_hostname, '[IPv6:2001:db8::1]')
741+
smtp.close()
742+
smtp.connect('other-server')
743+
self.assertEqual(smtp.local_hostname, '[IPv6:2001:db8::2]')
744+
745+
def test_hostname_preserved(self):
746+
for local_hostname, fqdn, expected in (
747+
('custom', 'host', 'custom'),
748+
(None, 'host.example', 'host.example'),
749+
):
750+
with self.subTest(local_hostname=local_hostname, fqdn=fqdn):
751+
sock = Mock(family=socket.AF_INET6)
752+
with mock.patch('smtplib.socket.getfqdn', return_value=fqdn), \
753+
mock.patch.object(smtplib.SMTP, '_get_socket',
754+
return_value=sock), \
755+
mock.patch.object(smtplib.SMTP, 'getreply',
756+
return_value=(220, b'OK')):
757+
smtp = smtplib.SMTP('server', local_hostname=local_hostname)
758+
self.addCleanup(smtp.close)
759+
self.assertEqual(smtp.local_hostname, expected)
760+
sock.getsockname.assert_not_called()
761+
762+
699763
class NonConnectingTests(unittest.TestCase):
700764

701765
def testNotConnected(self):
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
When no fully qualified hostname is available, :mod:`smtplib` now uses the
2+
connected socket's local address for the HELO/EHLO address literal. Previously,
3+
IPv6 was not handled correctly.

0 commit comments

Comments
 (0)