From 5e6e47ae7501284e9f460925e3be2fb578604c01 Mon Sep 17 00:00:00 2001 From: Jacob Middag Date: Thu, 2 Nov 2023 17:17:56 +0100 Subject: [PATCH] gh-76984: 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. --- Lib/smtplib.py | 68 +++++++++++++-- Lib/test/test_smtplib.py | 83 ++++++++++++++++++- Misc/ACKS | 1 + ...0-03-10-09-21-29.gh-issue-76984.czXc6a.rst | 2 + 4 files changed, 145 insertions(+), 9 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2020-03-10-09-21-29.gh-issue-76984.czXc6a.rst diff --git a/Lib/smtplib.py b/Lib/smtplib.py index b3cc68a789a7d8e..292d5bd774d3a5d 100755 --- a/Lib/smtplib.py +++ b/Lib/smtplib.py @@ -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"] @@ -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.""" @@ -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. @@ -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() @@ -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 @@ -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. diff --git a/Lib/test/test_smtplib.py b/Lib/test/test_smtplib.py index f2e02dab1c3ca53..8f13abc4d5c9041 100644 --- a/Lib/test/test_smtplib.py +++ b/Lib/test/test_smtplib.py @@ -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( @@ -1363,6 +1381,69 @@ def test_lowercase_mail_from_rcpt_to(self): self.assertIn(['mail from: size=14'], self.serv._SMTPchannel.all_received_lines) self.assertIn(['rcpt to:'], 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): diff --git a/Misc/ACKS b/Misc/ACKS index 812aa1be6e796a0..496c1e1e4e2e66a 100644 --- a/Misc/ACKS +++ b/Misc/ACKS @@ -1215,6 +1215,7 @@ Jason Michalski Franck Michea Vincent Michel Trent Mick +Jacob Middag Tom Middleton Thomas Miedema Stan Mihai diff --git a/Misc/NEWS.d/next/Library/2020-03-10-09-21-29.gh-issue-76984.czXc6a.rst b/Misc/NEWS.d/next/Library/2020-03-10-09-21-29.gh-issue-76984.czXc6a.rst new file mode 100644 index 000000000000000..55db76dd008e566 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2020-03-10-09-21-29.gh-issue-76984.czXc6a.rst @@ -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.