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