Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

gh-76984: Handle DATA correctly for LMTP with multiple RCPT #18896

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 60 additions & 8 deletions Lib/smtplib.py
Original file line number Diff line number Diff line change
@@ -53,7 +53,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"]

@@ -128,6 +128,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."""

@@ -830,6 +842,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.
@@ -872,12 +887,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()
@@ -886,13 +904,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

@@ -1084,6 +1115,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.
83 changes: 82 additions & 1 deletion Lib/test/test_smtplib.py
Original file line number Diff line number Diff line change
@@ -1295,7 +1295,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(
@@ -1362,6 +1380,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):

1 change: 1 addition & 0 deletions Misc/ACKS
Original file line number Diff line number Diff line change
@@ -1250,6 +1250,7 @@ Jason Michalski
Franck Michea
Vincent Michel
Trent Mick
Jacob Middag
Tom Middleton
Thomas Miedema
Stan Mihai
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.
Loading