Was looking around for a mail handler for Tornado, found it pretty amazing that it didn’t exist. Since I’ve only written on commercial SMTP server and have been playing with just about everything in Tornado Web at this point I figured it wasn’t too much work to whip one out. So, instead of using annoying little threads, here’s a fully async smtp client for Tornado.
Nothing fancy, just gets the job done.
Also available as a GitHub gist - https://gist.github.com/1358253
</span>
<pre>from tornado import ioloop
from tornado import iostream
import socket
class Envelope(object):
def __init__(self, sender, rcpt, body, callback):
self.sender = sender
self.rcpt = rcpt[:]
self.body = body
self.callback = callback
class SMTPClient(object):
CLOSED = -2
CONNECTED = -1
IDLE = 0
EHLO = 1
MAIL = 2
RCPT = 3
DATA = 4
DATA_DONE = 5
QUIT = 6
def __init__(self, host='localhost', port=25):
self.host = host
self.port = port
self.msgs = []
self.stream = None
self.state = self.CLOSED
def send_message(self, msg, callback=None):
"""Message is a django style EmailMessage object"""
if not msg:
return
self.msgs.append(Envelope(msg.from_email, msg.recipients(), msg.message().as_string(), callback))
self.begin()
def send(self, sender=None, rcpt=[], body="", callback=None):
"""Very simple sender, just take the necessary parameters to create an envelope"""
self.msgs.append(Envelope(sender, rcpt, body, callback))
self.begin()
def begin(self):
"""Start the sending of a message, if we need a connection open it"""
if not self.stream:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM, 0)
self.stream = iostream.IOStream(s)
self.stream.connect((self.host, self.port), self.connected)
else:
self.work_or_quit(self.process)
def work_or_quit(self, callback=None):
"""
callback is provided, for the startup case where we're not in the main processing loop
"""
if self.state == self.IDLE:
if self.msgs:
self.state = self.MAIL
self.stream.write('MAIL FROM: <%s>\r\n' % self.msgs[0].sender)
else:
self.state = self.QUIT
self.stream.write('QUIT\r\n')
if callback:
self.stream.read_until('\r\n', callback)
def connected(self):
"""Socket connect callback"""
self.state = self.CONNECTED
self.stream.read_until('\r\n', self.process)
def process(self, data):
# print self.state, data,
code = int(data[0:3])
if data[3] not in (' ', '\r', '\n'):
self.stream.read_until('\r\n', self.process)
return
if self.state == self.CONNECTED:
if not 200 <= code < 300:
return self.error("Unexpected status %d from CONNECT: %s" % (code, data.strip()))
self.state = self.EHLO
self.stream.write('EHLO localhost\r\n')
elif self.state == self.EHLO:
if not 200 <= code < 300:
return self.error("Unexpected status %d from EHLO: %s" % (code, data.strip()))
self.state = self.IDLE
self.work_or_quit()
elif self.state == self.MAIL:
if not 200 <= code < 300:
return self.error("Unexpected status %d from MAIL: %s" % (code, data.strip()))
if self.msgs[0].rcpt:
self.stream.write('RCPT TO: <%s>\r\n' % self.msgs[0].rcpt.pop(0))
self.state = self.RCPT
elif self.state == self.RCPT:
if not 200 <= code < 300:
return self.error("Unexpected status %d from RCPT: %s" % (code, data.strip()))
if self.msgs[0].rcpt:
self.stream.write('RCPT TO: <%s>\r\n' % self.msgs[0].rcpt.pop(0))
else:
self.stream.write('DATA\r\n')
self.state = self.DATA
elif self.state == self.DATA:
if code not in (354,) :
return self.error("Unexpected status %d from DATA: %s" % (code, data.strip()))
self.stream.write(self.msgs[0].body)
if self.msgs[0].body[-2:] != '\r\n':
self.stream.write('\r\n')
self.stream.write('.\r\n')
self.state = self.DATA_DONE
elif self.state == self.DATA_DONE:
if not 200 <= code < 300:
return self.error("Unexpected status %d from DATA END: %s" % (code, data.strip()))
if self.msgs[0].callback:
self.msgs[0].callback(True)
self.msgs.pop(0)
self.state = self.IDLE
self.work_or_quit()
elif self.state == self.QUIT:
if not 200 <= code < 300:
return self.error("Unexpected status %d from QUIT: %s" % (code, data.strip()))
self.close()
if self.stream:
self.stream.read_until('\r\n', self.process)
def error(self, msg):
self.close()
def close(self):
for msg in self.msgs:
if msg.callback:
msg.callback(False)
self.stream.close()
self.stream = None
self.state = self.CLOSED
if __name__ == '__main__':
client = SMTPClient('localhost', 25)
body = """Subject: Testing
Just a test
"""
client.send('foo@example.com', ['recipient@example.com'], body)
ioloop.IOLoop.instance().start()