Skip to content

Commit 5afbc30

Browse files
committed
bpo-32803: Handle DATA correctly for LMTP with multiple RCPT
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 700cb58 commit 5afbc30

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

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

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

@@ -827,6 +839,9 @@ def sendmail(self, from_addr, to_addrs, msg, mail_options=(),
827839
SMTPDataError The server replied with an unexpected
828840
error code (other than a refusal of
829841
a recipient).
842+
LMTPDataError The server replied with an unexpected
843+
error code (other than a refusal of
844+
a recipient) for ALL recipients.
830845
SMTPNotSupportedError The mail_options parameter includes 'SMTPUTF8'
831846
but the SMTPUTF8 extension is not supported by
832847
the server.
@@ -869,12 +884,15 @@ def sendmail(self, from_addr, to_addrs, msg, mail_options=(),
869884
else:
870885
self._rset()
871886
raise SMTPSenderRefused(code, resp, from_addr)
887+
rcpts = []
872888
senderrs = {}
873889
if isinstance(to_addrs, str):
874890
to_addrs = [to_addrs]
875891
for each in to_addrs:
876892
(code, resp) = self.rcpt(each, rcpt_options)
877-
if (code != 250) and (code != 251):
893+
if (code == 250) or (code == 251):
894+
rcpts.append(each)
895+
else:
878896
senderrs[each] = (code, resp)
879897
if code == 421:
880898
self.close()
@@ -883,13 +901,26 @@ def sendmail(self, from_addr, to_addrs, msg, mail_options=(),
883901
# the server refused all our recipients
884902
self._rset()
885903
raise SMTPRecipientsRefused(senderrs)
886-
(code, resp) = self.data(msg)
887-
if code != 250:
888-
if code == 421:
889-
self.close()
890-
else:
904+
if hasattr(self, 'multi_data'):
905+
rcpt_errs_size = len(senderrs)
906+
for rcpt, code, resp in self.multi_data(msg, rcpts):
907+
if code != 250:
908+
senderrs[rcpt] = (code, resp)
909+
if code == 421:
910+
self.close()
911+
raise LMTPDataError(senderrs)
912+
if rcpt_errs_size + len(rcpts) == len(senderrs):
913+
# the server refused all our recipients
891914
self._rset()
892-
raise SMTPDataError(code, resp)
915+
raise LMTPDataError(senderrs)
916+
else:
917+
code, resp = self.data(msg)
918+
if code != 250:
919+
if code == 421:
920+
self.close()
921+
else:
922+
self._rset()
923+
raise SMTPDataError(code, resp)
893924
#if we got here then somebody got our mail
894925
return senderrs
895926

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

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

11011153
# Test the sendmail method, which tests most of the others.
11021154
# 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
@@ -1179,7 +1179,25 @@ def found_terminator(self):
11791179
with self.assertRaises(smtplib.SMTPDataError):
11801180
smtp.sendmail('John@foo.org', ['Sally@foo.org'], 'test message')
11811181
self.assertIsNone(smtp.sock)
1182-
self.assertEqual(self.serv._SMTPchannel.rcpt_count, 0)
1182+
self.assertEqual(self.serv._SMTPchannel.rset_count, 0)
1183+
1184+
def test_421_from_multi_data_cmd(self):
1185+
class MySimSMTPChannel(SimSMTPChannel):
1186+
def found_terminator(self):
1187+
if self.smtp_state == self.DATA:
1188+
self.push('250 ok')
1189+
self.push('421 closing')
1190+
else:
1191+
super().found_terminator()
1192+
self.serv.channel_class = MySimSMTPChannel
1193+
smtp = smtplib.LMTP(HOST, self.port, local_hostname='localhost',
1194+
timeout=support.LOOPBACK_TIMEOUT)
1195+
smtp.noop()
1196+
with self.assertRaises(smtplib.LMTPDataError) as r:
1197+
smtp.sendmail('John@foo.org', ['Sally@foo.org', 'Frank@foo.org', 'George@foo.org'], 'test message')
1198+
self.assertEqual(r.exception.recipients, {'Frank@foo.org': (421, b'closing')})
1199+
self.assertIsNone(smtp.sock)
1200+
self.assertEqual(self.serv._SMTPchannel.rset_count, 0)
11831201

11841202
def test_smtputf8_NotSupportedError_if_no_server_support(self):
11851203
smtp = smtplib.SMTP(
@@ -1234,6 +1252,69 @@ def test_name_field_not_included_in_envelop_addresses(self):
12341252
self.assertEqual(self.serv._addresses['from'], 'michael@example.com')
12351253
self.assertEqual(self.serv._addresses['tos'], ['rene@example.com'])
12361254

1255+
def test_lmtp_multi_error(self):
1256+
class MySimSMTPChannel(SimSMTPChannel):
1257+
def found_terminator(self):
1258+
if self.smtp_state == self.DATA:
1259+
self.push('452 full')
1260+
self.push('250 ok')
1261+
else:
1262+
super().found_terminator()
1263+
def smtp_RCPT(self, arg):
1264+
if self.rcpt_count == 0:
1265+
self.rcpt_count += 1
1266+
self.push('450 busy')
1267+
else:
1268+
super().smtp_RCPT(arg)
1269+
self.serv.channel_class = MySimSMTPChannel
1270+
1271+
smtp = smtplib.LMTP(
1272+
HOST, self.port, local_hostname='localhost',
1273+
timeout=support.LOOPBACK_TIMEOUT)
1274+
self.addCleanup(smtp.close)
1275+
1276+
message = EmailMessage()
1277+
message['From'] = 'John@foo.org'
1278+
message['To'] = 'Sally@foo.org, Frank@foo.org, George@foo.org'
1279+
1280+
self.assertDictEqual(smtp.send_message(message), {
1281+
'Sally@foo.org': (450, b'busy'), 'Frank@foo.org': (452, b'full')
1282+
})
1283+
1284+
def test_lmtp_all_error(self):
1285+
class MySimSMTPChannel(SimSMTPChannel):
1286+
def found_terminator(self):
1287+
if self.smtp_state == self.DATA:
1288+
self.push('452 full')
1289+
self.received_lines = []
1290+
self.smtp_state = self.COMMAND
1291+
self.set_terminator(b'\r\n')
1292+
else:
1293+
super().found_terminator()
1294+
def smtp_RCPT(self, arg):
1295+
if self.rcpt_count == 0:
1296+
self.rcpt_count += 1
1297+
self.push('450 busy')
1298+
else:
1299+
super().smtp_RCPT(arg)
1300+
self.serv.channel_class = MySimSMTPChannel
1301+
1302+
smtp = smtplib.LMTP(
1303+
HOST, self.port, local_hostname='localhost',
1304+
timeout=support.LOOPBACK_TIMEOUT)
1305+
self.addCleanup(smtp.close)
1306+
1307+
message = EmailMessage()
1308+
message['From'] = 'John@foo.org'
1309+
message['To'] = 'Sally@foo.org, Frank@foo.org'
1310+
1311+
with self.assertRaises(smtplib.LMTPDataError) as r:
1312+
smtp.send_message(message)
1313+
self.assertEqual(r.exception.recipients, {
1314+
'Sally@foo.org': (450, b'busy'), 'Frank@foo.org': (452, b'full')
1315+
})
1316+
self.assertEqual(self.serv._SMTPchannel.rset_count, 1)
1317+
12371318

12381319
class SimSMTPUTF8Server(SimSMTPServer):
12391320

Misc/ACKS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1126,6 +1126,7 @@ Jason Michalski
11261126
Franck Michea
11271127
Vincent Michel
11281128
Trent Mick
1129+
Jacob Middag
11291130
Tom Middleton
11301131
Thomas Miedema
11311132
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)