Skip to content

Commit d6997bb

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 6fb5f7f commit d6997bb

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

@@ -830,6 +842,9 @@ def sendmail(self, from_addr, to_addrs, msg, mail_options=(),
830842
SMTPDataError The server replied with an unexpected
831843
error code (other than a refusal of
832844
a recipient).
845+
LMTPDataError The server replied with an unexpected
846+
error code (other than a refusal of
847+
a recipient) for ALL recipients.
833848
SMTPNotSupportedError The mail_options parameter includes 'SMTPUTF8'
834849
but the SMTPUTF8 extension is not supported by
835850
the server.
@@ -872,12 +887,15 @@ def sendmail(self, from_addr, to_addrs, msg, mail_options=(),
872887
else:
873888
self._rset()
874889
raise SMTPSenderRefused(code, resp, from_addr)
890+
rcpts = []
875891
senderrs = {}
876892
if isinstance(to_addrs, str):
877893
to_addrs = [to_addrs]
878894
for each in to_addrs:
879895
(code, resp) = self.rcpt(each, rcpt_options)
880-
if (code != 250) and (code != 251):
896+
if (code == 250) or (code == 251):
897+
rcpts.append(each)
898+
else:
881899
senderrs[each] = (code, resp)
882900
if code == 421:
883901
self.close()
@@ -886,13 +904,26 @@ def sendmail(self, from_addr, to_addrs, msg, mail_options=(),
886904
# the server refused all our recipients
887905
self._rset()
888906
raise SMTPRecipientsRefused(senderrs)
889-
(code, resp) = self.data(msg)
890-
if code != 250:
891-
if code == 421:
892-
self.close()
893-
else:
907+
if hasattr(self, 'multi_data'):
908+
rcpt_errs_size = len(senderrs)
909+
for rcpt, code, resp in self.multi_data(msg, rcpts):
910+
if code != 250:
911+
senderrs[rcpt] = (code, resp)
912+
if code == 421:
913+
self.close()
914+
raise LMTPDataError(senderrs)
915+
if rcpt_errs_size + len(rcpts) == len(senderrs):
916+
# the server refused all our recipients
894917
self._rset()
895-
raise SMTPDataError(code, resp)
918+
raise LMTPDataError(senderrs)
919+
else:
920+
code, resp = self.data(msg)
921+
if code != 250:
922+
if code == 421:
923+
self.close()
924+
else:
925+
self._rset()
926+
raise SMTPDataError(code, resp)
896927
#if we got here then somebody got our mail
897928
return senderrs
898929

@@ -1084,6 +1115,27 @@ def connect(self, host='localhost', port=0, source_address=None):
10841115
self._print_debug('connect:', msg)
10851116
return (code, msg)
10861117

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

10881140
# Test the sendmail method, which tests most of the others.
10891141
# 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
@@ -1295,7 +1295,25 @@ def found_terminator(self):
12951295
with self.assertRaises(smtplib.SMTPDataError):
12961296
smtp.sendmail('[email protected]', ['[email protected]'], 'test message')
12971297
self.assertIsNone(smtp.sock)
1298-
self.assertEqual(self.serv._SMTPchannel.rcpt_count, 0)
1298+
self.assertEqual(self.serv._SMTPchannel.rset_count, 0)
1299+
1300+
def test_421_from_multi_data_cmd(self):
1301+
class MySimSMTPChannel(SimSMTPChannel):
1302+
def found_terminator(self):
1303+
if self.smtp_state == self.DATA:
1304+
self.push('250 ok')
1305+
self.push('421 closing')
1306+
else:
1307+
super().found_terminator()
1308+
self.serv.channel_class = MySimSMTPChannel
1309+
smtp = smtplib.LMTP(HOST, self.port, local_hostname='localhost',
1310+
timeout=support.LOOPBACK_TIMEOUT)
1311+
smtp.noop()
1312+
with self.assertRaises(smtplib.LMTPDataError) as r:
1313+
smtp.sendmail('[email protected]', ['[email protected]', '[email protected]', '[email protected]'], 'test message')
1314+
self.assertEqual(r.exception.recipients, {'[email protected]': (421, b'closing')})
1315+
self.assertIsNone(smtp.sock)
1316+
self.assertEqual(self.serv._SMTPchannel.rset_count, 0)
12991317

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

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

13661447
class SimSMTPUTF8Server(SimSMTPServer):
13671448

Misc/ACKS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1250,6 +1250,7 @@ Jason Michalski
12501250
Franck Michea
12511251
Vincent Michel
12521252
Trent Mick
1253+
Jacob Middag
12531254
Tom Middleton
12541255
Thomas Miedema
12551256
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)