257 lines
8.7 KiB
Python
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
|