blob: 2d17b8ddd13ca89b0c25a595736e67610226dcfb [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()
Giampaolo RodolĂ 8664d742010-08-23 22:48:51 +0000124 try:
125 self.__peer = conn.getpeername()
126 except socket.error, err:
127 # a race condition may occur if the other end is closing
128 # before we can get the peername
129 self.close()
130 if err[0] != errno.ENOTCONN:
131 raise
132 return
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000133 print >> DEBUGSTREAM, 'Peer:', repr(self.__peer)
134 self.push('220 %s %s' % (self.__fqdn, __version__))
135 self.set_terminator('\r\n')
136
137 # Overrides base class for convenience
138 def push(self, msg):
139 asynchat.async_chat.push(self, msg + '\r\n')
140
141 # Implementation of base class abstract method
142 def collect_incoming_data(self, data):
143 self.__line.append(data)
144
145 # Implementation of base class abstract method
146 def found_terminator(self):
147 line = EMPTYSTRING.join(self.__line)
Barry Warsaw406d46e2001-08-13 21:18:01 +0000148 print >> DEBUGSTREAM, 'Data:', repr(line)
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000149 self.__line = []
150 if self.__state == self.COMMAND:
151 if not line:
152 self.push('500 Error: bad syntax')
153 return
154 method = None
155 i = line.find(' ')
156 if i < 0:
157 command = line.upper()
158 arg = None
159 else:
160 command = line[:i].upper()
161 arg = line[i+1:].strip()
162 method = getattr(self, 'smtp_' + command, None)
163 if not method:
164 self.push('502 Error: command "%s" not implemented' % command)
165 return
166 method(arg)
167 return
168 else:
Guido van Rossum4ba3d652001-03-02 06:42:34 +0000169 if self.__state != self.DATA:
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000170 self.push('451 Internal confusion')
171 return
172 # Remove extraneous carriage returns and de-transparency according
173 # to RFC 821, Section 4.5.2.
174 data = []
175 for text in line.split('\r\n'):
176 if text and text[0] == '.':
177 data.append(text[1:])
178 else:
179 data.append(text)
180 self.__data = NEWLINE.join(data)
181 status = self.__server.process_message(self.__peer,
182 self.__mailfrom,
183 self.__rcpttos,
184 self.__data)
185 self.__rcpttos = []
186 self.__mailfrom = None
187 self.__state = self.COMMAND
188 self.set_terminator('\r\n')
189 if not status:
190 self.push('250 Ok')
191 else:
192 self.push(status)
193
194 # SMTP and ESMTP commands
195 def smtp_HELO(self, arg):
196 if not arg:
197 self.push('501 Syntax: HELO hostname')
198 return
199 if self.__greeting:
200 self.push('503 Duplicate HELO/EHLO')
201 else:
202 self.__greeting = arg
203 self.push('250 %s' % self.__fqdn)
204
205 def smtp_NOOP(self, arg):
206 if arg:
207 self.push('501 Syntax: NOOP')
208 else:
209 self.push('250 Ok')
210
211 def smtp_QUIT(self, arg):
212 # args is ignored
213 self.push('221 Bye')
214 self.close_when_done()
215
216 # factored
217 def __getaddr(self, keyword, arg):
218 address = None
219 keylen = len(keyword)
220 if arg[:keylen].upper() == keyword:
221 address = arg[keylen:].strip()
Barry Warsawebf54272001-11-04 03:04:25 +0000222 if not address:
223 pass
224 elif address[0] == '<' and address[-1] == '>' and address != '<>':
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000225 # Addresses can be in the form <person@dom.com> but watch out
226 # for null address, e.g. <>
227 address = address[1:-1]
228 return address
229
230 def smtp_MAIL(self, arg):
231 print >> DEBUGSTREAM, '===> MAIL', arg
Guido van Rossum5e812702007-10-22 16:27:19 +0000232 address = self.__getaddr('FROM:', arg) if arg else None
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000233 if not address:
234 self.push('501 Syntax: MAIL FROM:<address>')
235 return
236 if self.__mailfrom:
237 self.push('503 Error: nested MAIL command')
238 return
239 self.__mailfrom = address
240 print >> DEBUGSTREAM, 'sender:', self.__mailfrom
241 self.push('250 Ok')
242
243 def smtp_RCPT(self, arg):
244 print >> DEBUGSTREAM, '===> RCPT', arg
245 if not self.__mailfrom:
246 self.push('503 Error: need MAIL command')
247 return
Guido van Rossum910ab502007-10-23 19:25:41 +0000248 address = self.__getaddr('TO:', arg) if arg else None
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000249 if not address:
250 self.push('501 Syntax: RCPT TO: <address>')
251 return
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000252 self.__rcpttos.append(address)
253 print >> DEBUGSTREAM, 'recips:', self.__rcpttos
254 self.push('250 Ok')
255
256 def smtp_RSET(self, arg):
257 if arg:
258 self.push('501 Syntax: RSET')
259 return
260 # Resets the sender, recipients, and data, but not the greeting
261 self.__mailfrom = None
262 self.__rcpttos = []
263 self.__data = ''
264 self.__state = self.COMMAND
265 self.push('250 Ok')
266
267 def smtp_DATA(self, arg):
268 if not self.__rcpttos:
269 self.push('503 Error: need RCPT command')
270 return
271 if arg:
272 self.push('501 Syntax: DATA')
273 return
274 self.__state = self.DATA
275 self.set_terminator('\r\n.\r\n')
276 self.push('354 End data with <CR><LF>.<CR><LF>')
277
278
Barry Warsaw0e8427e2001-10-04 16:27:04 +0000279
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000280class SMTPServer(asyncore.dispatcher):
281 def __init__(self, localaddr, remoteaddr):
282 self._localaddr = localaddr
283 self._remoteaddr = remoteaddr
284 asyncore.dispatcher.__init__(self)
Giampaolo RodolĂ e00e2f02010-06-30 17:38:28 +0000285 try:
286 self.create_socket(socket.AF_INET, socket.SOCK_STREAM)
287 # try to re-use a server port if possible
288 self.set_reuse_addr()
289 self.bind(localaddr)
290 self.listen(5)
291 except:
292 # cleanup asyncore.socket_map before raising
293 self.close()
294 raise
295 else:
296 print >> DEBUGSTREAM, \
297 '%s started at %s\n\tLocal addr: %s\n\tRemote addr:%s' % (
298 self.__class__.__name__, time.ctime(time.time()),
299 localaddr, remoteaddr)
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000300
301 def handle_accept(self):
Giampaolo RodolĂ 8664d742010-08-23 22:48:51 +0000302 try:
303 conn, addr = self.accept()
304 except TypeError:
305 # sometimes accept() might return None
306 return
307 except socket.error, err:
308 # ECONNABORTED might be thrown
309 if err[0] != errno.ECONNABORTED:
310 raise
311 return
312 else:
313 # sometimes addr == None instead of (ip, port)
314 if addr == None:
315 return
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000316 print >> DEBUGSTREAM, 'Incoming connection from %s' % repr(addr)
317 channel = SMTPChannel(self, conn, addr)
318
319 # API for "doing something useful with the message"
320 def process_message(self, peer, mailfrom, rcpttos, data):
321 """Override this abstract method to handle messages from the client.
322
323 peer is a tuple containing (ipaddr, port) of the client that made the
324 socket connection to our smtp port.
325
326 mailfrom is the raw address the client claims the message is coming
327 from.
328
329 rcpttos is a list of raw addresses the client wishes to deliver the
330 message to.
331
332 data is a string containing the entire full text of the message,
333 headers (if supplied) and all. It has been `de-transparencied'
334 according to RFC 821, Section 4.5.2. In other words, a line
335 containing a `.' followed by other text has had the leading dot
336 removed.
337
338 This function should return None, for a normal `250 Ok' response;
339 otherwise it returns the desired response string in RFC 821 format.
340
341 """
Guido van Rossumb8b45ea2001-04-15 13:06:04 +0000342 raise NotImplementedError
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000343
Tim Peters658cba62001-02-09 20:06:00 +0000344
Barry Warsaw0e8427e2001-10-04 16:27:04 +0000345
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000346class DebuggingServer(SMTPServer):
347 # Do something with the gathered message
348 def process_message(self, peer, mailfrom, rcpttos, data):
349 inheaders = 1
350 lines = data.split('\n')
351 print '---------- MESSAGE FOLLOWS ----------'
352 for line in lines:
353 # headers first
354 if inheaders and not line:
355 print 'X-Peer:', peer[0]
356 inheaders = 0
357 print line
358 print '------------ END MESSAGE ------------'
359
360
Barry Warsaw0e8427e2001-10-04 16:27:04 +0000361
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000362class PureProxy(SMTPServer):
363 def process_message(self, peer, mailfrom, rcpttos, data):
364 lines = data.split('\n')
365 # Look for the last header
366 i = 0
367 for line in lines:
368 if not line:
369 break
370 i += 1
371 lines.insert(i, 'X-Peer: %s' % peer[0])
372 data = NEWLINE.join(lines)
373 refused = self._deliver(mailfrom, rcpttos, data)
374 # TBD: what to do with refused addresses?
Neal Norwitzf1516252002-02-11 18:05:05 +0000375 print >> DEBUGSTREAM, 'we got some refusals:', refused
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000376
377 def _deliver(self, mailfrom, rcpttos, data):
378 import smtplib
379 refused = {}
380 try:
381 s = smtplib.SMTP()
382 s.connect(self._remoteaddr[0], self._remoteaddr[1])
383 try:
384 refused = s.sendmail(mailfrom, rcpttos, data)
385 finally:
386 s.quit()
387 except smtplib.SMTPRecipientsRefused, e:
388 print >> DEBUGSTREAM, 'got SMTPRecipientsRefused'
389 refused = e.recipients
390 except (socket.error, smtplib.SMTPException), e:
391 print >> DEBUGSTREAM, 'got', e.__class__
392 # All recipients were refused. If the exception had an associated
393 # error code, use it. Otherwise,fake it with a non-triggering
394 # exception code.
395 errcode = getattr(e, 'smtp_code', -1)
396 errmsg = getattr(e, 'smtp_error', 'ignore')
397 for r in rcpttos:
398 refused[r] = (errcode, errmsg)
399 return refused
400
401
Barry Warsaw0e8427e2001-10-04 16:27:04 +0000402
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000403class MailmanProxy(PureProxy):
404 def process_message(self, peer, mailfrom, rcpttos, data):
405 from cStringIO import StringIO
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000406 from Mailman import Utils
407 from Mailman import Message
408 from Mailman import MailList
409 # If the message is to a Mailman mailing list, then we'll invoke the
410 # Mailman script directly, without going through the real smtpd.
411 # Otherwise we'll forward it to the local proxy for disposition.
412 listnames = []
413 for rcpt in rcpttos:
414 local = rcpt.lower().split('@')[0]
415 # We allow the following variations on the theme
416 # listname
417 # listname-admin
418 # listname-owner
419 # listname-request
420 # listname-join
421 # listname-leave
422 parts = local.split('-')
423 if len(parts) > 2:
424 continue
425 listname = parts[0]
426 if len(parts) == 2:
427 command = parts[1]
428 else:
429 command = ''
430 if not Utils.list_exists(listname) or command not in (
431 '', 'admin', 'owner', 'request', 'join', 'leave'):
432 continue
433 listnames.append((rcpt, listname, command))
434 # Remove all list recipients from rcpttos and forward what we're not
435 # going to take care of ourselves. Linear removal should be fine
436 # since we don't expect a large number of recipients.
437 for rcpt, listname, command in listnames:
438 rcpttos.remove(rcpt)
Tim Peters658cba62001-02-09 20:06:00 +0000439 # If there's any non-list destined recipients left,
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000440 print >> DEBUGSTREAM, 'forwarding recips:', ' '.join(rcpttos)
441 if rcpttos:
442 refused = self._deliver(mailfrom, rcpttos, data)
443 # TBD: what to do with refused addresses?
Neal Norwitzf1516252002-02-11 18:05:05 +0000444 print >> DEBUGSTREAM, 'we got refusals:', refused
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000445 # Now deliver directly to the list commands
446 mlists = {}
447 s = StringIO(data)
448 msg = Message.Message(s)
449 # These headers are required for the proper execution of Mailman. All
Mark Dickinson3e4caeb2009-02-21 20:27:01 +0000450 # MTAs in existence seem to add these if the original message doesn't
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000451 # have them.
452 if not msg.getheader('from'):
453 msg['From'] = mailfrom
454 if not msg.getheader('date'):
455 msg['Date'] = time.ctime(time.time())
456 for rcpt, listname, command in listnames:
457 print >> DEBUGSTREAM, 'sending message to', rcpt
458 mlist = mlists.get(listname)
459 if not mlist:
460 mlist = MailList.MailList(listname, lock=0)
461 mlists[listname] = mlist
462 # dispatch on the type of command
463 if command == '':
464 # post
465 msg.Enqueue(mlist, tolist=1)
466 elif command == 'admin':
467 msg.Enqueue(mlist, toadmin=1)
468 elif command == 'owner':
469 msg.Enqueue(mlist, toowner=1)
470 elif command == 'request':
471 msg.Enqueue(mlist, torequest=1)
472 elif command in ('join', 'leave'):
473 # TBD: this is a hack!
474 if command == 'join':
475 msg['Subject'] = 'subscribe'
476 else:
477 msg['Subject'] = 'unsubscribe'
478 msg.Enqueue(mlist, torequest=1)
479
480
Barry Warsaw0e8427e2001-10-04 16:27:04 +0000481
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000482class Options:
483 setuid = 1
484 classname = 'PureProxy'
485
486
Barry Warsaw0e8427e2001-10-04 16:27:04 +0000487
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000488def parseargs():
489 global DEBUGSTREAM
490 try:
491 opts, args = getopt.getopt(
492 sys.argv[1:], 'nVhc:d',
493 ['class=', 'nosetuid', 'version', 'help', 'debug'])
494 except getopt.error, e:
495 usage(1, e)
496
497 options = Options()
498 for opt, arg in opts:
499 if opt in ('-h', '--help'):
500 usage(0)
501 elif opt in ('-V', '--version'):
502 print >> sys.stderr, __version__
503 sys.exit(0)
504 elif opt in ('-n', '--nosetuid'):
505 options.setuid = 0
506 elif opt in ('-c', '--class'):
507 options.classname = arg
508 elif opt in ('-d', '--debug'):
509 DEBUGSTREAM = sys.stderr
510
511 # parse the rest of the arguments
Barry Warsaw0e8427e2001-10-04 16:27:04 +0000512 if len(args) < 1:
513 localspec = 'localhost:8025'
514 remotespec = 'localhost:25'
515 elif len(args) < 2:
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000516 localspec = args[0]
Barry Warsaw0e8427e2001-10-04 16:27:04 +0000517 remotespec = 'localhost:25'
Barry Warsawebf54272001-11-04 03:04:25 +0000518 elif len(args) < 3:
519 localspec = args[0]
520 remotespec = args[1]
Barry Warsaw0e8427e2001-10-04 16:27:04 +0000521 else:
522 usage(1, 'Invalid arguments: %s' % COMMASPACE.join(args))
523
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000524 # split into host/port pairs
525 i = localspec.find(':')
526 if i < 0:
Barry Warsaw0e8427e2001-10-04 16:27:04 +0000527 usage(1, 'Bad local spec: %s' % localspec)
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000528 options.localhost = localspec[:i]
529 try:
530 options.localport = int(localspec[i+1:])
531 except ValueError:
Barry Warsaw0e8427e2001-10-04 16:27:04 +0000532 usage(1, 'Bad local port: %s' % localspec)
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000533 i = remotespec.find(':')
534 if i < 0:
Barry Warsaw0e8427e2001-10-04 16:27:04 +0000535 usage(1, 'Bad remote spec: %s' % remotespec)
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000536 options.remotehost = remotespec[:i]
537 try:
538 options.remoteport = int(remotespec[i+1:])
539 except ValueError:
Barry Warsaw0e8427e2001-10-04 16:27:04 +0000540 usage(1, 'Bad remote port: %s' % remotespec)
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000541 return options
542
543
Barry Warsaw0e8427e2001-10-04 16:27:04 +0000544
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000545if __name__ == '__main__':
546 options = parseargs()
547 # Become nobody
548 if options.setuid:
549 try:
550 import pwd
551 except ImportError:
552 print >> sys.stderr, \
553 'Cannot import module "pwd"; try running with -n option.'
554 sys.exit(1)
555 nobody = pwd.getpwnam('nobody')[2]
556 try:
557 os.setuid(nobody)
558 except OSError, e:
Guido van Rossum4ba3d652001-03-02 06:42:34 +0000559 if e.errno != errno.EPERM: raise
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000560 print >> sys.stderr, \
561 'Cannot setuid "nobody"; try running with -n option.'
562 sys.exit(1)
Skip Montanaro90e01532004-06-26 19:18:49 +0000563 classname = options.classname
564 if "." in classname:
565 lastdot = classname.rfind(".")
566 mod = __import__(classname[:lastdot], globals(), locals(), [""])
567 classname = classname[lastdot+1:]
568 else:
569 import __main__ as mod
Skip Montanaro90e01532004-06-26 19:18:49 +0000570 class_ = getattr(mod, classname)
Barry Warsaw7e0d9562001-01-31 22:51:35 +0000571 proxy = class_((options.localhost, options.localport),
572 (options.remotehost, options.remoteport))
573 try:
574 asyncore.loop()
575 except KeyboardInterrupt:
576 pass