blob: cf6821f2ba901b43e44a76011e94a545dff5ecb6 [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()
Guido van Rossum4ba3d652001-03-02 06:42:34 +0000214 if address[0] == '<' and address[-1] == '>' and address != '<>':
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000215 # Addresses can be in the form <person@dom.com> but watch out
216 # for null address, e.g. <>
217 address = address[1:-1]
218 return address
219
220 def smtp_MAIL(self, arg):
221 print >> DEBUGSTREAM, '===> MAIL', arg
222 address = self.__getaddr('FROM:', arg)
223 if not address:
224 self.push('501 Syntax: MAIL FROM:<address>')
225 return
226 if self.__mailfrom:
227 self.push('503 Error: nested MAIL command')
228 return
229 self.__mailfrom = address
230 print >> DEBUGSTREAM, 'sender:', self.__mailfrom
231 self.push('250 Ok')
232
233 def smtp_RCPT(self, arg):
234 print >> DEBUGSTREAM, '===> RCPT', arg
235 if not self.__mailfrom:
236 self.push('503 Error: need MAIL command')
237 return
238 address = self.__getaddr('TO:', arg)
239 if not address:
240 self.push('501 Syntax: RCPT TO: <address>')
241 return
242 if address.lower().startswith('stimpy'):
243 self.push('503 You suck %s' % address)
244 return
245 self.__rcpttos.append(address)
246 print >> DEBUGSTREAM, 'recips:', self.__rcpttos
247 self.push('250 Ok')
248
249 def smtp_RSET(self, arg):
250 if arg:
251 self.push('501 Syntax: RSET')
252 return
253 # Resets the sender, recipients, and data, but not the greeting
254 self.__mailfrom = None
255 self.__rcpttos = []
256 self.__data = ''
257 self.__state = self.COMMAND
258 self.push('250 Ok')
259
260 def smtp_DATA(self, arg):
261 if not self.__rcpttos:
262 self.push('503 Error: need RCPT command')
263 return
264 if arg:
265 self.push('501 Syntax: DATA')
266 return
267 self.__state = self.DATA
268 self.set_terminator('\r\n.\r\n')
269 self.push('354 End data with <CR><LF>.<CR><LF>')
270
271
Barry Warsaw0e8427e2001-10-04 16:27:04 +0000272
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000273class SMTPServer(asyncore.dispatcher):
274 def __init__(self, localaddr, remoteaddr):
275 self._localaddr = localaddr
276 self._remoteaddr = remoteaddr
277 asyncore.dispatcher.__init__(self)
278 self.create_socket(socket.AF_INET, socket.SOCK_STREAM)
279 # try to re-use a server port if possible
Barry Warsaw93a63272001-10-09 15:46:31 +0000280 self.set_reuse_addr()
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000281 self.bind(localaddr)
282 self.listen(5)
Barry Warsaw4a8e9f42001-10-05 17:10:31 +0000283 print >> DEBUGSTREAM, \
284 '%s started at %s\n\tLocal addr: %s\n\tRemote addr:%s' % (
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000285 self.__class__.__name__, time.ctime(time.time()),
286 localaddr, remoteaddr)
287
288 def handle_accept(self):
289 conn, addr = self.accept()
290 print >> DEBUGSTREAM, 'Incoming connection from %s' % repr(addr)
291 channel = SMTPChannel(self, conn, addr)
292
293 # API for "doing something useful with the message"
294 def process_message(self, peer, mailfrom, rcpttos, data):
295 """Override this abstract method to handle messages from the client.
296
297 peer is a tuple containing (ipaddr, port) of the client that made the
298 socket connection to our smtp port.
299
300 mailfrom is the raw address the client claims the message is coming
301 from.
302
303 rcpttos is a list of raw addresses the client wishes to deliver the
304 message to.
305
306 data is a string containing the entire full text of the message,
307 headers (if supplied) and all. It has been `de-transparencied'
308 according to RFC 821, Section 4.5.2. In other words, a line
309 containing a `.' followed by other text has had the leading dot
310 removed.
311
312 This function should return None, for a normal `250 Ok' response;
313 otherwise it returns the desired response string in RFC 821 format.
314
315 """
Guido van Rossumb8b45ea2001-04-15 13:06:04 +0000316 raise NotImplementedError
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000317
Tim Peters658cba62001-02-09 20:06:00 +0000318
Barry Warsaw0e8427e2001-10-04 16:27:04 +0000319
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000320class DebuggingServer(SMTPServer):
321 # Do something with the gathered message
322 def process_message(self, peer, mailfrom, rcpttos, data):
323 inheaders = 1
324 lines = data.split('\n')
325 print '---------- MESSAGE FOLLOWS ----------'
326 for line in lines:
327 # headers first
328 if inheaders and not line:
329 print 'X-Peer:', peer[0]
330 inheaders = 0
331 print line
332 print '------------ END MESSAGE ------------'
333
334
Barry Warsaw0e8427e2001-10-04 16:27:04 +0000335
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000336class PureProxy(SMTPServer):
337 def process_message(self, peer, mailfrom, rcpttos, data):
338 lines = data.split('\n')
339 # Look for the last header
340 i = 0
341 for line in lines:
342 if not line:
343 break
344 i += 1
345 lines.insert(i, 'X-Peer: %s' % peer[0])
346 data = NEWLINE.join(lines)
347 refused = self._deliver(mailfrom, rcpttos, data)
348 # TBD: what to do with refused addresses?
349 print >> DEBUGSTREAM, 'we got some refusals'
350
351 def _deliver(self, mailfrom, rcpttos, data):
352 import smtplib
353 refused = {}
354 try:
355 s = smtplib.SMTP()
356 s.connect(self._remoteaddr[0], self._remoteaddr[1])
357 try:
358 refused = s.sendmail(mailfrom, rcpttos, data)
359 finally:
360 s.quit()
361 except smtplib.SMTPRecipientsRefused, e:
362 print >> DEBUGSTREAM, 'got SMTPRecipientsRefused'
363 refused = e.recipients
364 except (socket.error, smtplib.SMTPException), e:
365 print >> DEBUGSTREAM, 'got', e.__class__
366 # All recipients were refused. If the exception had an associated
367 # error code, use it. Otherwise,fake it with a non-triggering
368 # exception code.
369 errcode = getattr(e, 'smtp_code', -1)
370 errmsg = getattr(e, 'smtp_error', 'ignore')
371 for r in rcpttos:
372 refused[r] = (errcode, errmsg)
373 return refused
374
375
Barry Warsaw0e8427e2001-10-04 16:27:04 +0000376
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000377class MailmanProxy(PureProxy):
378 def process_message(self, peer, mailfrom, rcpttos, data):
379 from cStringIO import StringIO
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000380 from Mailman import Utils
381 from Mailman import Message
382 from Mailman import MailList
383 # If the message is to a Mailman mailing list, then we'll invoke the
384 # Mailman script directly, without going through the real smtpd.
385 # Otherwise we'll forward it to the local proxy for disposition.
386 listnames = []
387 for rcpt in rcpttos:
388 local = rcpt.lower().split('@')[0]
389 # We allow the following variations on the theme
390 # listname
391 # listname-admin
392 # listname-owner
393 # listname-request
394 # listname-join
395 # listname-leave
396 parts = local.split('-')
397 if len(parts) > 2:
398 continue
399 listname = parts[0]
400 if len(parts) == 2:
401 command = parts[1]
402 else:
403 command = ''
404 if not Utils.list_exists(listname) or command not in (
405 '', 'admin', 'owner', 'request', 'join', 'leave'):
406 continue
407 listnames.append((rcpt, listname, command))
408 # Remove all list recipients from rcpttos and forward what we're not
409 # going to take care of ourselves. Linear removal should be fine
410 # since we don't expect a large number of recipients.
411 for rcpt, listname, command in listnames:
412 rcpttos.remove(rcpt)
Tim Peters658cba62001-02-09 20:06:00 +0000413 # If there's any non-list destined recipients left,
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000414 print >> DEBUGSTREAM, 'forwarding recips:', ' '.join(rcpttos)
415 if rcpttos:
416 refused = self._deliver(mailfrom, rcpttos, data)
417 # TBD: what to do with refused addresses?
418 print >> DEBUGSTREAM, 'we got refusals'
419 # Now deliver directly to the list commands
420 mlists = {}
421 s = StringIO(data)
422 msg = Message.Message(s)
423 # These headers are required for the proper execution of Mailman. All
424 # MTAs in existance seem to add these if the original message doesn't
425 # have them.
426 if not msg.getheader('from'):
427 msg['From'] = mailfrom
428 if not msg.getheader('date'):
429 msg['Date'] = time.ctime(time.time())
430 for rcpt, listname, command in listnames:
431 print >> DEBUGSTREAM, 'sending message to', rcpt
432 mlist = mlists.get(listname)
433 if not mlist:
434 mlist = MailList.MailList(listname, lock=0)
435 mlists[listname] = mlist
436 # dispatch on the type of command
437 if command == '':
438 # post
439 msg.Enqueue(mlist, tolist=1)
440 elif command == 'admin':
441 msg.Enqueue(mlist, toadmin=1)
442 elif command == 'owner':
443 msg.Enqueue(mlist, toowner=1)
444 elif command == 'request':
445 msg.Enqueue(mlist, torequest=1)
446 elif command in ('join', 'leave'):
447 # TBD: this is a hack!
448 if command == 'join':
449 msg['Subject'] = 'subscribe'
450 else:
451 msg['Subject'] = 'unsubscribe'
452 msg.Enqueue(mlist, torequest=1)
453
454
Barry Warsaw0e8427e2001-10-04 16:27:04 +0000455
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000456class Options:
457 setuid = 1
458 classname = 'PureProxy'
459
460
Barry Warsaw0e8427e2001-10-04 16:27:04 +0000461
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000462def parseargs():
463 global DEBUGSTREAM
464 try:
465 opts, args = getopt.getopt(
466 sys.argv[1:], 'nVhc:d',
467 ['class=', 'nosetuid', 'version', 'help', 'debug'])
468 except getopt.error, e:
469 usage(1, e)
470
471 options = Options()
472 for opt, arg in opts:
473 if opt in ('-h', '--help'):
474 usage(0)
475 elif opt in ('-V', '--version'):
476 print >> sys.stderr, __version__
477 sys.exit(0)
478 elif opt in ('-n', '--nosetuid'):
479 options.setuid = 0
480 elif opt in ('-c', '--class'):
481 options.classname = arg
482 elif opt in ('-d', '--debug'):
483 DEBUGSTREAM = sys.stderr
484
485 # parse the rest of the arguments
Barry Warsaw0e8427e2001-10-04 16:27:04 +0000486 if len(args) < 1:
487 localspec = 'localhost:8025'
488 remotespec = 'localhost:25'
489 elif len(args) < 2:
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000490 localspec = args[0]
Barry Warsaw0e8427e2001-10-04 16:27:04 +0000491 remotespec = 'localhost:25'
492 else:
493 usage(1, 'Invalid arguments: %s' % COMMASPACE.join(args))
494
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000495 # split into host/port pairs
496 i = localspec.find(':')
497 if i < 0:
Barry Warsaw0e8427e2001-10-04 16:27:04 +0000498 usage(1, 'Bad local spec: %s' % localspec)
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000499 options.localhost = localspec[:i]
500 try:
501 options.localport = int(localspec[i+1:])
502 except ValueError:
Barry Warsaw0e8427e2001-10-04 16:27:04 +0000503 usage(1, 'Bad local port: %s' % localspec)
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000504 i = remotespec.find(':')
505 if i < 0:
Barry Warsaw0e8427e2001-10-04 16:27:04 +0000506 usage(1, 'Bad remote spec: %s' % remotespec)
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000507 options.remotehost = remotespec[:i]
508 try:
509 options.remoteport = int(remotespec[i+1:])
510 except ValueError:
Barry Warsaw0e8427e2001-10-04 16:27:04 +0000511 usage(1, 'Bad remote port: %s' % remotespec)
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000512 return options
513
514
Barry Warsaw0e8427e2001-10-04 16:27:04 +0000515
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000516if __name__ == '__main__':
517 options = parseargs()
518 # Become nobody
519 if options.setuid:
520 try:
521 import pwd
522 except ImportError:
523 print >> sys.stderr, \
524 'Cannot import module "pwd"; try running with -n option.'
525 sys.exit(1)
526 nobody = pwd.getpwnam('nobody')[2]
527 try:
528 os.setuid(nobody)
529 except OSError, e:
Guido van Rossum4ba3d652001-03-02 06:42:34 +0000530 if e.errno != errno.EPERM: raise
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000531 print >> sys.stderr, \
532 'Cannot setuid "nobody"; try running with -n option.'
533 sys.exit(1)
534 import __main__
535 class_ = getattr(__main__, options.classname)
536 proxy = class_((options.localhost, options.localport),
537 (options.remotehost, options.remoteport))
538 try:
539 asyncore.loop()
540 except KeyboardInterrupt:
541 pass