Elimina los mensajes duplicados en tu IMAP4 (II)

En Elimina los mensajes duplicados en tu IMAP4 presento un pequeño programa Python que busca y elimina los correos electrónicos duplicados en nuestros buzones IMAP4. Os remito a dicho artículo para entender por qué necesito todo esto.

Para ello, el programa analiza el contenido de todas las carpetas IMAP4, extrae las cabeceras de todos los mensajes y los compara buscando duplicados.

Este proceso es simple, pero lento en tiempo y costoso para el servidor IMAP4, ya que debe analizar todos y cada uno de los mensajes del sistema. En configuraciones de correo como la mía, con cientos de buzones, millones de mensajes y docenas de gigabytes de contenido, estos defectos se notan.

En esta ocasión presento un segundo programa Python para realizar la misma tarea de manera diferente:

imap_duplicados-20170424.py (Código fuente)

#!/usr/bin/env python3

# (c) 2017 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=60)
imap.login('X', 'X')
#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()
    imap.select_folder(folder)

    # XXX: El search es lento, parece que busca en todos los mensajes
    # del buzón. Hacemos un primer filtro mirando la fecha del último
    # mensaje del buzón, aunque podría no ser el más reciente si andamos
    # copiando/moviendo/editando mensajes por ahí.
    # https://stackoverflow.com/questions/19237407/i-want-to-get-last-message-uid-from-imap-inbox

    cutpoint = datetime.datetime.now()-datetime.timedelta(days=7)
    # "*" es el último mensaje.
    last = imap.fetch('*', 'INTERNALDATE')
    if last and (last.popitem()[1][b'INTERNALDATE'] > cutpoint):

        # XXX: Deberíamos poder usar el "return (min)" de RFC4731, pero
        # esta librería no lo soporta. De momento hago la petición y elijo
        # el valor mínimo.
        seq = imap.search(['since', cutpoint])
    else:
        seq = []

    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(num_msgs, to_delete)
    imap.add_flags(to_delete, r'\DELETED', silent=True)
    imap.close_folder()

El código tiene muchas similitudes con la versión anterior. La diferencia fundamental es que en vez de obtener las cabeceras de todos los mensajes de cada carpeta IMAP4, analiza solo los mensajes recibidos en la última semana. Esto hace que, en teoría, el coste sea proporcional al número de mensajes recibidos en una semana, no al volumen total de mensajes en el sistema [1].

Las líneas 18-39 procesan cada carpeta IMAP4 de la manera siguiente: para cada una de ellas, obtiene la fecha de entrada del último mensaje presente en ella. Si el mensaje es antiguo (más de una semana), ignora la carpeta por completo. Es decir, las carpetas con correo poco frecuente o solo con correo antiguo (por ejemplo, archivos de mensajes de listas de correo que ya no existen), se despachan inmediatamente. En mi configuración esto descarta más de la mitad de mis carpetas IMAP4.

En caso de que una carpeta IMAP4 tenga correo reciente, obtenemos una lista de los mensajes recientes, los recibidos en los últimos siete días (línea 37). Esta operación debería ser rápida, pero en realidad puede ser muy lenta y costosa, como explico en la nota [1].

Una vez que tenemos el listado de los mensajes recientes de una carpeta IMAP4, solicitamos sus cabeceras y las comparamos buscando duplicados, tal y como hacíamos (y justificábamos los detalles) en el programa original. En ese artículo también se explica la semántica del comando IMAP4 close y diversas consideraciones a tener en cuenta.

[1] (1, 2)

Mi servidor IMAP4 es Dovecot. Por defecto, almacena muy poca información en sus índices. Cuando un cliente requiere información adicional, Dovecot construye un índice nuevo desde cero (analizando la carpeta IMAP4 entera), devuelve la información requerida y mantiene actualizado el nuevo índice durante un tiempo, a medida que entran y se borran mensajes. Si pasa cierto tiempo sin que ningún otro cliente IMAP4 vuelva a requerir esa información, el índice se elimina.

El efecto de todo esto es que si realizamos la limpieza solo de forma esporádica, cuando la necesitamos, es muy posible que Dovecot no tenga los índices necesarios y los reconstruya desde cero. Este proceso es lento y el tiempo es proporcional al número de mensajes existentes en la carpeta afectada. Todo ese trabajo se usará una sola vez y no se reaprovechará, porque nuestra siguiente limpieza puede ocurrir meses más tarde y los índices se habrán destruido ya por falta de uso.

Ejemplo práctico real con una carpeta IMAP4 con 21652 mensajes en su interior:

Comando IMAP4 "search since HOY":

  • Sin índice:
    • Generar el índice y devolver el resultado tarda ocho minutos y veinte segundos.
    • El fichero de índice generado, dovecot.index.cache, mide 350 Kbytes. Previamente borré el fichero y medía 35.8 Megabytes.
  • Con índice:
    • Devolver el resultado con la caché caliente tarda 0.15 segundos.
    • Si la caché está fría (hay que ir al disco a por ella, no está en memoria), el resultado tarda 1.6 segundos.

Como puede verse, conocer las características del sistema de caché de Dovecot es crítico para entender su impacto en el rendimiento.