Skip to content

Commit 607ffa2

Browse files
committed
gh-76984: Handle DATA correctly for LMTP with multiple RCPT (bpo-32803)
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 11a5fc8 commit 607ffa2

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
@@ -53,7 +53,7 @@
5353
from email.base64mime import body_encode as encode_base64
5454

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

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

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

@@ -844,6 +856,9 @@ def sendmail(self, from_addr, to_addrs, msg, mail_options=(),
844856
SMTPDataError The server replied with an unexpected
845857
error code (other than a refusal of
846858
a recipient).
859+
LMTPDataError The server replied with an unexpected
860+
error code (other than a refusal of
861+
a recipient) for ALL recipients.
847862
SMTPNotSupportedError The mail_options parameter includes 'SMTPUTF8'
848863
but the SMTPUTF8 extension is not supported by
849864
the server.
@@ -886,12 +901,15 @@ def sendmail(self, from_addr, to_addrs, msg, mail_options=(),
886901
else:
887902
self._rset()
888903
raise SMTPSenderRefused(code, resp, from_addr)
904+
rcpts = []
889905
senderrs = {}
890906
if isinstance(to_addrs, str):
891907
to_addrs = [to_addrs]
892908
for each in to_addrs:
893909
(code, resp) = self.rcpt(each, rcpt_options)
894-
if (code != 250) and (code != 251):
910+
if (code == 250) or (code == 251):
911+
rcpts.append(each)
912+
else:
895913
senderrs[each] = (code, resp)
896914
if code == 421:
897915
self.close()
@@ -900,13 +918,26 @@ def sendmail(self, from_addr, to_addrs, msg, mail_options=(),
900918
# the server refused all our recipients
901919
self._rset()
902920
raise SMTPRecipientsRefused(senderrs)
903-
(code, resp) = self.data(msg)
904-
if code != 250:
905-
if code == 421:
906-
self.close()
907-
else:
921+
if hasattr(self, 'multi_data'):
922+
rcpt_errs_size = len(senderrs)
923+
for rcpt, code, resp in self.multi_data(msg, rcpts):
924+
if code != 250:
925+
senderrs[rcpt] = (code, resp)
926+
if code == 421:
927+
self.close()
928+
raise LMTPDataError(senderrs)
929+
if rcpt_errs_size + len(rcpts) == len(senderrs):
930+
# the server refused all our recipients
908931
self._rset()
909-
raise SMTPDataError(code, resp)
932+
raise LMTPDataError(senderrs)
933+
else:
934+
code, resp = self.data(msg)
935+
if code != 250:
936+
if code == 421:
937+
self.close()
938+
else:
939+
self._rset()
940+
raise SMTPDataError(code, resp)
910941
#if we got here then somebody got our mail
911942
return senderrs
912943

@@ -1098,6 +1129,27 @@ def connect(self, host='localhost', port=0, source_address=None):
10981129
self._print_debug('connect:', msg)
10991130
return (code, msg)
11001131

1132+
def multi_data(self, msg, rcpts):
1133+
"""SMTP 'DATA' command -- sends message data to server
1134+
1135+
Differs from data in that it yields multiple results for each
1136+
recipient. This is necessary for LMTP processing and different
1137+
from SMTP processing.
1138+
1139+
Automatically quotes lines beginning with a period per rfc821.
1140+
Raises SMTPDataError if there is an unexpected reply to the
1141+
DATA command; the return value from this method is the final
1142+
response code received when the all data is sent. If msg
1143+
is a string, lone '\\r' and '\\n' characters are converted to
1144+
'\\r\\n' characters. If msg is bytes, it is transmitted as is.
1145+
"""
1146+
yield (rcpts[0],) + super().data(msg)
1147+
for rcpt in rcpts[1:]:
1148+
(code, msg) = self.getreply()
1149+
if self.debuglevel > 0:
1150+
self._print_debug('connect:', msg)
1151+
yield (rcpt, code, msg)
1152+
11011153

11021154
# Test the sendmail method, which tests most of the others.
11031155
# 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
@@ -1332,7 +1332,25 @@ def found_terminator(self):
13321332
with self.assertRaises(smtplib.SMTPDataError):
13331333
smtp.sendmail('[email protected]', ['[email protected]'], 'test message')
13341334
self.assertIsNone(smtp.sock)
1335-
self.assertEqual(self.serv._SMTPchannel.rcpt_count, 0)
1335+
self.assertEqual(self.serv._SMTPchannel.rset_count, 0)
1336+
1337+
def test_421_from_multi_data_cmd(self):
1338+
class MySimSMTPChannel(SimSMTPChannel):
1339+
def found_terminator(self):
1340+
if self.smtp_state == self.DATA:
1341+
self.push('250 ok')
1342+
self.push('421 closing')
1343+
else:
1344+
super().found_terminator()
1345+
self.serv.channel_class = MySimSMTPChannel
1346+
smtp = smtplib.LMTP(HOST, self.port, local_hostname='localhost',
1347+
timeout=support.LOOPBACK_TIMEOUT)
1348+
smtp.noop()
1349+
with self.assertRaises(smtplib.LMTPDataError) as r:
1350+
smtp.sendmail('[email protected]', ['[email protected]', '[email protected]', '[email protected]'], 'test message')
1351+
self.assertEqual(r.exception.recipients, {'[email protected]': (421, b'closing')})
1352+
self.assertIsNone(smtp.sock)
1353+
self.assertEqual(self.serv._SMTPchannel.rset_count, 0)
13361354

13371355
def test_smtputf8_NotSupportedError_if_no_server_support(self):
13381356
smtp = smtplib.SMTP(
@@ -1399,6 +1417,69 @@ def test_lowercase_mail_from_rcpt_to(self):
13991417
self.assertIn(['mail from:<John> size=14'], self.serv._SMTPchannel.all_received_lines)
14001418
self.assertIn(['rcpt to:<Sally>'], self.serv._SMTPchannel.all_received_lines)
14011419

1420+
def test_lmtp_multi_error(self):
1421+
class MySimSMTPChannel(SimSMTPChannel):
1422+
def found_terminator(self):
1423+
if self.smtp_state == self.DATA:
1424+
self.push('452 full')
1425+
self.push('250 ok')
1426+
else:
1427+
super().found_terminator()
1428+
def smtp_RCPT(self, arg):
1429+
if self.rcpt_count == 0:
1430+
self.rcpt_count += 1
1431+
self.push('450 busy')
1432+
else:
1433+
super().smtp_RCPT(arg)
1434+
self.serv.channel_class = MySimSMTPChannel
1435+
1436+
smtp = smtplib.LMTP(
1437+
HOST, self.port, local_hostname='localhost',
1438+
timeout=support.LOOPBACK_TIMEOUT)
1439+
self.addCleanup(smtp.close)
1440+
1441+
message = EmailMessage()
1442+
message['From'] = '[email protected]'
1443+
1444+
1445+
self.assertDictEqual(smtp.send_message(message), {
1446+
'[email protected]': (450, b'busy'), '[email protected]': (452, b'full')
1447+
})
1448+
1449+
def test_lmtp_all_error(self):
1450+
class MySimSMTPChannel(SimSMTPChannel):
1451+
def found_terminator(self):
1452+
if self.smtp_state == self.DATA:
1453+
self.push('452 full')
1454+
self.received_lines = []
1455+
self.smtp_state = self.COMMAND
1456+
self.set_terminator(b'\r\n')
1457+
else:
1458+
super().found_terminator()
1459+
def smtp_RCPT(self, arg):
1460+
if self.rcpt_count == 0:
1461+
self.rcpt_count += 1
1462+
self.push('450 busy')
1463+
else:
1464+
super().smtp_RCPT(arg)
1465+
self.serv.channel_class = MySimSMTPChannel
1466+
1467+
smtp = smtplib.LMTP(
1468+
HOST, self.port, local_hostname='localhost',
1469+
timeout=support.LOOPBACK_TIMEOUT)
1470+
self.addCleanup(smtp.close)
1471+
1472+
message = EmailMessage()
1473+
message['From'] = '[email protected]'
1474+
1475+
1476+
with self.assertRaises(smtplib.LMTPDataError) as r:
1477+
smtp.send_message(message)
1478+
self.assertEqual(r.exception.recipients, {
1479+
'[email protected]': (450, b'busy'), '[email protected]': (452, b'full')
1480+
})
1481+
self.assertEqual(self.serv._SMTPchannel.rset_count, 1)
1482+
14021483

14031484
class SimSMTPUTF8Server(SimSMTPServer):
14041485

Misc/ACKS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1259,6 +1259,7 @@ Jason Michalski
12591259
Franck Michea
12601260
Vincent Michel
12611261
Trent Mick
1262+
Jacob Middag
12621263
Tom Middleton
12631264
Thomas Miedema
12641265
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)