summaryrefslogtreecommitdiffstats
path: root/Lib/smtpd.py
diff options
context:
space:
mode:
Diffstat (limited to 'Lib/smtpd.py')
-rwxr-xr-xLib/smtpd.py531
1 files changed, 531 insertions, 0 deletions
diff --git a/Lib/smtpd.py b/Lib/smtpd.py
new file mode 100755
index 0000000..21c0114
--- /dev/null
+++ b/Lib/smtpd.py
@@ -0,0 +1,531 @@
+#! /usr/bin/env python
+"""An RFC 821 smtp proxy.
+
+Usage: %(program)s [options] localhost:port remotehost:port
+
+Options:
+
+ --nosetuid
+ -n
+ This program generally tries to setuid `nobody', unless this flag is
+ set. The setuid call will fail if this program is not run as root (in
+ which case, use this flag).
+
+ --version
+ -V
+ Print the version number and exit.
+
+ --class classname
+ -c classname
+ Use `classname' as the concrete SMTP proxy class. Uses `SMTPProxy' by
+ default.
+
+ --debug
+ -d
+ Turn on debugging prints.
+
+ --help
+ -h
+ Print this message and exit.
+
+Version: %(__version__)s
+
+"""
+
+# Overview:
+#
+# This file implements the minimal SMTP protocol as defined in RFC 821. It
+# has a hierarchy of classes which implement the backend functionality for the
+# smtpd. A number of classes are provided:
+#
+# SMTPServer - the base class for the backend. Raises an UnimplementedError
+# if you try to use it.
+#
+# DebuggingServer - simply prints each message it receives on stdout.
+#
+# PureProxy - Proxies all messages to a real smtpd which does final
+# delivery. One known problem with this class is that it doesn't handle
+# SMTP errors from the backend server at all. This should be fixed
+# (contributions are welcome!).
+#
+# MailmanProxy - An experimental hack to work with GNU Mailman
+# <www.list.org>. Using this server as your real incoming smtpd, your
+# mailhost will automatically recognize and accept mail destined to Mailman
+# lists when those lists are created. Every message not destined for a list
+# 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
+#
+# Author: Barry Warsaw <barry@digicool.com>
+#
+# TODO:
+#
+# - support mailbox delivery
+# - alias files
+# - ESMTP
+# - handle error codes from the backend smtpd
+
+import sys
+import os
+import errno
+import getopt
+import time
+import socket
+import asyncore
+import asynchat
+
+
+program = sys.argv[0]
+__version__ = 'Python SMTP proxy version 0.2'
+
+
+class Devnull:
+ def write(self, msg): pass
+ def flush(self): pass
+
+
+DEBUGSTREAM = Devnull()
+NEWLINE = '\n'
+EMPTYSTRING = ''
+
+
+
+def usage(code, msg=''):
+ print >> sys.stderr, __doc__ % globals()
+ if msg:
+ print >> sys.stderr, msg
+ sys.exit(code)
+
+
+
+class SMTPChannel(asynchat.async_chat):
+ COMMAND = 0
+ DATA = 1
+
+ def __init__(self, server, conn, addr):
+ asynchat.async_chat.__init__(self, conn)
+ self.__server = server
+ self.__conn = conn
+ self.__addr = addr
+ self.__line = []
+ self.__state = self.COMMAND
+ self.__greeting = 0
+ self.__mailfrom = None
+ self.__rcpttos = []
+ self.__data = ''
+ self.__fqdn = socket.gethostbyaddr(
+ socket.gethostbyname(socket.gethostname()))[0]
+ self.__peer = conn.getpeername()
+ print >> DEBUGSTREAM, 'Peer:', repr(self.__peer)
+ self.push('220 %s %s' % (self.__fqdn, __version__))
+ self.set_terminator('\r\n')
+
+ # Overrides base class for convenience
+ def push(self, msg):
+ asynchat.async_chat.push(self, msg + '\r\n')
+
+ # Implementation of base class abstract method
+ def collect_incoming_data(self, data):
+ self.__line.append(data)
+
+ # Implementation of base class abstract method
+ def found_terminator(self):
+ line = EMPTYSTRING.join(self.__line)
+ self.__line = []
+ if self.__state == self.COMMAND:
+ if not line:
+ self.push('500 Error: bad syntax')
+ return
+ method = None
+ i = line.find(' ')
+ if i < 0:
+ command = line.upper()
+ arg = None
+ else:
+ command = line[:i].upper()
+ arg = line[i+1:].strip()
+ method = getattr(self, 'smtp_' + command, None)
+ if not method:
+ self.push('502 Error: command "%s" not implemented' % command)
+ return
+ method(arg)
+ return
+ else:
+ if self.__state <> self.DATA:
+ self.push('451 Internal confusion')
+ return
+ # Remove extraneous carriage returns and de-transparency according
+ # to RFC 821, Section 4.5.2.
+ data = []
+ for text in line.split('\r\n'):
+ if text and text[0] == '.':
+ data.append(text[1:])
+ 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('\r\n')
+ if not status:
+ self.push('250 Ok')
+ else:
+ self.push(status)
+
+ # SMTP and ESMTP commands
+ def smtp_HELO(self, arg):
+ if not arg:
+ self.push('501 Syntax: HELO hostname')
+ return
+ if self.__greeting:
+ self.push('503 Duplicate HELO/EHLO')
+ else:
+ self.__greeting = arg
+ self.push('250 %s' % self.__fqdn)
+
+ def smtp_NOOP(self, arg):
+ if arg:
+ self.push('501 Syntax: NOOP')
+ else:
+ 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 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
+
+ def smtp_MAIL(self, arg):
+ print >> DEBUGSTREAM, '===> MAIL', arg
+ address = self.__getaddr('FROM:', arg)
+ if not address:
+ self.push('501 Syntax: MAIL FROM:<address>')
+ return
+ if self.__mailfrom:
+ self.push('503 Error: nested MAIL command')
+ return
+ self.__mailfrom = address
+ print >> DEBUGSTREAM, 'sender:', self.__mailfrom
+ self.push('250 Ok')
+
+ def smtp_RCPT(self, arg):
+ print >> DEBUGSTREAM, '===> RCPT', arg
+ if not self.__mailfrom:
+ self.push('503 Error: need MAIL command')
+ return
+ address = self.__getaddr('TO:', arg)
+ if not address:
+ self.push('501 Syntax: RCPT TO: <address>')
+ return
+ if address.lower().startswith('stimpy'):
+ self.push('503 You suck %s' % address)
+ return
+ self.__rcpttos.append(address)
+ print >> DEBUGSTREAM, 'recips:', self.__rcpttos
+ self.push('250 Ok')
+
+ def smtp_RSET(self, arg):
+ if arg:
+ self.push('501 Syntax: RSET')
+ return
+ # Resets the sender, recipients, and data, but not the greeting
+ self.__mailfrom = None
+ self.__rcpttos = []
+ self.__data = ''
+ self.__state = self.COMMAND
+ self.push('250 Ok')
+
+ def smtp_DATA(self, arg):
+ if not self.__rcpttos:
+ self.push('503 Error: need RCPT command')
+ return
+ if arg:
+ self.push('501 Syntax: DATA')
+ return
+ self.__state = self.DATA
+ self.set_terminator('\r\n.\r\n')
+ self.push('354 End data with <CR><LF>.<CR><LF>')
+
+
+
+class SMTPServer(asyncore.dispatcher):
+ def __init__(self, localaddr, remoteaddr):
+ self._localaddr = localaddr
+ self._remoteaddr = remoteaddr
+ asyncore.dispatcher.__init__(self)
+ self.create_socket(socket.AF_INET, socket.SOCK_STREAM)
+ # try to re-use a server port if possible
+ self.socket.setsockopt(
+ socket.SOL_SOCKET, socket.SO_REUSEADDR,
+ self.socket.getsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR) | 1)
+ self.bind(localaddr)
+ self.listen(5)
+ print '%s started at %s\n\tLocal addr: %s\n\tRemote addr:%s' % (
+ self.__class__.__name__, time.ctime(time.time()),
+ localaddr, remoteaddr)
+
+ def handle_accept(self):
+ conn, addr = self.accept()
+ print >> DEBUGSTREAM, 'Incoming connection from %s' % repr(addr)
+ channel = SMTPChannel(self, conn, addr)
+
+ # API for "doing something useful with the message"
+ def process_message(self, peer, mailfrom, rcpttos, data):
+ """Override this abstract method to handle messages from the client.
+
+ peer is a tuple containing (ipaddr, port) of the client that made the
+ socket connection to our smtp port.
+
+ mailfrom is the raw address the client claims the message is coming
+ from.
+
+ rcpttos is a list of raw addresses the client wishes to deliver the
+ message to.
+
+ data is a string containing the entire full text of the message,
+ headers (if supplied) and all. It has been `de-transparencied'
+ according to RFC 821, Section 4.5.2. In other words, a line
+ 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.
+
+ """
+ raise UnimplementedError
+
+
+class DebuggingServer(SMTPServer):
+ # Do something with the gathered message
+ def process_message(self, peer, mailfrom, rcpttos, data):
+ inheaders = 1
+ lines = data.split('\n')
+ print '---------- MESSAGE FOLLOWS ----------'
+ for line in lines:
+ # headers first
+ if inheaders and not line:
+ print 'X-Peer:', peer[0]
+ inheaders = 0
+ print line
+ print '------------ END MESSAGE ------------'
+
+
+
+class PureProxy(SMTPServer):
+ def process_message(self, peer, mailfrom, rcpttos, data):
+ lines = data.split('\n')
+ # Look for the last header
+ i = 0
+ for line in lines:
+ if not line:
+ break
+ i += 1
+ lines.insert(i, 'X-Peer: %s' % peer[0])
+ data = NEWLINE.join(lines)
+ refused = self._deliver(mailfrom, rcpttos, data)
+ # TBD: what to do with refused addresses?
+ print >> DEBUGSTREAM, 'we got some refusals'
+
+ def _deliver(self, mailfrom, rcpttos, data):
+ import smtplib
+ refused = {}
+ try:
+ s = smtplib.SMTP()
+ s.connect(self._remoteaddr[0], self._remoteaddr[1])
+ try:
+ refused = s.sendmail(mailfrom, rcpttos, data)
+ finally:
+ s.quit()
+ except smtplib.SMTPRecipientsRefused, e:
+ print >> DEBUGSTREAM, 'got SMTPRecipientsRefused'
+ refused = e.recipients
+ except (socket.error, smtplib.SMTPException), e:
+ print >> DEBUGSTREAM, 'got', e.__class__
+ # All recipients were refused. If the exception had an associated
+ # error code, use it. Otherwise,fake it with a non-triggering
+ # exception code.
+ errcode = getattr(e, 'smtp_code', -1)
+ errmsg = getattr(e, 'smtp_error', 'ignore')
+ for r in rcpttos:
+ refused[r] = (errcode, errmsg)
+ return refused
+
+
+
+class MailmanProxy(PureProxy):
+ def process_message(self, peer, mailfrom, rcpttos, data):
+ from cStringIO import StringIO
+ import paths
+ from Mailman import Utils
+ from Mailman import Message
+ from Mailman import MailList
+ # If the message is to a Mailman mailing list, then we'll invoke the
+ # Mailman script directly, without going through the real smtpd.
+ # Otherwise we'll forward it to the local proxy for disposition.
+ listnames = []
+ for rcpt in rcpttos:
+ local = rcpt.lower().split('@')[0]
+ # We allow the following variations on the theme
+ # listname
+ # listname-admin
+ # listname-owner
+ # listname-request
+ # listname-join
+ # listname-leave
+ parts = local.split('-')
+ if len(parts) > 2:
+ continue
+ listname = parts[0]
+ if len(parts) == 2:
+ command = parts[1]
+ else:
+ command = ''
+ if not Utils.list_exists(listname) or command not in (
+ '', 'admin', 'owner', 'request', 'join', 'leave'):
+ continue
+ listnames.append((rcpt, listname, command))
+ # Remove all list recipients from rcpttos and forward what we're not
+ # going to take care of ourselves. Linear removal should be fine
+ # since we don't expect a large number of recipients.
+ for rcpt, listname, command in listnames:
+ rcpttos.remove(rcpt)
+ # If there's any non-list destined recipients left,
+ print >> DEBUGSTREAM, 'forwarding recips:', ' '.join(rcpttos)
+ if rcpttos:
+ refused = self._deliver(mailfrom, rcpttos, data)
+ # TBD: what to do with refused addresses?
+ print >> DEBUGSTREAM, 'we got refusals'
+ # Now deliver directly to the list commands
+ mlists = {}
+ s = StringIO(data)
+ msg = Message.Message(s)
+ # These headers are required for the proper execution of Mailman. All
+ # MTAs in existance seem to add these if the original message doesn't
+ # have them.
+ if not msg.getheader('from'):
+ msg['From'] = mailfrom
+ if not msg.getheader('date'):
+ msg['Date'] = time.ctime(time.time())
+ for rcpt, listname, command in listnames:
+ print >> DEBUGSTREAM, 'sending message to', rcpt
+ mlist = mlists.get(listname)
+ if not mlist:
+ mlist = MailList.MailList(listname, lock=0)
+ mlists[listname] = mlist
+ # dispatch on the type of command
+ if command == '':
+ # post
+ msg.Enqueue(mlist, tolist=1)
+ elif command == 'admin':
+ msg.Enqueue(mlist, toadmin=1)
+ elif command == 'owner':
+ msg.Enqueue(mlist, toowner=1)
+ elif command == 'request':
+ msg.Enqueue(mlist, torequest=1)
+ elif command in ('join', 'leave'):
+ # TBD: this is a hack!
+ if command == 'join':
+ msg['Subject'] = 'subscribe'
+ else:
+ msg['Subject'] = 'unsubscribe'
+ msg.Enqueue(mlist, torequest=1)
+
+
+
+class Options:
+ setuid = 1
+ classname = 'PureProxy'
+
+
+def parseargs():
+ global DEBUGSTREAM
+ try:
+ opts, args = getopt.getopt(
+ sys.argv[1:], 'nVhc:d',
+ ['class=', 'nosetuid', 'version', 'help', 'debug'])
+ except getopt.error, e:
+ usage(1, e)
+
+ options = Options()
+ for opt, arg in opts:
+ if opt in ('-h', '--help'):
+ usage(0)
+ elif opt in ('-V', '--version'):
+ print >> sys.stderr, __version__
+ sys.exit(0)
+ elif opt in ('-n', '--nosetuid'):
+ options.setuid = 0
+ elif opt in ('-c', '--class'):
+ options.classname = arg
+ elif opt in ('-d', '--debug'):
+ DEBUGSTREAM = sys.stderr
+
+ # parse the rest of the arguments
+ try:
+ localspec = args[0]
+ remotespec = args[1]
+ except IndexError:
+ usage(1, 'Not enough arguments')
+ # split into host/port pairs
+ i = localspec.find(':')
+ if i < 0:
+ usage(1, 'Bad local spec: "%s"' % localspec)
+ options.localhost = localspec[:i]
+ try:
+ options.localport = int(localspec[i+1:])
+ except ValueError:
+ usage(1, 'Bad local port: "%s"' % localspec)
+ i = remotespec.find(':')
+ if i < 0:
+ usage(1, 'Bad remote spec: "%s"' % remotespec)
+ options.remotehost = remotespec[:i]
+ try:
+ options.remoteport = int(remotespec[i+1:])
+ except ValueError:
+ usage(1, 'Bad remote port: "%s"' % remotespec)
+ return options
+
+
+
+if __name__ == '__main__':
+ options = parseargs()
+ # Become nobody
+ if options.setuid:
+ try:
+ import pwd
+ except ImportError:
+ print >> sys.stderr, \
+ 'Cannot import module "pwd"; try running with -n option.'
+ sys.exit(1)
+ nobody = pwd.getpwnam('nobody')[2]
+ try:
+ os.setuid(nobody)
+ except OSError, e:
+ if e.errno <> errno.EPERM: raise
+ print >> sys.stderr, \
+ 'Cannot setuid "nobody"; try running with -n option.'
+ sys.exit(1)
+ import __main__
+ class_ = getattr(__main__, options.classname)
+ proxy = class_((options.localhost, options.localport),
+ (options.remotehost, options.remoteport))
+ try:
+ asyncore.loop()
+ except KeyboardInterrupt:
+ pass