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