SMTP Client for Tornado

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

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()