Bueno, hoy estoy verbose. Se vé que estoy haciendo cosas de nuevo...

La cuestión es que, como dije un par de posts atrás, ando con la idea de hacer un servidor de mail no configurable, sino programable. Es decir, que en vez de andar toqueteando archivos de configuración, tratando de adivinar la semántica de cada opción a partir de los manuales, uno se sienta y lo programa de la forma en que uno quiera que se comporte. Esta idea surgió en una charla con otro sysadmin amigo hará unos 3 o 4 años, y quedó dormida hasta que con Lucio vimos la charla de Lighttpd en San Francisco.

Es obvio que un proyecto de este estilo no funcionaría bien a menos que la programación sea relativamente sencilla. Y no puede ser sencilla si el lenguaje no es sencillo. Y qué mejor que python para esa tarea. Y si hablamos de python, de servidores y apuntamos un poco alto, no podemos dejar a twisted fuera de la ecuación.

Ahora, si hay algo en twisted es su curva de aprendizaje no intuitiva. Twisted es un framework para desarrollo de servidores con un sistema de eventos asíncronos. Con esto se saca un montón de problemas de escalabilidad asociados a servidores con múltiples clientes. El tema es que entonces, se tiene que programar el sistema como una máquina de estados, que en general es así, pero donde cada estado es prácticamente un procedimiento aparte y además no se puede quedar haciendo nada pesado. Esto último es porque twisted es un event loop, y si en uno de los eventos nos quedamos haciendo cosas sin devolver el control al loop, el loop no puede procesar otros eventos.

Mas allá de todo eso, me decidí a usarlo lo mismo. twisted.mail tiene un montón de cosas listas para usar, sobre todo muchas interfaces, pero la documentación es inicialmente un poco confusa y no hay un tutorial que uno pueda seguir. Por suerte en la oficina tenemos un "programming with Twisted" que justo viene con ejemplos de un server de SMTP y de un cliente, inclusive explicados.

Comenzando con ése es que me largué a hacer esto. Como la idea es que sea programable, y con esto lograr la mayor flexibilidad de configuración, decidí empezar por rascarme donde me pica: me hace falta un server que sepa usar varios samarthosts, dependiendo de a qué red esté conectado y de qué cuenta de mail use para enviar mail, y que soporte encolado cuando no tenga conexión a la red.

En sucesivos posts voy a ir poniendo cachos de código mas o menos explicando como funciona todo.

python twisted twismtpy

Posted Wed 27 Jan 2010 11:55:55 PM CET Tags: twismtpy

Vamos a empezar con un server básico, que es con lo que empecé yo. El código es prácticamente lo mismo que está en el libro que mencioné. Básicamnete es un servidor que sabe recibir mails y guardarlo en maildirs:

from twisted.mail import smtp, maildir
from twisted.internet import protocol, reactor

from zope.interface import implements
import os
from email.Header import Header

class <span class="createlink">MailDir</span> (object):
    """
    handles the local delivery to a maildir inbox
    """
    implements (smtp.IMessage)

    def __init__ (self, user):
        userDir= str (user.dest.local)
        # we create a directory for this user
        if not os.path.exists (userDir):
            os.mkdir (userDir)

        inboxDir= os.path.join (userDir, 'Inbox')
        self.mailbox= maildir.MaildirMailbox (inboxDir)
        self.lines= []

    def lineReceived (self, line):
        self.lines.append (line)

    def eomReceived (self):
        # message is complete, store it
        self.lines.append ('')
        messageData= '\n'.join (self.lines)
        return self.mailbox.appendMessage (messageData)

    def connectionLost (self):
        # unexpected loss of connectio, don't save
        del (self.lines)

