Elimina los mensajes duplicados en tu IMAP4 (III)

La solución propuesta en Elimina los mensajes duplicados en tu IMAP4 (II) funciona perfectamente y es rápida si nuestro servidor IMAP4 mantiene un índice actualizado de las fechas de entrada de cada mensaje en cada buzón. En Dovecot, mi servidor IMAP4, los índices son automáticos: Si se busca por un campo determinado, se creará un índice apropiado de forma automática. Ese índice se gestionará y actualizará automáticamente. Si transcurre un tiempo sin utilizarse, el índice se destruye de forma transparente.

Hace un año tenía varios scripts IMAP4 que utilizaban esos índices de forma rutinaria. Utilizar los índices varias veces por semana los mantenía vivos. Perfecto.

En este tiempo he ido actualizando mis scripts para utilizar la sincronización nativa IMAP4 descrita en RFC 5162 (actualizada en RFC 7162). Al no usar los índices, Dovecot los ha ido eliminando.

¿Ya no son necesarios?.

Tras meses, he vuelto a necesitar hacer limpieza de mensajes duplicados. Sin índices, he tenido que cancelar el proceso antes de terminar, cuando llevaba prácticamente diez horas de trabajo generando índices para ser utilizados una sola vez y ser destruídos de nuevo dentro de unas semanas.

Así que paré el script Python de limpieza y preparé una nueva versión que tenía en mente hace mucho:

imap_duplicados-20180827.py (Código fuente)

#!/usr/bin/env python3

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

import sys, os, argparse
import time, locale
import datetime

import imapclient

imap = imapclient.IMAPClient('127.0.0.1', use_uid=True, timeout=600, ssl=False)
imap.login('X', 'X')

cutpoint = datetime.datetime.now()-datetime.timedelta(days=7)

#busqueda = 'BODY.PEEK[HEADER.FIELDS (MESSSAGE-ID,DATE)]'
busqueda = 'BODY.PEEK[HEADER]'
b_busqueda = bytes(busqueda.replace('.PEEK', ''), 'utf-8')
folders = imap.list_folders()
for num, (_, _, folder) in enumerate(folders, 1):
    print(f'Procesando la carpeta "{folder}" ({num}/{len(folders)}): ', end='')
    sys.stdout.flush()

    assert imap.use_uid == True  # Nos aseguramos de que el atributo existe
    imap.use_uid = False

    # Tenemos que quedarnos con el "EXISTS", porque si no hay mensajes
    # en la carpeta y no estamos usando UID, no podemos seleccionar "*".
    last = imap.select_folder(folder)[b'EXISTS']
    msg = None
    total_mensajes = 0
    if last:
        last = imap.fetch(last, 'INTERNALDATE')
        msg = last.popitem()
        total_mensajes = msg[0]
    if msg and (msg[1][b'INTERNALDATE'] > cutpoint):
        maximo = total_mensajes
        minimo = 1
        while maximo >= minimo:
            medio = (maximo + minimo) // 2
            msg = imap.fetch(medio, 'INTERNALDATE')
            msg = msg.popitem()
            if msg[1][b'INTERNALDATE'] > cutpoint :
                maximo = medio - 1
            else:
                minimo = medio + 1

        seq = imap.fetch(f'{minimo}:*', ['UID'])
        seq = [i[b'UID'] for i in seq.values()]
    else:
        seq = []

    assert imap.use_uid == False  # Nos aseguramos de que el atributo existe
    imap.use_uid = True

    to_delete = []
    num_msgs = 0
    if seq:
        start = min(seq)
        reply = imap.fetch(f'{start}:*', busqueda)
        already_seen = set()
        for uid, values in reply.items():
            num_msgs += 1
            headers = values[b_busqueda]
            if headers in already_seen:
                to_delete.append(uid)
            else:
                already_seen.add(headers)

    print(total_mensajes, num_msgs, to_delete)
    if to_delete:
        imap.add_flags(to_delete, r'\DELETED', silent=True)
    imap.close_folder()

El cambio algorítmico es el siguiente: en vez de filtrar los mensajes por fecha de entrada, para lo que se necesita tener un índice, asumo que los mensajes de un buzón tienen un orden de entrada creciente (es decir, están ordenados por momento de entrada en el buzón) y uso una búsqueda binaria para localizar el mensaje a partir del cual hay que buscar duplicados. Si nos interesa revisar la última semana, por ejemplo, identificamos el primer mensaje en ese período y buscamos duplicados a partir de ese punto.

El código es muy similar al mostrado en Elimina los mensajes duplicados en tu IMAP4 (II). Voy a describir solo las diferencias.

La chicha está en las líneas 21-51. Todo el juego con imap.use_uid se debe a que en algunos comandos nos interesa utilizar la numeración IMAP4 clásica y en otros comandos nos interesa usar la numeración UID. La librería Python que utilizo permite usar ambas numeraciones, pero la decisión se realiza conexión a conexión. Yo podría emplear dos conexiones, una con UID y otra con numeración clásica, pero me ha parecido más simple manipular un atributo de la instancia IMAP4 directamente. Sería interesante que una versión futura de la librería permitiese elegir entre un modo u otro comando a comando.

En la línea 30 vemos cuántos mensajes tenemos en la carpeta que estamos procesando en este momento. Si la carpeta tiene algún mensaje, obtiene la fecha de entrada del último mensaje (líneas 33-36). Las líneas 37-39 comprueban si el último mensaje está dentro del punto de corte a considerar (una semana). Si no es así, no hay nada que hacer en esa carpeta.

Si hay mensajes a comprobar, buscamos el primero de ellos mediante bipartición (líneas 40-47). Esta es la innovación fundamental de esta versión del código.

Una vez que tenemos el primer mensaje que nos interesa, obtenemos los UID de ese mensaje y todos los siguientes en las líneas 49-50.

El resto del código es básicamente el mismo que la versión anterior, descrita en Elimina los mensajes duplicados en tu IMAP4 (II).

Limpiar mensajes duplicados en mis 531 carpetas IMAP4, con este script Python y Dovecot frío, necesita algo menos de 2 minutos y medio. Teniendo en cuenta que el script anterior llevaba diez horas trabajando y no había terminado aún, la mejora es apreciable. Con Dovecot y las cachés de disco del sistema operativo calientes, el proceso se completa en poco más de diez segundos. Esto no es importante porque en condiciones reales la caché estará siempre fría, quédate con el tiempo de dos minutos y medio para 531 carpetas y millones de mensajes de correo electrónico.