[issue8739] Update to smtpd.py to RFC 5321

Alberto Trevino report at bugs.python.org
Mon Aug 2 23:30:01 CEST 2010


Alberto Trevino <alberto at byu.edu> added the comment:

On Monday, July 05, 2010 10:41:28 am you wrote:
> Yes, the fact that there are no unit tests for the new functionality.

Sorry to take so long to reply.  I have attached the latest version of the 
patch which does everything in rev. 2 of the patch, patches the setuid 
problem discussed in issue 9168, updates to the unit test to account for the 
changes and to test the new functionality, plus some little changes here and 
there.

Please review and advise.

----------
Added file: http://bugs.python.org/file18332/smtpd.py-0.2-rfc5321-enhancements-4.diff

_______________________________________
Python tracker <report at bugs.python.org>
<http://bugs.python.org/issue8739>
_______________________________________
-------------- next part --------------
diff -aur Python-3.1.2.orig/Lib/smtpd.py Python-3.1.2/Lib/smtpd.py
--- Python-3.1.2.orig/Lib/smtpd.py	2009-02-21 13:59:32.000000000 -0700
+++ Python-3.1.2/Lib/smtpd.py	2010-08-02 08:23:04.424066197 -0600
@@ -1,5 +1,5 @@
 #! /usr/bin/env python
-"""An RFC 2821 smtp proxy.
+"""An RFC 5321 smtp proxy.
 
 Usage: %(program)s [options] [localhost:localport [remotehost:remoteport]]
 
@@ -20,6 +20,11 @@
         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.  Defaults to 0 (no limit).
+
     --debug
     -d
         Turn on debugging prints.
@@ -35,10 +40,9 @@
 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:
 #
@@ -59,15 +63,18 @@
 #   gets forwarded to a real backend smtpd, as with PureProxy.  Again, errors
 #   are not handled correctly yet.
 #
-# Please note that this script requires Python 2.0
+# Please note that this script requires Python 3.0
 #
 # Author: Barry Warsaw <barry at python.org>
 #
+# Contributors:
+#   Alberto Trevino <alberto at byu.edu>
+#
 # TODO:
 #
 # - support mailbox delivery
 # - alias files
-# - ESMTP
+# - Handle more ESMTP extensions
 # - handle error codes from the backend smtpd
 
 import sys
@@ -82,7 +89,7 @@
 __all__ = ["SMTPServer","DebuggingServer","PureProxy","MailmanProxy"]
 
 program = sys.argv[0]
-__version__ = 'Python SMTP proxy version 0.2'
+__version__ = 'Python SMTP proxy version 0.21'
 
 
 class Devnull:
@@ -96,7 +103,6 @@
 COMMASPACE = ', '
 
 
-
 def usage(code, msg=''):
     print(__doc__ % globals(), file=sys.stderr)
     if msg:
@@ -104,16 +110,16 @@
     sys.exit(code)
 
 
-
 class SMTPChannel(asynchat.async_chat):
     COMMAND = 0
     DATA = 1
 
-    def __init__(self, server, conn, addr):
+    def __init__(self, server, conn, addr, size):
         asynchat.async_chat.__init__(self, conn)
         self.__server = server
         self.__conn = conn
         self.__addr = addr
+        self.__size = size
         self.__line = []
         self.__state = self.COMMAND
         self.__greeting = 0
@@ -122,6 +128,7 @@
         self.__data = ''
         self.__fqdn = socket.getfqdn()
         self.__peer = conn.getpeername()
+        self.__8bitmime = False
         print('Peer:', repr(self.__peer), file=DEBUGSTREAM)
         self.push('220 %s %s' % (self.__fqdn, __version__))
         self.set_terminator(b'\r\n')
@@ -153,7 +160,7 @@
                 arg = line[i+1:].strip()
             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
@@ -162,7 +169,7 @@
                 self.push('451 Internal confusion')
                 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] == '.':
@@ -170,18 +177,39 @@
                 else:
                     data.append(text)
             self.__data = NEWLINE.join(data)