class <span class="createlink">MailRouter</span> (object):
    implements (smtp.IMessageDelivery)

    def __init__ (self, validDomains):
        self.validDomains= validDomains

    def receivedHeader (self, helo, origin, recipients):
        # client is how the client ident'ed itself
        # clientIP is the ip of the client side's end
        # we could do a reverse DNS lookup and check if it's true
        # also check on RBL's and such
        client, clientIP= helo
        recipient= recipients[0]
    # this must be our CNAME
        myself= 'localhost'
        value= """from %s [%s] by %s with ESMTP for %s; %s""" % (
            client, clientIP, myself, recipient, smtp.rfc822date ()
            )
        return "Received: %s" % Header (value)

    def validateFrom (self, helo, originAddress):
        self.client= helo
        # originAddress is a twisted.mail.smtp.Address
        # if the from is invalid, we should
        # raise smtp.SMTPBadSender
        return originAddress

    def validateTo (self, user):
        """
        routing is the most complicated part of serving an smtp server

        we can be run on a laptop that only wants to send mail
        with possibly many source address

        we can be run on a server that has a local user database;
        it con be a smarthost for otehr machines
        it can be a satellite machine with only a smarthost
        """
        if user.dest.domain in self.validDomains:
            return lambda: Maildir (user)
        else:
            raise smtp.SMTPBadRcpt (user)

class SMTPFactory (protocol.ServerFactory):
    def __init__ (self, validDomains):
        self.validDomains= validDomains

    def buildProtocol (self, addr):
        delivery= <span class="createlink">MailRouter</span> (self.validDomains)
        smtpProtocol= smtp.SMTP (delivery)
        smtpProtocol.factory= self
        return smtpProtocol

if __name__=='__main__':
    import sys

    # normal local server
    domains= sys.argv[1].split (',')
    reactor.listenTCP (2525, SMTPFactory (domains))
    reactor.run ()

Tenemos tres clases. La primera es SMTPFactory, la cual es sólo un factory de protocols. Tiene un método, buildProtocol() que tiene que construir un protocolo y devolverlo.

La segunda que veremos es la MailDir. Ésta implementa la interfaz t.m.s.IMessage, que se usa para la entrega de un mensaje. En este caso es una entrega local a un maildir, aunque luego implementaremos la entrega remota con esta misma interfaz. La clase tiene que implementar tres métodos:

lineReceived() es llamado por cada nueva línea del mail que llega. No hace diferencias entre si es parte del cuerpo o del header. Si tuviéramos que hacer algún procesamiento, como toquetear headers o rechazar el mail por tamaño, lo deberíamos hacer en este nivel.

eomReceived() se llama cuando todo el mail ha sido ya entregado a través de lineReceived(). En este caso escribimos el mail finalmente en un maildir. Fíjense que como t.m.maildir.MaildirMailbox no tiene esta misma interfaz, tenemos que acumular la líneas en una lista y pegarlas todas y dársela de commer a t.m.md.MDMB.append(). Ya me sentaré a verificar si no hay una mejor API para esto.

Finalmente, connectionLost() se llama si la conexión se pirde antes de recibir todo el mail.

Hasta ahora todo sencillo, sólo un factory y la implementación de una entrega local sin muchas luces (no sabe buscar el maildir de un usuario, sino que asume un directorio propio). Gran parte del meollo del mail está en la tercera clase que veremos, MailRouter.

La MailRouter es la que sencargará de definir si el mail es entregable o no. En nuestro caso inicial, no la picadura que me estoy rascando, vamos a permitir relaying libre siempre y sólo localmente a través de la clase MailDir. Antes de ver lo métodos implementados, veamos una conversación típica en SMTP:

>>> 220 mustang.grulicueva.net NO UCE NO UBE NO RELAY PROBES
<<< helo gurrumin
>>> 250 mustang.grulicueva.net Hello 127.0.0.1, nice to meet you
<<< mail from: mdione@except.com.ar
>>> 250 Sender address accepted
<<< rcpt to: mdione@localhost
>>> 250 Recipient address accepted
<<< rcpt to: root@localhost
>>> 250 Recipient address accepted
<<< data
>>> 354 Continue
<<< Subject: bongs
<<< To: mdione@whitehouse.gov
<<< 
<<< 
<<<         This is top secret info. So secret we won't even tell you. Sorry.
<<< .
>>> 250 Delivery in progress
<<< quit
>>> 221 See you later

