Limpieza selectiva de buzones IMAP4

En el artículo ¿Qué buzones IMAP4 me están consumiendo disco? explico cómo localizar fácilmente las carpetas IMAP4 que nos interesa limpiar. Lo más sencillo sería, simplemente, borrar el correo que no nos interesa, archivarlo, etc., pero la definición de "que no nos interesa" puede no estar muy bien definida.

En este artículo presento un script Python que realiza una limpieza de una carpeta IMAP4 remota de la siguiente manera:

  1. Se seleccionan los mensajes a borrar:

    1. Con la opción --all, el script Python se conecta al servidor IMAP4 y selecciona todos los mensajes de la carpeta IMAP4 indicada.
    2. Sin la opción --all, es decir, con el modo por defecto, el script Python hace lo siguiente:
      1. Se conecta al servidor IMAP4 y solicita el listado de todos los mensajes cuyo remitente soy yo o que hayan entrado hace menos de un mes.
      2. Solicita la descripción de hilos de esa carpeta IMAP4. O sea, intenta determinar el árbol de mensajes de cada conversación [1], qué mensajes son respuestas de otro, qué mensajes inician hilos nuevos, etc.
      3. Combinando la información recibida en los puntos 1 y 2, el script Python conserva los hilos completos (todos los mensajes) en los que hemos escrito nosotros o que tienen algún mensaje de menos de un mes de antigüedad.
      4. Se selecciona el resto de mensajes.
  2. Si estamos ejecutando la opción dry (opción por defecto), el script Python nos mostrará estadísticas por pantalla: número de mensajes en total, cuántos hilos y cuántos mensajes se van a conservar si se realizase la acción. También nos mostrará un texto indicando el siguiente paso.

    La opción por defecto es analizar la carpeta IMAP4, pero no borrar nada, ya que se trata de un proceso destructivo y no queremos perder correo por equivocación.

  3. Si hemos ejecutado el script Python con la opción --do, entonces el programa leerá y enviará a la salida estándar el contenido de los mensajes que ha seleccionado para ser borrados. Esto permite conservar dichos mensaje en un fichero mbox, a modo de archivo offline. Naturalmente también se puede enviar a /dev/null.

    Seguidamente procederá a borrar esos mensajes en el servidor IMAP4.

El código en todo su esplendor:

 #!/usr/bin/env python3

 # (c) 2015 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])

 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 = {}
 threads = defaultdict(list)
 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
 ventana = time.localtime(time.time()-86400*30)
 if not archive_all :
     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()))
 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)
 for i in mensajes_a_volcar :
     print("BORRANDO MENSAJE", i, end='\r', file=sys.stderr)
     reply = imap.store(str(i), '+FLAGS', '\\Deleted')
     if reply[0] != 'OK' :
         raise RuntimeError(reply[0])
 imap.close()  # EXPUNGE implícito
 imap.logout()

Este programa Python 3 (línea 1) tiene una dependencia de la biblioteca imapclient (línea 12). La utilizamos para la conversión del nombre de la carpeta IMAP4 de UTF-8 al formato mUTF-7 empleado en IMAP4.

En la línea 15 cambiamos el locale al formato estadounidense para poder realizar la búsqueda correctamente. En la línea 17 hacemos un Monkey patch de la biblioteca IMAP4 de Python para tolerar respuestas muy largas. Esto nos vendrá bien a la hora de solicitar el listado de hilos de carpetas IMAP4 con decenas de miles de mensajes.

Las líneas 19-33 emplean la magnífica biblioteca estándar argparse para analizar los parámetros con los que invocamos al programa. Gestiona automáticamente errores, la ayuda, etc. La línea 34 hace la conversión de UTF-8 a mUTF-7, mencionada más arriba.

En las líneas 36-40 conectamos con el servidor IMAP4 y entramos en la carpeta que deseamos procesar. En la línea 42 se pide el árbol de hilos de conversaciones [1] en esa carpeta IMAP4. En el código 42-66 se analiza la respuesta para generar una descripción en memoria de esos árboles de hilos.

Si no estamos usando la opción --all, solicitamos al servidor IMAP4 el listado de mensajes enviados por mí o que son recientes (línea 75). Por cada mensaje interesante localizamos el resto de mensajes del hilo (líneas 79-86). Esos son los mensajes a conservar.

Las líneas 90-92 imprimen estadísticas por pantalla. Si no hemos pasado el parámetro --do, la opción por defecto es no hacer nada más, pero se indica al usuario cuál sería el siguiente paso (línea 95).

Lo siguiente es leer los mensajes que vamos a borrar y enviarlos a la salida estándar para archivarlos en un fichero mbox o redirigirlos a /dev/null. Nos aseguramos de que no lo estamos enviando a la pantalla en la línea 99, que normalmente no es lo que queremos, y evitamos enviar 200 megabytes al terminal :-).

A continuación se itera sobre los mensajes que queremos borrar (línea 102), los leemos del servidor IMAP4 (línea 106), generamos un formato válido para un fichero mbox (líneas 109-113) y lo mandamos por la salida estándar EVITANDO REALIZAR UN CAMBIO DE "ENCODING" (línea 114).

Una vez volcados los mensajes, queremos asegurarnos de que están efectivamente en el disco antes de empezar a borrarlos del servidor IMAP4 (líneas 116-121). Es interesante señalar que la llamada de sistema fsync() da un error en Linux cuando el destino es /dev/null. En sistemas serios como Solaris ; -), sencillamente se ignora, como debe ser. El comportamiento de Linux me parece una inconsistencia y un bug, la verdad. Me obliga a considerar casos especiales.

