2 """An RFC 2821 smtp proxy.
4 Usage: %(program)s [options] [localhost:localport [remotehost:remoteport]]
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).
16 Print the version number and exit.
20 Use `classname' as the concrete SMTP proxy class. Uses `SMTPProxy' by
25 Turn on debugging prints.
29 Print this message and exit.
31 Version: %(__version__)s
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.
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:
45 # SMTPServer - the base class for the backend. Raises NotImplementedError
46 # if you try to use it.
48 # DebuggingServer - simply prints each message it receives on stdout.
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!).
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.
62 # Please note that this script requires Python 2.0
64 # Author: Barry Warsaw <barry@digicool.com>
68 # - support mailbox delivery
71 # - handle error codes from the backend smtpd
82 __all__
= ["SMTPServer","DebuggingServer","PureProxy","MailmanProxy"]
85 __version__
= 'Python SMTP proxy version 0.2'
89 def write(self
, msg
): pass
93 DEBUGSTREAM
= Devnull()
100 def usage(code
, msg
=''):
101 print >> sys
.stderr
, __doc__
% globals()
103 print >> sys
.stderr
, msg
108 class SMTPChannel(asynchat
.async_chat
):
112 def __init__(self
, server
, conn
, addr
):
113 asynchat
.async_chat
.__init
__(self
, conn
)
114 self
.__server
= server
118 self
.__state
= self
.COMMAND
120 self
.__mailfrom
= None
123 self
.__fqdn
= socket
.getfqdn()
124 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')
129 # Overrides base class for convenience
131 asynchat
.async_chat
.push(self
, msg
+ '\r\n')
133 # Implementation of base class abstract method
134 def collect_incoming_data(self
, data
):
135 self
.__line
.append(data
)
137 # Implementation of base class abstract method
138 def found_terminator(self
):
139 line
= EMPTYSTRING
.join(self
.__line
)
140 print >> DEBUGSTREAM
, 'Data:', repr(line
)
142 if self
.__state
== self
.COMMAND
:
144 self
.push('500 Error: bad syntax')
149 command
= line
.upper()
152 command
= line
[:i
].upper()
153 arg
= line
[i
+1:].strip()
154 method
= getattr(self
, 'smtp_' + command
, None)
156 self
.push('502 Error: command "%s" not implemented' % command
)
161 if self
.__state
!= self
.DATA
:
162 self
.push('451 Internal confusion')
164 # Remove extraneous carriage returns and de-transparency according
165 # to RFC 821, Section 4.5.2.
167 for text
in line
.split('\r\n'):
168 if text
and text
[0] == '.':
169 data
.append(text
[1:])
172 self
.__data
= NEWLINE
.join(data
)
173 status
= self
.__server
.process_message(self
.__peer
,
178 self
.__mailfrom
= None
179 self
.__state
= self
.COMMAND
180 self
.set_terminator('\r\n')
186 # SMTP and ESMTP commands
187 def smtp_HELO(self
, arg
):
189 self
.push('501 Syntax: HELO hostname')
192 self
.push('503 Duplicate HELO/EHLO')
194 self
.__greeting
= arg
195 self
.push('250 %s' % self
.__fqdn
)
197 def smtp_NOOP(self
, arg
):
199 self
.push('501 Syntax: NOOP')
203 def smtp_QUIT(self
, arg
):
206 self
.close_when_done()
209 def __getaddr(self
, keyword
, arg
):
211 keylen
= len(keyword
)
212 if arg
[:keylen
].upper() == keyword
:
213 address
= arg
[keylen
:].strip()
216 elif address
[0] == '<' and address
[-1] == '>' and address
!= '<>':
217 # Addresses can be in the form <person@dom.com> but watch out
218 # for null address, e.g. <>
219 address
= address
[1:-1]
222 def smtp_MAIL(self
, arg
):
223 print >> DEBUGSTREAM
, '===> MAIL', arg
224 address
= self
.__getaddr
('FROM:', arg
)
226 self
.push('501 Syntax: MAIL FROM:<address>')
229 self
.push('503 Error: nested MAIL command')
231 self
.__mailfrom
= address
232 print >> DEBUGSTREAM
, 'sender:', self
.__mailfrom
235 def smtp_RCPT(self
, arg
):
236 print >> DEBUGSTREAM
, '===> RCPT', arg
237 if not self
.__mailfrom
:
238 self
.push('503 Error: need MAIL command')
240 address
= self
.__getaddr
('TO:', arg
)
242 self
.push('501 Syntax: RCPT TO: <address>')
244 if address
.lower().startswith('stimpy'):
245 self
.push('503 You suck %s' % address
)
247 self
.__rcpttos
.append(address
)
248 print >> DEBUGSTREAM
, 'recips:', self
.__rcpttos
251 def smtp_RSET(self
, arg
):
253 self
.push('501 Syntax: RSET')
255 # Resets the sender, recipients, and data, but not the greeting
256 self
.__mailfrom
= None
259 self
.__state
= self
.COMMAND
262 def smtp_DATA(self
, arg
):
263 if not self
.__rcpttos
:
264 self
.push('503 Error: need RCPT command')
267 self
.push('501 Syntax: DATA')
269 self
.__state
= self
.DATA
270 self
.set_terminator('\r\n.\r\n')
271 self
.push('354 End data with <CR><LF>.<CR><LF>')
275 class SMTPServer(asyncore
.dispatcher
):
276 def __init__(self
, localaddr
, remoteaddr
):
277 self
._localaddr
= localaddr
278 self
._remoteaddr
= remoteaddr
279 asyncore
.dispatcher
.__init
__(self
)
280 self
.create_socket(socket
.AF_INET
, socket
.SOCK_STREAM
)
281 # try to re-use a server port if possible
282 self
.set_reuse_addr()
285 print >> DEBUGSTREAM
, \
286 '%s started at %s\n\tLocal addr: %s\n\tRemote addr:%s' % (
287 self
.__class
__.__name
__, time
.ctime(time
.time()),
288 localaddr
, remoteaddr
)
290 def handle_accept(self
):
291 conn
, addr
= self
.accept()
292 print >> DEBUGSTREAM
, 'Incoming connection from %s' % repr(addr
)
293 channel
= SMTPChannel(self
, conn
, addr
)
295 # API for "doing something useful with the message"
296 def process_message(self
, peer
, mailfrom
, rcpttos
, data
):
297 """Override this abstract method to handle messages from the client.
299 peer is a tuple containing (ipaddr, port) of the client that made the
300 socket connection to our smtp port.
302 mailfrom is the raw address the client claims the message is coming
305 rcpttos is a list of raw addresses the client wishes to deliver the
308 data is a string containing the entire full text of the message,
309 headers (if supplied) and all. It has been `de-transparencied'
310 according to RFC 821, Section 4.5.2. In other words, a line
311 containing a `.' followed by other text has had the leading dot
314 This function should return None, for a normal `250 Ok' response;
315 otherwise it returns the desired response string in RFC 821 format.
318 raise NotImplementedError
322 class DebuggingServer(SMTPServer
):
323 # Do something with the gathered message
324 def process_message(self
, peer
, mailfrom
, rcpttos
, data
):
326 lines
= data
.split('\n')
327 print '---------- MESSAGE FOLLOWS ----------'
330 if inheaders
and not line
:
331 print 'X-Peer:', peer
[0]
334 print '------------ END MESSAGE ------------'
338 class PureProxy(SMTPServer
):
339 def process_message(self
, peer
, mailfrom
, rcpttos
, data
):
340 lines
= data
.split('\n')
341 # Look for the last header
347 lines
.insert(i
, 'X-Peer: %s' % peer
[0])
348 data
= NEWLINE
.join(lines
)
349 refused
= self
._deliver
(mailfrom
, rcpttos
, data
)
350 # TBD: what to do with refused addresses?
351 print >> DEBUGSTREAM
, 'we got some refusals'
353 def _deliver(self
, mailfrom
, rcpttos
, data
):
358 s
.connect(self
._remoteaddr
[0], self
._remoteaddr
[1])
360 refused
= s
.sendmail(mailfrom
, rcpttos
, data
)
363 except smtplib
.SMTPRecipientsRefused
, e
:
364 print >> DEBUGSTREAM
, 'got SMTPRecipientsRefused'
365 refused
= e
.recipients
366 except (socket
.error
, smtplib
.SMTPException
), e
:
367 print >> DEBUGSTREAM
, 'got', e
.__class
__
368 # All recipients were refused. If the exception had an associated
369 # error code, use it. Otherwise,fake it with a non-triggering
371 errcode
= getattr(e
, 'smtp_code', -1)
372 errmsg
= getattr(e
, 'smtp_error', 'ignore')
374 refused
[r
] = (errcode
, errmsg
)
379 class MailmanProxy(PureProxy
):
380 def process_message(self
, peer
, mailfrom
, rcpttos
, data
):
381 from cStringIO
import StringIO
382 from Mailman
import Utils
383 from Mailman
import Message
384 from Mailman
import MailList
385 # If the message is to a Mailman mailing list, then we'll invoke the
386 # Mailman script directly, without going through the real smtpd.
387 # Otherwise we'll forward it to the local proxy for disposition.
390 local
= rcpt
.lower().split('@')[0]
391 # We allow the following variations on the theme
398 parts
= local
.split('-')
406 if not Utils
.list_exists(listname
) or command
not in (
407 '', 'admin', 'owner', 'request', 'join', 'leave'):
409 listnames
.append((rcpt
, listname
, command
))
410 # Remove all list recipients from rcpttos and forward what we're not
411 # going to take care of ourselves. Linear removal should be fine
412 # since we don't expect a large number of recipients.
413 for rcpt
, listname
, command
in listnames
:
415 # If there's any non-list destined recipients left,
416 print >> DEBUGSTREAM
, 'forwarding recips:', ' '.join(rcpttos
)
418 refused
= self
._deliver
(mailfrom
, rcpttos
, data
)
419 # TBD: what to do with refused addresses?
420 print >> DEBUGSTREAM
, 'we got refusals'
421 # Now deliver directly to the list commands
424 msg
= Message
.Message(s
)
425 # These headers are required for the proper execution of Mailman. All
426 # MTAs in existance seem to add these if the original message doesn't
428 if not msg
.getheader('from'):
429 msg
['From'] = mailfrom
430 if not msg
.getheader('date'):
431 msg
['Date'] = time
.ctime(time
.time())
432 for rcpt
, listname
, command
in listnames
:
433 print >> DEBUGSTREAM
, 'sending message to', rcpt
434 mlist
= mlists
.get(listname
)
436 mlist
= MailList
.MailList(listname
, lock
=0)
437 mlists
[listname
] = mlist
438 # dispatch on the type of command
441 msg
.Enqueue(mlist
, tolist
=1)
442 elif command
== 'admin':
443 msg
.Enqueue(mlist
, toadmin
=1)
444 elif command
== 'owner':
445 msg
.Enqueue(mlist
, toowner
=1)
446 elif command
== 'request':
447 msg
.Enqueue(mlist
, torequest
=1)
448 elif command
in ('join', 'leave'):
449 # TBD: this is a hack!
450 if command
== 'join':
451 msg
['Subject'] = 'subscribe'
453 msg
['Subject'] = 'unsubscribe'
454 msg
.Enqueue(mlist
, torequest
=1)
460 classname
= 'PureProxy'
467 opts
, args
= getopt
.getopt(
468 sys
.argv
[1:], 'nVhc:d',
469 ['class=', 'nosetuid', 'version', 'help', 'debug'])
470 except getopt
.error
, e
:
474 for opt
, arg
in opts
:
475 if opt
in ('-h', '--help'):
477 elif opt
in ('-V', '--version'):
478 print >> sys
.stderr
, __version__
480 elif opt
in ('-n', '--nosetuid'):
482 elif opt
in ('-c', '--class'):
483 options
.classname
= arg
484 elif opt
in ('-d', '--debug'):
485 DEBUGSTREAM
= sys
.stderr
487 # parse the rest of the arguments
489 localspec
= 'localhost:8025'
490 remotespec
= 'localhost:25'
493 remotespec
= 'localhost:25'
498 usage(1, 'Invalid arguments: %s' % COMMASPACE
.join(args
))
500 # split into host/port pairs
501 i
= localspec
.find(':')
503 usage(1, 'Bad local spec: %s' % localspec
)
504 options
.localhost
= localspec
[:i
]
506 options
.localport
= int(localspec
[i
+1:])
508 usage(1, 'Bad local port: %s' % localspec
)
509 i
= remotespec
.find(':')
511 usage(1, 'Bad remote spec: %s' % remotespec
)
512 options
.remotehost
= remotespec
[:i
]
514 options
.remoteport
= int(remotespec
[i
+1:])
516 usage(1, 'Bad remote port: %s' % remotespec
)
521 if __name__
== '__main__':
522 options
= parseargs()
528 print >> sys
.stderr
, \
529 'Cannot import module "pwd"; try running with -n option.'
531 nobody
= pwd
.getpwnam('nobody')[2]
535 if e
.errno
!= errno
.EPERM
: raise
536 print >> sys
.stderr
, \
537 'Cannot setuid "nobody"; try running with -n option.'
540 class_
= getattr(__main__
, options
.classname
)
541 proxy
= class_((options
.localhost
, options
.localport
),
542 (options
.remotehost
, options
.remoteport
))
545 except KeyboardInterrupt: