IMAP4 y la extensión MULTIAPPEND

El trabajo hecho en "fsync" y LD_PRELOAD nos aumenta el rendimiento de 4 a 22 mensajes por segundo. Es una mejora importante, aunque peligrosa y no apta para dejarla en producción. Pero como las extensiones Thunderbird que pretendíamos usar no funcionaban correctamente, incluso 22 mensajes por segundo es desesperante para moverlos a mano cuando necesitas mover un millón de mensajes. Doy detalles en Migrar Thunderbird de "mbox" a "IMAP" (III): ¡Migración!.

Uno de los grandes fastidios de Thunderbird es que Mozilla ya no lo considera un proyecto prioritario. Es un buen producto, pero languidece y no está adoptando extensiones IMAP4 modernas. Y algunas de ellas son muy importantes, como NOTIFY.

Thunderbird está enviando a Dovecot un mensaje y no hace nada hasta que Dovecot le da el OK. Esto es claramente poco óptimo. Al ocurrir escrituras síncronas en realidad la CPU está aburrida y podemos aumentar la velocidad considerablemente simplemente abriendo varias conexiones IMAP4 en paralelo a diferentes carpetas. Los experimentos en mi portátil, con dos cores, muestran que se puede duplicar el rendimiento trabajando simultaneamente con cuatro buzones a la vez, en paralelo.

Sería perfecto si IMAP4 tuviese una extensión que permitiese indicar que un mensaje determinado no es importante y que no importa si se pierde. Eso no existe :-). Pero sí existe una extensión interesante: MULTIAPPEND.

¿Qué pasa si en vez de enviar un mensaje al IMAP4 y esperar respuesta se enviasen 100 en un único paquete y se esperase una respuesta global a todos ellos?. Una transacción atómica: se añaden todos los mensajes a la vez o no se añade ninguno. En vez de tener grabaciones síncronas para cada mensaje, se tienen grabaciones síncronas para todo el paquete.

Me he escrito el siguiente programa en Python:

 #!/usr/bin/env python3

 # 2014 jcea@jcea.es - http://www.jcea.es/
 # Código liberado como Dominio Público.
 # Haz con él lo que quieras.

 # Requerimos las siguientes extensiones IMAP4:
 # LITERAL+     RFC 2008
 # MULTIAPPEND  RFC 3502

 import sys
 import socket
 import email.parser
 import mailbox
 import time

 t = time.time()

 # https://lxr.mozilla.org/seamonkey/source/mailnews/base/public/nsMsgMessageFlags.h#108
 # http://www.eyrich-net.org/mozilla/X-Mozilla-Status.html?en
 enum_flags = {
                 0x01: '\Seen',
                 0x02: '\Answered',
                 0x04: '\Flagged',
                 0x08: '\Deleted',
                 0x10: None,  # Subject empieza por "RE:"
                 0x1000: '$Forwarded',
             }

 class mboxMessage(mailbox.mboxMessage) :
     def __init__(self, message = None) :
         if message is not None :
             message = message.read()
             message = message.replace(b'\r', b'')  # Normaliza
             p = message.find(b'\n\n')
             if p == -1 :
                 raise RuntimeError('No encontramos el cuerpo')
             cabecera = message[:p+2]
             cuerpo = message[p+2:]
             cabecera = email.parser.BytesParser().parsebytes(cabecera,
                     headersonly=True)
         super().__init__(cabecera)
         self._cuerpo = cuerpo

     def cadena(self) :
         return self.as_bytes()+self._cuerpo

 mbox = mailbox.mbox(sys.argv[1], factory=mboxMessage, create=False)

 mensajes = []
 count = 0
 for msg in mbox :
     count += 1
     ts = msg.get_from().split()
     # El "From" no se cuenta
     if len(ts) == 6 :  # (From) - Wed Oct 06 07:10:06 2004
         day = int(ts[3])
         month = ts[2]
         year = ts[5]
         hms = ts[4]
         tz = 'GMT'  # Esto está mal, pero nos interesa solo el orden relativo
     else :  # (From) - Wed, 10 Sep 2014 21:22:17 +0000
         day = int(ts[2])
         month = ts[3]
         year = ts[4]
         hms = ts[5]
         tz = ts[6]

     ts = "%2d-%s-%s %s +0000" %(day, month, year, hms)
     status = int(msg.get('X-Mozilla-Status'),16)  # Hexadecimal
     status2 = int(msg.get('X-Mozilla-Status2'))
     # A veces hay varias cabeceras repetidas.
     # El comportamiento de Thunderbird en inconsistente en este caso.
     # Nos curamos en salud y conservamos todos los flags, aunque
     # podría ser que Thunderbird no los mostrase.
     flags = msg.get_all('X-Mozilla-Keys', [])
     flags = list(set((item.strip() for items in flags for item in items.split())))
     for v, flag in enum_flags.items() :
         if status & v :
             status -= v
             if flag :
                 flags.append(flag)

     if (tz not in ('GMT', '+0000')) \
             or (status2 != 0) or (status != 0) :
         raise RuntimeError('Cabecera no soportada: %s %s %s' \
                 %(status, status2, tz))

     del msg['X-Mozilla-Status']
     del msg['X-Mozilla-Status2']
     del msg['X-Mozilla-Keys']

     flags = " ".join(flags)
     msg = msg.cadena()
     head = '(%s) "%s" {%d+}\r\n' %(flags, ts, len(msg))
     mensajes.append(head.encode('latin-1')+msg)

 print('SUBIENDO %d MENSAJES' %len(mensajes))
 print('Tiempo de parseo: %f' %(time.time()-t))
 t = time.time()

 s = socket.socket()
 s.connect(("127.0.0.1", 143))
 s.send(b'a login a a\r\n')

 batch_size = 500
 while len(mensajes) :
     head = 'JCEA APPEND "%s"' %sys.argv[2]
     s.send(head.encode('latin-1'))
     msgs, mensajes = mensajes[:batch_size], mensajes[batch_size:]
     for msg in msgs :
         s.send(b' ')
         s.send(msg)
     s.send(b'\r\n')
     respuesta = b''
     while b'JCEA OK' not in respuesta :
         r = s.recv(99999)
         print(r)
         if not len(r) :
             raise RuntimeError('Conexión IMAP4 cerrada')
         respuesta += r
 s.send(b'XXX logout\r\n')
 while True :
     r = s.recv(99999)
     if not len(r) :
         break
     print(r)

 print('Tiempo de grabación: %f' %(time.time()-t))

