Long ago, Guido suggested that I add this to the standard library.
I'm now checking it in. I need to write some documentation for it, but I don't have time right now. Still, I wanted to get this into 2.1a2. # 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.
This commit is contained in:
parent
f9abaf42f8
commit
7e0d9563ca
|
@ -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
|
Loading…
Reference in New Issue