Skip to content

Commit 6c186e3

Browse files
authored
Update smptlib and test_smtpnet.py from 3.13.11 (#6435)
* Update `test_smtpnet.py` from 3.13.11 * Update `test_smtplib.py` from 3.13.11 * Update `smtplib.py` from 3.13.11 * Catch AttributeError
1 parent 70b9389 commit 6c186e3

File tree

3 files changed

+100
-35
lines changed

3 files changed

+100
-35
lines changed

Lib/smtplib.py

100644100755
Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -171,14 +171,24 @@ def quotedata(data):
171171
internet CRLF end-of-line.
172172
"""
173173
return re.sub(r'(?m)^\.', '..',
174-
re.sub(r'(?:\r\n|\n|\r(?!\n))', CRLF, data))
174+
re.sub(r'(?:\r\n|\n|\r(?!\n))', CRLF, data))
175175

176176
def _quote_periods(bindata):
177177
return re.sub(br'(?m)^\.', b'..', bindata)
178178

179179
def _fix_eols(data):
180180
return re.sub(r'(?:\r\n|\n|\r(?!\n))', CRLF, data)
181181

182+
183+
try:
184+
hmac.digest(b'', b'', 'md5')
185+
# except ValueError:
186+
except (ValueError, AttributeError): # TODO: RUSTPYTHON
187+
_have_cram_md5_support = False
188+
else:
189+
_have_cram_md5_support = True
190+
191+
182192
try:
183193
import ssl
184194
except ImportError:
@@ -475,7 +485,7 @@ def ehlo(self, name=''):
475485
if auth_match:
476486
# This doesn't remove duplicates, but that's no problem
477487
self.esmtp_features["auth"] = self.esmtp_features.get("auth", "") \
478-
+ " " + auth_match.groups(0)[0]
488+
+ " " + auth_match.groups(0)[0]
479489
continue
480490

481491
# RFC 1869 requires a space between ehlo keyword and parameters.
@@ -488,7 +498,7 @@ def ehlo(self, name=''):
488498
params = m.string[m.end("feature"):].strip()
489499
if feature == "auth":
490500
self.esmtp_features[feature] = self.esmtp_features.get(feature, "") \
491-
+ " " + params
501+
+ " " + params
492502
else:
493503
self.esmtp_features[feature] = params
494504
return (code, msg)
@@ -542,15 +552,15 @@ def mail(self, sender, options=()):
542552
raise SMTPNotSupportedError(
543553
'SMTPUTF8 not supported by server')
544554
optionlist = ' ' + ' '.join(options)
545-
self.putcmd("mail", "FROM:%s%s" % (quoteaddr(sender), optionlist))
555+
self.putcmd("mail", "from:%s%s" % (quoteaddr(sender), optionlist))
546556
return self.getreply()
547557

548558
def rcpt(self, recip, options=()):
549559
"""SMTP 'rcpt' command -- indicates 1 recipient for this mail."""
550560
optionlist = ''
551561
if options and self.does_esmtp:
552562
optionlist = ' ' + ' '.join(options)
553-
self.putcmd("rcpt", "TO:%s%s" % (quoteaddr(recip), optionlist))
563+
self.putcmd("rcpt", "to:%s%s" % (quoteaddr(recip), optionlist))
554564
return self.getreply()
555565

556566
def data(self, msg):
@@ -667,8 +677,11 @@ def auth_cram_md5(self, challenge=None):
667677
# CRAM-MD5 does not support initial-response.
668678
if challenge is None:
669679
return None
670-
return self.user + " " + hmac.HMAC(
671-
self.password.encode('ascii'), challenge, 'md5').hexdigest()
680+
if not _have_cram_md5_support:
681+
raise SMTPException("CRAM-MD5 is not supported")
682+
password = self.password.encode('ascii')
683+
authcode = hmac.HMAC(password, challenge, 'md5')
684+
return f"{self.user} {authcode.hexdigest()}"
672685

673686
def auth_plain(self, challenge=None):
674687
""" Authobject to use with PLAIN authentication. Requires self.user and
@@ -720,8 +733,10 @@ def login(self, user, password, *, initial_response_ok=True):
720733
advertised_authlist = self.esmtp_features["auth"].split()
721734

722735
# Authentication methods we can handle in our preferred order:
723-
preferred_auths = ['CRAM-MD5', 'PLAIN', 'LOGIN']
724-
736+
if _have_cram_md5_support:
737+
preferred_auths = ['CRAM-MD5', 'PLAIN', 'LOGIN']
738+
else:
739+
preferred_auths = ['PLAIN', 'LOGIN']
725740
# We try the supported authentications in our preferred order, if
726741
# the server supports them.
727742
authlist = [auth for auth in preferred_auths
@@ -905,7 +920,7 @@ def send_message(self, msg, from_addr=None, to_addrs=None,
905920
The arguments are as for sendmail, except that msg is an
906921
email.message.Message object. If from_addr is None or to_addrs is
907922
None, these arguments are taken from the headers of the Message as
908-
described in RFC 2822 (a ValueError is raised if there is more than
923+
described in RFC 5322 (a ValueError is raised if there is more than
909924
one set of 'Resent-' headers). Regardless of the values of from_addr and
910925
to_addr, any Bcc field (or Resent-Bcc field, when the Message is a
911926
resent) of the Message object won't be transmitted. The Message
@@ -919,7 +934,7 @@ def send_message(self, msg, from_addr=None, to_addrs=None,
919934
policy.
920935
921936
"""
922-
# 'Resent-Date' is a mandatory field if the Message is resent (RFC 2822
937+
# 'Resent-Date' is a mandatory field if the Message is resent (RFC 5322
923938
# Section 3.6.6). In such a case, we use the 'Resent-*' fields. However,
924939
# if there is more than one 'Resent-' block there's no way to
925940
# unambiguously determine which one is the most recent in all cases,
@@ -938,10 +953,10 @@ def send_message(self, msg, from_addr=None, to_addrs=None,
938953
else:
939954
raise ValueError("message has more than one 'Resent-' header block")
940955
if from_addr is None:
941-
# Prefer the sender field per RFC 2822:3.6.2.
956+
# Prefer the sender field per RFC 5322 section 3.6.2.
942957
from_addr = (msg[header_prefix + 'Sender']
943-
if (header_prefix + 'Sender') in msg
944-
else msg[header_prefix + 'From'])
958+
if (header_prefix + 'Sender') in msg
959+
else msg[header_prefix + 'From'])
945960
from_addr = email.utils.getaddresses([from_addr])[0][1]
946961
if to_addrs is None:
947962
addr_fields = [f for f in (msg[header_prefix + 'To'],

Lib/test/test_smtplib.py

Lines changed: 65 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import threading
1818

1919
import unittest
20+
import unittest.mock as mock
2021
from test import support, mock_socket
2122
from test.support import hashlib_helper
2223
from test.support import socket_helper
@@ -350,7 +351,7 @@ def testVRFY(self):
350351
timeout=support.LOOPBACK_TIMEOUT)
351352
self.addCleanup(smtp.close)
352353
expected = (252, b'Cannot VRFY user, but will accept message ' + \
353-
b'and attempt delivery')
354+
b'and attempt delivery')
354355
self.assertEqual(smtp.vrfy('nobody@nowhere.com'), expected)
355356
self.assertEqual(smtp.verify('nobody@nowhere.com'), expected)
356357
smtp.quit()
@@ -371,7 +372,7 @@ def testHELP(self):
371372
timeout=support.LOOPBACK_TIMEOUT)
372373
self.addCleanup(smtp.close)
373374
self.assertEqual(smtp.help(), b'Supported commands: EHLO HELO MAIL ' + \
374-
b'RCPT DATA RSET NOOP QUIT VRFY')
375+
b'RCPT DATA RSET NOOP QUIT VRFY')
375376
smtp.quit()
376377

377378
def testSend(self):
@@ -527,7 +528,7 @@ def testSendMessageWithAddresses(self):
527528
smtp.quit()
528529
# make sure the Bcc header is still in the message.
529530
self.assertEqual(m['Bcc'], 'John Root <root@localhost>, "Dinsdale" '
530-
'<warped@silly.walks.com>')
531+
'<warped@silly.walks.com>')
531532

532533
self.client_evt.set()
533534
self.serv_evt.wait()
@@ -766,7 +767,7 @@ def tearDown(self):
766767

767768
def testFailingHELO(self):
768769
self.assertRaises(smtplib.SMTPConnectError, smtplib.SMTP,
769-
HOST, self.port, 'localhost', 3)
770+
HOST, self.port, 'localhost', 3)
770771

771772

772773
class TooLongLineTests(unittest.TestCase):
@@ -804,14 +805,14 @@ def testLineTooLong(self):
804805
sim_users = {'Mr.A@somewhere.com':'John A',
805806
'Ms.B@xn--fo-fka.com':'Sally B',
806807
'Mrs.C@somewhereesle.com':'Ruth C',
807-
}
808+
}
808809

809810
sim_auth = ('Mr.A@somewhere.com', 'somepassword')
810811
sim_cram_md5_challenge = ('PENCeUxFREJoU0NnbmhNWitOMjNGNn'
811812
'dAZWx3b29kLmlubm9zb2Z0LmNvbT4=')
812813
sim_lists = {'list-1':['Mr.A@somewhere.com','Mrs.C@somewhereesle.com'],
813814
'list-2':['Ms.B@xn--fo-fka.com',],
814-
}
815+
}
815816

816817
# Simulated SMTP channel & server
817818
class ResponseException(Exception): pass
@@ -830,6 +831,7 @@ class SimSMTPChannel(smtpd.SMTPChannel):
830831
def __init__(self, extra_features, *args, **kw):
831832
self._extrafeatures = ''.join(
832833
[ "250-{0}\r\n".format(x) for x in extra_features ])
834+
self.all_received_lines = []
833835
super(SimSMTPChannel, self).__init__(*args, **kw)
834836

835837
# AUTH related stuff. It would be nice if support for this were in smtpd.
@@ -844,6 +846,7 @@ def found_terminator(self):
844846
self.smtp_state = self.COMMAND
845847
self.push('%s %s' % (e.smtp_code, e.smtp_error))
846848
return
849+
self.all_received_lines.append(self.received_lines)
847850
super().found_terminator()
848851

849852

@@ -924,11 +927,14 @@ def _auth_cram_md5(self, arg=None):
924927
except ValueError as e:
925928
self.push('535 Splitting response {!r} into user and password '
926929
'failed: {}'.format(logpass, e))
927-
return False
928-
valid_hashed_pass = hmac.HMAC(
929-
sim_auth[1].encode('ascii'),
930-
self._decode_base64(sim_cram_md5_challenge).encode('ascii'),
931-
'md5').hexdigest()
930+
return
931+
pwd = sim_auth[1].encode('ascii')
932+
msg = self._decode_base64(sim_cram_md5_challenge).encode('ascii')
933+
try:
934+
valid_hashed_pass = hmac.HMAC(pwd, msg, 'md5').hexdigest()
935+
except ValueError:
936+
self.push('504 CRAM-MD5 is not supported')
937+
return
932938
self._authenticated(user, hashed_pass == valid_hashed_pass)
933939
# end AUTH related stuff.
934940

@@ -1170,8 +1176,7 @@ def auth_buggy(challenge=None):
11701176
finally:
11711177
smtp.close()
11721178

1173-
# TODO: RUSTPYTHON
1174-
@unittest.expectedFailure
1179+
@unittest.expectedFailure # TODO: RUSTPYTHON
11751180
@hashlib_helper.requires_hashdigest('md5', openssl=True)
11761181
def testAUTH_CRAM_MD5(self):
11771182
self.serv.add_feature("AUTH CRAM-MD5")
@@ -1181,8 +1186,39 @@ def testAUTH_CRAM_MD5(self):
11811186
self.assertEqual(resp, (235, b'Authentication Succeeded'))
11821187
smtp.close()
11831188

1184-
# TODO: RUSTPYTHON
1185-
@unittest.expectedFailure
1189+
@mock.patch("hmac.HMAC")
1190+
@mock.patch("smtplib._have_cram_md5_support", False)
1191+
def testAUTH_CRAM_MD5_blocked(self, hmac_constructor):
1192+
# CRAM-MD5 is the only "known" method by the server,
1193+
# but it is not supported by the client. In particular,
1194+
# no challenge will ever be sent.
1195+
self.serv.add_feature("AUTH CRAM-MD5")
1196+
smtp = smtplib.SMTP(HOST, self.port, local_hostname='localhost',
1197+
timeout=support.LOOPBACK_TIMEOUT)
1198+
self.addCleanup(smtp.close)
1199+
msg = re.escape("No suitable authentication method found.")
1200+
with self.assertRaisesRegex(smtplib.SMTPException, msg):
1201+
smtp.login(sim_auth[0], sim_auth[1])
1202+
hmac_constructor.assert_not_called() # call has been bypassed
1203+
1204+
@mock.patch("smtplib._have_cram_md5_support", False)
1205+
def testAUTH_CRAM_MD5_blocked_and_fallback(self):
1206+
# Test that PLAIN is tried after CRAM-MD5 failed
1207+
self.serv.add_feature("AUTH CRAM-MD5 PLAIN")
1208+
smtp = smtplib.SMTP(HOST, self.port, local_hostname='localhost',
1209+
timeout=support.LOOPBACK_TIMEOUT)
1210+
self.addCleanup(smtp.close)
1211+
with (
1212+
mock.patch.object(smtp, "auth_cram_md5") as smtp_auth_cram_md5,
1213+
mock.patch.object(
1214+
smtp, "auth_plain", wraps=smtp.auth_plain
1215+
) as smtp_auth_plain
1216+
):
1217+
resp = smtp.login(sim_auth[0], sim_auth[1])
1218+
smtp_auth_plain.assert_called_once()
1219+
smtp_auth_cram_md5.assert_not_called() # no call to HMAC constructor
1220+
self.assertEqual(resp, (235, b'Authentication Succeeded'))
1221+
11861222
@hashlib_helper.requires_hashdigest('md5', openssl=True)
11871223
def testAUTH_multiple(self):
11881224
# Test that multiple authentication methods are tried.
@@ -1193,8 +1229,7 @@ def testAUTH_multiple(self):
11931229
self.assertEqual(resp, (235, b'Authentication Succeeded'))
11941230
smtp.close()
11951231

1196-
# TODO: RUSTPYTHON
1197-
@unittest.expectedFailure
1232+
@unittest.expectedFailure # TODO: RUSTPYTHON
11981233
def test_auth_function(self):
11991234
supported = {'PLAIN', 'LOGIN'}
12001235
try:
@@ -1354,6 +1389,18 @@ def test_name_field_not_included_in_envelop_addresses(self):
13541389
self.assertEqual(self.serv._addresses['from'], 'michael@example.com')
13551390
self.assertEqual(self.serv._addresses['tos'], ['rene@example.com'])
13561391

1392+
def test_lowercase_mail_from_rcpt_to(self):
1393+
m = 'A test message'
1394+
smtp = smtplib.SMTP(
1395+
HOST, self.port, local_hostname='localhost',
1396+
timeout=support.LOOPBACK_TIMEOUT)
1397+
self.addCleanup(smtp.close)
1398+
1399+
smtp.sendmail('John', 'Sally', m)
1400+
1401+
self.assertIn(['mail from:<John> size=14'], self.serv._SMTPchannel.all_received_lines)
1402+
self.assertIn(['rcpt to:<Sally>'], self.serv._SMTPchannel.all_received_lines)
1403+
13571404

13581405
class SimSMTPUTF8Server(SimSMTPServer):
13591406

@@ -1372,7 +1419,7 @@ def handle_accepted(self, conn, addr):
13721419
)
13731420

13741421
def process_message(self, peer, mailfrom, rcpttos, data, mail_options=None,
1375-
rcpt_options=None):
1422+
rcpt_options=None):
13761423
self.last_peer = peer
13771424
self.last_mailfrom = mailfrom
13781425
self.last_rcpttos = rcpttos

Lib/test/test_smtpnet.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,16 @@
22
from test import support
33
from test.support import import_helper
44
from test.support import socket_helper
5+
import os
56
import smtplib
67
import socket
78

89
ssl = import_helper.import_module("ssl")
910

1011
support.requires("network")
1112

13+
SMTP_TEST_SERVER = os.getenv('CPYTHON_TEST_SMTP_SERVER', 'smtp.gmail.com')
14+
1215
def check_ssl_verifiy(host, port):
1316
context = ssl.create_default_context()
1417
with socket.create_connection((host, port)) as sock:
@@ -22,7 +25,7 @@ def check_ssl_verifiy(host, port):
2225

2326

2427
class SmtpTest(unittest.TestCase):
25-
testServer = 'smtp.gmail.com'
28+
testServer = SMTP_TEST_SERVER
2629
remotePort = 587
2730

2831
def test_connect_starttls(self):
@@ -44,7 +47,7 @@ def test_connect_starttls(self):
4447

4548

4649
class SmtpSSLTest(unittest.TestCase):
47-
testServer = 'smtp.gmail.com'
50+
testServer = SMTP_TEST_SERVER
4851
remotePort = 465
4952

5053
def test_connect(self):
@@ -87,4 +90,4 @@ def test_connect_using_sslcontext_verified(self):
8790

8891

8992
if __name__ == "__main__":
90-
unittest.main()
93+
unittest.main()

0 commit comments

Comments
 (0)