blob: c1a2db5e8a8ef3fa6f2137fb6f6633f62c2daba1 [file] [log] [blame]
Barry Warsaw7e0d9562001-01-31 22:51:35 +00001#! /usr/bin/env python
Barry Warsaw406d46e2001-08-13 21:18:01 +00002"""An RFC 2821 smtp proxy.
Barry Warsaw7e0d9562001-01-31 22:51:35 +00003
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)
Barry Warsaw406d46e2001-08-13 21:18:01 +0000136 print >> DEBUGSTREAM, 'Data:', repr(line)
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000137 self.__line = []
138 if self.__state == self.COMMAND:
139 if not line:
140 self.push('500 Error: bad syntax')
141 return
142 method = None
143 i = line.find(' ')
144 if i < 0:
145 command = line.upper()
146 arg = None
147 else:
148 command = line[:i].upper()
149 arg = line[i+1:].strip()
150 method = getattr(self, 'smtp_' + command, None)
151 if not method:
152 self.push('502 Error: command "%s" not implemented' % command)
153 return
154 method(arg)
155 return
156 else:
Guido van Rossum4ba3d652001-03-02 06:42:34 +0000157 if self.__state != self.DATA:
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000158 self.push('451 Internal confusion')
159 return
160 # Remove extraneous carriage returns and de-transparency according
161 # to RFC 821, Section 4.5.2.
162 data = []
163 for text in line.split('\r\n'):
164 if text and text[0] == '.':
165 data.append(text[1:])
166 else:
167 data.append(text)
168 self.__data = NEWLINE.join(data)
169 status = self.__server.process_message(self.__peer,
170 self.__mailfrom,
171 self.__rcpttos,
172 self.__data)
173 self.__rcpttos = []
174 self.__mailfrom = None
175 self.__state = self.COMMAND
176 self.set_terminator('\r\n')
177 if not status:
178 self.push('250 Ok')
179 else:
180 self.push(status)
181
182 # SMTP and ESMTP commands
183 def smtp_HELO(self, arg):
184 if not arg:
185 self.push('501 Syntax: HELO hostname')
186 return
187 if self.__greeting:
188 self.push('503 Duplicate HELO/EHLO')
189 else:
190 self.__greeting = arg
191 self.push('250 %s' % self.__fqdn)
192
193 def smtp_NOOP(self, arg):
194 if arg:
195 self.push('501 Syntax: NOOP')
196 else:
197 self.push('250 Ok')
198
199 def smtp_QUIT(self, arg):
200 # args is ignored
201 self.push('221 Bye')
202 self.close_when_done()
203
204 # factored
205 def __getaddr(self, keyword, arg):
206 address = None
207 keylen = len(keyword)
208 if arg[:keylen].upper() == keyword:
209 address = arg[keylen:].strip()
Guido van Rossum4ba3d652001-03-02 06:42:34 +0000210 if address[0] == '<' and address[-1] == '>' and address != '<>':
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000211 # Addresses can be in the form <person@dom.com> but watch out
212 # for null address, e.g. <>
213 address = address[1:-1]
214 return address
215
216 def smtp_MAIL(self, arg):
217 print >> DEBUGSTREAM, '===> MAIL', arg
218 address = self.__getaddr('FROM:', arg)
219 if not address:
220 self.push('501 Syntax: MAIL FROM:<address>')
221 return
222 if self.__mailfrom:
223 self.push('503 Error: nested MAIL command')
224 return
225 self.__mailfrom = address
226 print >> DEBUGSTREAM, 'sender:', self.__mailfrom
227 self.push('250 Ok')
228
229 def smtp_RCPT(self, arg):
230 print >> DEBUGSTREAM, '===> RCPT', arg
231 if not self.__mailfrom:
232 self.push('503 Error: need MAIL command')
233 return
234 address = self.__getaddr('TO:', arg)
235 if not address:
236 self.push('501 Syntax: RCPT TO: <address>')
237 return
238 if address.lower().startswith('stimpy'):
239 self.push('503 You suck %s' % address)
240 return
241 self.__rcpttos.append(address)
242 print >> DEBUGSTREAM, 'recips:', self.__rcpttos
243 self.push('250 Ok')
244
245 def smtp_RSET(self, arg):
246 if arg:
247 self.push('501 Syntax: RSET')
248 return
249 # Resets the sender, recipients, and data, but not the greeting
250 self.__mailfrom = None
251 self.__rcpttos = []
252 self.__data = ''
253 self.__state = self.COMMAND
254 self.push('250 Ok')
255
256 def smtp_DATA(self, arg):
257 if not self.__rcpttos:
258 self.push('503 Error: need RCPT command')
259 return
260 if arg:
261 self.push('501 Syntax: DATA')
262 return
263 self.__state = self.DATA
264 self.set_terminator('\r\n.\r\n')
265 self.push('354 End data with <CR><LF>.<CR><LF>')
266
267
Tim Peters658cba62001-02-09 20:06:00 +0000268
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000269class SMTPServer(asyncore.dispatcher):
270 def __init__(self, localaddr, remoteaddr):
271 self._localaddr = localaddr
272 self._remoteaddr = remoteaddr
273 asyncore.dispatcher.__init__(self)
274 self.create_socket(socket.AF_INET, socket.SOCK_STREAM)
275 # try to re-use a server port if possible
276 self.socket.setsockopt(
277 socket.SOL_SOCKET, socket.SO_REUSEADDR,
278 self.socket.getsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR) | 1)
279 self.bind(localaddr)
280 self.listen(5)
281 print '%s started at %s\n\tLocal addr: %s\n\tRemote addr:%s' % (
282 self.__class__.__name__, time.ctime(time.time()),
283 localaddr, remoteaddr)
284
285 def handle_accept(self):
286 conn, addr = self.accept()
287 print >> DEBUGSTREAM, 'Incoming connection from %s' % repr(addr)
288 channel = SMTPChannel(self, conn, addr)
289
290 # API for "doing something useful with the message"
291 def process_message(self, peer, mailfrom, rcpttos, data):
292 """Override this abstract method to handle messages from the client.
293
294 peer is a tuple containing (ipaddr, port) of the client that made the
295 socket connection to our smtp port.
296
297 mailfrom is the raw address the client claims the message is coming
298 from.
299
300 rcpttos is a list of raw addresses the client wishes to deliver the
301 message to.
302
303 data is a string containing the entire full text of the message,
304 headers (if supplied) and all. It has been `de-transparencied'
305 according to RFC 821, Section 4.5.2. In other words, a line
306 containing a `.' followed by other text has had the leading dot
307 removed.
308
309 This function should return None, for a normal `250 Ok' response;
310 otherwise it returns the desired response string in RFC 821 format.
311
312 """
Guido van Rossumb8b45ea2001-04-15 13:06:04 +0000313 raise NotImplementedError
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000314
Tim Peters658cba62001-02-09 20:06:00 +0000315
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000316class DebuggingServer(SMTPServer):
317 # Do something with the gathered message
318 def process_message(self, peer, mailfrom, rcpttos, data):
319 inheaders = 1
320 lines = data.split('\n')
321 print '---------- MESSAGE FOLLOWS ----------'
322 for line in lines:
323 # headers first
324 if inheaders and not line:
325 print 'X-Peer:', peer[0]
326 inheaders = 0
327 print line
328 print '------------ END MESSAGE ------------'
329
330
Tim Peters658cba62001-02-09 20:06:00 +0000331
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000332class PureProxy(SMTPServer):
333 def process_message(self, peer, mailfrom, rcpttos, data):
334 lines = data.split('\n')
335 # Look for the last header
336 i = 0
337 for line in lines:
338 if not line:
339 break
340 i += 1
341 lines.insert(i, 'X-Peer: %s' % peer[0])
342 data = NEWLINE.join(lines)
343 refused = self._deliver(mailfrom, rcpttos, data)
344 # TBD: what to do with refused addresses?
345 print >> DEBUGSTREAM, 'we got some refusals'
346
347 def _deliver(self, mailfrom, rcpttos, data):
348 import smtplib
349 refused = {}
350 try:
351 s = smtplib.SMTP()
352 s.connect(self._remoteaddr[0], self._remoteaddr[1])
353 try:
354 refused = s.sendmail(mailfrom, rcpttos, data)
355 finally:
356 s.quit()
357 except smtplib.SMTPRecipientsRefused, e:
358 print >> DEBUGSTREAM, 'got SMTPRecipientsRefused'
359 refused = e.recipients
360 except (socket.error, smtplib.SMTPException), e:
361 print >> DEBUGSTREAM, 'got', e.__class__
362 # All recipients were refused. If the exception had an associated
363 # error code, use it. Otherwise,fake it with a non-triggering
364 # exception code.
365 errcode = getattr(e, 'smtp_code', -1)
366 errmsg = getattr(e, 'smtp_error', 'ignore')
367 for r in rcpttos:
368 refused[r] = (errcode, errmsg)
369 return refused
370
371
Tim Peters658cba62001-02-09 20:06:00 +0000372
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000373class MailmanProxy(PureProxy):
374 def process_message(self, peer, mailfrom, rcpttos, data):
375 from cStringIO import StringIO
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000376 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