Skip to content

Commit

Permalink
tweak: python/imap-exemples-concrets + python/imaplib-suppression-des…
Browse files Browse the repository at this point in the history
…-doublons
  • Loading branch information
BoboTiG committed Oct 29, 2024
1 parent c5ecb8c commit 66f5a78
Show file tree
Hide file tree
Showing 6 changed files with 150 additions and 96 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ _build/
_live/
luma/
.mypy_cache/
__pycache__/
.ruff_cache/
venv/

Expand Down
1 change: 1 addition & 0 deletions checks.sh
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ check_python_files() {
python -m ruff format "${FOLDER}" ./*.py
python -m ruff check --fix "${FOLDER}" ./*.py
python -m mypy "${FOLDER}" ./*.py
python -m doctest 'sources/python/snippets/imaplib-suppression-des-doublons.py'
}

check_shell_file() {
Expand Down
8 changes: 8 additions & 0 deletions sources/python/imap-exemples-concrets.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,13 @@ Afin de ne pas modifier l'état du message, préférer les commandes `(BODY.PEEK
:language: python
```

### Récupérer seulement l'UID des messages sans modifier l'état

```{literalinclude} snippets/imap-exemples-concrets.py
:lines: 71-73
:language: python
```

### Récupérer les messages non lus et ayant un sujet particulier

```{literalinclude} snippets/imap-exemples-concrets.py
Expand Down Expand Up @@ -132,6 +139,7 @@ La commande `MOVE` n'existe pas ; pour l'émuler, if faut faire une copie puis s

2024-10-29
: Ajout de plus d'informations dans l'avertissement quant à l'état du message après utilisation de la commande {py:func}`imaplib.IMAP4.fetch`.
: Ajout de l'exemple [Récupérer seulement l’UID des messages sans modifier l’état](#recuperer-seulement-l-uid-des-messages-sans-modifier-l-etat).

2024-02-01
: Déplacement de l'article depuis le [blog](https://www.tiger-222.fr/?d=2016/01/21/16/35/09-imap-exemple-concret).
Expand Down
91 changes: 52 additions & 39 deletions sources/python/imaplib-suppression-des-doublons.md
Original file line number Diff line number Diff line change
@@ -1,92 +1,101 @@
# Python et IMAP : Suppression des doublons

Commençons par nous connecter à la boîte de messagerie :
Script utile pour supprimer les messages en double/triple voire plus.

```{todo}
Supprimer les commentaires `type: ignore[…]` et corriger/retester le code.
```
## 🎩 Le Script

```{literalinclude} snippets/imaplib-suppression-des-doublons.py
:caption: imap-delete-duplicate.py
:lines: 1-124
:caption: imap-delete-duplicates.py
:language: python
```

## 📺 Utilisation

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

```{code-block} text
:caption: $ python imap-delete-duplicate.py 'mail.gandi.net' '[email protected]'
```{code-block} shell
python imap-delete-duplicates.py 'mail.gandi.net' '[email protected]'
```

```{code-block} text
:caption: Exemple de sortie
Password:
Drafts
Trash
>>> Drafts
>>> Trash
45 messages
Sent
>>> Sent
37 messages
1 doublons
INBOX
>>> INBOX
888 messages
443 doublons
INBOX/Droit du travail
>>> INBOX/Droit du travail
2 messages
```

Et avec une grosse boîte de messagerie :
Et avec une boîte de messagerie contenant plusieurs dizaines de millers de messages :

```{code-block} text
:caption: $ time python imap-delete-duplicate.py 'imap.gmail.com' '[email protected]'
```{code-block} shell
time python imap-delete-duplicates.py 'imap.gmail.com' '[email protected]'
```

```{code-block} text
:caption: Exemple de sortie
Password:
Archives
>>> Archives
10792 messages
9 doublons
INBOX
>>> INBOX
6550 messages
3 doublons
Personnel
>>> Personnel
4 messages
Re&AOc-us
[Gmail]/Billetterie
>>> Re&AOc-us
>>> [Gmail]/Billetterie
36 messages
[Gmail]/Brouillons
[Gmail]/Clef GNUPG
[Gmail]/Corbeille
>>> [Gmail]/Brouillons
>>> [Gmail]/Clef GNUPG
>>> [Gmail]/Corbeille
37 messages
[Gmail]/Important
>>> [Gmail]/Important
6153 messages
2 doublons
[Gmail]/Messages envoy&AOk-s
>>> [Gmail]/Messages envoy&AOk-s
8684 messages
[Gmail]/Spam
>>> [Gmail]/Spam
1169 messages
[Gmail]/Suivis
>>> [Gmail]/Suivis
22 messages
[Gmail]/Tous les 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 {py:mod}`smtplib` de Python :
## 📧 Message-ID

```{literalinclude} snippets/imaplib-suppression-des-doublons.py
:lines: 129-130
:dedent:
:language: python
Parfois, un message n'aura pas le Message-ID dans ses entêtes. Assuez-vous d'utiliser ces morceaux de code lorsque vous envoyez des courriels.

### 🐍 Python

Pour ajouter le bon Message-ID aux courriels envoyés par les functions du module {py:mod}`smtplib` :

```{code-block} 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()`](https://www.php.net/manual/function.mail.php) de PHP :
### 🐘 PHP

Pour ajouter le bon Message-ID aux courriels envoyés par la fonction [`mail()`](https://www.php.net/manual/function.mail.php) :

```{literalinclude} snippets/imaplib-suppression-des-doublons.php
:lines: 2-
:dedent:
:language: php
```
````

---

Expand All @@ -96,6 +105,10 @@ Pour ajouter le bon Message-ID aux courriels envoyés par la fonction [`mail()`]

## 📜 Historique

2024-10-29
: Revue de code pour supprimer les commentaires `type: ignore[…]`, moderniser, et corriger/retester l'ensemble'.
: Ajout des sections.

2024-02-01
: Déplacement de l'article depuis le [blog](https://www.tiger-222.fr/?d=2016/02/05/18/00/41-imaplib-suppression-des-doublons).

Expand Down
4 changes: 4 additions & 0 deletions sources/python/snippets/imap-exemples-concrets.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,7 @@

folder = "archives-personal"
conn.uid("copy", uids, folder)

ret, data = conn.uid("fetch", uids, "(BODY.PEEK[HEADER.FIELDS (MESSAGE-ID)])")
if ret == "OK":
uids = data[0].split()
141 changes: 84 additions & 57 deletions sources/python/snippets/imaplib-suppression-des-doublons.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,75 +6,115 @@
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
return [uid.decode() for uid in uids[0].split()] if ret == "OK" else []


def get_folder(raw_line: bytes | None) -> str:
r"""
Détermine le dossier depuis les données renvoyées par la fonction IMAP.
>>> get_folder(None)
''
>>> get_folder(b'(\\Noselect) "/" "Perso"')
''
>>> get_folder(b'() "/" "inbox"')
'"inbox"'
>>> get_folder(b'() "/" "[Gmail]/Tous les messages"')
'"[Gmail]/Tous les messages"'
"""
# Certains dossiers ne sont pas sélectionnables
if not raw_line or b"Noselect" in raw_line:
return ""

folder = raw_line.decode().split('"')[3]

# Il faut échapper le nom du dossier par des double-quotes pour éviter les erreurs.
# Ça protège les noms de dossier qui contiennent des espaces.
return f'"{folder}"'


def get_msg_id(raw_line: bytes) -> str:
r"""
Détermine le Message-ID depuis les données renvoyées par la fonction IMAP.
>>> get_msg_id(b"\r\n")
''
>>> get_msg_id(b"Message-ID: <CACqWxT1rjTZ7Y-43F=nWUfMa5pkRB5VJSFUkhuRtsE4a9da2Rw@mail.gmail.com>\r\n")
'<CACqWxT1rjTZ7Y-43F=nWUfMa5pkRB5VJSFUkhuRtsE4a9da2Rw@mail.gmail.com>'
"""
return line.split()[1] if raw_line and (line := raw_line.decode().strip()) else ""


def get_uid(raw_line: bytes) -> str:
"""
Détermine l'UID du message depuis les données renvoyées par la fonction IMAP.
>>> get_uid(b"2 (UID 15309 BODY[HEADER.FIELDS (MESSAGE-ID)] {82}")
'15309'
"""
return line.split()[2] if raw_line and (line := raw_line.decode()) else ""


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}"'
print(">>>", folder.strip('"'))

# Et on se rend dans ledit dossier
ret, data = conn.select(path)
ret, data = conn.select(folder)
if ret != "OK":
raise IMAP.error(ret)

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

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

# Recherchons les doublons
uniq_msgs = []
duplicata = []
# La méthode IMAP.uid() peut traiter plusieurs messages à la fois, ce qui économise
uniq_msgs: set[str] = set()
duplicates: set[str] = set()

# 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.
all_uids = ",".join(sorted(uids))

# On ne récupère que l'entête 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.
# `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]
for line in data:
if not isinstance(line, tuple):
continue
data_uid, data_msg_id = line

# On en déduit le Message-ID
msg_id = data[idx][1].split(" ")[1].replace("\r\n", "") # type: ignore[index,union-attr]
# 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 l'avertissement sur la page de l'article :
# https://www.tiger-222.fr/luma/python/imaplib-suppression-des-doublons.html
if not (msg_id := get_msg_id(data_msg_id)):
continue

# Si le Message-ID a déjà été traité, alors il s'agit d'un doublon
# 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)
# … et on ajoute son UID à la liste des messages à supprimer
duplicates.add(get_uid(data_uid))
else:
uniq_msgs.append(msg_id)
uniq_msgs.add(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)
if duplicates:
print(f"{len(duplicates):>6} doublons")
all_uids = ",".join(sorted(duplicates))
conn.uid("store", all_uids, "+FLAGS", "\\Deleted")

conn.close()


Expand All @@ -96,16 +136,10 @@ def main(server: str, user: str) -> int:
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)
assert isinstance(infos, bytes) # Pour Mypy
if folder := get_folder(infos):
purge(conn, folder)
except IMAP.error as ex:
print(ex)
return 1
Expand All @@ -118,14 +152,7 @@ def main(server: str, user: str) -> int:
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]))
print(f"python {sys.argv[0]} SERVER USER")
sys.exit(1)


def msg_id() -> None:
"""
>>> from email.utils import make_msgid
>>> msg["Message-ID"] = make_msgid()
"""
sys.exit(main(sys.argv[1], sys.argv[2]))

0 comments on commit 66f5a78

Please sign in to comment.