Python et IMAP : Suppression des doublons¶

Commençons par nous connecter à la boîte de messagerie :

Ă€ faire

Supprimer les commentaires type: ignore[…] et corriger/retester le code.

imap-delete-duplicate.py¶
from getpass import getpass
from imaplib import IMAP4_SSL as IMAP
from socket import gaierror


def get_emails(conn: IMAP) -> list[str]:
    """Récupérer la liste des identifiants uniques (UID) des messages."""

    emails = []
    # On cherche les messages marqués comme "non supprimés"
    ret, uids = conn.uid("search", "", "UNDELETED")
    if ret == "OK":
        emails = uids[0].split()
    return emails


def purge(conn: IMAP, folder: str) -> None:
    """Supprimer les doublons dans un dossier."""

    print(folder)
    # Il faut entourer le nom du dossier par des double-quotes pour Ă©viter les erreurs.
    # Ça protège les noms de dossier qui contiennent des espaces.
    path = f'"{folder}"'
    # Et on se rend dans ledit dossier
    ret, data = conn.select(path)
    if ret != "OK":
        raise IMAP.error(ret)

    # Récupérer la liste des courriels
    uids = get_emails(conn)
    total = len(uids)
    if not total:
        return

    print(f"{total:>6} messages")

    # Recherchons les doublons
    uniq_msgs = []
    duplicata = []
    # La méthode IMAP.uid() peut traiter plusieurs messages à la fois, ce qui économise
    # temps et ressources. On concatène tous les UID des messages avec une virgule.
    # Le gain de temps est phénoménal.
    all_uids = ",".join(uids)
    # On ne récupère que le champ Message-ID de chaque message, universellement unique.
    # BODY.PEEK permet de ne pas modifier l'Ă©tat du message.
    # Sinon, le message serait marqué comme lu.
    ret, data = conn.uid("fetch", all_uids, "(BODY.PEEK[HEADER.FIELDS (MESSAGE-ID)])")
    if ret != "OK":
        raise IMAP.error(ret)

    # data est une liste contenant UID, taille et Message-ID, entre autres.
    # Pour chaque message…
    for idx in range(0, len(data), 2):
        # Il se peut que le message n'aie pas de Message-ID, c'est souvent le cas
        # de ceux envoyés par la fonction PHP mail() ou Python smtplib.
        # Du coup, on zappe. Pour y remédier, voyez en bas de page.
        if not data[idx][1].strip():  # type: ignore[index,union-attr]
            continue

        # On en déduit le Message-ID
        msg_id = data[idx][1].split(" ")[1].replace("\r\n", "")  # type: ignore[index,union-attr]

        # Si le Message-ID a déjà été traité, alors il s'agit d'un doublon
        if msg_id in uniq_msgs:
            # On ajoute son UID Ă  la liste des messages Ă  supprimer
            uid = data[idx][0].split()[2]  # type: ignore[index,union-attr]
            duplicata.append(uid)
        else:
            uniq_msgs.append(msg_id)

    # Suppression des doublons
    if duplicata:
        print(f"{len(duplicata):>6} doublons")
        # Idem, en faisant une seule requĂŞte contenant tous les messages Ă  supprimer,
        # le gain de temps est Ă©norme.
        all_uids = ",".join(duplicata)
        conn.uid("store", all_uids, "+FLAGS", "\\Deleted")
    conn.close()


def main(server: str, user: str) -> int:
    password = getpass()

    # Connexion au serveur de messagerie
    try:
        conn = IMAP(server)
        conn.login(user, password)
    except (OSError, gaierror, IMAP.error) as ex:
        print(ex)
        return 1

    # On fait le ménage dans tous les dossiers
    ret, data = conn.list()
    if ret != "OK":
        print(ret)
        return 1

    try:
        # Pour chaque dossier…
        for infos in data:
            # infos contient plusieurs informations plus ou moins utiles.
            # Cependant, certains dossiers ne sont pas sélectionnables, on zappe.
            if "Noselect" in infos:  # type: ignore[operator]
                continue

            # On ne prend que ce qui nous intéresse, le nom du dossier.
            folder = str(infos).split('"')[3]
            purge(conn, folder)
    except IMAP.error as ex:
        print(ex)
        return 1

    conn.logout()
    return 0


if __name__ == "__main__":
    import sys

    if len(sys.argv) < 3:
        print("python IMAP-delete-duplicate.py SERVER USER")
        exit(1)

    exit(main(sys.argv[1], sys.argv[2]))

Et voici ce que ça donne en situation réelle :

$ python imap-delete-duplicate.py “mail.gandi.net” “test@jmsinfo.co”¶
Password:
Drafts
Trash
    45 messages
Sent
    37 messages
     1 doublons
INBOX
   888 messages
   443 doublons
INBOX/Droit du travail
     2 messages

Et avec une grosse boîte de messagerie :

$ time python imap-delete-duplicate.py “imap.gmail.com” “test@gmail.com”¶
Password:
Archives
 10792 messages
     9 doublons
INBOX
  6550 messages
     3 doublons
Personnel
     4 messages
Re&AOc-us
[Gmail]/Billetterie
    36 messages
[Gmail]/Brouillons
[Gmail]/Clef GNUPG
[Gmail]/Corbeille
    37 messages
[Gmail]/Important
  6153 messages
     2 doublons
[Gmail]/Messages envoy&AOk-s
  8684 messages
[Gmail]/Spam
  1169 messages
[Gmail]/Suivis
    22 messages
[Gmail]/Tous les messages
 25970 messages
    12 doublons

8,64s user 0,16s system 7% cpu 1:55,36 total

Note

Pour ajouter le bon Message-ID aux courriels envoyés par les functions du module smtplib de Python :

>>> from email.utils import make_msgid
>>> msg["Message-ID"] = make_msgid()

Note

Pour ajouter le bon Message-ID aux courriels envoyés par la fonction mail() de PHP :

$msg_id = sprintf(
    '<%s-%s@%s>',
    uniqid(time()),
    md5($from.$to),
    $_SERVER['SERVER_NAME']
);
$headers[] = 'Message-ID: '.$msg_id;

🎣 Sources¶

📜 Historique¶

2024-02-01

Déplacement de l’article depuis le blog.

2016-02-08

Optimisation et correction, certains dossiers sont inaccessibles (« [Gmail] » par exemple).

2016-02-05

Premier jet.