aiosmtpd

An asyncio-based SMTP server  

Pycon 2017 Portland, Oregon

May 21, 2017

Barry Warsaw

Every program attempts to expand until it can read mail. Those programs which cannot so expand are replaced by ones which can.

- jwz (though maybe not)

Zawinski's Law of Software Envelopment

Simple

Mail

Transport

Protocol

(and its little cousin LMTP

Local Mail Transport Protocol)

Motivation

Why an SMTP server?

  • Testing email clients
  • Pythonically configurable server
  • Experimental protocols
  • Production quality server

SMTP and LMTP

Some relevant RFCs

  • RFC 821 (1982) - Original SMTP standard
  • RFC 5321 (2008) - Defines ESMTP
  • RFC 2033 - Defines LMTP
  • RFC 1870 - message sizes
  • RFC 6531 - internationalization
  • (and so on...!)

SMTP in a nutshell

220 subdivisions Python SMTP 1.0
From: Geddy <geddy@example.com>
To: Alex <alex@example.com>
Bcc: Neil <neil@example.com>
Subject: New Music

Hey, we need to record a new album!
QUIT
221 Bye
HELO limelight
250 subdivisions
MAIL FROM:<geddy@example.com>
250 OK
RCPT TO:<alex@example.com>
250 OK
RCPT TO:<neil@example.com>
250 OK
DATA
354 End data with <CR><LF>.<CR><LF>
.
250 OK

RFC 5321

RFC 5322

Variations

Extended SMTP (ESMTP)

EHLO limelight

Local Mail Transport Protocol (LMTP)

LHLO limelight

Original SMTP

HELO limelight

(Very) brief history

  • asynchat/asyncore
  • smtpd.py (2001, Python 2.1a2)
  • lazr.smtptest (2009)
  • asyncio (2014, Python 3.4)
  • aiosmtp (Benjamin Bader)
  • aiosmtpd (April 2015 - Washington DC)

Moving pictures

Components

  • SMTP/LMTP classes - protocol
  • Session & envelope - state
  • Handlers - events
  • Controller - optional, but helpful!

Session

  • socket peer
  • SSL details
  • ESMTP flag
  • event loop

Newly created on every client connection

Envelope

  • MAIL FROM address
  • RCPT TO addresses
  • Original content (always bytes)
  • Content (bytes or str)
  • Additional ESMTP options

HELO/RSET state

SMTP class

  • Verbs as coroutines
  • STARTTLS
  • UTF-8 handling
  • Calls the event handler
  • Exception hooks
  • Extensible via subclassing

SMTP Verbs

HELO

    async def smtp_HELO(self, hostname):
        if not hostname:
            await self.push('501 Syntax: HELO hostname')
            return
        self._set_rset_state()
        status = await self._call_handler_hook('HELO', hostname)
        if status is MISSING:
            self.session.host_name = hostname
            status = '250' {}'.format(self.hostname)
        await self.push(status)

Get the server's time

from aiosmtpd.smtp import SMTP
from datetime import datetime

class MySMTPish(SMTP):    
    async def smtp_NOW(self, arg):
        if arg == 'UTC':
            now = datetime.utcnow()
        elif not arg:
            now = datetime.now()
        else:
            await self.push('501 Syntax: NOW [UTC]')
        now = now.replace(microsecond=0)
        await self.push('250 {}'.format(now))

Get the server's time

% telnet localhost 8025
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
220 presto Python SMTP 1.0
NOW
250 2017-05-16 12:05:05
NOW
250 2017-05-16 12:05:08
NOW UTC
250 2017-05-16 19:05:10
NOW AND THEN
501 Syntax: NOW [UTC]
QUIT
221 Bye

Event Handlers

Handlers

  • Introspection for verbs
  • Coroutines
  • Return status code
  • No default behavior
async def handle_VERB(self, server, session, envelope)

HELO counter

class Counter:
    helo_counter = 0
  
    async def handle_HELO(self, server, session, envelope, hostname):
        self.session.hostname = hostname
        self.helo_counter += 1
        return '250 OK'

smtp = SMTP(Counter())
run_server_for_a_while(smtp)
print('We saw {} HELOs'.format(smtp.event_handler.helo_counter))

Controller

  • Runs server in subthread
  • Start/stop semantics
  • Pass in hostname, port, etc.
  • Override factory() to use custom SMTP subclasses

Command line

$ python3 -m aiosmtpd -n -c MyHandler arg1 arg2

$ telnet localhost 8025
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
220 subdivisions Python SMTP 1.0
QUIT
221 Bye
Connection closed by foreign host.

CLI arguments

class MyHandler:
    @classmethod
    def from_cli(cls, parser, *args):
        kws = convert(args)
        return cls(**kws)

Acknowledgments

  • Benjamin Bader - aiosmtp (no 'd')
  • DC Hackathon Crew:
    • Jason Coombs
    • Andrew Kuchling
    • Eric V. Smith
    • Barry Warsaw
    • R. David Murray (honorary)
  • Konstantin Volkov
  • Matthias Rav
  • (and others... thanks!)

aiosmtpd

https://github.com/aio-libs/aiosmtpd

 

http://aiosmtpd.readthedocs.io/

 

Requires at least Python 3.4 (but see GH#16)

Barry Warsaw

barry@{python,list,debian}.org

barry@ubuntu.com

@pumpichank

github.com/warsaw

gitlab.com/warsaw

aiosmtpd

By Barry Warsaw

aiosmtpd

Pycon 2017 talk

  • 771