-            status = self.__server.process_message(self.__peer,
-                                                   self.__mailfrom,
-                                                   self.__rcpttos,
-                                                   self.__data)
-            self.__rcpttos = []
-            self.__mailfrom = None
-            self.__state = self.COMMAND
-            self.set_terminator(b'\r\n')
-            if not status:
-                self.push('250 Ok')
+
+            # Enforce data size limit
+            if self.__size == 0 or len(self.__data) <= self.__size:
+                status = self.__server.process_message(self.__peer,
+                                                       self.__mailfrom,
+                                                       self.__rcpttos,
+                                                       self.__data)
+                self.__rcpttos = []
+                self.__mailfrom = None
+                self.__state = self.COMMAND
+                self.set_terminator(b'\r\n')
+                if not status:
+                    self.push('250 OK')
+                else:
+                    self.push(status)
             else:
-                self.push(status)
+                self.__state = self.COMMAND
+                self.set_terminator(b'\r\n')
+                self.push('552 Too much mail data')
+
+    # factored
+    def __getaddr(self, keyword, arg):
+        address = None
+        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 at dom.com> but watch out
+                # for null address, e.g. <>
+                address = address[1:-1]
+        return address
 
     # SMTP and ESMTP commands
     def smtp_HELO(self, arg):
@@ -194,30 +222,73 @@
             self.__greeting = arg
             self.push('250 %s' % self.__fqdn)
 
+    def smtp_EHLO(self, arg):
+        if not arg:
+            self.push('501 Syntax: EHLO hostname')
+            return
+        if self.__greeting:
+            self.push('503 Duplicate HELO/EHLO')
+        else:
+            self.__greeting = arg
+            self.push('250-%s' % self.__fqdn)
+            self.push('250-8BITMIME')
+            if self.__size > 0:
+                self.push('250-SIZE %s' % self.__size)
+            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
-        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 at dom.com> but watch out
-                # for null address, e.g. <>
-                address = address[1:-1]
-        return address
+    def smtp_8BITMIME(self, arg):
+        # There is nothing in this code that forces 7 bits, so it seems OK
+        # to simply accept this command; its value is saved for future use;
+        # args is ignored
+        self.__8bitmime = True
+        self.push('250 OK')
+
+    def smtp_HELP(self, arg):
+        if arg:
+            lc_arg = arg.upper()
+            if lc_arg == 'EHLO':
+                self.push('250 EHLO your_fqdn')
+            elif lc_arg == 'HELO':
+                self.push('250 HELO your_fqdn')
+            elif lc_arg == 'MAIL':
+                self.push('250 MAIL FROM:<sender at your.domain>')
+            elif lc_arg == 'RCPT':
+                self.push('250 RCPT TO:<recipient at this.domain')
+            elif lc_arg == 'DATA':
+                self.push('250 DATA')
+            elif lc_arg == 'RSET':
+                self.push('250 RSET')
+            elif lc_arg == 'NOOP':
+                self.push('250 NOOP')
+            elif lc_arg == 'QUIT':
+                self.push('250 QUIT')
+            elif lc_arg == 'VRFY':
+                self.push('250 VRFY user | user at this.domain')
+            else:
+                self.push('250 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):
+        address = self.__getaddr('', arg) if arg else None
+        if address:
+            self.push('252 Cannot VRFY user, but will accept message and ' + \
+                      'attempt delivery')
+        else:
+            self.push('502 Could not VRFY %s' % arg)
 
     def smtp_MAIL(self, arg):
         print('===> MAIL', arg, file=DEBUGSTREAM)
@@ -230,7 +301,7 @@
             return
         self.__mailfrom = address
         print('sender:', self.__mailfrom, file=DEBUGSTREAM)
-        self.push('250 Ok')
+        self.push('250 OK')
 
     def smtp_RCPT(self, arg):
         print('===> RCPT', arg, file=DEBUGSTREAM)
@@ -243,7 +314,7 @@
             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:
@@ -254,7 +325,7 @@
         self.__rcpttos = []
         self.__data = ''
         self.__state = self.COMMAND
-        self.push('250 Ok')
+        self.push('250 OK')
 
     def smtp_DATA(self, arg):
         if not self.__rcpttos:
@@ -267,12 +338,16 @@
         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):
-    def __init__(self, localaddr, remoteaddr):
+    def __init__(self, localaddr, remoteaddr, size = 0):
         self._localaddr = localaddr
         self._remoteaddr = remoteaddr
