1#! /usr/bin/env python
2"""An RFC 2821 smtp proxy.
3
4Usage: %(program)s [options] [localhost:localport [remotehost:remoteport]]
5
6Options:
7
8    --nosetuid
9    -n
10        This program generally tries to setuid `nobody', unless this flag is
11        set.  The setuid call will fail if this program is not run as root (in
12        which case, use this flag).
13
14    --version
15    -V
16        Print the version number and exit.
17
18    --class classname
19    -c classname
20        Use `classname' as the concrete SMTP proxy class.  Uses `PureProxy' by
21        default.
22
23    --debug
24    -d
25        Turn on debugging prints.
26
27    --help
28    -h
29        Print this message and exit.
30
31Version: %(__version__)s
32
33If localhost is not given then `localhost' is used, and if localport is not
34given then 8025 is used.  If remotehost is not given then `localhost' is used,
35and if remoteport is not given, then 25 is used.
36"""
37
38# Overview:
39#
40# This file implements the minimal SMTP protocol as defined in RFC 821.  It
41# has a hierarchy of classes which implement the backend functionality for the
42# smtpd.  A number of classes are provided:
43#
44#   SMTPServer - the base class for the backend.  Raises NotImplementedError
45#   if you try to use it.
46#
47#   DebuggingServer - simply prints each message it receives on stdout.
48#
49#   PureProxy - Proxies all messages to a real smtpd which does final
50#   delivery.  One known problem with this class is that it doesn't handle
51#   SMTP errors from the backend server at all.  This should be fixed
52#   (contributions are welcome!).
53#
54#   MailmanProxy - An experimental hack to work with GNU Mailman
55#   <www.list.org>.  Using this server as your real incoming smtpd, your
56#   mailhost will automatically recognize and accept mail destined to Mailman
57#   lists when those lists are created.  Every message not destined for a list
58#   gets forwarded to a real backend smtpd, as with PureProxy.  Again, errors
59#   are not handled correctly yet.
60#
61# Please note that this script requires Python 2.0
62#
63# Author: Barry Warsaw <barry@python.org>
64#
65# TODO:
66#
67# - support mailbox delivery
68# - alias files
69# - ESMTP
70# - handle error codes from the backend smtpd
71
72import sys
73import os
74import errno
75import getopt
76import time
77import socket
78import asyncore
79import asynchat
80
81__all__ = ["SMTPServer","DebuggingServer","PureProxy","MailmanProxy"]
82
83program = sys.argv[0]
84__version__ = 'Python SMTP proxy version 0.2'
85
86
87class Devnull:
88    def write(self, msg): pass
89    def flush(self): pass
90
91
92DEBUGSTREAM = Devnull()
93NEWLINE = '\n'
94EMPTYSTRING = ''
95COMMASPACE = ', '
96
97
98def usage(code, msg=''):
99    print >> sys.stderr, __doc__ % globals()
100    if msg:
101        print >> sys.stderr, msg
102    sys.exit(code)
103
104
105class SMTPChannel(asynchat.async_chat):
106    COMMAND = 0
107    DATA = 1
108
109    def __init__(self, server, conn, addr):
110        asynchat.async_chat.__init__(self, conn)
111        self.__server = server
112        self.__conn = conn
113        self.__addr = addr
114        self.__line = []
115        self.__state = self.COMMAND
116        self.__greeting = 0
117        self.__mailfrom = None
118        self.__rcpttos = []
119        self.__data = ''
120        self.__fqdn = socket.getfqdn()
121        try:
122            self.__peer = conn.getpeername()
123        except socket.error, err:
124            # a race condition  may occur if the other end is closing
125            # before we can get the peername
126            self.close()
127            if err[0] != errno.ENOTCONN:
128                raise
129            return
130        print >> DEBUGSTREAM, 'Peer:', repr(self.__peer)
131        self.push('220 %s %s' % (self.__fqdn, __version__))
132        self.set_terminator('\r\n')
133
134    # Overrides base class for convenience
135    def push(self, msg):
136        asynchat.async_chat.push(self, msg + '\r\n')
137
138    # Implementation of base class abstract method
139    def collect_incoming_data(self, data):
140        self.__line.append(data)
141
142    # Implementation of base class abstract method
143    def found_terminator(self):
144        line = EMPTYSTRING.join(self.__line)
145        print >> DEBUGSTREAM, 'Data:', repr(line)
146        self.__line = []
147        if self.__state == self.COMMAND:
148            if not line:
149                self.push('500 Error: bad syntax')
150                return
151            method = None
152            i = line.find(' ')
153            if i < 0:
154                command = line.upper()
155                arg = None
156            else:
157                command = line[:i].upper()
158                arg = line[i+1:].strip()
159            method = getattr(self, 'smtp_' + command, None)
160            if not method:
161                self.push('502 Error: command "%s" not implemented' % command)
162                return
163            method(arg)
164            return
165        else:
166            if self.__state != self.DATA:
167                self.push('451 Internal confusion')
168                return
169            # Remove extraneous carriage returns and de-transparency according
170            # to RFC 821, Section 4.5.2.
171            data = []
172            for text in line.split('\r\n'):
173                if text and text[0] == '.':
174                    data.append(text[1:])
175                else:
176                    data.append(text)
177            self.__data = NEWLINE.join(data)
178            status = self.__server.process_message(self.__peer,
179                                                   self.__mailfrom,
180                                                   self.__rcpttos,
181                                                   self.__data)
182            self.__rcpttos = []
183            self.__mailfrom = None
184            self.__state = self.COMMAND
185            self.set_terminator('\r\n')
186            if not status:
187                self.push('250 Ok')
188            else:
189                self.push(status)
190
191    # SMTP and ESMTP commands
192    def smtp_HELO(self, arg):
193        if not arg:
194            self.push('501 Syntax: HELO hostname')
195            return
196        if self.__greeting:
197            self.push('503 Duplicate HELO/EHLO')
198        else:
199            self.__greeting = arg
200            self.push('250 %s' % self.__fqdn)
201
202    def smtp_NOOP(self, arg):
203        if arg:
204            self.push('501 Syntax: NOOP')
205        else:
206            self.push('250 Ok')
207
208    def smtp_QUIT(self, arg):
209        # args is ignored
210        self.push('221 Bye')
211        self.close_when_done()
212
213    # factored
214    def __getaddr(self, keyword, arg):
215        address = None
216        keylen = len(keyword)
217        if arg[:keylen].upper() == keyword:
218            address = arg[keylen:].strip()
219            if not address:
220                pass
221            elif address[0] == '<' and address[-1] == '>' and address != '<>':
222                # Addresses can be in the form <person@dom.com> but watch out
223                # for null address, e.g. <>
224                address = address[1:-1]
225        return address
226
227    def smtp_MAIL(self, arg):
228        print >> DEBUGSTREAM, '===> MAIL', arg
229        address = self.__getaddr('FROM:', arg) if arg else None
230        if not address:
231            self.push('501 Syntax: MAIL FROM:<address>')
232            return
233        if self.__mailfrom:
234            self.push('503 Error: nested MAIL command')
235            return
236        self.__mailfrom = address
237        print >> DEBUGSTREAM, 'sender:', self.__mailfrom
238        self.push('250 Ok')
239
240    def smtp_RCPT(self, arg):
241        print >> DEBUGSTREAM, '===> RCPT', arg
242        if not self.__mailfrom:
243            self.push('503 Error: need MAIL command')
244            return
245        address = self.__getaddr('TO:', arg) if arg else None
246        if not address:
247            self.push('501 Syntax: RCPT TO: <address>')
248            return
249        self.__rcpttos.append(address)
250        print >> DEBUGSTREAM, 'recips:', self.__rcpttos
251        self.push('250 Ok')
252
253    def smtp_RSET(self, arg):
254        if arg:
255            self.push('501 Syntax: RSET')
256            return
257        # Resets the sender, recipients, and data, but not the greeting
258        self.__mailfrom = None
259        self.__rcpttos = []
260        self.__data = ''
261        self.__state = self.COMMAND
262        self.push('250 Ok')
263
264    def smtp_DATA(self, arg):
265        if not self.__rcpttos:
266            self.push('503 Error: need RCPT command')
267            return
268        if arg:
269            self.push('501 Syntax: DATA')
270            return
271        self.__state = self.DATA
272        self.set_terminator('\r\n.\r\n')
273        self.push('354 End data with <CR><LF>.<CR><LF>')
274
275
276class SMTPServer(asyncore.dispatcher):
277    def __init__(self, localaddr, remoteaddr):
278        self._localaddr = localaddr
279        self._remoteaddr = remoteaddr
280        asyncore.dispatcher.__init__(self)
281        try:
282            self.create_socket(socket.AF_INET, socket.SOCK_STREAM)
283            # try to re-use a server port if possible
284            self.set_reuse_addr()
285            self.bind(localaddr)
286            self.listen(5)
287        except:
288            # cleanup asyncore.socket_map before raising
289            self.close()
290            raise
291        else:
292            print >> DEBUGSTREAM, \
293                  '%s started at %s\n\tLocal addr: %s\n\tRemote addr:%s' % (
294                self.__class__.__name__, time.ctime(time.time()),
295                localaddr, remoteaddr)
296
297    def handle_accept(self):
298        pair = self.accept()
299        if pair is not None:
300            conn, addr = pair
301            print >> DEBUGSTREAM, 'Incoming connection from %s' % repr(addr)
302            channel = SMTPChannel(self, conn, addr)
303
304    # API for "doing something useful with the message"
305    def process_message(self, peer, mailfrom, rcpttos, data):
306        """Override this abstract method to handle messages from the client.
307
308        peer is a tuple containing (ipaddr, port) of the client that made the
309        socket connection to our smtp port.
310
311        mailfrom is the raw address the client claims the message is coming
312        from.
313
314        rcpttos is a list of raw addresses the client wishes to deliver the
315        message to.
316
317        data is a string containing the entire full text of the message,
318        headers (if supplied) and all.  It has been `de-transparencied'
319        according to RFC 821, Section 4.5.2.  In other words, a line
320        containing a `.' followed by other text has had the leading dot
321        removed.
322
323        This function should return None, for a normal `250 Ok' response;
324        otherwise it returns the desired response string in RFC 821 format.
325
326        """
327        raise NotImplementedError
328
329
330class DebuggingServer(SMTPServer):
331    # Do something with the gathered message
332    def process_message(self, peer, mailfrom, rcpttos, data):
333        inheaders = 1
334        lines = data.split('\n')
335        print '---------- MESSAGE FOLLOWS ----------'
336        for line in lines:
337            # headers first
338            if inheaders and not line:
339                print 'X-Peer:', peer[0]
340                inheaders = 0
341            print line
342        print '------------ END MESSAGE ------------'
343
344
345class PureProxy(SMTPServer):
346    def process_message(self, peer, mailfrom, rcpttos, data):
347        lines = data.split('\n')
348        # Look for the last header
349        i = 0
350        for line in lines:
351            if not line:
352                break
353            i += 1
354        lines.insert(i, 'X-Peer: %s' % peer[0])
355        data = NEWLINE.join(lines)
356        refused = self._deliver(mailfrom, rcpttos, data)
357        # TBD: what to do with refused addresses?
358        print >> DEBUGSTREAM, 'we got some refusals:', refused
359
360    def _deliver(self, mailfrom, rcpttos, data):
361        import smtplib
362        refused = {}
363        try:
364            s = smtplib.SMTP()
365            s.connect(self._remoteaddr[0], self._remoteaddr[1])
366            try:
367                refused = s.sendmail(mailfrom, rcpttos, data)
368            finally:
369                s.quit()
370        except smtplib.SMTPRecipientsRefused, e:
371            print >> DEBUGSTREAM, 'got SMTPRecipientsRefused'
372            refused = e.recipients
373        except (socket.error, smtplib.SMTPException), e:
374            print >> DEBUGSTREAM, 'got', e.__class__
375            # All recipients were refused.  If the exception had an associated
376            # error code, use it.  Otherwise,fake it with a non-triggering
377            # exception code.
378            errcode = getattr(e, 'smtp_code', -1)
379            errmsg = getattr(e, 'smtp_error', 'ignore')
380            for r in rcpttos:
381                refused[r] = (errcode, errmsg)
382        return refused
383
384
385class MailmanProxy(PureProxy):
386    def process_message(self, peer, mailfrom, rcpttos, data):
387        from cStringIO import StringIO
388        from Mailman import Utils
389        from Mailman import Message
390        from Mailman import MailList
391        # If the message is to a Mailman mailing list, then we'll invoke the
392        # Mailman script directly, without going through the real smtpd.
393        # Otherwise we'll forward it to the local proxy for disposition.
394        listnames = []
395        for rcpt in rcpttos:
396            local = rcpt.lower().split('@')[0]
397            # We allow the following variations on the theme
398            #   listname
399            #   listname-admin
400            #   listname-owner
401            #   listname-request
402            #   listname-join
403            #   listname-leave
404            parts = local.split('-')
405            if len(parts) > 2:
406                continue
407            listname = parts[0]
408            if len(parts) == 2:
409                command = parts[1]
410            else:
411                command = ''
412            if not Utils.list_exists(listname) or command not in (
413                    '', 'admin', 'owner', 'request', 'join', 'leave'):
414                continue
415            listnames.append((rcpt, listname, command))
416        # Remove all list recipients from rcpttos and forward what we're not
417        # going to take care of ourselves.  Linear removal should be fine
418        # since we don't expect a large number of recipients.
419        for rcpt, listname, command in listnames:
420            rcpttos.remove(rcpt)
421        # If there's any non-list destined recipients left,
422        print >> DEBUGSTREAM, 'forwarding recips:', ' '.join(rcpttos)
423        if rcpttos:
424            refused = self._deliver(mailfrom, rcpttos, data)
425            # TBD: what to do with refused addresses?
426            print >> DEBUGSTREAM, 'we got refusals:', refused
427        # Now deliver directly to the list commands
428        mlists = {}
429        s = StringIO(data)
430        msg = Message.Message(s)
431        # These headers are required for the proper execution of Mailman.  All
432        # MTAs in existence seem to add these if the original message doesn't
433        # have them.
434        if not msg.getheader('from'):
435            msg['From'] = mailfrom
436        if not msg.getheader('date'):
437            msg['Date'] = time.ctime(time.time())
438        for rcpt, listname, command in listnames:
439            print >> DEBUGSTREAM, 'sending message to', rcpt
440            mlist = mlists.get(listname)
441            if not mlist:
442                mlist = MailList.MailList(listname, lock=0)
443                mlists[listname] = mlist
444            # dispatch on the type of command
445            if command == '':
446                # post
447                msg.Enqueue(mlist, tolist=1)
448            elif command == 'admin':
449                msg.Enqueue(mlist, toadmin=1)
450            elif command == 'owner':
451                msg.Enqueue(mlist, toowner=1)
452            elif command == 'request':
453                msg.Enqueue(mlist, torequest=1)
454            elif command in ('join', 'leave'):
455                # TBD: this is a hack!
456                if command == 'join':
457                    msg['Subject'] = 'subscribe'
458                else:
459                    msg['Subject'] = 'unsubscribe'
460                msg.Enqueue(mlist, torequest=1)
461
462
463class Options:
464    setuid = 1
465    classname = 'PureProxy'
466
467
468def parseargs():
469    global DEBUGSTREAM
470    try:
471        opts, args = getopt.getopt(
472            sys.argv[1:], 'nVhc:d',
473            ['class=', 'nosetuid', 'version', 'help', 'debug'])
474    except getopt.error, e:
475        usage(1, e)
476
477    options = Options()
478    for opt, arg in opts:
479        if opt in ('-h', '--help'):
480            usage(0)
481        elif opt in ('-V', '--version'):
482            print >> sys.stderr, __version__
483            sys.exit(0)
484        elif opt in ('-n', '--nosetuid'):
485            options.setuid = 0
486        elif opt in ('-c', '--class'):
487            options.classname = arg
488        elif opt in ('-d', '--debug'):
489            DEBUGSTREAM = sys.stderr
490
491    # parse the rest of the arguments
492    if len(args) < 1:
493        localspec = 'localhost:8025'
494        remotespec = 'localhost:25'
495    elif len(args) < 2:
496        localspec = args[0]
497        remotespec = 'localhost:25'
498    elif len(args) < 3:
499        localspec = args[0]
500        remotespec = args[1]
501    else:
502        usage(1, 'Invalid arguments: %s' % COMMASPACE.join(args))
503
504    # split into host/port pairs
505    i = localspec.find(':')
506    if i < 0:
507        usage(1, 'Bad local spec: %s' % localspec)
508    options.localhost = localspec[:i]
509    try:
510        options.localport = int(localspec[i+1:])
511    except ValueError:
512        usage(1, 'Bad local port: %s' % localspec)
513    i = remotespec.find(':')
514    if i < 0:
515        usage(1, 'Bad remote spec: %s' % remotespec)
516    options.remotehost = remotespec[:i]
517    try:
518        options.remoteport = int(remotespec[i+1:])
519    except ValueError:
520        usage(1, 'Bad remote port: %s' % remotespec)
521    return options
522
523
524if __name__ == '__main__':
525    options = parseargs()
526    # Become nobody
527    classname = options.classname
528    if "." in classname:
529        lastdot = classname.rfind(".")
530        mod = __import__(classname[:lastdot], globals(), locals(), [""])
531        classname = classname[lastdot+1:]
532    else:
533        import __main__ as mod
534    class_ = getattr(mod, classname)
535    proxy = class_((options.localhost, options.localport),
536                   (options.remotehost, options.remoteport))
537    if options.setuid:
538        try:
539            import pwd
540        except ImportError:
541            print >> sys.stderr, \
542                  'Cannot import module "pwd"; try running with -n option.'
543            sys.exit(1)
544        nobody = pwd.getpwnam('nobody')[2]
545        try:
546            os.setuid(nobody)
547        except OSError, e:
548            if e.errno != errno.EPERM: raise
549            print >> sys.stderr, \
550                  'Cannot setuid "nobody"; try running with -n option.'
551            sys.exit(1)
552    try:
553        asyncore.loop()
554    except KeyboardInterrupt:
555        pass
556