erplibre/script/todo/mail/store.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

625 lines
21 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 cache courriel local : une base SQLite et des fichiers .eml par compte.
Une racine par compte, jamais une base partagée : c'est ce qui permet à un
compte d'être éphémère pendant qu'un autre persiste, sans mélanger deux modes
de chiffrement dans les mêmes lignes.
Ce qui reste EN CLAIR dans la base — uid, dossier, date, drapeaux, taille —
est exactement ce dont le SQL a besoin pour trier et filtrer. Ce qui identifie
des personnes — expéditeur, destinataires, sujet, extrait, Message-ID — est
scellé. Le Message-ID a en plus un haché salé par la clé, pour qu'on puisse
recoller les fils de discussion sans le lire.
"""
from __future__ import annotations
import base64
import functools
import hashlib
import os
import shutil
import sqlite3
import stat
import threading
import urllib.parse
from dataclasses import dataclass
from pathlib import Path
from script.todo.mail.crypto import build_crypto, new_key
from script.todo.todo_i18n import t
SCHEMA_VERSION = 1
EPHEMERAL_PREFIX = "erplibre-mail-"
VALID_MODES = ("clear", "encrypted", "ephemeral")
SCHEMA = """
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT
);
CREATE TABLE IF NOT EXISTS folders (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
display TEXT,
role TEXT,
uidvalidity INTEGER,
uidnext INTEGER,
last_uid INTEGER NOT NULL DEFAULT 0,
total INTEGER NOT NULL DEFAULT 0,
unseen INTEGER NOT NULL DEFAULT 0,
synced_at INTEGER
);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY,
folder_id INTEGER NOT NULL REFERENCES folders(id) ON DELETE CASCADE,
uid INTEGER NOT NULL,
date INTEGER,
size INTEGER,
flags TEXT,
has_body INTEGER NOT NULL DEFAULT 0,
msgid_hash TEXT,
sealed_msgid BLOB,
sealed_from BLOB,
sealed_to BLOB,
sealed_subject BLOB,
sealed_snippet BLOB,
UNIQUE(folder_id, uid)
);
CREATE INDEX IF NOT EXISTS idx_msg_date ON messages(folder_id, date DESC);
"""
class StoreError(Exception):
"""Cache inutilisable : clé manquante, base corrompue, disque refusé."""
def _locked(method):
"""Sérialise l'accès à la connexion SQLite.
`check_same_thread=False` lève l'interdiction de la stdlib, mais ne rend
pas la connexion sûre pour autant : c'est CE verrou qui la rend sûre. Le
TUI synchronise dans un thread de travail pendant que l'écran lit le cache
depuis le thread principal — les deux se croisent vraiment, ce n'est pas
une précaution théorique.
"""
@functools.wraps(method)
def wrapper(self, *args, **kwargs):
with self._lock:
return method(self, *args, **kwargs)
return wrapper
@dataclass
class MessageMeta:
uid: int
date: int
size: int
flags: str
msgid: str
frm: str
to: str
subject: str
snippet: str
has_body: bool = False
def resolve_mode(account, prefs_get=None) -> str:
"""Le mode du compte, sinon le défaut général, sinon `clear`.
Un défaut général illisible ne doit pas empêcher d'ouvrir le cache :
on retombe sur le mode le plus permissif, jamais sur une erreur.
"""
if account.cache_mode in VALID_MODES:
return account.cache_mode
if prefs_get is None:
from script.todo import todo_prefs
prefs_get = todo_prefs.get
general = prefs_get("mail_cache_mode", "clear")
return general if general in VALID_MODES else "clear"
def default_base() -> Path:
return Path(os.path.expanduser("~/.erplibre/mail"))
def ephemeral_base() -> Path:
"""/dev/shm quand il est inscriptible, sinon le dossier temporaire."""
shm = Path("/dev/shm")
if shm.is_dir() and os.access(shm, os.W_OK):
return shm
import tempfile
return Path(tempfile.gettempdir())
def cache_root(account, mode: str, base: Path | None = None) -> Path:
if mode == "ephemeral":
base = Path(base) if base else ephemeral_base()
return base / f"{EPHEMERAL_PREFIX}{os.getpid()}" / account.name
base = Path(base) if base else default_base()
return base / account.name
# Noms qui, seuls, désigneraient autre chose que le dossier voulu.
DEGENERATE_DIRNAMES = {"": "_", ".": "%2E", "..": "%2E%2E"}
def folder_dirname(imap_name: str) -> str:
"""Un nom de dossier IMAP transformé en nom de dossier de fichiers.
`quote` avec `safe=""` échappe tous les séparateurs, donc le résultat est
toujours UN seul composant de chemin : « A/B » ne peut pas créer deux
niveaux.
Mais `quote` n'encode JAMAIS le point — la stdlib garde toujours
« _.-~ » — et c'est voulu : beaucoup de serveurs IMAP séparent leur
hiérarchie par des points, et « INBOX.Sent » doit rester lisible sur le
disque. Le prix à payer est que « . » et « .. » traverseraient tels
quels, puisque `racine / ".."` remonte d'un cran. Ces trois cas
dégénérés sont donc les seuls réécrits.
"""
quoted = urllib.parse.quote(imap_name, safe="")
return DEGENERATE_DIRNAMES.get(quoted, quoted)
def _assert_private_dir(path: Path) -> None:
"""Refuse un dossier qu'on ne possède pas, ou qui est un lien symbolique."""
info = os.lstat(path)
if stat.S_ISLNK(info.st_mode):
raise StoreError(f"{path} {t('mail_err_symlink_refused')}")
if info.st_uid != os.getuid():
raise StoreError(f"{path} {t('mail_err_owned_by_other_user')}")
def sweep_orphan_ephemeral(base: Path | None = None) -> int:
"""Efface les caches éphémères dont le processus n'existe plus.
`atexit` et les gestionnaires de signaux couvrent les sorties normales ;
un SIGKILL, lui, laisse un résidu. Ce balayage au démarrage est le filet.
"""
base = Path(base) if base else ephemeral_base()
removed = 0
if not base.is_dir():
return 0
for path in base.glob(f"{EPHEMERAL_PREFIX}*"):
if not path.is_dir():
continue
raw_pid = path.name[len(EPHEMERAL_PREFIX) :]
if not raw_pid.isdigit():
continue
pid = int(raw_pid)
try:
os.kill(pid, 0)
except ProcessLookupError:
shutil.rmtree(path, ignore_errors=True)
removed += 1
except PermissionError:
# Le PID existe et appartient à quelqu'un d'autre : on n'y touche pas.
continue
return removed
class Store:
"""Le cache d'UN compte. À ouvrir, à fermer, éventuellement à effacer."""
def __init__(
self,
account,
*,
mode: str | None = None,
key: bytes | None = None,
secrets=None,
base: Path | None = None,
) -> None:
self.account = account
self.mode = mode or resolve_mode(account)
self.root = cache_root(account, self.mode, base)
self._key = key
self._secrets = secrets
self._conn: sqlite3.Connection | None = None
self._crypto = None
self._lock = threading.RLock()
# -- Cycle de vie ---------------------------------------------------
@_locked
def open(self) -> None:
if self._conn is not None:
return
self._crypto = build_crypto(self.mode, self._resolve_key())
self._prepare_root()
db_path = self.root / "cache.db"
conn = None
try:
# `check_same_thread=False` parce que le TUI synchronise dans un
# thread de travail : sans ça, la première passe lèverait
# ProgrammingError. La sûreté vient du verrou, pas de ce drapeau.
conn = sqlite3.connect(db_path, check_same_thread=False)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
conn.executescript(SCHEMA)
conn.execute(
"INSERT OR IGNORE INTO meta(key, value) VALUES('schema_version', ?)",
(str(SCHEMA_VERSION),),
)
conn.commit()
except sqlite3.DatabaseError as exc:
if conn is not None:
conn.close()
raise StoreError(
f"{t('mail_err_cache_unreadable')} {db_path} ({exc})"
) from exc
# Publié SEULEMENT une fois le schéma en place. `sqlite3.connect` est
# paresseux : une base corrompue n'échoue qu'à `executescript`, donc
# affecter `self._conn` plus tôt laisserait un open() raté derrière lui
# un handle sans schéma — et le open() suivant, voyant `_conn` non nul,
# réussirait en silence sur une base inutilisable.
self._conn = conn
if db_path.exists():
os.chmod(db_path, 0o600)
@_locked
def close(self) -> None:
if self._conn is None:
return
try:
self._conn.commit()
except sqlite3.Error:
# Fermer prime sur sauver : un commit refusé ne doit pas laisser la
# connexion ouverte pour toujours.
pass
finally:
self._conn.close()
self._conn = None
def cleanup(self) -> None:
"""Efface la racine du compte. Appelé à la sortie en mode éphémère.
On n'efface QUE le dossier du compte : le dossier par PID est partagé
avec les autres comptes éphémères du même processus, et l'effacer
détruirait leurs caches vivants. Il ne part que s'il est vide.
"""
self.close()
if self.mode != "ephemeral":
return
shutil.rmtree(self.root, ignore_errors=True)
parent = self.root.parent
if parent.name.startswith(EPHEMERAL_PREFIX):
try:
parent.rmdir()
except OSError:
# Un autre compte éphémère l'occupe encore : c'est normal.
pass
def __enter__(self) -> "Store":
self.open()
return self
def __exit__(self, *exc) -> None:
self.close()
def _prepare_root(self) -> None:
"""Crée la racine, en 0700 à chaque niveau qui nous appartient.
`mkdir(parents=True)` crée les dossiers intermédiaires SANS appliquer
le mode — c'est documenté dans la stdlib. En éphémère la racine vit
sous `/dev/shm`, qui est en 1777 et partagé avec tous les utilisateurs
locaux : un dossier par PID laissé à l'umask y rendrait les noms de
comptes lisibles par n'importe qui, et un dossier pré-créé par un tiers
à un chemin devinable lui permettrait de glisser un lien symbolique
sous `write_body`.
"""
parent = self.root.parent
if self.mode == "ephemeral":
parent.parent.mkdir(parents=True, exist_ok=True)
parent.mkdir(mode=0o700, exist_ok=True)
_assert_private_dir(parent)
else:
parent.mkdir(parents=True, exist_ok=True)
os.chmod(parent, 0o700)
self.root.mkdir(parents=True, exist_ok=True)
os.chmod(self.root, 0o700)
def _resolve_key(self) -> bytes | None:
if self.mode == "clear":
return None
if self._key is not None:
return self._key
if self.mode == "ephemeral":
# Tirée ici, gardée en RAM, jamais écrite : c'est tout l'intérêt.
self._key = new_key()
return self._key
if self._secrets is None:
raise StoreError(
f"{t('mail_err_mode_prefix')} {self.mode}"
f" {t('mail_err_mode_requires_key_no_vault')}"
)
ref = self.account.cache_key_ref()
stored = self._secrets.get(ref)
if stored is None:
self._key = new_key()
self._secrets.set(ref, base64.b64encode(self._key).decode())
else:
self._key = base64.b64decode(stored)
return self._key
# -- Scellement -----------------------------------------------------
def _seal(self, text: str) -> bytes:
return self._crypto.seal((text or "").encode("utf-8"))
def _open(self, blob) -> str:
if blob is None:
return ""
return self._crypto.open(bytes(blob)).decode("utf-8", "replace")
def _msgid_hash(self, msgid: str) -> str:
salt = self._key or b"clear"
return hashlib.sha256(salt + (msgid or "").encode("utf-8")).hexdigest()
def _db(self) -> sqlite3.Connection:
if self._conn is None:
raise StoreError(t("mail_err_cache_not_open"))
return self._conn
# -- Dossiers -------------------------------------------------------
@_locked
def upsert_folder(
self,
name: str,
display: str = "",
role: str | None = None,
uidvalidity: int | None = None,
uidnext: int | None = None,
) -> int:
db = self._db()
db.execute(
"INSERT INTO folders(name, display, role, uidvalidity, uidnext)"
" VALUES(?,?,?,?,?)"
" ON CONFLICT(name) DO UPDATE SET"
" display = COALESCE(excluded.display, folders.display),"
" role = COALESCE(excluded.role, folders.role),"
" uidvalidity = COALESCE(excluded.uidvalidity, folders.uidvalidity),"
" uidnext = COALESCE(excluded.uidnext, folders.uidnext)",
(name, display or None, role, uidvalidity, uidnext),
)
db.commit()
# `display` vaut NULL tant qu'aucun nom affichable n'est connu : c'est
# ce qui rend le COALESCE vivant, donc ce qui permet à une resync qui
# ne repasse que le nom IMAP de NE PAS écraser un libellé déjà décodé.
# Les lecteurs retombent sur `name` (voir mailbox_refs, tâche 9).
return db.execute(
"SELECT id FROM folders WHERE name = ?", (name,)
).fetchone()[0]
@_locked
def folders(self) -> list[dict]:
return [
dict(r)
for r in self._db().execute("SELECT * FROM folders ORDER BY name")
]
@_locked
def folder_state(self, name: str) -> dict | None:
row = (
self._db()
.execute("SELECT * FROM folders WHERE name = ?", (name,))
.fetchone()
)
return dict(row) if row else None
@_locked
def set_folder_state(self, name: str, **fields) -> None:
allowed = {
"last_uid",
"total",
"unseen",
"uidvalidity",
"uidnext",
"synced_at",
"role",
"display",
}
unknown = set(fields) - allowed
if unknown:
raise StoreError(
f"{t('mail_err_unknown_folder_fields')} {sorted(unknown)}"
)
if not fields:
return
sets = ", ".join(f"{k} = ?" for k in fields)
db = self._db()
db.execute(
f"UPDATE folders SET {sets} WHERE name = ?",
(*fields.values(), name),
)
db.commit()
@_locked
def purge_folder(self, name: str) -> None:
db = self._db()
row = db.execute(
"SELECT id FROM folders WHERE name = ?", (name,)
).fetchone()
if row:
db.execute("DELETE FROM messages WHERE folder_id = ?", (row[0],))
db.execute(
"UPDATE folders SET last_uid = 0, total = 0, unseen = 0"
" WHERE id = ?",
(row[0],),
)
db.commit()
shutil.rmtree(self.root / folder_dirname(name), ignore_errors=True)
# -- Messages -------------------------------------------------------
@_locked
def upsert_messages(self, folder_id: int, metas: list[MessageMeta]) -> int:
db = self._db()
rows = [
(
folder_id,
m.uid,
m.date,
m.size,
m.flags,
self._msgid_hash(m.msgid),
self._seal(m.msgid),
self._seal(m.frm),
self._seal(m.to),
self._seal(m.subject),
self._seal(m.snippet),
)
for m in metas
]
db.executemany(
"INSERT INTO messages(folder_id, uid, date, size, flags,"
" msgid_hash, sealed_msgid, sealed_from, sealed_to,"
" sealed_subject, sealed_snippet)"
" VALUES(?,?,?,?,?,?,?,?,?,?,?)"
" ON CONFLICT(folder_id, uid) DO UPDATE SET"
" date = excluded.date, size = excluded.size,"
" flags = excluded.flags, msgid_hash = excluded.msgid_hash,"
" sealed_msgid = excluded.sealed_msgid,"
" sealed_from = excluded.sealed_from,"
" sealed_to = excluded.sealed_to,"
" sealed_subject = excluded.sealed_subject,"
" sealed_snippet = excluded.sealed_snippet",
rows,
)
db.commit()
return len(rows)
@_locked
def update_flags(self, folder_id: int, uid: int, flags: str) -> None:
db = self._db()
db.execute(
"UPDATE messages SET flags = ? WHERE folder_id = ? AND uid = ?",
(flags, folder_id, uid),
)
db.commit()
def _row_to_meta(self, row) -> MessageMeta:
return MessageMeta(
uid=row["uid"],
date=row["date"],
size=row["size"],
flags=row["flags"] or "",
msgid=self._open(row["sealed_msgid"]),
frm=self._open(row["sealed_from"]),
to=self._open(row["sealed_to"]),
subject=self._open(row["sealed_subject"]),
snippet=self._open(row["sealed_snippet"]),
has_body=bool(row["has_body"]),
)
@_locked
def list_messages(
self, folder_id: int, limit: int = 500, offset: int = 0
) -> list[MessageMeta]:
rows = (
self._db()
.execute(
"SELECT * FROM messages WHERE folder_id = ?"
" ORDER BY date DESC, uid DESC LIMIT ? OFFSET ?",
(folder_id, limit, offset),
)
.fetchall()
)
return [self._row_to_meta(r) for r in rows]
@_locked
def known_uids(self, folder_id: int, last_n: int = 500) -> list[int]:
rows = (
self._db()
.execute(
"SELECT uid FROM messages WHERE folder_id = ?"
" ORDER BY uid DESC LIMIT ?",
(folder_id, last_n),
)
.fetchall()
)
return [r[0] for r in rows]
@_locked
def count_unseen(self, folder_id: int) -> int:
"""Les non-lus. `flags` est en clair, donc c'est du SQL, pas du déchiffrement."""
return (
self._db()
.execute(
"SELECT COUNT(*) FROM messages"
" WHERE folder_id = ? AND flags NOT LIKE '%\\Seen%' ESCAPE '\\'",
(folder_id,),
)
.fetchone()[0]
)
@_locked
def set_snippet(self, folder_id: int, uid: int, text: str) -> None:
"""L'extrait n'existe qu'une fois le corps téléchargé : ENVELOPE ne le donne pas."""
db = self._db()
db.execute(
"UPDATE messages SET sealed_snippet = ?"
" WHERE folder_id = ? AND uid = ?",
(self._seal(text), folder_id, uid),
)
db.commit()
# -- Corps ----------------------------------------------------------
def _body_path(self, folder_name: str, uid: int) -> Path:
suffix = ".eml" if self.mode == "clear" else ".eml.enc"
return self.root / folder_dirname(folder_name) / f"{uid}{suffix}"
@_locked
def write_body(self, folder_name: str, uid: int, raw: bytes) -> None:
path = self._body_path(folder_name, uid)
path.parent.mkdir(parents=True, exist_ok=True)
os.chmod(path.parent, 0o700)
# `write_bytes` puis `chmod` laisserait le corps du message — scellé,
# mais destiné à rester privé même déchiffré — lisible à l'umask du
# process le temps entre les deux appels : le fichier est donc créé
# DÉJÀ en 0600.
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "wb") as handle:
handle.write(self._crypto.seal(raw))
os.chmod(path, 0o600)
db = self._db()
db.execute(
"UPDATE messages SET has_body = 1 WHERE uid = ? AND folder_id ="
" (SELECT id FROM folders WHERE name = ?)",
(uid, folder_name),
)
db.commit()
@_locked
def read_body(self, folder_name: str, uid: int) -> bytes | None:
path = self._body_path(folder_name, uid)
if not path.exists():
return None
return self._crypto.open(path.read_bytes())
# -- Entretien ------------------------------------------------------
@_locked
def size_bytes(self) -> int:
return sum(
p.stat().st_size for p in self.root.rglob("*") if p.is_file()
)
@_locked
def purge_all(self) -> None:
db = self._db()
db.execute("DELETE FROM messages")
db.execute("DELETE FROM folders")
db.commit()
for child in self.root.iterdir():
if child.is_dir():
shutil.rmtree(child, ignore_errors=True)