Tenemos marcados con >>> lo que escupe el server y con <<< lo que manda el cliente. Básicamente el protocolo se basa en tres fases: presentación, declaración de entrefa y cuerpo. En la presentación el cliente se identifica con un nombre. Acá también puede autenticarse con un par (usuario, passwd), arrancar encripción y todo ese tipo de cosas relativas a la conexión en si. Luego dice de quién viene y a quiénes va el mail, y finalmente el mail en sí. Notar que en esta parte van también los headers (estamos viendo solo dos, un To y un Subject. Estas dos últimas estapas se pueden repetir una detrás de la otra las veces que se quiera.

La clase Mailrouter implementa la interfaz t.m.s.IMessageDelivery, se crea una por cada conexión al puerto en el que estamos escuchando, en la que tenemos que implementar otros tres métodos:

El primero es el validateFrom(), el que es llamado por cada mail from. Recibe como parametro una tupla de str. El primer str es el nombre que usó el cliente en el comando helo y el segundo es el IP real. También recibe un t.m.s.Address con el from. Como dicen los comentarios, acá podríamos fijarnos si el nombre y el ip coinciden, o si está en un RBL o cosas parecidas, o si damos relay para el from. En este caso aceptamos todo. Notar que lo devuelto es también un t.m.s.Address. Eventualmente se podría devolver otro, aunque no se me ocurre en qué casos.

El más importante, el validateTo(). ¿Porqué digo el más importante? Porque éste es el que se encarga de hacer la decisión de ruteo, es decir, de decidir qué implementación de t.m.s.IMessage se va a encargar del delivery basado tanto en el from como en el to del mail. El parámetro que nos pasan es un t.m.s.User, el cual contiene ambas direciones en sus atributos orig y dest respectivamente. En este caso sólo nos fijamos que el destino esté entre los dominos al que le hacemos relay local, y si no levantamos una t.m.s.SMTPBadRcpt, la que se traduce en un mensaje de no relay al cleinte. Notar que lo que devuelve no es ni una instancia ni siquiera la clase que va a implementar el delivery, sino una función que devuelve una instancia. En el próximo post voy a estar mostrando porqué, y en los subsiguientes posts voy a complicar este método para lograr las políticas de relaying que tengo planeado.

Finalmente, receiveHeader() es el más sencillo. Sólo tenemos que devolver un string con un header Received apropiado para este delivery. Es éste nos pasan la misma tupla con dos str que en validateFrom(), la dirección de origen y la lista de instancias de t.m.s.User con los recipientes. Ejemplos de estos headers lo podemos encontrar en cualquier mail:

Received:  from [192.168.1.77] (unknown [201.250.21.186])
        (using TLSv1 with cipher DHE-RSA-AES256-SHA (256/256 bits))
        (No client certificate requested)
        by mail.madap.com.ar (Postfix) with ESMTPSA id 978604613
        for <mdione@grulic.org.ar>; Mon, 30 Jun 2008 08:16:56 -0300 (ART)
Received:  by 10.141.105.17 with HTTP; Wed, 2 Jul 2008 13:55:38 -0700 (PDT)
Received:  from localhost ([127.0.0.1] helo=forster.canonical.com)
        by forster.canonical.com with esmtp (Exim 4.69 #1 (Debian))
        id 1KDHgi-0000eo-Fa
        for <mdione@grulic.org.ar>; Mon, 30 Jun 2008 12:36:52 +0100

Bueno, es todo por hoy. Tengan en cuenta que todavía no pretendo que éste sea un tutorial de Twisted, pero si va mostrando cosas que nos vamos a encontrar en muchas de las implementaciones de servicios con este framework.

twismtpy python twisted

Posted Wed 27 Jan 2010 11:55:55 PM CET Tags: twismtpy

Hoy vamos a ver cómo hacer entrega remota de un mail. Como somos un server que recibió un mail que tiene que ser entregado en otro, hay una serie de pasos que tenemos que hacer. Empecemos con una implementación de t.m.s.IMessage que manda a un smarthost:

class Relay (object):
implements (smtp.IMessage)

    def __init__ (self, router, user):
    # select smarthost based on src domain
    self.user= user
    self.smarthost= 'our.smarthost.com'
    self.lines= []
    self.eom= False
    self.router= router

def lineReceived (self, line):
    self.lines.append (line)

def eomReceived (self):
    return self.send ()

def send (self, mxRecord=None):
    sender= smtp.sendmail (self.smarthost, self.user.orig,
    [self.user.dest], '\n'.join (self.lines), '')
    sender.addCallback (self.sendComplete)
    return sender

def sendComplete (self, *data):
    del self.lines
    return data

def connectionLost (self):
    del self.lines

Una cosa que no expliqué en el post anterior es el valor devuelto por eomReceived(). En ese caso era return self.mailbox.appendMessage (messageData); en éste, después de un par de vueltas, es el resultado de smtp.sendmail(). Lo que estamos devolviendo es un Deferred.

Los Deferreds son una parte importante de Twisted. Son básicamente una promesa de que en algún momento va a haber un valor disponible para devolver, pero que mientras le vamos dadndo esto como para que tenga. El truco es luego conectar con esa promesa nuestros callbacks llamando a addCallback(). Esos callbacks van a ser llamados cuando el valor esté disponible. También se pueden agregar errbacks, que son callbacks que son llamados cuando la operación que pedimos tuvo un error (típicamente una excepción).

Eso es exactamente lo que estamos haciendo en send(). t.m.s.sendmail() nos devuelve un Deferred al que le conectamos nuestro sendComplete() y lo devolvemos. sendComplete() simplemente borra las líneas (aparentemente tarde, ya veremos que nos van a hacer falta) y continúa la cadena de callbacks del deferred; cadena que se va armando de esta forma: cuando se llama a callback() en un deferred, éste llama al primer callback. El resultado de este callback es pasado al siguiente, y así.

Esto así como está manda por un smarthost. La diferencia entre mandar todo por un smarthost y mandar directamente es que esta última requiere un paso extra: averiguar a qué máquina debe entregarse el mail. Me refiero al registro MX. Vamos a tener que hacer una consulta de DNS mientras recibimos el mail. Una vez que tengamos ambos vamos a poder hacer la entrega, y finalizar. Veamos cómo nos las arreglamos:

mxCalc= relaymanager.MXCalculator ()

class Relay (object):
implements (smtp.IMessage)

def __init__ (self, router, user):
    # deliver by ourselves
    self.smarthost= None
    resolver= self.getSMTPServer (user)
    resolver.addCallback (self.send).addErrback (self.queue)
    self.lines= []
    self.eom= False
    self.router= router

def getSMTPServer (self, user):
    return mxCalc.getMX (user.dest.domain)

def lineReceived (self, line):
    self.lines.append (line)

def eomReceived (self):
    self.eom= True
    if self.smarthost is None:
        print "WARN: mail received and no smarthost!"
    else:
        print "mail finished; sending..."
        self.send ()
    self.sentSignal= defer.Deferred ()
    return self.sentSignal

def send (self, mxRecord=None):
    if mxRecord is not None:
        # mxRecord is a dns.*Record instance
        # mxRecord.name is a dns.Name instance
        self.smarthost= mxRecord.name.name
    if self.eom:
        sender= smtp.sendmail (self.smarthost, self.user.orig,
            [self.user.dest], '\n'.join (self.lines), config.heloAs)
        sender.addCallback (self.sendComplete).addErrback (self.queue)

def queue (self, error):
    self.router.queue (error=error, user=self.user, mail=self.lines)
    self.sentSignal.callback (True)

def sendComplete (self, *data):
    del self.lines
    print ignore
    self.sentSignal.callback (True)

def connectionLost (self):
    print "WARN: unfinished mail!"
    del self.lines
    self.sentSignal.errback (False)

Acá hay varias cosas. Por un lado tenemos una función que se encarga de pedir el registro MX, la que devuelve un Deferred al que le enganchamos nuestra función de entrega. Al mismo tiempo vamos recibiendo el mail, y cuando termine también intenta hacer la entrega. Ahora, acá el truco es crear un Deferred y devolverlo inmediatamente en eomReceived(). Cuando el mail es enviado finalmente, nuestro callback sendComplete() es llamado, el que a su vez hace un callback de nuestro Deferred. Por último, si tenemos un error de entrega, enconlamos a través de nuestro router el mail para un posterior intento de entrega.

Lo que vimos en este post es el manejo de Deferreds, y cómo se los usa para prometer volver a llamar cuando el resultado está disponible. Hasta ahora es el único momento en el que realmente he necesitado manejarlos. Supongo que ya volveré a verlos cuando empieze el duro camino de implementar filtros.

twismtpy python twisted

Posted Wed 27 Jan 2010 11:55:55 PM CET Tags: twismtpy