Limpieza selectiva de buzones IMAP4 (II)

Hace seis meses publiqué un script en Python 3 para hacer limpieza de buzones IMAP4 en el servidor Dovecot que tengo instalado en mi propio portátil. Lo detallo en el artículo Limpieza selectiva de buzones IMAP4.

A continuación incluyo la versión mejorada, con la experiencia de estos seis meses:

 #!/usr/bin/env python3

 # (c) 2015-2016 Jesús Cea Avión - jcea@jcea.es
 # This code is licensed under AGPLv3.

 import sys, os, argparse
 import imaplib
 import time, locale
 from collections import defaultdict
 from itertools import chain

 from imapclient import imap_utf7

 # Para el tiempo
 locale.setlocale(locale.LC_ALL, 'en_US.utf8')

 imaplib._MAXLINE = 1000000000

 parser = argparse.ArgumentParser(description='''Procesa un buzón IMAP4 y
 conserva los hilos donde hemos escrito nosotros o que contienen mensajes
 de menos de un mes. El resto de mensajes son enviados a la salida
 estándar y borrados del servidor IMAP4.

 Dado que es una acción destructiva, por defecto se realiza el análisis
 y se muestra cuál sería el resultado si se aplicase realmente, pero no
 la acción no se realiza a menos que pasemos el parámetro "--do".''')
 parser.add_argument('--do', action='store_true', help='Realiza la acción')
 parser.add_argument('--all', action='store_true',
         help='No conservar hilos con mensajes propios o recientes')
 parser.add_argument('buzon', metavar='buzón', help='Buzón a procesar')
 args = parser.parse_args()
 do = args.do
 archive_all = args.all
 buzon = '"%s"' %imap_utf7.encode(args.buzon).decode('latin-1')

 imap = imaplib.IMAP4('127.0.0.1')
 imap.login('X', 'X')
 reply = imap.select(buzon)
 if reply[0] != 'OK' :
     raise RuntimeError(reply[0])

 threads = defaultdict(list)
 if archive_all :
     reply = imap.search(None, 'not deleted')
     if reply[0] != 'OK' :
         raise RuntimeError(reply[0])
     reply = reply[1][0].decode('latin-1')
     ids = list(map(int, reply.split()))
     threads[0] = ids
 else :
     reply = imap.thread('REFERENCES', 'utf-8', 'not deleted')
     if reply[0] != 'OK' :
         raise RuntimeError(reply[0])
     reply = reply[1][0].decode('latin-1')
     num_thread = 0
     ids = {}
     indent = 0
     token = ''
     for i in reply :
         if i == '(' :
                 indent += 1
                 if indent == 1 :
                     num_thread += 1
         elif i == ')' :
             indent -= 1
             if token :
                 token = int(token)
                 ids[token] = num_thread
                 threads[num_thread].append(token)
                 token = ''
         elif i == ' ' :
             token = int(token)
             ids[token] = num_thread
             threads[num_thread].append(token)
             token = ''
         else :
             token += i

 num_hilos_ignorados = 0
 num_mensajes_ignorados = 0
 if not archive_all :
     ventana = time.localtime(time.time()-86400*30)
     reply = imap.search('utf-8', 'OR', 'FROM', 'jcea@', 'SINCE',
             time.strftime('%d-%b-%Y', ventana))
     if reply[0] != 'OK' :
         raise RuntimeError(reply[0])
     mensajes_a_ignorar = set(reply[1][0].decode('latin-1').split())
     for msg in mensajes_a_ignorar :
         msg = int(msg)
         thread = ids[msg]
         if thread in threads :
             num_hilos_ignorados += 1
             num_mensajes_ignorados += len(threads[thread])
             del threads[thread]
 else :
     mensajes_a_ignorar = set()

 print("Mensajes en total: %d - Hilos a conservar: %d - Mensajes a conservar: %d"
         %(len(ids), num_hilos_ignorados, num_mensajes_ignorados),
         file=sys.stderr)

 if not do :
     print("Para realizar la operación, añade el parámetro '--do'",
             file=sys.stderr)
     sys.exit()

 if sys.stdout.isatty() :
     raise RuntimeError('Debes redirigir STDOUT')

 mensajes_a_volcar = sorted(chain.from_iterable(threads.values()))

 devnull = os.stat('/dev/null').st_rdev == \
              os.fstat(sys.stdout.fileno()).st_rdev

 if devnull :
     print("Como redirigimos a '/dev/null', no hacemos el volcado",
             file=sys.stderr)
 else :
     for i in mensajes_a_volcar :
         print('Volcando mensaje', i, end='\r', file=sys.stderr)
         #reply = imap.fetch(str(i), '(INTERNALDATE RFC822)')
         reply = imap.fetch(str(i), '(INTERNALDATE BODY.PEEK[])')
         if reply[0] != 'OK' :
             raise RuntimeError(reply[0])
         date = reply[1][0][0].decode('latin-1').split()
         t = time.strptime(date[2]+' '+date[3], '"%d-%b-%Y %H:%M:%S')
         separador = time.strftime("From - %a %b %d %H:%M:%S %Y\r\n", t)
         sys.stdout.buffer.write(separador.encode('latin-1'))
         msg = reply[1][0][1].replace(b'\nFrom ', b'\n>From ')
         sys.stdout.buffer.write(msg)

     sys.stdout.buffer.flush()
     fd = sys.stdout.buffer.fileno()
     try :
         os.fsync(fd)
     except OSError :  # En Linux un fsync sobre "/dev/null" falla
         pass

     print(' '*30, end='\r', file=sys.stderr)

 if len(mensajes_a_volcar) :
     runs = []
     runstart = runend = mensajes_a_volcar[0]
     for i in mensajes_a_volcar[1:] :
         if i == runend + 1 :
             runend += 1
             continue
         runs.append((runstart, runend))
         runstart = runend = i
     else :
         runs.append((runstart, runend))

     for runstart, runend in runs :
         if runstart == runend :
             msg_set = str(runstart)
         else :
             msg_set = "%d:%d" %(runstart, runend)


         print('BORRANDO MENSAJE(S) %s' %msg_set + ' '*30, \
                 end='\r', file=sys.stderr)

         reply = imap.store(msg_set, '+FLAGS', '\\Deleted')
         if reply[0] != 'OK' :
             raise RuntimeError(reply[0])
     imap.close()  # EXPUNGE implícito
     print(' '*60, end='\r', file=sys.stderr)

 imap.logout()

