Elimina los mensajes duplicados en tu IMAP4

Como ya se ha explicado en el pasado, utilizo getmail para alimentar el servidor IMAP4 que tengo en mi propio portátil.

Getmail tiene varios problemas. Por ejemplo, guarda la lista de mensajes procesados solo cuando la descarga IMAP4 se completa con normalidad. Si ocurre cualquier problema, el proceso volverá a empezar desde el principio. Dado mi volumen de correo, tener el portátil apagado una semana supone una descarga de correo de varias horas, y si la conexión se corta durante el proceso, getmail volverá a empezar.

Esto ocasiona retrasos y molestias innecesarias, pero el mayor problema es que tendré mensajes duplicados. O triplicados. O cuadruplicados.

Aunque se trata de un problema ocasional, invita a errores: da lugar a una lista inmensa de mensajes a revisar, o puedes borrar un mensaje respondido y quedarte con una copia que no tendrá la marca de contestado.

He escrito el siguiente programa para hacer frente a este problema:

imap_duplicados-20170320.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

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)
    reply = imap.fetch('1:*', busqueda)
    already_seen = set()
    to_delete = []
    num_msgs = 0
    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()

Las líneas 8-11 importan la librería IMAPClient, mucho más conveniente que la librería estándar imaplib. A continuación itera sobre todas las carpetas de nuestra cuenta IMAP4 (líneas 15-34).

En cada carpeta extraemos las cabeceras de correo de todos los mensajes contenidos en ella (línea 20). A medida que vamos obteniendo las cabeceras de cada mensaje, miramos si ya las hemos visto antes. Si es así, se trata de un mensaje duplicado que podemos borrar (línea 28). En caso contrario, añadimos esas cabeceras a la lista de las ya vistas (línea 30).

Cuando terminamos de revisar el último mensaje de la carpeta, tendremos una lista de los mensajes que podemos borrar. Procedemos a marcar esos mensajes como borrados en la línea 33. Cerramos la carpeta en la línea 34, lo que implica ejecutar el borrado en sí tal y como se describe en la sección 6.4.2 del RFC 3501:

The CLOSE command permanently removes all messages that have the \Deleted flag set from the currently selected mailbox, and returns to the authenticated state from the selected state. No untagged EXPUNGE responses are sent.

Algunas consideraciones:

  • A la hora de revisar las cabeceras no quiero marcar los mensajes como ya vistos, porque no es así. Por eso usamos el modificador PEEK en la línea 13. De esta manera el estado de los mensajes no cambia. Según RFC 3501:

    BODY.PEEK[<section>]<<partial>>

    An alternate form of BODY[<section>] that does not implicitly set the \Seen flag.

  • Pedir el listado de todas las cabeceras de todos los mensajes es una operación lenta y costosa para el servidor IMAP4. La actividad de disco es importante, y el tiempo de proceso y la carga del sistema es proporcional al número de mensajes que estamos almacenando.

    Limitarse a solicitar un número reducido de cabeceras, por ejemplo Message-ID, es muchísimo más rápido porque suelen cachearse en el índice del servidor IMAP4. No obstante esto abre un mecanismo de ataque: al margen de los emails legítimos con cabeceras Message-ID duplicadas (un bug repudiable, pero nada raro), me resultaría trivial enviarte un mensaje con un Message-ID coincidente con el de un mensaje que quiero "borrarte" de tu sistema. Puedo determinar qué Message-ID emplear o bien por predicción o bien por tener acceso legítimo al Message-ID del mensaje a destruir a través de una lista de correo o porque yo también he recibido una copia de dicho mensaje.

    En una versión previa del programa (línea 12) utilizaba solo las cabeceras Message-ID y Date, pero juzgué que sería algo muy fácil de atacar para borrar mensajes maliciosamente.

    Una posibilidad sería usar la cabecera Message-ID y limitarse a pedir las cabeceras completas de los mensajes donde hemos visto los Message-ID repetidos. Hay que recordar también que es legítimo recibir un mismo mensaje por varias vías distintas, por ejemplo porque se nos está llegando a través de varias listas de correo diferentes.

  • La comprobación de duplicados solo se realiza dentro de la misma carpeta. Esto no es problema porque mi clasificación en carpetas es automática mediante SIEVE, así que los mensajes duplicados acaban en el mismo sitio.

  • Obsérvese que, a la hora de revisar los duplicados, no excluimos los mensajes marcados como \DELETED. Esto puede ser considerado un bug.

    Veamos la casuística: supongamos que hemos borrado un mensaje dado, pero no hemos visto aún su duplicado. Cuando aplicamos este programa, el mensaje no borrado que aún no hemos visto nos desaparecerá porque se identifica como duplicado de un mensaje que técnicamente ya hemos borrado y no tendría que entrar en la ecuación. También podría decirse que si hemos borrado un mensaje explícitamente, que este programa elimine sus duplicados no hace más que cumplir con nuestra intención. Estoy indeciso sobre cuál debería ser el comportamiento apropiado, aunque está claro cuál resulta más peligroso y sorprendente.

  • Aunque un atacante tuviese control absoluto de las cabeceras, hay una que no puede controlar: la cabecera Received que añade nuestro propio servidor de correo al recibir el mensaje.