diff options
-rw-r--r-- | Doc/library/smtpd.rst | 20 | ||||
-rwxr-xr-x | Lib/smtpd.py | 245 | ||||
-rw-r--r-- | Lib/test/test_logging.py | 3 | ||||
-rw-r--r-- | Lib/test/test_smtpd.py | 280 | ||||
-rw-r--r-- | Lib/test/test_smtplib.py | 19 | ||||
-rw-r--r-- | Misc/ACKS | 3 | ||||
-rw-r--r-- | Misc/NEWS | 3 |
7 files changed, 482 insertions, 91 deletions
diff --git a/Doc/library/smtpd.rst b/Doc/library/smtpd.rst index c391f71..82f4763 100644 --- a/Doc/library/smtpd.rst +++ b/Doc/library/smtpd.rst @@ -20,17 +20,24 @@ specific mail-sending strategies. Additionally the SMTPChannel may be extended to implement very specific interaction behaviour with SMTP clients. +The code supports :RFC:`5321`, plus the :rfc:`1870` SIZE extension. + + SMTPServer Objects ------------------ -.. class:: SMTPServer(localaddr, remoteaddr) +.. class:: SMTPServer(localaddr, remoteaddr, data_size_limit=33554432) Create a new :class:`SMTPServer` object, which binds to local address *localaddr*. It will treat *remoteaddr* as an upstream SMTP relayer. It inherits from :class:`asyncore.dispatcher`, and so will insert itself into :mod:`asyncore`'s event loop on instantiation. + *data_size_limit* specifies the maximum number of bytes that will be + accepted in a ``DATA`` command. A value of ``None`` or ``0`` means no + limit. + .. method:: process_message(peer, mailfrom, rcpttos, data) Raise :exc:`NotImplementedError` exception. Override this in subclasses to @@ -155,11 +162,15 @@ SMTPChannel Objects Command Action taken ======== =================================================================== HELO Accepts the greeting from the client and stores it in - :attr:`seen_greeting`. + :attr:`seen_greeting`. Sets server to base command mode. + EHLO Accepts the greeting from the client and stores it in + :attr:`seen_greeting`. Sets server to extended command mode. NOOP Takes no action. QUIT Closes the connection cleanly. MAIL Accepts the "MAIL FROM:" syntax and stores the supplied address as - :attr:`mailfrom`. + :attr:`mailfrom`. In extended command mode, accepts the + :rfc:`1870` SIZE attribute and responds appropriately based on the + value of ``data_size_limit``. RCPT Accepts the "RCPT TO:" syntax and stores the supplied addresses in the :attr:`rcpttos` list. RSET Resets the :attr:`mailfrom`, :attr:`rcpttos`, and @@ -167,4 +178,7 @@ SMTPChannel Objects DATA Sets the internal state to :attr:`DATA` and stores remaining lines from the client in :attr:`received_data` until the terminator "\r\n.\r\n" is received. + HELP Returns minimal information on command syntax + VRFY Returns code 252 (the server doesn't know if the address is valid) + EXPN Reports that the command is not implemented. ======== =================================================================== diff --git a/Lib/smtpd.py b/Lib/smtpd.py index 748fcae..778d6d6 100755 --- a/Lib/smtpd.py +++ b/Lib/smtpd.py @@ -1,5 +1,5 @@ #! /usr/bin/env python3 -"""An RFC 2821 smtp proxy. +"""An RFC 5321 smtp proxy. Usage: %(program)s [options] [localhost:localport [remotehost:remoteport]] @@ -20,6 +20,11 @@ Options: Use `classname' as the concrete SMTP proxy class. Uses `PureProxy' by default. + --size limit + -s limit + Restrict the total size of the incoming message to "limit" number of + bytes via the RFC 1870 SIZE extension. Defaults to 33554432 bytes. + --debug -d Turn on debugging prints. @@ -35,10 +40,9 @@ given then 8025 is used. If remotehost is not given then `localhost' is used, and if remoteport is not given, then 25 is used. """ - # Overview: # -# This file implements the minimal SMTP protocol as defined in RFC 821. It +# This file implements the minimal SMTP protocol as defined in RFC 5321. It # has a hierarchy of classes which implement the backend functionality for the # smtpd. A number of classes are provided: # @@ -66,7 +70,7 @@ and if remoteport is not given, then 25 is used. # # - support mailbox delivery # - alias files -# - ESMTP +# - Handle more ESMTP extensions # - handle error codes from the backend smtpd import sys @@ -77,12 +81,14 @@ import time import socket import asyncore import asynchat +import collections from warnings import warn +from email._header_value_parser import get_addr_spec, get_angle_addr __all__ = ["SMTPServer","DebuggingServer","PureProxy","MailmanProxy"] program = sys.argv[0] -__version__ = 'Python SMTP proxy version 0.2' +__version__ = 'Python SMTP proxy version 0.3' class Devnull: @@ -94,9 +100,9 @@ DEBUGSTREAM = Devnull() NEWLINE = '\n' EMPTYSTRING = '' COMMASPACE = ', ' +DATA_SIZE_DEFAULT = 33554432 - def usage(code, msg=''): print(__doc__ % globals(), file=sys.stderr) if msg: @@ -104,19 +110,23 @@ def usage(code, msg=''): sys.exit(code) - class SMTPChannel(asynchat.async_chat): COMMAND = 0 DATA = 1 - data_size_limit = 33554432 command_size_limit = 512 + command_size_limits = collections.defaultdict(lambda x=command_size_limit: x) + command_size_limits.update({ + 'MAIL': command_size_limit + 26, + }) + max_command_size_limit = max(command_size_limits.values()) - def __init__(self, server, conn, addr): + def __init__(self, server, conn, addr, data_size_limit=DATA_SIZE_DEFAULT): asynchat.async_chat.__init__(self, conn) self.smtp_server = server self.conn = conn self.addr = addr + self.data_size_limit = data_size_limit self.received_lines = [] self.smtp_state = self.COMMAND self.seen_greeting = '' @@ -137,6 +147,7 @@ class SMTPChannel(asynchat.async_chat): print('Peer:', repr(self.peer), file=DEBUGSTREAM) self.push('220 %s %s' % (self.fqdn, __version__)) self.set_terminator(b'\r\n') + self.extended_smtp = False # properties for backwards-compatibility @property @@ -268,7 +279,7 @@ class SMTPChannel(asynchat.async_chat): def collect_incoming_data(self, data): limit = None if self.smtp_state == self.COMMAND: - limit = self.command_size_limit + limit = self.max_command_size_limit elif self.smtp_state == self.DATA: limit = self.data_size_limit if limit and self.num_bytes > limit: @@ -283,11 +294,7 @@ class SMTPChannel(asynchat.async_chat): print('Data:', repr(line), file=DEBUGSTREAM) self.received_lines = [] if self.smtp_state == self.COMMAND: - if self.num_bytes > self.command_size_limit: - self.push('500 Error: line too long') - self.num_bytes = 0 - return - self.num_bytes = 0 + sz, self.num_bytes = self.num_bytes, 0 if not line: self.push('500 Error: bad syntax') return @@ -299,9 +306,14 @@ class SMTPChannel(asynchat.async_chat): else: command = line[:i].upper() arg = line[i+1:].strip() + max_sz = (self.command_size_limits[command] + if self.extended_smtp else self.command_size_limit) + if sz > max_sz: + self.push('500 Error: line too long') + return method = getattr(self, 'smtp_' + command, None) if not method: - self.push('502 Error: command "%s" not implemented' % command) + self.push('500 Error: command "%s" not recognized' % command) return method(arg) return @@ -310,12 +322,12 @@ class SMTPChannel(asynchat.async_chat): self.push('451 Internal confusion') self.num_bytes = 0 return - if self.num_bytes > self.data_size_limit: + if self.data_size_limit and self.num_bytes > self.data_size_limit: self.push('552 Error: Too much mail data') self.num_bytes = 0 return # Remove extraneous carriage returns and de-transparency according - # to RFC 821, Section 4.5.2. + # to RFC 5321, Section 4.5.2. data = [] for text in line.split('\r\n'): if text and text[0] == '.': @@ -333,7 +345,7 @@ class SMTPChannel(asynchat.async_chat): self.num_bytes = 0 self.set_terminator(b'\r\n') if not status: - self.push('250 Ok') + self.push('250 OK') else: self.push(status) @@ -346,66 +358,188 @@ class SMTPChannel(asynchat.async_chat): self.push('503 Duplicate HELO/EHLO') else: self.seen_greeting = arg + self.extended_smtp = False self.push('250 %s' % self.fqdn) + def smtp_EHLO(self, arg): + if not arg: + self.push('501 Syntax: EHLO hostname') + return + if self.seen_greeting: + self.push('503 Duplicate HELO/EHLO') + else: + self.seen_greeting = arg + self.extended_smtp = True + self.push('250-%s' % self.fqdn) + if self.data_size_limit: + self.push('250-SIZE %s' % self.data_size_limit) + self.push('250 HELP') + def smtp_NOOP(self, arg): if arg: self.push('501 Syntax: NOOP') else: - self.push('250 Ok') + self.push('250 OK') def smtp_QUIT(self, arg): # args is ignored self.push('221 Bye') self.close_when_done() - # factored - def __getaddr(self, keyword, arg): - address = None + def _strip_command_keyword(self, keyword, arg): keylen = len(keyword) if arg[:keylen].upper() == keyword: - address = arg[keylen:].strip() - if not address: - pass - elif address[0] == '<' and address[-1] == '>' and address != '<>': - # Addresses can be in the form <person@dom.com> but watch out - # for null address, e.g. <> - address = address[1:-1] - return address + return arg[keylen:].strip() + return '' + + def _getaddr(self, arg): + if not arg: + return '', '' + if arg.lstrip().startswith('<'): + address, rest = get_angle_addr(arg) + else: + address, rest = get_addr_spec(arg) + if not address: + return address, rest + return address.addr_spec, rest + + def _getparams(self, params): + # Return any parameters that appear to be syntactically valid according + # to RFC 1869, ignore all others. (Postel rule: accept what we can.) + params = [param.split('=', 1) for param in params.split() + if '=' in param] + return {k: v for k, v in params if k.isalnum()} + + def smtp_HELP(self, arg): + if arg: + extended = ' [SP <mail parameters]' + lc_arg = arg.upper() + if lc_arg == 'EHLO': + self.push('250 Syntax: EHLO hostname') + elif lc_arg == 'HELO': + self.push('250 Syntax: HELO hostname') + elif lc_arg == 'MAIL': + msg = '250 Syntax: MAIL FROM: <address>' + if self.extended_smtp: + msg += extended + self.push(msg) + elif lc_arg == 'RCPT': + msg = '250 Syntax: RCPT TO: <address>' + if self.extended_smtp: + msg += extended + self.push(msg) + elif lc_arg == 'DATA': + self.push('250 Syntax: DATA') + elif lc_arg == 'RSET': + self.push('250 Syntax: RSET') + elif lc_arg == 'NOOP': + self.push('250 Syntax: NOOP') + elif lc_arg == 'QUIT': + self.push('250 Syntax: QUIT') + elif lc_arg == 'VRFY': + self.push('250 Syntax: VRFY <address>') + else: + self.push('501 Supported commands: EHLO HELO MAIL RCPT ' + 'DATA RSET NOOP QUIT VRFY') + else: + self.push('250 Supported commands: EHLO HELO MAIL RCPT DATA ' + 'RSET NOOP QUIT VRFY') + + def smtp_VRFY(self, arg): + if arg: + address, params = self._getaddr(arg) + if address: + self.push('252 Cannot VRFY user, but will accept message ' + 'and attempt delivery') + else: + self.push('502 Could not VRFY %s' % arg) + else: + self.push('501 Syntax: VRFY <address>') def smtp_MAIL(self, arg): if not self.seen_greeting: self.push('503 Error: send HELO first'); return - print('===> MAIL', arg, file=DEBUGSTREAM) - address = self.__getaddr('FROM:', arg) if arg else None + syntaxerr = '501 Syntax: MAIL FROM: <address>' + if self.extended_smtp: + syntaxerr += ' [SP <mail-parameters>]' + if arg is None: + self.push(syntaxerr) + return + arg = self._strip_command_keyword('FROM:', arg) + address, params = self._getaddr(arg) + if not address: + self.push(syntaxerr) + return + if not self.extended_smtp and params: + self.push(syntaxerr) + return if not address: - self.push('501 Syntax: MAIL FROM:<address>') + self.push(syntaxerr) return if self.mailfrom: self.push('503 Error: nested MAIL command') return + params = self._getparams(params.upper()) + if params is None: + self.push(syntaxerr) + return + size = params.pop('SIZE', None) + if size: + if not size.isdigit(): + self.push(syntaxerr) + return + elif self.data_size_limit and int(size) > self.data_size_limit: + self.push('552 Error: message size exceeds fixed maximum message size') + return + if len(params.keys()) > 0: + self.push('555 MAIL FROM parameters not recognized or not implemented') + return self.mailfrom = address print('sender:', self.mailfrom, file=DEBUGSTREAM) - self.push('250 Ok') + self.push('250 OK') def smtp_RCPT(self, arg): if not self.seen_greeting: self.push('503 Error: send HELO first'); return - print('===> RCPT', arg, file=DEBUGSTREAM) if not self.mailfrom: self.push('503 Error: need MAIL command') return - address = self.__getaddr('TO:', arg) if arg else None + syntaxerr = '501 Syntax: RCPT TO: <address>' + if self.extended_smtp: + syntaxerr += ' [SP <mail-parameters>]' + if arg is None: + self.push(syntaxerr) + return + arg = self._strip_command_keyword('TO:', arg) + address, params = self._getaddr(arg) + if not address: + self.push(syntaxerr) + return + if params: + if self.extended_smtp: + params = self._getparams(params.upper()) + if params is None: + self.push(syntaxerr) + return + else: + self.push(syntaxerr) + return + if not address: + self.push(syntaxerr) + return + if params and len(params.keys()) > 0: + self.push('555 RCPT TO parameters not recognized or not implemented') + return if not address: self.push('501 Syntax: RCPT TO: <address>') return self.rcpttos.append(address) print('recips:', self.rcpttos, file=DEBUGSTREAM) - self.push('250 Ok') + self.push('250 OK') def smtp_RSET(self, arg): if arg: @@ -416,13 +550,12 @@ class SMTPChannel(asynchat.async_chat): self.rcpttos = [] self.received_data = '' self.smtp_state = self.COMMAND - self.push('250 Ok') + self.push('250 OK') def smtp_DATA(self, arg): if not self.seen_greeting: self.push('503 Error: send HELO first'); return - if not self.rcpttos: self.push('503 Error: need RCPT command') return @@ -433,15 +566,20 @@ class SMTPChannel(asynchat.async_chat): self.set_terminator(b'\r\n.\r\n') self.push('354 End data with <CR><LF>.<CR><LF>') + # Commands that have not been implemented + def smtp_EXPN(self, arg): + self.push('502 EXPN not implemented') + - class SMTPServer(asyncore.dispatcher): # SMTPChannel class to use for managing client connections channel_class = SMTPChannel - def __init__(self, localaddr, remoteaddr): + def __init__(self, localaddr, remoteaddr, + data_size_limit=DATA_SIZE_DEFAULT): self._localaddr = localaddr self._remoteaddr = remoteaddr + self.data_size_limit = data_size_limit asyncore.dispatcher.__init__(self) try: self.create_socket(socket.AF_INET, socket.SOCK_STREAM) @@ -459,7 +597,7 @@ class SMTPServer(asyncore.dispatcher): def handle_accepted(self, conn, addr): print('Incoming connection from %s' % repr(addr), file=DEBUGSTREAM) - channel = self.channel_class(self, conn, addr) + channel = self.channel_class(self, conn, addr, self.data_size_limit) # API for "doing something useful with the message" def process_message(self, peer, mailfrom, rcpttos, data): @@ -487,7 +625,6 @@ class SMTPServer(asyncore.dispatcher): raise NotImplementedError - class DebuggingServer(SMTPServer): # Do something with the gathered message def process_message(self, peer, mailfrom, rcpttos, data): @@ -503,7 +640,6 @@ class DebuggingServer(SMTPServer): print('------------ END MESSAGE ------------') - class PureProxy(SMTPServer): def process_message(self, peer, mailfrom, rcpttos, data): lines = data.split('\n') @@ -544,7 +680,6 @@ class PureProxy(SMTPServer): return refused - class MailmanProxy(PureProxy): def process_message(self, peer, mailfrom, rcpttos, data): from io import StringIO @@ -623,19 +758,18 @@ class MailmanProxy(PureProxy): msg.Enqueue(mlist, torequest=1) - class Options: setuid = 1 classname = 'PureProxy' + size_limit = None - def parseargs(): global DEBUGSTREAM try: opts, args = getopt.getopt( - sys.argv[1:], 'nVhc:d', - ['class=', 'nosetuid', 'version', 'help', 'debug']) + sys.argv[1:], 'nVhc:s:d', + ['class=', 'nosetuid', 'version', 'help', 'size=', 'debug']) except getopt.error as e: usage(1, e) @@ -652,6 +786,13 @@ def parseargs(): options.classname = arg elif opt in ('-d', '--debug'): DEBUGSTREAM = sys.stderr + elif opt in ('-s', '--size'): + try: + int_size = int(arg) + options.size_limit = int_size + except: + print('Invalid size: ' + arg, file=sys.stderr) + sys.exit(1) # parse the rest of the arguments if len(args) < 1: @@ -686,7 +827,6 @@ def parseargs(): return options - if __name__ == '__main__': options = parseargs() # Become nobody @@ -699,7 +839,8 @@ if __name__ == '__main__': import __main__ as mod class_ = getattr(mod, classname) proxy = class_((options.localhost, options.localport), - (options.remotehost, options.remoteport)) + (options.remotehost, options.remoteport), + options.size_limit) if options.setuid: try: import pwd diff --git a/Lib/test/test_logging.py b/Lib/test/test_logging.py index 3adeaec..26baf11 100644 --- a/Lib/test/test_logging.py +++ b/Lib/test/test_logging.py @@ -663,6 +663,7 @@ if threading: self.smtp_server = server self.conn = conn self.addr = addr + self.data_size_limit = None self.received_lines = [] self.smtp_state = self.COMMAND self.seen_greeting = '' @@ -682,6 +683,7 @@ if threading: return self.push('220 %s %s' % (self.fqdn, smtpd.__version__)) self.set_terminator(b'\r\n') + self.extended_smtp = False class TestSMTPServer(smtpd.SMTPServer): @@ -709,6 +711,7 @@ if threading: def __init__(self, addr, handler, poll_interval, sockmap): self._localaddr = addr self._remoteaddr = None + self.data_size_limit = None self.sockmap = sockmap asyncore.dispatcher.__init__(self, map=sockmap) try: diff --git a/Lib/test/test_smtpd.py b/Lib/test/test_smtpd.py index a7dc5f6..dda1941 100644 --- a/Lib/test/test_smtpd.py +++ b/Lib/test/test_smtpd.py @@ -1,4 +1,4 @@ -from unittest import TestCase +import unittest from test import support, mock_socket import socket import io @@ -26,7 +26,7 @@ class BrokenDummyServer(DummyServer): raise DummyDispatcherBroken() -class SMTPDServerTest(TestCase): +class SMTPDServerTest(unittest.TestCase): def setUp(self): smtpd.socket = asyncore.socket = mock_socket @@ -39,7 +39,7 @@ class SMTPDServerTest(TestCase): channel.socket.queue_recv(line) channel.handle_read() - write_line(b'HELO test.example') + write_line(b'HELO example') write_line(b'MAIL From:eggs@example') write_line(b'RCPT To:spam@example') write_line(b'DATA') @@ -50,7 +50,7 @@ class SMTPDServerTest(TestCase): asyncore.socket = smtpd.socket = socket -class SMTPDChannelTest(TestCase): +class SMTPDChannelTest(unittest.TestCase): def setUp(self): smtpd.socket = asyncore.socket = mock_socket self.old_debugstream = smtpd.DEBUGSTREAM @@ -79,36 +79,94 @@ class SMTPDChannelTest(TestCase): self.assertEqual(self.channel.socket.last, b'500 Error: bad syntax\r\n') - def test_EHLO_not_implemented(self): - self.write_line(b'EHLO test.example') + def test_EHLO(self): + self.write_line(b'EHLO example') + self.assertEqual(self.channel.socket.last, b'250 HELP\r\n') + + def test_EHLO_bad_syntax(self): + self.write_line(b'EHLO') + self.assertEqual(self.channel.socket.last, + b'501 Syntax: EHLO hostname\r\n') + + def test_EHLO_duplicate(self): + self.write_line(b'EHLO example') + self.write_line(b'EHLO example') + self.assertEqual(self.channel.socket.last, + b'503 Duplicate HELO/EHLO\r\n') + + def test_EHLO_HELO_duplicate(self): + self.write_line(b'EHLO example') + self.write_line(b'HELO example') self.assertEqual(self.channel.socket.last, - b'502 Error: command "EHLO" not implemented\r\n') + b'503 Duplicate HELO/EHLO\r\n') def test_HELO(self): name = smtpd.socket.getfqdn() - self.write_line(b'HELO test.example') + self.write_line(b'HELO example') self.assertEqual(self.channel.socket.last, '250 {}\r\n'.format(name).encode('ascii')) + def test_HELO_EHLO_duplicate(self): + self.write_line(b'HELO example') + self.write_line(b'EHLO example') + self.assertEqual(self.channel.socket.last, + b'503 Duplicate HELO/EHLO\r\n') + + def test_HELP(self): + self.write_line(b'HELP') + self.assertEqual(self.channel.socket.last, + b'250 Supported commands: EHLO HELO MAIL RCPT ' + \ + b'DATA RSET NOOP QUIT VRFY\r\n') + + def test_HELP_command(self): + self.write_line(b'HELP MAIL') + self.assertEqual(self.channel.socket.last, + b'250 Syntax: MAIL FROM: <address>\r\n') + + def test_HELP_command_unknown(self): + self.write_line(b'HELP SPAM') + self.assertEqual(self.channel.socket.last, + b'501 Supported commands: EHLO HELO MAIL RCPT ' + \ + b'DATA RSET NOOP QUIT VRFY\r\n') + def test_HELO_bad_syntax(self): self.write_line(b'HELO') self.assertEqual(self.channel.socket.last, b'501 Syntax: HELO hostname\r\n') def test_HELO_duplicate(self): - self.write_line(b'HELO test.example') - self.write_line(b'HELO test.example') + self.write_line(b'HELO example') + self.write_line(b'HELO example') self.assertEqual(self.channel.socket.last, b'503 Duplicate HELO/EHLO\r\n') + def test_HELO_parameter_rejected_when_extensions_not_enabled(self): + self.extended_smtp = False + self.write_line(b'HELO example') + self.write_line(b'MAIL from:<foo@example.com> SIZE=1234') + self.assertEqual(self.channel.socket.last, + b'501 Syntax: MAIL FROM: <address>\r\n') + + def test_MAIL_allows_space_after_colon(self): + self.write_line(b'HELO example') + self.write_line(b'MAIL from: <foo@example.com>') + self.assertEqual(self.channel.socket.last, + b'250 OK\r\n') + + def test_extended_MAIL_allows_space_after_colon(self): + self.write_line(b'EHLO example') + self.write_line(b'MAIL from: <foo@example.com> size=20') + self.assertEqual(self.channel.socket.last, + b'250 OK\r\n') + def test_NOOP(self): self.write_line(b'NOOP') - self.assertEqual(self.channel.socket.last, b'250 Ok\r\n') + self.assertEqual(self.channel.socket.last, b'250 OK\r\n') def test_HELO_NOOP(self): self.write_line(b'HELO example') self.write_line(b'NOOP') - self.assertEqual(self.channel.socket.last, b'250 Ok\r\n') + self.assertEqual(self.channel.socket.last, b'250 OK\r\n') def test_NOOP_bad_syntax(self): self.write_line(b'NOOP hi') @@ -136,15 +194,29 @@ class SMTPDChannelTest(TestCase): def test_command_too_long(self): self.write_line(b'HELO example') - self.write_line(b'MAIL from ' + + self.write_line(b'MAIL from: ' + b'a' * self.channel.command_size_limit + b'@example') self.assertEqual(self.channel.socket.last, b'500 Error: line too long\r\n') - def test_data_too_long(self): - # Small hack. Setting limit to 2K octets here will save us some time. - self.channel.data_size_limit = 2048 + def test_MAIL_command_limit_extended_with_SIZE(self): + self.write_line(b'EHLO example') + fill_len = self.channel.command_size_limit - len('MAIL from:<@example>') + self.write_line(b'MAIL from:<' + + b'a' * fill_len + + b'@example> SIZE=1234') + self.assertEqual(self.channel.socket.last, b'250 OK\r\n') + + self.write_line(b'MAIL from:<' + + b'a' * (fill_len + 26) + + b'@example> SIZE=1234') + self.assertEqual(self.channel.socket.last, + b'500 Error: line too long\r\n') + + def test_data_longer_than_default_data_size_limit(self): + # Hack the default so we don't have to generate so much data. + self.channel.data_size_limit = 1048 self.write_line(b'HELO example') self.write_line(b'MAIL From:eggs@example') self.write_line(b'RCPT To:spam@example') @@ -154,28 +226,93 @@ class SMTPDChannelTest(TestCase): self.assertEqual(self.channel.socket.last, b'552 Error: Too much mail data\r\n') + def test_MAIL_size_parameter(self): + self.write_line(b'EHLO example') + self.write_line(b'MAIL FROM:<eggs@example> SIZE=512') + self.assertEqual(self.channel.socket.last, + b'250 OK\r\n') + + def test_MAIL_invalid_size_parameter(self): + self.write_line(b'EHLO example') + self.write_line(b'MAIL FROM:<eggs@example> SIZE=invalid') + self.assertEqual(self.channel.socket.last, + b'501 Syntax: MAIL FROM: <address> [SP <mail-parameters>]\r\n') + + def test_MAIL_RCPT_unknown_parameters(self): + self.write_line(b'EHLO example') + self.write_line(b'MAIL FROM:<eggs@example> ham=green') + self.assertEqual(self.channel.socket.last, + b'555 MAIL FROM parameters not recognized or not implemented\r\n') + + self.write_line(b'MAIL FROM:<eggs@example>') + self.write_line(b'RCPT TO:<eggs@example> ham=green') + self.assertEqual(self.channel.socket.last, + b'555 RCPT TO parameters not recognized or not implemented\r\n') + + def test_MAIL_size_parameter_larger_than_default_data_size_limit(self): + self.channel.data_size_limit = 1048 + self.write_line(b'EHLO example') + self.write_line(b'MAIL FROM:<eggs@example> SIZE=2096') + self.assertEqual(self.channel.socket.last, + b'552 Error: message size exceeds fixed maximum message size\r\n') + def test_need_MAIL(self): self.write_line(b'HELO example') self.write_line(b'RCPT to:spam@example') self.assertEqual(self.channel.socket.last, b'503 Error: need MAIL command\r\n') - def test_MAIL_syntax(self): + def test_MAIL_syntax_HELO(self): self.write_line(b'HELO example') self.write_line(b'MAIL from eggs@example') self.assertEqual(self.channel.socket.last, - b'501 Syntax: MAIL FROM:<address>\r\n') + b'501 Syntax: MAIL FROM: <address>\r\n') - def test_MAIL_missing_from(self): + def test_MAIL_syntax_EHLO(self): + self.write_line(b'EHLO example') + self.write_line(b'MAIL from eggs@example') + self.assertEqual(self.channel.socket.last, + b'501 Syntax: MAIL FROM: <address> [SP <mail-parameters>]\r\n') + + def test_MAIL_missing_address(self): self.write_line(b'HELO example') self.write_line(b'MAIL from:') self.assertEqual(self.channel.socket.last, - b'501 Syntax: MAIL FROM:<address>\r\n') + b'501 Syntax: MAIL FROM: <address>\r\n') def test_MAIL_chevrons(self): self.write_line(b'HELO example') self.write_line(b'MAIL from:<eggs@example>') - self.assertEqual(self.channel.socket.last, b'250 Ok\r\n') + self.assertEqual(self.channel.socket.last, b'250 OK\r\n') + + def test_MAIL_empty_chevrons(self): + self.write_line(b'EHLO example') + self.write_line(b'MAIL from:<>') + self.assertEqual(self.channel.socket.last, b'250 OK\r\n') + + def test_MAIL_quoted_localpart(self): + self.write_line(b'EHLO example') + self.write_line(b'MAIL from: <"Fred Blogs"@example.com>') + self.assertEqual(self.channel.socket.last, b'250 OK\r\n') + self.assertEqual(self.channel.mailfrom, '"Fred Blogs"@example.com') + + def test_MAIL_quoted_localpart_no_angles(self): + self.write_line(b'EHLO example') + self.write_line(b'MAIL from: "Fred Blogs"@example.com') + self.assertEqual(self.channel.socket.last, b'250 OK\r\n') + self.assertEqual(self.channel.mailfrom, '"Fred Blogs"@example.com') + + def test_MAIL_quoted_localpart_with_size(self): + self.write_line(b'EHLO example') + self.write_line(b'MAIL from: <"Fred Blogs"@example.com> SIZE=1000') + self.assertEqual(self.channel.socket.last, b'250 OK\r\n') + self.assertEqual(self.channel.mailfrom, '"Fred Blogs"@example.com') + + def test_MAIL_quoted_localpart_with_size_no_angles(self): + self.write_line(b'EHLO example') + self.write_line(b'MAIL from: "Fred Blogs"@example.com SIZE=1000') + self.assertEqual(self.channel.socket.last, b'250 OK\r\n') + self.assertEqual(self.channel.mailfrom, '"Fred Blogs"@example.com') def test_nested_MAIL(self): self.write_line(b'HELO example') @@ -184,6 +321,22 @@ class SMTPDChannelTest(TestCase): self.assertEqual(self.channel.socket.last, b'503 Error: nested MAIL command\r\n') + def test_VRFY(self): + self.write_line(b'VRFY eggs@example') + self.assertEqual(self.channel.socket.last, + b'252 Cannot VRFY user, but will accept message and attempt ' + \ + b'delivery\r\n') + + def test_VRFY_syntax(self): + self.write_line(b'VRFY') + self.assertEqual(self.channel.socket.last, + b'501 Syntax: VRFY <address>\r\n') + + def test_EXPN_not_implemented(self): + self.write_line(b'EXPN') + self.assertEqual(self.channel.socket.last, + b'502 EXPN not implemented\r\n') + def test_no_HELO_MAIL(self): self.write_line(b'MAIL from:<foo@example.com>') self.assertEqual(self.channel.socket.last, @@ -196,13 +349,26 @@ class SMTPDChannelTest(TestCase): self.assertEqual(self.channel.socket.last, b'503 Error: need RCPT command\r\n') - def test_RCPT_syntax(self): + def test_RCPT_syntax_HELO(self): self.write_line(b'HELO example') - self.write_line(b'MAIL From:eggs@example') + self.write_line(b'MAIL From: eggs@example') self.write_line(b'RCPT to eggs@example') self.assertEqual(self.channel.socket.last, b'501 Syntax: RCPT TO: <address>\r\n') + def test_RCPT_syntax_EHLO(self): + self.write_line(b'EHLO example') + self.write_line(b'MAIL From: eggs@example') + self.write_line(b'RCPT to eggs@example') + self.assertEqual(self.channel.socket.last, + b'501 Syntax: RCPT TO: <address> [SP <mail-parameters>]\r\n') + + def test_RCPT_lowercase_to_OK(self): + self.write_line(b'HELO example') + self.write_line(b'MAIL From: eggs@example') + self.write_line(b'RCPT to: <eggs@example>') + self.assertEqual(self.channel.socket.last, b'250 OK\r\n') + def test_no_HELO_RCPT(self): self.write_line(b'RCPT to eggs@example') self.assertEqual(self.channel.socket.last, @@ -211,15 +377,15 @@ class SMTPDChannelTest(TestCase): def test_data_dialog(self): self.write_line(b'HELO example') self.write_line(b'MAIL From:eggs@example') - self.assertEqual(self.channel.socket.last, b'250 Ok\r\n') + self.assertEqual(self.channel.socket.last, b'250 OK\r\n') self.write_line(b'RCPT To:spam@example') - self.assertEqual(self.channel.socket.last, b'250 Ok\r\n') + self.assertEqual(self.channel.socket.last, b'250 OK\r\n') self.write_line(b'DATA') self.assertEqual(self.channel.socket.last, b'354 End data with <CR><LF>.<CR><LF>\r\n') self.write_line(b'data\r\nmore\r\n.') - self.assertEqual(self.channel.socket.last, b'250 Ok\r\n') + self.assertEqual(self.channel.socket.last, b'250 OK\r\n') self.assertEqual(self.server.messages, [('peer', 'eggs@example', ['spam@example'], 'data\nmore')]) @@ -267,7 +433,7 @@ class SMTPDChannelTest(TestCase): self.write_line(b'MAIL From:eggs@example') self.write_line(b'RCPT To:spam@example') self.write_line(b'RSET') - self.assertEqual(self.channel.socket.last, b'250 Ok\r\n') + self.assertEqual(self.channel.socket.last, b'250 OK\r\n') self.write_line(b'MAIL From:foo@example') self.write_line(b'RCPT To:eggs@example') self.write_line(b'DATA') @@ -278,12 +444,18 @@ class SMTPDChannelTest(TestCase): def test_HELO_RSET(self): self.write_line(b'HELO example') self.write_line(b'RSET') - self.assertEqual(self.channel.socket.last, b'250 Ok\r\n') + self.assertEqual(self.channel.socket.last, b'250 OK\r\n') def test_RSET_syntax(self): self.write_line(b'RSET hi') self.assertEqual(self.channel.socket.last, b'501 Syntax: RSET\r\n') + def test_unknown_command(self): + self.write_line(b'UNKNOWN_CMD') + self.assertEqual(self.channel.socket.last, + b'500 Error: command "UNKNOWN_CMD" not ' + \ + b'recognized\r\n') + def test_attribute_deprecations(self): with support.check_warnings(('', DeprecationWarning)): spam = self.channel._SMTPChannel__server @@ -330,8 +502,54 @@ class SMTPDChannelTest(TestCase): with support.check_warnings(('', DeprecationWarning)): self.channel._SMTPChannel__addr = 'spam' -def test_main(): - support.run_unittest(SMTPDServerTest, SMTPDChannelTest) + +class SMTPDChannelWithDataSizeLimitTest(unittest.TestCase): + + def setUp(self): + smtpd.socket = asyncore.socket = mock_socket + self.debug = smtpd.DEBUGSTREAM = io.StringIO() + self.server = DummyServer('a', 'b') + conn, addr = self.server.accept() + # Set DATA size limit to 32 bytes for easy testing + self.channel = smtpd.SMTPChannel(self.server, conn, addr, 32) + + def tearDown(self): + asyncore.close_all() + asyncore.socket = smtpd.socket = socket + + def write_line(self, line): + self.channel.socket.queue_recv(line) + self.channel.handle_read() + + def test_data_limit_dialog(self): + self.write_line(b'HELO example') + self.write_line(b'MAIL From:eggs@example') + self.assertEqual(self.channel.socket.last, b'250 OK\r\n') + self.write_line(b'RCPT To:spam@example') + self.assertEqual(self.channel.socket.last, b'250 OK\r\n') + + self.write_line(b'DATA') + self.assertEqual(self.channel.socket.last, + b'354 End data with <CR><LF>.<CR><LF>\r\n') + self.write_line(b'data\r\nmore\r\n.') + self.assertEqual(self.channel.socket.last, b'250 OK\r\n') + self.assertEqual(self.server.messages, + [('peer', 'eggs@example', ['spam@example'], 'data\nmore')]) + + def test_data_limit_dialog_too_much_data(self): + self.write_line(b'HELO example') + self.write_line(b'MAIL From:eggs@example') + self.assertEqual(self.channel.socket.last, b'250 OK\r\n') + self.write_line(b'RCPT To:spam@example') + self.assertEqual(self.channel.socket.last, b'250 OK\r\n') + + self.write_line(b'DATA') + self.assertEqual(self.channel.socket.last, + b'354 End data with <CR><LF>.<CR><LF>\r\n') + self.write_line(b'This message is longer than 32 bytes\r\n.') + self.assertEqual(self.channel.socket.last, + b'552 Error: Too much mail data\r\n') + if __name__ == "__main__": - test_main() + unittest.main() diff --git a/Lib/test/test_smtplib.py b/Lib/test/test_smtplib.py index 18dde2f..befc49e 100644 --- a/Lib/test/test_smtplib.py +++ b/Lib/test/test_smtplib.py @@ -229,13 +229,13 @@ class DebuggingServerTests(unittest.TestCase): def testNOOP(self): smtp = smtplib.SMTP(HOST, self.port, local_hostname='localhost', timeout=3) - expected = (250, b'Ok') + expected = (250, b'OK') self.assertEqual(smtp.noop(), expected) smtp.quit() def testRSET(self): smtp = smtplib.SMTP(HOST, self.port, local_hostname='localhost', timeout=3) - expected = (250, b'Ok') + expected = (250, b'OK') self.assertEqual(smtp.rset(), expected) smtp.quit() @@ -246,10 +246,18 @@ class DebuggingServerTests(unittest.TestCase): self.assertEqual(smtp.ehlo(), expected) smtp.quit() + def testNotImplemented(self): + # EXPN isn't implemented in DebuggingServer + smtp = smtplib.SMTP(HOST, self.port, local_hostname='localhost', timeout=3) + expected = (502, b'EXPN not implemented') + smtp.putcmd('EXPN') + self.assertEqual(smtp.getreply(), expected) + smtp.quit() + def testVRFY(self): - # VRFY isn't implemented in DebuggingServer smtp = smtplib.SMTP(HOST, self.port, local_hostname='localhost', timeout=3) - expected = (502, b'Error: command "VRFY" not implemented') + expected = (252, b'Cannot VRFY user, but will accept message ' + \ + b'and attempt delivery') self.assertEqual(smtp.vrfy('nobody@nowhere.com'), expected) self.assertEqual(smtp.verify('nobody@nowhere.com'), expected) smtp.quit() @@ -265,7 +273,8 @@ class DebuggingServerTests(unittest.TestCase): def testHELP(self): smtp = smtplib.SMTP(HOST, self.port, local_hostname='localhost', timeout=3) - self.assertEqual(smtp.help(), b'Error: command "HELP" not implemented') + self.assertEqual(smtp.help(), b'Supported commands: EHLO HELO MAIL ' + \ + b'RCPT DATA RSET NOOP QUIT VRFY') smtp.quit() def testSend(self): @@ -112,6 +112,7 @@ Gregory Bond Matias Bordese Jurjen Bos Peter Bosch +Dan Boswell Eric Bouck Thierry Bousch Sebastian Boving @@ -494,6 +495,7 @@ Geert Jansen Jack Jansen Bill Janssen Thomas Jarosch +Juhana Jauhiainen Zbigniew Jędrzejewski-Szmek Julien Jehannet Drew Jenkins @@ -1039,6 +1041,7 @@ Sandro Tosi Richard Townsend David Townshend Laurence Tratt +Alberto Trevino Matthias Troffaes John Tromp Jason Trowbridge @@ -46,6 +46,9 @@ Core and Builtins Library ------- +- Issue #8739: Updated smtpd to support RFC 5321, and added support for the + RFC 1870 SIZE extension. + - Issue #665194: Added a localtime function to email.utils to provide an aware local datetime for use in setting Date headers. |