Esta versión del programa mide un 30% más y empieza a requerir una refactorización para simplificarlo y empezar a meter pruebas. Hay una fina línea entre un script guarro de usar y tirar y un proyecto mantenido con seriedad. Este empieza a estar ya en el límite.

No voy a repetir la explicación de cómo funciona, documentado ya en mi artículo previo. Me centraré en lo que ha cambiado:

  • En la versión anterior del programa se calculaba la estructura de hilos de la carpeta, aunque hubiésemos especificado el parámetro --all al invocar el programa. Ahora no hacemos trabajo innecesario. De ello se encargan las líneas 43-49 y la línea 96.

  • Las líneas 112-117 evitan leer los mensajes si estamos enviando la salida a /dev/null. Esto ahorra bastante tiempo y tráfico de disco. Para comprobar si la salida estándar del programa está redirigida a /dev/null, comparamos si ambos ficheros se corresponden al mismo dispositivo. La existencia del campo st_rdev depende de la plataforma, pero funciona en las dos plataformas que me interesan ahora mismo: Linux y Solaris.

  • La versión previa del programa borraba uno a uno los mensajes volcados. Esta operación suele ser bastante rápida, pero a veces se ejecutaba de forma muy lenta (un par de mensajes por segundo), sobre todo si había visitado la carpeta implicada recientemente con Mozilla Thunderbird (mi cliente de correo). Probablemente fuese debido a las notificaciones de borrado que envía a los clientes conectados. En cualquier caso, de vez en cuando, el borrado era muy lento.

    La versión actual del código agrupa los mensajes a borrar en rangos y borra cada rango con un solo comando. Por ejemplo, si tengo que borrar los mensajes 1, 2, 3, 4, 6, 7, 8, 10, se borrarán tres rangos: 1:4, 6:8 y 10. La diferencia de rendimiento es apabullante, de órdenes de magnitud.

    El algoritmo en sí para identificar los grupos es sencillo, pero dediqué algo de tiempo para hacerlo claro de entender. Seguramente habrá formas más inteligentes, pero creo que las líneas 142-151 son sencillas de seguir.

Detalles finales

En Limpieza selectiva de buzones IMAP4 documento algunas cosas a tener en cuenta con el código original. Las novedades son:

  • En el artículo anterior hablo de pipelining, pero lo cierto es que es innecesario. La gestión de grupos durante el borrado lo hace innecesario en ese caso. En cuanto a la descarga de correo en sí, una posibilidad sería pedir también los mensajes por grupos. Si el API IMAP4 de Python devolviese iterables, el asunto sería sencillo y eficiente. Como no es el caso, las cosas son más complicadas pero todavía realizables con un coste razonable. Podemos hacer una petición por rangos de diez en diez mensajes, por ejemplo.

    En todo caso no es algo que yo necesite porque mi servidor IMAP4 se ejecuta en mi propio portátil. Si el acceso fuese remoto, la cosa sería distinta.

  • Algunos lectores me han hecho preguntas sobre el login "cableado" o que no use cifrado. Mi respuesta es evidente: este código se ejecuta en mi portátil y se conecta a un servidor Dovecot en mi portátil. No necesito ni login seguro ni cifrado. Si eso cambia en el futuro, lo implementaré.

  • Otros lectores señalan que requiero la extensión thread=references (RFC 5256) y que la empleo sin ni siquiera comprobar si el servidor IMAP4 la soporta. De nuevo, son mis necesidades y mi servidor Dovecot lo soporta. Este código es de uso propio, no pretendo hacer una herramienta utilizable por cualquiera sin adaptaciones personales.

  • Mi duda fundamental con el código original era la presencia de EXPUNGE, implícitos o explícitos. Los EXPUNGE hacen que la numeración de los mensajes cambie. Si ocurren EXPUNGE y yo los ignoro, el resultado es catastrófico.

    Esto, por supuesto, me causaba incomodidad. Así que me leí los RFC 3501 y RFC 5256 y me fijé en los siguientes párrafos:

    Sobre THREAD:

    Untagged EXPUNGE responses are not permitted while the server is responding to a THREAD command, but are permitted during a UID THREAD command.

    Sobre FETCH y STORE:

    An EXPUNGE response MUST NOT be sent when no command is in progress, nor while responding to a FETCH, STORE, or SEARCH command.

    En pocas palabras, el servidor IMAP4 no va a enviarme EXPUNGE con los comandos que estoy usando. No es preciso que contemple esa posibilidad.

    La solución definitiva es usar las variantes UID de IMAP4, cuya numeración es inmutable. Pero esto resulta innecesario con el estado actual del código.