A continuación marcamos los mensajes como borrados (línea 126). La eliminación en sí de esos mensajes se realizará en la línea 129 [1].

Finalmente nos desconectamos amablemente en la línea 130.

¿Cuánto tarda todo esto?. En mi portátil, con mi servidor IMAP4 Dovecot en el propio ordenador y una carpeta IMAP4 de 23517 mensajes, los tiempos son los siguientes:

  • Revisión de hilos en frío: 3 minutos y 24 segundos. Una vez que ya hemos hecho esta operación, el resultado está en caché y obtenerlo de nuevo lleva un tiempo inapreciable.
  • Leer los 23517 mensajes del servidor IMAP4 y volcarlos por la salida estándar:
    • 1 minuto y 17 segundos si los mensajes estaban marcados como "no leídos", ahora están "leídos" y tenemos un cliente de correo conectado a la carpeta IMAP4 al mismo tiempo.
    • 31 segundos si los mensajes están ya marcados como leídos o bien estamos usando PEEK para leer los mensajes sin marcarlos. Esa es la diferencia entre la línea 106 y la línea 105 comentada, el rendimiento.
  • Borrar los 23517 mensajes: 8 segundos. Buena parte de ello es el print(), pero optimizarlo no es prioritario.

El procedimiento de uso sería más o menos el siguiente:

  1. Seguimos las instrucciones en el artículo ¿Qué buzones IMAP4 me están consumiendo disco? para determinar qué carpetas IMAP4 merecen nuestra atención.
  2. Ejecutamos este script Python sobre esas carpetas, sin más paŕametros. Nos indicará cuántos mensajes e hilos se van a mantener y cuántos mensajes se van a borrar.
  3. Normalmente repetiremos la ejecución del script Python indicando el parámetro --do. Podemos redirigir la salida a un fichero mbox a crear o expandir, o bien redirigir los mensajes a /dev/null. En mi caso el nombre del fichero mbox contiene la fecha del volcado. Luego lo comprimo con xz y lo envío a mi máquina de backup para su almacenaje indefinido.
  4. El procedimiento anterior mantendría en la carpeta IMAP4 los hilos en los que he participado y los hilos con actividad reciente. Si no quiero mantener NADA online, puedo ejecutar el script Python con el parámetro --all. Esto dejaría la carpeta IMAP4 vacía.

Soy consciente de que cuando vamos a borrar toda la carpeta IMAP4, usando el parámetro --all, estamos haciendo mucho más trabajo del necesario. Para empezar, no haría falta analizar los hilos. No me importa, fue una funcionalidad añadida con posterioridad que apenas utilizo.

[1] (1, 2, 3)

El protocolo IMAP4 diferencia entre la operación de marcar un mensaje como borrado, del borrado físico en sí. Cuando se marca un mensaje como borrado se le pone la etiqueta \Deleted, pero el mensaje sigue ahí. Ese es el motivo por el que en la línea 42 excluímos explícitamente los mensajes borrados.

Normalmente el borrado físico de los mensajes marcados con la etiqueta \Deleted se realiza enviando un comando EXPUNGE al servidor IMAP4. El comando CLOSE de la línea 129 realiza un EXPUNGE implícito.

Este EXPUNGE implícito implica que no es necesario hacer un EXPUNGE explícito para recuperar el espacio en disco que hemos liberado, aunque en el caso de Dovecot será necesario hacer algo tipo doveadm purge.

Detalles IMPORTANTES a tener en cuenta

  • Publico este código bajo la licencia Affero General Public License v3 con plena consciencia de sus consecuencias.

    Si lo modificas, sé fiel a su espíritu.

  • El algoritmo de identificación de hilos es crítico. Un servidor IMAP4 que se precie debería seguir al pie de la letra el algoritmo descrito en el RFC 5256, sin atajos.

  • No uso pipelining en la conexión. Es decir, espero a recibir la respuesta de un comando antes de mandar el siguiente. Esto hace que la latencia de la conexión sea crítica para el rendimiento. Personalmente no me resulta un problema porque mi servidor IMAP4 está en mi mismo portátil. La latencia en ese caso es despreciable comparada con el propio procesamiento en sí.

    Si tenemos el servidor IMAP4 lejos, la latencia nos matará. Una evolución futura de este código debería utilizar pipelining tanto para la descarga de mensajes como para su borrado. hint, hint :-).

  • No hay ningún problema con cambios concurrentes en la carpeta IMAP4, incluyendo la entrada de mensajes nuevos.

    Salvo una excepción: los EXPUNGE, implícitos o explícitos.

    Si algún otro proceso realiza un EXPUNGE implícito o explícito mientras mi programa está trabajando, este se hará un lío, volcará y borrará los mensajes que no son.

    Esto es debido a que los EXPUNGE IMAP4 cambian la numeración de los mensajes, pero mi programa no lo tiene en cuenta.

    La solución para esto es utilizar las variantes UID de los comandos IMAP4, cuya numeración permanece inmutable.

    Otra mejora futura, hint, hint :-).

  • El programa podría tener en cuenta cosas como las etiquetas de los mensajes, el flag \Flagged, etc. Personalmente lo que más me interesaría a mí sería la etiqueta WATCHED de Mozilla Thunderbird, pero, lamentablemente, esa etiqueta se almacena de forma local en Thunderbird, no en el servidor IMAP4. Si alguien se anima con ello... hint, hint :-).

Actualización 20160304: Podéis ver una actualización de este artículo Limpieza selectiva de buzones IMAP4 (II).