Skip to content

Commit 1bf8b36

Browse files
committed
bpo-32803: Handle DATA correctly for LMTP with multiple RCPT (GH-76984)
Conform RFC 2033, the LMTP protocol gives for each successful recipient a reply. The smtplib only reads one. This gives problems sending more than one message with multiple recipients in a connection.
1 parent efb81a6 commit 1bf8b36

File tree

4 files changed

+145
-9
lines changed

4 files changed

+145
-9
lines changed

Lib/smtplib.py

Lines changed: 60 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@
5555
from email.base64mime import body_encode as encode_base64
5656

5757
__all__ = ["SMTPException", "SMTPNotSupportedError", "SMTPServerDisconnected", "SMTPResponseException",
58-
"SMTPSenderRefused", "SMTPRecipientsRefused", "SMTPDataError",
58+
"SMTPSenderRefused", "SMTPRecipientsRefused", "SMTPDataError", "LMTPDataError",
5959
"SMTPConnectError", "SMTPHeloError", "SMTPAuthenticationError",
6060
"quoteaddr", "quotedata", "SMTP"]
6161

@@ -130,6 +130,18 @@ def __init__(self, recipients):
130130
class SMTPDataError(SMTPResponseException):
131131
"""The SMTP server didn't accept the data."""
132132

133+
class LMTPDataError(SMTPResponseException):
134+
"""The LMTP server didn't accept the data.
135+
136+
The errors for each recipient are accessible through the attribute
137+
'recipients', which is a dictionary of exactly the same sort as
138+
SMTP.sendmail() returns.
139+
"""
140+
141+
def __init__(self, recipients):
142+
self.recipients = recipients
143+
self.args = (recipients,)
144+
133145
class SMTPConnectError(SMTPResponseException):
134146
"""Error during connection establishment."""
135147

