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