+        self._size = size
         asyncore.dispatcher.__init__(self)
         self.create_socket(socket.AF_INET, socket.SOCK_STREAM)
         # try to re-use a server port if possible
@@ -286,7 +361,7 @@
     def handle_accept(self):
         conn, addr = self.accept()
         print('Incoming connection from %s' % repr(addr), file=DEBUGSTREAM)
-        channel = SMTPChannel(self, conn, addr)
+        channel = SMTPChannel(self, conn, addr, self._size)
 
     # API for "doing something useful with the message"
     def process_message(self, peer, mailfrom, rcpttos, data):
@@ -307,14 +382,13 @@
         containing a `.' followed by other text has had the leading dot
         removed.
 
-        This function should return None, for a normal `250 Ok' response;
+        This function should return None, for a normal `250 OK' response;
         otherwise it returns the desired response string in RFC 821 format.
 
         """
         raise NotImplementedError
 
 
-
 class DebuggingServer(SMTPServer):
     # Do something with the gathered message
     def process_message(self, peer, mailfrom, rcpttos, data):
@@ -330,7 +404,6 @@
         print('------------ END MESSAGE ------------')
 
 
-
 class PureProxy(SMTPServer):
     def process_message(self, peer, mailfrom, rcpttos, data):
         lines = data.split('\n')
@@ -371,7 +444,6 @@
         return refused
 
 
-
 class MailmanProxy(PureProxy):
     def process_message(self, peer, mailfrom, rcpttos, data):
         from io import StringIO
@@ -450,19 +522,18 @@
                 msg.Enqueue(mlist, torequest=1)
 
 
-
 class Options:
     setuid = 1
     classname = 'PureProxy'
+    size = 0
 
 
-
 def parseargs():
     global DEBUGSTREAM
     try:
         opts, args = getopt.getopt(
-            sys.argv[1:], 'nVhc:d',
-            ['class=', 'nosetuid', 'version', 'help', 'debug'])
+            sys.argv[1:], 'nVhc:ds:',
+            ['class=', 'nosetuid', 'version', 'help', 'debug', 'size='])
     except getopt.error as e:
         usage(1, e)
 
@@ -479,6 +550,13 @@
             options.classname = arg
         elif opt in ('-d', '--debug'):
             DEBUGSTREAM = sys.stderr
+        elif opt in ('-s', '--size'):
+            try:
+                int_size = int(arg)
+                options.size = int_size
+            except:
+                print('Invalid size: ' + arg, file=sys.stderr)
+                sys.exit(1)
 
     # parse the rest of the arguments
     if len(args) < 1:
@@ -513,33 +591,45 @@
     return options
 
 
-
 if __name__ == '__main__':
     options = parseargs()
+    classname = options.classname
+    if "." in classname:
+        lastdot = classname.rfind(".")
+        mod = __import__(classname[:lastdot], globals(), locals(), [""])
+        classname = classname[lastdot+1:]
+    else:
+        import __main__ as mod
+    class_ = getattr(mod, classname)
+    proxy = class_((options.localhost, options.localport),
+                   (options.remotehost, options.remoteport))
+    classname = options.classname
+    if "." in classname:
+        lastdot = classname.rfind(".")
+        mod = __import__(classname[:lastdot], globals(), locals(), [""])
+        classname = classname[lastdot+1:]
+    else:
+        import __main__ as mod
+    class_ = getattr(mod, classname)
+    proxy = class_((options.localhost, options.localport),
+                   (options.remotehost, options.remoteport),
+                   options.size)
     # Become nobody
     if options.setuid:
         try:
             import pwd
         except ImportError:
-            print('Cannot import module "pwd"; try running with -n option.', file=sys.stderr)
+            print('Cannot import module "pwd"; try running with -n option.', \
+                  file=sys.stderr)
             sys.exit(1)
         nobody = pwd.getpwnam('nobody')[2]
         try:
             os.setuid(nobody)
         except OSError as e:
             if e.errno != errno.EPERM: raise
-            print('Cannot setuid "nobody"; try running with -n option.', file=sys.stderr)
+            print('Cannot setuid "nobody"; try running with -n option.', \
+                  file=sys.stderr)
             sys.exit(1)
-    classname = options.classname
-    if "." in classname:
-        lastdot = classname.rfind(".")
-        mod = __import__(classname[:lastdot], globals(), locals(), [""])
-        classname = classname[lastdot+1:]
-    else:
-        import __main__ as mod
-    class_ = getattr(mod, classname)
-    proxy = class_((options.localhost, options.localport),
-                   (options.remotehost, options.remoteport))
     try:
         asyncore.loop()
     except KeyboardInterrupt:
diff -aur Python-3.1.2.orig/Lib/test/test_smtplib.py Python-3.1.2/Lib/test/test_smtplib.py
--- Python-3.1.2.orig/Lib/test/test_smtplib.py	2009-05-29 12:03:16.000000000 -0600
+++ Python-3.1.2/Lib/test/test_smtplib.py	2010-08-02 08:25:52.587378508 -0600
@@ -153,7 +153,9 @@
         self.serv_evt = threading.Event()
         self.client_evt = threading.Event()
         self.port = support.find_unused_port()
-        self.serv = smtpd.DebuggingServer((HOST, self.port), ('nowhere', -1))
+        # Set a small size to test DATA size limits; it won't affect other tests
+        self.size = 20
+        self.serv = smtpd.DebuggingServer((HOST, self.port), ('nowhere', -1), self.size)
         serv_args = (self.serv, self.serv_evt, self.client_evt)
         threading.Thread(target=debugging_server, args=serv_args).start()
 
@@ -176,27 +178,26 @@
 
     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()
 
     def testNotImplemented(self):
-        # EHLO isn't implemented in DebuggingServer
+        # EXPN isn't implemented in DebuggingServer
         smtp = smtplib.SMTP(HOST, self.port, local_hostname='localhost', timeout=3)
-        expected = (502, b'Error: command "EHLO" not implemented')
-        self.assertEqual(smtp.ehlo(), expected)
+        expected = (502, b'EXPN not implemented')
+        self.assertEqual(smtp.expn('nobody at nowhere.com'), 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 and attempt delivery')
         self.assertEqual(smtp.vrfy('nobody at nowhere.com'), expected)
         self.assertEqual(smtp.verify('nobody at nowhere.com'), expected)
         smtp.quit()
@@ -212,7 +213,35 @@
 
     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 RCPT DATA RSET NOOP QUIT VRFY')
+        smtp.quit()
+
+    def testHELPARG(self):
+        # Test arguments to HELP
+        smtp = smtplib.SMTP(HOST, self.port, local_hostname='localhost', timeout=3)
+        self.assertEqual(smtp.help('EHLO'), b'EHLO your_fqdn')
+        smtp.quit()
+
+    def test8BITMIME(self):
+        # Tests the 8BITMIME command; should simply return 250 OK
+        smtp = smtplib.SMTP(HOST, self.port, local_hostname='localhost', timeout=3)
+        smtp.ehlo()
+        smtp.putcmd('8BITMIME')
+        self.assertEqual(smtp.getreply(), (250, b'OK'))
+        smtp.quit()
+
+    def testSIZELIMIT(self):
+        # Send a test message longer than self.size to trigger error
+        m = 'A test message that is too long'
+        smtp = smtplib.SMTP(HOST, self.port, local_hostname='localhost', timeout=3)
+        try:
+            smtp.sendmail('John', 'Sally', m)
+            raise 'Expected error code not received'
+        except smtplib.SMTPDataError as err:
+            self.assertEqual(str(err), "(552, b'Too much mail data')")
+
+        # See below for time out reason
+        time.sleep(0.01)
         smtp.quit()
 
     def testSend(self):
@@ -305,7 +334,7 @@
     def __init__(self, extra_features, *args, **kw):
         self._extrafeatures = ''.join(
             [ "250-{0}\r\n".format(x) for x in extra_features ])
-        super(SimSMTPChannel, self).__init__(*args, **kw)
+        super(SimSMTPChannel, self).__init__(*args, size = 0, **kw)
 
     def smtp_EHLO(self, arg):
         resp = ('250-testhost\r\n'


More information about the Python-bugs-list mailing list