From 93d6c5645ea50e7a308515dceacdbbce13daecbb Mon Sep 17 00:00:00 2001 From: Ishu Patel <258577714+ishu7w@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:58:15 +0530 Subject: [PATCH] gh-47711: Use connected source address for SMTP address literals --- Doc/library/smtplib.rst | 4 +- Lib/smtplib.py | 19 ++++-- Lib/test/test_smtplib.py | 64 +++++++++++++++++++ ...0-11-00-00-00.gh-issue-47711.smtp-ipv6.rst | 3 + 4 files changed, 84 insertions(+), 6 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-10-11-00-00-00.gh-issue-47711.smtp-ipv6.rst diff --git a/Doc/library/smtplib.rst b/Doc/library/smtplib.rst index 5c97199bc453e85..64ab13e278f7e51 100644 --- a/Doc/library/smtplib.rst +++ b/Doc/library/smtplib.rst @@ -30,7 +30,9 @@ Protocol) and :rfc:`1869` (SMTP Service Extensions). If specified, *local_hostname* is used as the FQDN of the local host in the HELO/EHLO command. Otherwise, the local hostname is found using - :func:`socket.getfqdn`. If the :meth:`connect` call returns anything other + :func:`socket.getfqdn`. If it is not fully qualified, the connected socket's + local address is used as an address literal instead. If the :meth:`connect` + call returns anything other than a success code, an :exc:`SMTPConnectError` is raised. The optional *timeout* parameter specifies a timeout in seconds for blocking operations like the connection attempt (if not specified, the global default timeout diff --git a/Lib/smtplib.py b/Lib/smtplib.py index 4cfc2338d99c67e..c8d5a1c94b5ae8c 100644 --- a/Lib/smtplib.py +++ b/Lib/smtplib.py @@ -257,11 +257,7 @@ def __init__(self, host='', port=0, local_hostname=None, self.source_address = source_address self._auth_challenge_count = 0 - if host: - (code, msg) = self.connect(host, port) - if code != 220: - self.close() - raise SMTPConnectError(code, msg) + self._find_local_hostname = False if local_hostname is not None: self.local_hostname = local_hostname else: @@ -273,6 +269,7 @@ def __init__(self, host='', port=0, local_hostname=None, self.local_hostname = fqdn else: # We can't find an fqdn hostname, so use a domain literal + self._find_local_hostname = True addr = '127.0.0.1' try: addr = socket.gethostbyname(socket.gethostname()) @@ -280,6 +277,12 @@ def __init__(self, host='', port=0, local_hostname=None, pass self.local_hostname = '[%s]' % addr + if host: + (code, msg) = self.connect(host, port) + if code != 220: + self.close() + raise SMTPConnectError(code, msg) + def __enter__(self): return self @@ -348,6 +351,12 @@ def connect(self, host='localhost', port=0, source_address=None): self.sock = self._get_socket(host, port, self.timeout) self.file = None (code, msg) = self.getreply() + if code == 220 and self._find_local_hostname: + addr = self.sock.getsockname()[0] + if self.sock.family == socket.AF_INET6: + # RFC 5321 section 4.1.3 requires the IPv6 tag and excludes scope IDs. + addr = f"IPv6:{addr.split('%', 1)[0]}" + self.local_hostname = '[%s]' % addr if self.debuglevel > 0: self._print_debug('connect:', repr(msg)) return (code, msg) diff --git a/Lib/test/test_smtplib.py b/Lib/test/test_smtplib.py index b8aac8c20202a26..a5feb75bf789910 100644 --- a/Lib/test/test_smtplib.py +++ b/Lib/test/test_smtplib.py @@ -696,6 +696,70 @@ def testSendMessageMultipleResentRaises(self): smtp.send_message(m) smtp.close() +class LocalHostnameTests(unittest.TestCase): + + def test_connected_address_literal(self): + cases = ( + (socket.AF_INET, ('192.0.2.1', 1234), '[192.0.2.1]'), + (socket.AF_INET6, ('2001:db8::1', 1234, 0, 0), + '[IPv6:2001:db8::1]'), + (socket.AF_INET6, ('fe80::1%eth0', 1234, 0, 1), '[IPv6:fe80::1]'), + ) + for family, address, expected in cases: + for connect_in_init in (True, False): + with self.subTest(family=family, address=address, + connect_in_init=connect_in_init): + sock = Mock(family=family) + sock.getsockname.return_value = address + with mock.patch('smtplib.socket.getfqdn', return_value='host'), \ + mock.patch('smtplib.socket.gethostbyname', + return_value='127.0.0.1'), \ + mock.patch.object(smtplib.SMTP, '_get_socket', + return_value=sock), \ + mock.patch.object(smtplib.SMTP, 'getreply', + return_value=(220, b'OK')): + smtp = smtplib.SMTP('server' if connect_in_init else '') + self.addCleanup(smtp.close) + if not connect_in_init: + smtp.connect('server') + self.assertEqual(smtp.local_hostname, expected) + smtp.ehlo() + sock.sendall.assert_called_with( + ('ehlo ' + expected + '\r\n').encode('ascii')) + + def test_address_literal_updated_on_reconnect(self): + sock = Mock(family=socket.AF_INET6) + sock.getsockname.side_effect = [ + ('2001:db8::1', 1234, 0, 0), ('2001:db8::2', 1234, 0, 0)] + with mock.patch('smtplib.socket.getfqdn', return_value='host'), \ + mock.patch('smtplib.socket.gethostbyname', return_value='127.0.0.1'), \ + mock.patch.object(smtplib.SMTP, '_get_socket', return_value=sock), \ + mock.patch.object(smtplib.SMTP, 'getreply', return_value=(220, b'OK')): + smtp = smtplib.SMTP('server') + self.addCleanup(smtp.close) + self.assertEqual(smtp.local_hostname, '[IPv6:2001:db8::1]') + smtp.close() + smtp.connect('other-server') + self.assertEqual(smtp.local_hostname, '[IPv6:2001:db8::2]') + + def test_hostname_preserved(self): + for local_hostname, fqdn, expected in ( + ('custom', 'host', 'custom'), + (None, 'host.example', 'host.example'), + ): + with self.subTest(local_hostname=local_hostname, fqdn=fqdn): + sock = Mock(family=socket.AF_INET6) + with mock.patch('smtplib.socket.getfqdn', return_value=fqdn), \ + mock.patch.object(smtplib.SMTP, '_get_socket', + return_value=sock), \ + mock.patch.object(smtplib.SMTP, 'getreply', + return_value=(220, b'OK')): + smtp = smtplib.SMTP('server', local_hostname=local_hostname) + self.addCleanup(smtp.close) + self.assertEqual(smtp.local_hostname, expected) + sock.getsockname.assert_not_called() + + class NonConnectingTests(unittest.TestCase): def testNotConnected(self): diff --git a/Misc/NEWS.d/next/Library/2026-10-11-00-00-00.gh-issue-47711.smtp-ipv6.rst b/Misc/NEWS.d/next/Library/2026-10-11-00-00-00.gh-issue-47711.smtp-ipv6.rst new file mode 100644 index 000000000000000..102e62ffd2122ed --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-10-11-00-00-00.gh-issue-47711.smtp-ipv6.rst @@ -0,0 +1,3 @@ +When no fully qualified hostname is available, :mod:`smtplib` now uses the +connected socket's local address for the HELO/EHLO address literal. Previously, +IPv6 was not handled correctly.