erplibre/script/todo/mail/imap_sync.py
Mathieu Benoit cfbdd9406e [REF] format : passer l'outillage et les tests sous ruff
Le formateur de ce dépôt est ruff depuis qu'il remplace black, qui ne connaît
aucune cible au-delà de py313 ; ce passage applique sa norme à l'arbre entier,
d'un coup, pour qu'aucun commit de fond n'ait à porter du style. L'écart tient
presque entièrement aux chaînes coupées à la main que ruff recolle quand elles
tiennent sur une ligne, et aux « with » multiples qu'il regroupe : aucune
valeur ne change, et les clés de traduction non plus.
Vérifié : la suite unitaire reste verte après le passage, et le contrôle de
syntaxe ne signale rien.

--- EN ---

This repository's formatter is ruff since it replaced black, which knows no
target beyond py313; this pass applies its standard to the whole tree at once,
so that no substantive commit has to carry style. The difference is almost
entirely the hand-split strings ruff joins back when they fit on one line, and
the multiple "with" it merges: no value changes, nor do the translation keys.
Checked: the unit suite stays green after the pass, and the syntax check
reports nothing.

Assisted-by: Claude Opus 5
2026-09-24 14:40:38 -04:00

257 lines
8.7 KiB
Python

#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Le moteur de synchronisation, séparé de ce qui parle vraiment IMAP.
`Syncer` ne connaît qu'un PROTOCOLE (`ImapTransport`). C'est ce qui permet de
l'exercer entièrement contre un serveur en mémoire, sans réseau ni compte, et
c'est ce qui garde le décodage verbeux d'`imaplib` dans son propre fichier.
Une passe est incrémentale par construction : on demande les UID strictement
supérieurs au dernier connu. Le seul cas qui force une reprise à zéro est le
changement d'UIDVALIDITY — le serveur annonce alors que ses UID ne veulent
plus rien dire, et garder l'ancien cache produirait des messages faux.
Les corps ne descendent JAMAIS pendant une passe : une boîte de 20 000
messages doit se synchroniser en secondes, pas en gigaoctets.
"""
from __future__ import annotations
import logging
import time
from dataclasses import dataclass, field
from typing import Protocol
from script.todo.mail.charset import decode_bytes
from script.todo.mail.store import MessageMeta
SNIPPET_LEN = 200
_logger = logging.getLogger(__name__)
@dataclass
class FolderInfo:
name: str
display: str = ""
role: str | None = None
# `\Noselect` / `\NonExistent` : un NIVEAU de la hiérarchie, pas une
# boîte. Gmail expose « [Gmail] » ainsi, simple parent de « [Gmail]/Sent
# Mail » et consorts. Le SELECTionner répond NO — c'est normal, et le
# serveur le dit d'avance dans les drapeaux de LIST.
selectable: bool = True
@dataclass
class SelectInfo:
uidvalidity: int
uidnext: int
exists: int
@dataclass
class HeaderInfo:
uid: int
date: int
size: int
flags: str
msgid: str
frm: str
to: str
subject: str
@dataclass
class SyncReport:
folders: int = 0
new_messages: int = 0
purged: list = field(default_factory=list)
errors: list = field(default_factory=list)
class ImapTransport(Protocol):
"""Ce dont le moteur a besoin. `imap_transport.ImaplibTransport` l'implémente."""
def list_folders(self) -> list[FolderInfo]: ...
def select(self, folder: str) -> SelectInfo: ...
def search_uids(self, since_uid: int) -> list[int]: ...
def fetch_headers(self, uids: list[int]) -> list[HeaderInfo]: ...
def fetch_flags(self, uids: list[int]) -> list[tuple[int, str]]: ...
def fetch_body(self, uid: int) -> bytes: ...
def store_flags(
self, uid: int, add: list[str], remove: list[str]
) -> None: ...
def append(self, folder: str, raw: bytes, flags: list[str]) -> None: ...
def logout(self) -> None: ...
def _chunks(items: list, size: int):
for start in range(0, len(items), size):
yield items[start : start + size]
def snippet_from_raw(raw: bytes, length: int = SNIPPET_LEN) -> str:
"""Les premiers mots du corps, pour la colonne d'aperçu de la liste."""
import email
try:
msg = email.message_from_bytes(raw)
except Exception:
return ""
part = msg
if msg.is_multipart():
part = next(
(p for p in msg.walk() if p.get_content_type() == "text/plain"),
None,
)
if part is None:
return ""
try:
payload = part.get_payload(decode=True) or b""
except Exception:
return ""
# `decode_bytes` (voir sa docstring) : un charset mal étiqueté ne doit
# pas faire tomber l'ouverture de la boîte.
text = decode_bytes(payload, part.get_content_charset())
return " ".join(text.split())[:length]
class Syncer:
"""Une passe de synchronisation, et le téléchargement d'un corps à la demande."""
BATCH = 200
FLAG_REFRESH = 500
def __init__(self, store, transport: ImapTransport) -> None:
self.store = store
self.transport = transport
def sync(self, progress=None) -> SyncReport:
report = SyncReport()
for folder in self.transport.list_folders():
try:
self._sync_folder(folder, report, progress)
except Exception as exc:
# Un dossier qui refuse ne doit pas priver l'utilisateur des autres.
_logger.exception("sync du dossier %r a échoué", folder.name)
report.errors.append(f"{folder.name} : {exc}")
report.folders += 1
return report
def sync_one(self, folder_name: str) -> SyncReport:
"""Une passe limitée à `folder_name`, par son nom seul.
Sert à `deliver()` (`tui.py`) juste après un APPEND réussi dans
Envoyés, pour que le message parti apparaisse sans attendre la
prochaine passe complète (design, ligne 308) — sans fabriquer de
ligne locale : c'est le serveur qui attribue l'UID, en inventer un
entrerait en collision avec un futur message réel. Ne connaissant
que le nom, on passe un `FolderInfo` sans `display`/`role` ; le
COALESCE de `store.upsert_folder` garde ceux déjà appris d'un LIST
complet.
Ne lève jamais, à l'image de `sync()` par dossier : cette sync est
un confort, pas une garantie — un envoi déjà réussi ne doit jamais
se lire comme un échec parce que cette relecture a raté.
"""
report = SyncReport()
try:
self._sync_folder(FolderInfo(name=folder_name), report, None)
except Exception as exc:
_logger.exception(
"sync ciblée du dossier %r a échoué", folder_name
)
report.errors.append(f"{folder_name} : {exc}")
report.folders = 1
return report
def _sync_folder(
self, folder: FolderInfo, report: SyncReport, progress
) -> None:
fid = self.store.upsert_folder(
folder.name, folder.display, folder.role
)
if not folder.selectable:
# Enregistré ci-dessus pour rester dans l'arbre — c'est un
# niveau de hiérarchie visible — mais on ne va pas plus loin :
# le SELECT répondrait NO, et cette erreur salissait CHAQUE
# synchronisation Gmail sans rien signaler d'anormal. Un
# journal qui crie sur du normal fait rater ce qui ne l'est pas.
return
info = self.transport.select(folder.name)
state = self.store.folder_state(folder.name) or {}
known_validity = state.get("uidvalidity")
if known_validity is not None and known_validity != info.uidvalidity:
self.store.purge_folder(folder.name)
report.purged.append(folder.name)
state = self.store.folder_state(folder.name) or {}
self.store.set_folder_state(
folder.name, uidvalidity=info.uidvalidity, uidnext=info.uidnext
)
last_uid = state.get("last_uid") or 0
uids = self.transport.search_uids(last_uid + 1)
done = 0
for batch in _chunks(uids, self.BATCH):
headers = self.transport.fetch_headers(batch)
self.store.upsert_messages(
fid,
[
MessageMeta(
uid=h.uid,
date=h.date,
size=h.size,
flags=h.flags,
msgid=h.msgid,
frm=h.frm,
to=h.to,
subject=h.subject,
snippet="",
)
for h in headers
],
)
report.new_messages += len(headers)
done += len(batch)
if progress:
progress(folder.name, done, len(uids))
if uids:
self.store.set_folder_state(folder.name, last_uid=max(uids))
# Les drapeaux des messages déjà connus changent sans que l'UID bouge :
# un « lu » ailleurs ne serait jamais vu sans cette relecture.
known = self.store.known_uids(fid, self.FLAG_REFRESH)
if known:
for uid, flags in self.transport.fetch_flags(known):
self.store.update_flags(fid, uid, flags)
self.store.set_folder_state(
folder.name,
total=info.exists,
unseen=self.store.count_unseen(fid),
synced_at=int(time.time()),
)
def fetch_body(self, folder_name: str, uid: int) -> bytes:
"""Le corps, du cache s'il y est, du serveur sinon."""
cached = self.store.read_body(folder_name, uid)
if cached is not None:
return cached
self.transport.select(folder_name)
raw = self.transport.fetch_body(uid)
self.store.write_body(folder_name, uid, raw)
state = self.store.folder_state(folder_name)
if state:
self.store.set_snippet(state["id"], uid, snippet_from_raw(raw))
return raw