El código es un poco cochino y no hay comprobación de errores. Considero que ya he perdido demasiado tiempo con este tema.

Repasemos el código poco a poco.

Las primeras líneas nos indican que necesitamos que nuestro servidor IMAP4 soporte las extensiones LITERAL+ y MULTIAPPEND. El programa no lo comprueba, cuenta con ello. Por supuesto, Dovecot soporta ambas sin hacer nada especial.

A continuación (líneas 21-28) definimos los flags que nos interesa conservar y alguno que debemos ignorar. Si en los mensajes aparecen flags desconocidos, hay que adaptar el código. En particular no gestiono los flags de watch thread, Ignore Thread y similares, porque en IMAP4 no se mapean sino que se almacenan directamente en los ficheros MSF de Thunderbird así que me resigno a perderlos durante la migración. Esto es algo a mejorar pero, como he dicho, ya he invertido demasiado tiempo en este asunto.

Seguidamente (líneas 30-46) defino una nueva clave mboxMessage. Esto lo hago porque tengo muchos casos de correo mal formado en mis buzones, y Python no lleva bien eso de intentar interpretar mensajes "ilegales". Mi variante de mboxMessage divide los mensajes en dos partes: cabeceras y cuerpo y solo procesa la cabecera. El parámetro headersonly=True de la rutina de parseo funciona bien al leer el mensaje, pero revienta al hacer un as_string(). Mi clase hace el trabajo mínimo para lo que necesito y es tolerante a, por ejemplo, caracteres UNICODE mal formados. Por lo que se ve hay mucha basura de clientes de correo por ahí.

En la línea 48 creamos una instancia de la clase mbox, con nuestra clase mboxMessage. Recordemos que el formato nativo de Thunderbird es, precisamente, mbox.

En las siguientes líneas vamos a cargar una carpeta mbox entera en memoria. ¿Por qué?. Fundamentalmente porque quiero procesar la carpeta entera y asegurarme de que no haya cabeceras extrañas, etc., antes de empezar a pasar mensajes al servidor. Mi carpeta de correo más grande mide 2Gbytes y tengo 8 gigabytes de RAM, así que no es problema.

