Merged revisions 81656 via svnmerge from
[python/dscho.git] / Lib / smtpd.py
blob3dc979334f83f582b9daee14536651e19f857a40
1 #! /usr/bin/env python
2 """An RFC 2821 smtp proxy.
4 Usage: %(program)s [options] [localhost:localport [remotehost:remoteport]]
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 `PureProxy' by
21 default.
23 --debug
25 Turn on debugging prints.
27 --help
29 Print this message and exit.
31 Version: %(__version__)s
33 If localhost is not given then `localhost' is used, and if localport is not
34 given then 8025 is used. If remotehost is not given then `localhost' is used,
35 and if remoteport is not given, then 25 is used.
36 """
39 # Overview:
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:
45 # SMTPServer - the base class for the backend. Raises NotImplementedError
46 # if you try to use it.
48 # DebuggingServer - simply prints each message it receives on stdout.
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!).
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.
62 # Please note that this script requires Python 2.0
64 # Author: Barry Warsaw <barry@python.org>
66 # TODO:
68 # - support mailbox delivery
69 # - alias files
70 # - ESMTP
71 # - handle error codes from the backend smtpd
73 import sys
74 import os
75 import errno
76 import getopt
77 import time
78 import socket
79 import asyncore
80 import asynchat
82 __all__ = ["SMTPServer","DebuggingServer","PureProxy","MailmanProxy"]
84 program = sys.argv[0]
85 __version__ = 'Python SMTP proxy version 0.2'
88 class Devnull:
89 def write(self, msg): pass
90 def flush(self): pass
93 DEBUGSTREAM = Devnull()
94 NEWLINE = '\n'
95 EMPTYSTRING = ''
96 COMMASPACE = ', '
100 def usage(code, msg=''):
101 print(__doc__ % globals(), file=sys.stderr)
102 if msg:
103 print(msg, file=sys.stderr)
104 sys.exit(code)
108 class SMTPChannel(asynchat.async_chat):
109 COMMAND = 0
110 DATA = 1
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 = ''
123 self.__fqdn = socket.getfqdn()
124 self.__peer = conn.getpeername()
125 print('Peer:', repr(self.__peer), file=DEBUGSTREAM)
126 self.push('220 %s %s' % (self.__fqdn, __version__))
127 self.set_terminator(b'\r\n')
129 # Overrides base class for convenience
130 def push(self, msg):
131 asynchat.async_chat.push(self, bytes(msg + '\r\n', 'ascii'))
133 # Implementation of base class abstract method
134 def collect_incoming_data(self, data):
135 self.__line.append(str(data, "utf8"))
137 # Implementation of base class abstract method
138 def found_terminator(self):
139 line = EMPTYSTRING.join(self.__line)
140 print('Data:', repr(line), file=DEBUGSTREAM)
141 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:
161 if self.__state != self.DATA:
162 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(b'\r\n')
181 if not status:
182 self.push('250 Ok')
183 else:
184 self.push(status)
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)
197 def smtp_NOOP(self, arg):
198 if arg:
199 self.push('501 Syntax: NOOP')
200 else:
201 self.push('250 Ok')
203 def smtp_QUIT(self, arg):
204 # args is ignored
205 self.push('221 Bye')
206 self.close_when_done()
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()
214 if not address:
215 pass
216 elif address[0] == '<' and address[-1] == '>' and address != '<>':
217 # 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
222 def smtp_MAIL(self, arg):
223 print('===> MAIL', arg, file=DEBUGSTREAM)
224 address = self.__getaddr('FROM:', arg) if arg else None
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('sender:', self.__mailfrom, file=DEBUGSTREAM)
233 self.push('250 Ok')
235 def smtp_RCPT(self, arg):
236 print('===> RCPT', arg, file=DEBUGSTREAM)
237 if not self.__mailfrom:
238 self.push('503 Error: need MAIL command')
239 return
240 address = self.__getaddr('TO:', arg) if arg else None
241 if not address:
242 self.push('501 Syntax: RCPT TO: <address>')
243 return
244 self.__rcpttos.append(address)
245 print('recips:', self.__rcpttos, file=DEBUGSTREAM)
246 self.push('250 Ok')
248 def smtp_RSET(self, arg):
249 if arg:
250 self.push('501 Syntax: RSET')
251 return
252 # Resets the sender, recipients, and data, but not the greeting
253 self.__mailfrom = None
254 self.__rcpttos = []
255 self.__data = ''
256 self.__state = self.COMMAND
257 self.push('250 Ok')
259 def smtp_DATA(self, arg):
260 if not self.__rcpttos:
261 self.push('503 Error: need RCPT command')
262 return
263 if arg:
264 self.push('501 Syntax: DATA')
265 return
266 self.__state = self.DATA
267 self.set_terminator(b'\r\n.\r\n')
268 self.push('354 End data with <CR><LF>.<CR><LF>')
272 class SMTPServer(asyncore.dispatcher):
273 def __init__(self, localaddr, remoteaddr):
274 self._localaddr = localaddr
275 self._remoteaddr = remoteaddr
276 asyncore.dispatcher.__init__(self)
277 self.create_socket(socket.AF_INET, socket.SOCK_STREAM)
278 # try to re-use a server port if possible
279 self.set_reuse_addr()
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), file=DEBUGSTREAM)
286 def handle_accept(self):
287 conn, addr = self.accept()
288 print('Incoming connection from %s' % repr(addr), file=DEBUGSTREAM)
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
318 class DebuggingServer(SMTPServer):
319 # Do something with the gathered message
320 def process_message(self, peer, mailfrom, rcpttos, data):
321 inheaders = 1
322 lines = data.split('\n')
323 print('---------- MESSAGE FOLLOWS ----------')
324 for line in lines:
325 # headers first
326 if inheaders and not line:
327 print('X-Peer:', peer[0])
328 inheaders = 0
329 print(line)
330 print('------------ END MESSAGE ------------')
334 class PureProxy(SMTPServer):
335 def process_message(self, peer, mailfrom, rcpttos, data):
336 lines = data.split('\n')
337 # Look for the last header
338 i = 0
339 for line in lines:
340 if not line:
341 break
342 i += 1
343 lines.insert(i, 'X-Peer: %s' % peer[0])
344 data = NEWLINE.join(lines)
345 refused = self._deliver(mailfrom, rcpttos, data)
346 # TBD: what to do with refused addresses?
347 print('we got some refusals:', refused, file=DEBUGSTREAM)
349 def _deliver(self, mailfrom, rcpttos, data):
350 import smtplib
351 refused = {}
352 try:
353 s = smtplib.SMTP()
354 s.connect(self._remoteaddr[0], self._remoteaddr[1])
355 try:
356 refused = s.sendmail(mailfrom, rcpttos, data)
357 finally:
358 s.quit()
359 except smtplib.SMTPRecipientsRefused as e:
360 print('got SMTPRecipientsRefused', file=DEBUGSTREAM)
361 refused = e.recipients
362 except (socket.error, smtplib.SMTPException) as e:
363 print('got', e.__class__, file=DEBUGSTREAM)
364 # All recipients were refused. If the exception had an associated
365 # error code, use it. Otherwise,fake it with a non-triggering
366 # exception code.
367 errcode = getattr(e, 'smtp_code', -1)
368 errmsg = getattr(e, 'smtp_error', 'ignore')
369 for r in rcpttos:
370 refused[r] = (errcode, errmsg)
371 return refused
375 class MailmanProxy(PureProxy):
376 def process_message(self, peer, mailfrom, rcpttos, data):
377 from io import StringIO
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('forwarding recips:', ' '.join(rcpttos), file=DEBUGSTREAM)
413 if rcpttos:
414 refused = self._deliver(mailfrom, rcpttos, data)
415 # TBD: what to do with refused addresses?
416 print('we got refusals:', refused, file=DEBUGSTREAM)
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 existence seem to add these if the original message doesn't
423 # have them.
424 if not msg.get('from'):
425 msg['From'] = mailfrom
426 if not msg.get('date'):
427 msg['Date'] = time.ctime(time.time())
428 for rcpt, listname, command in listnames:
429 print('sending message to', rcpt, file=DEBUGSTREAM)
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'
460 def parseargs():
461 global DEBUGSTREAM
462 try:
463 opts, args = getopt.getopt(
464 sys.argv[1:], 'nVhc:d',
465 ['class=', 'nosetuid', 'version', 'help', 'debug'])
466 except getopt.error as e:
467 usage(1, e)
469 options = Options()
470 for opt, arg in opts:
471 if opt in ('-h', '--help'):
472 usage(0)
473 elif opt in ('-V', '--version'):
474 print(__version__, file=sys.stderr)
475 sys.exit(0)
476 elif opt in ('-n', '--nosetuid'):
477 options.setuid = 0
478 elif opt in ('-c', '--class'):
479 options.classname = arg
480 elif opt in ('-d', '--debug'):
481 DEBUGSTREAM = sys.stderr
483 # parse the rest of the arguments
484 if len(args) < 1:
485 localspec = 'localhost:8025'
486 remotespec = 'localhost:25'
487 elif len(args) < 2:
488 localspec = args[0]
489 remotespec = 'localhost:25'
490 elif len(args) < 3:
491 localspec = args[0]
492 remotespec = args[1]
493 else:
494 usage(1, 'Invalid arguments: %s' % COMMASPACE.join(args))
496 # split into host/port pairs
497 i = localspec.find(':')
498 if i < 0:
499 usage(1, 'Bad local spec: %s' % localspec)
500 options.localhost = localspec[:i]
501 try:
502 options.localport = int(localspec[i+1:])
503 except ValueError:
504 usage(1, 'Bad local port: %s' % localspec)
505 i = remotespec.find(':')
506 if i < 0:
507 usage(1, 'Bad remote spec: %s' % remotespec)
508 options.remotehost = remotespec[:i]
509 try:
510 options.remoteport = int(remotespec[i+1:])
511 except ValueError:
512 usage(1, 'Bad remote port: %s' % remotespec)
513 return options
517 if __name__ == '__main__':
518 options = parseargs()
519 # Become nobody
520 if options.setuid:
521 try:
522 import pwd
523 except ImportError:
524 print('Cannot import module "pwd"; try running with -n option.', file=sys.stderr)
525 sys.exit(1)
526 nobody = pwd.getpwnam('nobody')[2]
527 try:
528 os.setuid(nobody)
529 except OSError as e:
530 if e.errno != errno.EPERM: raise
531 print('Cannot setuid "nobody"; try running with -n option.', file=sys.stderr)
532 sys.exit(1)
533 classname = options.classname
534 if "." in classname:
535 lastdot = classname.rfind(".")
536 mod = __import__(classname[:lastdot], globals(), locals(), [""])
537 classname = classname[lastdot+1:]
538 else:
539 import __main__ as mod
540 class_ = getattr(mod, classname)
541 proxy = class_((options.localhost, options.localport),
542 (options.remotehost, options.remoteport))
543 try:
544 asyncore.loop()
545 except KeyboardInterrupt:
546 pass