This commit was manufactured by cvs2svn to create tag 'r212'.
[python/dscho.git] / Lib / smtpd.py
blob95bacaec4ee91adfb1de9137f308c53452f8b53a
1 #! /usr/bin/env python
2 """An RFC 821 smtp proxy.
4 Usage: %(program)s [options] localhost:port remotehost:port
6 Options:
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).
14 --version
16 Print the version number and exit.
18 --class classname
19 -c classname
20 Use `classname' as the concrete SMTP proxy class. Uses `SMTPProxy' by
21 default.
23 --debug
25 Turn on debugging prints.
27 --help
29 Print this message and exit.
31 Version: %(__version__)s
33 """
35 # Overview:
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:
41 # SMTPServer - the base class for the backend. Raises NotImplementedError
42 # if you try to use it.
44 # DebuggingServer - simply prints each message it receives on stdout.
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!).
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.
58 # Please note that this script requires Python 2.0
60 # Author: Barry Warsaw <barry@digicool.com>
62 # TODO:
64 # - support mailbox delivery
65 # - alias files
66 # - ESMTP
67 # - handle error codes from the backend smtpd
69 import sys
70 import os
71 import errno
72 import getopt
73 import time
74 import socket
75 import asyncore
76 import asynchat
78 __all__ = ["SMTPServer","DebuggingServer","PureProxy","MailmanProxy"]
80 program = sys.argv[0]
81 __version__ = 'Python SMTP proxy version 0.2'
84 class Devnull:
85 def write(self, msg): pass
86 def flush(self): pass
89 DEBUGSTREAM = Devnull()
90 NEWLINE = '\n'
91 EMPTYSTRING = ''
95 def usage(code, msg=''):
96 print >> sys.stderr, __doc__ % globals()
97 if msg:
98 print >> sys.stderr, msg
99 sys.exit(code)
103 class SMTPChannel(asynchat.async_chat):
104 COMMAND = 0
105 DATA = 1
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')
125 # Overrides base class for convenience
126 def push(self, msg):
127 asynchat.async_chat.push(self, msg + '\r\n')
129 # Implementation of base class abstract method
130 def collect_incoming_data(self, data):
131 self.__line.append(data)
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:
156 if self.__state != self.DATA:
157 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)
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)
192 def smtp_NOOP(self, arg):
193 if arg:
194 self.push('501 Syntax: NOOP')
195 else:
196 self.push('250 Ok')
198 def smtp_QUIT(self, arg):
199 # args is ignored
200 self.push('221 Bye')
201 self.close_when_done()
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()
209 if not address:
210 pass
211 elif address[0] == '<' and address[-1] == '>' and address != '<>':
212 # Addresses can be in the form <person@dom.com> but watch out
213 # for null address, e.g. <>
214 address = address[1:-1]
215 return address
217 def smtp_MAIL(self, arg):
218 print >> DEBUGSTREAM, '===> MAIL', arg
219 address = self.__getaddr('FROM:', arg)
220 if not address:
221 self.push('501 Syntax: MAIL FROM:<address>')
222 return
223 if self.__mailfrom:
224 self.push('503 Error: nested MAIL command')
225 return
226 self.__mailfrom = address
227 print >> DEBUGSTREAM, 'sender:', self.__mailfrom
228 self.push('250 Ok')
230 def smtp_RCPT(self, arg):
231 print >> DEBUGSTREAM, '===> RCPT', arg
232 if not self.__mailfrom:
233 self.push('503 Error: need MAIL command')
234 return
235 address = self.__getaddr('TO:', arg)
236 if not address:
237 self.push('501 Syntax: RCPT TO: <address>')
238 return
239 if address.lower().startswith('stimpy'):
240 self.push('503 You suck %s' % address)
241 return
242 self.__rcpttos.append(address)
243 print >> DEBUGSTREAM, 'recips:', self.__rcpttos
244 self.push('250 Ok')
246 def smtp_RSET(self, arg):
247 if arg:
248 self.push('501 Syntax: RSET')
249 return
250 # Resets the sender, recipients, and data, but not the greeting
251 self.__mailfrom = None
252 self.__rcpttos = []
253 self.__data = ''
254 self.__state = self.COMMAND
255 self.push('250 Ok')
257 def smtp_DATA(self, arg):
258 if not self.__rcpttos:
259 self.push('503 Error: need RCPT command')
260 return
261 if arg:
262 self.push('501 Syntax: DATA')
263 return
264 self.__state = self.DATA
265 self.set_terminator('\r\n.\r\n')
266 self.push('354 End data with <CR><LF>.<CR><LF>')
270 class SMTPServer(asyncore.dispatcher):
271 def __init__(self, localaddr, remoteaddr):
272 self._localaddr = localaddr
273 self._remoteaddr = remoteaddr
274 asyncore.dispatcher.__init__(self)
275 self.create_socket(socket.AF_INET, socket.SOCK_STREAM)
276 # try to re-use a server port if possible
277 self.socket.setsockopt(
278 socket.SOL_SOCKET, socket.SO_REUSEADDR,
279 self.socket.getsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR) | 1)
280 self.bind(localaddr)
281 self.listen(5)
282 print '%s started at %s\n\tLocal addr: %s\n\tRemote addr:%s' % (
283 self.__class__.__name__, time.ctime(time.time()),
284 localaddr, remoteaddr)
286 def handle_accept(self):
287 conn, addr = self.accept()
288 print >> DEBUGSTREAM, 'Incoming connection from %s' % repr(addr)
289 channel = SMTPChannel(self, conn, addr)
291 # API for "doing something useful with the message"
292 def process_message(self, peer, mailfrom, rcpttos, data):
293 """Override this abstract method to handle messages from the client.
295 peer is a tuple containing (ipaddr, port) of the client that made the
296 socket connection to our smtp port.
298 mailfrom is the raw address the client claims the message is coming
299 from.
301 rcpttos is a list of raw addresses the client wishes to deliver the
302 message to.
304 data is a string containing the entire full text of the message,
305 headers (if supplied) and all. It has been `de-transparencied'
306 according to RFC 821, Section 4.5.2. In other words, a line
307 containing a `.' followed by other text has had the leading dot
308 removed.
310 This function should return None, for a normal `250 Ok' response;
311 otherwise it returns the desired response string in RFC 821 format.
314 raise NotImplementedError
317 class DebuggingServer(SMTPServer):
318 # Do something with the gathered message
319 def process_message(self, peer, mailfrom, rcpttos, data):
320 inheaders = 1
321 lines = data.split('\n')
322 print '---------- MESSAGE FOLLOWS ----------'
323 for line in lines:
324 # headers first
325 if inheaders and not line:
326 print 'X-Peer:', peer[0]
327 inheaders = 0
328 print line
329 print '------------ END MESSAGE ------------'
333 class PureProxy(SMTPServer):
334 def process_message(self, peer, mailfrom, rcpttos, data):
335 lines = data.split('\n')
336 # Look for the last header
337 i = 0
338 for line in lines:
339 if not line:
340 break
341 i += 1
342 lines.insert(i, 'X-Peer: %s' % peer[0])
343 data = NEWLINE.join(lines)
344 refused = self._deliver(mailfrom, rcpttos, data)
345 # TBD: what to do with refused addresses?
346 print >> DEBUGSTREAM, 'we got some refusals'
348 def _deliver(self, mailfrom, rcpttos, data):
349 import smtplib
350 refused = {}
351 try:
352 s = smtplib.SMTP()
353 s.connect(self._remoteaddr[0], self._remoteaddr[1])
354 try:
355 refused = s.sendmail(mailfrom, rcpttos, data)
356 finally:
357 s.quit()
358 except smtplib.SMTPRecipientsRefused, e:
359 print >> DEBUGSTREAM, 'got SMTPRecipientsRefused'
360 refused = e.recipients
361 except (socket.error, smtplib.SMTPException), e:
362 print >> DEBUGSTREAM, 'got', e.__class__
363 # All recipients were refused. If the exception had an associated
364 # error code, use it. Otherwise,fake it with a non-triggering
365 # exception code.
366 errcode = getattr(e, 'smtp_code', -1)
367 errmsg = getattr(e, 'smtp_error', 'ignore')
368 for r in rcpttos:
369 refused[r] = (errcode, errmsg)
370 return refused
374 class MailmanProxy(PureProxy):
375 def process_message(self, peer, mailfrom, rcpttos, data):
376 from cStringIO import StringIO
377 import paths
378 from Mailman import Utils
379 from Mailman import Message
380 from Mailman import MailList
381 # If the message is to a Mailman mailing list, then we'll invoke the
382 # Mailman script directly, without going through the real smtpd.
383 # Otherwise we'll forward it to the local proxy for disposition.
384 listnames = []
385 for rcpt in rcpttos:
386 local = rcpt.lower().split('@')[0]
387 # We allow the following variations on the theme
388 # listname
389 # listname-admin
390 # listname-owner
391 # listname-request
392 # listname-join
393 # listname-leave
394 parts = local.split('-')
395 if len(parts) > 2:
396 continue
397 listname = parts[0]
398 if len(parts) == 2:
399 command = parts[1]
400 else:
401 command = ''
402 if not Utils.list_exists(listname) or command not in (
403 '', 'admin', 'owner', 'request', 'join', 'leave'):
404 continue
405 listnames.append((rcpt, listname, command))
406 # Remove all list recipients from rcpttos and forward what we're not
407 # going to take care of ourselves. Linear removal should be fine
408 # since we don't expect a large number of recipients.
409 for rcpt, listname, command in listnames:
410 rcpttos.remove(rcpt)
411 # If there's any non-list destined recipients left,
412 print >> DEBUGSTREAM, 'forwarding recips:', ' '.join(rcpttos)
413 if rcpttos:
414 refused = self._deliver(mailfrom, rcpttos, data)
415 # TBD: what to do with refused addresses?
416 print >> DEBUGSTREAM, 'we got refusals'
417 # Now deliver directly to the list commands
418 mlists = {}
419 s = StringIO(data)
420 msg = Message.Message(s)
421 # These headers are required for the proper execution of Mailman. All
422 # MTAs in existance seem to add these if the original message doesn't
423 # have them.
424 if not msg.getheader('from'):
425 msg['From'] = mailfrom
426 if not msg.getheader('date'):
427 msg['Date'] = time.ctime(time.time())
428 for rcpt, listname, command in listnames:
429 print >> DEBUGSTREAM, 'sending message to', rcpt
430 mlist = mlists.get(listname)
431 if not mlist:
432 mlist = MailList.MailList(listname, lock=0)
433 mlists[listname] = mlist
434 # dispatch on the type of command
435 if command == '':
436 # post
437 msg.Enqueue(mlist, tolist=1)
438 elif command == 'admin':
439 msg.Enqueue(mlist, toadmin=1)
440 elif command == 'owner':
441 msg.Enqueue(mlist, toowner=1)
442 elif command == 'request':
443 msg.Enqueue(mlist, torequest=1)
444 elif command in ('join', 'leave'):
445 # TBD: this is a hack!
446 if command == 'join':
447 msg['Subject'] = 'subscribe'
448 else:
449 msg['Subject'] = 'unsubscribe'
450 msg.Enqueue(mlist, torequest=1)
454 class Options:
455 setuid = 1
456 classname = 'PureProxy'
459 def parseargs():
460 global DEBUGSTREAM
461 try:
462 opts, args = getopt.getopt(
463 sys.argv[1:], 'nVhc:d',
464 ['class=', 'nosetuid', 'version', 'help', 'debug'])
465 except getopt.error, e:
466 usage(1, e)
468 options = Options()
469 for opt, arg in opts:
470 if opt in ('-h', '--help'):
471 usage(0)
472 elif opt in ('-V', '--version'):
473 print >> sys.stderr, __version__
474 sys.exit(0)
475 elif opt in ('-n', '--nosetuid'):
476 options.setuid = 0
477 elif opt in ('-c', '--class'):
478 options.classname = arg
479 elif opt in ('-d', '--debug'):
480 DEBUGSTREAM = sys.stderr
482 # parse the rest of the arguments
483 try:
484 localspec = args[0]
485 remotespec = args[1]
486 except IndexError:
487 usage(1, 'Not enough arguments')
488 # split into host/port pairs
489 i = localspec.find(':')
490 if i < 0:
491 usage(1, 'Bad local spec: "%s"' % localspec)
492 options.localhost = localspec[:i]
493 try:
494 options.localport = int(localspec[i+1:])
495 except ValueError:
496 usage(1, 'Bad local port: "%s"' % localspec)
497 i = remotespec.find(':')
498 if i < 0:
499 usage(1, 'Bad remote spec: "%s"' % remotespec)
500 options.remotehost = remotespec[:i]
501 try:
502 options.remoteport = int(remotespec[i+1:])
503 except ValueError:
504 usage(1, 'Bad remote port: "%s"' % remotespec)
505 return options
509 if __name__ == '__main__':
510 options = parseargs()
511 # Become nobody
512 if options.setuid:
513 try:
514 import pwd
515 except ImportError:
516 print >> sys.stderr, \
517 'Cannot import module "pwd"; try running with -n option.'
518 sys.exit(1)
519 nobody = pwd.getpwnam('nobody')[2]
520 try:
521 os.setuid(nobody)
522 except OSError, e:
523 if e.errno != errno.EPERM: raise
524 print >> sys.stderr, \
525 'Cannot setuid "nobody"; try running with -n option.'
526 sys.exit(1)
527 import __main__
528 class_ = getattr(__main__, options.classname)
529 proxy = class_((options.localhost, options.localport),
530 (options.remotehost, options.remoteport))
531 try:
532 asyncore.loop()
533 except KeyboardInterrupt:
534 pass