Las líneas 55-67 intentan determinar la fecha de entrada del mensaje en el sistema. Hay varios formatos diferentes, posiblemente debido a las diferentes versiones de Thunderbird que han pasado por esos buzones. Llevo usando este software desde la época de Netscape Navigator. No me sorprendería que haya lectores de este artículo más jóvenes que algunos de mis emails :-).

En todo este código hay un lio tremendo con zonas horarias, etc. Como yo estoy más interesado en el orden relativo de los mensajes que en la hora exacta en la que entraron, hago algunos apaños para simplificarme la vida.

En las líneas 70-82 se intenta determinar los flags IMAP4 que tiene cada mensaje. Es de destacar que la cabecera X-Mozilla-Status es hexadecimal y que no proceso X-Mozilla-Status2 de ninguna manera. De hecho ahí hay flags útiles que convendría mirar. Pero ahora mismo mi programa se negará a funcionar si encuentra cosas que no entiende. Programación defensiva. Como debe ser.

La cabecera X-Mozilla-Keys contiene los tags de los mensajes, junto a alguna bandera más como "esto es correo basura". Aquí tuve algunos problemas. Muchos mensajes tienen dos cabeceras X-Mozilla-Keys y Thunderbird las trata de forma muy inconsistente. En particular si la primera cabecera tiene valor ignora la segunda. Y si no usa la segunda. Esto hace que, por ejemplo, algunos mensajes tengan tags que no se pueden quitar. Esto parece un bug claro de Thunderbird. Yo me he curado en saludo conservando todos los tags de todas las cabeceras. Es posible que así se vean más tags tras la migración, pero prefiero eso a perder tags.

Las líneas 84-86 se aseguran de que la carpeta entera contiene mensajes que sabemos cómo procesar. Si vemos cualquier cosa inesperada abortamos la ejecución y mostramos qué no nos ha gusta exactamente. Tocará o modificar ese mensaje o actualizar el programa.

Las líneas 89-91 eliminan las cabeceras exclusivas de Thunderbird porque en IMAP4 esa información se guarda en los flags IMAP4 y no en cabeceras.

En 93-96 construimos una lista con todos los mensajes del buzón y los metadatos que vamos a necesitar para MULTIAPPEND.

Una vez que hemos procesado la carpeta de correo entero nos conectamos al servidor IMAP4, nos autenticamos y empezamos a mandar los mensajes en paquetes de 500 en 500, usando la extensión MULTIAPPEND. Obsérvese que el control de errores es inexistente y que estamos usando la extensión LITERAL+ para no tener que leer lo que nos manda el servidor.

A pesar de que no hay control de errores nos aseguramos de que el paquete de 500 mensajes se ha grabado correctamente. Si no es así, el programa no progresa y vemos en pantalla que se ha quedado bloqueado y no sabe qué hacer. Esto son las líneas 116-121.

Es decir, si hay problemas el programa se bloquea. Pero si termina correctamente es que los mensajes se han grabado correctamente. De verdad de la buena. Esa es la clave del JCEA OK. Eso lo manda el servidor solo si se ha grabado el paquete MULTIAPPEND correctamente.

Tras esto el programa se despide y espera a que el servidor le desconecte. Esto no es necesario, pero el código original hacía más cosas.

¿Qué tal va este sistema en mi portátil de 2009?

Leer y procesar una carpeta de correo de 1021 mensajes, algunos de ellos con adjuntos de tamaño considerable, me lleva 5.12 segundos. Enviarlos a Dovecot son 9 segundos con grupos de 100 mensajes y 4.2 segundos en grupos de 500 mensajes.

Probando con varias instancias en paralelo en carpetas de correo separadas llego a un pico de unos 260 mensajes por segundo. Con esto puedo migrar mi millón de mensajes en poco más de una hora. No voy a perder el tiempo en mejorarlo más :).

Algunos detalles a tener en cuenta:

  • La conversión mUTF-7 debe hacerse de forma externa. Por ejemplo, usando doveadm mailbox mutf7.
  • Las carpetas de destino deben existir.
  • Ahora mismo agrupo los mensajes de 500 en 500, independientemente de su tamaño. Si hay mensajes con adjuntos podría ser un problema, aunque yo no lo he notado y tengo adjuntos de varios megabytes de tamaño. Tal vez sería más inteligente agrupar mensajes hasta llegar a un tamaño prefijado y no por número. Queda como investigación futura.
  • Durante la migración el Thunderbird debería estar cerrado para evitar que se modifiquen carpetas de correo de forma inadvertida. Esos cambios se perderían.