diff options
Diffstat (limited to 'Lib/smtpd.py')
| -rwxr-xr-x | Lib/smtpd.py | 287 | 
1 files changed, 206 insertions, 81 deletions
| diff --git a/Lib/smtpd.py b/Lib/smtpd.py index db7c8675e8..732066ef9a 100755 --- a/Lib/smtpd.py +++ b/Lib/smtpd.py @@ -1,5 +1,5 @@  #! /usr/bin/env python3 -"""An RFC 5321 smtp proxy. +"""An RFC 5321 smtp proxy with optional RFC 1870 and RFC 6531 extensions.  Usage: %(program)s [options] [localhost:localport [remotehost:remoteport]] @@ -25,6 +25,10 @@ Options:          Restrict the total size of the incoming message to "limit" number of          bytes via the RFC 1870 SIZE extension.  Defaults to 33554432 bytes. +    --smtputf8 +    -u +        Enable the SMTPUTF8 extension and behave as an RFC 6531 smtp proxy. +      --debug      -d          Turn on debugging prints. @@ -98,7 +102,6 @@ class Devnull:  DEBUGSTREAM = Devnull()  NEWLINE = '\n' -EMPTYSTRING = ''  COMMASPACE = ', '  DATA_SIZE_DEFAULT = 33554432 @@ -116,26 +119,48 @@ class SMTPChannel(asynchat.async_chat):      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()) + +    @property +    def max_command_size_limit(self): +        try: +            return max(self.command_size_limits.values()) +        except ValueError: +            return self.command_size_limit      def __init__(self, server, conn, addr, data_size_limit=DATA_SIZE_DEFAULT, -                 map=None): +                 map=None, enable_SMTPUTF8=False, decode_data=None):          asynchat.async_chat.__init__(self, conn, map=map)          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.enable_SMTPUTF8 = enable_SMTPUTF8 +        if enable_SMTPUTF8: +            if decode_data: +                raise ValueError("decode_data and enable_SMTPUTF8 cannot" +                                 " be set to True at the same time") +            decode_data = False +        if decode_data is None: +            warn("The decode_data default of True will change to False in 3.6;" +                 " specify an explicit value for this keyword", +                 DeprecationWarning, 2) +            decode_data = True +        self._decode_data = decode_data +        if decode_data: +            self._emptystring = '' +            self._linesep = '\r\n' +            self._dotsep = '.' +            self._newline = NEWLINE +        else: +            self._emptystring = b'' +            self._linesep = b'\r\n' +            self._dotsep = ord(b'.') +            self._newline = b'\n' +        self._set_rset_state()          self.seen_greeting = '' -        self.mailfrom = None -        self.rcpttos = [] -        self.received_data = '' +        self.extended_smtp = False +        self.command_size_limits.clear()          self.fqdn = socket.getfqdn() -        self.num_bytes = 0          try:              self.peer = conn.getpeername()          except OSError as err: @@ -147,8 +172,22 @@ class SMTPChannel(asynchat.async_chat):              return          print('Peer:', repr(self.peer), file=DEBUGSTREAM)          self.push('220 %s %s' % (self.fqdn, __version__)) + +    def _set_post_data_state(self): +        """Reset state variables to their post-DATA state.""" +        self.smtp_state = self.COMMAND +        self.mailfrom = None +        self.rcpttos = [] +        self.require_SMTPUTF8 = False +        self.num_bytes = 0          self.set_terminator(b'\r\n') -        self.extended_smtp = False + +    def _set_rset_state(self): +        """Reset all state variables except the greeting.""" +        self._set_post_data_state() +        self.received_data = '' +        self.received_lines = [] +      # properties for backwards-compatibility      @property @@ -272,9 +311,10 @@ class SMTPChannel(asynchat.async_chat):              "set 'addr' instead", DeprecationWarning, 2)          self.addr = value -    # Overrides base class for convenience +    # Overrides base class for convenience.      def push(self, msg): -        asynchat.async_chat.push(self, bytes(msg + '\r\n', 'ascii')) +        asynchat.async_chat.push(self, bytes( +            msg + '\r\n', 'utf-8' if self.require_SMTPUTF8 else 'ascii'))      # Implementation of base class abstract method      def collect_incoming_data(self, data): @@ -287,11 +327,14 @@ class SMTPChannel(asynchat.async_chat):              return          elif limit:              self.num_bytes += len(data) -        self.received_lines.append(str(data, "utf-8")) +        if self._decode_data: +            self.received_lines.append(str(data, 'utf-8')) +        else: +            self.received_lines.append(data)      # Implementation of base class abstract method      def found_terminator(self): -        line = EMPTYSTRING.join(self.received_lines) +        line = self._emptystring.join(self.received_lines)          print('Data:', repr(line), file=DEBUGSTREAM)          self.received_lines = []          if self.smtp_state == self.COMMAND: @@ -299,7 +342,8 @@ class SMTPChannel(asynchat.async_chat):              if not line:                  self.push('500 Error: bad syntax')                  return -            method = None +            if not self._decode_data: +                line = str(line, 'utf-8')              i = line.find(' ')              if i < 0:                  command = line.upper() @@ -330,21 +374,21 @@ class SMTPChannel(asynchat.async_chat):              # Remove extraneous carriage returns and de-transparency according              # to RFC 5321, Section 4.5.2.              data = [] -            for text in line.split('\r\n'): -                if text and text[0] == '.': +            for text in line.split(self._linesep): +                if text and text[0] == self._dotsep:                      data.append(text[1:])                  else:                      data.append(text) -            self.received_data = NEWLINE.join(data) -            status = self.smtp_server.process_message(self.peer, -                                                      self.mailfrom, -                                                      self.rcpttos, -                                                      self.received_data) -            self.rcpttos = [] -            self.mailfrom = None -            self.smtp_state = self.COMMAND -            self.num_bytes = 0 -            self.set_terminator(b'\r\n') +            self.received_data = self._newline.join(data) +            args = (self.peer, self.mailfrom, self.rcpttos, self.received_data) +            kwargs = {} +            if not self._decode_data: +                kwargs = { +                    'mail_options': self.mail_options, +                    'rcpt_options': self.rcpt_options, +                } +            status = self.smtp_server.process_message(*args, **kwargs) +            self._set_post_data_state()              if not status:                  self.push('250 OK')              else: @@ -355,26 +399,35 @@ class SMTPChannel(asynchat.async_chat):          if not arg:              self.push('501 Syntax: HELO hostname')              return +        # See issue #21783 for a discussion of this behavior.          if self.seen_greeting:              self.push('503 Duplicate HELO/EHLO') -        else: -            self.seen_greeting = arg -            self.extended_smtp = False -            self.push('250 %s' % self.fqdn) +            return +        self._set_rset_state() +        self.seen_greeting = arg +        self.push('250 %s' % self.fqdn)      def smtp_EHLO(self, arg):          if not arg:              self.push('501 Syntax: EHLO hostname')              return +        # See issue #21783 for a discussion of this behavior.          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') +            return +        self._set_rset_state() +        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.command_size_limits['MAIL'] += 26 +        if not self._decode_data: +            self.push('250-8BITMIME') +        if self.enable_SMTPUTF8: +            self.push('250-SMTPUTF8') +            self.command_size_limits['MAIL'] += 10 +        self.push('250 HELP')      def smtp_NOOP(self, arg):          if arg: @@ -405,11 +458,15 @@ class SMTPChannel(asynchat.async_chat):          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()} +        # Return params as dictionary. Return None if not all parameters +        # appear to be syntactically valid according to RFC 1869. +        result = {} +        for param in params: +            param, eq, value = param.partition('=') +            if not param.isalnum() or eq and not value: +                return None +            result[param] = value if eq else True +        return result      def smtp_HELP(self, arg):          if arg: @@ -459,7 +516,7 @@ class SMTPChannel(asynchat.async_chat):      def smtp_MAIL(self, arg):          if not self.seen_greeting: -            self.push('503 Error: send HELO first'); +            self.push('503 Error: send HELO first')              return          print('===> MAIL', arg, file=DEBUGSTREAM)          syntaxerr = '501 Syntax: MAIL FROM: <address>' @@ -479,10 +536,23 @@ class SMTPChannel(asynchat.async_chat):          if self.mailfrom:              self.push('503 Error: nested MAIL command')              return -        params = self._getparams(params.upper()) +        self.mail_options = params.upper().split() +        params = self._getparams(self.mail_options)          if params is None:              self.push(syntaxerr)              return +        if not self._decode_data: +            body = params.pop('BODY', '7BIT') +            if body not in ['7BIT', '8BITMIME']: +                self.push('501 Error: BODY can only be one of 7BIT, 8BITMIME') +                return +        if self.enable_SMTPUTF8: +            smtputf8 = params.pop('SMTPUTF8', False) +            if smtputf8 is True: +                self.require_SMTPUTF8 = True +            elif smtputf8 is not False: +                self.push('501 Error: SMTPUTF8 takes no arguments') +                return          size = params.pop('SIZE', None)          if size:              if not size.isdigit(): @@ -517,16 +587,16 @@ class SMTPChannel(asynchat.async_chat):          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 params and len(params.keys()) > 0: +        if not self.extended_smtp and params: +            self.push(syntaxerr) +            return +        self.rcpt_options = params.upper().split() +        params = self._getparams(self.rcpt_options) +        if params is None: +            self.push(syntaxerr) +            return +        # XXX currently there are no options we recognize. +        if len(params.keys()) > 0:              self.push('555 RCPT TO parameters not recognized or not implemented')              return          self.rcpttos.append(address) @@ -537,11 +607,7 @@ class SMTPChannel(asynchat.async_chat):          if arg:              self.push('501 Syntax: RSET')              return -        # Resets the sender, recipients, and data, but not the greeting -        self.mailfrom = None -        self.rcpttos = [] -        self.received_data = '' -        self.smtp_state = self.COMMAND +        self._set_rset_state()          self.push('250 OK')      def smtp_DATA(self, arg): @@ -568,13 +634,29 @@ class SMTPServer(asyncore.dispatcher):      channel_class = SMTPChannel      def __init__(self, localaddr, remoteaddr, -                 data_size_limit=DATA_SIZE_DEFAULT, map=None): +                 data_size_limit=DATA_SIZE_DEFAULT, map=None, +                 enable_SMTPUTF8=False, decode_data=None):          self._localaddr = localaddr          self._remoteaddr = remoteaddr          self.data_size_limit = data_size_limit +        self.enable_SMTPUTF8 = enable_SMTPUTF8 +        if enable_SMTPUTF8: +            if decode_data: +                raise ValueError("The decode_data and enable_SMTPUTF8" +                                 " parameters cannot be set to True at the" +                                 " same time.") +            decode_data = False +        if decode_data is None: +            warn("The decode_data default of True will change to False in 3.6;" +                 " specify an explicit value for this keyword", +                 DeprecationWarning, 2) +            decode_data = True +        self._decode_data = decode_data          asyncore.dispatcher.__init__(self, map=map)          try: -            self.create_socket(socket.AF_INET, socket.SOCK_STREAM) +            gai_results = socket.getaddrinfo(*localaddr, +                                             type=socket.SOCK_STREAM) +            self.create_socket(gai_results[0][0], gai_results[0][1])              # try to re-use a server port if possible              self.set_reuse_addr()              self.bind(localaddr) @@ -589,11 +671,16 @@ 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, self.data_size_limit, -                                     self._map) +        channel = self.channel_class(self, +                                     conn, +                                     addr, +                                     self.data_size_limit, +                                     self._map, +                                     self.enable_SMTPUTF8, +                                     self._decode_data)      # API for "doing something useful with the message" -    def process_message(self, peer, mailfrom, rcpttos, data): +    def process_message(self, peer, mailfrom, rcpttos, data, **kwargs):          """Override this abstract method to handle messages from the client.          peer is a tuple containing (ipaddr, port) of the client that made the @@ -611,29 +698,58 @@ class SMTPServer(asyncore.dispatcher):          containing a `.' followed by other text has had the leading dot          removed. -        This function should return None, for a normal `250 Ok' response; -        otherwise it returns the desired response string in RFC 821 format. +        kwargs is a dictionary containing additional information. It is empty +        unless decode_data=False or enable_SMTPUTF8=True was given as init +        parameter, in which case ut will contain the following keys: +            'mail_options': list of parameters to the mail command.  All +                            elements are uppercase strings.  Example: +                            ['BODY=8BITMIME', 'SMTPUTF8']. +            'rcpt_options': same, for the rcpt command. + +        This function should return None for a normal `250 Ok' response; +        otherwise, it should return 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): + +    def _print_message_content(self, peer, data):          inheaders = 1 -        lines = data.split('\n') -        print('---------- MESSAGE FOLLOWS ----------') +        lines = data.splitlines()          for line in lines:              # headers first              if inheaders and not line: -                print('X-Peer:', peer[0]) +                peerheader = 'X-Peer: ' + peer[0] +                if not isinstance(data, str): +                    # decoded_data=false; make header match other binary output +                    peerheader = repr(peerheader.encode('utf-8')) +                print(peerheader)                  inheaders = 0 +            if not isinstance(data, str): +                # Avoid spurious 'str on bytes instance' warning. +                line = repr(line)              print(line) + +    def process_message(self, peer, mailfrom, rcpttos, data, **kwargs): +        print('---------- MESSAGE FOLLOWS ----------') +        if kwargs: +            if kwargs.get('mail_options'): +                print('mail options: %s' % kwargs['mail_options']) +            if kwargs.get('rcpt_options'): +                print('rcpt options: %s\n' % kwargs['rcpt_options']) +        self._print_message_content(peer, data)          print('------------ END MESSAGE ------------')  class PureProxy(SMTPServer): +    def __init__(self, *args, **kwargs): +        if 'enable_SMTPUTF8' in kwargs and kwargs['enable_SMTPUTF8']: +            raise ValueError("PureProxy does not support SMTPUTF8.") +        super(PureProxy, self).__init__(*args, **kwargs) +      def process_message(self, peer, mailfrom, rcpttos, data):          lines = data.split('\n')          # Look for the last header @@ -674,6 +790,11 @@ class PureProxy(SMTPServer):  class MailmanProxy(PureProxy): +    def __init__(self, *args, **kwargs): +        if 'enable_SMTPUTF8' in kwargs and kwargs['enable_SMTPUTF8']: +            raise ValueError("MailmanProxy does not support SMTPUTF8.") +        super(PureProxy, self).__init__(*args, **kwargs) +      def process_message(self, peer, mailfrom, rcpttos, data):          from io import StringIO          from Mailman import Utils @@ -752,17 +873,19 @@ class MailmanProxy(PureProxy):  class Options: -    setuid = 1 +    setuid = True      classname = 'PureProxy'      size_limit = None +    enable_SMTPUTF8 = False  def parseargs():      global DEBUGSTREAM      try:          opts, args = getopt.getopt( -            sys.argv[1:], 'nVhc:s:d', -            ['class=', 'nosetuid', 'version', 'help', 'size=', 'debug']) +            sys.argv[1:], 'nVhc:s:du', +            ['class=', 'nosetuid', 'version', 'help', 'size=', 'debug', +             'smtputf8'])      except getopt.error as e:          usage(1, e) @@ -774,11 +897,13 @@ def parseargs():              print(__version__)              sys.exit(0)          elif opt in ('-n', '--nosetuid'): -            options.setuid = 0 +            options.setuid = False          elif opt in ('-c', '--class'):              options.classname = arg          elif opt in ('-d', '--debug'):              DEBUGSTREAM = sys.stderr +        elif opt in ('-u', '--smtputf8'): +            options.enable_SMTPUTF8 = True          elif opt in ('-s', '--size'):              try:                  int_size = int(arg) @@ -833,7 +958,7 @@ if __name__ == '__main__':      class_ = getattr(mod, classname)      proxy = class_((options.localhost, options.localport),                     (options.remotehost, options.remoteport), -                   options.size_limit) +                   options.size_limit, enable_SMTPUTF8=options.enable_SMTPUTF8)      if options.setuid:          try:              import pwd | 
