Skip to content

Commit

Permalink
bpo-32803: Handle DATA correctly for LMTP with multiple RCPT (GH-76984)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
middagj committed Jan 19, 2024
1 parent efb81a6 commit 1bf8b36
Show file tree
Hide file tree
Showing 4 changed files with 145 additions and 9 deletions.
68 changes: 60 additions & 8 deletions Lib/smtplib.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@
from email.base64mime import body_encode as encode_base64

__all__ = ["SMTPException", "SMTPNotSupportedError", "SMTPServerDisconnected", "SMTPResponseException",
"SMTPSenderRefused", "SMTPRecipientsRefused", "SMTPDataError",
"SMTPSenderRefused", "SMTPRecipientsRefused", "SMTPDataError", "LMTPDataError",
"SMTPConnectError", "SMTPHeloError", "SMTPAuthenticationError",
"quoteaddr", "quotedata", "SMTP"]

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

class LMTPDataError(SMTPResponseException):
"""The LMTP server didn't accept the data.
The errors for each recipient are accessible through the attribute
'recipients', which is a dictionary of exactly the same sort as
SMTP.sendmail() returns.
"""

def __init__(self, recipients):
self.recipients = recipients
self.args = (recipients,)

class SMTPConnectError(SMTPResponseException):
"""Error during connection establishment."""

Expand Down Expand Up @@ -832,6 +844,9 @@ def sendmail(self, from_addr, to_addrs, msg, mail_options=(),
SMTPDataError The server replied with an unexpected
error code (other than a refusal of
a recipient).
LMTPDataError The server replied with an unexpected
error code (other than a refusal of
a recipient) for ALL recipients.
SMTPNotSupportedError The mail_options parameter includes 'SMTPUTF8'
but the SMTPUTF8 extension is not supported by
the server.
Expand Down Expand Up @@ -874,12 +889,15 @@ def sendmail(self, from_addr, to_addrs, msg, mail_options=(),
else:
self._rset()
raise SMTPSenderRefused(code, resp, from_addr)
rcpts = []
senderrs = {}
if isinstance(to_addrs, str):
to_addrs = [to_addrs]
for each in to_addrs:
(code, resp) = self.rcpt(each, rcpt_options)
if (code != 250) and (code != 251):
if (code == 250) or (code == 251):
rcpts.append(each)
else:
senderrs[each] = (code, resp)
if code == 421:
self.close()
Expand All @@ -888,13 +906,26 @@ def sendmail(self, from_addr, to_addrs, msg, mail_options=(),
# the server refused all our recipients
self._rset()
raise SMTPRecipientsRefused(senderrs)
(code, resp) = self.data(msg)
if code != 250:
if code == 421:
self.close()
else:
if hasattr(self, 'multi_data'):
rcpt_errs_size = len(senderrs)
for rcpt, code, resp in self.multi_data(msg, rcpts):
if code != 250:
senderrs[rcpt] = (code, resp)
if code == 421:
self.close()
raise LMTPDataError(senderrs)
if rcpt_errs_size + len(rcpts) == len(senderrs):
# the server refused all our recipients
self._rset()
raise SMTPDataError(code, resp)
raise LMTPDataError(senderrs)
else:
code, resp = self.data(msg)
if code != 250:
if code == 421:
self.close()
else:
self._rset()
raise SMTPDataError(code, resp)
#if we got here then somebody got our mail
return senderrs

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

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


# Test the sendmail method, which tests most of the others.
# Note: This always sends to localhost.
Expand Down
83 changes: 82 additions & 1 deletion Lib/test/test_smtplib.py
Original file line number Diff line number Diff line change
Expand Up @@ -1296,7 +1296,25 @@ def found_terminator(self):
with self.assertRaises(smtplib.SMTPDataError):
smtp.sendmail('John@foo.org', ['Sally@foo.org'], 'test message')
self.assertIsNone(smtp.sock)
self.assertEqual(self.serv._SMTPchannel.rcpt_count, 0)
self.assertEqual(self.serv._SMTPchannel.rset_count, 0)

def test_421_from_multi_data_cmd(self):
class MySimSMTPChannel(SimSMTPChannel):
def found_terminator(self):
if self.smtp_state == self.DATA:
self.push('250 ok')
self.push('421 closing')
else:
super().found_terminator()
self.serv.channel_class = MySimSMTPChannel
smtp = smtplib.LMTP(HOST, self.port, local_hostname='localhost',
timeout=support.LOOPBACK_TIMEOUT)
smtp.noop()
with self.assertRaises(smtplib.LMTPDataError) as r:
smtp.sendmail('John@foo.org', ['Sally@foo.org', 'Frank@foo.org', 'George@foo.org'], 'test message')
self.assertEqual(r.exception.recipients, {'Frank@foo.org': (421, b'closing')})
self.assertIsNone(smtp.sock)
self.assertEqual(self.serv._SMTPchannel.rset_count, 0)

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

def test_lmtp_multi_error(self):
class MySimSMTPChannel(SimSMTPChannel):
def found_terminator(self):
if self.smtp_state == self.DATA:
self.push('452 full')
self.push('250 ok')
else:
super().found_terminator()
def smtp_RCPT(self, arg):
if self.rcpt_count == 0:
self.rcpt_count += 1
self.push('450 busy')
else:
super().smtp_RCPT(arg)
self.serv.channel_class = MySimSMTPChannel

smtp = smtplib.LMTP(
HOST, self.port, local_hostname='localhost',
timeout=support.LOOPBACK_TIMEOUT)
self.addCleanup(smtp.close)

message = EmailMessage()
message['From'] = 'John@foo.org'
message['To'] = 'Sally@foo.org, Frank@foo.org, George@foo.org'

self.assertDictEqual(smtp.send_message(message), {
'Sally@foo.org': (450, b'busy'), 'Frank@foo.org': (452, b'full')
})

def test_lmtp_all_error(self):
class MySimSMTPChannel(SimSMTPChannel):
def found_terminator(self):
if self.smtp_state == self.DATA:
self.push('452 full')
self.received_lines = []
self.smtp_state = self.COMMAND
self.set_terminator(b'\r\n')
else:
super().found_terminator()
def smtp_RCPT(self, arg):
if self.rcpt_count == 0:
self.rcpt_count += 1
self.push('450 busy')
else:
super().smtp_RCPT(arg)
self.serv.channel_class = MySimSMTPChannel

smtp = smtplib.LMTP(
HOST, self.port, local_hostname='localhost',
timeout=support.LOOPBACK_TIMEOUT)
self.addCleanup(smtp.close)

message = EmailMessage()
message['From'] = 'John@foo.org'
message['To'] = 'Sally@foo.org, Frank@foo.org'

with self.assertRaises(smtplib.LMTPDataError) as r:
smtp.send_message(message)
self.assertEqual(r.exception.recipients, {
'Sally@foo.org': (450, b'busy'), 'Frank@foo.org': (452, b'full')
})
self.assertEqual(self.serv._SMTPchannel.rset_count, 1)


class SimSMTPUTF8Server(SimSMTPServer):

Expand Down
1 change: 1 addition & 0 deletions Misc/ACKS
Original file line number Diff line number Diff line change
Expand Up @@ -1220,6 +1220,7 @@ Jason Michalski
Franck Michea
Vincent Michel
Trent Mick
Jacob Middag
Tom Middleton
Thomas Miedema
Stan Mihai
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
:class:`smtplib.LMTP` now reads all replies to the DATA command when a
message has multiple successful recipients. Patch by Jacob Middag.

0 comments on commit 1bf8b36

Please sign in to comment.