blob: c3bd6a5addad35337be7323b10c5bffd61756829 [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
Barry Warsaw0e8427e2001-10-04 16:27:04 +000038
Barry Warsaw7e0d9562001-01-31 22:51:35 +000039# Overview:
40#
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:
44#
Guido van Rossumb8b45ea2001-04-15 13:06:04 +000045# SMTPServer - the base class for the backend. Raises NotImplementedError
Barry Warsaw7e0d9562001-01-31 22:51:35 +000046# if you try to use it.
47#
48# DebuggingServer - simply prints each message it receives on stdout.
49#
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!).
54#
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.
61#
62# Please note that this script requires Python 2.0
63#
Barry Warsawb1027642004-07-12 23:10:08 +000064# Author: Barry Warsaw <barry@python.org>
Barry Warsaw7e0d9562001-01-31 22:51:35 +000065#
66# TODO:
67#
68# - support mailbox delivery
69# - alias files
70# - ESMTP
71# - handle error codes from the backend smtpd
72
73import sys
74import os
75import errno
76import getopt
77import time
78import socket
79import asyncore
80import asynchat
81
Skip Montanaro0de65802001-02-15 22:15:14 +000082__all__ = ["SMTPServer","DebuggingServer","PureProxy","MailmanProxy"]
Barry Warsaw7e0d9562001-01-31 22:51:35 +000083
84program = sys.argv[0]
85__version__ = 'Python SMTP proxy version 0.2'
86
87
88class Devnull:
89 def write(self, msg): pass
90 def flush(self): pass
91
92
93DEBUGSTREAM = Devnull()
94NEWLINE = '\n'
95EMPTYSTRING = ''
Barry Warsaw0e8427e2001-10-04 16:27:04 +000096COMMASPACE = ', '
Barry Warsaw7e0d9562001-01-31 22:51:35 +000097
98
Barry Warsaw0e8427e2001-10-04 16:27:04 +000099
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000100def usage(code, msg=''):
101 print >> sys.stderr, __doc__ % globals()
102 if msg:
103 print >> sys.stderr, msg
104 sys.exit(code)
105
106
Barry Warsaw0e8427e2001-10-04 16:27:04 +0000107
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000108class SMTPChannel(asynchat.async_chat):
109 COMMAND = 0
110 DATA = 1
111
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 = ''
Barry Warsaw0e8427e2001-10-04 16:27:04 +0000123 self.__fqdn = socket.getfqdn()
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000124 self.__peer = conn.getpeername()
125 print >> DEBUGSTREAM, 'Peer:', repr(self.__peer)
126 self.push('220 %s %s' % (self.__fqdn, __version__))
127 self.set_terminator('\r\n')
128
129 # Overrides base class for convenience
130 def push(self, msg):
131 asynchat.async_chat.push(self, msg + '\r\n')
132
133 # Implementation of base class abstract method
134 def collect_incoming_data(self, data):
135 self.__line.append(data)
136
137 # Implementation of base class abstract method
138 def found_terminator(self):
139 line = EMPTYSTRING.join(self.__line)
Barry Warsaw406d46e2001-08-13 21:18:01 +0000140 print >> DEBUGSTREAM, 'Data:', repr(line)
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000141 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:
Guido van Rossum4ba3d652001-03-02 06:42:34 +0000161 if self.__state != self.DATA:
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000162 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('\r\n')
181 if not status:
182 self.push('250 Ok')
183 else:
184 self.push(status)
185
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)
196
197 def smtp_NOOP(self, arg):
198 if arg:
199 self.push('501 Syntax: NOOP')
200 else:
201 self.push('250 Ok')
202
203 def smtp_QUIT(self, arg):
204 # args is ignored
205 self.push('221 Bye')
206 self.close_when_done()
207
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()
Barry Warsawebf54272001-11-04 03:04:25 +0000214 if not address:
215 pass
216 elif address[0] == '<' and address[-1] == '>' and address != '<>':
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000217 # 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
221
222 def smtp_MAIL(self, arg):
223 print >> DEBUGSTREAM, '===> MAIL', arg
Guido van Rossum5e812702007-10-22 16:27:19 +0000224 address = self.__getaddr('FROM:', arg) if arg else None
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000225 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 >> DEBUGSTREAM, 'sender:', self.__mailfrom
233 self.push('250 Ok')
234
235 def smtp_RCPT(self, arg):
236 print >> DEBUGSTREAM, '===> RCPT', arg
237 if not self.__mailfrom:
238 self.push('503 Error: need MAIL command')
239 return
Guido van Rossum910ab502007-10-23 19:25:41 +0000240 address = self.__getaddr('TO:', arg) if arg else None
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000241 if not address:
242 self.push('501 Syntax: RCPT TO: <address>')
243 return
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000244 self.__rcpttos.append(address)
245 print >> DEBUGSTREAM, 'recips:', self.__rcpttos
246 self.push('250 Ok')
247
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')
258
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('\r\n.\r\n')
268 self.push('354 End data with <CR><LF>.<CR><LF>')
269
270
Barry Warsaw0e8427e2001-10-04 16:27:04 +0000271
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000272class SMTPServer(asyncore.dispatcher):
273 def __init__(self, localaddr, remoteaddr):
274 self._localaddr = localaddr
275 self._remoteaddr = remoteaddr
276 asyncore.dispatcher.__init__(self)
Giampaolo Rodolàe00e2f02010-06-30 17:38:28 +0000277 try:
278 self.create_socket(socket.AF_INET, socket.SOCK_STREAM)
279 # try to re-use a server port if possible
280 self.set_reuse_addr()
281 self.bind(localaddr)
282 self.listen(5)
283 except:
284 # cleanup asyncore.socket_map before raising
285 self.close()
286 raise
287 else:
288 print >> DEBUGSTREAM, \
289 '%s started at %s\n\tLocal addr: %s\n\tRemote addr:%s' % (
290 self.__class__.__name__, time.ctime(time.time()),
291 localaddr, remoteaddr)
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000292
293 def handle_accept(self):
294 conn, addr = self.accept()
295 print >> DEBUGSTREAM, 'Incoming connection from %s' % repr(addr)
296 channel = SMTPChannel(self, conn, addr)
297
298 # API for "doing something useful with the message"
299 def process_message(self, peer, mailfrom, rcpttos, data):
300 """Override this abstract method to handle messages from the client.
301
302 peer is a tuple containing (ipaddr, port) of the client that made the
303 socket connection to our smtp port.
304
305 mailfrom is the raw address the client claims the message is coming
306 from.
307
308 rcpttos is a list of raw addresses the client wishes to deliver the
309 message to.
310
311 data is a string containing the entire full text of the message,
312 headers (if supplied) and all. It has been `de-transparencied'
313 according to RFC 821, Section 4.5.2. In other words, a line
314 containing a `.' followed by other text has had the leading dot
315 removed.
316
317 This function should return None, for a normal `250 Ok' response;
318 otherwise it returns the desired response string in RFC 821 format.
319
320 """
Guido van Rossumb8b45ea2001-04-15 13:06:04 +0000321 raise NotImplementedError
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000322
Tim Peters658cba62001-02-09 20:06:00 +0000323
Barry Warsaw0e8427e2001-10-04 16:27:04 +0000324
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000325class DebuggingServer(SMTPServer):
326 # Do something with the gathered message
327 def process_message(self, peer, mailfrom, rcpttos, data):
328 inheaders = 1
329 lines = data.split('\n')
330 print '---------- MESSAGE FOLLOWS ----------'
331 for line in lines:
332 # headers first
333 if inheaders and not line:
334 print 'X-Peer:', peer[0]
335 inheaders = 0
336 print line
337 print '------------ END MESSAGE ------------'
338
339
Barry Warsaw0e8427e2001-10-04 16:27:04 +0000340
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000341class PureProxy(SMTPServer):
342 def process_message(self, peer, mailfrom, rcpttos, data):
343 lines = data.split('\n')
344 # Look for the last header
345 i = 0
346 for line in lines:
347 if not line:
348 break
349 i += 1
350 lines.insert(i, 'X-Peer: %s' % peer[0])
351 data = NEWLINE.join(lines)
352 refused = self._deliver(mailfrom, rcpttos, data)
353 # TBD: what to do with refused addresses?
Neal Norwitzf1516252002-02-11 18:05:05 +0000354 print >> DEBUGSTREAM, 'we got some refusals:', refused
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000355
356 def _deliver(self, mailfrom, rcpttos, data):
357 import smtplib
358 refused = {}
359 try:
360 s = smtplib.SMTP()
361 s.connect(self._remoteaddr[0], self._remoteaddr[1])
362 try:
363 refused = s.sendmail(mailfrom, rcpttos, data)
364 finally:
365 s.quit()
366 except smtplib.SMTPRecipientsRefused, e:
367 print >> DEBUGSTREAM, 'got SMTPRecipientsRefused'
368 refused = e.recipients
369 except (socket.error, smtplib.SMTPException), e:
370 print >> DEBUGSTREAM, 'got', e.__class__
371 # All recipients were refused. If the exception had an associated
372 # error code, use it. Otherwise,fake it with a non-triggering
373 # exception code.
374 errcode = getattr(e, 'smtp_code', -1)
375 errmsg = getattr(e, 'smtp_error', 'ignore')
376 for r in rcpttos:
377 refused[r] = (errcode, errmsg)
378 return refused
379
380
Barry Warsaw0e8427e2001-10-04 16:27:04 +0000381
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000382class MailmanProxy(PureProxy):
383 def process_message(self, peer, mailfrom, rcpttos, data):
384 from cStringIO import StringIO
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000385 from Mailman import Utils
386 from Mailman import Message
387 from Mailman import MailList
388 # If the message is to a Mailman mailing list, then we'll invoke the
389 # Mailman script directly, without going through the real smtpd.
390 # Otherwise we'll forward it to the local proxy for disposition.
391 listnames = []
392 for rcpt in rcpttos:
393 local = rcpt.lower().split('@')[0]
394 # We allow the following variations on the theme
395 # listname
396 # listname-admin
397 # listname-owner
398 # listname-request
399 # listname-join
400 # listname-leave
401 parts = local.split('-')
402 if len(parts) > 2:
403 continue
404 listname = parts[0]
405 if len(parts) == 2:
406 command = parts[1]
407 else:
408 command = ''
409 if not Utils.list_exists(listname) or command not in (
410 '', 'admin', 'owner', 'request', 'join', 'leave'):
411 continue
412 listnames.append((rcpt, listname, command))
413 # Remove all list recipients from rcpttos and forward what we're not
414 # going to take care of ourselves. Linear removal should be fine
415 # since we don't expect a large number of recipients.
416 for rcpt, listname, command in listnames:
417 rcpttos.remove(rcpt)
Tim Peters658cba62001-02-09 20:06:00 +0000418 # If there's any non-list destined recipients left,
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000419 print >> DEBUGSTREAM, 'forwarding recips:', ' '.join(rcpttos)
420 if rcpttos:
421 refused = self._deliver(mailfrom, rcpttos, data)
422 # TBD: what to do with refused addresses?
Neal Norwitzf1516252002-02-11 18:05:05 +0000423 print >> DEBUGSTREAM, 'we got refusals:', refused
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000424 # Now deliver directly to the list commands
425 mlists = {}
426 s = StringIO(data)
427 msg = Message.Message(s)
428 # These headers are required for the proper execution of Mailman. All
Mark Dickinson3e4caeb2009-02-21 20:27:01 +0000429 # MTAs in existence seem to add these if the original message doesn't
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000430 # have them.
431 if not msg.getheader('from'):
432 msg['From'] = mailfrom
433 if not msg.getheader('date'):
434 msg['Date'] = time.ctime(time.time())
435 for rcpt, listname, command in listnames:
436 print >> DEBUGSTREAM, 'sending message to', rcpt
437 mlist = mlists.get(listname)
438 if not mlist:
439 mlist = MailList.MailList(listname, lock=0)
440 mlists[listname] = mlist
441 # dispatch on the type of command
442 if command == '':
443 # post
444 msg.Enqueue(mlist, tolist=1)
445 elif command == 'admin':
446 msg.Enqueue(mlist, toadmin=1)
447 elif command == 'owner':
448 msg.Enqueue(mlist, toowner=1)
449 elif command == 'request':
450 msg.Enqueue(mlist, torequest=1)
451 elif command in ('join', 'leave'):
452 # TBD: this is a hack!
453 if command == 'join':
454 msg['Subject'] = 'subscribe'
455 else:
456 msg['Subject'] = 'unsubscribe'
457 msg.Enqueue(mlist, torequest=1)
458
459
Barry Warsaw0e8427e2001-10-04 16:27:04 +0000460
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000461class Options:
462 setuid = 1
463 classname = 'PureProxy'
464
465
Barry Warsaw0e8427e2001-10-04 16:27:04 +0000466
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000467def parseargs():
468 global DEBUGSTREAM
469 try:
470 opts, args = getopt.getopt(
471 sys.argv[1:], 'nVhc:d',
472 ['class=', 'nosetuid', 'version', 'help', 'debug'])
473 except getopt.error, e:
474 usage(1, e)
475
476 options = Options()
477 for opt, arg in opts:
478 if opt in ('-h', '--help'):
479 usage(0)
480 elif opt in ('-V', '--version'):
481 print >> sys.stderr, __version__
482 sys.exit(0)
483 elif opt in ('-n', '--nosetuid'):
484 options.setuid = 0
485 elif opt in ('-c', '--class'):
486 options.classname = arg
487 elif opt in ('-d', '--debug'):
488 DEBUGSTREAM = sys.stderr
489
490 # parse the rest of the arguments
Barry Warsaw0e8427e2001-10-04 16:27:04 +0000491 if len(args) < 1:
492 localspec = 'localhost:8025'
493 remotespec = 'localhost:25'
494 elif len(args) < 2:
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000495 localspec = args[0]
Barry Warsaw0e8427e2001-10-04 16:27:04 +0000496 remotespec = 'localhost:25'
Barry Warsawebf54272001-11-04 03:04:25 +0000497 elif len(args) < 3:
498 localspec = args[0]
499 remotespec = args[1]
Barry Warsaw0e8427e2001-10-04 16:27:04 +0000500 else:
501 usage(1, 'Invalid arguments: %s' % COMMASPACE.join(args))
502
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000503 # split into host/port pairs
504 i = localspec.find(':')
505 if i < 0:
Barry Warsaw0e8427e2001-10-04 16:27:04 +0000506 usage(1, 'Bad local spec: %s' % localspec)
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000507 options.localhost = localspec[:i]
508 try:
509 options.localport = int(localspec[i+1:])
510 except ValueError:
Barry Warsaw0e8427e2001-10-04 16:27:04 +0000511 usage(1, 'Bad local port: %s' % localspec)
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000512 i = remotespec.find(':')
513 if i < 0:
Barry Warsaw0e8427e2001-10-04 16:27:04 +0000514 usage(1, 'Bad remote spec: %s' % remotespec)
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000515 options.remotehost = remotespec[:i]
516 try:
517 options.remoteport = int(remotespec[i+1:])
518 except ValueError:
Barry Warsaw0e8427e2001-10-04 16:27:04 +0000519 usage(1, 'Bad remote port: %s' % remotespec)
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000520 return options
521
522
Barry Warsaw0e8427e2001-10-04 16:27:04 +0000523
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000524if __name__ == '__main__':
525 options = parseargs()
526 # Become nobody
527 if options.setuid:
528 try:
529 import pwd
530 except ImportError:
531 print >> sys.stderr, \
532 'Cannot import module "pwd"; try running with -n option.'
533 sys.exit(1)
534 nobody = pwd.getpwnam('nobody')[2]
535 try:
536 os.setuid(nobody)
537 except OSError, e:
Guido van Rossum4ba3d652001-03-02 06:42:34 +0000538 if e.errno != errno.EPERM: raise
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000539 print >> sys.stderr, \
540 'Cannot setuid "nobody"; try running with -n option.'
541 sys.exit(1)
Skip Montanaro90e01532004-06-26 19:18:49 +0000542 classname = options.classname
543 if "." in classname:
544 lastdot = classname.rfind(".")
545 mod = __import__(classname[:lastdot], globals(), locals(), [""])
546 classname = classname[lastdot+1:]
547 else:
548 import __main__ as mod
Skip Montanaro90e01532004-06-26 19:18:49 +0000549 class_ = getattr(mod, classname)
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000550 proxy = class_((options.localhost, options.localport),
551 (options.remotehost, options.remoteport))
552 try:
553 asyncore.loop()
554 except KeyboardInterrupt:
555 pass