@@ -832,6 +844,9 @@ def sendmail(self, from_addr, to_addrs, msg, mail_options=(),
832844
SMTPDataError The server replied with an unexpected
833845
error code (other than a refusal of
834846
a recipient).
847+
LMTPDataError The server replied with an unexpected
848+
error code (other than a refusal of
849+
a recipient) for ALL recipients.
835850
SMTPNotSupportedError The mail_options parameter includes 'SMTPUTF8'
836851
but the SMTPUTF8 extension is not supported by
837852
the server.
@@ -874,12 +889,15 @@ def sendmail(self, from_addr, to_addrs, msg, mail_options=(),
874889
else:
875890
self._rset()
876891
raise SMTPSenderRefused(code, resp, from_addr)
892+
rcpts = []
877893
senderrs = {}
878894
if isinstance(to_addrs, str):
879895
to_addrs = [to_addrs]
880896
for each in to_addrs:
881897
(code, resp) = self.rcpt(each, rcpt_options)
882-
if (code != 250) and (code != 251):
898+
if (code == 250) or (code == 251):
899+
rcpts.append(each)
900+
else:
883901
senderrs[each] = (code, resp)
884902
if code == 421:
885903
self.close()
@@ -888,13 +906,26 @@ def sendmail(self, from_addr, to_addrs, msg, mail_options=(),
888906
# the server refused all our recipients
889907
self._rset()
890908
raise SMTPRecipientsRefused(senderrs)
891-
(code, resp) = self.data(msg)
892-
if code != 250:
893-
if code == 421:
894-
self.close()
895-
else:
909+
if hasattr(self, 'multi_data'):
910+
rcpt_errs_size = len(senderrs)
911+
for rcpt, code, resp in self.multi_data(msg, rcpts):
912+
if code != 250:
913+
senderrs[rcpt] = (code, resp)
914+
if code == 421:
915+
self.close()
916+
raise LMTPDataError(senderrs)
917+
if rcpt_errs_size + len(rcpts) == len(senderrs):
918+
# the server refused all our recipients
896919
self._rset()
897-
raise SMTPDataError(code, resp)
920+
raise LMTPDataError(senderrs)
921+
else:
922+
code, resp = self.data(msg)
923+
if code != 250:
924+
if code == 421:
925+
self.close()
926+
else:
927+
self._rset()
928+
raise SMTPDataError(code, resp)
898929
#if we got here then somebody got our mail
899930
return senderrs
900931

@@ -1086,6 +1117,27 @@ def connect(self, host='localhost', port=0, source_address=None):
10861117
self._print_debug('connect:', msg)
10871118
return (code, msg)
10881119

1120+
def multi_data(self, msg, rcpts):
1121+
"""SMTP 'DATA' command -- sends message data to server
1122+
1123+
Differs from data in that it yields multiple results for each
1124+
recipient. This is necessary for LMTP processing and different
1125+
from SMTP processing.
1126+
1127+
Automatically quotes lines beginning with a period per rfc821.
1128+
Raises SMTPDataError if there is an unexpected reply to the
1129+
DATA command; the return value from this method is the final
1130+
response code received when the all data is sent. If msg
1131+
is a string, lone '\\r' and '\\n' characters are converted to
1132+
'\\r\\n' characters. If msg is bytes, it is transmitted as is.
1133+
"""
1134+
yield (rcpts[0],) + super().data(msg)
1135+
for rcpt in rcpts[1:]:
1136+
(code, msg) = self.getreply()
1137+
if self.debuglevel > 0:
1138+
self._print_debug('connect:', msg)
1139+
yield (rcpt, code, msg)
1140+
10891141

10901142
# Test the sendmail method, which tests most of the others.
10911143
# Note: This always sends to localhost.

Lib/test/test_smtplib.py

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1296,7 +1296,25 @@ def found_terminator(self):
12961296
with self.assertRaises(smtplib.SMTPDataError):
12971297
smtp.sendmail('[email protected]', ['[email protected]'], 'test message')
12981298
self.assertIsNone(smtp.sock)
1299-
self.assertEqual(self.serv._SMTPchannel.rcpt_count, 0)
1299+
self.assertEqual(self.serv._SMTPchannel.rset_count, 0)
1300+
1301+
def test_421_from_multi_data_cmd(self):
1302+
class MySimSMTPChannel(SimSMTPChannel):
1303+
def found_terminator(self):
1304+
if self.smtp_state == self.DATA:
1305+
self.push('250 ok')
1306+
self.push('421 closing')
1307+
else:
1308+
super().found_terminator()
1309+
self.serv.channel_class = MySimSMTPChannel
1310+
smtp = smtplib.LMTP(HOST, self.port, local_hostname='localhost',
1311+
timeout=support.LOOPBACK_TIMEOUT)
1312+
smtp.noop()
1313+
with self.assertRaises(smtplib.LMTPDataError) as r:
1314+
smtp.sendmail('[email protected]', ['[email protected]', '[email protected]', '[email protected]'], 'test message')
1315+
self.assertEqual(r.exception.recipients, {'[email protected]': (421, b'closing')})
1316+
self.assertIsNone(smtp.sock)
1317+
self.assertEqual(self.serv._SMTPchannel.rset_count, 0)
13001318

13011319
def test_smtputf8_NotSupportedError_if_no_server_support(self):
13021320
smtp = smtplib.SMTP(
@@ -1363,6 +1381,69 @@ def test_lowercase_mail_from_rcpt_to(self):
13631381
self.assertIn(['mail from:<John> size=14'], self.serv._SMTPchannel.all_received_lines)
13641382
self.assertIn(['rcpt to:<Sally>'], self.serv._SMTPchannel.all_received_lines)
13651383

1384+
def test_lmtp_multi_error(self):
1385+
class MySimSMTPChannel(SimSMTPChannel):
1386+
def found_terminator(self):
1387+
if self.smtp_state == self.DATA:
1388+
self.push('452 full')
1389+
self.push('250 ok')
1390+
else:
1391+
super().found_terminator()
1392+
def smtp_RCPT(self, arg):
1393+
if self.rcpt_count == 0:
1394+
self.rcpt_count += 1
1395+
self.push('450 busy')
1396+
else:
1397+
super().smtp_RCPT(arg)
1398+
self.serv.channel_class = MySimSMTPChannel
1399+
1400+
smtp = smtplib.LMTP(
1401+
HOST, self.port, local_hostname='localhost',
1402+
timeout=support.LOOPBACK_TIMEOUT)
1403+
self.addCleanup(smtp.close)
1404+
1405+
message = EmailMessage()
1406+
message['From'] = '[email protected]'
1407+
1408+
1409+
self.assertDictEqual(smtp.send_message(message), {
1410+
'[email protected]': (450, b'busy'), '[email protected]': (452, b'full')
1411+
})
1412+
1413+
def test_lmtp_all_error(self):
1414+
class MySimSMTPChannel(SimSMTPChannel):
1415+
def found_terminator(self):
1416+
if self.smtp_state == self.DATA:
1417+
self.push('452 full')
1418+
self.received_lines = []
1419+
self.smtp_state = self.COMMAND
1420+
self.set_terminator(b'\r\n')
1421+
else:
1422+
super().found_terminator()
1423+
def smtp_RCPT(self, arg):
1424+
if self.rcpt_count == 0:
1425+
self.rcpt_count += 1
1426+
self.push('450 busy')
1427+
else:
1428+
super().smtp_RCPT(arg)
1429+
self.serv.channel_class = MySimSMTPChannel
1430+
1431+
smtp = smtplib.LMTP(
1432+
HOST, self.port, local_hostname='localhost',
1433+
timeout=support.LOOPBACK_TIMEOUT)
1434+
self.addCleanup(smtp.close)
1435+
1436+
message = EmailMessage()
1437+
message['From'] = '[email protected]'
1438+
1439+
1440+
with self.assertRaises(smtplib.LMTPDataError) as r:
1441+
smtp.send_message(message)
1442+
self.assertEqual(r.exception.recipients, {
1443+
'[email protected]': (450, b'busy'), '[email protected]': (452, b'full')
1444+
})
1445+
self.assertEqual(self.serv._SMTPchannel.rset_count, 1)
1446+
13661447

13671448
class SimSMTPUTF8Server(SimSMTPServer):
13681449

Misc/ACKS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1220,6 +1220,7 @@ Jason Michalski
12201220
Franck Michea
12211221
Vincent Michel
12221222
Trent Mick
1223+
Jacob Middag
12231224
Tom Middleton
12241225
Thomas Miedema
12251226
Stan Mihai
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
:class:`smtplib.LMTP` now reads all replies to the DATA command when a
2+
message has multiple successful recipients. Patch by Jacob Middag.

0 commit comments

Comments
 (0)