blob: b4d208b2eeb9857e89fcc9df8ffdb6a0233e429f [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
Barry Warsawf267b622004-10-09 21:44:13 +000020 Use `classname' as the concrete SMTP proxy class. Uses `PureProxy' by
Barry Warsaw7e0d9562001-01-31 22:51:35 +000021 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
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#
Guido van Rossumb8b45ea2001-04-15 13:06:04 +000044# SMTPServer - the base class for the backend. Raises NotImplementedError
Barry Warsaw7e0d9562001-01-31 22:51:35 +000045# 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#
Barry Warsawb1027642004-07-12 23:10:08 +000063# Author: Barry Warsaw <barry@python.org>
Barry Warsaw7e0d9562001-01-31 22:51:35 +000064#
65# TODO:
66#
67# - support mailbox delivery
68# - alias files
69# - ESMTP
70# - handle error codes from the backend smtpd
71
72import sys
73import os
74import errno
75import getopt
76import time
77import socket
78import asyncore
79import asynchat
80
Skip Montanaro0de65802001-02-15 22:15:14 +000081__all__ = ["SMTPServer","DebuggingServer","PureProxy","MailmanProxy"]
Barry Warsaw7e0d9562001-01-31 22:51:35 +000082
83program = sys.argv[0]
84__version__ = 'Python SMTP proxy version 0.2'
85
86
87class Devnull:
88 def write(self, msg): pass
89 def flush(self): pass
90
91
92DEBUGSTREAM = Devnull()
93NEWLINE = '\n'
94EMPTYSTRING = ''
Barry Warsaw0e8427e2001-10-04 16:27:04 +000095COMMASPACE = ', '
Barry Warsaw7e0d9562001-01-31 22:51:35 +000096
97
Barry Warsaw7e0d9562001-01-31 22:51:35 +000098def usage(code, msg=''):
99 print >> sys.stderr, __doc__ % globals()
100 if msg:
101 print >> sys.stderr, msg
102 sys.exit(code)
103
104
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000105class 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 = ''
Barry Warsaw0e8427e2001-10-04 16:27:04 +0000120 self.__fqdn = socket.getfqdn()
Giampaolo RodolĂ 8664d742010-08-23 22:48:51 +0000121 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
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000130 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)
Barry Warsaw406d46e2001-08-13 21:18:01 +0000145 print >> DEBUGSTREAM, 'Data:', repr(line)
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000146 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:
Guido van Rossum4ba3d652001-03-02 06:42:34 +0000166 if self.__state != self.DATA:
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000167 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()
Barry Warsawebf54272001-11-04 03:04:25 +0000219 if not address:
220 pass
221 elif address[0] == '<' and address[-1] == '>' and address != '<>':
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000222 # 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
Guido van Rossum5e812702007-10-22 16:27:19 +0000229 address = self.__getaddr('FROM:', arg) if arg else None
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000230 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
Guido van Rossum910ab502007-10-23 19:25:41 +0000245 address = self.__getaddr('TO:', arg) if arg else None
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000246 if not address:
247 self.push('501 Syntax: RCPT TO: <address>')
248 return
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000249 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
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000276class SMTPServer(asyncore.dispatcher):
277 def __init__(self, localaddr, remoteaddr):
278 self._localaddr = localaddr
279 self._remoteaddr = remoteaddr
280 asyncore.dispatcher.__init__(self)
Giampaolo RodolĂ e00e2f02010-06-30 17:38:28 +0000281 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)
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000296
297 def handle_accept(self):
Giampaolo RodolĂ 19e9fef2010-11-01 15:07:14 +0000298 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)
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000303
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 """
Guido van Rossumb8b45ea2001-04-15 13:06:04 +0000327 raise NotImplementedError
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000328
Tim Peters658cba62001-02-09 20:06:00 +0000329
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000330class 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
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000345class 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?
Neal Norwitzf1516252002-02-11 18:05:05 +0000358 print >> DEBUGSTREAM, 'we got some refusals:', refused
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000359
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
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000385class MailmanProxy(PureProxy):
386 def process_message(self, peer, mailfrom, rcpttos, data):
387 from cStringIO import StringIO
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000388 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)
Tim Peters658cba62001-02-09 20:06:00 +0000421 # If there's any non-list destined recipients left,
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000422 print >> DEBUGSTREAM, 'forwarding recips:', ' '.join(rcpttos)
423 if rcpttos:
424 refused = self._deliver(mailfrom, rcpttos, data)
425 # TBD: what to do with refused addresses?
Neal Norwitzf1516252002-02-11 18:05:05 +0000426 print >> DEBUGSTREAM, 'we got refusals:', refused
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000427 # 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
Mark Dickinson3e4caeb2009-02-21 20:27:01 +0000432 # MTAs in existence seem to add these if the original message doesn't
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000433 # 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
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000463class Options:
464 setuid = 1
465 classname = 'PureProxy'
466
467
468def 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
Barry Warsaw0e8427e2001-10-04 16:27:04 +0000492 if len(args) < 1:
493 localspec = 'localhost:8025'
494 remotespec = 'localhost:25'
495 elif len(args) < 2:
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000496 localspec = args[0]
Barry Warsaw0e8427e2001-10-04 16:27:04 +0000497 remotespec = 'localhost:25'
Barry Warsawebf54272001-11-04 03:04:25 +0000498 elif len(args) < 3:
499 localspec = args[0]
500 remotespec = args[1]
Barry Warsaw0e8427e2001-10-04 16:27:04 +0000501 else:
502 usage(1, 'Invalid arguments: %s' % COMMASPACE.join(args))
503
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000504 # split into host/port pairs
505 i = localspec.find(':')
506 if i < 0:
Barry Warsaw0e8427e2001-10-04 16:27:04 +0000507 usage(1, 'Bad local spec: %s' % localspec)
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000508 options.localhost = localspec[:i]
509 try:
510 options.localport = int(localspec[i+1:])
511 except ValueError:
Barry Warsaw0e8427e2001-10-04 16:27:04 +0000512 usage(1, 'Bad local port: %s' % localspec)
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000513 i = remotespec.find(':')
514 if i < 0:
Barry Warsaw0e8427e2001-10-04 16:27:04 +0000515 usage(1, 'Bad remote spec: %s' % remotespec)
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000516 options.remotehost = remotespec[:i]
517 try:
518 options.remoteport = int(remotespec[i+1:])
519 except ValueError:
Barry Warsaw0e8427e2001-10-04 16:27:04 +0000520 usage(1, 'Bad remote port: %s' % remotespec)
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000521 return options
522
523
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000524if __name__ == '__main__':
525 options = parseargs()
526 # Become nobody
Florent Xicluna47fb1922011-10-20 23:21:58 +0200527 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))
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000537 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:
Guido van Rossum4ba3d652001-03-02 06:42:34 +0000548 if e.errno != errno.EPERM: raise
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000549 print >> sys.stderr, \
550 'Cannot setuid "nobody"; try running with -n option.'
551 sys.exit(1)
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000552 try:
553 asyncore.loop()
554 except KeyboardInterrupt:
555 pass