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