From 16aecc2d337058efc277f51a453e8fbf89e089d0 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Mon, 10 Aug 2026 03:10:50 -0400 Subject: [PATCH] [ADD] mail: read and send email from the TODO CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An IMAP/SMTP client in the menu, with its tests against real servers. Most of the work went into refusals: an application password is named only when the server actually refuses, an accented password is reported as never having left the machine, and a refusal is recognised by what the server SAYS rather than by matching its wording. A malformed date no longer takes the whole folder down, and a received email is never read as markup. --- FR --- Un client IMAP/SMTP dans le menu, avec ses tests contre de vrais serveurs. L'essentiel du travail porte sur les refus : le mot de passe d'application n'est nommé que lorsque le serveur refuse vraiment, un mot de passe accentué est signalé comme n'ayant jamais quitté la machine, et un refus se reconnaît à ce que le serveur DIT plutôt qu'à ses mots. Une date illisible n'emporte plus le dossier entier, et un courriel reçu n'est jamais lu comme du balisage. Assisted-by: Claude Opus 5 --- doc/EMAIL.base.md | 678 ++++++ doc/EMAIL.fr.md | 340 +++ doc/EMAIL.md | 318 +++ doc/TODO.base.md | 8 + doc/TODO.fr.md | 5 +- doc/TODO.md | 3 + requirement/erplibre_require-ments.txt | 10 + script/config/config_file.py | 36 + script/todo/README.base.md | 8 + script/todo/README.fr.md | 6 +- script/todo/README.md | 4 + script/todo/mail/__init__.py | 11 + script/todo/mail/account_setup.py | 62 + script/todo/mail/accounts.py | 285 +++ script/todo/mail/charset.py | 28 + script/todo/mail/crypto.py | 119 ++ script/todo/mail/imap_sync.py | 256 +++ script/todo/mail/imap_transport.py | 339 +++ script/todo/mail/menu.py | 550 +++++ script/todo/mail/secrets.py | 178 ++ script/todo/mail/smtp_send.py | 293 +++ script/todo/mail/store.py | 624 ++++++ script/todo/mail/tui.py | 2701 ++++++++++++++++++++++++ script/todo/mail/tui_text.py | 218 ++ script/todo/todo.py | 127 ++ script/todo/todo_i18n.py | 618 ++++++ script/todo/todo_prefs.py | 20 + test/mail_sandbox.py | 832 ++++++++ test/test_config_file.py | 145 +- test/test_mail_account_setup.py | 149 ++ test/test_mail_accounts.py | 233 ++ test/test_mail_charset.py | 44 + test/test_mail_compose.py | 1382 ++++++++++++ test/test_mail_crypto.py | 120 ++ test/test_mail_imap_transport.py | 436 ++++ test/test_mail_live_server.py | 670 ++++++ test/test_mail_menu.py | 821 +++++++ test/test_mail_secrets.py | 229 ++ test/test_mail_send.py | 511 +++++ test/test_mail_store.py | 636 ++++++ test/test_mail_sync.py | 418 ++++ test/test_mail_tui.py | 431 ++++ test/test_mail_tui_account.py | 879 ++++++++ test/test_mail_tui_help.py | 627 ++++++ test/test_mail_tui_layout.py | 405 ++++ test/test_mail_tui_log.py | 514 +++++ test/test_mail_tui_refresh.py | 389 ++++ test/test_mail_tui_resize.py | 1048 +++++++++ test/test_mail_tui_splitter.py | 566 +++++ test/test_mail_tui_text.py | 298 +++ test/test_todo.py | 75 +- 51 files changed, 19632 insertions(+), 71 deletions(-) create mode 100644 doc/EMAIL.base.md create mode 100644 doc/EMAIL.fr.md create mode 100644 doc/EMAIL.md create mode 100644 script/todo/mail/__init__.py create mode 100644 script/todo/mail/account_setup.py create mode 100644 script/todo/mail/accounts.py create mode 100644 script/todo/mail/charset.py create mode 100644 script/todo/mail/crypto.py create mode 100644 script/todo/mail/imap_sync.py create mode 100644 script/todo/mail/imap_transport.py create mode 100644 script/todo/mail/menu.py create mode 100644 script/todo/mail/secrets.py create mode 100644 script/todo/mail/smtp_send.py create mode 100644 script/todo/mail/store.py create mode 100644 script/todo/mail/tui.py create mode 100644 script/todo/mail/tui_text.py create mode 100644 test/mail_sandbox.py create mode 100644 test/test_mail_account_setup.py create mode 100644 test/test_mail_accounts.py create mode 100644 test/test_mail_charset.py create mode 100644 test/test_mail_compose.py create mode 100644 test/test_mail_crypto.py create mode 100644 test/test_mail_imap_transport.py create mode 100644 test/test_mail_live_server.py create mode 100644 test/test_mail_menu.py create mode 100644 test/test_mail_secrets.py create mode 100644 test/test_mail_send.py create mode 100644 test/test_mail_store.py create mode 100644 test/test_mail_sync.py create mode 100644 test/test_mail_tui.py create mode 100644 test/test_mail_tui_account.py create mode 100644 test/test_mail_tui_help.py create mode 100644 test/test_mail_tui_layout.py create mode 100644 test/test_mail_tui_log.py create mode 100644 test/test_mail_tui_refresh.py create mode 100644 test/test_mail_tui_resize.py create mode 100644 test/test_mail_tui_splitter.py create mode 100644 test/test_mail_tui_text.py diff --git a/doc/EMAIL.base.md b/doc/EMAIL.base.md new file mode 100644 index 0000000..339b0b6 --- /dev/null +++ b/doc/EMAIL.base.md @@ -0,0 +1,678 @@ + + + + + + +# Mail client + +A mail client built into the TODO CLI: several accounts, IMAP + SMTP, and a +local cache — so you can read and answer email without leaving +`./script/todo/todo.py`. + +Every `Mail > ...` path below is shorthand for +`TODO > [3] Assistant > [2] Mail - Read and send email > ...` — the full path +is spelled out once, in "Adding an account". + + +# Client courriel + +Un client courriel intégré au CLI TODO : plusieurs comptes, IMAP + SMTP, et +un cache local — pour lire et répondre à son courriel sans quitter +`./script/todo/todo.py`. + +Chaque chemin `Courriel > ...` ci-dessous est un raccourci pour +`TODO > [3] Assistant > [2] Courriel - Lire et envoyer du courriel > ...` — le +chemin complet est écrit une fois, dans « Ajouter un compte ». + + +## Prerequisites + +Four Python packages, already listed in `requirement/erplibre_require-ments.txt` +(the `.venv.erplibre` environment, not an Odoo venv): + +- `cryptography` — seals the local cache in `encrypted` and `ephemeral` mode. +- `keyring` — the system keyring, one of the two places a password can live. +- `pykeepass` — the KDBX vault, the other place, and the one the client tries + first. +- `textual` — the terminal UI itself. Without it, "Open the mail client + (TUI)" prints a message and does nothing; the rest of the menu (accounts, + sync, cache) still works. + +Install them with: + + +## Prérequis + +Quatre paquets Python, déjà listés dans +`requirement/erplibre_require-ments.txt` (l'environnement `.venv.erplibre`, +pas un venv Odoo) : + +- `cryptography` — scelle le cache local en mode `encrypted` et `ephemeral`. +- `keyring` — le trousseau système, l'un des deux endroits où peut vivre un + mot de passe. +- `pykeepass` — le coffre KDBX, l'autre endroit, celui que le client essaie + en premier. +- `textual` — l'interface terminal elle-même. Sans lui, « Ouvrir le client + courriel (TUI) » affiche un message et ne fait rien ; le reste du menu + (comptes, synchronisation, cache) fonctionne quand même. + +Installez-les avec : + + +```bash +.venv.erplibre/bin/pip install -r requirement/erplibre_require-ments.txt +``` + + +### App passwords for Gmail, Outlook and iCloud + +Phase 1 speaks plain IMAP/SMTP login only — no OAuth yet (that is phase 2). +Gmail, Outlook and iCloud have all closed that door to the account's real +password, so each of these three presets requires an **app password** +instead: + +| Provider | Where to generate it | +|---|---| +| Gmail | Enable 2-step verification, then [myaccount.google.com](https://myaccount.google.com/security) > Security > App passwords | +| Outlook / Microsoft 365 | [account.microsoft.com](https://account.microsoft.com/security) > Security > Advanced security options > App passwords | +| iCloud | [account.apple.com](https://account.apple.com/) > Sign-In and Security > App-Specific Passwords | + +Use that generated password when account setup asks for one — never the +account's normal password. The "Standard server" preset (generic IMAP/SMTP) +does not need one. + + +### Mots de passe d'application pour Gmail, Outlook et iCloud + +La phase 1 ne parle qu'IMAP/SMTP en authentification simple — pas encore +OAuth (ça, c'est la phase 2). Gmail, Outlook et iCloud ont tous les trois +fermé cette porte au vrai mot de passe du compte : chacun de ces trois +préréglages exige donc un **mot de passe d'application** à la place : + +| Fournisseur | Où le générer | +|---|---| +| Gmail | Activez la validation en deux étapes, puis [myaccount.google.com](https://myaccount.google.com/security) > Sécurité > Mots de passe des applications | +| Outlook / Microsoft 365 | [account.microsoft.com](https://account.microsoft.com/security) > Sécurité > Options de sécurité avancées > Mots de passe d'application | +| iCloud | [account.apple.com](https://account.apple.com/) > Connexion et sécurité > Mots de passe spécifiques aux applications | + +Utilisez ce mot de passe généré quand la configuration du compte en demande +un — jamais le mot de passe normal du compte. Le préréglage « Serveur +standard » (IMAP/SMTP générique) n'en a pas besoin. + + +## Adding an account + +Menu path: `TODO > [3] Assistant > [2] Mail - Read and send email > [2] +Accounts > [2] Add an account`. + +The prompts, in order: + +1. **Short account name** — becomes both the folder name under + `~/.erplibre/mail/` and the vault reference, so it cannot contain `/` or + start with a dot. +2. **Email address**. +3. **Display name** (optional) — shown in the `From:` header as + `Display Name `. +4. **Provider** — a number from the printed list: Gmail, Outlook, iCloud, or + "Standard server" (generic IMAP/SMTP). +5. If you picked "Standard server", the **IMAP host** and **SMTP host** are + asked next; the other presets fill these in for you. +6. If the preset requires an app password, its note is printed here as a + reminder. +7. **Password** — typed hidden (`getpass`), then stored — never written to + `accounts.json`. + +Where the password goes: at the password step, the client hands off to the +CLI's shared **KDBX manager** — the same one already used for the OpenAI +key and Odoo credentials. It reads `kdbx.path` / `kdbx.password` from the +TODO config (`script/todo/todo.json`, overridable in +`private/todo/todo_override.json` / `private/todo/todo_override_private.json`). +If `kdbx.path` isn't set yet, a graphical file picker pops up asking you to +choose an existing `.kdbx` file — it needs a display, and cancelling it (or +running headless) fails account creation with "le fichier kdbx n'a pas pu +être ouvert" (French — see Troubleshooting). **Set `kdbx.path` (and +`kdbx.password`, to skip the prompt) before adding your first account**, +pointing at a `.kdbx` vault you already have (create one with KeePassXC or +similar). The system keyring is only ever used for an account whose +`secret_ref` already points at one — the menu itself always writes new +accounts into the KDBX vault. + +`accounts.json` (at `~/.erplibre/mail/accounts.json`) only ever holds a +`secret_ref` such as `kdbx:ERPLibre/Mail/perso` — a pointer, never the +secret. It is safe to read, edit by hand, or check into a private backup. + + +## Ajouter un compte + +Chemin de menu : `TODO > [3] Assistant > [2] Courriel - Lire et envoyer du +courriel > [2] Comptes > [2] Ajouter un compte`. + +Les questions, dans l'ordre : + +1. **Nom court du compte** — devient à la fois le nom de dossier sous + `~/.erplibre/mail/` et la référence dans le coffre : il ne peut donc pas + contenir `/` ni commencer par un point. +2. **Adresse courriel**. +3. **Nom affiché** (facultatif) — apparaît dans l'en-tête `De :` comme + `Nom affiché `. +4. **Fournisseur** — un numéro dans la liste affichée : Gmail, Outlook, + iCloud, ou « Serveur standard » (IMAP/SMTP générique). +5. Si vous choisissez « Serveur standard », le **serveur IMAP** puis le + **serveur SMTP** sont demandés ensuite ; les autres préréglages les + remplissent déjà pour vous. +6. Si le préréglage exige un mot de passe d'application, sa note s'affiche + ici en rappel. +7. **Mot de passe** — saisi masqué (`getpass`), puis rangé dans le coffre — + jamais écrit dans `accounts.json`. + +Où va le mot de passe : à l'étape du mot de passe, le client passe par le +**gestionnaire KDBX** partagé du CLI — le même que pour la clé OpenAI et +les identifiants Odoo. Il lit `kdbx.path` / `kdbx.password` dans la +configuration TODO (`script/todo/todo.json`, surchargeable dans +`private/todo/todo_override.json` / `private/todo/todo_override_private.json`). +Si `kdbx.path` n'est pas encore réglé, une fenêtre de sélection de fichier +s'ouvre pour choisir un `.kdbx` existant — il faut un affichage graphique, +et l'annuler (ou lancer le CLI sans affichage) fait échouer la création du +compte avec « le fichier kdbx n'a pas pu être ouvert » (voir Dépannage). +**Réglez `kdbx.path` (et `kdbx.password`, pour éviter l'invite) avant +d'ajouter votre premier compte**, en pointant vers un coffre `.kdbx` que +vous avez déjà (créez-en un avec KeePassXC ou équivalent). Le trousseau +système ne sert que pour un compte dont la `secret_ref` le désigne déjà — +le menu écrit toujours les nouveaux comptes dans le coffre KDBX. + +`accounts.json` (dans `~/.erplibre/mail/accounts.json`) ne contient jamais +qu'une `secret_ref` du genre `kdbx:ERPLibre/Mail/perso` — une référence, +jamais le secret. Il est sans danger à lire, à éditer à la main, ou à +mettre dans une sauvegarde privée. + + +## The three cache modes + +Every account keeps a local cache — a small SQLite database plus one file +per downloaded message — so the inbox stays readable offline. Three modes +control what that cache leaves on disk: + +| Mode | What's on disk | Encryption key | +|---|---|---| +| `clear` (default) | `~/.erplibre/mail//cache.db` and `.eml` files, readable as plain text | none | +| `encrypted` | same location, but sender, recipients, subject, snippet, message-id and message bodies are sealed with AES-256-GCM | generated once, stored in the vault next to the password (`.../cache-key`) | +| `ephemeral` | under `/dev/shm/erplibre-mail-//` (or the system temp dir if `/dev/shm` isn't writable), sealed the same way as `encrypted` | generated fresh in RAM at every run, never written anywhere, and the whole directory is removed when the session closes | + +Even in `clear` mode, the technical fields the SQL needs to sort and +filter — UID, folder, date, flags, size — are always plain; only the +person-identifying fields (and the message body) are ever sealed, and only +in `encrypted`/`ephemeral`. + +Set the **general default** at `Mail > [4] Cache > [1] Default cache mode`; +it is the `mail_cache_mode` preference (default `clear`). **Override it per +account** at `Mail > [4] Cache > [2] Cache mode of one account` — this +writes the account's `cache_mode` field in `accounts.json`; leaving it at +`null` there means "inherit the general default." + +`Mail > [4] Cache > [3] Cache size and purge` lists every account's +effective mode and disk usage, and can erase one account's cache entirely +(after confirmation) — the next sync rebuilds it from scratch. + + +## Les trois modes de cache + +Chaque compte garde un cache local — une petite base SQLite plus un fichier +par message téléchargé — pour que la boîte de réception reste lisible hors +ligne. Trois modes contrôlent ce que ce cache laisse sur le disque : + +| Mode | Ce qui reste sur le disque | Clé de chiffrement | +|---|---|---| +| `clear` (par défaut) | `~/.erplibre/mail//cache.db` et les fichiers `.eml`, lisibles en clair | aucune | +| `encrypted` | même emplacement, mais l'expéditeur, les destinataires, le sujet, l'extrait, le Message-ID et le corps des messages sont scellés en AES-256-GCM | générée une fois, rangée dans le coffre à côté du mot de passe (`.../cache-key`) | +| `ephemeral` | sous `/dev/shm/erplibre-mail-//` (ou le dossier temporaire système si `/dev/shm` n'est pas inscriptible), scellé comme `encrypted` | tirée en RAM à chaque lancement, jamais écrite nulle part, et tout le dossier est effacé à la fermeture de la session | + +Même en mode `clear`, les champs techniques dont le SQL a besoin pour trier +et filtrer — UID, dossier, date, drapeaux, taille — restent toujours en +clair ; seuls les champs qui identifient des personnes (et le corps du +message) sont scellés, et seulement en `encrypted`/`ephemeral`. + +Réglez le **défaut général** dans `Courriel > [4] Cache > [1] Mode de cache +par défaut` ; c'est la préférence `mail_cache_mode` (défaut `clear`). +**Surchargez-le par compte** dans `Courriel > [4] Cache > [2] Mode de cache +d'un compte` — ceci écrit le champ `cache_mode` du compte dans +`accounts.json` ; le laisser à `null` là-bas veut dire « hérite du défaut +général ». + +`Courriel > [4] Cache > [3] Taille du cache et purge` liste le mode +effectif et l'espace disque de chaque compte, et peut effacer entièrement le +cache d'un compte (après confirmation) — la prochaine synchronisation le +reconstruit à partir de zéro. + + +## The TUI + +`Mail > [1] Open the mail client (TUI)` opens a three-pane screen: an +account/folder tree on the left, the message list in the middle, and a +preview pane on the right, with a status line at the bottom. + +| Key | Action | +|---|---| +| `↑` `↓` `Tab` | move within a pane / move focus between panes (Textual defaults) | +| `h` | open the help window: every shortcut plus a few notes, closed with `Escape` | +| `z` | toggle full-screen preview (hides the folder tree and the message list) | +| `Escape` | leave full-screen | +| `v` | cycle the layout: columns, split, stacked | +| `+` / `-` | grow / shrink the pane that has focus | +| `0` | back to the default pane sizes | +| `r` | sync the account of the currently selected folder (all its folders) | +| `Shift+R` | sync every account | +| `/` | open the search field (filters the currently visible list only — locally, over subject/from/to/snippet; it does not search the server) | +| `s` / `u` | mark the selected message seen / unseen | +| `c` | compose a new message | +| `a` / `Shift+A` | reply / reply all | +| `f` | forward | +| `w` | save the message's **first** attachment to `~/Téléchargements` (created if missing) | +| `n` | add an account without leaving the client | +| `l` | show the tail of `~/.erplibre/mail.log` and this session's sync errors | +| `q` | quit | + +This table is written by hand and can fall behind the code; the `h` window +cannot. It builds its list from the application's own key bindings every time +it opens, so it is the reference if the two ever disagree. + +The bars between the panes can also be dragged with the mouse, and pane sizes +are remembered per layout. + +The footer's key hints, like the help window, follow the CLI's chosen +language, as do the account tree, the message list and the preview text. + + +## Le TUI + +`Courriel > [1] Ouvrir le client courriel (TUI)` ouvre un écran en trois +volets : l'arbre comptes/dossiers à gauche, la liste des messages au +centre, et un aperçu à droite, avec une ligne de statut en bas. + +| Touche | Action | +|---|---| +| `↑` `↓` `Tab` | se déplacer dans un volet / changer de volet (comportement par défaut de Textual) | +| `h` | ouvre la fenêtre d'aide : tous les raccourcis et quelques repères, fermée par `Échap` | +| `z` | plein écran sur l'aperçu (masque l'arbre et la liste) | +| `Échap` | quitter le plein écran | +| `v` | change de disposition : colonnes, partagée, empilée | +| `+` / `-` | agrandir / rétrécir le volet qui a le focus | +| `0` | revenir aux tailles de volets par défaut | +| `r` | synchronise le compte du dossier actuellement sélectionné (tous ses dossiers) | +| `Shift+R` | synchronise tous les comptes | +| `/` | ouvre le champ de recherche (filtre seulement la liste déjà affichée — localement, sur sujet/de/à/extrait ; ne cherche pas sur le serveur) | +| `s` / `u` | marquer le message sélectionné lu / non lu | +| `c` | écrire un nouveau message | +| `a` / `Shift+A` | répondre / répondre à tous | +| `f` | transférer | +| `w` | enregistrer la **première** pièce jointe du message dans `~/Téléchargements` (créé s'il n'existe pas) | +| `n` | ajouter un compte sans quitter le client | +| `l` | affiche la fin de `~/.erplibre/mail.log` et les erreurs de synchronisation de la session | +| `q` | quitter | + +Ce tableau est écrit à la main et peut prendre du retard sur le code ; la +fenêtre `h`, elle, ne le peut pas : elle construit sa liste depuis les +liaisons de l'application à chaque ouverture. En cas de désaccord entre les +deux, c'est elle qui a raison. + +Les barres entre les volets se glissent aussi à la souris, et les tailles +sont retenues par disposition. + +Les indices de touches du pied d'écran, comme la fenêtre d'aide, suivent la +langue choisie dans le CLI, tout comme l'arbre des comptes, la liste et le +texte d'aperçu. + + +## Writing a message + +`c` opens the compose form: `To`, `Cc`, `Subject`, an `Attachments` field +(semicolon-separated file paths — a comma is legal in a filename, so only +`;` splits entries; there is no file picker, type the paths), and a +multi-line body. `e` sends the body out to `$EDITOR` (or +`nano` if unset) and reads it back; if the editor is missing or exits with +an error, the body you had is kept untouched. `Ctrl+S` (or the Send button) +delivers the message; `Escape` discards the draft — there is no +save-as-draft. + +`a` (reply) and `Shift+A` (reply all) prefill `To`/`Cc`/`Subject`/ +`In-Reply-To`/`References` and quote the original message in the body. `f` +(forward) prefills the `Fwd:` subject and **attaches the original message** +automatically, as a `message/rfc822` attachment; the body itself starts +empty — write your own note above the attached original. + +Reply, reply-all and forward all need the original message's body +available — from the cache, or fetched live if the account is online; with +neither, you get "No message selected." / "No message to forward." + +Sending requires the account to be online (composing offline fails with +"Account offline: cannot send." — there is no offline outbox). Once sent, a +copy is filed into the account's Sent folder over IMAP; if that filing step +fails, the status line says so, but the message has already left — it is +not resent. + + +## Écrire un message + +`c` ouvre le formulaire : `À`, `Cc`, `Objet`, un champ `Pièces jointes` +(chemins de fichiers séparés par un point-virgule — une virgule est légale +dans un nom de fichier, donc seul `;` sépare les entrées ; il n'y a pas de +sélecteur de fichier, tapez les chemins), et un corps multi-lignes. +`e` envoie le corps vers `$EDITOR` (ou `nano` si non défini) et le relit ; +si l'éditeur manque ou sort en erreur, le texte de départ est conservé tel +quel. `Ctrl+S` (ou le bouton Envoyer) remet le message ; `Échap` abandonne +le brouillon — il n'y a pas d'enregistrement en brouillon. + +`a` (répondre) et `Shift+A` (répondre à tous) préremplissent `À`/`Cc`/ +`Objet`/`In-Reply-To`/`References` et citent le message d'origine dans le +corps. `f` (transférer) préremplit l'objet en `Fwd:` et **rattache le +message d'origine** automatiquement, en pièce jointe `message/rfc822` ; le +corps, lui, part vide — écrivez votre propre mot au-dessus du message +joint. + +Répondre, répondre à tous et transférer ont tous besoin du corps du message +d'origine — depuis le cache, ou récupéré en direct si le compte est en +ligne ; sans l'un ou l'autre, vous obtenez « Aucun message sélectionné. » / +« Aucun message à transférer. ». + +Envoyer exige que le compte soit en ligne (écrire hors ligne échoue avec +« Compte hors ligne : envoi impossible. » — il n'y a pas de file d'attente +hors ligne). Une fois envoyé, une copie est classée dans le dossier +Envoyés du compte par IMAP ; si ce classement échoue, la ligne de statut le +dit, mais le message est déjà parti — il n'est pas renvoyé. + + +## Synchronization + +A sync pass is incremental: only UIDs above the last known one are +fetched, message bodies are never downloaded during a pass (only headers), +and bodies are fetched on demand when you open a message. Flags +(read/unread, etc.) of already-known messages are re-checked on every +pass, so a message read elsewhere shows up correctly here too. + +Sync happens: + +- **At launch** — opening the TUI kicks off one background sync of every + account. +- **On demand** — `r` (current account) / `Shift+R` (all accounts) inside + the TUI, or `Mail > [3] Synchronise now` from the CLI menu (prints a + per-account summary to the terminal). +- **Automatically, every `mail_refresh_sec` seconds** (default 300 = 5 + minutes; 0 disables it) — **but only while the TUI is open**. Close it + and the timer goes with it; nothing syncs in the background afterward. + +If the server reports a changed `UIDVALIDITY` for a folder (its UIDs no +longer mean what they used to — typically after a server-side migration), +that folder's cache is purged and resynced from scratch automatically; +there is currently no on-screen notice when this happens beyond the folder +briefly emptying and refilling. + + +## Synchronisation + +Une passe de synchronisation est incrémentale : seuls les UID supérieurs au +dernier connu sont demandés, le corps des messages n'est jamais téléchargé +pendant une passe (seulement les en-têtes), et les corps sont récupérés à +la demande à l'ouverture d'un message. Les drapeaux (lu/non lu, etc.) des +messages déjà connus sont revérifiés à chaque passe, donc un message lu +ailleurs apparaît correctement lu ici aussi. + +La synchronisation a lieu : + +- **Au lancement** — ouvrir le TUI déclenche une synchronisation de tous + les comptes en arrière-plan. +- **À la demande** — `r` (compte courant) / `Shift+R` (tous les comptes) + dans le TUI, ou `Courriel > [3] Synchroniser maintenant` depuis le menu + CLI (affiche un résumé par compte dans le terminal). +- **Automatiquement, toutes les `mail_refresh_sec` secondes** (défaut 300 = + 5 minutes ; 0 la désactive) — **mais seulement tant que le TUI est + ouvert**. Fermez-le et la minuterie part avec lui ; rien ne se + synchronise en arrière-plan ensuite. + +Si le serveur annonce un `UIDVALIDITY` changé pour un dossier (ses UID ne +veulent plus dire ce qu'ils disaient — typiquement après une migration +côté serveur), le cache de ce dossier est purgé et resynchronisé à partir +de zéro automatiquement ; il n'y a actuellement aucun avis à l'écran +au-delà du dossier qui se vide puis se remplit à nouveau brièvement. + + +## Where the files live + +| Path | Contents | +|---|---| +| `~/.erplibre/mail/accounts.json` | account list — servers, presets, cache mode, and a `secret_ref` pointer; never a password (mode 0600) | +| `~/.erplibre/mail//cache.db` | that account's SQLite cache (mode 0600, parent directory 0700) | +| `~/.erplibre/mail///.eml` (or `.eml.enc` when sealed) | one file per downloaded message body | +| `/dev/shm/erplibre-mail-//` | an `ephemeral` account's cache while the process is alive; removed when it exits (a sweep at every startup also clears directories left behind by a killed process) | + + +## Où sont les fichiers + +| Chemin | Contenu | +|---|---| +| `~/.erplibre/mail/accounts.json` | la liste des comptes — serveurs, préréglages, mode de cache, et une référence `secret_ref` ; jamais un mot de passe (mode 0600) | +| `~/.erplibre/mail//cache.db` | le cache SQLite de ce compte (mode 0600, dossier parent 0700) | +| `~/.erplibre/mail///.eml` (ou `.eml.enc` s'il est scellé) | un fichier par corps de message téléchargé | +| `/dev/shm/erplibre-mail-//` | le cache d'un compte `ephemeral` pendant que le processus vit ; effacé à sa sortie (un balayage au démarrage nettoie aussi ce qu'un processus tué aurait laissé) | + + +## Troubleshooting + +Error messages raised by the mail package itself (`secrets.py`, +`store.py`, `crypto.py`, `accounts.py`, `smtp_send.py`, +`imap_transport.py`, `imap_sync.py`) now go through the CLI's translation +layer, the same as the menu prompts and TUI labels: running the CLI in +English shows them in English. The wording below is quoted in French, this +document's reference language; expect the matching English wording when +`EL_LANG=en`. + +**"Connection failed: ..." when adding or testing an account.** +`Mail > [2] Accounts > [5] Test an account connection` prints the server's +exact error and then asks for the password again — up to 3 attempts. The +password in the vault is only overwritten *after* a successful connection, +so a typo never destroys a working password. If the account is Gmail, +Outlook or iCloud, check first that you used an app password (see +"Prerequisites" above), not the account's normal one. Opening the TUI +itself does not retry automatically: an account with a rejected password +gets a ⚠ marker; if it had synced successfully before, its already-cached +folders stay visible and readable, they just stop refreshing — only a +brand-new account (nothing synced yet) shows no folders at all. Either +way, go run "Test an account connection" to fix it. + +**"le fichier kdbx n'a pas pu être ouvert" when adding an account.** +The shared KDBX vault isn't configured yet, its file picker was cancelled, +or the CLI is running without a display to show that picker. Set +`kdbx.path` (and `kdbx.password`) as described in "Adding an account" +above, then try again. + +**"le trousseau du système écrirait le mot de passe en clair (backend +...)".** +`keyring`'s active backend isn't one of the ones known to actually +encrypt — this happens over SSH, in a container, or on a machine with no +desktop session, where `keyring` silently falls back to a plaintext file +store. The client refuses rather than pretend that's safe. Use the KDBX +vault instead (see above), or run somewhere a real keyring is unlocked. + +**"Install textual for the mail client (pip)."** +`textual` isn't installed. `Mail > [1] Open the mail client (TUI)` just +prints this and returns; every other menu entry (accounts, sync, cache) +still works without it. + +**The folder cache says it changed (`UIDVALIDITY`).** +Nothing to do — the client purges and resyncs that folder by itself the +next time it syncs. Expect the message list to empty briefly and refill. + +**"cache illisible, purgez-le et resynchronisez : ...".** +The account's `cache.db` is corrupt. `Mail > [4] Cache > [3] Cache size and +purge` may itself fail to open the same broken file; if so, delete the +account's cache directory by hand and resync: + + +## Dépannage + +Les messages d'erreur qui viennent du paquet courriel lui-même +(`secrets.py`, `store.py`, `crypto.py`, `accounts.py`, `smtp_send.py`, +`imap_transport.py`, `imap_sync.py`) passent maintenant par la couche de +traduction du CLI, comme les invites de menu et les libellés du TUI : +lancer le CLI en anglais les affiche en anglais. Le libellé ci-dessous est +cité en français, la langue de référence de ce document ; attendez-vous au +libellé anglais correspondant avec `EL_LANG=en`. + +**« Connexion échouée : ... » en ajoutant ou en testant un compte.** +`Courriel > [2] Comptes > [5] Tester la connexion d'un compte` affiche +l'erreur exacte du serveur puis redemande le mot de passe — jusqu'à 3 +tentatives. Le mot de passe dans le coffre n'est écrasé qu'*après* une +connexion réussie, donc une faute de frappe ne détruit jamais un mot de +passe qui fonctionnait. Si le compte est Gmail, Outlook ou iCloud, +vérifiez d'abord que vous avez utilisé un mot de passe d'application (voir +« Prérequis » plus haut), pas le mot de passe normal du compte. Ouvrir le +TUI lui-même ne relance pas cette demande automatiquement : un compte au +mot de passe refusé porte un ⚠ ; s'il avait déjà synchronisé avec succès, +ses dossiers déjà en cache restent visibles et lisibles, ils cessent +seulement de se rafraîchir — seul un compte tout neuf (rien de +synchronisé encore) n'affiche aucun dossier du tout. Dans tous les cas, +passez par « Tester la connexion d'un compte » pour corriger. + +**« le fichier kdbx n'a pas pu être ouvert » en ajoutant un compte.** +Le coffre KDBX partagé n'est pas encore configuré, sa fenêtre de sélection +de fichier a été annulée, ou le CLI tourne sans affichage pour la montrer. +Réglez `kdbx.path` (et `kdbx.password`) comme décrit dans « Ajouter un +compte » plus haut, puis réessayez. + +**« le trousseau du système écrirait le mot de passe en clair (backend +...) ».** +Le backend actif de `keyring` n'est pas de ceux qu'on sait vraiment +chiffrer — ça arrive en SSH, dans un conteneur, ou sur une machine sans +session graphique, où `keyring` retombe silencieusement sur un fichier en +clair. Le client refuse plutôt que de faire semblant que c'est sûr. +Utilisez le coffre KDBX à la place (voir plus haut), ou lancez-le là où un +vrai trousseau est déverrouillé. + +**« Installez textual pour le client courriel (pip). »** +`textual` n'est pas installé. `Courriel > [1] Ouvrir le client courriel +(TUI)` affiche seulement ce message et revient au menu ; tout le reste +(comptes, synchronisation, cache) fonctionne quand même sans lui. + +**Le cache d'un dossier signale qu'il a changé (`UIDVALIDITY`).** +Rien à faire — le client purge et resynchronise ce dossier tout seul à la +prochaine synchronisation. La liste des messages se vide puis se remplit +brièvement. + +**« cache illisible, purgez-le et resynchronisez : ... ».** +Le `cache.db` du compte est corrompu. `Courriel > [4] Cache > [3] Taille +du cache et purge` peut lui-même échouer à ouvrir ce même fichier cassé ; +le cas échéant, effacez à la main le dossier de cache du compte et +resynchronisez : + + +```bash +rm -rf ~/.erplibre/mail// +``` + + +## Testing against a real server + +Almost every mail test uses an in-memory double. A double only produces what +its author imagined, which is how three protocol bugs reached users. So there +is also a **sandbox**: a real IMAP server (Twisted) and a real SMTP server +(aiosmtpd) that a test starts on an ephemeral loopback port, talks to over +real TCP, and kills when it finishes — pass or fail. + +The point is not conformance. A well-behaved server proves little; this one +can **misbehave on purpose**. A test declares the exact bytes a message is +made of — raw 8-bit header bytes, an `unknown-8bit` charset — and can drop the +connection or refuse a command mid-sync. Adding a new hostile behaviour is a +small subclass in `test/mail_sandbox.py`, not a new server. + +These tests do **not** run in the fast loop. Without `twisted` and `aiosmtpd` +the whole file skips visibly. Run them deliberately: + + +## Tester contre un vrai serveur + +Presque tous les tests courriel passent par un double en mémoire. Un double ne +produit que ce que son auteur avait imaginé — c'est par là que trois bugs de +protocole sont arrivés jusqu'aux utilisateurs. D'où un **bac à sable** : un +vrai serveur IMAP (Twisted) et un vrai serveur SMTP (aiosmtpd), qu'un test +démarre sur un port éphémère de la boucle locale, à qui il parle en vrai TCP, +et qu'il tue en terminant — qu'il réussisse ou qu'il échoue. + +Le but n'est pas la conformité. Un serveur poli ne prouve pas grand-chose ; +celui-ci sait **se conduire mal exprès**. Un test déclare les octets exacts +d'un message — en-tête en 8 bits bruts, charset `unknown-8bit` — et peut +couper la connexion ou refuser une commande en pleine synchronisation. +Ajouter une nouvelle méchanceté est une petite sous-classe dans +`test/mail_sandbox.py`, pas un nouveau serveur. + +Ces tests ne tournent **pas** dans la boucle rapide. Sans `twisted` ni +`aiosmtpd`, tout le fichier se saute visiblement. Pour les lancer +volontairement : + + +```bash +.venv.erplibre/bin/python -m unittest discover -s test \ + -p test_mail_live_server.py -v +``` + + +What it does **not** cover, and will not pretend to: + +- **`SPECIAL-USE`** — Twisted announces only `IMAP4REV1 NAMESPACE IDLE`. The + bug where a sent message was filed under a guessed folder name instead of + the one the server announced is therefore out of reach. Implementing the + extension in the sandbox would only test our own assumption about it, which + is the exact failure this sandbox exists to escape. +- **No provider quirk** — Gmail's label-as-folder model, Microsoft's OAuth, + Apple app passwords: none of it is exercised. The sandbox is a plain + RFC 3501 server, not a stand-in for a specific provider. +- **No TLS** — the sandbox talks in the clear on `127.0.0.1`. `starttls` and + `ssl` code paths are not exercised here. +- **Nothing leaves the machine** — no external host, no OS keyring, no + `~/.erplibre`, no real credentials, and never a fixed port. + + +Ce qu'il ne couvre **pas**, et ne fera pas semblant de couvrir : + +- **`SPECIAL-USE`** — Twisted n'annonce que `IMAP4REV1 NAMESPACE IDLE`. Le bug + du message classé sous un nom de dossier deviné plutôt que sous celui + annoncé par le serveur reste donc hors de portée. Implémenter l'extension + dans le bac à sable ne testerait que notre propre supposition à son sujet — + précisément l'erreur que ce bac à sable existe pour éviter. +- **Aucune particularité de fournisseur** — les dossiers-étiquettes de Gmail, + OAuth chez Microsoft, les mots de passe d'application d'Apple : rien de tout + cela n'est exercé. Le bac à sable est un serveur RFC 3501 ordinaire, pas la + doublure d'un fournisseur précis. +- **Pas de TLS** — le bac à sable parle en clair sur `127.0.0.1`. Les chemins + `starttls` et `ssl` ne sont pas exercés ici. +- **Rien ne quitte la machine** — aucun hôte externe, aucun trousseau système, + aucun `~/.erplibre`, aucun identifiant réel, et jamais un port fixe. + + +## Phase 1 limits + +- **No OAuth** — Gmail, Outlook and iCloud need an app password (see + above); OAuth is phase 2. +- **No statistics** — no read/unread counters or activity dashboards beyond + the per-folder unseen count shown in the folder tree. +- **No server-side search** — `/` filters only what's already synced to the + local cache. +- **No offline outbox** — sending requires the account to be online; there + is no queue that flushes once you're back online. + +See the [design spec](../docs/superpowers/specs/2026-08-02-email-tui-design.md) +for what the following phases add. + + +## Limites de la phase 1 + +- **Pas d'OAuth** — Gmail, Outlook et iCloud demandent un mot de passe + d'application (voir plus haut) ; OAuth arrive en phase 2. +- **Pas de statistiques** — aucun compteur lu/non lu global ni tableau de + bord d'activité, au-delà du compte de non-lus par dossier affiché dans + l'arbre. +- **Pas de recherche côté serveur** — `/` ne filtre que ce qui est déjà + synchronisé dans le cache local. +- **Pas de file d'attente hors ligne** — l'envoi exige que le compte soit + en ligne ; rien ne se met en attente pour partir au retour du réseau. + +Voir le [spec de conception](../docs/superpowers/specs/2026-08-02-email-tui-design.md) +pour ce qu'apportent les phases suivantes. diff --git a/doc/EMAIL.fr.md b/doc/EMAIL.fr.md new file mode 100644 index 0000000..c4a4c14 --- /dev/null +++ b/doc/EMAIL.fr.md @@ -0,0 +1,340 @@ + +# Client courriel + +Un client courriel intégré au CLI TODO : plusieurs comptes, IMAP + SMTP, et +un cache local — pour lire et répondre à son courriel sans quitter +`./script/todo/todo.py`. + +Chaque chemin `Courriel > ...` ci-dessous est un raccourci pour +`TODO > [3] Assistant > [2] Courriel - Lire et envoyer du courriel > ...` — le +chemin complet est écrit une fois, dans « Ajouter un compte ». + +## Prérequis + +Quatre paquets Python, déjà listés dans +`requirement/erplibre_require-ments.txt` (l'environnement `.venv.erplibre`, +pas un venv Odoo) : + +- `cryptography` — scelle le cache local en mode `encrypted` et `ephemeral`. +- `keyring` — le trousseau système, l'un des deux endroits où peut vivre un + mot de passe. +- `pykeepass` — le coffre KDBX, l'autre endroit, celui que le client essaie + en premier. +- `textual` — l'interface terminal elle-même. Sans lui, « Ouvrir le client + courriel (TUI) » affiche un message et ne fait rien ; le reste du menu + (comptes, synchronisation, cache) fonctionne quand même. + +Installez-les avec : + +```bash +.venv.erplibre/bin/pip install -r requirement/erplibre_require-ments.txt +``` + +### Mots de passe d'application pour Gmail, Outlook et iCloud + +La phase 1 ne parle qu'IMAP/SMTP en authentification simple — pas encore +OAuth (ça, c'est la phase 2). Gmail, Outlook et iCloud ont tous les trois +fermé cette porte au vrai mot de passe du compte : chacun de ces trois +préréglages exige donc un **mot de passe d'application** à la place : + +| Fournisseur | Où le générer | +|---|---| +| Gmail | Activez la validation en deux étapes, puis [myaccount.google.com](https://myaccount.google.com/security) > Sécurité > Mots de passe des applications | +| Outlook / Microsoft 365 | [account.microsoft.com](https://account.microsoft.com/security) > Sécurité > Options de sécurité avancées > Mots de passe d'application | +| iCloud | [account.apple.com](https://account.apple.com/) > Connexion et sécurité > Mots de passe spécifiques aux applications | + +Utilisez ce mot de passe généré quand la configuration du compte en demande +un — jamais le mot de passe normal du compte. Le préréglage « Serveur +standard » (IMAP/SMTP générique) n'en a pas besoin. + +## Ajouter un compte + +Chemin de menu : `TODO > [3] Assistant > [2] Courriel - Lire et envoyer du +courriel > [2] Comptes > [2] Ajouter un compte`. + +Les questions, dans l'ordre : + +1. **Nom court du compte** — devient à la fois le nom de dossier sous + `~/.erplibre/mail/` et la référence dans le coffre : il ne peut donc pas + contenir `/` ni commencer par un point. +2. **Adresse courriel**. +3. **Nom affiché** (facultatif) — apparaît dans l'en-tête `De :` comme + `Nom affiché `. +4. **Fournisseur** — un numéro dans la liste affichée : Gmail, Outlook, + iCloud, ou « Serveur standard » (IMAP/SMTP générique). +5. Si vous choisissez « Serveur standard », le **serveur IMAP** puis le + **serveur SMTP** sont demandés ensuite ; les autres préréglages les + remplissent déjà pour vous. +6. Si le préréglage exige un mot de passe d'application, sa note s'affiche + ici en rappel. +7. **Mot de passe** — saisi masqué (`getpass`), puis rangé dans le coffre — + jamais écrit dans `accounts.json`. + +Où va le mot de passe : à l'étape du mot de passe, le client passe par le +**gestionnaire KDBX** partagé du CLI — le même que pour la clé OpenAI et +les identifiants Odoo. Il lit `kdbx.path` / `kdbx.password` dans la +configuration TODO (`script/todo/todo.json`, surchargeable dans +`private/todo/todo_override.json` / `private/todo/todo_override_private.json`). +Si `kdbx.path` n'est pas encore réglé, une fenêtre de sélection de fichier +s'ouvre pour choisir un `.kdbx` existant — il faut un affichage graphique, +et l'annuler (ou lancer le CLI sans affichage) fait échouer la création du +compte avec « le fichier kdbx n'a pas pu être ouvert » (voir Dépannage). +**Réglez `kdbx.path` (et `kdbx.password`, pour éviter l'invite) avant +d'ajouter votre premier compte**, en pointant vers un coffre `.kdbx` que +vous avez déjà (créez-en un avec KeePassXC ou équivalent). Le trousseau +système ne sert que pour un compte dont la `secret_ref` le désigne déjà — +le menu écrit toujours les nouveaux comptes dans le coffre KDBX. + +`accounts.json` (dans `~/.erplibre/mail/accounts.json`) ne contient jamais +qu'une `secret_ref` du genre `kdbx:ERPLibre/Mail/perso` — une référence, +jamais le secret. Il est sans danger à lire, à éditer à la main, ou à +mettre dans une sauvegarde privée. + +## Les trois modes de cache + +Chaque compte garde un cache local — une petite base SQLite plus un fichier +par message téléchargé — pour que la boîte de réception reste lisible hors +ligne. Trois modes contrôlent ce que ce cache laisse sur le disque : + +| Mode | Ce qui reste sur le disque | Clé de chiffrement | +|---|---|---| +| `clear` (par défaut) | `~/.erplibre/mail//cache.db` et les fichiers `.eml`, lisibles en clair | aucune | +| `encrypted` | même emplacement, mais l'expéditeur, les destinataires, le sujet, l'extrait, le Message-ID et le corps des messages sont scellés en AES-256-GCM | générée une fois, rangée dans le coffre à côté du mot de passe (`.../cache-key`) | +| `ephemeral` | sous `/dev/shm/erplibre-mail-//` (ou le dossier temporaire système si `/dev/shm` n'est pas inscriptible), scellé comme `encrypted` | tirée en RAM à chaque lancement, jamais écrite nulle part, et tout le dossier est effacé à la fermeture de la session | + +Même en mode `clear`, les champs techniques dont le SQL a besoin pour trier +et filtrer — UID, dossier, date, drapeaux, taille — restent toujours en +clair ; seuls les champs qui identifient des personnes (et le corps du +message) sont scellés, et seulement en `encrypted`/`ephemeral`. + +Réglez le **défaut général** dans `Courriel > [4] Cache > [1] Mode de cache +par défaut` ; c'est la préférence `mail_cache_mode` (défaut `clear`). +**Surchargez-le par compte** dans `Courriel > [4] Cache > [2] Mode de cache +d'un compte` — ceci écrit le champ `cache_mode` du compte dans +`accounts.json` ; le laisser à `null` là-bas veut dire « hérite du défaut +général ». + +`Courriel > [4] Cache > [3] Taille du cache et purge` liste le mode +effectif et l'espace disque de chaque compte, et peut effacer entièrement le +cache d'un compte (après confirmation) — la prochaine synchronisation le +reconstruit à partir de zéro. + +## Le TUI + +`Courriel > [1] Ouvrir le client courriel (TUI)` ouvre un écran en trois +volets : l'arbre comptes/dossiers à gauche, la liste des messages au +centre, et un aperçu à droite, avec une ligne de statut en bas. + +| Touche | Action | +|---|---| +| `↑` `↓` `Tab` | se déplacer dans un volet / changer de volet (comportement par défaut de Textual) | +| `h` | ouvre la fenêtre d'aide : tous les raccourcis et quelques repères, fermée par `Échap` | +| `z` | plein écran sur l'aperçu (masque l'arbre et la liste) | +| `Échap` | quitter le plein écran | +| `v` | change de disposition : colonnes, partagée, empilée | +| `+` / `-` | agrandir / rétrécir le volet qui a le focus | +| `0` | revenir aux tailles de volets par défaut | +| `r` | synchronise le compte du dossier actuellement sélectionné (tous ses dossiers) | +| `Shift+R` | synchronise tous les comptes | +| `/` | ouvre le champ de recherche (filtre seulement la liste déjà affichée — localement, sur sujet/de/à/extrait ; ne cherche pas sur le serveur) | +| `s` / `u` | marquer le message sélectionné lu / non lu | +| `c` | écrire un nouveau message | +| `a` / `Shift+A` | répondre / répondre à tous | +| `f` | transférer | +| `w` | enregistrer la **première** pièce jointe du message dans `~/Téléchargements` (créé s'il n'existe pas) | +| `n` | ajouter un compte sans quitter le client | +| `l` | affiche la fin de `~/.erplibre/mail.log` et les erreurs de synchronisation de la session | +| `q` | quitter | + +Ce tableau est écrit à la main et peut prendre du retard sur le code ; la +fenêtre `h`, elle, ne le peut pas : elle construit sa liste depuis les +liaisons de l'application à chaque ouverture. En cas de désaccord entre les +deux, c'est elle qui a raison. + +Les barres entre les volets se glissent aussi à la souris, et les tailles +sont retenues par disposition. + +Les indices de touches du pied d'écran, comme la fenêtre d'aide, suivent la +langue choisie dans le CLI, tout comme l'arbre des comptes, la liste et le +texte d'aperçu. + +## Écrire un message + +`c` ouvre le formulaire : `À`, `Cc`, `Objet`, un champ `Pièces jointes` +(chemins de fichiers séparés par un point-virgule — une virgule est légale +dans un nom de fichier, donc seul `;` sépare les entrées ; il n'y a pas de +sélecteur de fichier, tapez les chemins), et un corps multi-lignes. +`e` envoie le corps vers `$EDITOR` (ou `nano` si non défini) et le relit ; +si l'éditeur manque ou sort en erreur, le texte de départ est conservé tel +quel. `Ctrl+S` (ou le bouton Envoyer) remet le message ; `Échap` abandonne +le brouillon — il n'y a pas d'enregistrement en brouillon. + +`a` (répondre) et `Shift+A` (répondre à tous) préremplissent `À`/`Cc`/ +`Objet`/`In-Reply-To`/`References` et citent le message d'origine dans le +corps. `f` (transférer) préremplit l'objet en `Fwd:` et **rattache le +message d'origine** automatiquement, en pièce jointe `message/rfc822` ; le +corps, lui, part vide — écrivez votre propre mot au-dessus du message +joint. + +Répondre, répondre à tous et transférer ont tous besoin du corps du message +d'origine — depuis le cache, ou récupéré en direct si le compte est en +ligne ; sans l'un ou l'autre, vous obtenez « Aucun message sélectionné. » / +« Aucun message à transférer. ». + +Envoyer exige que le compte soit en ligne (écrire hors ligne échoue avec +« Compte hors ligne : envoi impossible. » — il n'y a pas de file d'attente +hors ligne). Une fois envoyé, une copie est classée dans le dossier +Envoyés du compte par IMAP ; si ce classement échoue, la ligne de statut le +dit, mais le message est déjà parti — il n'est pas renvoyé. + +## Synchronisation + +Une passe de synchronisation est incrémentale : seuls les UID supérieurs au +dernier connu sont demandés, le corps des messages n'est jamais téléchargé +pendant une passe (seulement les en-têtes), et les corps sont récupérés à +la demande à l'ouverture d'un message. Les drapeaux (lu/non lu, etc.) des +messages déjà connus sont revérifiés à chaque passe, donc un message lu +ailleurs apparaît correctement lu ici aussi. + +La synchronisation a lieu : + +- **Au lancement** — ouvrir le TUI déclenche une synchronisation de tous + les comptes en arrière-plan. +- **À la demande** — `r` (compte courant) / `Shift+R` (tous les comptes) + dans le TUI, ou `Courriel > [3] Synchroniser maintenant` depuis le menu + CLI (affiche un résumé par compte dans le terminal). +- **Automatiquement, toutes les `mail_refresh_sec` secondes** (défaut 300 = + 5 minutes ; 0 la désactive) — **mais seulement tant que le TUI est + ouvert**. Fermez-le et la minuterie part avec lui ; rien ne se + synchronise en arrière-plan ensuite. + +Si le serveur annonce un `UIDVALIDITY` changé pour un dossier (ses UID ne +veulent plus dire ce qu'ils disaient — typiquement après une migration +côté serveur), le cache de ce dossier est purgé et resynchronisé à partir +de zéro automatiquement ; il n'y a actuellement aucun avis à l'écran +au-delà du dossier qui se vide puis se remplit à nouveau brièvement. + +## Où sont les fichiers + +| Chemin | Contenu | +|---|---| +| `~/.erplibre/mail/accounts.json` | la liste des comptes — serveurs, préréglages, mode de cache, et une référence `secret_ref` ; jamais un mot de passe (mode 0600) | +| `~/.erplibre/mail//cache.db` | le cache SQLite de ce compte (mode 0600, dossier parent 0700) | +| `~/.erplibre/mail///.eml` (ou `.eml.enc` s'il est scellé) | un fichier par corps de message téléchargé | +| `/dev/shm/erplibre-mail-//` | le cache d'un compte `ephemeral` pendant que le processus vit ; effacé à sa sortie (un balayage au démarrage nettoie aussi ce qu'un processus tué aurait laissé) | + +## Dépannage + +Les messages d'erreur qui viennent du paquet courriel lui-même +(`secrets.py`, `store.py`, `crypto.py`, `accounts.py`, `smtp_send.py`, +`imap_transport.py`, `imap_sync.py`) passent maintenant par la couche de +traduction du CLI, comme les invites de menu et les libellés du TUI : +lancer le CLI en anglais les affiche en anglais. Le libellé ci-dessous est +cité en français, la langue de référence de ce document ; attendez-vous au +libellé anglais correspondant avec `EL_LANG=en`. + +**« Connexion échouée : ... » en ajoutant ou en testant un compte.** +`Courriel > [2] Comptes > [5] Tester la connexion d'un compte` affiche +l'erreur exacte du serveur puis redemande le mot de passe — jusqu'à 3 +tentatives. Le mot de passe dans le coffre n'est écrasé qu'*après* une +connexion réussie, donc une faute de frappe ne détruit jamais un mot de +passe qui fonctionnait. Si le compte est Gmail, Outlook ou iCloud, +vérifiez d'abord que vous avez utilisé un mot de passe d'application (voir +« Prérequis » plus haut), pas le mot de passe normal du compte. Ouvrir le +TUI lui-même ne relance pas cette demande automatiquement : un compte au +mot de passe refusé porte un ⚠ ; s'il avait déjà synchronisé avec succès, +ses dossiers déjà en cache restent visibles et lisibles, ils cessent +seulement de se rafraîchir — seul un compte tout neuf (rien de +synchronisé encore) n'affiche aucun dossier du tout. Dans tous les cas, +passez par « Tester la connexion d'un compte » pour corriger. + +**« le fichier kdbx n'a pas pu être ouvert » en ajoutant un compte.** +Le coffre KDBX partagé n'est pas encore configuré, sa fenêtre de sélection +de fichier a été annulée, ou le CLI tourne sans affichage pour la montrer. +Réglez `kdbx.path` (et `kdbx.password`) comme décrit dans « Ajouter un +compte » plus haut, puis réessayez. + +**« le trousseau du système écrirait le mot de passe en clair (backend +...) ».** +Le backend actif de `keyring` n'est pas de ceux qu'on sait vraiment +chiffrer — ça arrive en SSH, dans un conteneur, ou sur une machine sans +session graphique, où `keyring` retombe silencieusement sur un fichier en +clair. Le client refuse plutôt que de faire semblant que c'est sûr. +Utilisez le coffre KDBX à la place (voir plus haut), ou lancez-le là où un +vrai trousseau est déverrouillé. + +**« Installez textual pour le client courriel (pip). »** +`textual` n'est pas installé. `Courriel > [1] Ouvrir le client courriel +(TUI)` affiche seulement ce message et revient au menu ; tout le reste +(comptes, synchronisation, cache) fonctionne quand même sans lui. + +**Le cache d'un dossier signale qu'il a changé (`UIDVALIDITY`).** +Rien à faire — le client purge et resynchronise ce dossier tout seul à la +prochaine synchronisation. La liste des messages se vide puis se remplit +brièvement. + +**« cache illisible, purgez-le et resynchronisez : ... ».** +Le `cache.db` du compte est corrompu. `Courriel > [4] Cache > [3] Taille +du cache et purge` peut lui-même échouer à ouvrir ce même fichier cassé ; +le cas échéant, effacez à la main le dossier de cache du compte et +resynchronisez : + +```bash +rm -rf ~/.erplibre/mail// +``` + +## Tester contre un vrai serveur + +Presque tous les tests courriel passent par un double en mémoire. Un double ne +produit que ce que son auteur avait imaginé — c'est par là que trois bugs de +protocole sont arrivés jusqu'aux utilisateurs. D'où un **bac à sable** : un +vrai serveur IMAP (Twisted) et un vrai serveur SMTP (aiosmtpd), qu'un test +démarre sur un port éphémère de la boucle locale, à qui il parle en vrai TCP, +et qu'il tue en terminant — qu'il réussisse ou qu'il échoue. + +Le but n'est pas la conformité. Un serveur poli ne prouve pas grand-chose ; +celui-ci sait **se conduire mal exprès**. Un test déclare les octets exacts +d'un message — en-tête en 8 bits bruts, charset `unknown-8bit` — et peut +couper la connexion ou refuser une commande en pleine synchronisation. +Ajouter une nouvelle méchanceté est une petite sous-classe dans +`test/mail_sandbox.py`, pas un nouveau serveur. + +Ces tests ne tournent **pas** dans la boucle rapide. Sans `twisted` ni +`aiosmtpd`, tout le fichier se saute visiblement. Pour les lancer +volontairement : + +```bash +.venv.erplibre/bin/python -m unittest discover -s test \ + -p test_mail_live_server.py -v +``` + +Ce qu'il ne couvre **pas**, et ne fera pas semblant de couvrir : + +- **`SPECIAL-USE`** — Twisted n'annonce que `IMAP4REV1 NAMESPACE IDLE`. Le bug + du message classé sous un nom de dossier deviné plutôt que sous celui + annoncé par le serveur reste donc hors de portée. Implémenter l'extension + dans le bac à sable ne testerait que notre propre supposition à son sujet — + précisément l'erreur que ce bac à sable existe pour éviter. +- **Aucune particularité de fournisseur** — les dossiers-étiquettes de Gmail, + OAuth chez Microsoft, les mots de passe d'application d'Apple : rien de tout + cela n'est exercé. Le bac à sable est un serveur RFC 3501 ordinaire, pas la + doublure d'un fournisseur précis. +- **Pas de TLS** — le bac à sable parle en clair sur `127.0.0.1`. Les chemins + `starttls` et `ssl` ne sont pas exercés ici. +- **Rien ne quitte la machine** — aucun hôte externe, aucun trousseau système, + aucun `~/.erplibre`, aucun identifiant réel, et jamais un port fixe. + +## Limites de la phase 1 + +- **Pas d'OAuth** — Gmail, Outlook et iCloud demandent un mot de passe + d'application (voir plus haut) ; OAuth arrive en phase 2. +- **Pas de statistiques** — aucun compteur lu/non lu global ni tableau de + bord d'activité, au-delà du compte de non-lus par dossier affiché dans + l'arbre. +- **Pas de recherche côté serveur** — `/` ne filtre que ce qui est déjà + synchronisé dans le cache local. +- **Pas de file d'attente hors ligne** — l'envoi exige que le compte soit + en ligne ; rien ne se met en attente pour partir au retour du réseau. + +Voir le [spec de conception](../docs/superpowers/specs/2026-08-02-email-tui-design.md) +pour ce qu'apportent les phases suivantes. \ No newline at end of file diff --git a/doc/EMAIL.md b/doc/EMAIL.md new file mode 100644 index 0000000..cecb420 --- /dev/null +++ b/doc/EMAIL.md @@ -0,0 +1,318 @@ + +# Mail client + +A mail client built into the TODO CLI: several accounts, IMAP + SMTP, and a +local cache — so you can read and answer email without leaving +`./script/todo/todo.py`. + +Every `Mail > ...` path below is shorthand for +`TODO > [3] Assistant > [2] Mail - Read and send email > ...` — the full path +is spelled out once, in "Adding an account". + +## Prerequisites + +Four Python packages, already listed in `requirement/erplibre_require-ments.txt` +(the `.venv.erplibre` environment, not an Odoo venv): + +- `cryptography` — seals the local cache in `encrypted` and `ephemeral` mode. +- `keyring` — the system keyring, one of the two places a password can live. +- `pykeepass` — the KDBX vault, the other place, and the one the client tries + first. +- `textual` — the terminal UI itself. Without it, "Open the mail client + (TUI)" prints a message and does nothing; the rest of the menu (accounts, + sync, cache) still works. + +Install them with: + +```bash +.venv.erplibre/bin/pip install -r requirement/erplibre_require-ments.txt +``` + +### App passwords for Gmail, Outlook and iCloud + +Phase 1 speaks plain IMAP/SMTP login only — no OAuth yet (that is phase 2). +Gmail, Outlook and iCloud have all closed that door to the account's real +password, so each of these three presets requires an **app password** +instead: + +| Provider | Where to generate it | +|---|---| +| Gmail | Enable 2-step verification, then [myaccount.google.com](https://myaccount.google.com/security) > Security > App passwords | +| Outlook / Microsoft 365 | [account.microsoft.com](https://account.microsoft.com/security) > Security > Advanced security options > App passwords | +| iCloud | [account.apple.com](https://account.apple.com/) > Sign-In and Security > App-Specific Passwords | + +Use that generated password when account setup asks for one — never the +account's normal password. The "Standard server" preset (generic IMAP/SMTP) +does not need one. + +## Adding an account + +Menu path: `TODO > [3] Assistant > [2] Mail - Read and send email > [2] +Accounts > [2] Add an account`. + +The prompts, in order: + +1. **Short account name** — becomes both the folder name under + `~/.erplibre/mail/` and the vault reference, so it cannot contain `/` or + start with a dot. +2. **Email address**. +3. **Display name** (optional) — shown in the `From:` header as + `Display Name `. +4. **Provider** — a number from the printed list: Gmail, Outlook, iCloud, or + "Standard server" (generic IMAP/SMTP). +5. If you picked "Standard server", the **IMAP host** and **SMTP host** are + asked next; the other presets fill these in for you. +6. If the preset requires an app password, its note is printed here as a + reminder. +7. **Password** — typed hidden (`getpass`), then stored — never written to + `accounts.json`. + +Where the password goes: at the password step, the client hands off to the +CLI's shared **KDBX manager** — the same one already used for the OpenAI +key and Odoo credentials. It reads `kdbx.path` / `kdbx.password` from the +TODO config (`script/todo/todo.json`, overridable in +`private/todo/todo_override.json` / `private/todo/todo_override_private.json`). +If `kdbx.path` isn't set yet, a graphical file picker pops up asking you to +choose an existing `.kdbx` file — it needs a display, and cancelling it (or +running headless) fails account creation with "le fichier kdbx n'a pas pu +être ouvert" (French — see Troubleshooting). **Set `kdbx.path` (and +`kdbx.password`, to skip the prompt) before adding your first account**, +pointing at a `.kdbx` vault you already have (create one with KeePassXC or +similar). The system keyring is only ever used for an account whose +`secret_ref` already points at one — the menu itself always writes new +accounts into the KDBX vault. + +`accounts.json` (at `~/.erplibre/mail/accounts.json`) only ever holds a +`secret_ref` such as `kdbx:ERPLibre/Mail/perso` — a pointer, never the +secret. It is safe to read, edit by hand, or check into a private backup. + +## The three cache modes + +Every account keeps a local cache — a small SQLite database plus one file +per downloaded message — so the inbox stays readable offline. Three modes +control what that cache leaves on disk: + +| Mode | What's on disk | Encryption key | +|---|---|---| +| `clear` (default) | `~/.erplibre/mail//cache.db` and `.eml` files, readable as plain text | none | +| `encrypted` | same location, but sender, recipients, subject, snippet, message-id and message bodies are sealed with AES-256-GCM | generated once, stored in the vault next to the password (`.../cache-key`) | +| `ephemeral` | under `/dev/shm/erplibre-mail-//` (or the system temp dir if `/dev/shm` isn't writable), sealed the same way as `encrypted` | generated fresh in RAM at every run, never written anywhere, and the whole directory is removed when the session closes | + +Even in `clear` mode, the technical fields the SQL needs to sort and +filter — UID, folder, date, flags, size — are always plain; only the +person-identifying fields (and the message body) are ever sealed, and only +in `encrypted`/`ephemeral`. + +Set the **general default** at `Mail > [4] Cache > [1] Default cache mode`; +it is the `mail_cache_mode` preference (default `clear`). **Override it per +account** at `Mail > [4] Cache > [2] Cache mode of one account` — this +writes the account's `cache_mode` field in `accounts.json`; leaving it at +`null` there means "inherit the general default." + +`Mail > [4] Cache > [3] Cache size and purge` lists every account's +effective mode and disk usage, and can erase one account's cache entirely +(after confirmation) — the next sync rebuilds it from scratch. + +## The TUI + +`Mail > [1] Open the mail client (TUI)` opens a three-pane screen: an +account/folder tree on the left, the message list in the middle, and a +preview pane on the right, with a status line at the bottom. + +| Key | Action | +|---|---| +| `↑` `↓` `Tab` | move within a pane / move focus between panes (Textual defaults) | +| `h` | open the help window: every shortcut plus a few notes, closed with `Escape` | +| `z` | toggle full-screen preview (hides the folder tree and the message list) | +| `Escape` | leave full-screen | +| `v` | cycle the layout: columns, split, stacked | +| `+` / `-` | grow / shrink the pane that has focus | +| `0` | back to the default pane sizes | +| `r` | sync the account of the currently selected folder (all its folders) | +| `Shift+R` | sync every account | +| `/` | open the search field (filters the currently visible list only — locally, over subject/from/to/snippet; it does not search the server) | +| `s` / `u` | mark the selected message seen / unseen | +| `c` | compose a new message | +| `a` / `Shift+A` | reply / reply all | +| `f` | forward | +| `w` | save the message's **first** attachment to `~/Téléchargements` (created if missing) | +| `n` | add an account without leaving the client | +| `l` | show the tail of `~/.erplibre/mail.log` and this session's sync errors | +| `q` | quit | + +This table is written by hand and can fall behind the code; the `h` window +cannot. It builds its list from the application's own key bindings every time +it opens, so it is the reference if the two ever disagree. + +The bars between the panes can also be dragged with the mouse, and pane sizes +are remembered per layout. + +The footer's key hints, like the help window, follow the CLI's chosen +language, as do the account tree, the message list and the preview text. + +## Writing a message + +`c` opens the compose form: `To`, `Cc`, `Subject`, an `Attachments` field +(semicolon-separated file paths — a comma is legal in a filename, so only +`;` splits entries; there is no file picker, type the paths), and a +multi-line body. `e` sends the body out to `$EDITOR` (or +`nano` if unset) and reads it back; if the editor is missing or exits with +an error, the body you had is kept untouched. `Ctrl+S` (or the Send button) +delivers the message; `Escape` discards the draft — there is no +save-as-draft. + +`a` (reply) and `Shift+A` (reply all) prefill `To`/`Cc`/`Subject`/ +`In-Reply-To`/`References` and quote the original message in the body. `f` +(forward) prefills the `Fwd:` subject and **attaches the original message** +automatically, as a `message/rfc822` attachment; the body itself starts +empty — write your own note above the attached original. + +Reply, reply-all and forward all need the original message's body +available — from the cache, or fetched live if the account is online; with +neither, you get "No message selected." / "No message to forward." + +Sending requires the account to be online (composing offline fails with +"Account offline: cannot send." — there is no offline outbox). Once sent, a +copy is filed into the account's Sent folder over IMAP; if that filing step +fails, the status line says so, but the message has already left — it is +not resent. + +## Synchronization + +A sync pass is incremental: only UIDs above the last known one are +fetched, message bodies are never downloaded during a pass (only headers), +and bodies are fetched on demand when you open a message. Flags +(read/unread, etc.) of already-known messages are re-checked on every +pass, so a message read elsewhere shows up correctly here too. + +Sync happens: + +- **At launch** — opening the TUI kicks off one background sync of every + account. +- **On demand** — `r` (current account) / `Shift+R` (all accounts) inside + the TUI, or `Mail > [3] Synchronise now` from the CLI menu (prints a + per-account summary to the terminal). +- **Automatically, every `mail_refresh_sec` seconds** (default 300 = 5 + minutes; 0 disables it) — **but only while the TUI is open**. Close it + and the timer goes with it; nothing syncs in the background afterward. + +If the server reports a changed `UIDVALIDITY` for a folder (its UIDs no +longer mean what they used to — typically after a server-side migration), +that folder's cache is purged and resynced from scratch automatically; +there is currently no on-screen notice when this happens beyond the folder +briefly emptying and refilling. + +## Where the files live + +| Path | Contents | +|---|---| +| `~/.erplibre/mail/accounts.json` | account list — servers, presets, cache mode, and a `secret_ref` pointer; never a password (mode 0600) | +| `~/.erplibre/mail//cache.db` | that account's SQLite cache (mode 0600, parent directory 0700) | +| `~/.erplibre/mail///.eml` (or `.eml.enc` when sealed) | one file per downloaded message body | +| `/dev/shm/erplibre-mail-//` | an `ephemeral` account's cache while the process is alive; removed when it exits (a sweep at every startup also clears directories left behind by a killed process) | + +## Troubleshooting + +Error messages raised by the mail package itself (`secrets.py`, +`store.py`, `crypto.py`, `accounts.py`, `smtp_send.py`, +`imap_transport.py`, `imap_sync.py`) now go through the CLI's translation +layer, the same as the menu prompts and TUI labels: running the CLI in +English shows them in English. The wording below is quoted in French, this +document's reference language; expect the matching English wording when +`EL_LANG=en`. + +**"Connection failed: ..." when adding or testing an account.** +`Mail > [2] Accounts > [5] Test an account connection` prints the server's +exact error and then asks for the password again — up to 3 attempts. The +password in the vault is only overwritten *after* a successful connection, +so a typo never destroys a working password. If the account is Gmail, +Outlook or iCloud, check first that you used an app password (see +"Prerequisites" above), not the account's normal one. Opening the TUI +itself does not retry automatically: an account with a rejected password +gets a ⚠ marker; if it had synced successfully before, its already-cached +folders stay visible and readable, they just stop refreshing — only a +brand-new account (nothing synced yet) shows no folders at all. Either +way, go run "Test an account connection" to fix it. + +**"le fichier kdbx n'a pas pu être ouvert" when adding an account.** +The shared KDBX vault isn't configured yet, its file picker was cancelled, +or the CLI is running without a display to show that picker. Set +`kdbx.path` (and `kdbx.password`) as described in "Adding an account" +above, then try again. + +**"le trousseau du système écrirait le mot de passe en clair (backend +...)".** +`keyring`'s active backend isn't one of the ones known to actually +encrypt — this happens over SSH, in a container, or on a machine with no +desktop session, where `keyring` silently falls back to a plaintext file +store. The client refuses rather than pretend that's safe. Use the KDBX +vault instead (see above), or run somewhere a real keyring is unlocked. + +**"Install textual for the mail client (pip)."** +`textual` isn't installed. `Mail > [1] Open the mail client (TUI)` just +prints this and returns; every other menu entry (accounts, sync, cache) +still works without it. + +**The folder cache says it changed (`UIDVALIDITY`).** +Nothing to do — the client purges and resyncs that folder by itself the +next time it syncs. Expect the message list to empty briefly and refill. + +**"cache illisible, purgez-le et resynchronisez : ...".** +The account's `cache.db` is corrupt. `Mail > [4] Cache > [3] Cache size and +purge` may itself fail to open the same broken file; if so, delete the +account's cache directory by hand and resync: + +```bash +rm -rf ~/.erplibre/mail// +``` + +## Testing against a real server + +Almost every mail test uses an in-memory double. A double only produces what +its author imagined, which is how three protocol bugs reached users. So there +is also a **sandbox**: a real IMAP server (Twisted) and a real SMTP server +(aiosmtpd) that a test starts on an ephemeral loopback port, talks to over +real TCP, and kills when it finishes — pass or fail. + +The point is not conformance. A well-behaved server proves little; this one +can **misbehave on purpose**. A test declares the exact bytes a message is +made of — raw 8-bit header bytes, an `unknown-8bit` charset — and can drop the +connection or refuse a command mid-sync. Adding a new hostile behaviour is a +small subclass in `test/mail_sandbox.py`, not a new server. + +These tests do **not** run in the fast loop. Without `twisted` and `aiosmtpd` +the whole file skips visibly. Run them deliberately: + +```bash +.venv.erplibre/bin/python -m unittest discover -s test \ + -p test_mail_live_server.py -v +``` + +What it does **not** cover, and will not pretend to: + +- **`SPECIAL-USE`** — Twisted announces only `IMAP4REV1 NAMESPACE IDLE`. The + bug where a sent message was filed under a guessed folder name instead of + the one the server announced is therefore out of reach. Implementing the + extension in the sandbox would only test our own assumption about it, which + is the exact failure this sandbox exists to escape. +- **No provider quirk** — Gmail's label-as-folder model, Microsoft's OAuth, + Apple app passwords: none of it is exercised. The sandbox is a plain + RFC 3501 server, not a stand-in for a specific provider. +- **No TLS** — the sandbox talks in the clear on `127.0.0.1`. `starttls` and + `ssl` code paths are not exercised here. +- **Nothing leaves the machine** — no external host, no OS keyring, no + `~/.erplibre`, no real credentials, and never a fixed port. + +## Phase 1 limits + +- **No OAuth** — Gmail, Outlook and iCloud need an app password (see + above); OAuth is phase 2. +- **No statistics** — no read/unread counters or activity dashboards beyond + the per-folder unseen count shown in the folder tree. +- **No server-side search** — `/` filters only what's already synced to the + local cache. +- **No offline outbox** — sending requires the account to be online; there + is no queue that flushes once you're back online. + +See the [design spec](../docs/superpowers/specs/2026-08-02-email-tui-design.md) +for what the following phases add. diff --git a/doc/TODO.base.md b/doc/TODO.base.md index 2834cb7..ed6885f 100644 --- a/doc/TODO.base.md +++ b/doc/TODO.base.md @@ -33,3 +33,11 @@ TODO: having the DB variable configurable À FAIRE : rendre la variable DB configurable + + +See also: [EMAIL.md](EMAIL.md) — the mail client built into the TODO CLI +(`Assistant > Mail`). + + +Voir aussi : [EMAIL.fr.md](EMAIL.fr.md) — le client courriel intégré au CLI +TODO (`Assistant > Courriel`). diff --git a/doc/TODO.fr.md b/doc/TODO.fr.md index 3b937c3..84b6740 100644 --- a/doc/TODO.fr.md +++ b/doc/TODO.fr.md @@ -11,4 +11,7 @@ Une base de données est installée mais on n'arrive pas à l'exécuter HEALTHCHECK CMD curl --fail http://localhost:8069/web || exit 1 -À FAIRE : rendre la variable DB configurable \ No newline at end of file +À FAIRE : rendre la variable DB configurable + +Voir aussi : [EMAIL.fr.md](EMAIL.fr.md) — le client courriel intégré au CLI +TODO (`Assistant > Courriel`). \ No newline at end of file diff --git a/doc/TODO.md b/doc/TODO.md index 90f0f64..ef2a322 100644 --- a/doc/TODO.md +++ b/doc/TODO.md @@ -12,3 +12,6 @@ A database is installed but cannot be executed HEALTHCHECK CMD curl --fail http://localhost:8069/web || exit 1 TODO: having the DB variable configurable + +See also: [EMAIL.md](EMAIL.md) — the mail client built into the TODO CLI +(`Assistant > Mail`). diff --git a/requirement/erplibre_require-ments.txt b/requirement/erplibre_require-ments.txt index 9016738..b170dc0 100644 --- a/requirement/erplibre_require-ments.txt +++ b/requirement/erplibre_require-ments.txt @@ -17,6 +17,8 @@ uvloop python-randomword-fr isort pykeepass +cryptography +keyring click aioshutil python-magic @@ -52,6 +54,14 @@ virtualenv==20.36.1 git+https://github.com/psf/black.git@24.8.0 pre-commit +# Bac à sable du client courriel : un VRAI serveur SMTP et un VRAI serveur +# IMAP, sur loopback, pour tester ce qu'un faux transport ne peut pas — +# littéraux IMAP, en-têtes 8 bits, coupures en plein FETCH. Utilisés +# UNIQUEMENT par les tests, jamais par le client lui-même : les tests +# concernés s'ignorent d'eux-mêmes si ces paquets manquent. +aiosmtpd +twisted + odoo-module-migrator # Ignore because need installation system diff --git a/script/config/config_file.py b/script/config/config_file.py index 3420974..50ccd9b 100644 --- a/script/config/config_file.py +++ b/script/config/config_file.py @@ -57,6 +57,42 @@ class ConfigFile: config_data = config_data.get(param) return config_data + def set_config_value(self, keys: list[str], value: Any) -> None: + """Écrit `value` sous le chemin `keys` dans + CONFIG_OVERRIDE_PRIVATE_FILE. + + C'est le seul des trois fichiers fusionnés par `get_config` qui + soit gitignored (`git check-ignore` le confirme ; `private/` lui- + même est un dossier versionné, et CONFIG_OVERRIDE_FILE ne l'est + pas) — donc le seul où une valeur personnelle comme `kdbx.path` + peut être écrite sans finir commitée. + + Fusionne avec le contenu existant plutôt que de l'écraser, et + écrit de façon atomique : fichier temporaire créé en 0600 dans le + même dossier, puis `os.replace` (qui hérite du mode de la source). + Le fichier réel n'est donc jamais vu à moitié écrit, et un fichier + déjà présent avec des permissions trop larges se retrouve corrigé. + """ + data: Dict[str, Any] = {} + if os.path.exists(CONFIG_OVERRIDE_PRIVATE_FILE): + with open(CONFIG_OVERRIDE_PRIVATE_FILE) as cfg: + data = json.load(cfg) + + node = data + for key in keys[:-1]: + node = node.setdefault(key, {}) + node[keys[-1]] = value + + parent = os.path.dirname(CONFIG_OVERRIDE_PRIVATE_FILE) or "." + os.makedirs(parent, exist_ok=True) + os.chmod(parent, 0o700) + + tmp_path = f"{CONFIG_OVERRIDE_PRIVATE_FILE}.tmp" + fd = os.open(tmp_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w") as tmp_file: + json.dump(data, tmp_file, indent=2, ensure_ascii=False) + os.replace(tmp_path, CONFIG_OVERRIDE_PRIVATE_FILE) + def get_logo_ascii_file_path(self) -> str: return LOGO_ASCII_FILE diff --git a/script/todo/README.base.md b/script/todo/README.base.md index d2842f2..500f481 100644 --- a/script/todo/README.base.md +++ b/script/todo/README.base.md @@ -9,8 +9,16 @@ Execute it with `./script/todo/todo.py` or `make todo`. For a new project, copy todo_example.json to private/todo/todo_override.json | private/todo/todo_override_private.json and edit it. +The `mail/` package is the mail client reachable from `Assistant > Mail`: +several IMAP/SMTP accounts, a local cache, and a Textual TUI. See +[../../doc/EMAIL.md](../../doc/EMAIL.md). + TODO est un robot assistant pour utiliser ERPLibre Exécutez-le avec `./script/todo/todo.py` ou `make todo`. Pour un nouveau projet, copiez todo_example.json vers private/todo/todo_override.json | private/todo/todo_override_private.json et modifiez-le. + +Le paquet `mail/` est le client courriel accessible depuis +`Assistant > Courriel` : plusieurs comptes IMAP/SMTP, un cache local, et un +TUI Textual. Voir [../../doc/EMAIL.fr.md](../../doc/EMAIL.fr.md). diff --git a/script/todo/README.fr.md b/script/todo/README.fr.md index a26aba8..e21ef79 100644 --- a/script/todo/README.fr.md +++ b/script/todo/README.fr.md @@ -2,4 +2,8 @@ TODO est un robot assistant pour utiliser ERPLibre Exécutez-le avec `./script/todo/todo.py` ou `make todo`. -Pour un nouveau projet, copiez todo_example.json vers private/todo/todo_override.json | private/todo/todo_override_private.json et modifiez-le. \ No newline at end of file +Pour un nouveau projet, copiez todo_example.json vers private/todo/todo_override.json | private/todo/todo_override_private.json et modifiez-le. + +Le paquet `mail/` est le client courriel accessible depuis +`Assistant > Courriel` : plusieurs comptes IMAP/SMTP, un cache local, et un +TUI Textual. Voir [../../doc/EMAIL.fr.md](../../doc/EMAIL.fr.md). \ No newline at end of file diff --git a/script/todo/README.md b/script/todo/README.md index 5159e67..6f981e2 100644 --- a/script/todo/README.md +++ b/script/todo/README.md @@ -3,3 +3,7 @@ TODO is an assistant robot to use ERPLibre Execute it with `./script/todo/todo.py` or `make todo`. For a new project, copy todo_example.json to private/todo/todo_override.json | private/todo/todo_override_private.json and edit it. + +The `mail/` package is the mail client reachable from `Assistant > Mail`: +several IMAP/SMTP accounts, a local cache, and a Textual TUI. See +[../../doc/EMAIL.md](../../doc/EMAIL.md). diff --git a/script/todo/mail/__init__.py b/script/todo/mail/__init__.py new file mode 100644 index 0000000..d473508 --- /dev/null +++ b/script/todo/mail/__init__.py @@ -0,0 +1,11 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Client courriel du CLI TODO. + +Le paquet est découpé par responsabilité : `crypto` scelle, `secrets` garde +les mots de passe, `accounts` décrit les comptes, `store` cache localement, +`imap_sync` synchronise, `smtp_send` envoie, `tui` affiche, `menu` branche le +tout sur le CLI. Aucun de ces modules n'importe `todo.py` ; c'est `todo.py` +qui importe `menu`. +""" diff --git a/script/todo/mail/account_setup.py b/script/todo/mail/account_setup.py new file mode 100644 index 0000000..829b0a2 --- /dev/null +++ b/script/todo/mail/account_setup.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""La logique d'ajout de compte et de mise en place du coffre, sans UI. + +`menu._add_account` et `menu._ensure_kdbx` mêlaient cette logique — pas +triviale, elle annule l'écriture du secret si la sauvegarde du compte échoue +— à des `input()`/`getpass.getpass()`. Le CLI et le TUI ont chacun leur façon +de demander l'information, mais doivent appeler exactement le même code une +fois qu'ils l'ont : sinon les deux copies dérivent. Ce module ne connaît ni +`input`, ni Textual, ni aucune bibliothèque d'interface. +""" +from __future__ import annotations + +import os + +from script.todo.mail import accounts as mail_accounts +from script.todo.mail.accounts import AccountError +from script.todo.mail.secrets import SecretError, create_kdbx +from script.todo.todo_i18n import t + +# Chemin proposé par défaut pour un kdbx nouvellement créé. `private/` est un +# dossier versionné, mais `private/.gitignore` y ignore déjà `*.kdbx` : c'est +# la convention du dépôt pour les fichiers de coffre. +DEFAULT_KDBX_PATH = "private/erplibre.kdbx" + + +def save_new_account(secret_store, accounts, account, password) -> None: + """Écrit le mot de passe puis sauvegarde `accounts` (qui doit déjà + contenir `account`, à la place voulue par l'appelant). + + Si la sauvegarde échoue, le secret est retiré du coffre avant que + l'exception ne remonte : l'y laisser sous une référence qu'aucune + configuration ne désigne en ferait un déchet invisible. + """ + secret_store.set(account.secret_ref, password) + try: + mail_accounts.save(accounts) + except (AccountError, OSError): + try: + secret_store.delete(account.secret_ref) + except SecretError: + pass + raise + + +def kdbx_is_configured(config_file) -> bool: + """Vrai si un kdbx est déjà désigné dans la configuration.""" + return bool(config_file.get_config_value(["kdbx", "path"])) + + +def create_vault(config_file, path: str, password: str) -> None: + """Crée un nouveau kdbx à `path`, puis l'enregistre comme coffre actif.""" + create_kdbx(path, password) + config_file.set_config_value(["kdbx", "path"], path) + + +def use_existing_vault(config_file, path: str) -> None: + """Adopte un kdbx déjà présent sur disque comme coffre actif.""" + if not path or not os.path.isfile(path): + raise SecretError(f"{t('mail_kdbx_path_not_found')} {path}") + config_file.set_config_value(["kdbx", "path"], path) diff --git a/script/todo/mail/accounts.py b/script/todo/mail/accounts.py new file mode 100644 index 0000000..b535da7 --- /dev/null +++ b/script/todo/mail/accounts.py @@ -0,0 +1,285 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Les comptes courriel : description, préréglages, fichier de config. + +`accounts.json` ne contient QUE ce qui n'est pas secret. Le mot de passe vit +dans le coffre (voir `secrets.py`) et le fichier n'en garde qu'une référence. +Le fichier reste donc lisible, éditable à la main et réparable, sans devenir +un endroit d'où une fuite ferait mal. + +Les préréglages `gmail`, `outlook` et `icloud` supposent un MOT DE PASSE +D'APPLICATION : l'authentification simple ne passe plus autrement chez ces +fournisseurs. C'est la limite assumée de la phase 1 ; la phase 2 apporte OAuth. +""" +from __future__ import annotations + +import json +import os +from dataclasses import asdict, dataclass, field +from pathlib import Path + +from script.todo.todo_i18n import t + +SCHEMA_VERSION = 1 +SECURITIES = ("ssl", "starttls", "none") + +PRESETS: dict[str, dict] = { + "gmail": { + "label": "Google / Gmail", + "imap": {"host": "imap.gmail.com", "port": 993, "security": "ssl"}, + "smtp": { + "host": "smtp.gmail.com", + "port": 587, + "security": "starttls", + }, + "sent_folder": "[Gmail]/Sent Mail", + "app_password": True, + "note_key": "mail_preset_note_gmail", + }, + "outlook": { + "label": "Microsoft / Outlook", + "imap": { + "host": "outlook.office365.com", + "port": 993, + "security": "ssl", + }, + "smtp": { + "host": "smtp.office365.com", + "port": 587, + "security": "starttls", + }, + "sent_folder": "Sent Items", + "app_password": True, + "note_key": "mail_preset_note_outlook", + }, + "icloud": { + "label": "Apple / iCloud", + "imap": {"host": "imap.mail.me.com", "port": 993, "security": "ssl"}, + "smtp": { + "host": "smtp.mail.me.com", + "port": 587, + "security": "starttls", + }, + "sent_folder": "Sent Messages", + "app_password": True, + "note_key": "mail_preset_note_icloud", + }, + "generic": { + "label": "Serveur standard (IMAP/SMTP)", + "imap": {"host": "", "port": 993, "security": "ssl"}, + "smtp": {"host": "", "port": 587, "security": "starttls"}, + "sent_folder": "Sent", + "app_password": False, + "note_key": "mail_preset_note_generic", + }, +} + + +class AccountError(Exception): + """Configuration de compte invalide, illisible ou en conflit.""" + + +@dataclass +class ServerConf: + host: str + port: int + security: str + user: str + + def __post_init__(self) -> None: + if self.security not in SECURITIES: + raise AccountError( + f"{t('mail_err_unknown_security')} {self.security!r}" + f" {t('mail_err_expected')} {SECURITIES})" + ) + + +@dataclass +class Account: + name: str + email: str + imap: ServerConf + smtp: ServerConf + secret_ref: str + display_name: str = "" + preset: str = "generic" + cache_mode: str | None = None + sent_folder: str = "Sent" + enabled: bool = True + + def __post_init__(self) -> None: + if not self.name: + raise AccountError(t("mail_err_account_needs_name")) + if ( + "/" in self.name + or os.sep in self.name + or self.name.startswith(".") + ): + raise AccountError( + f"{t('mail_err_invalid_account_name')} {self.name!r}" + f" {t('mail_err_account_name_reason')}" + ) + if self.cache_mode not in (None, "clear", "encrypted", "ephemeral"): + raise AccountError( + f"{t('mail_err_unknown_cache_mode')} {self.cache_mode!r}" + ) + + def cache_key_ref(self) -> str: + """Référence de la clé de chiffrement, distincte du mot de passe.""" + return f"{self.secret_ref}/cache-key" + + def from_header(self) -> str: + return ( + f"{self.display_name} <{self.email}>" + if self.display_name + else self.email + ) + + def to_dict(self) -> dict: + data = asdict(self) + return data + + @classmethod + def from_dict(cls, d: dict) -> "Account": + try: + return cls( + name=d["name"], + email=d["email"], + imap=ServerConf(**d["imap"]), + smtp=ServerConf(**d["smtp"]), + secret_ref=d["secret_ref"], + display_name=d.get("display_name", ""), + preset=d.get("preset", "generic"), + cache_mode=d.get("cache_mode"), + sent_folder=d.get("sent_folder", "Sent"), + enabled=d.get("enabled", True), + ) + except (KeyError, TypeError) as exc: + raise AccountError( + f"{t('mail_err_account_unreadable')} {exc}" + ) from exc + + +def accounts_path() -> Path: + return Path(os.path.expanduser("~/.erplibre/mail/accounts.json")) + + +def _prepare_parent(path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + os.chmod(path.parent, 0o700) + + +def _write_private(path: Path, text: str) -> None: + """Écrit `text` dans un fichier créé en 0600 dès sa création. + + Écrire puis `chmod` laisserait le fichier — mot de passe absent, mais + `secret_ref` et adresses y sont — lisible à l'umask du process le temps + entre les deux appels. `os.open` avec le mode dès l'ouverture ferme cette + fenêtre ; le `chmod` qui suit corrige aussi un fichier déjà là écrit par + un umask permissif. + """ + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w") as handle: + handle.write(text) + os.chmod(path, 0o600) + + +def account_from_preset( + name: str, + email: str, + preset_key: str, + *, + user: str | None = None, + display_name: str = "", + vault: str = "kdbx", +) -> Account: + preset = PRESETS.get(preset_key) + if preset is None: + raise AccountError(f"{t('mail_err_unknown_preset')} {preset_key!r}") + login = user or email + ref = ( + f"kdbx:ERPLibre/Mail/{name}" if vault == "kdbx" else f"keyring:{name}" + ) + return Account( + name=name, + email=email, + display_name=display_name, + preset=preset_key, + imap=ServerConf(user=login, **preset["imap"]), + smtp=ServerConf(user=login, **preset["smtp"]), + secret_ref=ref, + cache_mode=None, + sent_folder=preset["sent_folder"], + enabled=True, + ) + + +def load(path: Path | None = None) -> list[Account]: + path = Path(path) if path else accounts_path() + if not path.exists(): + return [] + try: + data = json.loads(path.read_text()) + except ValueError as exc: + raise AccountError( + f"{path} {t('mail_err_not_valid_json')} {exc}" + ) from exc + if not isinstance(data, dict): + raise AccountError( + f"{path} {t('mail_err_should_contain_json_object')}" + ) + return [Account.from_dict(d) for d in data.get("accounts", [])] + + +def save(accounts: list[Account], path: Path | None = None) -> None: + path = Path(path) if path else accounts_path() + names = [a.name for a in accounts] + duplicates = {n for n in names if names.count(n) > 1} + if duplicates: + raise AccountError( + f"{t('mail_err_duplicate_account_names')} {sorted(duplicates)}" + ) + _prepare_parent(path) + payload = { + "version": SCHEMA_VERSION, + "default_account": names[0] if names else None, + "accounts": [a.to_dict() for a in accounts], + } + _write_private(path, json.dumps(payload, ensure_ascii=False, indent=2)) + + +def find(accounts: list[Account], name: str) -> Account | None: + return next((a for a in accounts if a.name == name), None) + + +def write_template(path: Path | None = None, force: bool = False) -> Path: + """Écrit un accounts.json d'exemple, un compte désactivé par préréglage. + + JSON n'a pas de commentaires : les explications passent par des clés + `_comment`, que `Account.from_dict` ignore. + """ + path = Path(path) if path else accounts_path() + if path.exists() and not force: + raise AccountError(f"{path} {t('mail_err_already_exists_relaunch')}") + examples = [] + for key, preset in PRESETS.items(): + acc = account_from_preset( + f"exemple-{key}", f"vous@exemple.ca", key + ).to_dict() + acc["enabled"] = False + acc["_comment"] = t(preset["note_key"]) + examples.append(acc) + payload = { + "version": SCHEMA_VERSION, + "_comment": ( + "Modèle ERPLibre. Aucun mot de passe ici : `secret_ref` pointe" + " vers le coffre. Passez `enabled` à true une fois rempli." + " `cache_mode` à null hérite du réglage général." + ), + "default_account": None, + "accounts": examples, + } + _prepare_parent(path) + _write_private(path, json.dumps(payload, ensure_ascii=False, indent=2)) + return path diff --git a/script/todo/mail/charset.py b/script/todo/mail/charset.py new file mode 100644 index 0000000..95a4a8e --- /dev/null +++ b/script/todo/mail/charset.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Décoder des octets dont le nom de charset vient du serveur, sans jamais lever. + +Un charset annoncé par un en-tête (`Content-Type`, un mot encodé RFC 2047) +peut être n'importe quelle chaîne — y compris une étiquette que Python ne +reconnaît pas, comme `unknown-8bit`, que certains MTA posent sur un en-tête +8 bits mal formé. `bytes.decode(charset, errors="replace")` lève quand même +`LookupError` dans ce cas : la RECHERCHE du codec échoue AVANT que `errors` +ne soit consulté — `errors="replace"` ne protège donc de rien ici. + +Cette fonction a été réinventée quatre fois dans ce paquet +(`imap_transport.decode_header_value`, `imap_sync.snippet_from_raw`, +`smtp_send._plain_text`, `tui_text._decode_part`) avant d'être extraite ici : +un charset non fiable ne doit jamais faire tomber l'affichage ou la +synchronisation d'un message entier. +""" +from __future__ import annotations + + +def decode_bytes(payload: bytes, charset: str | None) -> str: + """`payload` décodé avec `charset`, replié sur UTF-8 si `charset` est + absent ou inconnu de Python.""" + try: + return payload.decode(charset or "utf-8", "replace") + except LookupError: + return payload.decode("utf-8", "replace") diff --git a/script/todo/mail/crypto.py b/script/todo/mail/crypto.py new file mode 100644 index 0000000..887dd7b --- /dev/null +++ b/script/todo/mail/crypto.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Scellement du cache courriel. + +Une enveloppe AUTO-DESCRIPTIVE précède chaque donnée : le premier octet-paire +dit comment lire la suite. Conséquence voulue : une base écrite en clair reste +lisible après passage en mode chiffré, et l'inverse échoue bruyamment plutôt +que de rendre du charabia. + + clair b"P0" + donnees + chiffre b"E1" + nonce(12) + AES-256-GCM(chiffre || tag) +""" +from __future__ import annotations + +import os + +from script.todo.todo_i18n import t + +CLEAR_MAGIC = b"P0" +SEALED_MAGIC = b"E1" +NONCE_LEN = 12 +KEY_LEN = 32 + + +class CryptoError(Exception): + """Clé absente, clé fausse, enveloppe inconnue ou donnée altérée.""" + + +def new_key() -> bytes: + """Une clé AES-256 tirée du générateur du système.""" + return os.urandom(KEY_LEN) + + +class MailCrypto: + """Interface commune. `open` sait toujours lire une enveloppe en clair.""" + + def seal(self, data: bytes) -> bytes: + raise NotImplementedError + + def open(self, blob: bytes) -> bytes: + raise NotImplementedError + + @staticmethod + def _split(blob: bytes) -> tuple[bytes, bytes]: + if not isinstance(blob, (bytes, bytearray)) or len(blob) < 2: + raise CryptoError(t("mail_err_envelope_too_short")) + return bytes(blob[:2]), bytes(blob[2:]) + + +class NullCrypto(MailCrypto): + """Mode `clear` : on marque, on ne chiffre pas.""" + + def seal(self, data: bytes) -> bytes: + return CLEAR_MAGIC + data + + def open(self, blob: bytes) -> bytes: + magic, body = self._split(blob) + if magic == CLEAR_MAGIC: + return body + if magic == SEALED_MAGIC: + raise CryptoError(t("mail_err_sealed_in_clear_mode")) + raise CryptoError(f"{t('mail_err_unknown_envelope')} {magic!r}") + + +class AesGcmCrypto(MailCrypto): + """Modes `encrypted` et `ephemeral` : AES-256-GCM, nonce neuf à chaque appel.""" + + def __init__(self, key: bytes) -> None: + if not isinstance(key, (bytes, bytearray)) or len(key) != KEY_LEN: + raise CryptoError( + f"{t('mail_err_key_wrong_length')} {KEY_LEN}" + f" {t('mail_err_octets_unit')}" + ) + try: + from cryptography.exceptions import InvalidTag + from cryptography.hazmat.primitives.ciphers.aead import AESGCM + except ImportError as exc: # pragma: no cover - dépendance absente + raise CryptoError( + t("mail_err_cryptography_not_installed") + ) from exc + self._aes = AESGCM(bytes(key)) + self._invalid_tag = InvalidTag + + def seal(self, data: bytes) -> bytes: + nonce = os.urandom(NONCE_LEN) + return SEALED_MAGIC + nonce + self._aes.encrypt(nonce, data, None) + + def open(self, blob: bytes) -> bytes: + magic, body = self._split(blob) + if magic == CLEAR_MAGIC: + return body + if magic != SEALED_MAGIC: + raise CryptoError(f"{t('mail_err_unknown_envelope')} {magic!r}") + nonce, payload = body[:NONCE_LEN], body[NONCE_LEN:] + try: + return self._aes.decrypt(nonce, payload, None) + except self._invalid_tag as exc: + raise CryptoError(t("mail_err_decrypt_refused")) from exc + except ValueError as exc: + raise CryptoError( + f"{t('mail_err_envelope_unreadable')} {exc}" + ) from exc + # Toute autre exception remonte telle quelle : un bug de programmation + # ne doit JAMAIS se déguiser en « mauvaise clé ». + + +def build_crypto(mode: str, key: bytes | None) -> MailCrypto: + """La boîte qui correspond au mode de cache d'un compte.""" + if mode == "clear": + return NullCrypto() + if mode in ("encrypted", "ephemeral"): + if key is None: + raise CryptoError( + f"{t('mail_err_mode_prefix')} {mode}" + f" {t('mail_err_mode_requires_key')}" + ) + return AesGcmCrypto(key) + raise CryptoError(f"{t('mail_err_unknown_cache_mode')} {mode}") diff --git a/script/todo/mail/imap_sync.py b/script/todo/mail/imap_sync.py new file mode 100644 index 0000000..2c696b9 --- /dev/null +++ b/script/todo/mail/imap_sync.py @@ -0,0 +1,256 @@ +#!/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 diff --git a/script/todo/mail/imap_transport.py b/script/todo/mail/imap_transport.py new file mode 100644 index 0000000..7207c1c --- /dev/null +++ b/script/todo/mail/imap_transport.py @@ -0,0 +1,339 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""La seule couche qui parle vraiment IMAP. + +Choix qui explique tout le reste du fichier : on ne décode PAS `ENVELOPE`. +`BODY.PEEK[HEADER.FIELDS (...)]` rend des en-têtes RFC822 bruts, que le module +`email` de la stdlib sait déjà lire — encodages, mots encodés, dates comprises. +Analyser `ENVELOPE` à la main coûterait cent lignes de plus, toutes fausses +sur un cas limite ou l'autre. + +`BODY.PEEK` et non `BODY` : lire un message dans le TUI ne doit pas le marquer +lu sur le serveur à l'insu de l'utilisateur. +""" +from __future__ import annotations + +import email +import email.utils +import re +from email.header import decode_header +from email.parser import BytesHeaderParser + +from script.todo.mail.charset import decode_bytes +from script.todo.mail.imap_sync import FolderInfo, HeaderInfo, SelectInfo +from script.todo.todo_i18n import t + +HEADER_FIELDS = "FROM TO SUBJECT DATE MESSAGE-ID" + +SPECIAL_USE = { + "\\Sent": "sent", + "\\Drafts": "drafts", + "\\Trash": "trash", + "\\Junk": "junk", + "\\Archive": "archive", + "\\All": "archive", +} + +_UID_RE = re.compile(rb"UID\s+(\d+)") +_SIZE_RE = re.compile(rb"RFC822\.SIZE\s+(\d+)") +_FLAGS_RE = re.compile(rb"FLAGS\s+\(([^)]*)\)") +_LIST_RE = re.compile(rb'^\(([^)]*)\)\s+("[^"]*"|NIL)\s+(.*)$') + + +class ImapError(Exception): + """Le serveur a refusé, ou a répondu quelque chose d'inattendu.""" + + +def decode_header_value(raw: str | None) -> str: + """Un en-tête RFC 2047 rendu en texte lisible, sans jamais lever.""" + if not raw: + return "" + try: + parts = decode_header(raw) + except Exception: + # `raw` vient du serveur : un en-tête mal formé ne doit jamais faire + # tomber l'affichage d'un message. + return str(raw) + out = [] + for value, charset in parts: + if isinstance(value, bytes): + out.append(decode_bytes(value, charset)) + else: + out.append(value) + return "".join(out).strip() + + +def decode_mailbox(name: str) -> str: + """Nom de boîte en UTF-7 modifié (RFC 3501) rendu lisible. + + Sur entrée invalide on rend le nom d'origine : un affichage imparfait vaut + mieux qu'un dossier qu'on n'arrive plus à sélectionner. + """ + if "&" not in name: + return name + try: + out = [] + for chunk in name.split("&"): + if not out: + out.append(chunk) + continue + encoded, sep, rest = chunk.partition("-") + if not sep: + raise ValueError(t("mail_err_unterminated_ampersand")) + if encoded == "": + out.append("&" + rest) + else: + pad = "=" * (-len(encoded) % 4) + decoded = (encoded.replace(",", "/") + pad).encode("ascii") + import base64 + + out.append( + base64.b64decode(decoded).decode("utf-16-be") + rest + ) + return "".join(out) + except Exception: + # Nom de boîte mal formé : on garde l'original plutôt que de perdre + # l'accès au dossier pour une simple erreur d'affichage. + return name + + +def parse_list_line(line: bytes) -> FolderInfo: + """Une ligne de réponse LIST → nom, nom affichable, rôle.""" + match = _LIST_RE.match(line.strip()) + if not match: + raw_name = line.decode("utf-8", "replace").strip().strip('"') + return FolderInfo(name=raw_name, display=decode_mailbox(raw_name)) + flags = match.group(1).decode("ascii", "replace").split() + name = match.group(3).decode("utf-8", "replace").strip().strip('"') + role = next((SPECIAL_USE[f] for f in flags if f in SPECIAL_USE), None) + if role is None and name.upper() == "INBOX": + role = "inbox" + bas = {f.lower() for f in flags} + return FolderInfo( + name=name, + display=decode_mailbox(name), + role=role, + selectable=not (bas & {"\\noselect", "\\nonexistent"}), + ) + + +def parse_fetch_headers(data: list) -> list[HeaderInfo]: + """Réponse FETCH d'en-têtes → une liste de `HeaderInfo`.""" + parser = BytesHeaderParser() + out = [] + for index, item in enumerate(data): + if not isinstance(item, tuple) or len(item) < 2: + continue + prefix, raw_headers = item[0], item[1] + # Les attributs peuvent SUIVRE le littéral au lieu de le précéder : + # RFC 3501 n'impose aucun ordre, et `imaplib` rend alors la fin de la + # ligne dans l'entrée suivante, hors du tuple. Ne lire que le préfixe + # ferait disparaître le message SANS erreur — et comme le moteur + # avance `last_uid` derrière, il ne serait jamais réessayé. + trailer = b"" + if index + 1 < len(data) and isinstance( + data[index + 1], (bytes, bytearray) + ): + trailer = bytes(data[index + 1]) + meta = bytes(prefix) + b" " + trailer + uid_match = _UID_RE.search(meta) + if not uid_match: + continue + size_match = _SIZE_RE.search(meta) + flags_match = _FLAGS_RE.search(meta) + msg = parser.parsebytes(raw_headers) + date_raw = msg.get("Date") + try: + # `str()` d'abord : un `Date:` porteur d'octets 8 bits bruts — + # vu en boîte réelle — fait renvoyer un `Header` et non une + # chaîne, et `parsedate_to_datetime` y lève un AttributeError + # que ce `except` ne rattrapait pas. Une seule date illisible + # emportait alors la synchro du dossier ENTIER. + stamp = ( + int( + email.utils.parsedate_to_datetime( + str(date_raw) + ).timestamp() + ) + if date_raw + else 0 + ) + except (TypeError, ValueError, AttributeError, OverflowError): + # Une date est une commodité d'affichage : aucune valeur + # d'en-tête ne justifie de perdre le message. + stamp = 0 + out.append( + HeaderInfo( + uid=int(uid_match.group(1)), + date=stamp, + size=int(size_match.group(1)) if size_match else 0, + flags=( + flags_match.group(1).decode("ascii", "replace") + if flags_match + else "" + ), + msgid=(msg.get("Message-ID") or "").strip(), + frm=decode_header_value(msg.get("From")), + to=decode_header_value(msg.get("To")), + subject=decode_header_value(msg.get("Subject")), + ) + ) + return out + + +class ImaplibTransport: + """`ImapTransport` réalisé sur `imaplib`. Le client est injecté : les tests + passent un double, la production passe une connexion TLS.""" + + def __init__(self, client) -> None: + self.client = client + + @staticmethod + def _ok(result, label: str): + status, data = result + if status != "OK": + raise ImapError( + f"{label} {t('mail_err_server_replied')} {status} ({data!r})" + ) + return data + + def list_folders(self) -> list[FolderInfo]: + data = self._ok(self.client.list(), "LIST") + return [parse_list_line(line) for line in data if line] + + def select(self, folder: str) -> SelectInfo: + data = self._ok(self.client.select(f'"{folder}"'), f"SELECT {folder}") + exists = int(data[0]) if data and data[0] else 0 + return SelectInfo( + uidvalidity=int( + self._first(self.client.response("UIDVALIDITY")) or 0 + ), + uidnext=int(self._first(self.client.response("UIDNEXT")) or 0), + exists=exists, + ) + + @staticmethod + def _first(response) -> bytes | None: + _, data = response + return data[0] if data and data[0] else None + + def search_uids(self, since_uid: int) -> list[int]: + data = self._ok( + self.client.uid("SEARCH", None, f"UID {since_uid}:*"), "SEARCH" + ) + raw = (data[0] or b"").split() + # `UID n:*` rend toujours au moins un UID, même inférieur à n quand la + # boîte est plus courte : on refiltre côté client. + return [int(u) for u in raw if int(u) >= since_uid] + + def fetch_headers(self, uids: list[int]) -> list[HeaderInfo]: + if not uids: + return [] + spec = f"(UID FLAGS RFC822.SIZE BODY.PEEK[HEADER.FIELDS ({HEADER_FIELDS})])" + data = self._ok( + self.client.uid("FETCH", ",".join(str(u) for u in uids), spec), + "FETCH HEADERS", + ) + return parse_fetch_headers(data) + + def fetch_flags(self, uids: list[int]) -> list[tuple[int, str]]: + if not uids: + return [] + data = self._ok( + self.client.uid( + "FETCH", ",".join(str(u) for u in uids), "(UID FLAGS)" + ), + "FETCH FLAGS", + ) + out = [] + for line in data: + raw = line[0] if isinstance(line, tuple) else line + if not isinstance(raw, (bytes, bytearray)): + continue + uid_match = _UID_RE.search(raw) + flags_match = _FLAGS_RE.search(raw) + if uid_match: + out.append( + ( + int(uid_match.group(1)), + ( + flags_match.group(1).decode("ascii", "replace") + if flags_match + else "" + ), + ) + ) + return out + + def fetch_body(self, uid: int) -> bytes: + data = self._ok( + self.client.uid("FETCH", str(uid), "(BODY.PEEK[])"), "FETCH BODY" + ) + for item in data: + if isinstance(item, tuple) and len(item) >= 2: + return item[1] + raise ImapError(f"{t('mail_err_no_body_for_uid')} {uid}") + + def store_flags(self, uid: int, add: list[str], remove: list[str]) -> None: + if add: + self._ok( + self.client.uid( + "STORE", str(uid), "+FLAGS", f"({' '.join(add)})" + ), + "STORE +FLAGS", + ) + if remove: + self._ok( + self.client.uid( + "STORE", str(uid), "-FLAGS", f"({' '.join(remove)})" + ), + "STORE -FLAGS", + ) + + def append(self, folder: str, raw: bytes, flags: list[str]) -> None: + self._ok( + self.client.append( + f'"{folder}"', f"({' '.join(flags)})", None, raw + ), + f"APPEND {folder}", + ) + + def logout(self) -> None: + """Fermer proprement est souhaitable, pas indispensable : on n'échoue + jamais sur la sortie.""" + try: + self.client.logout() + except Exception: + # Best-effort : la connexion peut déjà être fermée par le serveur. + pass + + +def connect(account, password: str) -> ImaplibTransport: + """Ouvre une connexion TLS et se connecte. Lève `ImapError` sur refus.""" + import imaplib + + conf = account.imap + try: + if conf.security == "ssl": + client = imaplib.IMAP4_SSL(conf.host, conf.port, timeout=30) + else: + client = imaplib.IMAP4(conf.host, conf.port, timeout=30) + if conf.security == "starttls": + client.starttls() + client.login(conf.user, password) + except UnicodeEncodeError as exc: + # `imaplib` encode la commande LOGIN en ASCII : un mot de passe + # accentué n'atteint même pas le serveur. Ce cas sort AVANT le + # rattrapage général, dont le préfixe dit « refusée » — or personne + # n'a rien refusé, et l'ancien message « ordinal not in range(128) » + # accusait le serveur d'un refus qu'il n'a jamais prononcé. + raise ImapError(t("mail_err_password_not_ascii")) from exc + except Exception as exc: + # Toute panne réseau ou d'authentification devient une seule erreur + # de haut niveau, pour un message utile à l'utilisateur. + raise ImapError( + f"{t('mail_err_imap_connection_prefix')} {conf.host}" + f" {t('mail_err_connection_refused_suffix')} {exc}" + ) from exc + return ImaplibTransport(client) diff --git a/script/todo/mail/menu.py b/script/todo/mail/menu.py new file mode 100644 index 0000000..e1fb35f --- /dev/null +++ b/script/todo/mail/menu.py @@ -0,0 +1,550 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Les entrées de menu du client courriel. + +Ce module est le SEUL point de contact entre le paquet `mail` et le CLI : +`todo.py` importe `prompt_execute_mail` et rien d'autre. Le sens de la +dépendance est volontaire — `mail` ne doit jamais importer `todo`. +""" +from __future__ import annotations + +import getpass +import logging +import os +import shutil +from pathlib import Path + +import click + +from script.todo import todo_prefs +from script.todo.mail import account_setup +from script.todo.mail import accounts as mail_accounts +from script.todo.mail.accounts import PRESETS, AccountError +from script.todo.mail.secrets import SecretError, SecretStore +from script.todo.mail.store import Store, StoreError, resolve_mode +from script.todo.todo_i18n import t + +CACHE_MODES = ("clear", "encrypted", "ephemeral") + +# Chemin proposé par défaut pour un kdbx nouvellement créé — voir +# `account_setup.py`, la seule source de vérité, aussi utilisée par le TUI. +_DEFAULT_KDBX_PATH = account_setup.DEFAULT_KDBX_PATH + +# `True` une fois `_configure_mail_logging` passée : évite d'empiler un +# second `FileHandler` si l'utilisateur rouvre le menu courriel plusieurs +# fois dans le même processus `todo`. +_LOG_CONFIGURED = False + + +def mail_log_path() -> Path: + """Le chemin du journal du paquet `mail` — SOURCE UNIQUE, pour que + `_configure_mail_logging` (ci-dessous) et `tui.LogScreen` (touche `l`, + qui affiche sa fin) ne puissent jamais en dériver deux formules + différentes.""" + return Path(os.path.expanduser("~/.erplibre")) / "mail.log" + + +def _configure_mail_logging() -> None: + """Branche le journal du paquet `mail` sur `mail_log_path()`. + + Les modules du paquet (`imap_sync.py`, `tui.py`, ...) ne font + qu'appeler `_logger.exception(...)` : brancher un GESTIONNAIRE est le + travail de L'APPLICATION, pas d'une bibliothèque — c'est pourquoi cette + fonction vit ici, au seul point d'entrée du paquet (voir le docstring du + module), et jamais dans `mail/*.py`. Jamais vers la console non plus : + Textual possède le terminal pendant tout le TUI, et une ligne de log qui + s'y mêlerait corromprait l'affichage. + """ + global _LOG_CONFIGURED + if _LOG_CONFIGURED: + return + log_path = mail_log_path() + log_path.parent.mkdir(parents=True, exist_ok=True) + handler = logging.FileHandler(log_path) + handler.setFormatter( + logging.Formatter("%(asctime)s %(name)s %(levelname)s %(message)s") + ) + # Sur le logger PARENT de tout le paquet (`script.todo.mail`, préfixe + # commun de `script.todo.mail.tui`, `script.todo.mail.imap_sync`, ...) : + # un seul gestionnaire couvre tous les modules, sans qu'aucun d'eux + # n'ait à en connaître l'existence. + logger = logging.getLogger("script.todo.mail") + logger.addHandler(handler) + logger.setLevel(logging.INFO) + # `todo.py` appelle `logging.basicConfig()` à l'IMPORT (ligne 68), ce qui + # pose un `StreamHandler` sur le logger RACINE. `propagate` vaut `True` + # par défaut : sans cette ligne, chaque `_logger.exception(...)` du + # paquet remonterait AUSSI jusqu'à ce gestionnaire — donc sur le + # terminal que Textual possède pendant tout le TUI, silencieusement. + # Constaté pour de vrai : la suite complète l'a fait fuir dans la sortie + # pointillée d'`unittest` dès qu'un fichier de test important déjà + # `script.todo.todo` tournait avant les tests courriel dans le même + # processus. + logger.propagate = False + _LOG_CONFIGURED = True + + +def secret_store_for(todo) -> SecretStore: + """Le coffre du CLI : son kdbx s'il en a un, sinon le trousseau système.""" + manager = getattr(todo, "kdbx_manager", None) + return SecretStore(kdbx_manager=manager, use_keyring=True) + + +def cache_summary(accounts, base=None, prefs_get=None) -> list[dict]: + """Nom, mode effectif et taille sur disque, pour l'écran de cache.""" + rows = [] + for account in accounts: + mode = resolve_mode(account, prefs_get) + size = 0 + try: + root = Store(account, mode=mode, base=base).root + if root.is_dir(): + size = sum( + p.stat().st_size for p in root.rglob("*") if p.is_file() + ) + except Exception: + # Un cache absent, un lien symbolique refusé ou un disque + # inaccessible ne doivent pas empêcher d'afficher les AUTRES + # comptes : la taille retombe simplement à zéro pour celui-ci. + size = 0 + rows.append({"name": account.name, "mode": mode, "size": size}) + return rows + + +def _load_accounts(): + try: + return mail_accounts.load() + except AccountError as exc: + print(exc) + return [] + + +def prompt_execute_mail(todo) -> None: + _configure_mail_logging() + while True: + help_info = f"""{todo._menu_header()} +[1] {t("mail_open_tui")} +[2] {t("mail_accounts_menu")} +[3] {t("mail_sync_now")} +[4] {t("mail_cache_menu")} +[0] {t("Back")}""" + status = click.prompt(help_info) + print() + if status == "0": + return + if status == "1": + _open_tui(todo) + elif status == "2": + prompt_mail_accounts(todo) + elif status == "3": + _sync_now(todo) + elif status == "4": + prompt_mail_cache(todo) + else: + print(t("Command not found !")) + + +def _open_tui(todo) -> None: + from script.todo.mail.tui import open_sessions, run_tui + + accounts = _load_accounts() + secrets = secret_store_for(todo) + sessions = open_sessions(accounts, secrets) + try: + # Un TUI sans aucun compte n'est plus une impasse : `config_file` et + # `secrets` lui permettent d'en créer un depuis l'écran d'ajout. + run_tui( + sessions=sessions, + config_file=todo.config_file, + secret_store=secrets, + ) + finally: + for session in sessions: + session.close() + + +def _sync_now(todo) -> None: + from script.todo.mail.tui import open_sessions + + accounts = _load_accounts() + if not accounts: + print(t("mail_no_account")) + return + sessions = open_sessions(accounts, secret_store_for(todo)) + try: + for session in sessions: + if not session.online: + print(f"{session.account.name} : {session.error}") + continue + report = session.sync() + print( + f"{session.account.name} : {report.new_messages}" + f" {t('mail_new_messages')}" + ) + for error in report.errors: + print(f" {error}") + if report.purged: + print( + f" {t('mail_folders_resynced')}" + f" {', '.join(report.purged)}" + ) + finally: + for session in sessions: + session.close() + + +def prompt_mail_accounts(todo) -> None: + while True: + help_info = f"""{todo._menu_header()} +[1] {t("mail_account_list")} +[2] {t("mail_account_add")} +[3] {t("mail_account_delete")} +[4] {t("mail_account_template")} +[5] {t("mail_account_test")} +[0] {t("Back")}""" + status = click.prompt(help_info) + print() + if status == "0": + return + if status == "1": + _list_accounts() + elif status == "2": + _add_account(todo) + elif status == "3": + _delete_account(todo) + elif status == "4": + _write_template() + elif status == "5": + _test_account(todo) + else: + print(t("Command not found !")) + + +def _list_accounts() -> None: + accounts = _load_accounts() + if not accounts: + print(t("mail_no_account")) + return + for account in accounts: + mark = "" if account.enabled else " (désactivé)" + print( + f" {account.name}{mark} — {account.email}" + f" — {account.imap.host} / {account.smtp.host}" + ) + + +def _ensure_kdbx(todo) -> bool: + """Vrai si un kdbx est utilisable pour la suite de `_add_account`. + + Si `kdbx.path` est déjà configuré, ne pose aucune question — c'est le + cas courant après la première utilisation. Sinon, offre les deux choix + promis par la conception : créer un nouveau `.kdbx` ou en choisir un + existant. + """ + if todo.config_file.get_config_value(["kdbx", "path"]): + return True + + print(t("mail_kdbx_none_configured")) + print(f" [1] {t('mail_kdbx_menu_create')}") + print(f" [2] {t('mail_kdbx_menu_choose')}") + print(f" [0] {t('mail_kdbx_menu_cancel')}") + choice = input(t("mail_kdbx_ask_choice")).strip() + if choice == "1": + return _create_kdbx_interactive(todo) + if choice == "2": + return _choose_kdbx_interactive(todo) + return False + + +def _create_kdbx_interactive(todo) -> bool: + prompt = f"{t('mail_kdbx_ask_path_new')} [{_DEFAULT_KDBX_PATH}]: " + path = input(prompt).strip() or _DEFAULT_KDBX_PATH + + password = getpass.getpass(t("mail_kdbx_ask_password")) + confirm = getpass.getpass(t("mail_kdbx_ask_password_confirm")) + if password != confirm: + print(t("mail_kdbx_password_mismatch")) + return False + + try: + account_setup.create_vault(todo.config_file, path, password) + except SecretError as exc: + print(exc) + return False + + print(f"{t('mail_kdbx_created')} {path}") + return True + + +def _choose_kdbx_interactive(todo) -> bool: + path = input(t("mail_kdbx_ask_path_existing")).strip() + try: + account_setup.use_existing_vault(todo.config_file, path) + except SecretError: + print(f"{t('mail_kdbx_path_not_found')} {path}") + return False + + print(f"{t('mail_kdbx_path_recorded')} {path}") + return True + + +def _add_account(todo) -> None: + if not _ensure_kdbx(todo): + return + + store = secret_store_for(todo) + if not store.available_backends(): + print(t("mail_no_vault")) + return + + name = input(t("mail_ask_name")).strip() + email_addr = input(t("mail_ask_email")).strip() + display = input(t("mail_ask_display_name")).strip() + + keys = list(PRESETS) + for index, key in enumerate(keys, start=1): + print(f" [{index}] {PRESETS[key]['label']}") + choice = input(t("mail_ask_preset")).strip() + try: + preset_key = keys[int(choice) - 1] + except (ValueError, IndexError): + preset_key = "generic" + + vault = "kdbx" if "kdbx" in store.available_backends() else "keyring" + try: + account = mail_accounts.account_from_preset( + name, email_addr, preset_key, display_name=display, vault=vault + ) + except AccountError as exc: + print(exc) + return + + if preset_key == "generic": + account.imap.host = input(t("mail_ask_imap_host")).strip() + account.smtp.host = input(t("mail_ask_smtp_host")).strip() + attendu_app = bool(PRESETS[preset_key]["app_password"]) + if attendu_app: + print(t("mail_app_password_note")) + print(f" {t(PRESETS[preset_key]['note_key'])}") + + password = getpass.getpass( + t("mail_ask_app_password" if attendu_app else "mail_ask_password") + ) + existing = [a for a in _load_accounts() if a.name != account.name] + try: + account_setup.save_new_account( + store, existing + [account], account, password + ) + except (SecretError, AccountError, OSError) as exc: + # Une exception qui remonte ici tuerait le menu ; `save_new_account` + # a déjà annulé l'écriture du secret si la sauvegarde a échoué. + print(exc) + return + print(t("mail_account_saved")) + + +def _pick_account(prompt_key="mail_ask_account"): + accounts = _load_accounts() + if not accounts: + print(t("mail_no_account")) + return None, [] + for index, account in enumerate(accounts, start=1): + print(f" [{index}] {account.name}") + choice = input(t(prompt_key)).strip() + try: + return accounts[int(choice) - 1], accounts + except (ValueError, IndexError): + return None, accounts + + +def _delete_account(todo) -> None: + account, accounts = _pick_account() + if account is None: + return + try: + secret_store_for(todo).delete(account.secret_ref) + except SecretError: + # Le secret peut avoir déjà disparu : ce n'est pas une raison de + # garder le compte dans la configuration. + pass + mail_accounts.save([a for a in accounts if a.name != account.name]) + print(t("mail_account_deleted")) + + +def _write_template() -> None: + try: + path = mail_accounts.write_template() + except AccountError as exc: + print(exc) + return + print(f"{t('mail_template_written')} {path}") + + +def _looks_like_auth_failure(cause) -> bool: + """Le serveur a-t-il RÉPONDU, ou n'est-il rien revenu ? + + La question n'est pas « le message ressemble-t-il à un refus » : la + première version cherchait « invalid credentials » et compagnie, et a + manqué le cas le plus clair qui soit — Gmail répond + « [ALERT] Application-specific password required » une fois la double + authentification active, sans employer aucun de ces mots. Une liste de + libellés attendus est toujours en retard sur les serveurs réels. + + On teste donc l'inverse, qui est structurel : `imaplib` lève + `IMAP4.error` quand le SERVEUR a parlé, et un `OSError` (délai, + coupure, DNS) quand rien n'est revenu. Seul ce second cas fait taire la + note. Une cause inconnue l'affiche : c'est un conseil, pas un verdict — + le donner à tort coûte une ligne, le taire à tort coûte la panne. + """ + if cause is None: + return True + origine = getattr(cause, "__cause__", None) + if isinstance(origine, OSError): + return False + # Sans exception d'origine (une chaîne, un test), on retombe sur les + # formulations qui disent explicitement que rien n'est revenu. + bas = str(cause).lower() + return not any( + muet in bas + for muet in ("timed out", "timeout", "unreachable", "not known") + ) + + +def retry_password( + todo, account, attempts: int = 3, connect_fn=None, cause=None +) -> bool: + """Redemande le mot de passe jusqu'à ce qu'il passe. Vrai si le coffre a + été mis à jour. + + L'écriture n'a lieu QU'APRÈS une connexion réussie : remplacer un mot de + passe valide par une faute de frappe serait pire que l'échec initial. + """ + if connect_fn is None: + from script.todo.mail.imap_transport import connect as connect_fn + + store = secret_store_for(todo) + # C'est ICI que la note sert, pas seulement à l'ajout du compte : on + # vient de se faire refuser et on redemande un mot de passe. Gmail, + # Outlook et iCloud répondent « Invalid credentials » au mot de passe + # habituel exactement comme à une faute de frappe — sans cette ligne, + # l'invite pousse à retaper le même, et à se le faire refuser autant de + # fois qu'on le redemande. + preset = PRESETS.get(account.preset, {}) + attendu_app = bool(preset.get("app_password")) + if attendu_app and _looks_like_auth_failure(cause): + print(t("mail_app_password_note")) + print(f" {t(preset['note_key'])}") + # L'invite elle-même nomme ce qu'on attend. « Mot de passe : » invitait + # à saisir CELUI DU COMPTE, que ces fournisseurs refusent — la note + # au-dessus se lit une fois, l'invite se relit à chaque tentative. + invite = t("mail_ask_app_password" if attendu_app else "mail_ask_password") + for _ in range(attempts): + password = getpass.getpass(invite) + if not password: + return False + try: + transport = connect_fn(account, password) + except Exception as exc: + # `connect_fn` peut lever `ImapError` ou n'importe quelle erreur + # réseau brute (`OSError`, ...) : les deux méritent la même + # invite à ressaisir, pas un plantage du menu. + print(f"{t('mail_connection_failed')} {exc}") + continue + transport.logout() + store.set(account.secret_ref, password) + print(t("mail_connection_ok")) + return True + return False + + +def _test_account(todo) -> None: + from script.todo.mail.imap_transport import connect + + account, _ = _pick_account() + if account is None: + return + # Le coffre peut refuser de s'ouvrir — mauvais mot de passe KeePass, + # fichier absent, coffre non configuré. C'est un renoncement de + # l'utilisateur ou une erreur qu'il peut corriger, pas de quoi faire + # tomber le CLI : on le dit et on revient au menu. + try: + password = secret_store_for(todo).get(account.secret_ref) + except SecretError as exc: + print(f"{t('mail_connection_failed')} {exc}") + return + if not password: + print(t("mail_no_password_stored")) + return + try: + transport = connect(account, password) + folders = transport.list_folders() + transport.logout() + except Exception as exc: + # Connexion refusée, mot de passe expiré, ou LIST qui échoue : dans + # tous les cas, offrir de resaisir le mot de passe plutôt que de + # faire tomber le menu. + print(f"{t('mail_connection_failed')} {exc}") + retry_password(todo, account, cause=exc) + return + print(f"{t('mail_connection_ok')} {len(folders)}") + + +def prompt_mail_cache(todo) -> None: + while True: + current = todo_prefs.get("mail_cache_mode", "clear") + help_info = f"""{todo._menu_header()} +[1] {t("mail_cache_default_mode")} ({current}) +[2] {t("mail_cache_account_mode")} +[3] {t("mail_cache_size_purge")} +[0] {t("Back")}""" + status = click.prompt(help_info) + print() + if status == "0": + return + if status == "1": + mode = input(t("mail_ask_mode")).strip() + if mode in CACHE_MODES: + todo_prefs.set("mail_cache_mode", mode) + else: + print(t("Command not found !")) + elif status == "2": + account, accounts = _pick_account() + if account is None: + continue + mode = input(t("mail_ask_mode")).strip() + account.cache_mode = mode if mode in CACHE_MODES else None + mail_accounts.save(accounts) + elif status == "3": + _cache_size_and_purge(todo) + else: + print(t("Command not found !")) + + +def _cache_size_and_purge(todo) -> None: + accounts = _load_accounts() + if not accounts: + print(t("mail_no_account")) + return + for row in cache_summary(accounts): + print(f" {row['name']} — {row['mode']} — {row['size'] // 1024} ko") + account, _ = _pick_account() + if account is None: + return + if input(t("mail_purge_confirm")).strip().lower() not in ("o", "y"): + return + store = Store(account, secrets=secret_store_for(todo)) + try: + store.open() + except StoreError as exc: + # Le cache est illisible : on ne peut pas le vider par SQL, mais c'est + # exactement le cas où l'utilisateur a besoin qu'il disparaisse. + print(exc) + shutil.rmtree(store.root, ignore_errors=True) + print(t("mail_purged")) + return + try: + store.purge_all() + finally: + store.close() + print(t("mail_purged")) diff --git a/script/todo/mail/secrets.py b/script/todo/mail/secrets.py new file mode 100644 index 0000000..8d97828 --- /dev/null +++ b/script/todo/mail/secrets.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Où vivent les mots de passe courriel. + +Deux coffres, dans cet ordre : le kdbx du dépôt (déjà utilisé par le CLI pour +OpenAI et les comptes Odoo), puis le trousseau du système. + +Le trousseau système n'est accepté QUE si son backend chiffre vraiment. Sans +service de secrets — SSH, conteneur, poste sans session graphique — `keyring` +retombe sur `keyrings.alt`, qui écrit le mot de passe en clair dans un fichier. +L'accepter en silence serait un piège, donc on refuse et on le dit. + +Référence de secret : ":" + kdbx:ERPLibre/Mail/perso -> groupe ERPLibre > Mail, entrée perso + kdbx:ERPLibre/Mail/perso/cache-key -> ... entrée cache-key + keyring:perso -> service "erplibre-mail", user perso +""" +from __future__ import annotations + +import logging + +from script.todo.todo_i18n import t + +_logger = logging.getLogger(__name__) + +KEYRING_SERVICE = "erplibre-mail" + +# Backends dont on sait qu'ils chiffrent. Liste blanche volontaire : un +# backend inconnu est refusé, parce qu'on ne peut pas prouver qu'il chiffre. +SAFE_BACKENDS = { + ("keyring.backends.SecretService", "Keyring"), + ("keyring.backends.macOS", "Keyring"), + ("keyring.backends.Windows", "WinVaultKeyring"), + ("keyring.backends.kwallet", "DBusKeyring"), +} + + +class SecretError(Exception): + """Aucun coffre utilisable, ou référence malformée.""" + + +def keyring_backend_name() -> str: + """Nom pleinement qualifié du backend keyring actif, "" s'il est absent.""" + try: + import keyring + except ImportError: + return "" + backend = keyring.get_keyring() + cls = type(backend) + return f"{cls.__module__}.{cls.__qualname__}" + + +def keyring_is_safe() -> bool: + """Vrai seulement si le backend actif chiffre pour de bon.""" + try: + import keyring + except ImportError: + return False + cls = type(keyring.get_keyring()) + return (cls.__module__, cls.__qualname__) in SAFE_BACKENDS + + +def create_kdbx(path: str, password: str) -> None: + """Crée une base KeePass vide. Refuse d'écraser un fichier existant.""" + import os + + from pykeepass import create_database + + if os.path.exists(path): + raise SecretError(f"{t('mail_err_file_already_exists')} {path}") + parent = os.path.dirname(os.path.abspath(path)) + os.makedirs(parent, exist_ok=True) + os.chmod(parent, 0o700) + # `create_database` écrit d'abord un fichier `.tmp` — `construct` l'ouvre + # avec `open(filename, "w+b")`, donc à l'umask du process — avant un + # `shutil.move` vers `path` (pykeepass évite ainsi de corrompre une base + # existante en cas d'échec). Un `os.open` sur `path` ne fermerait donc + # PAS la fenêtre : c'est ce fichier intermédiaire, invisible d'ici, qui + # porterait le coffre en clair. Resserrer l'umask le temps de l'appel + # couvre les deux fichiers. + previous_umask = os.umask(0o077) + try: + create_database(path, password=password) + finally: + os.umask(previous_umask) + os.chmod(path, 0o600) + + +class SecretStore: + """Lecture et écriture de secrets, par référence.""" + + def __init__(self, kdbx_manager=None, use_keyring: bool = True) -> None: + self._kdbx_manager = kdbx_manager + self._use_keyring = use_keyring + + # -- API publique --------------------------------------------------- + + def available_backends(self) -> list[str]: + found = [] + if self._kdbx_manager is not None: + found.append("kdbx") + if self._use_keyring and keyring_is_safe(): + found.append("keyring") + return found + + def get(self, ref: str) -> str | None: + scheme, path = self._parse(ref) + if scheme == "kdbx": + entry = self._kdbx_entry(path, create=False) + return entry.password if entry else None + return self._keyring_call("get_password", path) + + def set(self, ref: str, secret: str) -> None: + scheme, path = self._parse(ref) + if scheme == "kdbx": + entry = self._kdbx_entry(path, create=True) + entry.password = secret + self._kdbx().save() + return + self._keyring_call("set_password", path, secret) + + def delete(self, ref: str) -> None: + scheme, path = self._parse(ref) + if scheme == "kdbx": + entry = self._kdbx_entry(path, create=False) + if entry: + self._kdbx().delete_entry(entry) + self._kdbx().save() + return + self._keyring_call("delete_password", path) + + # -- Détail --------------------------------------------------------- + + @staticmethod + def _parse(ref: str) -> tuple[str, str]: + scheme, sep, path = (ref or "").partition(":") + if not sep or scheme not in ("kdbx", "keyring") or not path: + raise SecretError(f"{t('mail_err_invalid_secret_ref')} {ref!r}") + return scheme, path + + def _kdbx(self): + if self._kdbx_manager is None: + raise SecretError(t("mail_err_no_kdbx_configured")) + kp = self._kdbx_manager.get_kdbx() + if kp is None: + raise SecretError(t("mail_err_kdbx_unreadable")) + return kp + + def _kdbx_entry(self, path: str, create: bool): + """`path` = "Groupe/SousGroupe/Titre". Crée les groupes au besoin.""" + kp = self._kdbx() + *group_names, title = path.split("/") + group = kp.root_group + for name in group_names: + found = next((g for g in group.subgroups if g.name == name), None) + if found is None: + if not create: + return None + found = kp.add_group(group, name) + group = found + entry = next((e for e in group.entries if e.title == title), None) + if entry is None and create: + entry = kp.add_entry(group, title, "", "") + return entry + + def _keyring_call(self, func_name: str, *args): + if not self._use_keyring: + raise SecretError(t("mail_err_no_vault_available")) + if not keyring_is_safe(): + raise SecretError( + f"{t('mail_err_keyring_plaintext')}" + f" {keyring_backend_name() or 'absent'})." + f" {t('mail_err_keyring_plaintext_hint')}" + ) + import keyring + + return getattr(keyring, func_name)(KEYRING_SERVICE, *args) diff --git a/script/todo/mail/smtp_send.py b/script/todo/mail/smtp_send.py new file mode 100644 index 0000000..35bf524 --- /dev/null +++ b/script/todo/mail/smtp_send.py @@ -0,0 +1,293 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Construire un message et le remettre à un serveur SMTP. + +`EmailMessage` fait le gros du travail : encodage des en-têtes accentués, +choix du transfert, structure multipart. On se contente de décider QUOI mettre +dedans — et surtout de ne pas mettre le Cci dans les en-têtes, où il cesserait +d'être caché tout en restant destinataire d'enveloppe. + +`date` et `msgid` sont injectables pour que les tests soient déterministes ; +en production on laisse la stdlib les produire. +""" +from __future__ import annotations + +import mimetypes +from email.message import EmailMessage +from email.utils import formatdate, getaddresses, make_msgid +from pathlib import Path +from typing import Protocol + +from script.todo.mail.charset import decode_bytes +from script.todo.todo_i18n import t + +MAX_QUOTE_LINES = 200 + + +class SmtpError(Exception): + """Message impossible à construire, ou serveur qui refuse.""" + + +def _as_list(value) -> list[str]: + if not value: + return [] + if isinstance(value, str): + return [value] + return list(value) + + +def _addresses(*header_values) -> list[str]: + pairs = getaddresses([v for v in header_values if v]) + return [addr for _, addr in pairs if addr] + + +def build_message( + account, + to, + subject: str, + body: str, + *, + cc=None, + bcc=None, + attachments=None, + in_reply_to: str | None = None, + references: str | None = None, + date: str | None = None, + msgid: str | None = None, +) -> EmailMessage: + to_list = _as_list(to) + cc_list = _as_list(cc) + bcc_list = _as_list(bcc) + if not (to_list or cc_list or bcc_list): + raise SmtpError(t("mail_err_message_needs_recipient")) + + msg = EmailMessage() + msg["From"] = account.from_header() + if to_list: + msg["To"] = ", ".join(to_list) + if cc_list: + msg["Cc"] = ", ".join(cc_list) + msg["Subject"] = subject + msg["Date"] = date or formatdate(localtime=True) + msg["Message-ID"] = msgid or make_msgid( + domain=account.email.split("@")[-1] + ) + if in_reply_to: + msg["In-Reply-To"] = in_reply_to + if references: + msg["References"] = references + msg.set_content(body) + + # Le Cci ne va PAS dans les en-têtes : il ne vit que dans l'enveloppe + # SMTP, que `recipients()` reconstitue. + if bcc_list: + msg["X-ERPLibre-Bcc"] = ", ".join(bcc_list) + + for path in attachments or []: + _attach_file(msg, Path(path)) + return msg + + +def _attach_file(msg: EmailMessage, path: Path) -> None: + if not path.is_file(): + raise SmtpError(f"{t('mail_err_attachment_missing')} {path}") + guessed, _ = mimetypes.guess_type(path.name) + maintype, _, subtype = (guessed or "application/octet-stream").partition( + "/" + ) + msg.add_attachment( + path.read_bytes(), + maintype=maintype, + subtype=subtype or "octet-stream", + filename=path.name, + ) + + +def _plain_text(message) -> str: + """Le texte d'un message, quel que soit son emballage.""" + if isinstance(message, EmailMessage): + part = message.get_body(("plain",)) + if part is not None: + return part.get_content() + if message.is_multipart(): + for part in message.walk(): + if part.get_content_type() == "text/plain": + payload = part.get_payload(decode=True) or b"" + # `decode_bytes` (voir sa docstring) : un charset mal + # étiqueté ne doit pas faire tomber la réponse ou le + # transfert. + return decode_bytes(payload, part.get_content_charset()) + return "" + payload = message.get_payload(decode=True) + if payload is None: + return str(message.get_payload()) + return decode_bytes(payload, message.get_content_charset()) + + +def _quote(text: str) -> str: + lines = text.splitlines()[:MAX_QUOTE_LINES] + return "\n".join(f"> {line}" for line in lines) + + +def build_reply( + account, + message, + body: str, + *, + reply_all: bool = False, + date: str | None = None, + msgid: str | None = None, +) -> EmailMessage: + subject = message.get("Subject", "") + if not subject.lower().startswith("re:"): + subject = f"Re: {subject}" + + to = [message.get("Reply-To") or message.get("From", "")] + cc = [] + if reply_all: + mine = account.email.lower() + others = [ + addr + for addr in _addresses(message.get("To"), message.get("Cc")) + if addr.lower() != mine + ] + already = {a.lower() for a in _addresses(*to)} + cc = [a for a in others if a.lower() not in already] + + parent_id = (message.get("Message-ID") or "").strip() + references = " ".join( + part + for part in [(message.get("References") or "").strip(), parent_id] + if part + ) + + return build_message( + account, + to, + subject, + f"{body}\n\n{_quote(_plain_text(message))}\n", + cc=cc, + in_reply_to=parent_id or None, + references=references or None, + date=date, + msgid=msgid, + ) + + +def build_forward( + account, + message, + to, + body: str, + *, + date: str | None = None, + msgid: str | None = None, +) -> EmailMessage: + subject = message.get("Subject", "") + if not subject.lower().startswith("fwd:"): + subject = f"Fwd: {subject}" + msg = build_message(account, to, subject, body, date=date, msgid=msgid) + forwarded = message + if not isinstance(forwarded, EmailMessage): + import email + + forwarded = email.message_from_bytes( + message.as_bytes(), _class=EmailMessage + ) + # `add_attachment` dispatche vers `set_message_content` pour un `Message` : + # ce gestionnaire n'accepte pas `maintype` (toujours "message" pour lui), + # seulement `subtype` — le passer lève `TypeError` à chaque appel. + msg.add_attachment(forwarded, subtype="rfc822") + return msg + + +def recipients(msg) -> list[str]: + """Les destinataires d'enveloppe : To, Cc et le Cci gardé à part.""" + seen, out = set(), [] + for addr in _addresses( + msg.get("To"), msg.get("Cc"), msg.get("X-ERPLibre-Bcc") + ): + low = addr.lower() + if low not in seen: + seen.add(low) + out.append(addr) + return out + + +class SmtpTransport(Protocol): + def send_message( + self, msg, from_addr: str, to_addrs: list[str] + ) -> None: ... + + def quit(self) -> None: ... + + +class SmtplibTransport: + def __init__(self, client) -> None: + self.client = client + + def send_message(self, msg, from_addr: str, to_addrs: list[str]) -> None: + self.client.send_message(msg, from_addr=from_addr, to_addrs=to_addrs) + + def quit(self) -> None: + try: + self.client.quit() + except Exception: + # Best-effort : la connexion peut déjà être fermée par le serveur. + pass + + +def connect(account, password: str) -> SmtplibTransport: + import smtplib + + conf = account.smtp + try: + if conf.security == "ssl": + client = smtplib.SMTP_SSL(conf.host, conf.port, timeout=30) + else: + client = smtplib.SMTP(conf.host, conf.port, timeout=30) + if conf.security == "starttls": + client.starttls() + client.login(conf.user, password) + except Exception as exc: + # Toute panne réseau ou d'authentification devient une seule erreur + # de haut niveau, pour un message utile à l'utilisateur. + raise SmtpError( + f"{t('mail_err_smtp_connection_prefix')} {conf.host}" + f" {t('mail_err_connection_refused_suffix')} {exc}" + ) from exc + return SmtplibTransport(client) + + +def send(account, msg, transport: SmtpTransport) -> list[str]: + """Remet le message. Rend les destinataires servis, lève `SmtpError` sinon.""" + to_addrs = recipients(msg) + if not to_addrs: + raise SmtpError(t("mail_err_no_recipient_nothing_sent")) + outgoing = without_bcc(msg) + try: + transport.send_message(outgoing, account.email, to_addrs) + except Exception as exc: + # Le serveur peut refuser pour mille raisons (auth, quota, + # destinataire rejeté) : une seule erreur de haut niveau, avec le + # texte du serveur conservé pour l'utilisateur. + raise SmtpError(f"{t('mail_err_send_refused')} {exc}") from exc + return to_addrs + + +def without_bcc(msg): + """Une copie sans le porte-Cci interne. + + PUBLIQUE à dessein : `send()` n'est pas le seul chemin par lequel le + message quitte la machine. La copie déposée dans le dossier Envoyés part + par IMAP, et si elle gardait `X-ERPLibre-Bcc` le Cci serait lisible sur le + serveur — la même fuite, par une autre porte. + """ + if msg.get("X-ERPLibre-Bcc") is None: + return msg + import copy + + clone = copy.deepcopy(msg) + del clone["X-ERPLibre-Bcc"] + return clone diff --git a/script/todo/mail/store.py b/script/todo/mail/store.py new file mode 100644 index 0000000..eba5c74 --- /dev/null +++ b/script/todo/mail/store.py @@ -0,0 +1,624 @@ +#!/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) diff --git a/script/todo/mail/tui.py b/script/todo/mail/tui.py new file mode 100644 index 0000000..628da49 --- /dev/null +++ b/script/todo/mail/tui.py @@ -0,0 +1,2701 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Le client courriel à l'écran : trois volets, et la lecture en plein écran. + +Textual s'importe DANS `run_tui`, jamais au niveau module — c'est le motif du +reste de `script/todo/`. Conséquence utile : la moitié basse de ce fichier +(sessions, dossiers) se teste sans écran et sans dépendance. + +Une session = un compte ouvert. Elle survit à une panne réseau : le cache +s'ouvre d'abord, la connexion est tentée ensuite, et son échec ne fait que +poser un drapeau `online = False`. Une boîte hors ligne reste lisible. +""" +from __future__ import annotations + +import logging +import threading +from dataclasses import dataclass + +from script.todo.mail.imap_sync import Syncer +from script.todo.mail.store import Store, sweep_orphan_ephemeral + +try: + from script.todo.todo_i18n import t +except Exception: # pragma: no cover - repli si i18n indisponible + + def t(key: str) -> str: + return key + + +_logger = logging.getLogger(__name__) + + +ROLE_ORDER = { + "inbox": 0, + "drafts": 1, + "sent": 2, + "archive": 3, + "junk": 4, + "trash": 5, +} + +# `data` du nœud "+ Ajouter un compte" au pied de l'arbre des comptes — un +# marqueur, pas une donnée métier, distingué d'un `MailboxRef` par son type. +ADD_ACCOUNT_NODE = "__add_account__" + +# Les dispositions de volets, dans l'ordre de cycle de la touche `v`. Chaque +# entrée est (identifiant, clé i18n du nom affiché) — AUCUNE liste de +# `if layout == ...` ailleurs dans ce module : l'arrangement vient d'une +# classe CSS (`layout-{id}`) posée sur `#panes`, voir la CSS de `MailApp`. +# Ajouter une disposition = ajouter UNE entrée ici et UN bloc CSS assorti. +MAIL_LAYOUTS = [ + ("columns", "mail_layout_columns"), + ("split", "mail_layout_split"), + ("stacked", "mail_layout_stacked"), +] +_LAYOUT_IDS = [layout_id for layout_id, _ in MAIL_LAYOUTS] +_LAYOUT_I18N_KEYS = dict(MAIL_LAYOUTS) + + +def resolve_layout(value: str) -> str: + """La disposition retenue pour `value`, sinon `columns`. + + Même motif que `store.resolve_mode` pour `mail_cache_mode` : une valeur + absente ou corrompue dans `todo_prefs` (édition manuelle, ancienne + version qui listait moins de dispositions) ne doit jamais empêcher le + client de s'ouvrir — elle retombe sur la disposition par défaut. + """ + return value if value in _LAYOUT_IDS else _LAYOUT_IDS[0] + + +def next_layout(current: str) -> str: + """La disposition suivante dans le cycle, avec retour au début après la + dernière. `resolve_layout` d'abord : une valeur inconnue ne doit pas + lever `ValueError` dans `.index()`, elle doit se comporter comme si on + partait de la première disposition. + """ + index = _LAYOUT_IDS.index(resolve_layout(current)) + return _LAYOUT_IDS[(index + 1) % len(_LAYOUT_IDS)] + + +# Chaque disposition a EXACTEMENT deux volets ajustables : `folders` (face à +# `#right`) et `list_pane` (face à `#preview`). Le voisin de chaque paire +# reste TOUJOURS `1fr` et n'est jamais stocké — c'est le modèle Textual qui +# lui redonne ce que l'autre cède, donc « agrandir un volet rétrécit son +# voisin » ne demande aucune comptabilité couplée. La DIMENSION que ce +# nombre ajuste (largeur ou hauteur) n'est PAS une table Python par +# disposition : elle se lit, à l'application, sur la disposition CSS +# réellement posée (`styles.layout.name` du conteneur parent) — voir +# `MailApp._pane_dimension`. Ajouter une disposition ne touche donc rien +# ici, seulement le bloc CSS de `MAIL_LAYOUTS`. +_PANE_SLOTS = ("folders", "list_pane") +PANE_SIZE_MIN = 4 # cellules ; plancher commun largeur/hauteur. +PANE_SIZE_STEP = 4 + +# Barre de partage (tâche 25) : un widget FEUILLE, de taille FIXE, inséré +# ENTRE `slot` et son voisin `1fr` — glissable à la souris, mais +# n'appartenant à NI L'UN NI L'AUTRE des deux volets qu'il sépare, jamais +# stocké, jamais lui-même ajustable. `_SPLITTER_IDS` donne l'identifiant DOM +# de la barre propre à chaque volet réglable : un widget créé UNE FOIS +# dans `compose()`, jamais reconstruit au changement de disposition — seule +# son orientation (largeur ou hauteur) suit celle du volet qu'il jouxte, en +# CSS, un bloc par disposition comme les volets eux-mêmes (voir la CSS de +# `MailApp`) — jamais une table Python par disposition. +_SPLITTER_IDS = {"folders": "folders_splitter", "list_pane": "list_splitter"} +# cellules ; DOIT rester en phase avec la CSS (`width: 1`/`height: 1` de +# chaque barre). `MailApp._pane_total` ne s'en sert PAS — elle mesure le +# widget réel, donc son calcul du plafond est immunisé contre un futur +# désaccord entre les deux. `_PANE_SIBLING_MIN["folders"]` ci-dessous, en +# revanche, additionne CETTE constante directement (un dict de module ne +# peut pas mesurer un widget) : si la CSS change sans que cette valeur ne +# suive, seul `_PANE_SIBLING_MIN` dérive silencieusement, pas `_pane_total`. +_SPLITTER_SIZE = 1 + +# `#right` n'est PAS un volet-feuille comme `#preview` : il héberge à son +# tour `list_pane`/`preview` ET la barre de partage qui les sépare +# (`#list_splitter`), qui ont chacun besoin d'au moins `PANE_SIZE_MIN` +# (`_SPLITTER_SIZE` pour la barre). Ne réserver que `PANE_SIZE_MIN` au +# voisin de `#folders` suffirait à ne pas écraser `#right` LUI-MÊME, mais +# pas à empêcher ses enfants de s'écraser l'un l'autre une fois `#right` +# réduit à ce seul plancher — d'où le double, `+1` pour la barre. `list_pane` +# n'a pas ce problème : son voisin (`#preview`) est une feuille, sans enfant +# ni barre à protéger derrière lui. Ce n'est pas une branche PAR +# DISPOSITION : `#right` héberge toujours la même paire imbriquée, dans les +# trois dispositions (le DOM fixe de la tâche 23) — c'est un fait de +# STRUCTURE, pas de disposition. `+ _SPLITTER_SIZE` compte UN SEUL enfant +# fixe connu (`#list_splitter`) — pas une formule pour un nombre arbitraire +# d'enfants futurs : si `#right` héberge un jour autre chose que +# `list_pane` + `list_splitter` + `preview`, cette valeur devra être revue +# à la main, comme `_pane_total` ci-dessous (même limite, même raison). +_PANE_SIBLING_MIN = { + "folders": 2 * PANE_SIZE_MIN + _SPLITTER_SIZE, + "list_pane": PANE_SIZE_MIN, +} + +# Le plancher de `#list_pane` est une CONTRAINTE DE MISE EN PAGE, pas une +# correction mesurée puis reposée en Python : il n'y a RIEN à mesurer pour le +# tenir, donc plus aucune mesure à lire trop tôt. C'est ce qui a retiré +# (tâche 27) la relecture de région post-effacement qui laissait `list_pane` +# figé sous son plancher. +# +# UN SEUL enfant `fr` contraint PAR CONTENEUR — c'est une règle, pas une +# économie. `resolve_fraction_unit` (`_resolve.py:190-214`) épingle à son +# minimum chaque enfant `fr` qui descendrait sous lui ET le RETIRE du +# réservoir de fractions ; si TOUS les frères `fr` s'épinglent, +# `remaining_fraction` tombe à zéro et la fonction rend `initial_space` — +# c'est-à-dire que `1fr` vaut alors TOUT l'espace restant, et CHAQUE frère +# est dimensionné à la totalité. En « stacked » sur 80x20, `#list_pane` et +# `#preview` (`1fr` chacun) voulaient 3,5 pour un plancher de 4 : les deux +# s'épinglaient, les deux recevaient 7 dans un conteneur de 8, et `#preview` +# commençait une ligne SOUS le bas de `#panes` — jamais composité, aucune +# barre de défilement, `Tab` ne l'atteignant pas. Ne contraindre que +# `#list_pane` laisse toujours `#preview` dans le réservoir : le cas « tous +# épinglés » devient INATTEIGNABLE, et `#preview` absorbe le reste. +# +# Le plancher de `#preview`, lui, ne vient pas d'une règle CSS mais de la +# réserve faite à `#right` (`_PANE_SIBLING_MIN["folders"]`, appliquée par +# `_apply_pane_size_for_slot`) : `#right` gardant au moins deux planchers +# plus la barre, le partage `fr` qui suit donne au moins son plancher à +# chacun de ses deux enfants sans que personne n'ait à s'épingler. +# +# `border-box` (le défaut) : `_resolve_extrema` (`widget.py:2489-2493`) retire +# la bordure du minimum, et `_get_box_model` (`widget.py:1814`, `1862`) +# l'applique APRÈS avoir résolu l'échelle, `fr` compris — `min-width: N` veut +# donc bien dire `region.width >= N`, la convention que tout ce fichier mesure +# sur `.region`. Une réserve toutefois : `constrain_width` +# (`widget.py:1829-1830`) s'applique APRÈS le minimum, et seul +# `layouts/grid.py:337` l'active — poser un jour `layout: grid` sur `#panes` +# ou `#right` annulerait donc ce plancher en silence. +# +# GÉNÉRÉ depuis la constante, jamais recopié : une valeur en dur ici +# dériverait en silence le jour où `PANE_SIZE_MIN` change, et le plancher +# affiché ne serait plus celui que `clamp_pane_size` fait respecter. +_PANE_MIN_CSS = f""" + #list_pane {{ + min-width: {PANE_SIZE_MIN}; + min-height: {PANE_SIZE_MIN}; + }} +""" + + +def clamp_pane_size( + value, + total: int | None, + minimum: int = PANE_SIZE_MIN, + sibling_minimum: int | None = None, +) -> int | None: + """`value`, borné pour ne jamais écraser le volet NI son voisin. + + Rend `None` quand `total` (l'espace total dont ce volet et son voisin se + partagent) est inconnu ou non positif — un widget pas encore rendu, + notamment — puisqu'il n'y a alors aucune borne calculable. Sinon, + jamais sous `minimum`, et jamais au point de laisser le voisin sous + `sibling_minimum` (par défaut `minimum` — un voisin ordinaire ; plus, + pour `folders`, quand ce voisin est lui-même un conteneur à protéger, + voir `_PANE_SIBLING_MIN`). + """ + if total is None or total <= 0: + return None + if sibling_minimum is None: + sibling_minimum = minimum + ceiling = max(minimum, total - sibling_minimum) + return max(minimum, min(int(value), ceiling)) + + +def resolve_pane_sizes(stored, layout_id: str) -> dict: + """Les tailles personnalisées de `layout_id` dans `stored` + (`todo_prefs.get("mail_pane_sizes")`), filtrées et validées. + + Même motif que `resolve_layout`/`store.resolve_mode` : un magasin + absent, du mauvais type, une entrée de disposition absente ou du + mauvais type, une clé de volet inconnue, ou une valeur non numérique, + booléenne (un `bool` EST un `int` en Python — jamais une taille valide) + ou non positive ne lèvent jamais — ils sont silencieusement ignorés, + laissant le volet concerné à la valeur de sa feuille de style. + """ + per_layout = stored.get(layout_id) if isinstance(stored, dict) else None + if not isinstance(per_layout, dict): + return {} + result = {} + for slot in _PANE_SLOTS: + value = per_layout.get(slot) + if ( + isinstance(value, (int, float)) + and not isinstance(value, bool) + and value > 0 + ): + result[slot] = int(value) + return result + + +@dataclass +class MailboxRef: + account_name: str + folder_name: str + display: str + unseen: int + + +class Session: + """Un compte ouvert : son cache, et son lien réseau s'il tient.""" + + def __init__( + self, + account, + store: Store, + syncer: Syncer | None, + error: str = "", + password: str = "", + ): + self.account = account + self.store = store + self.syncer = syncer + self.error = error + self.password = password + + @property + def online(self) -> bool: + return self.syncer is not None + + def sync(self, progress=None): + if self.syncer is None: + return None + return self.syncer.sync(progress=progress) + + def close(self) -> None: + if self.syncer is not None: + try: + self.syncer.transport.logout() + except Exception: + pass + if self.store is not None: + if self.store.mode == "ephemeral": + self.store.cleanup() + else: + self.store.close() + + +def open_session(account, secrets, base=None, connect_fn=None) -> Session: + """Ouvre le cache d'UN compte, puis tente le réseau — voir + `open_sessions` pour l'ordre et sa justification, identique ici. + + Extrait du corps de boucle d'`open_sessions` pour être rappelable seul : + le TUI s'en sert après l'ajout d'un compte, sans ré-ouvrir tous les + autres ni ré-enregistrer les gestionnaires de signaux (voir + `_register_ephemeral_cleanup`). + """ + if connect_fn is None: + from script.todo.mail.imap_transport import connect as connect_fn + + store = Store(account, secrets=secrets, base=base) + try: + store.open() + except Exception as exc: + # Un cache corrompu, une clé introuvable ou un disque plein ne + # doivent pas empêcher les AUTRES comptes de s'ouvrir. On garde la + # session, sans cache, avec son erreur affichable — c'est le même + # principe que pour une panne réseau, appliqué au disque. + return Session(account, None, None, str(exc), "") + + syncer, error, password = None, "", "" + try: + password = secrets.get(account.secret_ref) or "" + if not password: + raise ValueError(t("mail_no_password_stored")) + syncer = Syncer(store, connect_fn(account, password)) + except Exception as exc: + error = str(exc) + return Session(account, store, syncer, error, password) + + +def open_sessions( + accounts, secrets, base=None, connect_fn=None +) -> list[Session]: + """Ouvre le cache de chaque compte actif, puis tente le réseau. + + L'ordre compte : un mot de passe absent ou un serveur muet ne doit pas + priver l'utilisateur de ce qu'il a déjà téléchargé. + """ + if connect_fn is None: + from script.todo.mail.imap_transport import connect as connect_fn + + sweep_orphan_ephemeral() + sessions = [ + open_session(account, secrets, base=base, connect_fn=connect_fn) + for account in accounts + if account.enabled + ] + _register_ephemeral_cleanup(sessions) + return sessions + + +def _register_ephemeral_cleanup(sessions) -> None: + """`atexit` ne s'exécute pas sur un signal, et un cache éphémère qui + survit au processus est exactement ce que le mode promet d'éviter.""" + import atexit + import os + import signal + + targets = [ + s + for s in sessions + if s.store is not None and s.store.mode == "ephemeral" + ] + if not targets: + return + + def _cleanup(*_): + for session in targets: + try: + session.store.cleanup() + except Exception: + # Sortie best-effort : on ne relève jamais depuis un handler. + pass + + atexit.register(_cleanup) + for sig in (signal.SIGINT, signal.SIGTERM): + previous = signal.getsignal(sig) + + def _chained(signum, frame, _previous=previous): + _cleanup() + if callable(_previous): + _previous(signum, frame) + return + # `SIG_DFL` et `SIG_IGN` sont des entiers, pas des appelables : + # s'arrêter là AVALERAIT le signal, et un `kill` ou un + # `systemctl stop` ne terminerait plus le processus. On + # réinstalle la disposition d'origine puis on se renvoie le + # signal, pour que l'action par défaut ait bien lieu — après le + # nettoyage. + signal.signal(signum, _previous) + os.kill(os.getpid(), signum) + + try: + signal.signal(sig, _chained) + except ValueError: + # `signal.signal` n'est utilisable que depuis le thread principal. + pass + + +def mailbox_refs(sessions: list[Session]) -> list[MailboxRef]: + """Les dossiers de tous les comptes, boîte de réception en tête.""" + refs = [] + for session in sessions: + if session.store is None: + # Compte dont le cache n'a pas pu s'ouvrir : il apparaît dans + # l'arbre avec son erreur, mais il n'a aucun dossier à lister. + continue + folders = sorted( + session.store.folders(), + key=lambda f: (ROLE_ORDER.get(f["role"], 99), f["name"].lower()), + ) + for folder in folders: + refs.append( + MailboxRef( + account_name=session.account.name, + folder_name=folder["name"], + display=folder["display"] or folder["name"], + unseen=folder["unseen"] or 0, + ) + ) + return refs + + +def save_attachment(raw: bytes, index: int, directory) -> "pathlib.Path": + """Écrit la pièce jointe `index` du message dans `directory`. + + Le nom vient du message, donc d'une source non fiable : on n'en garde que + le nom de base, et on refuse de sortir du dossier demandé. + """ + import email + import email.policy + import pathlib + + from script.todo.mail.tui_text import extract_body + + _, attachments = extract_body(raw) + match = next((a for a in attachments if a.index == index), None) + if match is None: + raise ValueError(t("mail_attachment_not_found")) + + message = email.message_from_bytes(raw, policy=email.policy.default) + parts = [ + part + for part in (message.walk() if message.is_multipart() else [message]) + if part.get_content_maintype() != "multipart" + and (part.get_content_disposition() or "").lower() + in ("attachment", "inline") + ] + payload = parts[index].get_payload(decode=True) or b"" + + directory = pathlib.Path(directory).expanduser() + directory.mkdir(parents=True, exist_ok=True) + target = directory / pathlib.Path(match.filename).name + target.write_bytes(payload) + return target + + +def parse_recipients(raw: str) -> list[str]: + """« a@y.ca; Alice » → deux entrées. Virgule ou point-virgule.""" + if not raw: + return [] + parts = raw.replace(";", ",").split(",") + return [p.strip() for p in parts if p.strip()] + + +def parse_paths(raw: str) -> list[str]: + """« a.pdf; "Facture, T3.pdf" » → deux chemins. + + Point-virgule SEUL : contrairement à un destinataire, une virgule est + légale dans un nom de fichier — la scinder dessus aussi transformerait + silencieusement un seul fichier en deux chemins inexistants. + """ + if not raw: + return [] + out = [] + for part in raw.split(";"): + part = part.strip() + if not part: + continue + if len(part) >= 2 and part[0] == part[-1] and part[0] in "'\"": + part = part[1:-1] + out.append(part) + return out + + +def append_attachment_path(current: str, new_path: str) -> str: + """Ajoute `new_path` au champ `#files`, séparé par le « ; » qu'attend + `parse_paths`. + + Le champ peut être vide, contenir déjà un ou plusieurs chemins, ou finir + par un « ; » — dans tous les cas un seul séparateur sépare l'ancien + contenu du nouveau, jamais un « ; » en tête ni doublé. + """ + current = (current or "").strip() + if not current: + return new_path + if current.endswith(";"): + return f"{current} {new_path}" + return f"{current}; {new_path}" + + +def edit_in_external_editor( + text: str, editor: str | None = None, runner=None +) -> str: + """Ouvre `$EDITOR` sur le corps et rend ce qui en revient. + + Si l'éditeur manque ou sort en erreur, on garde le texte de départ : perdre + un brouillon parce que `vim` n'est pas installé serait inacceptable. + """ + import os + import subprocess + import tempfile + + editor = editor or os.environ.get("EDITOR") or "nano" + runner = runner or (lambda cmd: subprocess.call(cmd)) + handle = tempfile.NamedTemporaryFile( + "w", suffix=".txt", delete=False, encoding="utf-8" + ) + path = handle.name + try: + handle.write(text or "") + handle.close() + try: + code = runner([editor, path]) + except Exception: + return text + if code != 0: + return text + with open(path, encoding="utf-8") as opened: + return opened.read() + finally: + try: + os.unlink(path) + except OSError: + pass + + +def resolve_sent_folder(session) -> str: + """Le dossier Envoyés tel que le SERVEUR l'a annoncé. + + Le préréglage n'est qu'une supposition : un serveur peut nommer le sien + « INBOX.Sent », « Sent Items » ou autrement, et il le déclare lui-même + par l'attribut \\Sent que `parse_list_line` traduit en rôle. On croit + donc le serveur d'abord, et le préréglage seulement s'il n'a rien dit — + par exemple avant la toute première synchronisation. + """ + if session.store is not None: + try: + folders = session.store.folders() + except Exception: + # Un cache verrouillé ou corrompu ne doit jamais transformer un + # envoi déjà réussi en échec signalé au niveau du bouton + # Envoyer : le contrat de cette fonction ("le préréglage si on + # ne sait pas mieux") doit rester vrai même quand « ne pas + # savoir » vient d'une exception plutôt que d'une absence de + # rôle. + folders = [] + for folder in folders: + if folder["role"] == "sent": + return folder["name"] + return session.account.sent_folder + + +def deliver(session, msg, send_fn=None, connect_fn=None) -> str: + """Envoie, puis dépose une copie dans Envoyés. Rend le texte de statut. + + L'ordre n'est pas négociable : l'APPEND vient APRÈS l'envoi, et son échec + n'annule rien. Le message est déjà parti ; le signaler comme un échec + pousserait l'utilisateur à l'envoyer deux fois. + """ + from script.todo.mail.smtp_send import SmtpError + from script.todo.mail.smtp_send import connect as smtp_connect + from script.todo.mail.smtp_send import send as smtp_send_fn + from script.todo.mail.smtp_send import without_bcc + + if not session.online: + raise SmtpError(t("mail_offline_cannot_send")) + + send_fn = send_fn or smtp_send_fn + transport = None + if send_fn is smtp_send_fn: + connect_fn = connect_fn or smtp_connect + transport = connect_fn(session.account, session.password) + try: + served = send_fn(session.account, msg, transport) + finally: + if transport is not None: + transport.quit() + + status = f"{t('mail_sent_to')} {', '.join(served)}" + sent_folder = resolve_sent_folder(session) + try: + # Le Cci ne doit pas ressortir par cette porte non plus : `send()` + # l'a déjà retiré avant l'envoi SMTP, mais la copie déposée ici part + # par IMAP — sans `without_bcc`, le Cci redeviendrait un en-tête + # lisible sur le serveur. + session.syncer.transport.append( + sent_folder, + without_bcc(msg).as_bytes(), + ["\\Seen"], + ) + except Exception as exc: + _logger.exception( + "%s : APPEND vers %r a échoué", session.account.name, sent_folder + ) + # En SUFFIXE, cet échec disparaissait en bout d'une ligne de statut + # qui peut être longue (plusieurs destinataires) — sur la barre + # d'une seule ligne de haut, la fin est justement ce qui se perd. En + # PRÉFIXE, en gras rouge, il reste visible même tronqué. + status = f"[b red]⚠ {t('mail_sent_not_filed')} ({exc})[/] — {status}" + else: + # Écriture locale immédiate (design, ligne 308) : une sync ciblée + # sur Envoyés seul, pour que le message apparaisse sans attendre la + # prochaine passe complète. `sync_one` ne lève jamais — voir sa + # docstring — donc un envoi déjà réussi ne peut pas se lire comme un + # échec parce que cette relecture aurait raté. + session.syncer.sync_one(sent_folder) + return status + + +_LOG_TAIL_LINES = 200 + + +def read_log_tail( + path, max_lines: int = _LOG_TAIL_LINES +) -> tuple[list[str], str]: + """Les `max_lines` dernières lignes de `path`, SANS le charger en + entier — un journal grossit sans limite pendant toute une session, et + l'ouvrir en entier après plusieurs heures ferait attendre l'utilisateur + sur des dizaines de mégaoctets pour n'en montrer que la fin. On lit + donc par blocs DEPUIS LA FIN du fichier, jusqu'à tenir assez de sauts de + ligne. + + Rend `(lignes, message)` : `message` est vide quand `lignes` est + utilisable, sinon il dit POURQUOI elle ne l'est pas — absent, vide, + illisible. Une fenêtre qui s'ouvre en silence sur une liste vide + reproduirait exactement la plainte que cette fonction existe pour + résoudre : « j'ai une erreur, mais aucun log ». + """ + if not path.exists(): + return [], t("mail_log_missing") + try: + size = path.stat().st_size + if size == 0: + return [], t("mail_log_empty") + chunk_size = 8192 + data = b"" + with open(path, "rb") as handle: + remaining = size + while remaining > 0 and data.count(b"\n") <= max_lines: + step = min(chunk_size, remaining) + remaining -= step + handle.seek(remaining) + data = handle.read(step) + data + except OSError as exc: + # Frontière de résilience DÉLIBÉRÉE : permissions refusées, fichier + # supprimé entre le `exists()` et l'ouverture, dossier au lieu d'un + # fichier, etc. — lire le journal ne doit JAMAIS pouvoir faire + # tomber le client, l'exacte raison pour laquelle cette fenêtre + # existe. + return [], f"{t('mail_log_unreadable')} : {exc}" + # `errors="replace"` : le journal peut contenir des octets qui ne sont + # pas de l'UTF-8 valide (texte serveur reproduit tel quel dans un + # message d'exception) — jamais une raison de faire échouer la lecture. + lines = data.decode("utf-8", "replace").splitlines()[-max_lines:] + if not lines: + # `size > 0` au `stat()` ci-dessus ne garantit PAS que `data` soit + # non vide ici : le fichier peut avoir été TRONQUÉ entre le + # `stat()` et la lecture (rotation de journal, notamment) — + # `read()` rend alors `b""` malgré une taille annoncée non nulle. + # Sans cette garde, ce cas silencieux (aucune ligne, aucun + # message) reproduirait exactement la plainte que cette fonction + # existe pour résoudre. + return [], t("mail_log_empty") + return lines, "" + + +def run_tui( + run_app: bool = True, + sessions=None, + config_file=None, + secret_store=None, + connect_fn=None, + base=None, +) -> None: + """Ouvre le client. `run_app=False` construit l'application sans la lancer, + ce qui permet de vérifier qu'elle se compose sans écran. + + `config_file`/`secret_store` restent optionnels : sans eux, l'écran + d'ajout de compte se refuse poliment plutôt que de planter — c'est le cas + des tests qui montent l'application sans passer par `menu._open_tui`. + """ + try: + # `rich` dans le MÊME `try` que `textual` : Textual en dépend + # durement (ses propres modules l'importent partout, et `Static` + # accepte un rendu Rich), donc l'absence de l'un ou de l'autre se + # soigne par le même « installez textual » ci-dessous. `Table` sert + # à la fenêtre d'aide (`HelpScreen`), la seule vue de ce module qui + # ait besoin d'une colonne qui se replie sans casser l'alignement. + from rich.table import Table + from rich.text import Text + from textual.app import App, ComposeResult + from textual.binding import Binding + from textual.containers import ( + Container, + Horizontal, + Vertical, + VerticalScroll, + ) + from textual.screen import ModalScreen + from textual.widgets import ( + Button, + DataTable, + Footer, + Header, + Input, + Log, + Select, + Static, + TextArea, + Tree, + ) + except ImportError: + print(t("mail_install_textual")) + return + + from script.todo import todo_prefs + from script.todo.mail import account_setup + from script.todo.mail import accounts as mail_accounts + from script.todo.mail import tui_text + from script.todo.mail.accounts import PRESETS + from script.todo.mail.secrets import SecretStore + + class _SearchInput(Input): + """Le champ de recherche : Échap le vide plutôt que de remonter à + `MailApp` — visible au pied d'écran (`show=True`, par défaut) SEULEMENT + tant que ce champ a le focus, contrairement au « Retour » (plein + écran) de `MailApp`, qui reste cette liaison globale, masquée + (`show=False`), et inchangée. + + Textual fusionne les liaisons du nœud focalisé et de ses ancêtres + pour une même touche, et donne priorité à la plus proche du focus + (`Screen._binding_chain`, vérifié dans la source de Textual) : la + liaison ci-dessous intercepte donc Échap avant que `MailApp` ne la + voie, sans code de répartition à écrire à la main — et sa + description, traduite, n'apparaît au pied d'écran QUE lorsque ce + champ est le nœud focalisé, exactement le moment où l'action est + pertinente. + """ + + BINDINGS = [Binding("escape", "clear", t("mail_search_clear"))] + + def action_clear(self) -> None: + self.app.clear_search() + + class _PanesContainer(Container): + """`#panes` : reborne les tailles de volets à chaque redimensionnement + RÉEL du terminal (rétrécir la fenêtre sans toucher à aucun volet ne + passe par aucune action clavier/souris — sans ce point d'entrée, un + volet grandi resterait figé à son ancienne taille et écraserait son + voisin jusqu'à zéro, sans qu'aucune touche ne puisse le récupérer : + le plafond du volet écrasé se calculerait alors contre SA PROPRE + région, déjà nulle). + + `Resize` (`bubble=False`, `events.py`) est envoyé DIRECTEMENT au + widget dont la taille vient de changer — pas à `MailApp` : un essai + avec `on_resize` sur `MailApp` a mesuré `#panes.region` encore à + L'ANCIENNE taille au moment où le gestionnaire tournait (le + redimensionnement RÉEL de l'écran, posté par `App._check_resize` + vers `Screen`, n'a lieu qu'après un minuteur interne de `1/120` s — + l'App reçoit l'évènement AVANT ce minuteur, pas après). Ici, en + recevant l'évènement DE `#panes` lui-même, `self.region` est + garanti à jour : c'est précisément ce dont ce widget vient de nous + informer. + """ + + def on_resize(self, event) -> None: + self.app._apply_pane_sizes() + + class _PaneSplitter(Static): + """Barre de partage entre `slot` et son voisin — glissable à la + souris (tâche 25) pour redimensionner les deux, en direct. + + AUCUN état de glissement propre : il vit entièrement sur `MailApp` + (`_begin_pane_drag`/`_drag_pane_to`/`_end_pane_drag`), qui possède + déjà `_pane_widgets`/`_pane_dimension`/`_store_pane_size` — une + taille glissée traverse donc le MÊME `_store_pane_size` que `+`/`-` + au clavier (tâche 24), jamais un second magasin. + + `can_focus = False` : ce n'est pas un widget de navigation clavier — + `+`/`-`/`0` redimensionnent déjà le volet qui a le focus, et ajouter + cette barre à la chaîne de tabulation lui donnerait le focus par + défaut (`Screen.AUTO_FOCUS`) sans lui offrir la moindre action au + clavier. + + La capture de souris (`Widget.capture_mouse`, `App.capture_mouse`, + vérifiées dans la source de Textual 8.2.8) fait que `MouseMove`/ + `MouseUp` atteignent CETTE barre même quand le pointeur a quitté sa + région d'un seul cellule — `Screen._forward_event` redirige tout + évènement souris vers `self.app.mouse_captured` dès qu'il est posé, + quelle que soit la position réelle du pointeur — exactement ce + qu'un glissement demande. `on_mouse_release` (PAS seulement + `on_mouse_up`) termine aussi le glissement : `App.capture_mouse` + poste `MouseRelease` à CE widget dès que la capture change, y + compris vers `None` — ce qu'`App.push_screen` fait explicitement + avant d'empiler un écran modal (`l`/`c`/`n`/le coffre), sans jamais + poster de `MouseUp` — voir `on_mouse_release` ci-dessous pour la + conséquence si ce signal n'est pas reçu. + """ + + can_focus = False + + def __init__(self, slot: str, **kwargs) -> None: + # `classes="pane-splitter"` posée ici, pas laissée au site + # d'appel : les DEUX barres (`compose()`) partagent ainsi le + # même style (voir `.pane-splitter` dans la CSS de `MailApp`) + # sans dépendre d'un `classes=` répété à chaque + # `yield _PaneSplitter(...)`. + super().__init__("", classes="pane-splitter", **kwargs) + self._slot = slot + self.tooltip = t("mail_pane_splitter_tooltip") + + def on_mouse_down(self, event) -> None: + event.stop() + self.capture_mouse() + self.app._begin_pane_drag( + self._slot, event.screen_x, event.screen_y + ) + + def on_mouse_move(self, event) -> None: + self.app._drag_pane_to(event.screen_x, event.screen_y) + + def on_mouse_up(self, event) -> None: + self.app._end_pane_drag() + + def on_mouse_release(self, event) -> None: + # `App.capture_mouse` (`app.py:3222`, vérifié dans la source de + # Textual 8.2.8) poste TOUJOURS `MouseRelease` au widget qui + # ÉTAIT capturé dès que la capture change — y compris vers + # `None`, ce que `App.push_screen` fait EXPLICITEMENT + # (`app.py:2937`) avant d'empiler un nouvel écran modal, SANS + # jamais poster de `MouseUp`. Un glissement encore actif au + # moment où `l`/`c`/`n`/le coffre pousse un écran perdrait donc + # sa capture sans que `_end_pane_drag` ne tourne -- l'état de + # glissement de `MailApp` resterait pointé sur CE volet, et le + # tout premier `MouseMove` (synthétique, précédant son propre + # `MouseDown`) d'un glissement SUIVANT et SANS RAPPORT, ailleurs, + # lui serait appliqué par erreur avant que son `MouseDown` n'ait + # eu la chance de corriger l'état. Même point de sortie que + # `on_mouse_up`, `on_app_blur` et `action_toggle_fullscreen` : + # `_end_pane_drag` est déjà idempotent (`_drag_slot` déjà `None` + # ne fait rien), donc le recevoir en plus d'un `on_mouse_up` + # normal (qui appelle lui-même `capture_mouse(None)`, et déclenche + # donc AUSSI ce `MouseRelease`) est sans danger. + self.app._end_pane_drag() + + class MailApp(App): + CSS = """ + #panes { height: 1fr; } + #preview { padding: 0 1; } + #status { height: 1; background: $panel; } + #search_row { display: none; height: auto; } + #search_row.visible { display: block; } + #search_row > Input { width: 1fr; } + #search_row > Button { width: auto; } + /* `#list_pane`, pas `#list` : le plein écran doit effacer le + VOLET (recherche + liste), pas seulement son contenu — sinon le + conteneur garde la taille que lui donne le bloc de la disposition + active, et laisse un bloc vide à la place de la liste cachée. Une + seule règle, hors de tout bloc de disposition : elle vaut pour les + trois, et pour toute disposition ajoutée plus tard. Les deux barres + de partage (tâche 25) suivent le même sort : plein écran ne laisse + plus qu'UN volet, rien à partager tant qu'il dure — `action_toggle_ + fullscreen` termine d'ailleurs tout glissement en cours avant de + poser cette classe, une barre masquée ne pouvant plus recevoir son + `MouseUp`. */ + .fullscreen #folders, .fullscreen #list_pane, + .fullscreen #folders_splitter, .fullscreen #list_splitter { + display: none; + } + #attachments_row { height: auto; } + #attachments_row > Input { width: 1fr; } + #attachments_row > Button { width: auto; } + + /* Barre de partage (tâche 25) : l'ancienne bordure de `#folders`/ + `#preview` (bloc ci-dessus, avant ce commit) faisait déjà office de + séparateur visuel — cette classe la REMPLACE par un widget réel, + glissable, sans ajouter de largeur/hauteur : un widget partagé + (`_PaneSplitter`) plutôt qu'une bordure signifie aussi UNE seule + déclaration ici pour les DEUX barres, au lieu d'une bordure séparée + par volet bordé. `:hover`/`.dragging` sont un signal PUREMENT visuel + pour la souris — le clavier redimensionne par `+`/`-`/`0`, entièrement + indépendant de cette barre (voir `MailApp._resize_focused_pane`). */ + .pane-splitter { background: $panel; } + .pane-splitter:hover, .pane-splitter.dragging { background: $accent; } + + /* Une disposition = une classe sur #panes ; `#right` regroupe + `#list_pane` (recherche + liste) et `#preview` pour que « split » + et « stacked » puissent les empiler À CÔTÉ des dossiers, sans + toucher au reste de l'arbre. Ajouter une disposition n'ajoute + qu'une entrée à MAIL_LAYOUTS et un bloc comme un de ceux-ci — + jamais de branche Python par disposition (voir `action_cycle_layout` + ci-dessous, qui bascule cette classe). Chaque bloc donne aussi + l'orientation des DEUX barres de partage : une cellule le long de + l'axe que ce bloc partage déjà (largeur en horizontal, hauteur en + vertical), `1fr` sur l'autre axe pour occuper tout le volet en face + — jamais une décision Python, exactement comme les volets eux-mêmes. */ + #panes.layout-columns { layout: horizontal; } + #panes.layout-columns #folders { width: 28; height: 1fr; } + #panes.layout-columns #folders_splitter { width: 1; height: 1fr; } + #panes.layout-columns #right { width: 1fr; height: 1fr; layout: horizontal; } + #panes.layout-columns #list_pane { width: 2fr; height: 1fr; } + #panes.layout-columns #list_splitter { width: 1; height: 1fr; } + #panes.layout-columns #preview { width: 3fr; height: 1fr; } + + #panes.layout-split { layout: horizontal; } + #panes.layout-split #folders { width: 28; height: 1fr; } + #panes.layout-split #folders_splitter { width: 1; height: 1fr; } + #panes.layout-split #right { width: 1fr; height: 1fr; layout: vertical; } + #panes.layout-split #list_pane { width: 1fr; height: 1fr; } + #panes.layout-split #list_splitter { width: 1fr; height: 1; } + #panes.layout-split #preview { width: 1fr; height: 1fr; } + + #panes.layout-stacked { layout: vertical; } + #panes.layout-stacked #folders { width: 1fr; height: 1fr; } + #panes.layout-stacked #folders_splitter { width: 1fr; height: 1; } + #panes.layout-stacked #right { width: 1fr; height: 1fr; layout: vertical; } + #panes.layout-stacked #list_pane { width: 1fr; height: 1fr; } + #panes.layout-stacked #list_splitter { width: 1fr; height: 1; } + #panes.layout-stacked #preview { width: 1fr; height: 1fr; } + """ + # Hors de la chaîne littérale : ce bloc est GÉNÉRÉ depuis + # `PANE_SIZE_MIN` (voir `_PANE_MIN_CSS`). Une seule règle pour les + # trois dispositions et pour toute disposition ajoutée plus tard — + # le plancher ne dépend d'aucune d'elles. + CSS += _PANE_MIN_CSS + + BINDINGS = [ + # `h` EN PREMIER : le pied d'écran affiche ces liaisons dans + # l'ordre de cette liste et n'a pas la largeur de les montrer + # toutes — la seule qui doive rester visible quand on ne sait + # plus quoi presser est celle qui explique les autres. + # + # SANS `priority=True`, et pas pour la raison qu'on croit : dans + # le champ de recherche, NI l'une NI l'autre forme ne vole la + # frappe, parce que `Screen._binding_chain` retire les liaisons + # de tout caractère imprimable dès que le widget focalisé + # déclare pouvoir le consommer (`Input.check_consume_key` ; + # `Screen._binding_chain`, `screen.py:428-435`), avant même la + # répartition. Ce qu'une priorité changerait est ailleurs : elle + # est cherchée sur la chaîne NON tronquée aux écrans modaux + # (`App._check_bindings` lit `Screen._binding_chain` quand + # `priority=True`, et `_modal_binding_chain` sinon, + # `app.py:3978`) — `h` ouvrirait + # alors l'aide PAR-DESSUS l'écran d'écriture, le coffre, ou + # l'aide elle-même. Mesuré dans les deux sens sur Textual 8.2.8 + # avant d'écrire ceci. + Binding("h", "show_help", t("mail_help_binding")), + Binding("q", "quit", t("mail_quit_binding")), + Binding("r", "sync_current", t("mail_sync_current_binding")), + Binding("R", "sync_all", t("mail_sync_all_binding")), + # PAS `enter` : `Tree`/`DataTable` (`#folders`/`#list`, les deux + # seuls widgets focalisables de cet écran) lient déjà `enter` à + # leur propre `select_cursor`, et Textual donne toujours la + # priorité à la liaison la plus proche du nœud focalisé + # (`App._check_bindings`, chaîne `focused.ancestors_with_self` — + # voir le commentaire de `_SearchInput`). L'un des deux a + # TOUJOURS le focus par défaut, donc `enter` ici ne se + # déclenchait en pratique JAMAIS depuis le clavier — confirmé + # identique au commit de base (défaut préexistant, tâche 23). + # `z`, libre (grep de tous les `Binding(` de ce module, et de + # `Tree.BINDINGS`/`DataTable.BINDINGS`/`Input.BINDINGS` dans + # Textual 8.2.8 : aucun ne revendique une lettre nue), le + # remplace. Caractère simple, donc SANS `priority=True`, même + # raison que pour `v` (tâche 23) et que pour `h` ci-dessus, dont + # le commentaire porte le mécanisme mesuré. L'effet serait ici + # SILENCIEUX, donc pire : mesuré, un `z` prioritaire frappé sous + # un écran modal pose bien `fullscreen` sur `#panes`, sans que + # rien ne bouge à l'écran (le modal la couvre) — et la classe y + # est ENCORE au renvoi du modal, arbre et liste disparus, sans + # lien visible avec la touche qui l'a causé. (Ce commentaire a + # longtemps dit qu'une priorité « casserait la frappe partout + # ailleurs » : faux, `Input` est servi avant toute liaison — + # corrigé tâche 26.) + Binding("z", "toggle_fullscreen", t("mail_fullscreen_binding")), + Binding( + "escape", + "leave_fullscreen", + t("mail_back_binding"), + show=False, + ), + Binding("slash", "focus_search", t("mail_search_binding")), + Binding("s", "mark_seen", t("mail_mark_seen_binding")), + Binding("u", "mark_unseen", t("mail_mark_unseen_binding")), + Binding("w", "save_attachment", t("mail_save_attachment_binding")), + Binding("c", "compose", t("mail_compose_binding")), + Binding("a", "reply", t("mail_reply_binding")), + Binding("A", "reply_all", t("mail_reply_all_binding")), + Binding("f", "forward", t("mail_forward_binding")), + Binding("n", "add_account", t("mail_add_account_binding")), + Binding("l", "show_log", t("mail_log_binding")), + Binding("v", "cycle_layout", t("mail_layout_binding")), + Binding("plus", "grow_pane", t("mail_pane_grow_binding")), + Binding("minus", "shrink_pane", t("mail_pane_shrink_binding")), + Binding("0", "reset_pane_sizes", t("mail_pane_reset_binding")), + ] + + def __init__( + self, + sessions, + config_file=None, + secret_store=None, + connect_fn=None, + base=None, + ): + super().__init__() + self.sessions = sessions + self.config_file = config_file + self.secret_store = secret_store + self.connect_fn = connect_fn + self.base = base + self.refs: list[MailboxRef] = [] + self.current_ref: MailboxRef | None = None + self.metas = [] + self.query = "" + # Lue ici, PAS dans `on_mount` : `compose()` a besoin de la + # classe CSS de disposition dès le premier rendu, avant que + # `on_mount` ne tourne. `resolve_layout` protège contre une + # valeur absente ou corrompue dans `todo_prefs`. + self.mail_layout = resolve_layout( + todo_prefs.get("mail_layout", _LAYOUT_IDS[0]) + ) + # Les erreurs de la DERNIÈRE synchronisation de chaque compte + # (`report.errors`, voir `_sync` ci-dessous) — lues par + # `LogScreen` (touche `l`). Elles comptent parce qu'elles + # peuvent ne JAMAIS atteindre le fichier : si + # `_configure_mail_logging` échoue à écrire (dossier + # `~/.erplibre` refusé, disque plein), ce dictionnaire reste le + # seul endroit où l'utilisateur peut encore les lire. + self.session_errors: dict[str, list[str]] = {} + # L'auto-refresh et un `r`/`R` manuel lancent chacun `_sync` via + # `run_worker(thread=True)`, sans exclusivité : deux passes + # peuvent tourner en vrais threads en même temps, et `imaplib` + # n'est pas thread-safe. L'annulation ne protège pas contre ça + # (elle ne s'applique pas aux workers de type thread) — ce verrou + # sérialise donc les accès réseau à sa place. + self._sync_lock = threading.RLock() + # État d'un glissement de barre de partage EN COURS (souris, + # tâche 25) ; `None` hors glissement. Vit sur l'App — pas sur la + # barre elle-même (`_PaneSplitter`) — puisque c'est ici que + # vivent déjà `_pane_widgets`/`_pane_dimension`/`_store_pane_size`, + # et que `on_app_blur` doit pouvoir terminer un glissement sans + # savoir QUELLE des deux barres l'a commencé. + self._drag_slot: str | None = None + self._drag_dimension: str | None = None + self._drag_origin: int | None = None + self._drag_base: int | None = None + self._drag_last_value: int | None = None + + # -- composition ------------------------------------------------ + + def compose(self) -> ComposeResult: + yield Header(show_clock=True) + # La classe posée ici — pas dans `on_mount` — donne la bonne + # disposition dès le premier rendu, sans reconstruire l'arbre au + # changement : `action_cycle_layout` ne fait ensuite que + # basculer cette classe. + with _PanesContainer(id="panes", classes=self._layout_class()): + yield Tree(t("mail_accounts"), id="folders") + # Barre de partage (tâche 25) : glissable à la souris pour + # redimensionner `folders`/`right` — voir `_PaneSplitter` et + # `_pane_total` (le total partagé par les deux volets + # ADJUSTABLE exclut cette barre, de taille fixe). + yield _PaneSplitter("folders", id="folders_splitter") + # `#right` regroupe la liste et l'aperçu en UN volet, pour + # que « split » et « stacked » puissent les empiler l'un + # sur l'autre à côté des dossiers — un arrangement qu'un + # simple `Horizontal`/`Vertical` à plat sur les trois volets + # ne peut pas exprimer (voir la CSS de la classe). + with Container(id="right"): + with Vertical(id="list_pane"): + with Horizontal(id="search_row"): + yield _SearchInput( + placeholder=t("mail_search"), id="search" + ) + yield Button( + "✕", + id="search_clear", + # Ce survol est un bonus pour la souris, PAS + # le chemin accessible : Textual ne + # déclenche les tooltips que sur + # `MouseMove` (`screen.py:_handle_mouse_move` + # / `_handle_tooltip_timer`, vérifié dans la + # source) — aucun clavier n'y mène. Le + # chemin traduit et découvrable au clavier + # est la liaison Échap de `_SearchInput`, + # visible au pied d'écran pendant que ce + # champ a le focus. + tooltip=t("mail_search_clear"), + ) + yield DataTable(id="list", cursor_type="row") + # Barre de partage entre `list_pane` et `preview` — même + # motif que `folders_splitter` ci-dessus, un volet plus + # bas dans l'arbre. + yield _PaneSplitter("list_pane", id="list_splitter") + yield Static("", id="preview") + yield Static("", id="status") + yield Footer() + + def on_mount(self) -> None: + table = self.query_one("#list", DataTable) + table.add_columns( + " ", t("mail_from"), t("mail_subject"), t("mail_date") + ) + self.reload_folders() + self.run_worker(self.sync_all_worker, thread=True) + interval = todo_prefs.get("mail_refresh_sec", 300) + if interval: + # Minuterie posée à l'ouverture, retirée avec l'écran : aucune + # synchronisation ne tourne quand le TUI n'est pas là. + self.set_interval( + interval, + lambda: self.run_worker(self.sync_all_worker, thread=True), + ) + # Différé : à `on_mount`, `#panes`/`#right` n'ont pas encore + # forcément leur taille réelle (le premier passage de mise en + # page n'a pas eu lieu) — `clamp_pane_size` ne pourrait alors + # rien borner. `call_after_refresh` attend ce premier rendu. + # Les redimensionnements SUIVANTS du terminal sont couverts par + # `_PanesContainer.on_resize`, pas ici (voir son commentaire : + # un `on_resize` posé sur `MailApp` mesurait encore l'ANCIENNE + # taille de `#panes` au moment où il tournait). + self.call_after_refresh(self._apply_pane_sizes) + + # -- données ---------------------------------------------------- + + def reload_folders(self) -> None: + self.refs = mailbox_refs(self.sessions) + tree = self.query_one("#folders", Tree) + tree.clear() + by_account: dict[str, list[MailboxRef]] = {} + for ref in self.refs: + by_account.setdefault(ref.account_name, []).append(ref) + for session in self.sessions: + mark = "" if session.online else " ⚠" + node = tree.root.add( + f"{session.account.name}{mark}", expand=True + ) + for ref in by_account.get(session.account.name, []): + label = ref.display + if ref.unseen: + label = f"{label} {ref.unseen}" + node.add_leaf(label, data=ref) + tree.root.add_leaf( + f"+ {t('mail_account_add')}", data=ADD_ACCOUNT_NODE + ) + tree.root.expand() + if self.current_ref is None and self.refs: + self.select_ref(self.refs[0]) + else: + # Un dossier est déjà ouvert : `select_ref` ne le + # rappellerait pas ici, mais ce que la synchronisation vient + # d'écrire au cache — un message qui arrive, un « lu » + # changé ailleurs — doit tout de même atteindre l'écran, + # SANS redémarrer le client pour le voir. + self.refresh_current_folder() + + def session_for(self, name: str) -> Session | None: + return next( + (s for s in self.sessions if s.account.name == name), None + ) + + def select_ref(self, ref: MailboxRef) -> None: + self.current_ref = ref + session = self.session_for(ref.account_name) + state = session.store.folder_state(ref.folder_name) + self.metas = ( + session.store.list_messages(state["id"]) if state else [] + ) + self.refresh_list() + + def refresh_list(self) -> None: + import time + + table = self.query_one("#list", DataTable) + table.clear() + now = int(time.time()) + for meta in tui_text.filter_messages(self.metas, self.query): + table.add_row( + "●" if tui_text.is_unread(meta.flags) else " ", + tui_text.truncate(tui_text.short_addr(meta.frm), 22), + tui_text.truncate( + meta.subject or t("mail_no_subject"), 48 + ), + tui_text.format_date(meta.date, now), + key=str(meta.uid), + ) + + def refresh_current_folder(self) -> None: + """Recharge les messages du dossier affiché depuis le cache et + redessine la liste, SANS perdre le curseur ni le filtre de + recherche en cours. + + Appelée après une synchronisation (`_sync`, via + `reload_folders`) ou un envoi (`_after_compose`) : contrairement + à `select_ref` — changement DÉLIBÉRÉ de dossier, où revenir en + tête de liste est attendu — celle-ci s'exécute pendant que + l'utilisateur regarde peut-être déjà cette liste, ce qui rend le + curseur et le filtre aussi importants à préserver que les + données elles-mêmes. + """ + if self.current_ref is None: + return + session = self.session_for(self.current_ref.account_name) + if session is None: + return + # Capturé AVANT de recharger `self.metas` : `current_meta()` lit + # encore l'ancienne liste et la position actuelle du curseur. + current = self.current_meta() + current_uid = current.uid if current is not None else None + state = session.store.folder_state(self.current_ref.folder_name) + self.metas = ( + session.store.list_messages(state["id"]) if state else [] + ) + self.refresh_list() + if current_uid is None: + return + from textual.widgets.data_table import RowDoesNotExist + + table = self.query_one("#list", DataTable) + try: + row_index = table.get_row_index(str(current_uid)) + except RowDoesNotExist: + # Le message qui avait le focus a disparu du dossier (purge + # de synchronisation, suppression ailleurs) : `refresh_list` + # a déjà laissé le curseur là où `DataTable.clear()` le + # remet — en tête de liste, seul choix qui ne pointe pas + # dans le vide. + return + table.move_cursor(row=row_index) + + def current_meta(self): + table = self.query_one("#list", DataTable) + if table.cursor_row is None or not self.metas: + return None + shown = tui_text.filter_messages(self.metas, self.query) + if table.cursor_row >= len(shown): + return None + return shown[table.cursor_row] + + # -- événements ------------------------------------------------- + + def on_tree_node_selected(self, event) -> None: + data = getattr(event.node, "data", None) + if data == ADD_ACCOUNT_NODE: + self.action_add_account() + elif isinstance(data, MailboxRef): + self.select_ref(data) + + def on_data_table_row_highlighted(self, event) -> None: + self.show_preview() + + def on_input_changed(self, event) -> None: + if event.input.id != "search" or event.value == self.query: + # `clear_search` a déjà mis `self.query` à jour ET rafraîchi + # la liste avant que Textual ne poste ce message — sans ce + # garde-fou, `refresh_list()` tournerait une seconde fois + # pour rien dès que le champ change de valeur. + return + self.query = event.value + self.refresh_list() + + def show_preview(self) -> None: + meta = self.current_meta() + preview = self.query_one("#preview", Static) + if meta is None or self.current_ref is None: + preview.update("") + return + session = self.session_for(self.current_ref.account_name) + # `Text`, PAS une chaîne de balisage : expéditeur, sujet et + # corps viennent du message, donc de n'importe qui. Un jeton de + # suivi contenant « [...] » — vu sur un vrai courriel — était + # analysé comme une balise et faisait lever `MarkupError` à + # l'affichage. `escape()` ne suffit pas : il laisse justement + # ces formes intactes. Un `Text` n'est jamais analysé. + header = Text() + for etiquette, valeur in ( + (t("mail_from"), meta.frm), + (t("mail_to"), meta.to), + (t("mail_subject"), meta.subject), + (t("mail_date"), tui_text.format_date_full(meta.date)), + ): + header.append(f"{etiquette} ", style="bold") + header.append(f"{valeur}\n") + header.append(f"{tui_text.format_size(meta.size)}\n\n") + if not session.online and not meta.has_body: + preview.update(header + Text(t("mail_body_needs_network"))) + return + try: + raw = ( + session.syncer.fetch_body( + self.current_ref.folder_name, meta.uid + ) + if session.online + else session.store.read_body( + self.current_ref.folder_name, meta.uid + ) + ) + except Exception as exc: + preview.update(header + Text(f"{t('mail_body_error')} {exc}")) + return + body, attachments = tui_text.extract_body(raw or b"") + if attachments: + # Le nom de fichier vient lui aussi du message. + header.append(f"{t('mail_attachments')}\n") + for piece in attachments: + header.append( + f" 📎 {piece.filename} " + f"{tui_text.format_size(piece.size)}\n" + ) + header.append("\n") + preview.update(header + Text(body)) + + # -- actions ---------------------------------------------------- + + def action_toggle_fullscreen(self) -> None: + # `.fullscreen` masque `#folders_splitter`/`#list_splitter` + # (voir la CSS) : un glissement EN COURS sur l'une des deux + # perdrait alors la capture de souris sans jamais recevoir son + # `MouseUp` (le widget capturé devient introuvable pour + # `Screen._forward_event`, vérifié dans la source de Textual) — + # terminer le glissement AVANT de masquer, comme une levée + # normale, plutôt que de laisser l'app coincée en glissement. + self._end_pane_drag() + self.query_one("#panes").toggle_class("fullscreen") + + def action_leave_fullscreen(self) -> None: + self.query_one("#panes").remove_class("fullscreen") + + def _layout_class(self) -> str: + return f"layout-{self.mail_layout}" + + def action_cycle_layout(self) -> None: + """Touche `v` : la disposition suivante, appliquée SANS + reconstruire les volets — seule la classe CSS de `#panes` + change, donc le dossier sélectionné, le message en surbrillance, + le filtre de recherche et le plein écran (une AUTRE classe du + même nœud, jamais touchée ici) traversent le changement intacts. + """ + self.mail_layout = next_layout(self.mail_layout) + panes = self.query_one("#panes") + for layout_id in _LAYOUT_IDS: + panes.remove_class(f"layout-{layout_id}") + panes.add_class(self._layout_class()) + todo_prefs.set("mail_layout", self.mail_layout) + # La disposition qui vient de prendre la classe a peut-être SA + # PROPRE personnalisation de tailles (ou aucune) — jamais celle + # de la précédente, qui resterait sinon posée en style en ligne + # sur les mêmes widgets (ils ne sont pas reconstruits). + self._apply_pane_sizes() + self.set_status( + f"{t('mail_layout_switched')}" + f" {t(_LAYOUT_I18N_KEYS[self.mail_layout])}" + ) + + # -- tailles des volets (`+`/`-`/`0`) ----------------------------- + + def _pane_widgets(self, slot: str): + """Le volet réglable `slot`, et le conteneur PARENT dont il + partage l'espace avec son unique voisin — `#panes` pour + `folders` (voisin `#right`), `#right` pour `list_pane` (voisin + `#preview`). Le voisin lui-même n'est jamais retourné : il reste + toujours `1fr`, jamais stocké ni fixé. + """ + if slot == "folders": + return self.query_one("#folders"), self.query_one("#panes") + return self.query_one("#list_pane"), self.query_one("#right") + + def _pane_dimension(self, container) -> str: + """« width » si `container` range ses enfants horizontalement, + sinon « height ». Lue sur la disposition RÉELLEMENT posée + (`styles.layout`, calculée depuis la classe CSS active de + `#panes`/`#right`) — jamais une table `if layout == ...` : + ajouter une disposition n'ajoute qu'un bloc CSS, jamais une + entrée ici. + """ + layout = container.styles.layout + return ( + "width" + if layout is not None and layout.name == "horizontal" + else "height" + ) + + def _pane_total(self, slot: str, parent) -> int: + """L'espace que `slot` et son unique voisin `1fr` se partagent + RÉELLEMENT — jamais `parent.region` telle quelle : la barre de + partage (tâche 25) insérée ENTRE eux a une taille FIXE + (`_SPLITTER_SIZE`) qui n'appartient à NI L'UN NI L'AUTRE. La + compter dans le total partageable laisserait le voisin + `sibling_minimum - (taille de la barre)` au plafond plutôt que + `sibling_minimum` — la même erreur d'une cellule que la bordure + (tâche 24), sous une autre forme. Mesurée sur le widget RÉEL de + la barre, jamais recopiée depuis la CSS : une seule source de + vérité pour sa taille. + + Généralise à « un seul voisin FIXE et CONNU » (la barre), pas à + un nombre arbitraire d'enfants supplémentaires : si `#panes`/ + `#right` héberge un jour un TROISIÈME enfant fixe en plus de + `slot`, de son voisin `1fr` et de CETTE barre, cette soustraction + devra en tenir compte explicitement — elle ne les détecte pas + toute seule. + """ + dimension = self._pane_dimension(parent) + splitter = self.query_one(f"#{_SPLITTER_IDS[slot]}") + return getattr(parent.region, dimension) - getattr( + splitter.region, dimension + ) + + def _focused_pane_slot(self) -> str | None: + """Le volet réglable qui contient le nœud focalisé, déterminé + par ASCENDANCE (`ancestors_with_self`) — jamais par le TYPE du + widget focalisé (`Tree`, `DataTable`, `Input`) : `#list` et le + champ de recherche vivent tous deux dans `#list_pane`, donc les + deux mènent au même volet sans code séparé pour chacun. Rend + `None` si rien n'a le focus ou si le focus est ailleurs (aucun + volet réglable aujourd'hui n'est hors de `#folders`/`#list_pane`, + mais un futur widget focalisable hors des deux ne doit pas + planter ici). + """ + focused = self.focused + if focused is None: + return None + chain = focused.ancestors_with_self + if self.query_one("#folders") in chain: + return "folders" + if self.query_one("#list_pane") in chain: + return "list_pane" + return None + + def _clear_pane_size(self, slot: str) -> None: + """Retire toute surcharge en ligne des DEUX dimensions de + `slot` : une disposition précédente a pu en fixer une (largeur + OU hauteur selon SA propre orientation), et la laisser traîner + entrerait en conflit avec la feuille de style de la disposition + actuelle. `styles.X = None` efface la règle et redonne la main + à la feuille de style (`ScalarProperty.__set__`, vérifié dans + Textual 8.2.8) — c'est aussi, exactement, ce que « réinitialiser » + doit faire. + + Les DEUX plafonds partent avec : `_apply_pane_size_for_slot` en + pose un (`max_width`/`max_height`) plutôt qu'une taille fixe + quand la feuille de style laisse le volet élastique, et un + plafond calculé pour un terminal ou une disposition précédente + est tout aussi périmé qu'une taille. + """ + pane, _ = self._pane_widgets(slot) + pane.styles.width = None + pane.styles.height = None + pane.styles.max_width = None + pane.styles.max_height = None + + def _apply_pane_size_for_slot( + self, slot: str, stored: dict, then=None + ) -> None: + """Pose — ou efface — la surcharge de taille d'UN SEUL volet. + + RÈGLE STRUCTURELLE (tâche 27) : cette méthode ne lit JAMAIS la + région du volet qu'elle règle. Elle vient d'effacer sa + surcharge (`_clear_pane_size`) ; sa région est donc, jusqu'au + prochain calcul de mise en page, celle de l'ANCIENNE règle. La + version précédente s'en servait comme base quand rien n'était + stocké, et différait la lecture d'un `call_after_refresh` en + espérant que la mise en page ait eu lieu entre-temps. Elle + avait lieu presque toujours ; « presque » a coûté 3 % des + exécutions, dans lesquelles la base valait l'ancienne + surcharge, le bornage retombait donc sur elle, la branche + « rien à corriger » sautait l'écriture, et le volet restait + DÉFINITIVEMENT à la part que la feuille de style lui donne + (`2fr` de `#right`, soit 3 cellules) — rien n'étant reprogrammé + pour le rattraper. + + Un tour de plus, ou une reprise, n'aurait fait que rendre le + cas plus rare. La lecture est donc SUPPRIMÉE, pas retardée : + + - le PLANCHER ne se mesure plus du tout, il est déclaré + (`_PANE_MIN_CSS`) et tenu par le moteur de mise en page ; + - la seule base de bornage restante est une valeur DÉCLARÉE : + la taille stockée, ou, à défaut, la largeur/hauteur que la + feuille de style donne à ce volet quand elle est exprimée en + CELLULES (`#folders { width: 28 }`), la seule qui puisse + écraser le voisin. Une fraction (`2fr`, `1fr`) occupe par + définition ce qui reste : rien à borner, et son plancher est + déjà garanti. + + Un `Scalar` de `styles` est une RÈGLE, pas une mesure d'écran : + il ne peut pas être « pré-rafraîchissement ». Pour qu'une + lecture périmée revienne ici, il faudrait qu'on réintroduise + une lecture de `.region` d'un widget que ce même appel vient de + modifier — il n'en reste aucune sur le volet lui-même, et + `test_mail_tui_resize.py` en fait un test. + + `parent.region` (via `_pane_total`) reste mesurée : elle n'est + jamais invalidée par ce que ce volet vient de faire, seulement + par ce qu'un volet PRÉCÉDENT de la chaîne a fait — d'où `then`, + qui enchaîne `list_pane` après `folders` pour que `#right` soit + mesuré après le rafraîchissement qui suit le réglage de + `folders`, jamais avant. + """ + self._clear_pane_size(slot) + + def _settle() -> None: + pane, parent = self._pane_widgets(slot) + dimension = self._pane_dimension(parent) + # `.region` (via `_pane_total`), pas `.size` : `box_sizing` + # par défaut est `border-box` (Textual), donc + # `styles.width = N` fixe la boîte ENTIÈRE (bordure + # comprise) à N — exactement ce que `.region` mesure. + # `.size` est l'aire de CONTENU, plus petite d'une cellule + # sur un volet bordé (`#folders`, `#preview`) ; s'en servir + # ici désynchroniserait le nombre stocké de ce que l'écran + # affiche réellement. + total = self._pane_total(slot, parent) + basis = self._pane_size_basis(pane, dimension, stored, slot) + # `basis is None` : la feuille de style laisse ce volet + # ÉLASTIQUE (`1fr`). On ne lui fixe alors pas de taille — + # ce serait perdre son élasticité — mais on lui pose le + # PLAFOND qui réserve à son voisin de quoi tenir ses + # propres planchers. Sans lui, `#folders` en « stacked » + # (`1fr`) prenait la moitié de `#panes` et laissait `#right` + # trop court pour ses deux enfants. `clamp_pane_size(total, + # ...)` rend exactement ce plafond : le maximum qu'un volet + # puisse demander sans écraser le voisin, calculé par la + # MÊME règle que tous les autres bornages ici. + value = clamp_pane_size( + total if basis is None else basis, + total, + sibling_minimum=_PANE_SIBLING_MIN[slot], + ) + # `value is None` : `parent` n'a pas encore de taille + # mesurable (premier montage), rien à poser cette fois — le + # premier `Resize` de `#panes` repassera. + if value is not None: + name = ( + dimension if basis is not None else f"max_{dimension}" + ) + setattr(pane.styles, name, value) + if then is not None: + then() + + self.call_after_refresh(_settle) + + def _pane_size_basis( + self, pane, dimension: str, stored: dict, slot: str + ): + """La taille à FIXER pour `slot`, ou `None` si la feuille de + style le laisse élastique — toujours DÉCLARÉE, jamais mesurée + sur l'écran. + + La taille stockée si l'utilisateur en a réglé une ; sinon celle + que la feuille de style donne au volet, et seulement si elle est + exprimée en CELLULES (`#folders { width: 28 }`). Une fraction + rend `None` : l'appelant lui pose un PLAFOND au lieu d'une + taille, ce qui réserve la place du voisin sans figer un volet + que la feuille de style veut élastique. + """ + stored_value = stored.get(slot) + if stored_value is not None: + return stored_value + declared = getattr(pane.styles, dimension) + if declared is None or not declared.is_cells: + return None + return int(declared.value) + + def _apply_pane_sizes(self) -> None: + """Pose — ou efface — la surcharge de taille de chaque volet + pour la disposition ACTIVE, à partir de `todo_prefs`. Appelée au + montage (différée, voir `on_mount`), après chaque changement de + disposition, et à chaque redimensionnement du TERMINAL (voir + `on_resize`) : les moments où les tailles affichées doivent + changer, ou être recontrôlées contre l'espace RÉELLEMENT + disponible. + + `list_pane` est enchaîné APRÈS `folders` (`then=`) : son total + (`#right`) dépend de la taille RETENUE pour `folders`, qui n'est + elle-même connue qu'après le rafraîchissement que + `_apply_pane_size_for_slot` attend déjà pour `folders` — un + second rafraîchissement, pas le même, sépare donc les deux + mesures. + """ + stored = resolve_pane_sizes( + todo_prefs.get("mail_pane_sizes", {}), self.mail_layout + ) + self._apply_pane_size_for_slot( + "folders", + stored, + then=lambda: self._apply_pane_size_for_slot( + "list_pane", stored + ), + ) + + def _store_pane_size(self, slot: str, value: int | None) -> None: + """Écrit (ou efface, si `value` est `None`) la taille de `slot` + pour la disposition ACTIVE dans `todo_prefs`, sans jamais muter + en place le dictionnaire qu'il rend : `todo_prefs.get` peut + rendre l'objet `DEFAULTS` PARTAGÉ quand rien n'est encore + enregistré — le modifier sur place corromprait ce défaut pour + tout le processus. + """ + sizes = todo_prefs.get("mail_pane_sizes", {}) + if not isinstance(sizes, dict): + sizes = {} + per_layout = dict(sizes.get(self.mail_layout) or {}) + if value is None: + per_layout.pop(slot, None) + else: + per_layout[slot] = value + sizes = dict(sizes) + sizes[self.mail_layout] = per_layout + todo_prefs.set("mail_pane_sizes", sizes) + + def _apply_new_pane_size( + self, slot: str, value: int, persist: bool = True + ) -> int | None: + """Borne `value` et la pose comme taille EN DIRECT de `slot` ; + l'écrit aussi dans `todo_prefs` (`_store_pane_size`) SAUF si + `persist=False`. Partagée par le clavier (`+`/`-`, TOUJOURS + persisté — une pression est un évènement rare) et le glissement + à la souris (`persist=False` PENDANT le glissement lui-même : + `_store_pane_size` fait un aller-retour disque à CHAQUE appel, + et un glissement poste potentiellement des dizaines de + `MouseMove` par seconde ; `persist` ne redevient vrai qu'une + fois, à la levée — voir `_end_pane_drag`). Rend la valeur + RETENUE (après bornage), ou `None` si `total` n'était pas + encore mesurable — dans les deux cas, jamais de relecture de + région après avoir écrit un style, la même prudence que + partout ailleurs dans ce fichier. + """ + pane, parent = self._pane_widgets(slot) + dimension = self._pane_dimension(parent) + total = self._pane_total(slot, parent) + new_value = clamp_pane_size( + value, total, sibling_minimum=_PANE_SIBLING_MIN[slot] + ) + if new_value is None: + return None + setattr(pane.styles, dimension, new_value) + if persist: + self._store_pane_size(slot, new_value) + if slot == "folders": + # `#folders` grandi peut avoir affamé `list_pane`/`preview` + # sous LEUR plancher (`_PANE_SIBLING_MIN["folders"]` réserve + # de la place à #right, mais pas encore à SES propres + # enfants) : `_apply_pane_size_for_slot` attend déjà, seule, + # le rafraîchissement qui suit avant de mesurer `#right` — + # rien à différer ici en plus. `list_pane`, lui, n'a pas ce + # problème — son voisin `#preview` est une feuille. Ce + # correctif tourne QUE `persist` soit vrai ou non : il lit + # la taille PERSISTÉE de `list_pane` (jamais celle de + # `folders`, non concernée), donc un glissement de + # `folders` pas encore relâché doit re-corriger `list_pane` + # exactement comme le clavier le fait déjà. + stored = resolve_pane_sizes( + todo_prefs.get("mail_pane_sizes", {}), self.mail_layout + ) + self._apply_pane_size_for_slot("list_pane", stored) + return new_value + + def _resize_focused_pane(self, delta: int) -> None: + slot = self._focused_pane_slot() + if slot is None: + return + pane, parent = self._pane_widgets(slot) + dimension = self._pane_dimension(parent) + # `.region` ici aussi, même raison que dans `_apply_pane_sizes`. + current = getattr(pane.region, dimension) + self._apply_new_pane_size(slot, current + delta) + + # -- glissement de la barre de partage (souris, tâche 25) -------- + + def _begin_pane_drag( + self, slot: str, screen_x: int, screen_y: int + ) -> None: + """`MouseDown` sur la barre de partage de `slot` (voir + `_PaneSplitter`) : mémorise le POINT de départ, en coordonnées + ÉCRAN (`event.screen_x`/`screen_y` — jamais `event.x`/`event.y`, + relatifs à la barre elle-même et donc TOUJOURS nuls une fois la + souris capturée : voir `MouseEvent._apply_offset`, qui ne + touche jamais `_screen_x`/`_screen_y`, vérifié dans la source + de Textual 8.2.8), et la taille ACTUELLE du volet — pour + calculer un delta à chaque `MouseMove` suivant, sans jamais + relire de région en cours de route. + """ + pane, parent = self._pane_widgets(slot) + dimension = self._pane_dimension(parent) + self._drag_slot = slot + self._drag_dimension = dimension + self._drag_origin = screen_x if dimension == "width" else screen_y + self._drag_base = getattr(pane.region, dimension) + self._drag_last_value = None + # Signal visuel PUR (voir la CSS `.pane-splitter.dragging`) : + # aucune décision de redimensionnement n'en dépend, seulement + # posé/retiré ici et dans `_end_pane_drag`, jamais sur la barre + # elle-même — un seul endroit qui connaît l'état du glissement. + self.query_one(f"#{_SPLITTER_IDS[slot]}").add_class("dragging") + + def _drag_pane_to(self, screen_x: int, screen_y: int) -> None: + """`MouseMove` pendant un glissement : redimensionne EN DIRECT, + sans persister (`persist=False`, voir `_apply_new_pane_size`) — + seule la levée (`_end_pane_drag`) écrit sur disque, une fois. + Hors glissement (`_drag_slot` encore `None`, par exemple un + `MouseMove` qui précède le tout premier `MouseDown` — voir + `Pilot.mouse_down`), ne fait rien. + """ + if self._drag_slot is None: + return + current = screen_x if self._drag_dimension == "width" else screen_y + applied = self._apply_new_pane_size( + self._drag_slot, + self._drag_base + (current - self._drag_origin), + persist=False, + ) + if applied is not None: + self._drag_last_value = applied + + def _end_pane_drag(self) -> None: + """Termine un glissement — `MouseUp`/`MouseRelease` sur la + barre (voir `_PaneSplitter.on_mouse_up`/`on_mouse_release` : la + capture peut être révoquée SANS `MouseUp`, par exemple par + `App.push_screen` avant d'empiler un écran modal), OU l'app qui + perd le focus (`on_app_blur`), OU un passage en plein écran qui + masquerait la barre en cours de glissement + (`action_toggle_fullscreen`) : UN SEUL point de sortie pour ces + quatre signaux, qui persiste la DERNIÈRE taille appliquée + (`_drag_last_value` — jamais une relecture de région, la même + prudence que le reste de ce fichier). Un simple clic sans + mouvement (`_drag_last_value` resté `None`) ne persiste rien. + Toujours sûr à appeler hors glissement (`_drag_slot` déjà + `None`) : plusieurs appelants le font sans savoir si un + glissement est réellement en cours, et certains (un `MouseUp` + normal PUIS le `MouseRelease` que sa propre `capture_mouse(None)` + déclenche en retour) l'appellent deux fois pour le MÊME + glissement — la seconde fois est un no-op. + """ + if self._drag_slot is None: + return + if self._drag_last_value is not None: + self._store_pane_size(self._drag_slot, self._drag_last_value) + splitter_id = _SPLITTER_IDS[self._drag_slot] + self._drag_slot = None + self._drag_dimension = None + self._drag_origin = None + self._drag_base = None + self._drag_last_value = None + self.capture_mouse(None) + self.query_one(f"#{splitter_id}").remove_class("dragging") + + def on_app_blur(self) -> None: + """L'app perd le focus (terminal minimisé, alt-tab, perte de la + fenêtre) : un glissement en cours ne recevra alors PLUS JAMAIS + son `MouseUp` — le terminer ici comme une levée normale plutôt + que de laisser l'app coincée en glissement (capture de souris + comprise) jusqu'à la prochaine action de souris, qui peut ne + jamais venir. + """ + self._end_pane_drag() + + def action_grow_pane(self) -> None: + self._resize_focused_pane(PANE_SIZE_STEP) + + def action_shrink_pane(self) -> None: + self._resize_focused_pane(-PANE_SIZE_STEP) + + def action_reset_pane_sizes(self) -> None: + for slot in _PANE_SLOTS: + self._clear_pane_size(slot) + self._store_pane_size(slot, None) + self.set_status(t("mail_pane_reset_done")) + + def action_focus_search(self) -> None: + self.query_one("#search_row").add_class("visible") + self.query_one("#search", Input).focus() + + def clear_search(self) -> None: + """Vide le champ ET `self.query` — les deux, pas seulement le + champ : sinon la liste resterait filtrée par une requête devenue + invisible, pire que pas de bouton du tout. + """ + self.query_one("#search", Input).value = "" + self.query = "" + self.refresh_list() + + def on_button_pressed(self, event) -> None: + if event.button.id == "search_clear": + self.clear_search() + + def action_mark_seen(self) -> None: + self._set_flag("\\Seen", add=True) + + def action_mark_unseen(self) -> None: + self._set_flag("\\Seen", add=False) + + def _set_flag(self, flag: str, add: bool) -> None: + meta = self.current_meta() + if meta is None or self.current_ref is None: + return + session = self.session_for(self.current_ref.account_name) + state = session.store.folder_state(self.current_ref.folder_name) + flags = set(meta.flags.split()) if meta.flags else set() + flags.add(flag) if add else flags.discard(flag) + session.store.update_flags( + state["id"], meta.uid, " ".join(sorted(flags)) + ) + if session.online: + try: + session.syncer.transport.select( + self.current_ref.folder_name + ) + session.syncer.transport.store_flags( + meta.uid, [flag] if add else [], [] if add else [flag] + ) + except Exception as exc: + self.set_status(f"{t('mail_flag_error')} {exc}") + self.select_ref(self.current_ref) + + def action_sync_current(self) -> None: + self.run_worker(self.sync_current_worker, thread=True) + + def action_sync_all(self) -> None: + self.run_worker(self.sync_all_worker, thread=True) + + def set_status(self, text: str) -> None: + # Plusieurs appelants y glissent le message d'une exception. + # Mesuré : les crochets NUS passent (« [ALERT] », + # « [NONEXISTENT] », « [Gmail] » s'affichent tels quels) ; ce + # qui lève `MarkupError`, c'est un crochet contenant un « = », + # donc ressemblant à une balise avec valeur — une URL de suivi + # dans un message d'erreur suffit. Le statut disparaîtrait au + # moment PRÉCIS où il sert. On tente donc le balisage — + # `deliver` s'en sert pour son ⚠ en gras rouge — et on retombe + # sur du littéral dès qu'il ne tient pas. + if isinstance(text, str): + try: + text = Text.from_markup(text) + except Exception: + text = Text(text) + ( + self.call_from_thread( + self.query_one("#status", Static).update, text + ) + if self._thread_id_differs() + else self.query_one("#status", Static).update(text) + ) + + def _thread_id_differs(self) -> bool: + return threading.current_thread() is not threading.main_thread() + + def sync_current_worker(self) -> None: + if self.current_ref is None: + return + self._sync([self.session_for(self.current_ref.account_name)]) + + def sync_all_worker(self) -> None: + self._sync(self.sessions) + + def _sync(self, sessions) -> None: + # Sérialise TOUTE la passe, pas seulement l'appel réseau : deux + # `run_worker(thread=True)` (auto-refresh et `r`/`R` manuel) + # partageraient sinon le même socket imaplib, qui n'est pas + # thread-safe. + with self._sync_lock: + for session in sessions: + if session is None or not session.online: + continue + self.set_status( + f"{t('mail_syncing')} {session.account.name}…" + ) + try: + report = session.sync() + except Exception as exc: + _logger.exception( + "sync de %s a échoué", session.account.name + ) + self.set_status(f"{session.account.name} : {exc}") + # Le statut ci-dessus est ÉPHÉMÈRE (le prochain + # message l'efface) : `LogScreen` (touche `l`) + # existe précisément pour regarder APRÈS coup, donc + # la panne la plus grave — la synchronisation + # entière qui a levé, pas seulement un dossier — + # doit y rester lisible, dans la même forme que les + # entrées de `report.errors` ci-dessous. + self.session_errors[session.account.name] = [str(exc)] + continue + # La DERNIÈRE passe l'emporte, même vide : un compte qui + # se remet à synchroniser proprement ne doit pas garder + # affichée, dans `LogScreen`, une erreur qui ne décrit + # plus l'état courant. + self.session_errors[session.account.name] = list( + report.errors + ) + message = ( + f"{session.account.name} : {report.new_messages}" + f" {t('mail_new_messages')}" + ) + if report.errors: + # Le premier message d'erreur EN ENTIER, pas + # seulement leur compte : un « 1 erreur » n'a jamais + # dit à personne ce qui a échoué. Le journal (voir + # `imap_sync.Syncer.sync`) garde les autres au cas où + # il y en aurait plus d'un. + message += f" — {report.errors[0]}" + extra = len(report.errors) - 1 + if extra: + message += f" (+{extra} {t('mail_errors')})" + if report.purged: + message += ( + f" — {t('mail_folders_resynced')}" + f" {', '.join(report.purged)}" + ) + self.set_status(message) + if self._thread_id_differs(): + self.call_from_thread(self.reload_folders) + else: + self.reload_folders() + + def action_save_attachment(self) -> None: + meta = self.current_meta() + if meta is None or self.current_ref is None: + return + session = self.session_for(self.current_ref.account_name) + raw = session.store.read_body( + self.current_ref.folder_name, meta.uid + ) + if raw is None: + self.set_status(t("mail_body_needs_network")) + return + _, attachments = tui_text.extract_body(raw) + if not attachments: + self.set_status(t("mail_no_attachment")) + return + try: + target = save_attachment(raw, 0, "~/Téléchargements") + except Exception as exc: + self.set_status(f"{t('mail_save_failed')} {exc}") + return + self.set_status(f"{t('mail_saved_to')} {target}") + + def action_compose(self) -> None: + session = self._session_or_first() + if session: + self.push_screen(ComposeScreen(session), self._after_compose) + + def action_reply(self) -> None: + self._open_reply(reply_all=False) + + def action_reply_all(self) -> None: + self._open_reply(reply_all=True) + + def action_forward(self) -> None: + self._open_with_original(forward=True) + + def _session_or_first(self): + if self.current_ref: + return self.session_for(self.current_ref.account_name) + return self.sessions[0] if self.sessions else None + + def _original_message(self): + meta = self.current_meta() + if meta is None or self.current_ref is None: + return None, None + session = self.session_for(self.current_ref.account_name) + raw = session.store.read_body( + self.current_ref.folder_name, meta.uid + ) + if raw is None and session.online: + raw = session.syncer.fetch_body( + self.current_ref.folder_name, meta.uid + ) + if raw is None: + return session, None + import email + import email.policy + + return session, email.message_from_bytes( + raw, policy=email.policy.default + ) + + def _open_reply(self, reply_all: bool) -> None: + from script.todo.mail.smtp_send import build_reply + + session, original = self._original_message() + if session is None or original is None: + self.set_status(t("mail_nothing_to_reply_to")) + return + draft = build_reply( + session.account, original, "", reply_all=reply_all + ) + self.push_screen( + ComposeScreen( + session, + { + "to": draft["To"] or "", + "cc": draft["Cc"] or "", + "subject": draft["Subject"] or "", + "body": draft.get_content(), + "in_reply_to": draft["In-Reply-To"], + "references": draft["References"], + }, + ), + self._after_compose, + ) + + def _open_with_original(self, forward: bool) -> None: + session, original = self._original_message() + if session is None or original is None: + self.set_status(t("mail_nothing_to_forward")) + return + # `build_forward` exige un destinataire réel (il construit le + # message final, prêt à partir) : lui en passer un vide — le + # temps de ne connaître QUE le sujet, avant que l'utilisateur + # n'ait rempli le formulaire — lève `SmtpError`. Le préfixe + # « Fwd: » est donc calculé ici, sans passer par `build_message`. + subject = original.get("Subject", "") or "" + if not subject.lower().startswith("fwd:"): + subject = f"Fwd: {subject}" + self.push_screen( + ComposeScreen( + session, + { + "subject": subject, + "body": "", + # Le message d'origine voyage à part : `action_send` + # reconstruit le courriel depuis le formulaire, donc + # sans ça le transfert partirait VIDE, avec le seul + # objet « Fwd: ». + "forward_of": original, + }, + ), + self._after_compose, + ) + + def _after_compose(self, status) -> None: + if status: + self.set_status(status) + # `deliver()` a classé une copie dans Envoyés (`sync_one`, + # tâche 19) — si c'est le dossier déjà ouvert, l'écran doit + # la montrer tout de suite, pas seulement après un + # redémarrage. `status` est vide seulement sur annulation + # (`action_cancel` → `dismiss(None)`), où rien n'a changé. + self.refresh_current_folder() + + def action_show_log(self) -> None: + from script.todo.mail.menu import mail_log_path + + self.push_screen(LogScreen(mail_log_path(), self.session_errors)) + + def action_show_help(self) -> None: + self.push_screen(HelpScreen()) + + def action_add_account(self) -> None: + if self.config_file is None or self.secret_store is None: + self.set_status(t("mail_account_add_unavailable")) + return + if account_setup.kdbx_is_configured(self.config_file): + self.push_screen( + AccountScreen(self.secret_store), + self._after_account_added, + ) + else: + self.push_screen(VaultScreen(), self._after_vault_screen) + + def _after_vault_screen(self, store) -> None: + # `store` est `None` si l'écran a été annulé : la création de + # compte ne doit PAS s'ouvrir sans coffre derrière elle. + if store is None: + return + self.secret_store = store + self.push_screen( + AccountScreen(self.secret_store), self._after_account_added + ) + + def _after_account_added(self, account) -> None: + if account is None: + return + session = open_session( + account, + self.secret_store, + base=self.base, + connect_fn=self.connect_fn, + ) + self.sessions.append(session) + if session.store is not None and session.store.mode == ( + "ephemeral" + ): + # `open_sessions()` réinstallerait les gestionnaires de + # signaux ET rouvrirait TOUS les comptes existants : on + # n'enregistre le nettoyage QUE pour cette session neuve. + _register_ephemeral_cleanup([session]) + self.reload_folders() + self.set_status(t("mail_account_saved")) + self.run_worker(lambda: self._sync([session]), thread=True) + + def on_unmount(self) -> None: + for session in self.sessions: + session.close() + + class LogScreen(ModalScreen): + """Touche `l` : la fin du journal, et les erreurs de synchronisation + de la session en cours — sans quitter le client pour les lire dans + `~/.erplibre/mail.log`. + + Une fenêtre qui s'ouvre VIDE reproduirait exactement la plainte qui + justifie son existence (« j'ai une erreur, mais aucun log ») : + chaque état — journal absent, vide, illisible, aucune erreur de + session — se dit en toutes lettres, jamais en silence. + """ + + BINDINGS = [ + Binding("escape", "close_log", t("mail_log_close")), + ] + + CSS = """ + #log_tail { height: 1fr; border: solid $panel; } + #log_errors { height: auto; padding: 0 1; } + """ + + def __init__(self, log_path, session_errors): + super().__init__() + self.log_path = log_path + # Référence, pas copie : ce que `MailApp._sync` y a déjà écrit + # au moment de l'ouverture est exactement ce que cet écran doit + # montrer, sans logique de synchronisation à lui seul. + self.session_errors = session_errors + + def compose(self): + with Vertical(id="log_screen"): + yield Static(t("mail_log_tail_heading")) + yield Log(id="log_tail") + yield Static(t("mail_log_errors_heading")) + yield Static("", id="log_errors") + + def on_mount(self) -> None: + lines, message = read_log_tail(self.log_path) + log_widget = self.query_one("#log_tail", Log) + if message: + log_widget.write_line(message) + else: + log_widget.write_lines(lines) + self.query_one("#log_errors", Static).update( + self._session_errors_text() + ) + + def _session_errors_text(self) -> str: + rows = [ + f"{account_name} — {error}" + for account_name, errors in self.session_errors.items() + for error in errors + ] + return "\n".join(rows) if rows else t("mail_log_no_errors") + + def action_close_log(self) -> None: + self.dismiss() + + class HelpScreen(ModalScreen): + """Touche `h` : les raccourcis du client, et le peu qu'une liste de + touches ne peut pas dire. + + La liste n'est JAMAIS écrite à la main — elle est engendrée, à + chaque ouverture, depuis `MailApp.BINDINGS` (voir + `_shortcuts_table`). Six liaisons (`z`, `l`, `v`, `+`, `-`, `0`) ont + été ajoutées ou changées pendant ce seul plan : une liste recopiée + serait déjà fausse aujourd'hui, et enseignerait ensuite avec aplomb + des touches qui n'existent plus. Engendrée, elle ne peut pas + dériver. + + Elle ne montre PAS les liaisons de Textual lui-même (`App.BINDINGS`, + `ctrl+q`/`ctrl+c`) : ce sont les raccourcis DU CLIENT qu'on vient + chercher ici, pas ceux du cadre applicatif. + + `escape` ferme cette fenêtre, et n'apparaît pas dans le tableau : la + liaison `escape` de `MailApp` est le « Retour » du plein écran, + déclarée `show=False` pour ne pas se lire comme un raccourci + général — son rôle ICI est dit en prose (`mail_help_close_hint`), + pas emprunté à une liaison qui parle d'autre chose. + + Un second `h` pendant que cette fenêtre est ouverte ne fait rien (il + n'empile pas une deuxième aide) : dès qu'un écran MODAL est posé, + les liaisons de `MailApp` ne sont plus consultées — + `Screen._modal_binding_chain` (`screen.py:449`) tronque la chaîne au + dernier écran modal, et c'est elle qu'`App._check_bindings` + (`app.py:3978`) parcourt pour une liaison sans priorité. Mesuré sur + Textual 8.2.8, et c'est aussi ce qui interdit `priority=True` sur + `h` (voir `MailApp.BINDINGS`). + """ + + BINDINGS = [ + Binding("escape", "close_help", t("mail_help_close")), + ] + + CSS = """ + /* Le titre reste visible, le reste défile : ~19 raccourcis plus les + remarques dépassent un terminal de 24 lignes, et une aide tronquée + SANS ascenseur cacherait précisément les touches ajoutées en + dernier. La marge est posée sur le CONTENEUR, pas sur chaque enfant : + un bloc ajouté ici s'aligne alors tout seul sur les autres. */ + #help_title { padding: 0 1; } + #help_body { height: 1fr; padding: 0 1; } + #help_notes { padding-top: 1; } + """ + + def compose(self): + with Vertical(id="help_screen"): + yield Static(t("mail_help_title"), id="help_title") + with VerticalScroll(id="help_body"): + yield Static(t("mail_help_keys_heading")) + yield Static(self._shortcuts_table(), id="help_keys") + yield Static(t("mail_help_notes_heading")) + # `markup=False` : ces phrases sont des données de + # traduction, pas du balisage — un crochet dans une + # traduction future doit s'afficher, pas se faire lire + # comme une balise de style (et disparaître). + yield Static( + self._notes_text(), id="help_notes", markup=False + ) + + def _shortcuts_table(self): + """Le tableau touche → description, construit depuis + `MailApp.BINDINGS`. + + Un tableau Rich, et pas un `DataTable` : une description longue + doit rester LISIBLE, or `DataTable` coupe une cellule trop large + au lieu de la replier. Ici la colonne des descriptions se replie + DANS sa colonne, sous elle-même, l'alignement du tableau intact — + sans qu'aucun code d'ici n'ait à mesurer quoi que ce soit. + + `App.get_key_display` — la MÊME fonction que le pied d'écran — + met la touche en forme : `plus`/`minus`/`slash` s'y lisent + `+`/`-`/`/` et `r`/`R` restent distincts (`format_key`, + `textual/keys.py:290`, vérifié dans Textual 8.2.8). Une table de + correspondance écrite ici serait un deuxième endroit à tenir à + jour, qui dirait un jour autre chose que le pied d'écran. + """ + table = Table(box=None, show_header=False, padding=(0, 2, 0, 0)) + table.add_column(no_wrap=True) + table.add_column(overflow="fold") + # `make_bindings` normalise : `MailApp.BINDINGS` peut légalement + # contenir des tuples ou des chaînes plutôt que des `Binding` + # (Textual l'accepte), et cette fenêtre ne doit pas être ce qui + # casse le jour où quelqu'un en écrit un. + for binding in Binding.make_bindings(self.app.BINDINGS): + if not binding.show: + continue + # `Text(...)` plutôt que la chaîne nue : Rich lirait sinon + # un crochet dans une description comme du balisage. + table.add_row( + Text(self.app.get_key_display(binding)), + Text(binding.description), + ) + return table + + def _notes_text(self) -> str: + return "\n\n".join( + t(key) + for key in ( + "mail_help_mouse", + "mail_help_layouts", + "mail_help_sync", + "mail_help_files", + "mail_help_close_hint", + ) + ) + + def action_close_help(self) -> None: + self.dismiss() + + class ComposeScreen(ModalScreen): + BINDINGS = [ + Binding("ctrl+s", "send", "Envoyer"), + # `priority=True` : `Input` et `TextArea` lient déjà `ctrl+e` eux- + # mêmes (style Emacs, « fin de ligne ») et la consommeraient + # avant qu'elle n'atteigne cet écran — exactement le problème + # que ce changement corrige (`e` nu, avalé par le widget qui a + # le focus), sous une autre forme. `priority=True` fait vérifier + # cette liaison AVANT le widget focalisé (`App._check_bindings`, + # appelé avec `priority=True` avant le transfert de l'évènement), + # donc elle marche depuis À, Cc, Objet, Pièces jointes ou le + # corps — pas seulement quand le focus est par hasard sur un + # bouton. + Binding("ctrl+e", "external_editor", "Éditeur", priority=True), + Binding("escape", "cancel", "Annuler"), + ] + + def __init__(self, session, msg_defaults=None): + super().__init__() + self.session = session + self.defaults = msg_defaults or {} + + def compose(self): + with Vertical(id="compose"): + yield Static( + f"{t('mail_from')} {self.session.account.from_header()}" + ) + yield Input( + value=self.defaults.get("to", ""), + placeholder=t("mail_to"), + id="to", + ) + yield Input( + value=self.defaults.get("cc", ""), + placeholder=t("mail_cc"), + id="cc", + ) + yield Input( + value=self.defaults.get("subject", ""), + placeholder=t("mail_subject"), + id="subject", + ) + with Horizontal(id="attachments_row"): + yield Input( + placeholder=t("mail_attachments_paths"), id="files" + ) + yield Button(t("mail_browse"), id="browse_files") + yield TextArea(self.defaults.get("body", ""), id="body") + yield Static("", id="compose_status") + yield Button(t("mail_send"), id="send") + + def action_external_editor(self) -> None: + # `edit_in_external_editor` lance `vim`/`nano` par `subprocess`, + # qui a besoin du terminal — Textual le tient encore et continue + # d'y dessiner tant qu'on ne le lui a pas repris. `suspend()` + # rend le terminal le temps du `with`, puis Textual le reprend + # et redessine. + area = self.query_one("#body", TextArea) + with self.app.suspend(): + new_text = edit_in_external_editor(area.text) + area.text = new_text + + def action_cancel(self) -> None: + self.dismiss(None) + + def on_button_pressed(self, event) -> None: + if event.button.id == "send": + self.action_send() + elif event.button.id == "browse_files": + self._browse_files() + + def _browse_start_dir(self, raw_files: str) -> str: + import os + + paths = parse_paths(raw_files) + if paths: + candidate = os.path.dirname(os.path.expanduser(paths[-1])) + if candidate and os.path.isdir(candidate): + return candidate + return os.path.expanduser("~") + + def _browse_files(self) -> None: + # `todo_file_browser` est bâti sur urwid, qui possède son propre + # `urwid.MainLoop` — deux boucles ne peuvent pas tenir le + # terminal en même temps. `suspend()` rend le terminal à urwid + # le temps du `with`, puis Textual le reprend et redessine. + # + # L'IMPORT est fait DANS le `with`, pas avant, et ce n'est pas + # cosmétique : `urwid.raw_display.Screen.__init__` a pour + # défaut `output=sys.stdout`, résolu une seule fois, à + # l'IMPORT du module — donc à la première exécution de cette + # méthode dans le processus. Importer `todo_file_browser` + # AVANT `suspend()` fige ce défaut sur le `sys.stdout` + # qu'App.run() redirige pendant tout le cycle de vie de + # l'appli (`redirect_stdout(self._capture_stdout)`), et PAS le + # vrai terminal — `Screen.get_cols_rows()` plante alors sur un + # descripteur -1. Constaté par un test manuel (voir le + # rapport) ; importer ici, une fois le terminal rendu par + # `suspend()`, fige le bon `sys.stdout` à la place. + files_input = self.query_one("#files", Input) + initial = self._browse_start_dir(files_input.value) + chosen: dict = {} + + try: + with self.app.suspend(): + from script.todo import todo_file_browser + + def _on_selected(path: str) -> None: + chosen["path"] = path + todo_file_browser.exit_program() + + browser = todo_file_browser.FileBrowser( + initial, _on_selected + ) + browser.run_main_frame() + except Exception as exc: + # Un sélecteur qui échoue à s'ouvrir (terminal incompatible, + # `suspend()` non supporté, etc.) ne doit pas emporter le + # brouillon en cours : l'écran reste utilisable, seul le + # statut change. + self.query_one("#compose_status", Static).update( + f"{t('mail_browse_failed')} {exc}" + ) + return + + if "path" in chosen: + files_input.value = append_attachment_path( + files_input.value, chosen["path"] + ) + + def action_send(self) -> None: + from script.todo.mail.smtp_send import build_message + + status = self.query_one("#compose_status", Static) + try: + paths = parse_paths(self.query_one("#files", Input).value) + msg = build_message( + self.session.account, + parse_recipients(self.query_one("#to", Input).value), + self.query_one("#subject", Input).value, + self.query_one("#body", TextArea).text, + cc=parse_recipients(self.query_one("#cc", Input).value), + attachments=paths, + in_reply_to=self.defaults.get("in_reply_to"), + references=self.defaults.get("references"), + ) + forwarded = self.defaults.get("forward_of") + if forwarded is not None: + # `subtype=` seul : pour un `Message`, `add_attachment` + # dispatche vers `set_message_content`, qui n'a pas de + # paramètre `maintype` (vérifié à la tâche 7). + msg.add_attachment(forwarded, subtype="rfc822") + self.dismiss(deliver(self.session, msg)) + except Exception as exc: + # On ne ferme PAS l'écran : le brouillon reste à l'écran, avec + # l'erreur exacte du serveur, prêt à être corrigé et renvoyé. + status.update(f"[b red]{exc}[/]") + + class _OpenKdbxManager: + """Un `KdbxManager` minimal, dont le mot de passe est déjà connu — + saisi à l'instant dans `VaultScreen` — pour que `get_kdbx()` n'ait + jamais besoin de `getpass.getpass()` : Textual possède déjà le + terminal, un tel appel n'a nulle part où s'afficher proprement.""" + + def __init__(self, path: str, password: str): + self._path = path + self._password = password + self._kdbx = None + + def get_kdbx(self): + if self._kdbx is None: + from pykeepass import PyKeePass + + self._kdbx = PyKeePass(self._path, password=self._password) + return self._kdbx + + class VaultScreen(ModalScreen): + """Poussé en premier quand aucun kdbx n'est configuré : créer un + nouveau coffre ou en adopter un déjà présent sur disque. Annuler + annule tout le flux — `AccountScreen` n'est jamais poussé.""" + + BINDINGS = [ + Binding("escape", "cancel", "Annuler"), + ] + + def compose(self): + with Vertical(id="vault_form"): + yield Static(t("mail_kdbx_none_configured")) + yield Input( + value=account_setup.DEFAULT_KDBX_PATH, + placeholder=t("mail_kdbx_ask_path_new"), + id="vault_path", + ) + yield Input( + placeholder=t("mail_kdbx_ask_password"), + password=True, + id="vault_password", + ) + yield Input( + placeholder=t("mail_kdbx_ask_password_confirm"), + password=True, + id="vault_password_confirm", + ) + yield Static("", id="vault_status") + yield Button(t("mail_kdbx_menu_create"), id="vault_create") + yield Button(t("mail_kdbx_menu_choose"), id="vault_choose") + + def action_cancel(self) -> None: + self.dismiss(None) + + def on_button_pressed(self, event) -> None: + if event.button.id == "vault_create": + self._create() + elif event.button.id == "vault_choose": + self._choose() + + def _create(self) -> None: + status = self.query_one("#vault_status", Static) + try: + # RÈGLE STRUCTURELLE (round 3) : dès que `password` est lu + # ci-dessous, PLUS AUCUNE instruction de cette méthode ne + # doit tourner hors de ce `try` — trois manches de revue ont + # chacune trouvé un appel oublié hors garde (`create_vault`, + # `get_kdbx`) pendant que `password`, en clair, restait une + # variable locale de CETTE fonction. Une exception qui s'en + # échappe atterrit dans `App._handle_exception`, qui affiche + # un `rich.traceback.Traceback(show_locals=True, …)` — donc + # `except Exception` large à dessein. + # + # Le `try` couvre le CORPS, PAS `dismiss()` (round 4) : + # `password`/`confirm` restent des noms liés dans CETTE + # fonction jusqu'à son retour, garde ou pas. Vérifié dans + # Textual 8.2.8 : `Screen.dismiss()` ne rappelle PAS le + # callback de résultat directement — + # `ResultCallback.__call__` (`screen.py:130`) fait + # `self.requester.call_next(self.callback, result)`, qui ne + # fait qu'EMPILER l'appel (`message_pump.py:507`) ; il ne + # tourne qu'après le retour complet de cette méthode, dans + # un cadre d'appel disjoint (confirmé empiriquement : ce + # cadre-ci n'apparaît PAS dans la pile du callback). Rien ne + # prouve donc qu'un échec dans ce callback exposerait + # `password`/`confirm` d'ICI — mais les effacer avant + # `dismiss()` ne coûte rien et retire le doute pour de bon : + # une traceback ne peut rien afficher d'un nom qui ne + # référence plus rien. + path = self.query_one("#vault_path", Input).value.strip() + password = self.query_one("#vault_password", Input).value + confirm = self.query_one( + "#vault_password_confirm", Input + ).value + if password != confirm: + status.update(t("mail_kdbx_password_mismatch")) + return + + account_setup.create_vault( + self.app.config_file, path, password + ) + + # Ouvre le coffre MAINTENANT, symétriquement à `_choose` : + # un coffre créé mais inouvrable (pykeepass lève + # `CredentialsError`, `HeaderChecksumError`, etc. — aucune + # n'étant une `OSError`) se signale ICI, où l'utilisateur + # peut encore agir dessus, pas plus tard sur `AccountScreen`. + manager = _OpenKdbxManager(path, password) + manager.get_kdbx() + except Exception as exc: + status.update(str(exc)) + return + password = None + confirm = None + self.dismiss(SecretStore(kdbx_manager=manager, use_keyring=True)) + + def _choose(self) -> None: + status = self.query_one("#vault_status", Static) + try: + # Même règle structurelle qu'au-dessus dans `_create`, et + # même vérification sur la portée de `password` face à + # `dismiss()` (round 4, voir le commentaire détaillé + # là-bas). + path = self.query_one("#vault_path", Input).value.strip() + password = self.query_one("#vault_password", Input).value + + account_setup.use_existing_vault(self.app.config_file, path) + + manager = _OpenKdbxManager(path, password) + # Ouvre le coffre MAINTENANT plutôt que d'attendre le premier + # `SecretStore.set()` : un mauvais mot de passe s'affiche ici, + # sur cet écran, au lieu d'échouer plus tard sans explication. + manager.get_kdbx() + except Exception as exc: + status.update(str(exc)) + return + password = None + self.dismiss(SecretStore(kdbx_manager=manager, use_keyring=True)) + + class AccountScreen(ModalScreen): + """Le formulaire d'ajout de compte : mêmes champs que le CLI + (`menu._add_account`), mais un mot de passe erroné ou un nom invalide + se lit sur `#account_status`, jamais comme un plantage.""" + + BINDINGS = [ + Binding("ctrl+s", "save", "Enregistrer"), + Binding("escape", "cancel", "Annuler"), + ] + + def __init__(self, secret_store): + super().__init__() + self.secret_store = secret_store + + def compose(self): + with Vertical(id="account_form"): + yield Static(t("mail_account_add")) + yield Input(placeholder=t("mail_ask_name"), id="acc_name") + yield Input(placeholder=t("mail_ask_email"), id="acc_email") + yield Input( + placeholder=t("mail_ask_display_name"), id="acc_display" + ) + yield Static(t("mail_ask_preset")) + yield Select( + [(PRESETS[key]["label"], key) for key in PRESETS], + id="acc_preset", + value="generic", + allow_blank=False, + ) + yield Input(placeholder=t("mail_ask_imap_host"), id="acc_imap") + yield Input(placeholder=t("mail_ask_smtp_host"), id="acc_smtp") + yield Input( + placeholder=t("mail_ask_password"), + password=True, + id="acc_password", + ) + yield Static("", id="account_status") + yield Button(t("mail_account_save"), id="acc_save") + + def action_cancel(self) -> None: + self.dismiss(None) + + def on_button_pressed(self, event) -> None: + if event.button.id == "acc_save": + self.action_save() + + def on_select_changed(self, event) -> None: + if event.select.id != "acc_preset": + return + imap_input = self.query_one("#acc_imap", Input) + smtp_input = self.query_one("#acc_smtp", Input) + preset_key = event.value + if preset_key == "generic": + imap_input.value = "" + smtp_input.value = "" + imap_input.disabled = False + smtp_input.disabled = False + else: + preset = PRESETS[preset_key] + imap_input.value = preset["imap"]["host"] + smtp_input.value = preset["smtp"]["host"] + imap_input.disabled = True + smtp_input.disabled = True + + def action_save(self) -> None: + status = self.query_one("#account_status", Static) + try: + # RÈGLE STRUCTURELLE (round 3) : dès que `password` est lu + # ci-dessous, PLUS AUCUNE instruction de cette méthode ne + # doit tourner hors de ce `try` — trois manches de revue ont + # chacune trouvé un appel oublié hors garde + # (`mail_accounts.load()`, `secret_store.available_backends()`, + # `account_setup.save_new_account`) pendant que `password`, + # en clair, restait une variable locale de CETTE fonction. + # Une exception qui s'en échappe atterrit dans + # `App._handle_exception`, qui affiche un + # `rich.traceback.Traceback(show_locals=True, …)` — donc + # `except Exception` large à dessein. + # + # Le `try` couvre le CORPS, PAS `dismiss()` (round 4) : + # `password` reste un nom lié dans CETTE fonction jusqu'à + # son retour, garde ou pas. Vérifié dans Textual 8.2.8 : + # `Screen.dismiss()` ne rappelle PAS le callback de résultat + # (ici `_after_account_added` → `reload_folders()` → + # `mailbox_refs()`) directement — `ResultCallback.__call__` + # (`screen.py:130`) fait `self.requester.call_next(self.callback, + # result)`, qui ne fait qu'EMPILER l'appel + # (`message_pump.py:507`) ; il ne tourne qu'après le retour + # complet de cette méthode, dans un cadre d'appel disjoint + # (confirmé empiriquement : ce cadre-ci n'apparaît PAS dans + # la pile du callback). Rien ne prouve donc qu'un échec dans + # ce callback exposerait `password` d'ICI — mais l'effacer + # avant `dismiss()` ne coûte rien et retire le doute pour de + # bon : une traceback ne peut rien afficher d'un nom qui ne + # référence plus rien. + name = self.query_one("#acc_name", Input).value.strip() + email_addr = self.query_one("#acc_email", Input).value.strip() + display = self.query_one("#acc_display", Input).value.strip() + preset_key = self.query_one("#acc_preset", Select).value + password = self.query_one("#acc_password", Input).value + + if not name or not email_addr or not password: + status.update(t("mail_account_missing_fields")) + return + + vault = ( + "kdbx" + if "kdbx" in self.secret_store.available_backends() + else "keyring" + ) + account = mail_accounts.account_from_preset( + name, + email_addr, + preset_key, + display_name=display, + vault=vault, + ) + + if preset_key == "generic": + account.imap.host = self.query_one( + "#acc_imap", Input + ).value.strip() + account.smtp.host = self.query_one( + "#acc_smtp", Input + ).value.strip() + + existing = [ + a for a in mail_accounts.load() if a.name != account.name + ] + account_setup.save_new_account( + self.secret_store, + existing + [account], + account, + password, + ) + except Exception as exc: + status.update(str(exc)) + return + + password = None + self.dismiss(account) + + app = MailApp( + sessions or [], + config_file=config_file, + secret_store=secret_store, + connect_fn=connect_fn, + base=base, + ) + if run_app: + app.run() diff --git a/script/todo/mail/tui_text.py b/script/todo/mail/tui_text.py new file mode 100644 index 0000000..07163eb --- /dev/null +++ b/script/todo/mail/tui_text.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Tout ce que le TUI calcule avant d'afficher. + +Ces fonctions sont volontairement hors de `tui.py` : elles n'ont besoin +d'aucun widget, donc elles se testent en une ligne. Le fichier de l'application +n'a plus qu'à composer des cadres et à appeler ces fonctions. + +Un courriel arrive rarement dans la forme qu'on espère : corps vide, HTML seul, +charset menteur, pièce jointe sans nom. Aucune de ces fonctions ne lève ; au +pire elles rendent une chaîne vide. Un message illisible doit s'afficher mal, +pas faire tomber la boîte de réception. +""" +from __future__ import annotations + +import datetime +import email +import email.policy +import html as html_module +import re +import unicodedata +from dataclasses import dataclass + +from script.todo.mail.charset import decode_bytes + +_SCRIPT_STYLE_RE = re.compile( + r"<(script|style)\b.*?", re.IGNORECASE | re.DOTALL +) +_BR_RE = re.compile(r"", re.IGNORECASE) +_BLOCK_RE = re.compile( + r"", re.IGNORECASE +) +_TAG_RE = re.compile(r"<[^>]+>") +_BLANKS_RE = re.compile(r"\n{3,}") + + +@dataclass +class Attachment: + filename: str + content_type: str + size: int + index: int + + +def html_to_text(html: str) -> str: + """Du HTML rendu lisible, sans dépendance externe. + + Ce n'est pas un moteur de rendu : on veut lire un courriel, pas afficher + une page. Scripts et styles disparaissent, les blocs deviennent des sauts + de ligne, le reste est du texte. + """ + if not html: + return "" + text = _SCRIPT_STYLE_RE.sub("", html) + text = _BR_RE.sub("\n", text) + text = _BLOCK_RE.sub("\n", text) + text = _TAG_RE.sub("", text) + text = html_module.unescape(text) + lines = [line.strip() for line in text.splitlines()] + return _BLANKS_RE.sub("\n\n", "\n".join(lines)).strip() + + +def _decode_part(part) -> str: + try: + payload = part.get_payload(decode=True) + except Exception: + # Broad except: parsing can fail in many ways; see module docstring. + return "" + if payload is None: + return "" + # `decode_bytes` (see its docstring): an unrecognised charset name must + # not bring down the whole message display. + return decode_bytes(payload, part.get_content_charset()) + + +def extract_body(raw: bytes) -> tuple[str, list[Attachment]]: + """Le texte affichable d'un message, et la liste de ses pièces jointes.""" + try: + msg = email.message_from_bytes(raw, policy=email.policy.default) + except Exception: + # Broad except: see module docstring. + return raw.decode("utf-8", "replace"), [] + + plain, html, attachments = "", "", [] + index = 0 + for part in msg.walk() if msg.is_multipart() else [msg]: + if part.get_content_maintype() == "multipart": + continue + disposition = (part.get_content_disposition() or "").lower() + ctype = part.get_content_type() + if disposition == "attachment" or ( + disposition == "inline" and not ctype.startswith("text/") + ): + try: + payload = part.get_payload(decode=True) or b"" + except Exception: + # Broad except: see module docstring. + payload = b"" + attachments.append( + Attachment( + filename=part.get_filename() + or f"piece-jointe-{index + 1}", + content_type=ctype, + size=len(payload), + index=index, + ) + ) + index += 1 + continue + if ctype == "text/plain" and not plain: + plain = _decode_part(part) + elif ctype == "text/html" and not html: + html = _decode_part(part) + + if plain: + return plain, attachments + if html: + return html_to_text(html), attachments + return "", attachments + + +def short_addr(value: str) -> str: + """« Alice Tremblay » → « Alice Tremblay ». Sinon l'adresse.""" + if not value: + return "" + from email.utils import getaddresses + + pairs = getaddresses([value]) + if not pairs: + return value.strip() + name, addr = pairs[0] + return (name or addr).strip() + + +def truncate(text: str, width: int) -> str: + """Coupé à `width` caractères au plus, ellipse comprise.""" + text = text or "" + if width <= 0: + return "" + if len(text) <= width: + return text + return text[: width - 1] + "…" + + +def format_date(epoch: int, now: int) -> str: + """Aujourd'hui → l'heure. Cette année → jour-mois. Avant → la date pleine.""" + if not epoch: + return "" + try: + stamp = datetime.datetime.fromtimestamp(epoch) + today = datetime.datetime.fromtimestamp(now) + except (OSError, OverflowError, ValueError): + # `fromtimestamp` lève hors de la plage représentable — OSError ou + # OverflowError selon l'ampleur. Une date aberrante vient d'un + # en-tête, donc d'une source non fiable : elle doit s'afficher vide, + # pas faire tomber la liste des messages. + return "" + if stamp.date() == today.date(): + return stamp.strftime("%H:%M") + if stamp.year == today.year: + return stamp.strftime("%m-%d") + return stamp.strftime("%Y-%m-%d") + + +def format_date_full(epoch: int) -> str: + """La date pleine, jour et heure, lisible sans le contexte de la liste. + + `format_date` est compact À DESSEIN pour la colonne de la liste ; + l'aperçu d'un message veut savoir QUAND il a été envoyé, sans avoir à + deviner l'année à partir de la date du jour. Même garde que + `format_date` : un en-tête vient d'une source non fiable, une date + aberrante doit rendre une chaîne vide, jamais lever. + """ + if not epoch: + return "" + try: + stamp = datetime.datetime.fromtimestamp(epoch) + except (OSError, OverflowError, ValueError): + # Voir `format_date` : `fromtimestamp` lève hors de la plage + # représentable — OSError ou OverflowError selon l'ampleur. + return "" + return stamp.strftime("%Y-%m-%d %H:%M") + + +def format_size(size: int) -> str: + size = size or 0 + if size < 1024: + return f"{size} o" + if size < 1024 * 1024: + return f"{size / 1024:.1f} ko" + return f"{size / (1024 * 1024):.1f} Mo" + + +def is_unread(flags: str | None) -> bool: + return "\\seen" not in (flags or "").lower() + + +def _fold(text: str) -> str: + """Sans accents ni casse : « revise » doit trouver « révisé ».""" + stripped = unicodedata.normalize("NFKD", text or "") + return "".join(c for c in stripped if not unicodedata.combining(c)).lower() + + +def filter_messages(metas: list, query: str) -> list: + """Filtre incrémental sur ce que le cache contient déjà. + + Volontairement local : la recherche côté serveur est une fonction de la + phase 3, celle-ci doit répondre à chaque frappe sans réseau. + """ + if not query: + return list(metas) + needle = _fold(query) + return [ + m + for m in metas + if needle in _fold(f"{m.subject} {m.frm} {m.to} {m.snippet}") + ] diff --git a/script/todo/todo.py b/script/todo/todo.py index 21e2162..793191f 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -120,6 +120,109 @@ class TODO: set_lang("en") print(t("Language changed to: English")) + def run(self): + with open(self.config_file.get_logo_ascii_file_path()) as my_file: + print(my_file.read()) + self._ask_language() + print(t("Opening TODO ...")) + print(f"🤖 {t('=> Enter your choice by number and press Enter!')}") + help_info = f"""{self._menu_header()} +[1] {t("Execute")} +[2] {t("Install")} +[3] {t("Assistant")} +[4] {t("Fork - Open TODO in a new tab")} +[5] {t("Navigation telemetry (TUI)")} +[6] {t("Configuration")} +[0] {t("Quit")} +""" + while True: + try: + status = click.prompt(help_info) + except NameError: + print("Do") + print(f"source ./{VENV_ERPLIBRE}/bin/activate && make") + sys.exit(1) + except ImportError: + print("Do") + print(f"source ./{VENV_ERPLIBRE}/bin/activate && make") + sys.exit(1) + except click.exceptions.Abort: + sys.exit(0) + print() + if status == "0": + break + elif status == "1": + self.prompt_execute() + elif status == "2": + self.prompt_install() + elif status == "3": + self.prompt_assistant() + elif status == "4": + # cmd = ( + # f"gnome-terminal --tab -- bash -c 'source" + # f" ./{VENV_ERPLIBRE}/bin/activate;make todo'" + # ) + cmd = "make todo" + self.execute.exec_command_live(cmd, source_erplibre=True) + elif status == "5": + self._todo_telemetry_tui() + elif status == "6": + self.prompt_configuration() + # elif status == "3" or status == "install": + # print("install") + else: + print(t("Command not found !")) + + print(status) + # manipuler() + + def prompt_assistant(self): + """Ce qui s'adresse à l'humain : poser une question, lire son courriel.""" + from script.todo.mail.menu import prompt_execute_mail + + while True: + help_info = f"""{self._menu_header()} +[1] {t("mail_ai_question")} +[2] {t("mail_menu")} +[0] {t("Back")}""" + status = click.prompt(help_info) + print() + if status == "0": + return + if status == "1": + self._assistant_question() + elif status == "2": + prompt_execute_mail(self) + else: + print(t("Command not found !")) + + def _assistant_question(self): + while True: + help_info = f"""{self._menu_header()} +[0] {t("Back")} +{t("Write your question ")}""" + status = click.prompt(help_info) + print() + if status == "0": + return + kp = self.kdbx_manager.get_kdbx() + if not kp: + return + config_name = self.config_file.get_config_value( + ["kdbx_config", "openai", "kdbx_key"] + ) + entry = kp.find_entries_by_title(config_name, first=True) + + client = openai.OpenAI(api_key=entry.password) + prompt_update = status + completion = client.chat.completions.create( + model="gpt-4o", + messages=[{"role": "user", "content": prompt_update}], + ) + + print(completion.choices[0].message.content) + print() + def prompt_execute(self): help_info = f"""{self._menu_header()} @@ -7536,3 +7639,27 @@ class TODO: status = self.execute.exec_command_live( "./mobile/compile_and_run.sh", source_erplibre=False ) + + +if __name__ == "__main__": + start_time = time.time() + try: + todo = TODO() + if ENABLE_CRASH: + todo.crash_diagnostic(CRASH_E) + todo.run() + except (KeyboardInterrupt, click.exceptions.Abort): + # click.prompt() raises Abort (not a KeyboardInterrupt subclass) on + # both Ctrl+C and Ctrl+D/EOF. run() only catches it for its own + # top-level prompt; every submenu's click.prompt() would otherwise + # let Abort escape here as an uncaught exception. + print(t("Keyboard interrupt")) + finally: + end_time = time.time() + duration_sec = end_time - start_time + if humanize: + duration_delta = datetime.timedelta(seconds=duration_sec) + humain_time = humanize.precisedelta(duration_delta) + print(f"\n{t('TODO execution time')} {humain_time}\n") + else: + print(f"\n{t('TODO execution time')} {duration_sec:.2f} sec.\n") diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 6a248c5..7e36d2c 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -35,6 +35,10 @@ TRANSLATIONS = { "fr": "📦 Installation", "en": "📦 Install", }, + "Assistant": { + "fr": "🤖 Assistant", + "en": "🤖 Assistant", + }, "Fork - Open TODO in a new tab": { "fr": "🔀 Fork - Ouvre TODO dans une nouvelle tabulation", "en": "🔀 Fork - Open TODO in a new tab", @@ -4097,6 +4101,620 @@ TRANSLATIONS = { "fr": "chemin d'un fichier de configuration Odoo", "en": "path to an Odoo config file", }, + # Courriel + "mail_menu": { + "fr": "Courriel - Lire et envoyer du courriel", + "en": "Mail - Read and send email", + }, + "mail_ai_question": { + "fr": "Question IA - Poser une question à un modèle", + "en": "AI question - Ask a model a question", + }, + "mail_open_tui": { + "fr": "Ouvrir le client courriel (TUI)", + "en": "Open the mail client (TUI)", + }, + "mail_accounts_menu": {"fr": "Comptes", "en": "Accounts"}, + "mail_sync_now": { + "fr": "Synchroniser maintenant", + "en": "Synchronise now", + }, + "mail_cache_menu": {"fr": "Cache", "en": "Cache"}, + "mail_account_list": {"fr": "Lister les comptes", "en": "List accounts"}, + "mail_account_add": {"fr": "Ajouter un compte", "en": "Add an account"}, + "mail_account_delete": { + "fr": "Supprimer un compte", + "en": "Delete an account", + }, + "mail_account_template": { + "fr": "Générer un modèle accounts.json", + "en": "Generate an accounts.json template", + }, + "mail_account_test": { + "fr": "Tester la connexion d'un compte", + "en": "Test an account connection", + }, + "mail_cache_default_mode": { + "fr": "Mode de cache par défaut", + "en": "Default cache mode", + }, + "mail_cache_account_mode": { + "fr": "Mode de cache d'un compte", + "en": "Cache mode of one account", + }, + "mail_cache_size_purge": { + "fr": "Taille du cache et purge", + "en": "Cache size and purge", + }, + "mail_no_account": { + "fr": "Aucun compte configuré. Ajoutez-en un d'abord.", + "en": "No account configured. Add one first.", + }, + "mail_ask_name": { + "fr": "Nom court du compte : ", + "en": "Short account name: ", + }, + "mail_ask_email": {"fr": "Adresse courriel : ", "en": "Email address: "}, + "mail_ask_display_name": { + "fr": "Nom affiché (facultatif) : ", + "en": "Display name (optional): ", + }, + "mail_ask_preset": {"fr": "Fournisseur : ", "en": "Provider: "}, + "mail_ask_password": {"fr": "Mot de passe : ", "en": "Password: "}, + "mail_ask_imap_host": {"fr": "Serveur IMAP : ", "en": "IMAP server: "}, + "mail_ask_smtp_host": {"fr": "Serveur SMTP : ", "en": "SMTP server: "}, + "mail_ask_account": {"fr": "Quel compte ? ", "en": "Which account? "}, + "mail_ask_mode": { + "fr": "Mode (clear / encrypted / ephemeral) : ", + "en": "Mode (clear / encrypted / ephemeral): ", + }, + "mail_app_password_note": { + "fr": "Ce fournisseur exige un mot de passe d'application.", + "en": "This provider requires an app password.", + }, + "mail_account_saved": {"fr": "Compte enregistré.", "en": "Account saved."}, + "mail_account_save": {"fr": "Enregistrer", "en": "Save"}, + "mail_account_missing_fields": { + "fr": "Nom, adresse et mot de passe sont requis.", + "en": "Name, address and password are required.", + }, + "mail_account_add_unavailable": { + "fr": "Ajout de compte indisponible dans ce contexte.", + "en": "Adding an account is unavailable in this context.", + }, + "mail_account_deleted": { + "fr": "Compte supprimé.", + "en": "Account deleted.", + }, + "mail_connection_ok": { + "fr": "Connexion réussie.", + "en": "Connection succeeded.", + }, + "mail_connection_failed": { + "fr": "Connexion échouée :", + "en": "Connection failed:", + }, + "mail_template_written": { + "fr": "Modèle écrit dans", + "en": "Template written to", + }, + "mail_purge_confirm": { + "fr": "Effacer tout le cache de ce compte ? (o/N) ", + "en": "Erase this account's whole cache? (y/N) ", + }, + "mail_purged": {"fr": "Cache effacé.", "en": "Cache erased."}, + "mail_no_vault": { + "fr": "Aucun coffre disponible : installez pykeepass ou déverrouillez un trousseau système.", + "en": "No vault available: install pykeepass or unlock a system keyring.", + }, + "mail_kdbx_none_configured": { + "fr": "Aucun fichier kdbx n'est configuré.", + "en": "No kdbx file is configured.", + }, + "mail_kdbx_menu_create": { + "fr": "Créer un nouveau fichier .kdbx", + "en": "Create a new .kdbx file", + }, + "mail_kdbx_menu_choose": { + "fr": "Choisir un fichier existant", + "en": "Choose an existing file", + }, + "mail_kdbx_menu_cancel": {"fr": "Annuler", "en": "Cancel"}, + "mail_kdbx_ask_choice": {"fr": "Votre choix : ", "en": "Your choice: "}, + "mail_kdbx_ask_path_new": { + "fr": "Chemin du nouveau fichier kdbx", + "en": "Path for the new kdbx file", + }, + "mail_kdbx_ask_path_existing": { + "fr": "Chemin du fichier kdbx existant : ", + "en": "Path to the existing kdbx file: ", + }, + "mail_kdbx_ask_password": { + "fr": "Mot de passe du coffre : ", + "en": "Vault password: ", + }, + "mail_kdbx_ask_password_confirm": { + "fr": "Confirmez le mot de passe : ", + "en": "Confirm the password: ", + }, + "mail_kdbx_password_mismatch": { + "fr": "Les mots de passe ne correspondent pas.", + "en": "Passwords do not match.", + }, + "mail_kdbx_path_not_found": { + "fr": "Ce fichier n'existe pas :", + "en": "This file does not exist:", + }, + "mail_kdbx_created": { + "fr": "Fichier kdbx créé :", + "en": "Kdbx file created:", + }, + "mail_kdbx_path_recorded": { + "fr": "Fichier kdbx configuré :", + "en": "Kdbx file configured:", + }, + "mail_no_password_stored": { + "fr": "Aucun mot de passe enregistré pour ce compte.", + "en": "No password stored for this account.", + }, + "mail_install_textual": { + "fr": "Installez textual pour le client courriel (pip).", + "en": "Install textual for the mail client (pip).", + }, + "mail_accounts": {"fr": "Comptes", "en": "Accounts"}, + "mail_search": {"fr": "Rechercher…", "en": "Search…"}, + "mail_search_clear": { + "fr": "Effacer la recherche", + "en": "Clear search", + }, + "mail_from": {"fr": "De :", "en": "From:"}, + "mail_to": {"fr": "À :", "en": "To:"}, + "mail_cc": {"fr": "Cc :", "en": "Cc:"}, + "mail_subject": {"fr": "Objet :", "en": "Subject:"}, + "mail_date": {"fr": "Date", "en": "Date"}, + "mail_send": {"fr": "Envoyer", "en": "Send"}, + "mail_attachments": {"fr": "Pièces jointes :", "en": "Attachments:"}, + "mail_attachments_paths": { + "fr": "Pièces jointes (chemins séparés par des points-virgules)", + "en": "Attachments (semicolon-separated paths)", + }, + "mail_browse": {"fr": "Parcourir…", "en": "Browse…"}, + "mail_browse_failed": { + "fr": "Sélecteur de fichiers impossible :", + "en": "File browser unavailable:", + }, + "mail_no_subject": {"fr": "(sans objet)", "en": "(no subject)"}, + "mail_body_needs_network": { + "fr": "Corps non téléchargé — connexion requise.", + "en": "Body not downloaded — connection required.", + }, + "mail_body_error": { + "fr": "Lecture du corps impossible :", + "en": "Cannot read the body:", + }, + "mail_flag_error": { + "fr": "Drapeau non transmis au serveur :", + "en": "Flag not sent to the server:", + }, + "mail_syncing": {"fr": "Synchronisation de", "en": "Synchronising"}, + "mail_new_messages": {"fr": "nouveaux messages", "en": "new messages"}, + "mail_errors": {"fr": "erreurs", "en": "errors"}, + "mail_folders_resynced": { + "fr": "dossiers resynchronisés (UIDVALIDITY changé) :", + "en": "folders resynchronised (UIDVALIDITY changed):", + }, + "mail_offline_cannot_send": { + "fr": "Compte hors ligne : envoi impossible.", + "en": "Account offline: cannot send.", + }, + "mail_sent_to": {"fr": "Envoyé à", "en": "Sent to"}, + "mail_sent_not_filed": { + "fr": "envoyé, mais pas classé dans Envoyés", + "en": "sent, but not filed in Sent", + }, + "mail_nothing_to_reply_to": { + "fr": "Aucun message sélectionné.", + "en": "No message selected.", + }, + "mail_nothing_to_forward": { + "fr": "Aucun message à transférer.", + "en": "No message to forward.", + }, + "mail_attachment_not_found": { + "fr": "Pièce jointe introuvable.", + "en": "Attachment not found.", + }, + "mail_no_attachment": { + "fr": "Ce message n'a pas de pièce jointe.", + "en": "This message has no attachment.", + }, + "mail_save_failed": { + "fr": "Enregistrement impossible :", + "en": "Cannot save:", + }, + "mail_saved_to": {"fr": "Enregistré dans", "en": "Saved to"}, + "mail_log_binding": {"fr": "Journal", "en": "Log"}, + "mail_log_close": {"fr": "Fermer", "en": "Close"}, + "mail_log_tail_heading": { + "fr": "Journal (fin) :", + "en": "Log (tail):", + }, + "mail_log_errors_heading": { + "fr": "Erreurs de synchronisation (session en cours) :", + "en": "Sync errors (current session):", + }, + "mail_log_missing": { + "fr": "journal introuvable", + "en": "log not found", + }, + "mail_log_empty": {"fr": "journal vide", "en": "log empty"}, + "mail_log_unreadable": { + "fr": "journal illisible", + "en": "log unreadable", + }, + "mail_log_no_errors": { + "fr": "Aucune erreur de synchronisation dans cette session.", + "en": "No sync errors in this session.", + }, + "mail_layout_binding": {"fr": "Vue", "en": "View"}, + "mail_layout_switched": {"fr": "Disposition :", "en": "Layout:"}, + "mail_layout_columns": {"fr": "Colonnes", "en": "Columns"}, + "mail_layout_split": {"fr": "Partagée", "en": "Split"}, + "mail_layout_stacked": {"fr": "Empilée", "en": "Stacked"}, + "mail_pane_grow_binding": {"fr": "Agrandir volet", "en": "Grow pane"}, + "mail_pane_shrink_binding": { + "fr": "Rétrécir volet", + "en": "Shrink pane", + }, + "mail_pane_reset_binding": { + "fr": "Tailles par défaut", + "en": "Reset sizes", + }, + "mail_pane_reset_done": { + "fr": "Tailles des volets réinitialisées.", + "en": "Pane sizes reset.", + }, + "mail_pane_splitter_tooltip": { + "fr": "Glisser pour redimensionner", + "en": "Drag to resize", + }, + "mail_fullscreen_binding": {"fr": "Plein écran", "en": "Full screen"}, + # -- Libellés des raccourcis de MailApp (suffixe _binding) ------------- + # Ce sont les descriptions des `Binding` de `MailApp` : elles s'affichent + # au pied d'écran ET, depuis la tâche 26, dans la fenêtre d'aide (`h`), + # qui les lit directement dans `MailApp.BINDINGS`. Le français est repris + # MOT POUR MOT de ce qui était écrit en dur avant cette tâche — le pied + # d'écran d'un utilisateur francophone ne change pas. + "mail_quit_binding": {"fr": "Quitter", "en": "Quit"}, + "mail_sync_current_binding": {"fr": "Sync", "en": "Sync"}, + "mail_sync_all_binding": {"fr": "Sync tout", "en": "Sync all"}, + "mail_back_binding": {"fr": "Retour", "en": "Back"}, + "mail_search_binding": {"fr": "Rechercher", "en": "Search"}, + "mail_mark_seen_binding": {"fr": "Lu", "en": "Read"}, + "mail_mark_unseen_binding": {"fr": "Non lu", "en": "Unread"}, + "mail_save_attachment_binding": { + "fr": "Enregistrer PJ", + "en": "Save attachment", + }, + "mail_compose_binding": {"fr": "Écrire", "en": "Compose"}, + "mail_reply_binding": {"fr": "Répondre", "en": "Reply"}, + "mail_reply_all_binding": {"fr": "Répondre à tous", "en": "Reply all"}, + "mail_forward_binding": {"fr": "Transférer", "en": "Forward"}, + "mail_add_account_binding": { + "fr": "Nouveau compte", + "en": "New account", + }, + # -- Fenêtre d'aide (touche h) ----------------------------------------- + # La liste des touches n'est PAS ici : elle est engendrée depuis + # `MailApp.BINDINGS` (voir `HelpScreen`), avec les libellés ci-dessus. + # Seul ce qu'une liste de touches ne peut pas dire est rédigé ici. + "mail_help_binding": {"fr": "Aide", "en": "Help"}, + "mail_help_close": {"fr": "Fermer", "en": "Close"}, + "mail_help_title": { + "fr": "Aide — client courriel", + "en": "Help — mail client", + }, + "mail_help_keys_heading": { + "fr": "Raccourcis clavier :", + "en": "Keyboard shortcuts:", + }, + "mail_help_notes_heading": {"fr": "Bon à savoir :", "en": "Good to know:"}, + "mail_help_mouse": { + "fr": ( + "Souris : glisser une barre entre deux volets les redimensionne." + " Au clavier, + et - font de même sur le volet qui a le focus, et" + " 0 remet les tailles par défaut. Les tailles sont retenues par" + " disposition." + ), + "en": ( + "Mouse: drag a bar between two panes to resize them. From the" + " keyboard, + and - do the same to the focused pane, and 0 resets" + " the sizes. Sizes are remembered per layout." + ), + }, + "mail_help_layouts": { + "fr": ( + "Dispositions : v passe de colonnes à partagée, puis empilée, puis" + " revient à colonnes." + ), + "en": ( + "Layouts: v cycles columns, split, stacked, then back to columns." + ), + }, + "mail_help_sync": { + "fr": ( + "Synchronisation : r synchronise le compte du dossier sélectionné" + " (tous ses dossiers), R synchronise tous les comptes. La" + " synchronisation automatique ne tourne QUE tant que le client est" + " ouvert, à l'intervalle mail_refresh_sec (300 s par défaut ; 0 la" + " désactive)." + ), + "en": ( + "Sync: r syncs the account of the selected folder (all its" + " folders), R syncs every account. The automatic refresh runs ONLY" + " while the client is open, at the mail_refresh_sec interval (300 s" + " by default; 0 disables it)." + ), + }, + "mail_help_files": { + "fr": ( + "Fichiers : le journal est dans ~/.erplibre/mail.log (la touche l" + " en montre la fin), les comptes dans" + " ~/.erplibre/mail/accounts.json. Les mots de passe n'y sont JAMAIS" + " écrits : ils vivent dans le coffre kdbx ou le trousseau du" + " système." + ), + "en": ( + "Files: the log lives in ~/.erplibre/mail.log (the l key shows its" + " tail), the accounts in ~/.erplibre/mail/accounts.json. Passwords" + " are NEVER written there: they live in the kdbx vault or in the" + " system keyring." + ), + }, + "mail_help_close_hint": { + "fr": "Échap ferme cette fenêtre.", + "en": "Esc closes this window.", + }, + # -- Exceptions internes au paquet courriel (préfixe mail_err_) -------- + # Label traduit, données dynamiques (chemins, texte serveur, valeurs de + # config) concaténées crues : jamais de traduction d'un message serveur + # ou d'un chemin de fichier. + # + # Quelques messages ont la donnée dynamique AU MILIEU de la phrase : ils + # sont donc assemblés à partir de deux clés (voire trois), dans un ordre + # FIXE codé au site d'appel plutôt que par une seule clé avec un + # emplacement — mail_err_unknown_security + mail_err_expected, + # mail_err_key_wrong_length + mail_err_octets_unit, + # mail_err_mode_prefix + mail_err_mode_requires_key(_no_vault), + # mail_err_imap_connection_prefix / mail_err_smtp_connection_prefix + + # mail_err_connection_refused_suffix, et mail_err_keyring_plaintext + + # mail_err_keyring_plaintext_hint. L'ordre des mots vit donc dans le + # code, pas dans les chaînes traduisibles : une langue à l'ordre des + # mots différent devra remplacer ces paires par une convention à + # emplacement (ex. `.format()`), pas par une simple concaténation. + "mail_err_envelope_too_short": { + "fr": "enveloppe trop courte", + "en": "envelope too short", + }, + "mail_err_sealed_in_clear_mode": { + "fr": "donnée chiffrée lue en mode clair : la clé du compte manque", + "en": "encrypted data read in clear mode: the account key is missing", + }, + "mail_err_unknown_envelope": { + "fr": "enveloppe inconnue :", + "en": "unknown envelope:", + }, + "mail_err_key_wrong_length": { + "fr": "la clé doit faire", + "en": "the key must be", + }, + "mail_err_octets_unit": {"fr": "octets", "en": "bytes"}, + "mail_err_cryptography_not_installed": { + "fr": "le paquet cryptography n'est pas installé", + "en": "the cryptography package is not installed", + }, + "mail_err_decrypt_refused": { + "fr": "déchiffrement refusé : clé fausse ou donnée altérée", + "en": "decryption refused: wrong key or corrupted data", + }, + "mail_err_envelope_unreadable": { + "fr": "enveloppe illisible :", + "en": "unreadable envelope:", + }, + "mail_err_mode_prefix": {"fr": "le mode", "en": "mode"}, + "mail_err_mode_requires_key": { + "fr": "exige une clé", + "en": "requires a key", + }, + "mail_err_unknown_cache_mode": { + "fr": "mode de cache inconnu :", + "en": "unknown cache mode:", + }, + "mail_err_file_already_exists": { + "fr": "le fichier existe déjà :", + "en": "the file already exists:", + }, + "mail_err_invalid_secret_ref": { + "fr": "référence de secret invalide :", + "en": "invalid secret reference:", + }, + # Notes par fournisseur. Affichées à l'ajout d'un compte ET juste avant + # de redemander un mot de passe après un refus : elles doivent donner + # l'adresse EXACTE, pas un chemin de menu — Google et Apple déplacent + # régulièrement ces pages, et Google cache la sienne. + "mail_preset_note_gmail": { + "fr": ( + "Générez-le sur https://myaccount.google.com/apppasswords" + " (16 caractères, les espaces sont acceptés). La validation en" + " deux étapes doit être active, sinon la page est vide." + ), + "en": ( + "Generate one at https://myaccount.google.com/apppasswords" + " (16 characters, spaces are accepted). Two-step verification" + " must be on, otherwise the page is empty." + ), + }, + "mail_preset_note_outlook": { + "fr": ( + "Générez-le sur https://account.microsoft.com/security." + " Microsoft ferme l'authentification simple sur les comptes" + " grand public : sans mot de passe d'application, il faudra" + " OAuth (phase 2, non implémentée)." + ), + "en": ( + "Generate one at https://account.microsoft.com/security." + " Microsoft is closing basic authentication on consumer" + " accounts: without an app password this needs OAuth (phase 2," + " not implemented)." + ), + }, + "mail_preset_note_icloud": { + "fr": ( + "Générez-le sur https://account.apple.com, section « Connexion" + " et sécurité ». L'authentification à deux facteurs doit être" + " active." + ), + "en": ( + 'Generate one at https://account.apple.com, under "Sign-In and' + ' Security". Two-factor authentication must be on.' + ), + }, + "mail_preset_note_generic": { + "fr": "Saisissez les serveurs de votre fournisseur.", + "en": "Enter your provider's servers.", + }, + "mail_ask_app_password": { + "fr": "Mot de passe d'application : ", + "en": "App password: ", + }, + "mail_err_no_kdbx_configured": { + "fr": "aucun fichier kdbx configuré", + "en": "no kdbx file configured", + }, + "mail_err_kdbx_unreadable": { + "fr": "le fichier kdbx n'a pas pu être ouvert", + "en": "the kdbx file could not be opened", + }, + "mail_err_no_vault_available": { + "fr": "aucun coffre disponible : ni kdbx, ni trousseau système", + "en": "no vault available: neither kdbx nor system keyring", + }, + "mail_err_keyring_plaintext": { + "fr": ( + "le trousseau du système écrirait le mot de passe en clair" + " (backend" + ), + "en": ( + "the system keyring would store the password in plaintext" + " (backend" + ), + }, + "mail_err_keyring_plaintext_hint": { + "fr": "Utilisez un fichier kdbx, ou déverrouillez un vrai trousseau.", + "en": "Use a kdbx file, or unlock a real keyring.", + }, + "mail_err_unknown_security": { + "fr": "sécurité inconnue :", + "en": "unknown security:", + }, + "mail_err_expected": {"fr": "(attendu", "en": "(expected"}, + "mail_err_account_needs_name": { + "fr": "un compte doit avoir un nom", + "en": "an account must have a name", + }, + "mail_err_invalid_account_name": { + "fr": "nom de compte invalide :", + "en": "invalid account name:", + }, + "mail_err_account_name_reason": { + "fr": "(il sert de nom de dossier et de référence de coffre)", + "en": "(it is used as a folder name and vault reference)", + }, + "mail_err_account_unreadable": { + "fr": "compte illisible :", + "en": "unreadable account:", + }, + "mail_err_unknown_preset": { + "fr": "préréglage inconnu :", + "en": "unknown preset:", + }, + "mail_err_not_valid_json": { + "fr": "n'est pas du JSON valide :", + "en": "is not valid JSON:", + }, + "mail_err_should_contain_json_object": { + "fr": "devrait contenir un objet JSON", + "en": "should contain a JSON object", + }, + "mail_err_duplicate_account_names": { + "fr": "noms de compte en double :", + "en": "duplicate account names:", + }, + "mail_err_already_exists_relaunch": { + "fr": "existe déjà — relancez avec l'option de remplacement", + "en": "already exists — rerun with the overwrite option", + }, + "mail_err_symlink_refused": { + "fr": "est un lien symbolique : cache refusé", + "en": "is a symlink: cache refused", + }, + "mail_err_owned_by_other_user": { + "fr": "appartient à un autre utilisateur : cache refusé", + "en": "belongs to another user: cache refused", + }, + "mail_err_cache_unreadable": { + "fr": "cache illisible, purgez-le et resynchronisez :", + "en": "unreadable cache, purge it and resynchronise:", + }, + "mail_err_mode_requires_key_no_vault": { + "fr": "exige une clé : aucun coffre fourni", + "en": "requires a key: no vault provided", + }, + "mail_err_cache_not_open": { + "fr": "cache non ouvert : appelez open() d'abord", + "en": "cache not open: call open() first", + }, + "mail_err_unknown_folder_fields": { + "fr": "champs de dossier inconnus :", + "en": "unknown folder fields:", + }, + "mail_err_unterminated_ampersand": { + "fr": "séquence & non terminée", + "en": "unterminated & sequence", + }, + "mail_err_server_replied": { + "fr": ": le serveur a répondu", + "en": ": the server replied", + }, + "mail_err_no_body_for_uid": { + "fr": "aucun corps rendu pour l'UID", + "en": "no body returned for UID", + }, + "mail_err_imap_connection_prefix": { + "fr": "connexion IMAP à", + "en": "IMAP connection to", + }, + "mail_err_smtp_connection_prefix": { + "fr": "connexion SMTP à", + "en": "SMTP connection to", + }, + "mail_err_connection_refused_suffix": { + "fr": "refusée :", + "en": "refused:", + }, + "mail_err_message_needs_recipient": { + "fr": "un message doit avoir au moins un destinataire", + "en": "a message must have at least one recipient", + }, + "mail_err_attachment_missing": { + "fr": "pièce jointe introuvable :", + "en": "attachment not found:", + }, + "mail_err_no_recipient_nothing_sent": { + "fr": "aucun destinataire : rien n'a été envoyé", + "en": "no recipient: nothing was sent", + }, + "mail_err_send_refused": {"fr": "envoi refusé :", "en": "send refused:"}, } diff --git a/script/todo/todo_prefs.py b/script/todo/todo_prefs.py index 9273c24..14eab51 100644 --- a/script/todo/todo_prefs.py +++ b/script/todo/todo_prefs.py @@ -30,6 +30,26 @@ DEFAULTS = { "qemu_deploy_progress": "cli", # Interface de la migration Odoo : "ask" / "tui" / "cli". "migration_ui": "ask", + # Cache courriel : mode par DÉFAUT. Un compte peut le surcharger via + # sa clé `cache_mode` dans accounts.json ; `null` là-bas veut dire + # « hérite d'ici ». Valeurs : clear | encrypted | ephemeral. + "mail_cache_mode": "clear", + # Rafraîchissement automatique des boîtes, en secondes, ACTIF seulement + # tant que le TUI courriel est à l'écran. 0 désactive. + "mail_refresh_sec": 300, + # Disposition des volets du client courriel (touche `v`). Voir + # `script.todo.mail.tui.MAIL_LAYOUTS` pour les valeurs valides ; + # `resolve_layout` y retombe sur "columns" si la valeur stockée n'en fait + # plus partie. + "mail_layout": "columns", + # Tailles personnalisées des volets (`+`/`-`/`0`, et la souris de la + # tâche suivante), UNE entrée PAR disposition : {"": {"folders": + # , "list_pane": }}. Une disposition ou un volet + # absent de ce dictionnaire veut dire « pas encore personnalisé » — la + # feuille de style de la disposition décide seule. Voir + # `script.todo.mail.tui.resolve_pane_sizes`, qui retombe sur {} pour + # toute valeur absente ou corrompue. + "mail_pane_sizes": {}, } diff --git a/test/mail_sandbox.py b/test/mail_sandbox.py new file mode 100644 index 0000000..09d8e47 --- /dev/null +++ b/test/mail_sandbox.py @@ -0,0 +1,832 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Un vrai serveur IMAP et un vrai serveur SMTP, jetables, pour les tests. + +Pourquoi : tous les autres tests courriel passent par un double +(`FakeImapTransport`, `MagicMock`). Un double ne produit que ce qu'on avait +imaginé en l'écrivant — c'est précisément par là que des bugs de protocole +sont passés jusqu'à l'utilisateur. Ce module ouvre de VRAIES sockets sur +127.0.0.1 pour que le client soit exercé sur du vrai TCP. + +L'intérêt n'est pas la conformité : un serveur poli ne prouve pas grand-chose. +L'intérêt est de pouvoir SE CONDUIRE MAL à la demande — servir un en-tête en +octets 8 bits, un charset `unknown-8bit`, une connexion coupée en plein FETCH. +Ajouter une méchanceté doit rester une petite addition (une sous-classe de +`Fault`, ou de simples octets déclarés par le test), jamais un nouveau serveur. + +## Le réacteur Twisted ne se redémarre pas + +`reactor.run()` ne peut être appelé qu'UNE fois par processus ; après +`reactor.stop()` il refuse de repartir. `unittest` enchaîne les tests dans un +seul processus : « un réacteur par test » échouerait dès le deuxième test, et +la panne ressemble à un blocage, pas à une erreur claire. + +D'où le choix ici : UN seul réacteur, démarré à la demande dans un fil de +fond, et JAMAIS arrêté avant la fin du processus. Un test n'ouvre et ne ferme +qu'un port d'écoute (`reactor.listenTCP` / `port.stopListening`). L'état +propre par test ne vient donc pas du réacteur — il vient des objets : chaque +test construit ses propres boîtes et ses propres messages, et rien n'est +partagé entre deux tests. Les tests passent donc dans n'importe quel ordre et +un par un. + +`aiosmtpd.Controller` porte sa propre boucle asyncio dans un fil et n'a pas ce +problème ; il ne sait en revanche pas se lier au port 0 tel quel, voir +`SmtpSandbox`. + +## Sécurité + +Rien ici ne sort de la machine : on se lie à 127.0.0.1 sur le port 0 (l'OS +choisit), jamais sur un port fixe qui entrerait en collision avec ce qui +écoute déjà. Aucun trousseau, aucun `~/.erplibre`, aucun identifiant réel. +""" +from __future__ import annotations + +import atexit +import email +import logging +import re +import socket +import threading +import unittest +import warnings +from dataclasses import dataclass, field +from io import BytesIO + +from twisted.cred import checkers, portal +from twisted.internet import protocol +from twisted.internet.threads import blockingCallFromThread +from twisted.mail import imap4 +from zope.interface import implementer + +from script.todo.mail.accounts import Account, ServerConf + +REACTOR_START_TIMEOUT = 10 +SERVER_STOP_TIMEOUT = 10 + +USER = "moi" +PASSWORD = "secret" + +# Tous les bacs à sable actuellement à l'écoute. Sert de preuve de non-fuite : +# à la fin d'un test l'ensemble doit être revenu à ce qu'il était (voir +# `TestSandboxLifecycle` dans `test_mail_live_server.py`). +LIVE_SERVERS: set = set() + + +# -------------------------------------------------------------------------- +# Le réacteur, un seul, dans un fil de fond +# -------------------------------------------------------------------------- + +_reactor_lock = threading.Lock() +_reactor_thread: threading.Thread | None = None + + +def reactor_in_thread(): + """Le réacteur global, en marche dans un fil de fond. + + Idempotent : le premier appel le démarre, les suivants le retrouvent. On + ne l'arrête qu'à la sortie du processus (`atexit`), parce qu'un réacteur + arrêté ne repart jamais. + """ + global _reactor_thread + from twisted.internet import reactor + + with _reactor_lock: + if _reactor_thread is None: + running = threading.Event() + reactor.callWhenRunning(running.set) + _reactor_thread = threading.Thread( + target=reactor.run, + kwargs={"installSignalHandlers": False}, + name="mail-sandbox-reactor", + daemon=True, + ) + _reactor_thread.start() + if not running.wait(REACTOR_START_TIMEOUT): + raise RuntimeError( + "le réacteur Twisted n'a pas démarré en" + f" {REACTOR_START_TIMEOUT}s" + ) + atexit.register(_stop_reactor) + return reactor + + +def _stop_reactor() -> None: + """Arrêt de fin de processus. Le fil est `daemon` : même si le réacteur + reste coincé, il n'empêchera pas Python de sortir.""" + global _reactor_thread + from twisted.internet import reactor + + thread, _reactor_thread = _reactor_thread, None + if thread is None: + return + try: + reactor.callFromThread(reactor.stop) + except Exception: + # Le réacteur peut déjà être mort : la sortie du processus ne doit + # jamais échouer là-dessus. + return + thread.join(SERVER_STOP_TIMEOUT) + + +# -------------------------------------------------------------------------- +# Les méchancetés +# -------------------------------------------------------------------------- + + +@dataclass +class Fault: + """Une panne serveur déclenchée par une commande cliente. + + Ajouter une méchanceté = une sous-classe de trois lignes. `command` dit + sur quelle commande elle se déclenche (`b"FETCH"`, `b"SELECT"`...), + `after` combien d'occurrences on laisse passer avant de frapper — c'est + ce qui permet de couper la connexion au milieu d'une passe plutôt qu'à + son premier mot — et `strike()` fait le mal. + + Le déclenchement est compté, pas chronométré : aucun test ne dépend + d'une durée, donc aucun ne devient instable sur une machine chargée. + """ + + command: bytes + after: int = 0 + fired: int = field(default=0, init=False) + + def matches(self, command: bytes) -> bool: + if command.upper() != self.command.upper(): + return False + self.fired += 1 + return self.fired > self.after + + def strike(self, server, tag) -> None: + raise NotImplementedError + + +@dataclass +class DropConnection(Fault): + """Le serveur raccroche sans un mot, la commande restée sans réponse. + + C'est la panne réseau ordinaire — coupure Wi-Fi, pare-feu, serveur qui + redémarre — et celle qu'aucun double n'a jamais produite, puisqu'un + double répond toujours. + """ + + def strike(self, server, tag) -> None: + server.transport.abortConnection() + + +@dataclass +class RefuseCommand(Fault): + """Le serveur répond NO. Un dossier qu'on n'a pas le droit de lire, un + quota dépassé : le client doit continuer sur les autres dossiers.""" + + text: bytes = b"Sandbox refuses this command" + + def strike(self, server, tag) -> None: + server.sendNegativeResponse(tag, self.text) + + +# -------------------------------------------------------------------------- +# Le contenu servi : des octets, tels que le test les déclare +# -------------------------------------------------------------------------- + + +def _as_text(value) -> str: + return ( + value.decode("ascii", "replace") if isinstance(value, bytes) else value + ) + + +def _as_bytes(value) -> bytes: + return ( + value.encode("ascii", "replace") if isinstance(value, str) else value + ) + + +def _split_header_lines(raw: bytes) -> list[bytes]: + """Les lignes d'en-tête de `raw`, repliements compris, terminées en CRLF. + + On accepte le LF seul en entrée : c'est ce que rend `as_bytes()`, donc ce + que le client dépose vraiment par APPEND. Découper sur le seul CRLF + rendrait alors le message ENTIER comme un unique en-tête, sans rien lever. + Le terminateur rendu, lui, est toujours CRLF — c'est le format du fil. + """ + head = re.split(rb"\r?\n\r?\n", raw, maxsplit=1)[0] + lines: list[bytes] = [] + for line in re.split(rb"\r?\n", head): + if not line: + continue + if line[:1] in (b" ", b"\t") and lines: + lines[-1] += b"\r\n" + line + else: + lines.append(line) + return [line + b"\r\n" for line in lines] + + +@implementer(imap4.IMessage, imap4.IMessageFile) +class SandboxMessage: + """Un message servi VERBATIM, tel que le test l'a écrit. + + `IMessageFile` (une seule méthode, `open()`) est ce qui rend possible de + servir des octets hostiles : sur un FETCH du message entier, Twisted + recopie ce flux tel quel au lieu de repasser par son chemin MIME, qui + finit en `networkString()` → `.encode("ascii")` et refuserait tout octet + 8 bits. + """ + + def __init__(self, uid: int, raw: bytes, flags=(), internal_date=None): + self.uid = uid + self.raw = raw + self.flags = list(flags) + self.internal_date = internal_date or b"06-Aug-2026 10:00:00 +0000" + self.parsed = email.message_from_bytes(raw) + + # -- IMessagePart / IMessage ---------------------------------------- + + def getUID(self) -> int: + return self.uid + + def getFlags(self) -> list: + return list(self.flags) + + def getInternalDate(self) -> bytes: + return self.internal_date + + def getHeaders(self, negate, *names) -> dict: + """Les en-têtes en `str` — ce que Twisted attend (il fait + `v.splitlines()`, donc surtout pas un `email.header.Header`). + + Les NOMS demandés, eux, arrivent en OCTETS depuis le serveur + (`IMAP4Server.spew_body` passe `part.header.fields`), alors que les + recherches internes (`search_SUBJECT`...) les passent en `str`. Une + comparaison sur un seul des deux types rend un dictionnaire vide — + sans erreur, et donc sans rien pour la faire remarquer : le client ne + voit qu'un message sans sujet ni date. + """ + wanted = {_as_text(n).upper() for n in names} + return { + key: str(value) + for key, value in self.parsed.items() + if ( + (key.upper() not in wanted) + if negate + else (key.upper() in wanted) + ) + } + + def raw_header_block(self, negate, fields) -> bytes: + """Les mêmes en-têtes, mais en OCTETS bruts. + + Twisted ne sait pas les servir : `_formatHeaders` finit par + `networkString()`, donc `.encode("ascii")`, et lève sur le moindre + octet 8 bits. Or c'est exactement ce qu'un vrai serveur nous a + envoyé le jour du bug. `SandboxIMAP4Server.spew_body` bascule ici + quand le bloc n'est pas ASCII (voir sa docstring). + """ + wanted = {_as_bytes(f).upper() for f in fields} + out = [] + for line in _split_header_lines(self.raw): + name = line.split(b":", 1)[0].strip().upper() + if (name not in wanted) if negate else (name in wanted): + out.append(line) + return b"".join(out) + b"\r\n" + + def open(self): + return BytesIO(self.raw) + + def getBodyFile(self): + parts = re.split(rb"\r?\n\r?\n", self.raw, maxsplit=1) + return BytesIO(parts[1] if len(parts) > 1 else b"") + + def getSize(self) -> int: + return len(self.raw) + + def isMultipart(self) -> bool: + return self.parsed.is_multipart() + + def getSubPart(self, part): + raise TypeError("le bac à sable ne sert pas de sous-partie") + + +@implementer(imap4.IMailbox, imap4.IMailboxInfo) +class SandboxMailbox: + def __init__(self, name: str, uidvalidity: int = 42): + self.name = name + self.uidvalidity = uidvalidity + self.messages: list[SandboxMessage] = [] + self.listeners: list = [] + self.appended: list[tuple[bytes, tuple]] = [] + + # -- écriture par le test ------------------------------------------- + + def deliver(self, raw: bytes, flags=(), uid: int | None = None): + message = SandboxMessage( + uid if uid is not None else self.getUIDNext(), raw, flags + ) + self.messages.append(message) + return message + + def _max_uid(self) -> int: + return max((m.uid for m in self.messages), default=0) + + # -- IMailbox -------------------------------------------------------- + + def getFlags(self) -> list: + return ["\\Seen", "\\Answered", "\\Flagged", "\\Deleted", "\\Draft"] + + def getUIDValidity(self) -> int: + return self.uidvalidity + + def getUIDNext(self) -> int: + return self._max_uid() + 1 + + def getUID(self, message: int) -> int: + return self.messages[message - 1].uid + + def getMessageCount(self) -> int: + return len(self.messages) + + def getRecentCount(self) -> int: + return 0 + + def getUnseenCount(self) -> int: + return sum(1 for m in self.messages if "\\Seen" not in m.flags) + + def isWriteable(self) -> bool: + return True + + def getHierarchicalDelimiter(self) -> str: + return "." + + def requestStatus(self, names): + return imap4.statusRequestHelper(self, names) + + def addListener(self, listener) -> None: + self.listeners.append(listener) + + def removeListener(self, listener) -> None: + if listener in self.listeners: + self.listeners.remove(listener) + + def addMessage(self, body, flags=(), date=None): + """APPEND. Doit rendre un `Deferred` et non un entier : Twisted fait + `d.addCallback(...)` sur le résultat sans le passer par + `maybeDeferred`, et un entier y devient un « Server error encountered + while opening mailbox » — un message qui désigne le mauvais coupable. + """ + from twisted.internet import defer + + raw = body.read() if hasattr(body, "read") else body + self.appended.append((raw, tuple(flags))) + self.deliver(raw, flags) + return defer.succeed(len(self.messages)) + + def fetch(self, messages, uid): + """`messages` est un `MessageSet` : il faut lui donner sa borne haute + avant de l'interroger, sinon `*` ne veut rien dire.""" + if uid: + messages.last = self._max_uid() + return [(m.uid, m) for m in self.messages if m.uid in messages] + messages.last = len(self.messages) + return [ + (index + 1, m) + for index, m in enumerate(self.messages) + if index + 1 in messages + ] + + def store(self, messages, flags, mode, uid): + out = {} + for number, message in self.fetch(messages, uid): + current = set(message.flags) + if mode < 0: + current -= set(flags) + elif mode > 0: + current |= set(flags) + else: + current = set(flags) + message.flags = sorted(current) + out[number] = message.flags + return out + + def expunge(self) -> list: + return [] + + def destroy(self) -> None: + pass + + +@implementer(imap4.IAccount) +class SandboxIMAPAccount: + def __init__(self): + self.boxes: dict[str, SandboxMailbox] = {} + + def add(self, name: str, uidvalidity: int = 42) -> SandboxMailbox: + box = SandboxMailbox(name, uidvalidity) + self.boxes[name] = box + return box + + def _key(self, path: str) -> str: + # INBOX est insensible à la casse (RFC 3501), le reste ne l'est pas. + return "INBOX" if path.upper() == "INBOX" else path + + def listMailboxes(self, ref, wildcard): + return list(self.boxes.items()) + + def select(self, path, rw=True): + return self.boxes.get(self._key(path)) + + def create(self, path): + self.add(self._key(path)) + return True + + def delete(self, path): + self.boxes.pop(self._key(path), None) + + def rename(self, old, new): + self.boxes[self._key(new)] = self.boxes.pop(self._key(old)) + + def isSubscribed(self, name): + return True + + def subscribe(self, name): + return True + + def unsubscribe(self, name): + return True + + +@implementer(portal.IRealm) +class _SandboxRealm: + def __init__(self, account: SandboxIMAPAccount): + self.account = account + + def requestAvatar(self, avatarId, mind, *interfaces): + return imap4.IAccount, self.account, lambda: None + + +# -------------------------------------------------------------------------- +# Le serveur IMAP +# -------------------------------------------------------------------------- + + +class SandboxIMAP4Server(imap4.IMAP4Server): + def __init__(self, sandbox: "ImapSandbox"): + # `IMAP4Server.__init__` prend (chal, contextFactory, scheduler) et + # NON un portal : celui-ci s'affecte après coup. + super().__init__() + self.sandbox = sandbox + + def connectionMade(self): + self.sandbox.connections.add(self) + super().connectionMade() + + def connectionLost(self, reason): + self.sandbox.connections.discard(self) + super().connectionLost(reason) + + def dispatchCommand(self, tag, cmd, rest, uid=None): + """Le seul point où les méchancetés s'insèrent. + + `UID FETCH ...` passe ici deux fois — une pour `UID`, une pour le + `FETCH` interne — ce qui permet à un `Fault` de viser précisément + l'une ou l'autre. + """ + for fault in self.sandbox.faults: + if fault.matches(cmd): + fault.strike(self, tag) + return None + return super().dispatchCommand(tag, cmd, rest, uid) + + def spew_body(self, part, id, msg, _w=None, _f=None): + """Sert les en-têtes en octets bruts quand ils ne sont pas ASCII. + + Par défaut on laisse faire Twisted : le bac à sable est un serveur + POLI, et les tests doivent traverser son vrai code. Mais son + `_formatHeaders` se termine par `networkString()` — un `.encode( + "ascii")` — et lève sur le moindre octet 8 bits, que tout vrai + serveur transmet pourtant sans broncher. Dans ce seul cas on écrit + le littéral nous-mêmes, avec les octets déclarés par le test. Le + cadrage du littéral reste celui de Twisted (`imap4._literal`). + """ + block = None + if part.header is not None: + raw = getattr(msg, "raw_header_block", None) + if raw is not None: + block = raw(part.header.negate, part.header.fields) + if block is None or _is_ascii(block): + return super().spew_body(part, id, msg, _w, _f) + write = _w if _w is not None else self.transport.write + write(bytes(part) + b" " + imap4._literal(block)) + return None + + +def _is_ascii(data: bytes) -> bool: + try: + data.decode("ascii") + except UnicodeDecodeError: + return False + return True + + +class _SandboxFactory(protocol.Factory): + def __init__(self, sandbox: "ImapSandbox"): + self.sandbox = sandbox + + def buildProtocol(self, addr): + server = SandboxIMAP4Server(self.sandbox) + server.factory = self + server.portal = self.sandbox.portal + return server + + +class ImapSandbox: + """Un serveur IMAP jetable, sur un port éphémère de 127.0.0.1. + + Usage : + + imap = ImapSandbox() + imap.folder("INBOX").deliver(RAW_BYTES, flags=["\\\\Seen"]) + imap.fail(DropConnection(b"FETCH", after=1)) + imap.start() + ... + imap.stop() + + `MailSandboxCase.imap_server()` fait tout cela et branche l'arrêt sur + `addCleanup`, qui s'exécute même quand le test échoue. + """ + + def __init__(self): + self.account = SandboxIMAPAccount() + self.faults: list[Fault] = [] + self.connections: set = set() + self.port = 0 + self._listening = None + checker = checkers.InMemoryUsernamePasswordDatabaseDontUse() + checker.addUser(USER.encode(), PASSWORD.encode()) + self.portal = portal.Portal(_SandboxRealm(self.account)) + self.portal.registerChecker(checker) + + # -- déclaration du contenu ----------------------------------------- + + def folder(self, name: str, uidvalidity: int = 42) -> SandboxMailbox: + return self.account.boxes.get(name) or self.account.add( + name, uidvalidity + ) + + def fail(self, fault: Fault) -> Fault: + self.faults.append(fault) + return fault + + # -- cycle de vie ----------------------------------------------------- + + def start(self) -> "ImapSandbox": + reactor = reactor_in_thread() + self._listening = blockingCallFromThread( + reactor, + reactor.listenTCP, + 0, + _SandboxFactory(self), + interface="127.0.0.1", + ) + self.port = self._listening.getHost().port + LIVE_SERVERS.add(self) + return self + + def stop(self) -> None: + """Ferme le port ET coupe les connexions encore ouvertes. + + `stopListening` seul cesse d'ACCEPTER : une session cliente restée + ouverte garderait un descripteur et un protocole vivants d'un test à + l'autre. + """ + listening, self._listening = self._listening, None + LIVE_SERVERS.discard(self) + if listening is None: + return + from twisted.internet import reactor + + def close(): + for server in list(self.connections): + server.transport.abortConnection() + return listening.stopListening() + + blockingCallFromThread(reactor, close) + + +# -------------------------------------------------------------------------- +# Le serveur SMTP +# -------------------------------------------------------------------------- + + +@dataclass +class SentMessage: + """Ce qui est VRAIMENT sorti : l'enveloppe et les octets sur le fil.""" + + mail_from: str + rcpt_tos: list + content: bytes + + def headers(self): + return email.message_from_bytes(self.content) + + +class _CaptureHandler: + def __init__(self): + self.messages: list[SentMessage] = [] + + async def handle_DATA(self, server, session, envelope): + self.messages.append( + SentMessage( + mail_from=envelope.mail_from, + rcpt_tos=list(envelope.rcpt_tos), + content=bytes(envelope.content), + ) + ) + return "250 Message accepted for delivery" + + +class SmtpSandbox: + """Un serveur SMTP jetable qui capture ce qu'on lui remet. + + `aiosmtpd.Controller` ne sait pas se lier au port 0 : après `start()` il + rouvre une connexion de vérification vers `self.port`, qui vaut encore 0. + On relit le vrai numéro sur la socket avant cette vérification — c'est le + seul point à corriger. + """ + + def __init__(self, *, require_auth: bool = False): + from aiosmtpd.controller import Controller + from aiosmtpd.smtp import AuthResult, LoginPassword + + # Deux bruits d'`aiosmtpd` sans objet ici, et qui masqueraient les + # vraies pannes dans la sortie des tests : un WARNING à chaque + # authentification réussie (« Session.login_data is deprecated »), et + # un avertissement sur AUTH sans TLS — justifié en production, sans + # objet pour un serveur qu'on vient de démarrer soi-même sur la + # boucle locale. Le filtre est posé ici et non à l'import : le + # lanceur `unittest` réinitialise `warnings.filters` avant de courir. + logging.getLogger("mail.log").setLevel(logging.ERROR) + warnings.filterwarnings( + "ignore", + message="Requiring AUTH while not requiring TLS", + category=UserWarning, + ) + + def authenticate(server, session, envelope, mechanism, auth_data): + ok = isinstance(auth_data, LoginPassword) and ( + auth_data.login == USER.encode() + and auth_data.password == PASSWORD.encode() + ) + if ok: + return AuthResult(success=True, auth_data=auth_data) + # `handled` vaut True PAR DÉFAUT, et veut dire « j'ai déjà répondu + # au client moi-même ». Un simple `AuthResult(success=False)` + # laisse donc `aiosmtpd` muet : le client attend une réponse qui + # ne vient jamais et le test se bloque jusqu'au délai de la + # socket, sans rien dire de la cause. + return AuthResult(success=False, handled=False) + + class _Port0Controller(Controller): + def _trigger_server(self): + if self.port == 0 and self.server is not None: + self.port = self.server.sockets[0].getsockname()[1] + super()._trigger_server() + + self.handler = _CaptureHandler() + self.controller = _Port0Controller( + self.handler, + hostname="127.0.0.1", + port=0, + authenticator=authenticate, + auth_required=require_auth, + auth_require_tls=False, + ) + self.port = 0 + self._running = False + + @property + def messages(self) -> list[SentMessage]: + return self.handler.messages + + def start(self) -> "SmtpSandbox": + # Marqué vivant AVANT de démarrer : `Controller.start()` lance déjà + # son fil avant de pouvoir échouer, et le nettoyage doit passer + # derrière lui même dans ce cas-là. + self._running = True + LIVE_SERVERS.add(self) + self.controller.start() + self.port = self.controller.port + return self + + def stop(self) -> None: + """Idempotent : un test peut vouloir tuer son serveur en plein + milieu, et le nettoyage repassera derrière lui de toute façon.""" + if not self._running: + return + self._running = False + LIVE_SERVERS.discard(self) + self.controller.stop(no_assert=True) + + +# -------------------------------------------------------------------------- +# Le compte, et le socle de test +# -------------------------------------------------------------------------- + + +def sandbox_account( + imap_port: int = 0, + smtp_port: int = 0, + *, + name: str = "bac-a-sable", + address: str = "moi@example.ca", + display_name: str = "", + sent_folder: str = "INBOX.Sent", +) -> Account: + """Un `Account` réel pointé sur les serveurs jetables. + + `security="none"` : on parle en clair sur la boucle locale, à un serveur + qu'on vient de démarrer soi-même. Rien de tout cela ne quitte la machine. + """ + return Account( + name=name, + email=address, + display_name=display_name, + preset="generic", + imap=ServerConf( + host="127.0.0.1", port=imap_port, security="none", user=USER + ), + smtp=ServerConf( + host="127.0.0.1", port=smtp_port, security="none", user=USER + ), + secret_ref="kdbx:ERPLibre/Mail/bac-a-sable", + cache_mode="clear", + sent_folder=sent_folder, + ) + + +def close_imap_client(transport) -> None: + """Ferme la socket cliente, quoi qu'il soit arrivé pendant le test. + + `ImaplibTransport.logout()` est best-effort : sur une connexion déjà + morte — exactement ce que `DropConnection` produit — `imaplib.logout()` + lève avant d'atteindre son propre `shutdown()`, et le descripteur reste + ouvert jusqu'au ramasse-miettes. Acceptable dans le TUI, pas dans une + suite de tests où il s'accumulerait. + """ + transport.logout() + try: + transport.client.shutdown() + except Exception: + # Déjà fermée : c'est le cas normal quand `logout()` a réussi. + pass + + +def port_is_closed(port: int, timeout: float = 0.5) -> bool: + """Vrai si plus rien n'écoute sur ce port de la boucle locale.""" + with socket.socket() as probe: + probe.settimeout(timeout) + return probe.connect_ex(("127.0.0.1", port)) != 0 + + +class MailSandboxCase(unittest.TestCase): + """Le socle : tout serveur démarré ici meurt avec le test. + + L'arrêt passe par `addCleanup`, enregistré AVANT le démarrage : + `unittest` l'exécute quel que soit le sort du test — succès, échec ou + erreur — et même si `start()` lève à mi-chemin. Une socket d'écoute + oubliée ou un fil coincé empoisonneraient toute la suite. + """ + + def imap_server(self) -> ImapSandbox: + sandbox = ImapSandbox() + self.addCleanup(sandbox.stop) + return sandbox.start() + + def smtp_server(self, **kwargs) -> SmtpSandbox: + sandbox = SmtpSandbox(**kwargs) + self.addCleanup(sandbox.stop) + return sandbox.start() + + def temp_store(self, account): + """Un cache SQLite dans un dossier temporaire, jamais le vrai.""" + import tempfile + from pathlib import Path + + from script.todo.mail.store import Store + + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + store = Store(account, mode="clear", base=Path(tmp.name)) + self.addCleanup(store.close) + store.open() + return store + + def imap_transport(self, sandbox: ImapSandbox, account=None): + """Le VRAI client (`imap_transport.connect`), branché sur le bac à + sable — connexion et LOGIN compris.""" + from script.todo.mail import imap_transport + + account = account or sandbox_account(imap_port=sandbox.port) + transport = imap_transport.connect(account, PASSWORD) + self.addCleanup(close_imap_client, transport) + return transport diff --git a/test/test_config_file.py b/test/test_config_file.py index 9ba030e..f94e14d 100644 --- a/test/test_config_file.py +++ b/test/test_config_file.py @@ -20,33 +20,23 @@ class TestDeepMergeWithLists(unittest.TestCase): self.assertEqual(result, {}) def test_dest_only(self): - result = self.cfg.deep_merge_with_lists( - {"a": 1, "b": 2}, {} - ) + result = self.cfg.deep_merge_with_lists({"a": 1, "b": 2}, {}) self.assertEqual(result, {"a": 1, "b": 2}) def test_src_only(self): - result = self.cfg.deep_merge_with_lists( - {}, {"a": 1, "b": 2} - ) + result = self.cfg.deep_merge_with_lists({}, {"a": 1, "b": 2}) self.assertEqual(result, {"a": 1, "b": 2}) def test_simple_merge(self): - result = self.cfg.deep_merge_with_lists( - {"a": 1}, {"b": 2} - ) + result = self.cfg.deep_merge_with_lists({"a": 1}, {"b": 2}) self.assertEqual(result, {"a": 1, "b": 2}) def test_src_overrides_dest_string(self): - result = self.cfg.deep_merge_with_lists( - {"a": "old"}, {"a": "new"} - ) + result = self.cfg.deep_merge_with_lists({"a": "old"}, {"a": "new"}) self.assertEqual(result, {"a": "new"}) def test_empty_src_string_keeps_dest(self): - result = self.cfg.deep_merge_with_lists( - {"a": "old"}, {"a": ""} - ) + result = self.cfg.deep_merge_with_lists({"a": "old"}, {"a": ""}) self.assertEqual(result, {"a": "old"}) def test_nested_dict_merge(self): @@ -80,9 +70,7 @@ class TestDeepMergeWithLists(unittest.TestCase): self.assertEqual(dest, {"a": {"x": 1}}) def test_src_overrides_non_string_non_dict_non_list(self): - result = self.cfg.deep_merge_with_lists( - {"a": 1}, {"a": 2} - ) + result = self.cfg.deep_merge_with_lists({"a": 1}, {"a": 2}) self.assertEqual(result, {"a": 2}) @@ -106,9 +94,7 @@ class TestGetConfig(unittest.TestCase): base_path = self._write_json( "base.json", {"instance": [{"name": "test"}]} ) - with patch( - "script.config.config_file.CONFIG_FILE", base_path - ), patch( + with patch("script.config.config_file.CONFIG_FILE", base_path), patch( "script.config.config_file.CONFIG_OVERRIDE_FILE", os.path.join(self.tmpdir, "nonexistent1.json"), ), patch( @@ -120,9 +106,7 @@ class TestGetConfig(unittest.TestCase): def test_get_config_returns_none_for_missing_key(self): base_path = self._write_json("base.json", {"a": 1}) - with patch( - "script.config.config_file.CONFIG_FILE", base_path - ), patch( + with patch("script.config.config_file.CONFIG_FILE", base_path), patch( "script.config.config_file.CONFIG_OVERRIDE_FILE", os.path.join(self.tmpdir, "nonexistent1.json"), ), patch( @@ -141,9 +125,7 @@ class TestGetConfig(unittest.TestCase): "override.json", {"instance": [{"name": "override"}]}, ) - with patch( - "script.config.config_file.CONFIG_FILE", base_path - ), patch( + with patch("script.config.config_file.CONFIG_FILE", base_path), patch( "script.config.config_file.CONFIG_OVERRIDE_FILE", override_path, ), patch( @@ -166,9 +148,7 @@ class TestGetConfig(unittest.TestCase): "private.json", {"data": {"key": "private_val"}}, ) - with patch( - "script.config.config_file.CONFIG_FILE", base_path - ), patch( + with patch("script.config.config_file.CONFIG_FILE", base_path), patch( "script.config.config_file.CONFIG_OVERRIDE_FILE", os.path.join(self.tmpdir, "nonexistent.json"), ), patch( @@ -191,9 +171,7 @@ class TestGetConfig(unittest.TestCase): "private.json", {"items": [3], "meta": {"a": "private"}}, ) - with patch( - "script.config.config_file.CONFIG_FILE", base_path - ), patch( + with patch("script.config.config_file.CONFIG_FILE", base_path), patch( "script.config.config_file.CONFIG_OVERRIDE_FILE", override_path, ), patch( @@ -207,9 +185,7 @@ class TestGetConfig(unittest.TestCase): self.assertEqual(result_items, [1, 3, 2]) # Dict merge: {a: base} + {a: private} = {a: private} # then {a: private} + {b: override} = {a: private, b: override} - self.assertEqual( - result_meta, {"a": "private", "b": "override"} - ) + self.assertEqual(result_meta, {"a": "private", "b": "override"}) def test_no_config_files_exist(self): with patch( @@ -226,6 +202,99 @@ class TestGetConfig(unittest.TestCase): self.assertIsNone(result) +class TestSetConfigValue(unittest.TestCase): + """`set_config_value` est le pendant écriture de `get_config_value` : + seul `CONFIG_OVERRIDE_PRIVATE_FILE` est gitignored (vérifié avec + `git check-ignore`), donc c'est le seul des trois fichiers où écrire un + chemin personnel (ex. `kdbx.path`) sans risquer de le committer. + """ + + def setUp(self): + self.cfg = ConfigFile() + self.tmp = tempfile.TemporaryDirectory() + # Sous un sous-dossier qui n'existe pas encore, comme le vrai + # `private/todo/` d'un checkout neuf. + self.private_path = os.path.join( + self.tmp.name, "private", "todo", "todo_override_private.json" + ) + self.patcher = patch( + "script.config.config_file.CONFIG_OVERRIDE_PRIVATE_FILE", + self.private_path, + ) + self.patcher.start() + + def tearDown(self): + self.patcher.stop() + self.tmp.cleanup() + + def test_creates_missing_file_and_directory(self): + self.cfg.set_config_value(["kdbx", "path"], "/x/y.kdbx") + self.assertTrue(os.path.exists(self.private_path)) + with open(self.private_path) as f: + data = json.load(f) + self.assertEqual(data, {"kdbx": {"path": "/x/y.kdbx"}}) + + def test_nested_key_creation(self): + self.cfg.set_config_value(["a", "b", "c"], "v") + with open(self.private_path) as f: + data = json.load(f) + self.assertEqual(data, {"a": {"b": {"c": "v"}}}) + + def test_preserves_existing_unrelated_content(self): + os.makedirs(os.path.dirname(self.private_path)) + with open(self.private_path, "w") as f: + json.dump({"other": {"key": "kept"}}, f) + + self.cfg.set_config_value(["kdbx", "path"], "/x/y.kdbx") + + with open(self.private_path) as f: + data = json.load(f) + self.assertEqual( + data, + {"other": {"key": "kept"}, "kdbx": {"path": "/x/y.kdbx"}}, + ) + + def test_overwrites_only_the_targeted_key(self): + self.cfg.set_config_value(["kdbx", "path"], "/first.kdbx") + self.cfg.set_config_value(["kdbx", "path"], "/second.kdbx") + with open(self.private_path) as f: + data = json.load(f) + self.assertEqual(data, {"kdbx": {"path": "/second.kdbx"}}) + + def test_file_mode_is_0600(self): + self.cfg.set_config_value(["kdbx", "path"], "/x/y.kdbx") + mode = os.stat(self.private_path).st_mode & 0o777 + self.assertEqual(mode, 0o600) + + def test_directory_mode_is_0700(self): + self.cfg.set_config_value(["kdbx", "path"], "/x/y.kdbx") + mode = os.stat(os.path.dirname(self.private_path)).st_mode & 0o777 + self.assertEqual(mode, 0o700) + + def test_existing_file_with_looser_mode_is_corrected(self): + os.makedirs(os.path.dirname(self.private_path)) + with open(self.private_path, "w") as f: + json.dump({}, f) + os.chmod(self.private_path, 0o644) + + self.cfg.set_config_value(["kdbx", "path"], "/x/y.kdbx") + + mode = os.stat(self.private_path).st_mode & 0o777 + self.assertEqual(mode, 0o600) + + def test_round_trips_through_get_config_value(self): + self.cfg.set_config_value(["kdbx", "path"], "/round/trip.kdbx") + with patch( + "script.config.config_file.CONFIG_FILE", + os.path.join(self.tmp.name, "nonexistent_base.json"), + ), patch( + "script.config.config_file.CONFIG_OVERRIDE_FILE", + os.path.join(self.tmp.name, "nonexistent_override.json"), + ): + result = self.cfg.get_config_value(["kdbx", "path"]) + self.assertEqual(result, "/round/trip.kdbx") + + class TestGetConfigValue(unittest.TestCase): def setUp(self): self.cfg = ConfigFile() @@ -236,9 +305,7 @@ class TestGetConfigValue(unittest.TestCase): "get_config", return_value={"level1": {"level2": "found"}}, ): - result = self.cfg.get_config_value( - ["root", "level1", "level2"] - ) + result = self.cfg.get_config_value(["root", "level1", "level2"]) self.assertEqual(result, "found") def test_single_key(self): diff --git a/test/test_mail_account_setup.py b/test/test_mail_account_setup.py new file mode 100644 index 0000000..b270bbd --- /dev/null +++ b/test/test_mail_account_setup.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import os +import tempfile +import unittest +from unittest.mock import MagicMock, patch + +from script.todo.mail.account_setup import ( + create_vault, + kdbx_is_configured, + save_new_account, + use_existing_vault, +) +from script.todo.mail.accounts import account_from_preset +from script.todo.mail.secrets import SecretError + + +class FakeConfigFile: + """Un `config_file` minimal : seuls `get_config_value`/`set_config_value` + sont utilisés par `account_setup`, pas besoin du vrai `ConfigFile`.""" + + def __init__(self): + self._values: dict = {} + + def get_config_value(self, keys): + node = self._values + for key in keys: + if not isinstance(node, dict) or key not in node: + return None + node = node[key] + return node + + def set_config_value(self, keys, value): + node = self._values + for key in keys[:-1]: + node = node.setdefault(key, {}) + node[keys[-1]] = value + + +class TestSaveNewAccountRollsBack(unittest.TestCase): + """C'est la couture qu'un défaut réel emprunterait : un secret orphelin + sous une référence qu'aucune configuration ne désigne, invisible.""" + + def setUp(self): + self.account = account_from_preset("perso", "a@x.ca", "generic") + self.vault = MagicMock() + + def test_rollback_deletes_the_secret_when_save_raises(self): + with patch( + "script.todo.mail.account_setup.mail_accounts.save", + side_effect=OSError("disque plein"), + ): + with self.assertRaises(OSError): + save_new_account( + self.vault, [self.account], self.account, "hunter2" + ) + self.vault.set.assert_called_once_with( + self.account.secret_ref, "hunter2" + ) + self.vault.delete.assert_called_once_with(self.account.secret_ref) + + def test_rollback_survives_a_vault_that_cannot_delete_either(self): + """Le secret peut avoir déjà disparu du coffre : ce n'est pas une + raison de masquer l'échec de la sauvegarde initiale.""" + self.vault.delete.side_effect = SecretError("introuvable") + with patch( + "script.todo.mail.account_setup.mail_accounts.save", + side_effect=OSError("disque plein"), + ): + with self.assertRaises(OSError): + save_new_account( + self.vault, [self.account], self.account, "hunter2" + ) + + def test_successful_save_leaves_the_secret_in_place(self): + with patch( + "script.todo.mail.account_setup.mail_accounts.save" + ) as mock_save: + save_new_account( + self.vault, [self.account], self.account, "hunter2" + ) + mock_save.assert_called_once_with([self.account]) + self.vault.set.assert_called_once_with( + self.account.secret_ref, "hunter2" + ) + self.vault.delete.assert_not_called() + + +class TestKdbxIsConfigured(unittest.TestCase): + def test_false_when_nothing_is_configured(self): + self.assertFalse(kdbx_is_configured(FakeConfigFile())) + + def test_false_on_the_empty_string(self): + config = FakeConfigFile() + config.set_config_value(["kdbx", "path"], "") + self.assertFalse(kdbx_is_configured(config)) + + def test_true_once_a_path_is_set(self): + config = FakeConfigFile() + config.set_config_value(["kdbx", "path"], "/already/there.kdbx") + self.assertTrue(kdbx_is_configured(config)) + + +class TestCreateVault(unittest.TestCase): + def test_creates_a_real_kdbx_and_persists_its_path(self): + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "new.kdbx") + config = FakeConfigFile() + create_vault(config, path, "hunter2") + self.assertTrue(os.path.isfile(path)) + self.assertEqual(config.get_config_value(["kdbx", "path"]), path) + + def test_refuses_to_overwrite_an_existing_file(self): + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "already.kdbx") + with open(path, "wb") as handle: + handle.write(b"already there") + config = FakeConfigFile() + with self.assertRaises(SecretError): + create_vault(config, path, "hunter2") + self.assertIsNone(config.get_config_value(["kdbx", "path"])) + + +class TestUseExistingVault(unittest.TestCase): + def test_refuses_a_nonexistent_file(self): + config = FakeConfigFile() + with self.assertRaises(SecretError): + use_existing_vault(config, "/nope/does-not-exist.kdbx") + self.assertIsNone(config.get_config_value(["kdbx", "path"])) + + def test_refuses_an_empty_path(self): + config = FakeConfigFile() + with self.assertRaises(SecretError): + use_existing_vault(config, "") + + def test_accepts_and_persists_an_existing_file(self): + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "existing.kdbx") + with open(path, "wb") as handle: + handle.write(b"not a real kdbx, just a file") + config = FakeConfigFile() + use_existing_vault(config, path) + self.assertEqual(config.get_config_value(["kdbx", "path"]), path) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_mail_accounts.py b/test/test_mail_accounts.py new file mode 100644 index 0000000..f67c23a --- /dev/null +++ b/test/test_mail_accounts.py @@ -0,0 +1,233 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import json +import os +import stat +import tempfile +import unittest +from pathlib import Path + +from script.todo.mail.accounts import ( + PRESETS, + Account, + AccountError, + account_from_preset, + find, + load, + save, + write_template, +) + + +class TestPresets(unittest.TestCase): + def test_four_presets(self): + self.assertEqual( + set(PRESETS), {"gmail", "outlook", "icloud", "generic"} + ) + + def test_gmail_servers(self): + self.assertEqual(PRESETS["gmail"]["imap"]["host"], "imap.gmail.com") + self.assertEqual(PRESETS["gmail"]["imap"]["port"], 993) + self.assertEqual(PRESETS["gmail"]["smtp"]["host"], "smtp.gmail.com") + self.assertEqual(PRESETS["gmail"]["smtp"]["port"], 587) + + def test_security_values_are_known(self): + for key, preset in PRESETS.items(): + for proto in ("imap", "smtp"): + self.assertIn( + preset[proto]["security"], + ("ssl", "starttls", "none"), + f"{key}.{proto}", + ) + + def test_app_password_flag(self): + self.assertTrue(PRESETS["gmail"]["app_password"]) + self.assertTrue(PRESETS["icloud"]["app_password"]) + self.assertFalse(PRESETS["generic"]["app_password"]) + + +class TestAccountFromPreset(unittest.TestCase): + def test_fills_servers_and_user(self): + acc = account_from_preset("perso", "moi@gmail.com", "gmail") + self.assertEqual(acc.imap.host, "imap.gmail.com") + self.assertEqual(acc.imap.user, "moi@gmail.com") + self.assertEqual(acc.smtp.user, "moi@gmail.com") + + def test_user_override(self): + acc = account_from_preset( + "perso", "moi@x.ca", "generic", user="login-different" + ) + self.assertEqual(acc.imap.user, "login-different") + + def test_secret_ref_defaults_to_kdbx(self): + acc = account_from_preset("perso", "moi@x.ca", "generic") + self.assertEqual(acc.secret_ref, "kdbx:ERPLibre/Mail/perso") + + def test_secret_ref_keyring(self): + acc = account_from_preset( + "perso", "moi@x.ca", "generic", vault="keyring" + ) + self.assertEqual(acc.secret_ref, "keyring:perso") + + def test_cache_key_ref(self): + acc = account_from_preset("perso", "moi@x.ca", "generic") + self.assertEqual( + acc.cache_key_ref(), "kdbx:ERPLibre/Mail/perso/cache-key" + ) + + def test_cache_mode_inherits_by_default(self): + self.assertIsNone( + account_from_preset("perso", "moi@x.ca", "generic").cache_mode + ) + + def test_unknown_preset_raises(self): + with self.assertRaises(AccountError): + account_from_preset("perso", "moi@x.ca", "aol") + + def test_empty_name_raises(self): + with self.assertRaises(AccountError): + account_from_preset("", "moi@x.ca", "generic") + + def test_name_with_slash_raises(self): + """Le nom sert de segment de chemin et de référence kdbx.""" + with self.assertRaises(AccountError): + account_from_preset("per/so", "moi@x.ca", "generic") + + +class TestRoundtrip(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.path = Path(self.tmp.name) / "accounts.json" + + def tearDown(self): + self.tmp.cleanup() + + def test_save_then_load(self): + acc = account_from_preset("perso", "moi@gmail.com", "gmail") + save([acc], self.path) + loaded = load(self.path) + self.assertEqual(len(loaded), 1) + self.assertEqual(loaded[0].to_dict(), acc.to_dict()) + + def test_file_is_0600(self): + save([account_from_preset("perso", "moi@x.ca", "generic")], self.path) + mode = stat.S_IMODE(os.stat(self.path).st_mode) + self.assertEqual(mode, 0o600) + + def test_parent_dir_is_0700(self): + nested = Path(self.tmp.name) / "mail" / "accounts.json" + save([account_from_preset("perso", "moi@x.ca", "generic")], nested) + mode = stat.S_IMODE(os.stat(nested.parent).st_mode) + self.assertEqual(mode, 0o700) + + def test_no_window_at_the_process_umask(self): + """`write_text` puis `chmod` laisserait le fichier lisible à l'umask + du process le temps entre les deux appels. Au moment où `chmod` est + appelé, le fichier doit déjà être en 0600 — la preuve qu'il n'a + jamais existé autrement.""" + from unittest.mock import patch + + seen = [] + original_chmod = os.chmod + + def spy(path, mode): + if Path(path) == self.path: + seen.append(stat.S_IMODE(os.stat(path).st_mode)) + return original_chmod(path, mode) + + with patch("os.chmod", side_effect=spy): + save( + [account_from_preset("perso", "moi@x.ca", "generic")], + self.path, + ) + + self.assertEqual(seen, [0o600]) + + def test_load_missing_file_returns_empty(self): + self.assertEqual(load(Path(self.tmp.name) / "absent.json"), []) + + def test_no_password_key_is_written(self): + save([account_from_preset("perso", "moi@x.ca", "generic")], self.path) + raw = self.path.read_text() + self.assertNotIn("password", raw.lower()) + + def test_corrupt_json_raises(self): + self.path.write_text("{ pas du json") + with self.assertRaises(AccountError): + load(self.path) + + def test_duplicate_name_raises_on_save(self): + acc = account_from_preset("perso", "moi@x.ca", "generic") + with self.assertRaises(AccountError): + save([acc, acc], self.path) + + def test_find(self): + accs = [ + account_from_preset("perso", "a@x.ca", "generic"), + account_from_preset("travail", "b@x.ca", "generic"), + ] + self.assertEqual(find(accs, "travail").email, "b@x.ca") + self.assertIsNone(find(accs, "absent")) + + +class TestTemplate(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.path = Path(self.tmp.name) / "accounts.json" + + def tearDown(self): + self.tmp.cleanup() + + def test_writes_valid_json(self): + write_template(self.path) + data = json.loads(self.path.read_text()) + self.assertEqual(data["version"], 1) + + def test_has_one_example_per_preset(self): + write_template(self.path) + data = json.loads(self.path.read_text()) + presets = {a["preset"] for a in data["accounts"]} + self.assertEqual(presets, set(PRESETS)) + + def test_examples_are_disabled(self): + """Un modèle ne doit rien tenter de synchroniser tel quel.""" + write_template(self.path) + data = json.loads(self.path.read_text()) + self.assertTrue(all(not a["enabled"] for a in data["accounts"])) + + def test_carries_comments(self): + write_template(self.path) + data = json.loads(self.path.read_text()) + self.assertIn("_comment", data) + + def test_refuses_to_overwrite(self): + self.path.write_text("{}") + with self.assertRaises(AccountError): + write_template(self.path) + + def test_force_overwrites(self): + self.path.write_text("{}") + write_template(self.path, force=True) + self.assertIn("accounts", json.loads(self.path.read_text())) + + def test_no_window_at_the_process_umask(self): + from unittest.mock import patch + + seen = [] + original_chmod = os.chmod + + def spy(path, mode): + if Path(path) == self.path: + seen.append(stat.S_IMODE(os.stat(path).st_mode)) + return original_chmod(path, mode) + + with patch("os.chmod", side_effect=spy): + write_template(self.path) + + self.assertEqual(seen, [0o600]) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_mail_charset.py b/test/test_mail_charset.py new file mode 100644 index 0000000..9d2236d --- /dev/null +++ b/test/test_mail_charset.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import unittest + +from script.todo.mail.charset import decode_bytes + + +class TestDecodeBytes(unittest.TestCase): + def test_known_charset(self): + self.assertEqual(decode_bytes("café".encode("utf-8"), "utf-8"), "café") + + def test_missing_charset_falls_back_to_utf8(self): + self.assertEqual(decode_bytes("café".encode("utf-8"), None), "café") + + def test_empty_charset_falls_back_to_utf8(self): + self.assertEqual(decode_bytes("café".encode("utf-8"), ""), "café") + + def test_unknown_8bit_falls_back_to_utf8(self): + """La valeur réelle qui a fait tomber la synchro d'un dossier entier + (voir `imap_transport.decode_header_value` et le rapport de tâche).""" + self.assertEqual( + decode_bytes("café".encode("utf-8"), "unknown-8bit"), "café" + ) + + def test_charset_with_a_stray_quote_falls_back(self): + self.assertEqual( + decode_bytes("café".encode("utf-8"), 'unknown-8bit"'), "café" + ) + + def test_bogus_charset_name_falls_back(self): + self.assertEqual( + decode_bytes("café".encode("utf-8"), "bogus-charset-xyz"), "café" + ) + + def test_wrong_but_known_charset_replaces_undecodable_bytes(self): + # ascii connu, mais incapable de décoder un octet accentué : c'est + # `errors="replace"`, pas le repli `LookupError`, qui doit agir ici. + self.assertIn("�", decode_bytes(b"caf\xe9", "ascii")) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_mail_compose.py b/test/test_mail_compose.py new file mode 100644 index 0000000..9b28b0e --- /dev/null +++ b/test/test_mail_compose.py @@ -0,0 +1,1382 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import os +import tempfile +import unittest +from contextlib import contextmanager +from pathlib import Path +from unittest.mock import patch + +from script.todo.mail.accounts import account_from_preset +from script.todo.mail.smtp_send import SmtpError, build_message +from script.todo.mail.store import MessageMeta, Store +from script.todo.mail.tui import ( + MailboxRef, + Session, + append_attachment_path, + deliver, + edit_in_external_editor, + parse_paths, + parse_recipients, + resolve_sent_folder, +) + + +class TestParseRecipients(unittest.TestCase): + def test_single(self): + self.assertEqual(parse_recipients("a@y.ca"), ["a@y.ca"]) + + def test_comma_separated(self): + self.assertEqual( + parse_recipients("a@y.ca, b@y.ca"), ["a@y.ca", "b@y.ca"] + ) + + def test_semicolon_also_works(self): + self.assertEqual( + parse_recipients("a@y.ca; b@y.ca"), ["a@y.ca", "b@y.ca"] + ) + + def test_keeps_display_names(self): + self.assertEqual( + parse_recipients("Alice , b@y.ca"), + ["Alice ", "b@y.ca"], + ) + + def test_drops_empty_fragments(self): + self.assertEqual( + parse_recipients("a@y.ca,, ,b@y.ca"), ["a@y.ca", "b@y.ca"] + ) + + def test_empty_string(self): + self.assertEqual(parse_recipients(""), []) + + +class TestParsePaths(unittest.TestCase): + """Contrairement aux destinataires, les chemins de pièces jointes ne se + séparent QUE par un point-virgule : une virgule est légale dans un nom de + fichier (`Facture, T3.pdf`).""" + + def test_single(self): + self.assertEqual(parse_paths("a.pdf"), ["a.pdf"]) + + def test_comma_in_a_filename_survives(self): + self.assertEqual(parse_paths("Facture, T3.pdf"), ["Facture, T3.pdf"]) + + def test_semicolon_separates(self): + self.assertEqual(parse_paths("a.pdf; b.pdf"), ["a.pdf", "b.pdf"]) + + def test_double_quoted_path_is_unquoted(self): + self.assertEqual(parse_paths('"a, b.pdf"'), ["a, b.pdf"]) + + def test_single_quoted_path_is_unquoted(self): + self.assertEqual(parse_paths("'a.pdf'"), ["a.pdf"]) + + def test_drops_empty_fragments(self): + self.assertEqual(parse_paths("a.pdf;; ;b.pdf"), ["a.pdf", "b.pdf"]) + + def test_empty_string(self): + self.assertEqual(parse_paths(""), []) + + +class TestAppendAttachmentPath(unittest.TestCase): + """`append_attachment_path` est la seule logique testable du bouton + Parcourir : le reste (suspendre Textual, ouvrir urwid) a besoin d'un + écran monté ou d'un vrai terminal, voir `TestBrowseFilesButton`.""" + + def test_empty_field_gets_just_the_path(self): + self.assertEqual(append_attachment_path("", "a.pdf"), "a.pdf") + + def test_appends_after_an_existing_path(self): + self.assertEqual( + append_attachment_path("a.pdf", "b.pdf"), "a.pdf; b.pdf" + ) + + def test_field_already_ending_in_separator_is_not_doubled(self): + self.assertEqual( + append_attachment_path("a.pdf;", "b.pdf"), "a.pdf; b.pdf" + ) + + def test_no_leading_separator_on_an_empty_field(self): + self.assertFalse(append_attachment_path("", "a.pdf").startswith(";")) + + def test_none_is_treated_as_empty(self): + self.assertEqual(append_attachment_path(None, "a.pdf"), "a.pdf") + + +class TestExternalEditor(unittest.TestCase): + def test_returns_what_the_editor_wrote(self): + def runner(cmd): + path = Path(cmd[-1]) + path.write_text("écrit dans vim") + return 0 + + self.assertEqual( + edit_in_external_editor("départ", editor="vim", runner=runner), + "écrit dans vim", + ) + + def test_seeds_the_file_with_the_current_text(self): + seen = {} + + def runner(cmd): + seen["contenu"] = Path(cmd[-1]).read_text() + return 0 + + edit_in_external_editor("brouillon", editor="vim", runner=runner) + self.assertEqual(seen["contenu"], "brouillon") + + def test_non_zero_exit_keeps_the_original(self): + def runner(cmd): + Path(cmd[-1]).write_text("ignoré") + return 1 + + self.assertEqual( + edit_in_external_editor("départ", editor="vim", runner=runner), + "départ", + ) + + def test_missing_editor_keeps_the_original(self): + def runner(cmd): + raise FileNotFoundError("pas d'éditeur") + + self.assertEqual( + edit_in_external_editor("départ", editor="absent", runner=runner), + "départ", + ) + + def test_temp_file_is_removed(self): + seen = {} + + def runner(cmd): + seen["chemin"] = Path(cmd[-1]) + return 0 + + edit_in_external_editor("x", editor="vim", runner=runner) + self.assertFalse(seen["chemin"].exists()) + + +class _TransportQuiAccepte: + """Le transport par défaut du harnais : il enregistre ce qu'on lui + dépose, ce qui permet aussi de le relire quand un test s'y intéresse.""" + + def __init__(self): + self.appended = [] + + def append(self, folder, raw, flags): + self.appended.append((folder, raw, flags)) + + +# Sentinelle : distingue « le test n'a rien demandé » de « le test veut +# explicitement aucun transport ». `None` ne peut pas servir aux deux. +_ACCEPTE_TOUT = object() + + +class DeliverCase(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.account = account_from_preset("perso", "moi@x.ca", "generic") + self.store = Store( + self.account, mode="clear", base=Path(self.tmp.name) + ) + self.store.open() + self.msg = build_message(self.account, "a@y.ca", "Devis", "Bonjour") + + def tearDown(self): + self.store.close() + self.tmp.cleanup() + + def session(self, online=True, transport=_ACCEPTE_TOUT): + """Le transport par défaut ACCEPTE l'APPEND. + + Il valait `None` auparavant, ce qui faisait échouer l'APPEND sur un + AttributeError : tout test qui ne s'intéressait pas au dépôt dans + Envoyés empruntait donc le chemin d'ÉCHEC sans le savoir, et + `test_sends_and_reports` — le test du chemin heureux — a passé des + semaines à vérifier l'inverse de son nom. + + En production ce cas n'existe pas : `Syncer` n'est construit qu'avec + un transport vivant (`tui.py`, `Syncer(store, connect_fn(...))`) et + `deliver` refuse déjà les sessions hors ligne. Le défaut du harnais + décrivait donc un état impossible. Rester sans transport est + toujours possible — `transport=None` — mais doit être DÉLIBÉRÉ. + """ + + class FakeSyncer: + def __init__(self, transport): + self.transport = transport + self.synced = [] + + def sync_one(self, folder_name): + self.synced.append(folder_name) + + if transport is _ACCEPTE_TOUT: + transport = _TransportQuiAccepte() + syncer = FakeSyncer(transport) if online else None + return Session(self.account, self.store, syncer) + + +class TestResolveSentFolder(DeliverCase): + """Le préréglage (`account.sent_folder`) n'est qu'une supposition : le + serveur annonce lui-même son dossier Envoyés par l'attribut `\\Sent`, + que `imap_transport.parse_list_line` traduit en `role="sent"` et que + `Syncer._sync_folder` enregistre via `store.upsert_folder`. Une fois ce + rôle connu, il doit l'emporter sur la supposition.""" + + def test_uses_the_role_the_server_announced(self): + self.store.upsert_folder("INBOX.Sent", "Envoyés", "sent") + self.assertEqual(resolve_sent_folder(self.session()), "INBOX.Sent") + + def test_falls_back_to_the_preset_without_a_sent_role(self): + self.store.upsert_folder("INBOX", "INBOX", "inbox") + self.assertEqual( + resolve_sent_folder(self.session()), self.account.sent_folder + ) + + def test_falls_back_when_the_store_has_no_folder_yet(self): + self.assertEqual( + resolve_sent_folder(self.session()), self.account.sent_folder + ) + + def test_falls_back_when_the_store_is_none(self): + session = Session(self.account, None, None) + self.assertEqual( + resolve_sent_folder(session), self.account.sent_folder + ) + + def test_falls_back_when_the_store_read_raises(self): + """Round 2 de la revue : un cache verrouillé ou corrompu ne doit + JAMAIS faire lever cette fonction. Sans ça, l'exception s'échapperait + de `deliver()` (l'appel se fait AVANT son `try`), remonterait à + `ComposeScreen.action_send`, et un envoi SMTP déjà réussi se + lirait comme un échec d'ENVOI — exactement la double-envoi que la + conception de `deliver` existe pour empêcher.""" + + class BrokenStore: + def folders(self): + raise OSError("base verrouillée") + + session = Session(self.account, BrokenStore(), None) + self.assertEqual( + resolve_sent_folder(session), self.account.sent_folder + ) + + +class TestDeliver(DeliverCase): + def test_sends_and_reports(self): + """Le chemin HEUREUX, et il faut vraiment l'emprunter. + + Ce test a longtemps parcouru le chemin d'ÉCHEC : le harnais + fournissait un transport `None`, l'APPEND partait en AttributeError, + et l'unique assertion — « a@y.ca » dans le statut — était vraie du + statut d'échec comme de celui du succès. Les deux assertions du bas + sont ce qui distingue les deux, et donc ce qui protège encore si le + défaut du harnais redevenait cassé. + """ + sent = [] + status = deliver( + self.session(), + self.msg, + send_fn=lambda acc, m, tr: sent.append(m) or ["a@y.ca"], + ) + self.assertEqual(len(sent), 1) + self.assertIn("a@y.ca", status) + # `⚠` et le balisage rouge sont posés par le chemin d'échec seul, et + # ne dépendent pas de la langue — contrairement au texte traduit. + self.assertNotIn("⚠", status) + self.assertNotIn("[b red]", status) + + def test_appends_to_the_sent_folder(self): + class FakeTransport: + def __init__(self): + self.appended = [] + + def append(self, folder, raw, flags): + self.appended.append((folder, flags)) + + transport = FakeTransport() + deliver( + self.session(transport=transport), + self.msg, + send_fn=lambda acc, m, tr: ["a@y.ca"], + ) + self.assertEqual(transport.appended[0][0], self.account.sent_folder) + self.assertIn("\\Seen", transport.appended[0][1]) + + def test_appends_to_the_resolved_folder_not_the_preset(self): + """La couture qui compte : `resolve_sent_folder` seul ne suffit pas + à prouver que `deliver` s'en sert réellement pour l'APPEND.""" + + class FakeTransport: + def __init__(self): + self.appended = [] + + def append(self, folder, raw, flags): + self.appended.append((folder, flags)) + + self.store.upsert_folder("INBOX.Sent", "Envoyés", "sent") + self.assertNotEqual("INBOX.Sent", self.account.sent_folder) + + transport = FakeTransport() + deliver( + self.session(transport=transport), + self.msg, + send_fn=lambda acc, m, tr: ["a@y.ca"], + ) + self.assertEqual(transport.appended[0][0], "INBOX.Sent") + + def test_append_failure_does_not_lose_the_send(self): + """Le message est parti : un APPEND raté ne doit pas se lire comme un échec.""" + + class BrokenTransport: + def append(self, folder, raw, flags): + raise OSError("dossier Envoyés introuvable") + + status = deliver( + self.session(transport=BrokenTransport()), + self.msg, + send_fn=lambda acc, m, tr: ["a@y.ca"], + ) + self.assertIn("a@y.ca", status) + + def test_append_failure_is_unmistakable_and_logged(self): + """Round 17 : l'échec ne doit plus se lire comme un simple suffixe en + bout de ligne — il doit ressortir (préfixe, mis en évidence) et + laisser une trace dans le journal.""" + + class BrokenTransport: + def append(self, folder, raw, flags): + raise OSError("dossier Envoyés introuvable") + + with self.assertLogs("script.todo.mail.tui", level="ERROR"): + status = deliver( + self.session(transport=BrokenTransport()), + self.msg, + send_fn=lambda acc, m, tr: ["a@y.ca"], + ) + self.assertIn("a@y.ca", status) + self.assertTrue(status.startswith("[b red]")) + self.assertIn("dossier Envoyés introuvable", status) + + def test_offline_session_refuses(self): + with self.assertRaises(SmtpError): + deliver(self.session(online=False), self.msg) + + def test_send_failure_is_propagated(self): + def boom(acc, m, tr): + raise SmtpError("550 refus") + + with self.assertRaises(SmtpError): + deliver(self.session(), self.msg, send_fn=boom) + + def test_successful_append_triggers_a_targeted_sync_of_sent(self): + """Design (`docs/superpowers/specs/2026-08-02-email-tui-design.md`, + ligne 308) : « écriture locale immédiate pour qu'il apparaisse sans + attendre la sync ». Pas de ligne fabriquée — une sync ciblée sur + Envoyés, puisque c'est le serveur qui attribue l'UID.""" + + class FakeTransport: + def append(self, folder, raw, flags): + pass + + session = self.session(transport=FakeTransport()) + deliver(session, self.msg, send_fn=lambda acc, m, tr: ["a@y.ca"]) + self.assertEqual(session.syncer.synced, [self.account.sent_folder]) + + def test_targeted_sync_uses_the_resolved_folder_not_the_preset(self): + class FakeTransport: + def append(self, folder, raw, flags): + pass + + self.store.upsert_folder("INBOX.Sent", "Envoyés", "sent") + self.assertNotEqual("INBOX.Sent", self.account.sent_folder) + + session = self.session(transport=FakeTransport()) + deliver(session, self.msg, send_fn=lambda acc, m, tr: ["a@y.ca"]) + self.assertEqual(session.syncer.synced, ["INBOX.Sent"]) + + def test_failed_append_does_not_trigger_a_sync(self): + """Rien à synchroniser : l'APPEND n'a pas eu lieu.""" + + class BrokenTransport: + def append(self, folder, raw, flags): + raise OSError("dossier Envoyés introuvable") + + session = self.session(transport=BrokenTransport()) + deliver(session, self.msg, send_fn=lambda acc, m, tr: ["a@y.ca"]) + self.assertEqual(session.syncer.synced, []) + + def test_appended_copy_has_no_bcc_header(self): + """Le Cci ne doit pas fuir par la copie qui part par IMAP. + + `send()` retire déjà `X-ERPLibre-Bcc` avant l'envoi SMTP, mais la + copie déposée dans Envoyés part par IMAP : sans `without_bcc`, le Cci + redeviendrait un en-tête lisible sur le serveur. + """ + + class FakeTransport: + def __init__(self): + self.appended = [] + + def append(self, folder, raw, flags): + self.appended.append(raw) + + msg = build_message( + self.account, "a@y.ca", "Devis", "Bonjour", bcc="secret@y.ca" + ) + transport = FakeTransport() + deliver( + self.session(transport=transport), + msg, + send_fn=lambda acc, m, tr: ["a@y.ca"], + ) + self.assertNotIn(b"X-ERPLibre-Bcc", transport.appended[0]) + self.assertNotIn(b"secret@y.ca", transport.appended[0]) + + +class TestComposeScreenMounted(unittest.IsolatedAsyncioTestCase): + """Monte l'écran de composition pour de vrai, via `run_test()`. + + `MailApp` et `ComposeScreen` sont des classes locales à `run_tui` : rien + ne les expose. On capte l'instance en interceptant le PREMIER + `App.__init__` appelé pendant `run_tui(run_app=False, ...)` — un + monkeypatch entièrement contenu à ce test, restauré dans un `finally`. + + `on_mount` lit aussi les préférences via `todo_prefs`, qui crée + `~/.erplibre` s'il est absent : sans détourner `$HOME` vers un répertoire + jetable, monter l'écran pour de vrai toucherait la machine. + """ + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.account = account_from_preset("perso", "moi@x.ca", "generic") + self.store = Store( + self.account, mode="clear", base=Path(self.tmp.name) + ) + self.store.open() + self.fake_home = tempfile.TemporaryDirectory() + self._old_home = os.environ.get("HOME") + os.environ["HOME"] = self.fake_home.name + + def tearDown(self): + if self._old_home is None: + os.environ.pop("HOME", None) + else: + os.environ["HOME"] = self._old_home + self.fake_home.cleanup() + self.store.close() + self.tmp.cleanup() + + class _FakeSyncer: + def __init__(self, transport): + self.transport = transport + + def sync(self, progress=None): + from types import SimpleNamespace + + return SimpleNamespace(new_messages=0, errors=[], purged=[]) + + def sync_one(self, folder_name): + from types import SimpleNamespace + + return SimpleNamespace(new_messages=0, errors=[], folders=1) + + def fetch_body(self, folder, uid): + return None + + class _FakeIMAPTransport: + def __init__(self): + self.appended = [] + + def append(self, folder, raw, flags): + self.appended.append((folder, raw, flags)) + + class _FakeSMTPTransport: + def quit(self): + pass + + async def _mounted_app(self, imap_transport): + import textual.app + + from script.todo.mail.tui import run_tui + + session = Session( + self.account, + self.store, + self._FakeSyncer(imap_transport), + password="hunter2", + ) + + captured = [] + orig_init = textual.app.App.__init__ + + def capturing_init(app_self, *a, **kw): + orig_init(app_self, *a, **kw) + captured.append(app_self) + + textual.app.App.__init__ = capturing_init + try: + run_tui(run_app=False, sessions=[session]) + finally: + textual.app.App.__init__ = orig_init + return captured[-1] + + async def test_send_failure_keeps_the_screen_up_with_the_error(self): + from textual.screen import ModalScreen + from textual.widgets import Input, Static, TextArea + + import script.todo.mail.smtp_send as smtp_send_mod + + app = await self._mounted_app(self._FakeIMAPTransport()) + orig_connect, orig_send = smtp_send_mod.connect, smtp_send_mod.send + smtp_send_mod.connect = ( + lambda account, password: self._FakeSMTPTransport() + ) + + def boom(account, msg, transport): + raise SmtpError("550 refus") + + smtp_send_mod.send = boom + try: + async with app.run_test() as pilot: + await pilot.press("c") + await pilot.pause() + self.assertIsInstance(app.screen, ModalScreen) + + app.screen.query_one("#to", Input).value = "dest@example.com" + app.screen.query_one("#subject", Input).value = "Sujet" + app.screen.query_one("#body", TextArea).text = "Corps" + + await pilot.press("ctrl+s") + await pilot.pause() + + # Le brouillon reste à l'écran, avec l'erreur du serveur. + self.assertIsInstance(app.screen, ModalScreen) + status = app.screen.query_one("#compose_status", Static) + self.assertIn("550 refus", str(status.content)) + finally: + smtp_send_mod.connect = orig_connect + smtp_send_mod.send = orig_send + + async def test_send_success_dismisses_and_files_a_copy(self): + from textual.screen import ModalScreen + from textual.widgets import Input, Static, TextArea + + import script.todo.mail.smtp_send as smtp_send_mod + + imap_transport = self._FakeIMAPTransport() + app = await self._mounted_app(imap_transport) + orig_connect, orig_send = smtp_send_mod.connect, smtp_send_mod.send + smtp_send_mod.connect = ( + lambda account, password: self._FakeSMTPTransport() + ) + smtp_send_mod.send = lambda account, msg, transport: [ + "dest@example.com" + ] + try: + async with app.run_test() as pilot: + await pilot.press("c") + await pilot.pause() + + app.screen.query_one("#to", Input).value = "dest@example.com" + app.screen.query_one("#subject", Input).value = "Sujet" + app.screen.query_one("#body", TextArea).text = "Corps" + + await pilot.press("ctrl+s") + await pilot.pause() + + # L'écran s'est refermé ; le statut principal confirme l'envoi. + self.assertNotIsInstance(app.screen, ModalScreen) + status = app.query_one("#status", Static) + self.assertIn("dest@example.com", str(status.content)) + self.assertEqual( + imap_transport.appended[0][0], self.account.sent_folder + ) + finally: + smtp_send_mod.connect = orig_connect + smtp_send_mod.send = orig_send + + async def test_forward_attaches_the_original_message(self): + """Régression : `_open_with_original` construisait un brouillon de + transfert portant le message original en pièce jointe + `message/rfc822`, mais ne passait que `subject`/`body` à + `ComposeScreen` — `action_send` reconstruit le message depuis le + formulaire, donc l'original ne partait JAMAIS. Ce test regarde le + message réellement remis à `send()`, pas les valeurs par défaut du + formulaire : c'est la couture que Task 12 a trouvée trouée. + + `_original_message()` est fixée directement : sa propre logique + (cache, réseau) est déjà couverte ailleurs, et n'est pas la couture + visée ici. + """ + from textual.widgets import Input + + import script.todo.mail.smtp_send as smtp_send_mod + + original = build_message( + self.account, + "quelqu.un@ailleurs.ca", + "Rapport trimestriel", + "Contenu original à transférer", + ) + session = Session( + self.account, + self.store, + self._FakeSyncer(self._FakeIMAPTransport()), + password="hunter2", + ) + + app = await self._mounted_app(self._FakeIMAPTransport()) + app._original_message = lambda: (session, original) + + captured = {} + + def capture_send(account, msg, transport): + captured["msg"] = msg + return ["dest@example.com"] + + orig_connect, orig_send = smtp_send_mod.connect, smtp_send_mod.send + smtp_send_mod.connect = ( + lambda account, password: self._FakeSMTPTransport() + ) + smtp_send_mod.send = capture_send + try: + async with app.run_test() as pilot: + await pilot.press("f") + await pilot.pause() + + app.screen.query_one("#to", Input).value = "dest@example.com" + await pilot.press("ctrl+s") + await pilot.pause() + finally: + smtp_send_mod.connect = orig_connect + smtp_send_mod.send = orig_send + + self.assertIn("msg", captured) + sent = captured["msg"] + attachment_types = [ + part.get_content_type() for part in sent.iter_attachments() + ] + self.assertIn("message/rfc822", attachment_types) + forwarded = next( + part + for part in sent.iter_attachments() + if part.get_content_type() == "message/rfc822" + ).get_payload(0) + self.assertEqual(forwarded["Subject"], "Rapport trimestriel") + + +class TestExternalEditorSuspendsTerminal(TestComposeScreenMounted): + """`vim`/`nano` tourne par `subprocess` et a besoin du terminal — + Textual le tient encore et continue d'y dessiner tant qu'on ne le lui a + pas repris. Ce test ne peut pas vérifier la reprise RÉELLE du terminal + (il faudrait un vrai tty, voir le rapport pour la vérification + manuelle) : il vérifie seulement que `App.suspend()` est entré AVANT + l'appel à l'éditeur, pas après ni pas du tout.""" + + async def test_suspend_wraps_the_editor_call(self): + from textual.widgets import TextArea + + import script.todo.mail.tui as tui_mod + + app = await self._mounted_app(self._FakeIMAPTransport()) + order = [] + + @contextmanager + def fake_suspend(self_app): + order.append("suspend") + yield + + def fake_editor(text): + order.append("editor") + return "nouveau texte" + + orig_editor = tui_mod.edit_in_external_editor + tui_mod.edit_in_external_editor = fake_editor + try: + async with app.run_test() as pilot: + await pilot.press("c") + await pilot.pause() + # Une vraie frappe de touche, focus laissé où `c` l'a mis + # (le champ « À » via `Input`) : `ctrl+e` est un accord de + # contrôle, pas un caractère imprimable, donc `Input` ne le + # capture pas pour l'insérer — contrairement à l'ancien `e` + # nu, qu'un widget de texte avale avant qu'il n'atteigne la + # liaison de touche de l'écran (constaté par un essai + # isolé). C'est justement ce que corrige `ctrl+e`. + with patch.object(type(app), "suspend", fake_suspend): + await pilot.press("ctrl+e") + await pilot.pause() + + self.assertEqual( + app.screen.query_one("#body", TextArea).text, + "nouveau texte", + ) + finally: + tui_mod.edit_in_external_editor = orig_editor + + self.assertEqual(order, ["suspend", "editor"]) + + async def test_ctrl_e_reaches_the_binding_with_focus_on_the_body(self): + """Le bug rapporté : `e` nu ne se déclenchait QUE si le focus se + trouvait par hasard sur un bouton, jamais depuis la zone de texte du + corps — le widget de texte avale le caractère imprimable avant qu'il + n'atteigne la liaison. `ctrl+e` n'est pas un caractère imprimable : + il doit déclencher l'éditeur même avec le focus sur `#body`.""" + from textual.widgets import TextArea + + import script.todo.mail.tui as tui_mod + + app = await self._mounted_app(self._FakeIMAPTransport()) + calls = [] + + @contextmanager + def fake_suspend(self_app): + yield + + def fake_editor(text): + calls.append(text) + return text + + orig_editor = tui_mod.edit_in_external_editor + tui_mod.edit_in_external_editor = fake_editor + try: + async with app.run_test() as pilot: + await pilot.press("c") + await pilot.pause() + body = app.screen.query_one("#body", TextArea) + body.focus() + await pilot.pause() + + with patch.object(type(app), "suspend", fake_suspend): + await pilot.press("ctrl+e") + await pilot.pause() + finally: + tui_mod.edit_in_external_editor = orig_editor + + self.assertEqual(len(calls), 1) + + +class TestBrowseFilesButton(TestComposeScreenMounted): + """Le bouton Parcourir ouvre `todo_file_browser.FileBrowser` sous + `App.suspend()` — urwid et Textual ne peuvent pas se partager le + terminal. Le sélecteur urwid lui-même n'est pas testable ici (il a + besoin d'un vrai terminal) : voir le rapport pour la vérification + manuelle.""" + + async def test_suspends_and_appends_the_chosen_path(self): + from textual.widgets import Button, Input + + import script.todo.todo_file_browser as browser_mod + + app = await self._mounted_app(self._FakeIMAPTransport()) + order = [] + + @contextmanager + def fake_suspend(self_app): + order.append("suspend") + yield + + class FakeFileBrowser: + def __init__(self, initial_path, callback): + order.append("constructed") + self.initial_path = initial_path + self.callback = callback + + def run_main_frame(self): + # `urwid.MainLoop.run()` avale `ExitMainLoop` (`with + # suppress(ExitMainLoop): self._run()`) — c'est ce qui rend + # normal, dans le vrai composant, l'appel à + # `todo_file_browser.exit_program()` que fait le callback + # réel. Le reproduire ici est nécessaire : sans ça, ce faux + # laisserait l'exception s'échapper, ce qu'un VRAI + # `FileBrowser` ne ferait jamais. + import urwid + + order.append("run") + try: + self.callback("/chosen/devis.pdf") + except urwid.ExitMainLoop: + pass + + async with app.run_test() as pilot: + await pilot.press("c") + await pilot.pause() + with patch.object( + type(app), "suspend", fake_suspend + ), patch.object(browser_mod, "FileBrowser", FakeFileBrowser): + app.screen.query_one("#browse_files", Button).press() + await pilot.pause() + + self.assertEqual( + app.screen.query_one("#files", Input).value, + "/chosen/devis.pdf", + ) + + self.assertEqual(order, ["suspend", "constructed", "run"]) + + async def test_a_failing_browser_leaves_the_draft_intact(self): + """Un sélecteur qui échoue à s'ouvrir ne doit PAS emporter le + brouillon : perdre un brouillon à cause d'un sélecteur cassé serait + pire que ne pas avoir de sélecteur du tout.""" + from textual.widgets import Button, Input, Static + + import script.todo.todo_file_browser as browser_mod + + app = await self._mounted_app(self._FakeIMAPTransport()) + + @contextmanager + def broken_suspend(self_app): + raise RuntimeError("terminal incompatible") + yield # pragma: no cover - jamais atteint + + async with app.run_test() as pilot: + await pilot.press("c") + await pilot.pause() + app.screen.query_one("#subject", Input).value = "Ne pas perdre" + with patch.object(type(app), "suspend", broken_suspend): + app.screen.query_one("#browse_files", Button).press() + await pilot.pause() + + # Le brouillon est toujours là, et l'écran ne s'est pas fermé. + self.assertEqual( + app.screen.query_one("#subject", Input).value, + "Ne pas perdre", + ) + status = app.screen.query_one("#compose_status", Static) + self.assertIn("terminal incompatible", str(status.content)) + + +class TestSyncSurfacesResync(TestComposeScreenMounted): + """`Syncer.sync()` rend `report.purged` (dossiers vidés parce que le + serveur a changé l'UIDVALIDITY) : `MailApp._sync` doit le montrer, pas + seulement le calculer — sinon l'utilisateur ne sait jamais qu'une + resynchronisation complète a eu lieu.""" + + class _FakeSyncerWithPurge: + def __init__(self, transport): + self.transport = transport + + def sync(self, progress=None): + from types import SimpleNamespace + + return SimpleNamespace(new_messages=2, errors=[], purged=["INBOX"]) + + def fetch_body(self, folder, uid): + return None + + async def test_purged_folders_are_shown_in_the_status(self): + from textual.widgets import Static + + session = Session( + self.account, + self.store, + self._FakeSyncerWithPurge(self._FakeIMAPTransport()), + password="hunter2", + ) + app = await self._mounted_app(self._FakeIMAPTransport()) + async with app.run_test() as pilot: + # `on_mount` lance déjà sa propre synchronisation en tâche de + # fond : l'attendre évite une course avec l'appel explicite + # ci-dessous, qui écraserait sinon le statut au hasard. + await app.workers.wait_for_complete() + app._sync([session]) + await pilot.pause() + status = app.query_one("#status", Static) + self.assertIn("INBOX", str(status.content)) + + +class TestSyncSurfacesErrors(TestComposeScreenMounted): + """`report.errors` porte le texte exact de l'échec (`"dossier : exc"`, + voir `imap_sync.Syncer.sync`) : avant ce correctif, `MailApp._sync` n'en + affichait que le COMPTE (`"— 1 erreurs"`), perdant le texte que + l'utilisateur aurait besoin de lire pour diagnostiquer quoi que ce + soit.""" + + class _FakeSyncerWithErrors: + def __init__(self, transport): + self.transport = transport + + def sync(self, progress=None): + from types import SimpleNamespace + + return SimpleNamespace( + new_messages=0, + errors=["Archives : 501 refus du serveur"], + purged=[], + ) + + def fetch_body(self, folder, uid): + return None + + async def test_the_error_text_is_shown_not_just_the_count(self): + from textual.widgets import Static + + session = Session( + self.account, + self.store, + self._FakeSyncerWithErrors(self._FakeIMAPTransport()), + password="hunter2", + ) + app = await self._mounted_app(self._FakeIMAPTransport()) + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + app._sync([session]) + await pilot.pause() + status = app.query_one("#status", Static) + self.assertIn( + "Archives : 501 refus du serveur", str(status.content) + ) + + async def test_additional_errors_are_counted_alongside_the_first(self): + from textual.widgets import Static + + class _FakeSyncerWithManyErrors: + def __init__(self, transport): + self.transport = transport + + def sync(self, progress=None): + from types import SimpleNamespace + + return SimpleNamespace( + new_messages=0, + errors=["INBOX : 501 refus", "Archives : 502 refus"], + purged=[], + ) + + def fetch_body(self, folder, uid): + return None + + session = Session( + self.account, + self.store, + _FakeSyncerWithManyErrors(self._FakeIMAPTransport()), + password="hunter2", + ) + app = await self._mounted_app(self._FakeIMAPTransport()) + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + app._sync([session]) + await pilot.pause() + status = app.query_one("#status", Static) + text = str(status.content) + self.assertIn("INBOX : 501 refus", text) + self.assertIn("+1", text) + + +class TestSyncLogsTotalFailure(TestComposeScreenMounted): + """Quand `session.sync()` lève directement (connexion totalement + perdue, pas un simple dossier récalcitrant), `MailApp._sync` affichait + déjà le message d'erreur — mais sans jamais le journaliser.""" + + class _FakeSyncerThatRaises: + def __init__(self, transport): + self.transport = transport + + def sync(self, progress=None): + raise OSError("connexion perdue") + + def fetch_body(self, folder, uid): + return None + + async def test_total_failure_is_logged(self): + session = Session( + self.account, + self.store, + self._FakeSyncerThatRaises(self._FakeIMAPTransport()), + password="hunter2", + ) + app = await self._mounted_app(self._FakeIMAPTransport()) + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + with self.assertLogs("script.todo.mail.tui", level="ERROR"): + app._sync([session]) + await pilot.pause() + + +class TestSyncSerializesAccess(TestComposeScreenMounted): + """L'auto-refresh et un `r`/`R` manuel lancent chacun `_sync` via + `run_worker(thread=True)`, avec `exclusive=False` : deux passes peuvent + donc tourner en vrais threads en même temps, et `imaplib` n'est pas + thread-safe. `_sync_lock` doit les sérialiser — l'annulation ne joue + aucun rôle ici, elle ne s'applique pas aux workers de type thread.""" + + class _SlowFakeSyncer: + """Incrémente un compteur partagé pendant `sync()` : si deux passes + s'exécutent en même temps, le compteur dépasse 1 au moins une fois.""" + + def __init__(self, transport, active, guard, overlap): + self.transport = transport + self._active = active + self._guard = guard + self._overlap = overlap + + def sync(self, progress=None): + import time + from types import SimpleNamespace + + with self._guard: + self._active["n"] += 1 + if self._active["n"] > 1: + self._overlap.set() + time.sleep(0.05) + with self._guard: + self._active["n"] -= 1 + return SimpleNamespace(new_messages=0, errors=[], purged=[]) + + def fetch_body(self, folder, uid): + return None + + async def test_concurrent_syncs_are_serialized(self): + import asyncio + import threading + + overlap = threading.Event() + active = {"n": 0} + guard = threading.Lock() + + session_a = Session( + self.account, + self.store, + self._SlowFakeSyncer( + self._FakeIMAPTransport(), active, guard, overlap + ), + password="hunter2", + ) + session_b = Session( + self.account, + self.store, + self._SlowFakeSyncer( + self._FakeIMAPTransport(), active, guard, overlap + ), + password="hunter2", + ) + + app = await self._mounted_app(self._FakeIMAPTransport()) + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + + t1 = threading.Thread(target=app._sync, args=([session_a],)) + t2 = threading.Thread(target=app._sync, args=([session_b],)) + t1.start() + t2.start() + # Une attente asynchrone, pas `Thread.join()` : bloquer la + # boucle d'évènements empêcherait `call_from_thread` (utilisé par + # `set_status` depuis ces threads) de jamais s'exécuter. + while t1.is_alive() or t2.is_alive(): + await asyncio.sleep(0.01) + await pilot.pause() + + self.assertFalse(overlap.is_set()) + + +class TestPreviewShowsFullDate(TestComposeScreenMounted): + """`format_date` reste compact pour la colonne de la liste — l'aperçu + d'un message doit montrer la date PLEINE, sans avoir à deviner l'année + ou le jour à partir de la date du jour.""" + + async def test_header_shows_the_full_send_date(self): + from textual.widgets import Static + + from script.todo.mail.tui_text import format_date_full + + meta = MessageMeta( + uid=1, + date=1785580860, + size=100, + flags="", + msgid="<1@x.ca>", + frm="Alice ", + to="moi@x.ca", + subject="Devis", + snippet="", + ) + ref = MailboxRef( + account_name=self.account.name, + folder_name="INBOX", + display="INBOX", + unseen=0, + ) + app = await self._mounted_app(self._FakeIMAPTransport()) + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + # `current_meta`/`current_ref` sont la couture éprouvée ailleurs + # dans ce fichier (voir `test_forward_attaches_the_original_message`) : + # elle isole ce test du mécanisme de curseur du `DataTable`, qui + # n'est pas ce que ce test vise à vérifier. + app.current_ref = ref + app.current_meta = lambda: meta + app.show_preview() + await pilot.pause() + + preview = app.query_one("#preview", Static) + self.assertIn(format_date_full(meta.date), str(preview.content)) + + +class TestPreviewNeverParsesTheMessageAsMarkup(TestComposeScreenMounted): + """Signalé sur un VRAI courriel (une infolettre Netflix) : le corps + portait un jeton de suivi entre crochets, que Textual analysait comme + une balise — `MarkupError`, et le message devenait illisible. + + Expéditeur, sujet et corps viennent du message, donc de n'importe qui. + `escape()` ne suffit pas : il laisse ces formes intactes. Seul un + `Text` n'est jamais analysé. + """ + + JETON = ( + "[&g=ef7085be-63eb-4622&MESSAGE_GUID=ef7085be&trkId=13710079" + "&msg_token=EQIAmQABAYEAE9uAN9GfY9%2FDdS8pu2y7FrNuD7H%3D]" + ) + + async def _apercu( + self, + frm="Netflix ", + subject="Grand Theft Auto VI", + corps="", + ): + from textual.widgets import Static + + meta = MessageMeta( + uid=1, + date=1785580860, + size=100, + flags="", + msgid="<1@x.ca>", + frm=frm, + to="moi@x.ca", + subject=subject, + snippet="", + ) + ref = MailboxRef( + account_name=self.account.name, + folder_name="INBOX", + display="INBOX", + unseen=0, + ) + brut = ( + b"From: x@y.ca\r\nSubject: s\r\n" + b"Content-Type: text/plain; charset=utf-8\r\n\r\n" + + corps.encode("utf-8") + ) + app = await self._mounted_app(self._FakeIMAPTransport()) + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + app.current_ref = ref + app.current_meta = lambda: meta + app.session_for(self.account.name).syncer.fetch_body = ( + lambda f, u: brut + ) + app.show_preview() + await pilot.pause() + return str(app.query_one("#preview", Static).content) + + async def test_a_bracket_token_in_the_body_is_shown_not_parsed(self): + rendu = await self._apercu(corps=f"Bonjour {self.JETON} fin") + self.assertIn("msg_token", rendu) + + async def test_a_bracket_token_in_the_subject_is_harmless(self): + """Le sujet passait par la même chaîne de balisage que le corps.""" + rendu = await self._apercu(subject=f"Promo {self.JETON}") + self.assertIn("Promo", rendu) + + async def test_a_bracket_token_in_the_sender_is_harmless(self): + rendu = await self._apercu(frm=f"Pub {self.JETON} ") + self.assertIn("Pub", rendu) + + async def test_the_labels_are_still_emphasised(self): + """Le contrôle qui empêche la correction paresseuse : tout rendre + littéral en supprimant le gras passerait les tests ci-dessus.""" + from textual.widgets import Static + + meta = MessageMeta( + uid=1, + date=1785580860, + size=100, + flags="", + msgid="<1@x.ca>", + frm="a@y.ca", + to="moi@x.ca", + subject="Devis", + snippet="", + ) + ref = MailboxRef( + account_name=self.account.name, + folder_name="INBOX", + display="INBOX", + unseen=0, + ) + app = await self._mounted_app(self._FakeIMAPTransport()) + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + app.current_ref = ref + app.current_meta = lambda: meta + app.show_preview() + await pilot.pause() + contenu = app.query_one("#preview", Static).content + styles = [str(s.style) for s in contenu.spans] + self.assertTrue( + any("bold" in s for s in styles), + f"aucun libellé en gras : {styles}", + ) + + +class TestSearchClear(TestComposeScreenMounted): + """Le champ de recherche (`/`) n'avait aucun moyen de se vider : ni + bouton, ni raccourci. Le bouton ✕ et Échap doivent vider `self.query` + EN PLUS du champ — sinon la liste resterait filtrée par une requête + devenue invisible, pire que pas de bouton du tout.""" + + def setUp(self): + super().setUp() + folder_id = self.store.upsert_folder("INBOX", "INBOX", "inbox") + self.store.upsert_messages( + folder_id, + [ + MessageMeta( + uid=1, + date=1785580860, + size=10, + flags="", + msgid="<1@x.ca>", + frm="Alice ", + to="moi@x.ca", + subject="Devis", + snippet="", + ), + MessageMeta( + uid=2, + date=1785580860, + size=10, + flags="", + msgid="<2@x.ca>", + frm="Bob ", + to="moi@x.ca", + subject="CR réunion", + snippet="", + ), + ], + ) + + async def test_clear_button_restores_the_full_list(self): + from textual.widgets import Button, DataTable, Input + + app = await self._mounted_app(self._FakeIMAPTransport()) + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + await pilot.press("slash") + for ch in "devis": + await pilot.press(ch) + await pilot.pause() + + table = app.query_one("#list", DataTable) + self.assertEqual(app.query, "devis") + self.assertEqual(table.row_count, 1) + + app.query_one("#search_clear", Button).press() + await pilot.pause() + + # La couture qui compte : pas seulement le champ vidé, mais + # `self.query` aussi — sinon la liste resterait filtrée par + # une requête devenue invisible. + self.assertEqual(app.query, "") + self.assertEqual(app.query_one("#search", Input).value, "") + self.assertEqual(table.row_count, 2) + + async def test_escape_clears_the_search_when_it_has_focus(self): + app = await self._mounted_app(self._FakeIMAPTransport()) + async with app.run_test() as pilot: + from textual.widgets import DataTable, Input + + await app.workers.wait_for_complete() + app.query_one("#panes").add_class("fullscreen") + await pilot.press("slash") + for ch in "devis": + await pilot.press(ch) + await pilot.pause() + self.assertEqual(app.query, "devis") + + await pilot.press("escape") + await pilot.pause() + + self.assertEqual(app.query, "") + self.assertEqual(app.query_one("#search", Input).value, "") + table = app.query_one("#list", DataTable) + self.assertEqual(table.row_count, 2) + # Échap a été consommé par le vidage de la recherche, pas par + # la sortie du plein écran. + self.assertTrue(app.query_one("#panes").has_class("fullscreen")) + + async def test_escape_elsewhere_still_leaves_fullscreen(self): + app = await self._mounted_app(self._FakeIMAPTransport()) + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + app.query_one("#panes").add_class("fullscreen") + await pilot.pause() + + await pilot.press("escape") + await pilot.pause() + + self.assertFalse(app.query_one("#panes").has_class("fullscreen")) + + async def test_clearing_the_search_is_discoverable_in_the_footer(self): + """Round 2 de la revue : le bouton ✕ a un `tooltip`, mais Textual ne + déclenche les tooltips que sur `MouseMove` (vérifié dans + `screen.py`) — aucun clavier n'y mène. Le vrai chemin accessible + est la description traduite qu'affiche le pied d'écran, et + SEULEMENT pendant que le champ a le focus (`Screen.active_bindings`, + que `Footer` lit directement).""" + from textual.widgets import Input + + from script.todo.todo_i18n import t + + app = await self._mounted_app(self._FakeIMAPTransport()) + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + + # Hors du champ : la liaison Échap de l'appli reste celle + # d'avant, masquée — inchangée par ce correctif. + outside = app.screen.active_bindings["escape"] + self.assertFalse(outside.binding.show) + + await pilot.press("slash") + await pilot.pause() + + # Dans le champ : une AUTRE liaison Échap, traduite et visible, + # prend le dessus — c'est elle que le pied d'écran affiche. + focused = app.screen.active_bindings["escape"] + self.assertTrue(focused.binding.show) + self.assertEqual( + focused.binding.description, t("mail_search_clear") + ) + self.assertIsInstance(app.focused, Input) + + async def test_clear_search_does_not_refresh_the_list_twice(self): + """`clear_search` met `self.query` à jour ET rafraîchit tout de + suite ; vider le champ poste aussi un `Input.Changed`, que + `on_input_changed` aurait traité une seconde fois sans son + garde-fou (`event.value == self.query`).""" + app = await self._mounted_app(self._FakeIMAPTransport()) + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + await pilot.press("slash") + for ch in "devis": + await pilot.press(ch) + await pilot.pause() + + calls = [] + original = app.refresh_list + + def counting_refresh_list(): + calls.append(1) + original() + + app.refresh_list = counting_refresh_list + app.clear_search() + await pilot.pause() + + self.assertEqual(len(calls), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_mail_crypto.py b/test/test_mail_crypto.py new file mode 100644 index 0000000..da9a440 --- /dev/null +++ b/test/test_mail_crypto.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import unittest + +from script.todo.mail.crypto import ( + CLEAR_MAGIC, + SEALED_MAGIC, + AesGcmCrypto, + CryptoError, + NullCrypto, + build_crypto, + new_key, +) + + +class TestNullCrypto(unittest.TestCase): + def test_roundtrip(self): + box = NullCrypto() + self.assertEqual(box.open(box.seal(b"bonjour")), b"bonjour") + + def test_envelope_is_marked_clear(self): + self.assertTrue(NullCrypto().seal(b"x").startswith(CLEAR_MAGIC)) + + def test_cannot_open_sealed_blob(self): + blob = AesGcmCrypto(new_key()).seal(b"secret") + with self.assertRaises(CryptoError): + NullCrypto().open(blob) + + +class TestAesGcmCrypto(unittest.TestCase): + def setUp(self): + self.key = new_key() + + def test_key_is_32_bytes(self): + self.assertEqual(len(self.key), 32) + + def test_roundtrip(self): + box = AesGcmCrypto(self.key) + self.assertEqual(box.open(box.seal(b"bonjour")), b"bonjour") + + def test_envelope_is_marked_sealed(self): + self.assertTrue( + AesGcmCrypto(self.key).seal(b"x").startswith(SEALED_MAGIC) + ) + + def test_ciphertext_hides_plaintext(self): + blob = AesGcmCrypto(self.key).seal(b"sujet confidentiel") + self.assertNotIn(b"confidentiel", blob) + + def test_nonce_differs_each_call(self): + box = AesGcmCrypto(self.key) + self.assertNotEqual(box.seal(b"meme texte"), box.seal(b"meme texte")) + + def test_wrong_key_raises(self): + blob = AesGcmCrypto(self.key).seal(b"secret") + with self.assertRaises(CryptoError): + AesGcmCrypto(new_key()).open(blob) + + def test_tampered_blob_raises(self): + blob = bytearray(AesGcmCrypto(self.key).seal(b"secret")) + blob[-1] ^= 0xFF + with self.assertRaises(CryptoError): + AesGcmCrypto(self.key).open(bytes(blob)) + + def test_reads_clear_blob(self): + """Une base écrite en clair reste lisible après passage en chiffré.""" + clear = NullCrypto().seal(b"ancien") + self.assertEqual(AesGcmCrypto(self.key).open(clear), b"ancien") + + def test_rejects_bad_key_length(self): + with self.assertRaises(CryptoError): + AesGcmCrypto(b"trop court") + + def test_rejects_unknown_magic(self): + with self.assertRaises(CryptoError): + AesGcmCrypto(self.key).open(b"ZZdonnees") + + def test_unexpected_error_is_not_disguised_as_a_bad_key(self): + """Un bug de programmation doit remonter tel quel, pas en CryptoError.""" + box = AesGcmCrypto(self.key) + blob = box.seal(b"secret") + + class Boom: + # AESGCM est adossé à Rust : `decrypt` y est en lecture seule. + # On remplace donc l'objet entier, pas sa méthode. + def decrypt(self, *args, **kwargs): + raise RuntimeError("bug interne") + + box._aes = Boom() + with self.assertRaises(RuntimeError): + box.open(blob) + + +class TestBuildCrypto(unittest.TestCase): + def test_clear_mode(self): + self.assertIsInstance(build_crypto("clear", None), NullCrypto) + + def test_encrypted_mode(self): + self.assertIsInstance( + build_crypto("encrypted", new_key()), AesGcmCrypto + ) + + def test_ephemeral_mode(self): + self.assertIsInstance( + build_crypto("ephemeral", new_key()), AesGcmCrypto + ) + + def test_encrypted_without_key_raises(self): + with self.assertRaises(CryptoError): + build_crypto("encrypted", None) + + def test_unknown_mode_raises(self): + with self.assertRaises(CryptoError): + build_crypto("magique", None) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_mail_imap_transport.py b/test/test_mail_imap_transport.py new file mode 100644 index 0000000..603c5ba --- /dev/null +++ b/test/test_mail_imap_transport.py @@ -0,0 +1,436 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import unittest +from unittest.mock import MagicMock, patch + +from script.todo.mail.accounts import account_from_preset +from script.todo.mail.imap_transport import ( + ImapError, + ImaplibTransport, + connect, + decode_header_value, + decode_mailbox, + parse_fetch_headers, + parse_list_line, +) + + +class TestDecodeHeaderValue(unittest.TestCase): + def test_plain(self): + self.assertEqual(decode_header_value("Bonjour"), "Bonjour") + + def test_encoded_word_base64(self): + self.assertEqual( + decode_header_value("=?UTF-8?B?RGV2aXMgcsOpdmlzw6k=?="), + "Devis révisé", + ) + + def test_encoded_word_quoted_printable(self): + self.assertEqual( + decode_header_value("=?UTF-8?Q?Devis_r=C3=A9vis=C3=A9?="), + "Devis révisé", + ) + + def test_mixed_parts(self): + self.assertEqual( + decode_header_value("Re: =?UTF-8?B?ZGV2aXM=?="), "Re: devis" + ) + + def test_none_is_empty(self): + self.assertEqual(decode_header_value(None), "") + + def test_broken_encoding_does_not_raise(self): + self.assertIsInstance(decode_header_value("=?UTF-8?B?!!!?="), str) + + def test_unknown_8bit_charset_does_not_raise(self): + """Étiquette réelle vue en usage : certains MTA la posent sur un + en-tête 8 bits mal formé. Python ne connaît pas ce nom de codec : + `bytes.decode("unknown-8bit", "replace")` lève `LookupError` à la + recherche du codec, avant que `errors="replace"` ne serve. Un seul + message ainsi étiqueté faisait échouer toute la synchronisation du + dossier (`_sync_folder` catch par dossier, donc le dossier entier + n'était jamais marqué synchronisé).""" + self.assertEqual( + decode_header_value("=?unknown-8bit?Q?Bonjour?="), "Bonjour" + ) + + +class TestDecodeMailbox(unittest.TestCase): + def test_ascii_unchanged(self): + self.assertEqual(decode_mailbox("INBOX"), "INBOX") + + def test_modified_utf7(self): + self.assertEqual(decode_mailbox("&AMk-l&AOk-ments"), "Éléments") + + def test_ampersand_escape(self): + self.assertEqual(decode_mailbox("A&-B"), "A&B") + + def test_broken_input_returns_original(self): + self.assertEqual(decode_mailbox("&&&"), "&&&") + + +class TestParseListLine(unittest.TestCase): + def test_plain_inbox(self): + info = parse_list_line(b'(\\HasNoChildren) "/" "INBOX"') + self.assertEqual(info.name, "INBOX") + self.assertEqual(info.role, "inbox") + + def test_sent_role_from_special_use(self): + info = parse_list_line( + b'(\\HasNoChildren \\Sent) "/" "[Gmail]/Sent Mail"' + ) + self.assertEqual(info.name, "[Gmail]/Sent Mail") + self.assertEqual(info.role, "sent") + + def test_trash_role(self): + self.assertEqual( + parse_list_line(b'(\\Trash) "/" "Corbeille"').role, "trash" + ) + + def test_drafts_role(self): + self.assertEqual( + parse_list_line(b'(\\Drafts) "/" "Drafts"').role, "drafts" + ) + + def test_junk_role(self): + self.assertEqual(parse_list_line(b'(\\Junk) "/" "Spam"').role, "junk") + + def test_archive_role(self): + self.assertEqual( + parse_list_line(b'(\\Archive) "/" "Archive"').role, "archive" + ) + + def test_noselect_containers_are_marked_unselectable(self): + """« [Gmail] » est un NIVEAU de hiérarchie, pas une boîte : le + serveur l'annonce dans les drapeaux de LIST, il suffit de l'écouter + plutôt que de traiter son nom comme un cas particulier.""" + for ligne in ( + b'(\\HasChildren \\Noselect) "/" "[Gmail]"', + b'(\\NonExistent \\HasChildren) "/" "Vieux"', + ): + self.assertFalse(parse_list_line(ligne).selectable, ligne) + + def test_ordinary_folders_stay_selectable(self): + """Le contrôle négatif : marquer TOUT comme non sélectionnable + passerait le test ci-dessus, et ne synchroniserait plus rien.""" + for ligne in ( + b'(\\HasNoChildren) "/" "INBOX"', + b'(\\HasNoChildren \\Sent) "/" "[Gmail]/Sent Mail"', + ): + self.assertTrue(parse_list_line(ligne).selectable, ligne) + + def test_no_special_use_has_no_role(self): + self.assertIsNone( + parse_list_line(b'(\\HasNoChildren) "/" "Projets"').role + ) + + def test_unquoted_name(self): + self.assertEqual( + parse_list_line(b'(\\HasNoChildren) "/" Projets').name, "Projets" + ) + + def test_display_is_decoded(self): + info = parse_list_line(b'(\\HasNoChildren) "/" "&AMk-l&AOk-ments"') + self.assertEqual(info.display, "Éléments") + + +HEADERS_1 = ( + b"1 (UID 101 RFC822.SIZE 420 FLAGS (\\Seen) BODY[HEADER.FIELDS " + b"(FROM TO SUBJECT DATE MESSAGE-ID)] {160}", + b"From: Alice \r\n" + b"To: moi@x.ca\r\n" + b"Subject: =?UTF-8?B?RGV2aXMgcsOpdmlzw6k=?=\r\n" + b"Date: Fri, 01 Aug 2026 10:41:00 +0000\r\n" + b"Message-ID: \r\n\r\n", +) +HEADERS_2 = ( + b"2 (UID 102 RFC822.SIZE 12 FLAGS () BODY[HEADER.FIELDS " + b"(FROM TO SUBJECT DATE MESSAGE-ID)] {40}", + b"From: bob@x.ca\r\nSubject: CR\r\n\r\n", +) + + +# Même message, mais le serveur place les attributs APRÈS le littéral. +# `imaplib` rend alors la fin de ligne dans une entrée séparée. +HEADERS_TRAILING = ( + b"3 (BODY[HEADER.FIELDS (FROM TO SUBJECT DATE MESSAGE-ID)] {42}", + b"From: carl@x.ca\r\nSubject: Facture\r\n\r\n", +) +TRAILING_ATTRS = b" UID 103 RFC822.SIZE 99 FLAGS (\\Answered))" + + +class TestParseFetchHeaders(unittest.TestCase): + def test_single_message(self): + got = parse_fetch_headers([HEADERS_1, b")"]) + self.assertEqual(len(got), 1) + self.assertEqual(got[0].uid, 101) + + def test_size_and_flags(self): + got = parse_fetch_headers([HEADERS_1, b")"])[0] + self.assertEqual(got.size, 420) + self.assertEqual(got.flags, "\\Seen") + + def test_subject_is_decoded(self): + self.assertEqual( + parse_fetch_headers([HEADERS_1, b")"])[0].subject, "Devis révisé" + ) + + def test_from_and_to(self): + got = parse_fetch_headers([HEADERS_1, b")"])[0] + self.assertEqual(got.frm, "Alice ") + self.assertEqual(got.to, "moi@x.ca") + + def test_msgid(self): + self.assertEqual( + parse_fetch_headers([HEADERS_1, b")"])[0].msgid, "" + ) + + def test_raw_8bit_bytes_in_the_date_do_not_lose_the_message(self): + """Signalé depuis une VRAIE boîte : un `Date:` porteur d'octets 8 + bits fait renvoyer un `Header` et non une chaîne, et + `parsedate_to_datetime` y lève un AttributeError que le `except` + d'origine ne rattrapait pas — la synchro du dossier ENTIER tombait + sur un seul message. Une date est une commodité d'affichage : elle + ne vaut pas la perte du message.""" + entetes = ( + b"From: a@x.ca\r\n" + b"Subject: essai\r\n" + b"Date: Wed, 06 Ao\xfbt 2026 10:00:00 +0000\r\n\r\n" + ) + data = [ + ( + b"1 (UID 42 RFC822.SIZE 100 FLAGS (\\Seen) " + b"BODY[HEADER.FIELDS (DATE FROM TO SUBJECT MESSAGE-ID)] " + b"{%d}" % len(entetes), + entetes, + ), + b")", + ] + infos = parse_fetch_headers(data) + self.assertEqual(len(infos), 1) + self.assertEqual(infos[0].uid, 42) + self.assertEqual(infos[0].date, 0) + self.assertEqual(infos[0].subject, "essai") + + def test_date_is_epoch(self): + self.assertEqual( + parse_fetch_headers([HEADERS_1, b")"])[0].date, 1785580860 + ) + + def test_missing_date_is_zero(self): + self.assertEqual(parse_fetch_headers([HEADERS_2, b")"])[0].date, 0) + + def test_missing_to_is_empty(self): + self.assertEqual(parse_fetch_headers([HEADERS_2, b")"])[0].to, "") + + def test_several_messages(self): + got = parse_fetch_headers([HEADERS_1, b")", HEADERS_2, b")"]) + self.assertEqual([m.uid for m in got], [101, 102]) + + def test_non_tuple_entries_are_skipped(self): + self.assertEqual(parse_fetch_headers([b")", None]), []) + + def test_attributes_after_the_literal_are_read(self): + """RFC 3501 n'impose pas l'ordre : sinon le message disparaît.""" + got = parse_fetch_headers([HEADERS_TRAILING, TRAILING_ATTRS]) + self.assertEqual(len(got), 1) + self.assertEqual(got[0].uid, 103) + + def test_attributes_after_the_literal_keep_flags_and_size(self): + got = parse_fetch_headers([HEADERS_TRAILING, TRAILING_ATTRS])[0] + self.assertEqual(got.flags, "\\Answered") + self.assertEqual(got.size, 99) + self.assertEqual(got.subject, "Facture") + + def test_both_orders_in_one_response(self): + got = parse_fetch_headers( + [HEADERS_1, b")", HEADERS_TRAILING, TRAILING_ATTRS] + ) + self.assertEqual([m.uid for m in got], [101, 103]) + + +class TestTransport(unittest.TestCase): + def setUp(self): + self.client = MagicMock() + self.transport = ImaplibTransport(self.client) + + def test_list_folders(self): + self.client.list.return_value = ( + "OK", + [b'(\\HasNoChildren) "/" "INBOX"', b'(\\Sent) "/" "Sent"'], + ) + names = [f.name for f in self.transport.list_folders()] + self.assertEqual(names, ["INBOX", "Sent"]) + + def test_list_failure_raises(self): + self.client.list.return_value = ("NO", [b"refuse"]) + with self.assertRaises(ImapError): + self.transport.list_folders() + + def test_select_reads_uidvalidity_and_uidnext(self): + self.client.select.return_value = ("OK", [b"42"]) + self.client.response.side_effect = lambda k: { + "UIDVALIDITY": ("OK", [b"7"]), + "UIDNEXT": ("OK", [b"103"]), + }[k] + info = self.transport.select("INBOX") + self.assertEqual( + (info.uidvalidity, info.uidnext, info.exists), (7, 103, 42) + ) + + def test_select_failure_raises(self): + self.client.select.return_value = ("NO", [b"pas de boite"]) + with self.assertRaises(ImapError): + self.transport.select("ABSENT") + + def test_search_uids(self): + self.client.uid.return_value = ("OK", [b"101 102 103"]) + self.assertEqual(self.transport.search_uids(101), [101, 102, 103]) + + def test_search_empty(self): + self.client.uid.return_value = ("OK", [b""]) + self.assertEqual(self.transport.search_uids(1), []) + + def test_fetch_headers_empty_list_skips_network(self): + self.assertEqual(self.transport.fetch_headers([]), []) + self.client.uid.assert_not_called() + + def test_fetch_body(self): + self.client.uid.return_value = ( + "OK", + [(b"1 (UID 101 BODY[] {5}", b"corps"), b")"], + ) + self.assertEqual(self.transport.fetch_body(101), b"corps") + + def test_fetch_body_failure_raises(self): + self.client.uid.return_value = ("NO", [b"refuse"]) + with self.assertRaises(ImapError): + self.transport.fetch_body(101) + + def test_store_flags_add_and_remove(self): + self.client.uid.return_value = ("OK", [b""]) + self.transport.store_flags(101, ["\\Seen"], ["\\Flagged"]) + calls = [c.args for c in self.client.uid.call_args_list] + self.assertIn(("STORE", "101", "+FLAGS", "(\\Seen)"), calls) + self.assertIn(("STORE", "101", "-FLAGS", "(\\Flagged)"), calls) + + def test_logout_is_forgiving(self): + self.client.logout.side_effect = OSError("déjà fermé") + self.transport.logout() # ne doit pas lever + + +class TestFetchFlags(unittest.TestCase): + def setUp(self): + self.client = MagicMock() + self.transport = ImaplibTransport(self.client) + + def test_empty_list_skips_the_network(self): + self.assertEqual(self.transport.fetch_flags([]), []) + self.client.uid.assert_not_called() + + def test_parses_bare_lines(self): + self.client.uid.return_value = ( + "OK", + [b"1 (UID 101 FLAGS (\\Seen))", b"2 (UID 102 FLAGS ())"], + ) + self.assertEqual( + self.transport.fetch_flags([101, 102]), + [(101, "\\Seen"), (102, "")], + ) + + def test_skips_entries_without_a_uid(self): + self.client.uid.return_value = ( + "OK", + [b")", None, b"1 (UID 101 FLAGS (\\Seen))"], + ) + self.assertEqual(self.transport.fetch_flags([101]), [(101, "\\Seen")]) + + def test_failure_raises(self): + self.client.uid.return_value = ("NO", [b"refuse"]) + with self.assertRaises(ImapError): + self.transport.fetch_flags([101]) + + +class TestAppend(unittest.TestCase): + def setUp(self): + self.client = MagicMock() + self.transport = ImaplibTransport(self.client) + + def test_quotes_the_folder_and_joins_the_flags(self): + self.client.append.return_value = ("OK", [b"fait"]) + self.transport.append("Sent Items", b"brut", ["\\Seen"]) + self.client.append.assert_called_once_with( + '"Sent Items"', "(\\Seen)", None, b"brut" + ) + + def test_failure_raises(self): + self.client.append.return_value = ("NO", [b"refuse"]) + with self.assertRaises(ImapError): + self.transport.append("Sent", b"brut", []) + + +class TestConnect(unittest.TestCase): + """`connect` est le code le plus sensible au protocole du fichier. + + Aucun de ces tests ne joint le réseau : `imaplib` est remplacé. + """ + + def _account(self, security): + account = account_from_preset("perso", "moi@x.ca", "generic") + account.imap.host = "imap.x.ca" + account.imap.port = 993 + account.imap.security = security + return account + + def test_ssl_branch(self): + client = MagicMock() + with patch("imaplib.IMAP4_SSL", return_value=client) as ctor: + transport = connect(self._account("ssl"), "hunter2") + ctor.assert_called_once_with("imap.x.ca", 993, timeout=30) + client.login.assert_called_once_with("moi@x.ca", "hunter2") + self.assertIsInstance(transport, ImaplibTransport) + + def test_starttls_branch_upgrades(self): + client = MagicMock() + with patch("imaplib.IMAP4", return_value=client) as ctor: + connect(self._account("starttls"), "hunter2") + client.starttls.assert_called_once() + ctor.assert_called_once_with("imap.x.ca", 993, timeout=30) + + def test_plain_branch_does_not_upgrade(self): + client = MagicMock() + with patch("imaplib.IMAP4", return_value=client): + connect(self._account("none"), "hunter2") + client.starttls.assert_not_called() + + def test_login_failure_becomes_an_imap_error(self): + client = MagicMock() + client.login.side_effect = OSError("530 refus") + with patch("imaplib.IMAP4_SSL", return_value=client): + with self.assertRaises(ImapError) as ctx: + connect(self._account("ssl"), "mauvais") + self.assertIn("530", str(ctx.exception)) + + def test_a_non_ascii_password_says_it_never_left(self): + """`imaplib` encode LOGIN en ASCII : un mot de passe accentué + n'atteint pas le serveur. Le message général dit « refusée », ce qui + accuserait le serveur d'un refus qu'il n'a pas prononcé — et + enverrait chercher la panne du mauvais côté du réseau.""" + client = MagicMock() + client.login.side_effect = UnicodeEncodeError( + "ascii", "motdepassé", 10, 11, "ordinal not in range(128)" + ) + with patch("imaplib.IMAP4_SSL", return_value=client): + with self.assertRaises(ImapError) as ctx: + connect(self._account("ssl"), "motdepassé") + message = str(ctx.exception) + self.assertIn("ASCII", message) + self.assertNotIn("refusée", message) + self.assertNotIn("ordinal", message) + + def test_connection_failure_becomes_an_imap_error(self): + with patch("imaplib.IMAP4_SSL", side_effect=OSError("injoignable")): + with self.assertRaises(ImapError): + connect(self._account("ssl"), "hunter2") diff --git a/test/test_mail_live_server.py b/test/test_mail_live_server.py new file mode 100644 index 0000000..76df3b9 --- /dev/null +++ b/test/test_mail_live_server.py @@ -0,0 +1,670 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Les tests courriel qui parlent à un VRAI serveur. + +Tous les autres tests du paquet `mail` passent par un double. Ceux-ci ouvrent +une socket sur 127.0.0.1, vers un serveur IMAP (Twisted) et un serveur SMTP +(aiosmtpd) démarrés puis tués par le test lui-même — voir `mail_sandbox.py`. + +Ce qu'on cherche n'est PAS la conformité : c'est de reproduire ce qu'un +double n'aurait jamais produit, parce que son auteur ne l'avait pas imaginé. +Chaque classe ci-dessous nomme le risque réel qu'elle couvre. + +Ils NE tournent PAS dans la boucle rapide : sans `twisted` ni `aiosmtpd`, +tout le fichier se saute proprement. Pour les lancer volontairement : + + .venv.erplibre/bin/python -m unittest discover -s test \\ + -p test_mail_live_server.py -v +""" +import email.utils +import threading +import unittest +import unittest.mock + +try: + import aiosmtpd # noqa: F401 + import twisted # noqa: F401 + + SANDBOX_MISSING = "" +except ImportError as exc: # pragma: no cover - dépend de l'installation + SANDBOX_MISSING = str(exc) + +if SANDBOX_MISSING: # pragma: no cover - dépend de l'installation + MailSandboxCase = unittest.TestCase +else: + from mail_sandbox import ( + LIVE_SERVERS, + PASSWORD, + DropConnection, + MailSandboxCase, + RefuseCommand, + port_is_closed, + sandbox_account, + ) + +requires_servers = unittest.skipIf( + bool(SANDBOX_MISSING), + f"serveurs de test absents ({SANDBOX_MISSING})" + " : pip install -r requirement/erplibre_require-ments.txt", +) + +# Le message qui a cassé la production. Deux méchancetés en une : un mot +# encodé RFC 2047 qui annonce `unknown-8bit`, étiquette qu'aucun codec Python +# ne connaît, et un `From` qui porte des octets 8 bits BRUTS, sans encodage +# d'aucune sorte. Les deux existent en vrai ; aucun double ne les produisait. +HOSTILE = ( + b"From: Ren\xe9 Lavall\xe9e \r\n" + b"To: moi@example.ca\r\n" + b"Subject: =?unknown-8bit?Q?sujet_h=E9rit=E9?=\r\n" + b"Date: Wed, 06 Aug 2026 10:00:00 +0000\r\n" + b"Message-ID: \r\n" + b'Content-Type: text/plain; charset="unknown-8bit"\r\n' + b"\r\n" + b"Bonjour, corps accentu\xe9.\r\n" +) + + +def polite(uid: int, subject: str = "Devis") -> bytes: + """Un message ordinaire, servi par le chemin normal de Twisted.""" + return ( + f"From: Alice \r\n" + f"To: moi@example.ca\r\n" + f"Subject: {subject}\r\n" + f"Date: Wed, 06 Aug 2026 10:0{uid}:00 +0000\r\n" + f"Message-ID: \r\n" + f"\r\n" + f"Corps du message {uid}.\r\n" + ).encode() + + +@requires_servers +class TestUnknown8bitDoesNotAbortSync(MailSandboxCase): + """Bug 1 : `unknown encoding: unknown-8bit` arrêtait la sync d'un dossier + pour de bon — le dossier échouait à chaque passe, donc son `last_uid` + n'avançait jamais et l'utilisateur ne voyait plus rien arriver. + + Le risque couvert : un en-tête qu'aucun codec Python ne sait lire ne doit + pas coûter le dossier. Ici l'étiquette arrive VRAIMENT du réseau, décodée + par `imaplib` puis `parse_fetch_headers`, et non fabriquée par le test. + """ + + def setUp(self): + self.imap = self.imap_server() + self.imap.folder("INBOX").deliver(HOSTILE, flags=["\\Seen"]) + self.account = sandbox_account(imap_port=self.imap.port) + self.store = self.temp_store(self.account) + self.transport = self.imap_transport(self.imap, self.account) + + def sync(self): + from script.todo.mail.imap_sync import Syncer + + return Syncer(self.store, self.transport).sync() + + def stored(self): + state = self.store.folder_state("INBOX") + return self.store.list_messages(state["id"]) + + def test_sync_reports_no_error(self): + self.assertEqual(self.sync().errors, []) + + def test_the_message_is_stored(self): + self.sync() + self.assertEqual(len(self.stored()), 1) + + def test_the_unknown_charset_is_substituted_not_fatal(self): + """`charset.decode_bytes` remplace ce qu'il ne sait pas lire. Sans ce + garde-fou, `decode_header` lève `LookupError` et le message n'existe + pas du tout.""" + self.sync() + subject = self.stored()[0].subject + self.assertTrue(subject.startswith("sujet h")) + self.assertIn("�", subject) + + def test_raw_8bit_header_bytes_survive_the_wire(self): + """Le `From` part en octets 8 bits bruts, sans encodage : c'est ce + que fait un vrai MTA relâché, et ce que Twisted refuse de reformater + (voir `SandboxIMAP4Server.spew_body`).""" + self.sync() + self.assertIn("Lavall", self.stored()[0].frm) + self.assertIn("rene@example.org", self.stored()[0].frm) + + def test_the_body_still_yields_a_snippet(self): + """Le corps porte le même charset inconnu : ouvrir le message ne doit + pas échouer non plus.""" + from script.todo.mail.imap_sync import Syncer + + syncer = Syncer(self.store, self.transport) + syncer.sync() + raw = syncer.fetch_body("INBOX", 1) + self.assertIn(b"Bonjour", raw) + self.assertIn("Bonjour", self.stored()[0].snippet) + + def test_the_bytes_are_served_verbatim(self): + """Le corps rendu par le serveur est OCTET POUR OCTET celui déclaré : + si le bac à sable réécrivait quoi que ce soit, il ne prouverait plus + rien sur le vrai réseau.""" + from script.todo.mail.imap_sync import Syncer + + syncer = Syncer(self.store, self.transport) + syncer.sync() + self.assertEqual(syncer.fetch_body("INBOX", 1), HOSTILE) + + +@requires_servers +class TestWhatLeavesBySmtp(MailSandboxCase): + """Le risque couvert : le Cci ne doit JAMAIS apparaître dans un en-tête. + + C'est la propriété de sécurité que tout le plan protège, et jusqu'ici + elle n'était vérifiée que sur l'objet `EmailMessage` que nous tenions en + main. Ici on l'affirme sur les OCTETS reçus à l'autre bout de la socket — + ce que le serveur a vraiment lu. + """ + + def setUp(self): + from script.todo.mail import smtp_send + + self.smtp = self.smtp_server() + self.account = sandbox_account( + smtp_port=self.smtp.port, + address="moi@example.ca", + display_name="Mathieu Benoit", + ) + self.transport = smtp_send.connect(self.account, PASSWORD) + self.addCleanup(self.transport.quit) + self.msg = smtp_send.build_message( + self.account, + ["alice@example.org"], + "Devis daté d'août", + "Bonjour Alice", + cc=["copie@example.org"], + bcc=["cache@example.org"], + date="Fri, 01 Aug 2026 10:41:00 +0000", + msgid="", + ) + self.served = smtp_send.send(self.account, self.msg, self.transport) + + @property + def sent(self): + return self.smtp.messages[0] + + def test_the_server_received_exactly_one_message(self): + self.assertEqual(len(self.smtp.messages), 1) + + def test_bcc_is_absent_from_the_bytes_that_left(self): + self.assertNotIn(b"cache@example.org", self.sent.content) + self.assertNotIn(b"Bcc", self.sent.content) + self.assertNotIn(b"X-ERPLibre-Bcc", self.sent.content) + + def test_bcc_is_absent_from_every_header(self): + parsed = self.sent.headers() + self.assertIsNone(parsed.get("Bcc")) + self.assertIsNone(parsed.get("X-ERPLibre-Bcc")) + + def test_bcc_is_served_by_the_envelope(self): + """Caché ne veut pas dire non livré : le Cci ne vit que dans + l'enveloppe SMTP, que le serveur nous rend ici telle qu'il l'a + reçue.""" + self.assertIn("cache@example.org", self.sent.rcpt_tos) + self.assertEqual( + sorted(self.sent.rcpt_tos), + sorted( + ["alice@example.org", "copie@example.org", "cache@example.org"] + ), + ) + + def test_the_envelope_sender_is_the_account(self): + self.assertEqual(self.sent.mail_from, "moi@example.ca") + + def test_send_reports_the_recipients_it_served(self): + self.assertEqual(sorted(self.served), sorted(self.sent.rcpt_tos)) + + def test_nothing_8bit_left_on_the_wire(self): + """Un en-tête accentué doit partir encodé RFC 2047. Sorti brut, il + serait mutilé par le premier relais venu — et c'est exactement le + genre d'octet qui nous est revenu en `unknown-8bit`.""" + headers = self.sent.content.split(b"\r\n\r\n", 1)[0] + headers.decode("ascii") # lève si un octet 8 bits a fui + + def test_the_accented_subject_arrives_intact(self): + self.assertEqual( + self.sent.headers()["Subject"], + "Devis =?utf-8?q?dat=C3=A9_d=27ao=C3=BBt?=", + ) + from script.todo.mail.imap_transport import decode_header_value + + self.assertEqual( + decode_header_value(self.sent.headers()["Subject"]), + "Devis daté d'août", + ) + + def test_the_visible_headers_are_the_ones_we_built(self): + parsed = self.sent.headers() + self.assertEqual(parsed["From"], "Mathieu Benoit ") + self.assertEqual(parsed["To"], "alice@example.org") + self.assertEqual(parsed["Cc"], "copie@example.org") + self.assertEqual(parsed["Message-ID"], "") + + +@requires_servers +class TestSmtpRefusals(MailSandboxCase): + """Le risque couvert : un refus du serveur doit devenir une `SmtpError` + lisible, jamais une exception brute remontée dans le TUI.""" + + def test_a_wrong_password_is_an_smtp_error(self): + """Le serveur EXIGE l'authentification et la refuse pour de vrai — + un 535 sur le fil, pas une exception injectée. + + La sous-classe ne change rien au protocole : elle ne sert qu'à + retenir la connexion pour la fermer. `smtp_send.connect()` ne ferme + pas sa socket quand `login()` échoue — elle traîne jusqu'au + ramasse-miettes, ce qui est bénin dans le TUI mais laisserait ici un + `ResourceWarning` attaché à un test au hasard. + """ + import smtplib + + from script.todo.mail.smtp_send import SmtpError, connect + + opened = [] + + class Recording(smtplib.SMTP): + def __init__(inner, *args, **kwargs): + super().__init__(*args, **kwargs) + opened.append(inner) + + smtp = self.smtp_server(require_auth=True) + account = sandbox_account(smtp_port=smtp.port) + with unittest.mock.patch("smtplib.SMTP", Recording): + with self.assertRaises(SmtpError): + connect(account, "mauvais-mot-de-passe") + for client in opened: + client.close() + + def test_a_closed_port_is_an_smtp_error(self): + """Le serveur est démarré puis tué : le port est fermé pour de bon, + et personne d'autre n'écoute dessus.""" + from script.todo.mail.smtp_send import SmtpError, connect + + smtp = self.smtp_server() + port = smtp.port + smtp.stop() + account = sandbox_account(smtp_port=port) + with self.assertRaises(SmtpError): + connect(account, PASSWORD) + + +@requires_servers +class TestConnectionDroppedMidSync(MailSandboxCase): + """Le risque couvert : une coupure en pleine passe ne doit ni faire + tomber le TUI, ni PERDRE des messages en avançant `last_uid` sur des + en-têtes jamais reçus. Aucun double ne coupe jamais la ligne : il répond + toujours. + """ + + def setUp(self): + self.imap = self.imap_server() + inbox = self.imap.folder("INBOX") + for uid in (1, 2, 3): + inbox.deliver(polite(uid), uid=uid) + self.account = sandbox_account(imap_port=self.imap.port) + self.store = self.temp_store(self.account) + + def syncer(self): + from script.todo.mail.imap_sync import Syncer + + return Syncer(self.store, self.imap_transport(self.imap, self.account)) + + def test_a_drop_is_reported_not_raised(self): + self.imap.fail(DropConnection(b"FETCH")) + with self.assertLogs("script.todo.mail.imap_sync", level="ERROR"): + report = self.syncer().sync() + self.assertEqual(len(report.errors), 1) + self.assertIn("INBOX", report.errors[0]) + + def test_a_drop_before_the_headers_loses_nothing(self): + """La coupure tombe sur le tout premier FETCH : rien n'a été stocké, + donc `last_uid` ne doit pas avoir bougé — sinon les trois messages + seraient sautés pour toujours.""" + self.imap.fail(DropConnection(b"FETCH")) + with self.assertLogs("script.todo.mail.imap_sync", level="ERROR"): + self.syncer().sync() + state = self.store.folder_state("INBOX") + self.assertEqual(state["last_uid"], 0) + + def test_the_next_pass_recovers_every_message(self): + self.imap.fail(DropConnection(b"FETCH")) + with self.assertLogs("script.todo.mail.imap_sync", level="ERROR"): + self.syncer().sync() + self.imap.faults.clear() + report = self.syncer().sync() + self.assertEqual(report.errors, []) + self.assertEqual(report.new_messages, 3) + state = self.store.folder_state("INBOX") + self.assertEqual( + sorted(m.uid for m in self.store.list_messages(state["id"])), + [1, 2, 3], + ) + + def test_a_drop_after_the_headers_keeps_what_arrived(self): + """Cette fois la coupure tombe sur le FETCH des drapeaux, après que + les en-têtes soient descendus : le travail déjà fait doit rester.""" + self.imap.fail(DropConnection(b"FETCH", after=1)) + with self.assertLogs("script.todo.mail.imap_sync", level="ERROR"): + report = self.syncer().sync() + self.assertEqual(report.new_messages, 3) + state = self.store.folder_state("INBOX") + self.assertEqual(len(self.store.list_messages(state["id"])), 3) + + def test_a_refused_folder_does_not_cost_the_others(self): + """Un NO sur un dossier — droits, quota, boîte verrouillée — laisse + le reste de la boîte utilisable. Ici le refus vient du serveur, pas + d'une exception que le test aurait injectée.""" + self.imap.folder("INBOX.Archive").deliver(polite(9), uid=9) + self.imap.fail( + RefuseCommand(b"SELECT", after=1, text=b"Mailbox locked") + ) + with self.assertLogs("script.todo.mail.imap_sync", level="ERROR"): + report = self.syncer().sync() + self.assertEqual(report.folders, 2) + self.assertEqual(len(report.errors), 1) + self.assertEqual(report.new_messages, 3) + + +@requires_servers +class TestSentCopyLandsOnTheServer(MailSandboxCase): + """Bug 3 : la liste montrait un état périmé après un envoi. + + Le chemin réel est APPEND puis `sync_one` sur le dossier Envoyés. Le + risque couvert : la copie doit exister CHEZ LE SERVEUR, sous l'UID que le + serveur attribue — pas un UID inventé localement, qui entrerait en + collision avec un futur message réel — et elle ne doit pas rouvrir la + porte du Cci par ce second chemin. + """ + + def setUp(self): + from script.todo.mail import smtp_send + + self.imap = self.imap_server() + self.sent_box = self.imap.folder("INBOX.Sent") + self.sent_box.deliver(polite(1, "Un envoi précédent"), uid=1) + self.account = sandbox_account( + imap_port=self.imap.port, sent_folder="INBOX.Sent" + ) + self.store = self.temp_store(self.account) + self.transport = self.imap_transport(self.imap, self.account) + self.msg = smtp_send.build_message( + self.account, + ["alice@example.org"], + "Copie classée", + "Bonjour", + bcc=["cache@example.org"], + date="Fri, 01 Aug 2026 10:41:00 +0000", + msgid="", + ) + + def file_the_copy(self): + """Exactement ce que fait `deliver()` dans `tui.py` : `without_bcc` + puis APPEND, puis une sync ciblée.""" + from script.todo.mail.imap_sync import Syncer + from script.todo.mail.smtp_send import without_bcc + + self.transport.append( + self.account.sent_folder, + without_bcc(self.msg).as_bytes(), + ["\\Seen"], + ) + return Syncer(self.store, self.transport).sync_one( + self.account.sent_folder + ) + + def test_the_server_accepted_the_append(self): + self.file_the_copy() + self.assertEqual(len(self.sent_box.appended), 1) + + def test_the_copy_the_server_stored_carries_no_bcc(self): + """Assertion sur les octets DÉPOSÉS, pas sur notre objet en mémoire : + c'est la seconde porte par laquelle le Cci pourrait fuir.""" + self.file_the_copy() + stored, _flags = self.sent_box.appended[0] + self.assertNotIn(b"cache@example.org", stored) + self.assertNotIn(b"X-ERPLibre-Bcc", stored) + + def test_the_flags_we_asked_for_reached_the_server(self): + self.file_the_copy() + _stored, flags = self.sent_box.appended[0] + self.assertIn("\\Seen", flags) + + def test_the_copy_shows_up_in_the_list(self): + report = self.file_the_copy() + self.assertEqual(report.errors, []) + state = self.store.folder_state("INBOX.Sent") + subjects = {m.subject for m in self.store.list_messages(state["id"])} + self.assertIn("Copie classée", subjects) + + def test_the_uid_comes_from_the_server(self): + """La boîte contenait déjà un message : la copie doit porter l'UID 2, + celui que le serveur a attribué.""" + self.file_the_copy() + state = self.store.folder_state("INBOX.Sent") + stored = { + m.subject: m.uid for m in self.store.list_messages(state["id"]) + } + self.assertEqual(stored["Copie classée"], 2) + + +@requires_servers +class TestFolderListing(MailSandboxCase): + """Le risque couvert : les noms de dossiers viennent d'une réponse LIST + réelle, avec son délimiteur, ses guillemets et son UTF-7 modifié, pas + d'une chaîne que le test aurait écrite dans le format qui l'arrange. + + C'est ce que le bac à sable donne gratuitement : Twisted encode de + lui-même le nom accentué en UTF-7 modifié (RFC 3501), un aller-retour + qu'aucun double n'avait jamais fait faire à `decode_mailbox`. + """ + + ACCENTED = "INBOX.Courriels envoyés" + + def setUp(self): + self.imap = self.imap_server() + for name in ("INBOX", "INBOX.Sent", self.ACCENTED): + self.imap.folder(name).deliver(polite(1), uid=1) + self.transport = self.imap_transport(self.imap) + + def test_every_folder_is_listed(self): + self.assertEqual(len(self.transport.list_folders()), 3) + + def test_the_accented_name_arrives_in_modified_utf7(self): + """Le nom BRUT est celui du fil — c'est lui qu'il faudra renvoyer au + serveur pour sélectionner le dossier, pas sa version lisible.""" + names = {f.name for f in self.transport.list_folders()} + self.assertIn("INBOX.Courriels envoy&AOk-s", names) + + def test_the_accented_name_is_decoded_for_display(self): + display = {f.name: f.display for f in self.transport.list_folders()} + self.assertEqual(display["INBOX.Courriels envoy&AOk-s"], self.ACCENTED) + + def test_inbox_gets_its_role_from_its_name(self): + roles = {f.name: f.role for f in self.transport.list_folders()} + self.assertEqual(roles["INBOX"], "inbox") + + def test_no_special_use_role_is_announced(self): + """LACUNE ASSUMÉE — bug 2 (le dossier Envoyés annoncé par le serveur) + reste hors de portée : Twisted n'annonce que + `IMAP4REV1 NAMESPACE IDLE`, sans `SPECIAL-USE`. Implémenter + l'extension nous-mêmes reviendrait à tester notre propre supposition + sur elle — précisément l'erreur que ce bac à sable existe pour + éviter. Ce test verrouille la lacune au lieu de la maquiller : le + jour où un serveur de test l'annoncera, il échouera et rappellera + qu'il y a mieux à écrire.""" + self.assertNotIn( + "SPECIAL-USE", + str(self.transport.client.capabilities).upper(), + ) + roles = {f.role for f in self.transport.list_folders()} + self.assertEqual(roles - {"inbox"}, {None}) + + +@requires_servers +class TestSandboxLifecycle(MailSandboxCase): + """Le risque couvert : une socket d'écoute oubliée ou un fil coincé + empoisonneraient toute la suite. Ces tests-ci vérifient le bac à sable + lui-même, pas le client.""" + + def test_ports_are_ephemeral_and_distinct(self): + first, second = self.imap_server(), self.imap_server() + self.assertNotEqual(first.port, 0) + self.assertNotEqual(first.port, second.port) + + def test_a_failing_test_still_closes_its_servers(self): + """La preuve de non-fuite : on fait ÉCHOUER un test à l'intérieur du + test, et on vérifie que ses serveurs sont morts quand même.""" + ports = {} + + class Doomed(MailSandboxCase): + def runTest(inner): + ports["imap"] = inner.imap_server().port + ports["smtp"] = inner.smtp_server().port + inner.fail("échec provoqué") + + before = set(LIVE_SERVERS) + result = unittest.TestResult() + Doomed().run(result) + + self.assertEqual(len(result.failures), 1) + self.assertEqual(set(LIVE_SERVERS), before) + self.assertTrue(port_is_closed(ports["imap"])) + self.assertTrue(port_is_closed(ports["smtp"])) + + def test_an_open_client_session_does_not_survive_the_test(self): + """`stopListening` seul cesse d'ACCEPTER : une session cliente restée + ouverte garderait un descripteur vivant d'un test à l'autre.""" + ports = {} + + class LeavesAClientBehind(MailSandboxCase): + def runTest(inner): + sandbox = inner.imap_server() + sandbox.folder("INBOX") + ports["imap"] = sandbox.port + inner.imap_transport(sandbox) # jamais fermée par le test + + result = unittest.TestResult() + LeavesAClientBehind().run(result) + self.assertEqual(result.errors, []) + self.assertTrue(port_is_closed(ports["imap"])) + + def test_only_one_reactor_thread_exists(self): + """Le cœur du choix de conception : le réacteur Twisted ne se + redémarre pas, donc il n'y en a qu'UN pour toute la session, quel que + soit le nombre de serveurs démarrés.""" + self.imap_server() + self.imap_server() + reactors = [ + th + for th in threading.enumerate() + if th.name == "mail-sandbox-reactor" + ] + self.assertEqual(len(reactors), 1) + + def test_smtp_threads_do_not_pile_up(self): + """`aiosmtpd` porte sa propre boucle asyncio dans un fil : chaque + serveur arrêté doit rendre le sien.""" + before = threading.active_count() + for _ in range(3): + self.smtp_server().stop() + self.assertLessEqual(threading.active_count(), before) + + +@requires_servers +class TestSandboxServesWhatWasDeclared(MailSandboxCase): + """Le bac à sable n'est utile que s'il ne réécrit rien. Si ces + assertions-là tombent, tous les autres tests de ce fichier ne prouvent + plus rien sur le vrai réseau.""" + + def selected(self, imap, folder="INBOX"): + transport = self.imap_transport(imap) + transport.select(folder) + return transport + + def test_a_polite_message_goes_through_twisteds_own_path(self): + """Par défaut le serveur est POLI : les en-têtes ASCII sont formatés + par Twisted, pas par nous (voir `SandboxIMAP4Server.spew_body`).""" + imap = self.imap_server() + imap.folder("INBOX").deliver(polite(1, "Devis"), uid=1) + headers = self.selected(imap).fetch_headers([1]) + self.assertEqual(headers[0].subject, "Devis") + self.assertEqual(headers[0].msgid, "") + + def test_flags_and_size_come_from_the_server(self): + imap = self.imap_server() + raw = polite(1) + imap.folder("INBOX").deliver(raw, flags=["\\Seen"], uid=1) + headers = self.selected(imap).fetch_headers([1]) + self.assertIn("\\Seen", headers[0].flags) + self.assertEqual(headers[0].size, len(raw)) + + def test_the_date_is_parsed_from_the_served_header(self): + imap = self.imap_server() + imap.folder("INBOX").deliver(polite(1), uid=1) + headers = self.selected(imap).fetch_headers([1]) + expected = int( + email.utils.parsedate_to_datetime( + "Wed, 06 Aug 2026 10:01:00 +0000" + ).timestamp() + ) + self.assertEqual(headers[0].date, expected) + + def test_only_the_requested_uids_come_back(self): + imap = self.imap_server() + for uid in (1, 2, 3): + imap.folder("INBOX").deliver(polite(uid), uid=uid) + headers = self.selected(imap).fetch_headers([2]) + self.assertEqual([h.uid for h in headers], [2]) + + def test_lf_only_line_endings_are_still_split_correctly(self): + """`EmailMessage.as_bytes()` — ce que le client dépose par APPEND — + termine ses lignes en LF SEUL, alors que le fil IMAP est en CRLF. + + Le message commence par un `Received` que le client ne demande pas, + comme tout message ayant traversé un relais. Un découpage qui + n'attendrait que du CRLF verrait UNE seule ligne, nommée `Received`, + donc hors du filtre : le bloc rendu serait VIDE, sans rien lever. + L'octet 8 bits dans `From` force le chemin verbatim, le seul où ce + découpage sert. + """ + imap = self.imap_server() + raw = ( + "Received: from relais.example.org by example.ca; " + "Wed, 06 Aug 2026 10:01:00 +0000\n" + "From: Ren\xe9 \n" + "To: moi@example.ca\n" + "Subject: Devis\n" + "Date: Wed, 06 Aug 2026 10:01:00 +0000\n" + "Message-ID: \n" + "\n" + "Corps.\n" + ).encode("latin-1") + imap.folder("INBOX").deliver(raw, uid=1) + headers = self.selected(imap).fetch_headers([1]) + self.assertEqual(headers[0].msgid, "") + self.assertEqual(headers[0].subject, "Devis") + self.assertIn("rene@example.org", headers[0].frm) + + def test_search_only_returns_uids_at_or_above_the_mark(self): + imap = self.imap_server() + for uid in (1, 2, 3): + imap.folder("INBOX").deliver(polite(uid), uid=uid) + self.assertEqual(self.selected(imap).search_uids(2), [2, 3]) + + def test_store_flags_reaches_the_server(self): + imap = self.imap_server() + box = imap.folder("INBOX") + box.deliver(polite(1), uid=1) + transport = self.selected(imap) + transport.store_flags(1, ["\\Seen"], []) + self.assertEqual(transport.fetch_flags([1]), [(1, "\\Seen")]) + self.assertEqual(box.messages[0].flags, ["\\Seen"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_mail_menu.py b/test/test_mail_menu.py new file mode 100644 index 0000000..593f7e8 --- /dev/null +++ b/test/test_mail_menu.py @@ -0,0 +1,821 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import json +import logging +import os +import tempfile +import unittest +from pathlib import Path + +from script.todo.mail.accounts import account_from_preset +from script.todo.mail.menu import cache_summary +from script.todo.mail.store import Store + + +class TestCacheSummary(unittest.TestCase): + # Sans injection, `resolve_mode` retombe sur les VRAIES préférences de la + # machine, et `todo_prefs` crée `~/.erplibre` au passage. + CLEAR = staticmethod(lambda k, d=None: "clear") + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.base = Path(self.tmp.name) + self.accounts = [ + account_from_preset("perso", "a@x.ca", "generic"), + account_from_preset("travail", "b@x.ca", "generic"), + ] + + def tearDown(self): + self.tmp.cleanup() + + def test_one_row_per_account(self): + rows = cache_summary( + self.accounts, base=self.base, prefs_get=self.CLEAR + ) + self.assertEqual([r["name"] for r in rows], ["perso", "travail"]) + + def test_reports_the_effective_mode(self): + self.accounts[0].cache_mode = "encrypted" + rows = cache_summary( + self.accounts, base=self.base, prefs_get=lambda k, d=None: "clear" + ) + self.assertEqual(rows[0]["mode"], "encrypted") + self.assertEqual(rows[1]["mode"], "clear") + + def test_size_is_zero_before_any_sync(self): + rows = cache_summary( + self.accounts, base=self.base, prefs_get=self.CLEAR + ) + self.assertEqual(rows[0]["size"], 0) + + def test_size_grows_with_the_cache(self): + store = Store(self.accounts[0], mode="clear", base=self.base) + store.open() + store.upsert_folder("INBOX") + store.write_body("INBOX", 1, b"x" * 4096) + store.close() + rows = cache_summary( + self.accounts, base=self.base, prefs_get=self.CLEAR + ) + self.assertGreater(rows[0]["size"], 4000) + + def test_missing_cache_does_not_raise(self): + rows = cache_summary( + self.accounts, + base=self.base / "inexistant", + prefs_get=self.CLEAR, + ) + self.assertEqual(len(rows), 2) + + +class TestAddAccountRollsBack(unittest.TestCase): + """Une sauvegarde ratée ne doit pas laisser le mot de passe dans le coffre.""" + + def test_failed_save_removes_the_orphan_secret(self): + from unittest.mock import MagicMock, patch + + import script.todo.mail.menu as menu + + vault = MagicMock() + vault.available_backends.return_value = ["kdbx"] + with patch.object( + menu, "secret_store_for", return_value=vault + ), patch.object( + menu.mail_accounts, "save", side_effect=OSError("disque plein") + ), patch.object( + menu, "_load_accounts", return_value=[] + ), patch( + "builtins.input", + side_effect=[ + "perso", + "moi@x.ca", + "", + "4", + "imap.x.ca", + "smtp.x.ca", + ], + ), patch( + "getpass.getpass", return_value="hunter2" + ): + menu._add_account(MagicMock()) + + vault.set.assert_called_once() + vault.delete.assert_called_once_with("kdbx:ERPLibre/Mail/perso") + + def test_failed_save_does_not_crash_the_menu(self): + from unittest.mock import MagicMock, patch + + import script.todo.mail.menu as menu + + vault = MagicMock() + vault.available_backends.return_value = ["kdbx"] + with patch.object( + menu, "secret_store_for", return_value=vault + ), patch.object( + menu.mail_accounts, "save", side_effect=OSError("disque plein") + ), patch.object( + menu, "_load_accounts", return_value=[] + ), patch( + "builtins.input", + side_effect=[ + "perso", + "moi@x.ca", + "", + "4", + "imap.x.ca", + "smtp.x.ca", + ], + ), patch( + "getpass.getpass", return_value="hunter2" + ): + menu._add_account(MagicMock()) # ne doit pas lever + + +class TestOpenTuiAllowsEmptyAccounts(unittest.TestCase): + """Le TUI sait désormais créer un compte depuis son propre écran : le + refus historique de s'ouvrir sans compte (`mail_no_account`) défait + exactement la fonctionnalité que cette tâche ajoute.""" + + def test_opens_with_zero_accounts_instead_of_refusing(self): + from unittest.mock import MagicMock, patch + + import script.todo.mail.menu as menu + + todo = MagicMock() + with patch.object(menu, "_load_accounts", return_value=[]), patch( + "script.todo.mail.tui.open_sessions", return_value=[] + ) as mock_open, patch("script.todo.mail.tui.run_tui") as mock_run: + menu._open_tui(todo) + + mock_open.assert_called_once() + mock_run.assert_called_once() + + def test_passes_config_file_and_the_secret_store_through(self): + """Sans ça, l'écran d'ajout de compte du TUI n'aurait ni où écrire + le chemin du kdbx, ni de coffre pour y déposer le mot de passe.""" + from unittest.mock import MagicMock, patch + + import script.todo.mail.menu as menu + + todo = MagicMock() + secrets = MagicMock() + with patch.object( + menu, "_load_accounts", return_value=[] + ), patch.object(menu, "secret_store_for", return_value=secrets), patch( + "script.todo.mail.tui.open_sessions", return_value=[] + ), patch( + "script.todo.mail.tui.run_tui" + ) as mock_run: + menu._open_tui(todo) + + _, kwargs = mock_run.call_args + self.assertIs(kwargs["config_file"], todo.config_file) + self.assertIs(kwargs["secret_store"], secrets) + + +class TestSyncNowSurfacesResync(unittest.TestCase): + """`report.purged` (dossiers vidés car l'UIDVALIDITY a changé) doit + atteindre l'utilisateur — il ne suffit pas qu'il soit calculé et testé + dans `imap_sync.py`, encore faut-il qu'un appelant l'affiche.""" + + def test_purged_folders_are_printed(self): + import io + from contextlib import redirect_stdout + from types import SimpleNamespace + from unittest.mock import MagicMock, patch + + import script.todo.mail.menu as menu + + account = account_from_preset("perso", "a@x.ca", "generic") + + class FakeSession: + def __init__(self): + self.account = account + self.online = True + self.error = "" + + def sync(self): + return SimpleNamespace( + new_messages=1, errors=[], purged=["INBOX"] + ) + + def close(self): + pass + + buf = io.StringIO() + with patch.object( + menu, "_load_accounts", return_value=[account] + ), patch( + "script.todo.mail.tui.open_sessions", + return_value=[FakeSession()], + ), redirect_stdout( + buf + ): + menu._sync_now(MagicMock()) + + self.assertIn("INBOX", buf.getvalue()) + + +class TestMailLogFile(unittest.TestCase): + """Aucun gestionnaire n'existait nulle part dans `script/todo/mail/` + avant ce correctif : les modules journalisent (`_logger.exception(...)`), + mais brancher un gestionnaire est le travail de L'APPLICATION — ici, + `prompt_execute_mail`, le seul point d'entrée du paquet. Jamais vers la + console : Textual possède le terminal pendant tout le TUI. + """ + + def setUp(self): + self.fake_home = tempfile.TemporaryDirectory() + self._old_home = os.environ.get("HOME") + os.environ["HOME"] = self.fake_home.name + + def tearDown(self): + import script.todo.mail.menu as menu + + logger = logging.getLogger("script.todo.mail") + for handler in list(logger.handlers): + logger.removeHandler(handler) + handler.close() + logger.propagate = True + menu._LOG_CONFIGURED = False + + if self._old_home is None: + os.environ.pop("HOME", None) + else: + os.environ["HOME"] = self._old_home + self.fake_home.cleanup() + + def test_creates_the_log_file_under_home(self): + import script.todo.mail.menu as menu + + menu._configure_mail_logging() + logging.getLogger("script.todo.mail.tui").error("boum") + + log_path = Path(self.fake_home.name) / ".erplibre" / "mail.log" + self.assertTrue(log_path.exists()) + self.assertIn("boum", log_path.read_text()) + + def test_is_idempotent(self): + import script.todo.mail.menu as menu + + menu._configure_mail_logging() + menu._configure_mail_logging() + + logger = logging.getLogger("script.todo.mail") + file_handlers = [ + h for h in logger.handlers if isinstance(h, logging.FileHandler) + ] + self.assertEqual(len(file_handlers), 1) + + def test_never_installs_a_console_handler(self): + import script.todo.mail.menu as menu + + menu._configure_mail_logging() + + logger = logging.getLogger("script.todo.mail") + non_file_stream_handlers = [ + h + for h in logger.handlers + if isinstance(h, logging.StreamHandler) + and not isinstance(h, logging.FileHandler) + ] + self.assertEqual(non_file_stream_handlers, []) + + def test_never_leaks_to_the_console_via_root_propagation(self): + """`script/todo/todo.py:68` calls `logging.basicConfig()` at + import time, which installs a `StreamHandler` on the ROOT logger. + `propagate` defaults to `True`: without disabling it explicitly, + every `_logger.exception(...)` in the mail package would ALSO + reach that root handler — straight onto the terminal Textual owns + for the whole TUI. `test_never_installs_a_console_handler` above + cannot catch this: it only inspects `script.todo.mail`'s OWN + handlers, never what a PARENT logger does with a propagated + record. + + Reproduced for real in the full suite: this exact leak showed up, + unprompted, in the middle of `unittest`'s dotted progress output + the first time the mail tests ran in a process where + `script.todo.todo` (and its `basicConfig`) had already been + imported by an earlier test file. + """ + import io + + import script.todo.mail.menu as menu + import script.todo.todo # noqa: F401 - installe le handler racine + + root = logging.getLogger() + buf = io.StringIO() + capture = logging.StreamHandler(buf) + root.addHandler(capture) + try: + menu._configure_mail_logging() + logging.getLogger("script.todo.mail.tui").error("ne doit pas fuir") + finally: + root.removeHandler(capture) + + self.assertEqual(buf.getvalue(), "") + + def test_prompt_execute_mail_configures_logging_before_the_loop(self): + """`prompt_execute_mail` est le seul point d'entrée du paquet + `mail` : c'est là, et nulle part ailleurs, que le gestionnaire doit + être branché.""" + from unittest.mock import MagicMock, patch + + import script.todo.mail.menu as menu + + with patch("click.prompt", return_value="0"): + menu.prompt_execute_mail(MagicMock()) + + logger = logging.getLogger("script.todo.mail") + self.assertTrue( + any(isinstance(h, logging.FileHandler) for h in logger.handlers) + ) + + +class TestCacheSizeAndPurge(unittest.TestCase): + """`_cache_size_and_purge` doit survivre à un coffre absent et à un + cache corrompu — les deux tuaient tout le CLI avant ce correctif. + + `Store(account)` sans `base` retombe sur `~/.erplibre/mail` : on détourne + `$HOME`, comme `TestComposeScreenMounted` (test_mail_compose.py), plutôt + que d'ajouter un paramètre `base` qu'aucun appelant réel n'utilise. + """ + + def setUp(self): + self.fake_home = tempfile.TemporaryDirectory() + self._old_home = os.environ.get("HOME") + os.environ["HOME"] = self.fake_home.name + + def tearDown(self): + if self._old_home is None: + os.environ.pop("HOME", None) + else: + os.environ["HOME"] = self._old_home + self.fake_home.cleanup() + + def test_purges_a_healthy_encrypted_account_without_raising(self): + from unittest.mock import MagicMock, patch + + import script.todo.mail.menu as menu + + account = account_from_preset("perso", "a@x.ca", "generic") + account.cache_mode = "encrypted" + vault = {} + secrets = MagicMock() + secrets.get.side_effect = vault.get + secrets.set.side_effect = lambda ref, value: vault.__setitem__( + ref, value + ) + + with patch.object( + menu, "_load_accounts", return_value=[account] + ), patch.object(menu, "secret_store_for", return_value=secrets), patch( + "builtins.input", side_effect=["1", "o"] + ): + menu._cache_size_and_purge(MagicMock()) # ne doit pas lever + + def test_corrupted_cache_is_removed_from_disk_instead_of_crashing(self): + from unittest.mock import MagicMock, patch + + import script.todo.mail.menu as menu + + account = account_from_preset("perso", "a@x.ca", "generic") + store = Store(account) + store.root.mkdir(parents=True, exist_ok=True) + (store.root / "cache.db").write_bytes(b"pas une base sqlite" * 50) + + with patch.object( + menu, "_load_accounts", return_value=[account] + ), patch.object( + menu, "secret_store_for", return_value=MagicMock() + ), patch( + "builtins.input", side_effect=["1", "o"] + ): + menu._cache_size_and_purge(MagicMock()) # ne doit pas lever + + self.assertFalse(store.root.exists()) + + +class TestEnsureKdbx(unittest.TestCase): + """`_ensure_kdbx` doit tenir la promesse de la conception (lignes + 204-207 du design) : créer un nouveau kdbx ou en choisir un existant + quand aucun n'est configuré. Un vrai `ConfigFile`, pointé vers un + dossier temporaire, sert de `todo.config_file` : on veut vérifier que + `set_config_value` est réellement câblé, pas seulement appelé sur un + mock. + """ + + def setUp(self): + from types import SimpleNamespace + from unittest.mock import patch as mock_patch + + from script.config.config_file import ConfigFile + + self.tmp = tempfile.TemporaryDirectory() + self.private_path = os.path.join( + self.tmp.name, "private", "todo", "todo_override_private.json" + ) + # Rejoue la forme du vrai `script/todo/todo.json`, qui déclare déjà + # une section "kdbx" avec path/password vides (ce squelette existe + # justement pour que `get_config_value(["kdbx", "path"])` renvoie + # toujours une chaîne, jamais None, quand rien n'est configuré) — + # sans quoi l'absence totale de fichiers ferait planter + # `get_config_value` (`"path" in None`), un cas que la vraie + # configuration versionnée n'expose jamais. + base_path = os.path.join(self.tmp.name, "base.json") + with open(base_path, "w") as f: + json.dump({"kdbx": {"path": "", "password": ""}}, f) + self.patchers = [ + mock_patch( + "script.config.config_file.CONFIG_OVERRIDE_PRIVATE_FILE", + self.private_path, + ), + mock_patch( + "script.config.config_file.CONFIG_FILE", + base_path, + ), + mock_patch( + "script.config.config_file.CONFIG_OVERRIDE_FILE", + os.path.join(self.tmp.name, "nonexistent_override.json"), + ), + ] + for patcher in self.patchers: + patcher.start() + self.todo = SimpleNamespace(config_file=ConfigFile()) + + def tearDown(self): + for patcher in self.patchers: + patcher.stop() + self.tmp.cleanup() + + def test_skips_the_prompt_when_already_configured(self): + from unittest.mock import patch as mock_patch + + import script.todo.mail.menu as menu + + self.todo.config_file.set_config_value( + ["kdbx", "path"], "/already/there.kdbx" + ) + with mock_patch("builtins.input") as mock_input: + result = menu._ensure_kdbx(self.todo) + self.assertTrue(result) + mock_input.assert_not_called() + + def test_create_writes_a_real_kdbx_and_records_its_path(self): + from unittest.mock import patch as mock_patch + + import script.todo.mail.menu as menu + + kdbx_path = os.path.join(self.tmp.name, "new.kdbx") + with mock_patch( + "builtins.input", side_effect=["1", kdbx_path] + ), mock_patch("getpass.getpass", side_effect=["hunter2", "hunter2"]): + result = menu._ensure_kdbx(self.todo) + self.assertTrue(result) + self.assertTrue(os.path.isfile(kdbx_path)) + self.assertEqual( + self.todo.config_file.get_config_value(["kdbx", "path"]), + kdbx_path, + ) + + def test_mismatched_passwords_are_refused_and_nothing_is_created(self): + from unittest.mock import patch as mock_patch + + import script.todo.mail.menu as menu + + kdbx_path = os.path.join(self.tmp.name, "new.kdbx") + with mock_patch( + "builtins.input", side_effect=["1", kdbx_path] + ), mock_patch( + "getpass.getpass", side_effect=["hunter2", "somethingelse"] + ): + result = menu._ensure_kdbx(self.todo) + self.assertFalse(result) + self.assertFalse(os.path.exists(kdbx_path)) + # Le squelette du vrai `todo.json` donne "" (pas None) tant que + # rien n'a été configuré — c'est la valeur falsy que teste + # `_ensure_kdbx`, peu importe sa forme exacte. + self.assertFalse( + self.todo.config_file.get_config_value(["kdbx", "path"]) + ) + + def test_choosing_a_nonexistent_file_is_refused(self): + from unittest.mock import patch as mock_patch + + import script.todo.mail.menu as menu + + missing_path = os.path.join(self.tmp.name, "nope.kdbx") + with mock_patch("builtins.input", side_effect=["2", missing_path]): + result = menu._ensure_kdbx(self.todo) + self.assertFalse(result) + # Le squelette du vrai `todo.json` donne "" (pas None) tant que + # rien n'a été configuré — c'est la valeur falsy que teste + # `_ensure_kdbx`, peu importe sa forme exacte. + self.assertFalse( + self.todo.config_file.get_config_value(["kdbx", "path"]) + ) + + def test_choosing_an_existing_file_records_its_path(self): + from unittest.mock import patch as mock_patch + + import script.todo.mail.menu as menu + + existing_path = os.path.join(self.tmp.name, "existing.kdbx") + Path(existing_path).write_bytes(b"not a real kdbx, just a file") + with mock_patch("builtins.input", side_effect=["2", existing_path]): + result = menu._ensure_kdbx(self.todo) + self.assertTrue(result) + self.assertEqual( + self.todo.config_file.get_config_value(["kdbx", "path"]), + existing_path, + ) + + def test_cancel_creates_neither_file_nor_account(self): + from unittest.mock import patch as mock_patch + + import script.todo.mail.menu as menu + + with mock_patch.object( + menu.mail_accounts, "save" + ) as mock_save, mock_patch("builtins.input", side_effect=["0"]): + menu._add_account(self.todo) + mock_save.assert_not_called() + # Le squelette du vrai `todo.json` donne "" (pas None) tant que + # rien n'a été configuré — c'est la valeur falsy que teste + # `_ensure_kdbx`, peu importe sa forme exacte. + self.assertFalse( + self.todo.config_file.get_config_value(["kdbx", "path"]) + ) + + +class TestMailKeysAreTranslated(unittest.TestCase): + """Le seul filet contre une clé oubliée. + + `t()` rend la clé elle-même quand elle est absente : rien n'échoue, et + l'interface affiche « mail_body_error » en toutes lettres à l'utilisateur. + Aucun autre test ne peut attraper ça. + """ + + def test_every_key_used_in_the_mail_package_is_declared(self): + import re + from pathlib import Path + + import script.todo.mail as mail_pkg + from script.todo.todo_i18n import TRANSLATIONS + + pattern = re.compile(r"""(?" + + +def account(): + return account_from_preset( + "perso", "moi@x.ca", "generic", display_name="Mathieu Benoit" + ) + + +def original(subject="Devis", frm="Alice ", to="moi@x.ca", cc=""): + raw = ( + f"From: {frm}\r\nTo: {to}\r\n" + + (f"Cc: {cc}\r\n" if cc else "") + + f"Subject: {subject}\r\n" + f"Message-ID: \r\n" + f"Date: {FIXED_DATE}\r\n\r\nLe corps d'origine.\r\n" + ) + return email.message_from_string(raw) + + +class FakeSmtp: + def __init__(self, fail=False): + self.sent = [] + self.quit_called = False + self.fail = fail + + def send_message(self, msg, from_addr, to_addrs): + if self.fail: + raise OSError("550 destinataire refusé") + self.sent.append((msg, from_addr, list(to_addrs))) + + def quit(self): + self.quit_called = True + + +class TestBuildMessage(unittest.TestCase): + def setUp(self): + self.acc = account() + + def build(self, **kw): + kw.setdefault("date", FIXED_DATE) + kw.setdefault("msgid", FIXED_MSGID) + return build_message(self.acc, "alice@y.ca", "Devis", "Bonjour", **kw) + + def test_from_uses_display_name(self): + self.assertEqual(self.build()["From"], "Mathieu Benoit ") + + def test_from_without_display_name(self): + acc = account_from_preset("perso", "moi@x.ca", "generic") + msg = build_message( + acc, "a@y.ca", "S", "B", date=FIXED_DATE, msgid=FIXED_MSGID + ) + self.assertEqual(msg["From"], "moi@x.ca") + + def test_to_and_subject(self): + msg = self.build() + self.assertEqual(msg["To"], "alice@y.ca") + self.assertEqual(msg["Subject"], "Devis") + + def test_body(self): + self.assertIn("Bonjour", self.build().get_content()) + + def test_accented_subject_survives(self): + msg = build_message( + self.acc, + "a@y.ca", + "Devis révisé", + "B", + date=FIXED_DATE, + msgid=FIXED_MSGID, + ) + reparsed = email.message_from_bytes(msg.as_bytes()) + from email.header import decode_header + + # RFC 2047 permet d'encoder seulement le segment non-ASCII d'un + # en-tête ; `decode_header` rend alors PLUSIEURS morceaux + # ("Devis ", puis "révisé" encodé), qu'il faut tous rejoindre pour + # retrouver le texte d'origine — ne lire que le premier le tronque. + text = "".join( + ( + chunk.decode(charset or "utf-8") + if isinstance(chunk, bytes) + else chunk + ) + for chunk, charset in decode_header(reparsed["Subject"]) + ) + self.assertEqual(text, "Devis révisé") + + def test_multiple_recipients(self): + msg = self.build(cc=["bob@y.ca", "carl@y.ca"]) + self.assertEqual(msg["Cc"], "bob@y.ca, carl@y.ca") + + def test_bcc_is_not_in_headers(self): + """Un Cci qui part dans les en-têtes n'est plus un Cci.""" + msg = self.build(bcc=["secret@y.ca"]) + self.assertIsNone(msg["Bcc"]) + + def test_bcc_is_still_a_recipient(self): + msg = self.build(bcc=["secret@y.ca"]) + self.assertIn("secret@y.ca", recipients(msg)) + + def test_message_id_present(self): + self.assertEqual(self.build()["Message-ID"], FIXED_MSGID) + + def test_generated_message_id_when_absent(self): + msg = build_message(self.acc, "a@y.ca", "S", "B", date=FIXED_DATE) + self.assertTrue(msg["Message-ID"].startswith("<")) + + def test_empty_recipient_raises(self): + with self.assertRaises(SmtpError): + build_message(self.acc, "", "S", "B") + + +class TestAttachments(unittest.TestCase): + def setUp(self): + self.acc = account() + self.tmp = tempfile.TemporaryDirectory() + self.pdf = Path(self.tmp.name) / "devis.pdf" + self.pdf.write_bytes(b"%PDF-1.4 faux") + + def tearDown(self): + self.tmp.cleanup() + + def test_message_becomes_multipart(self): + msg = build_message( + self.acc, + "a@y.ca", + "S", + "B", + attachments=[self.pdf], + date=FIXED_DATE, + msgid=FIXED_MSGID, + ) + self.assertTrue(msg.is_multipart()) + + def test_filename_is_kept(self): + msg = build_message( + self.acc, + "a@y.ca", + "S", + "B", + attachments=[self.pdf], + date=FIXED_DATE, + msgid=FIXED_MSGID, + ) + names = [p.get_filename() for p in msg.iter_attachments()] + self.assertEqual(names, ["devis.pdf"]) + + def test_content_type_is_guessed(self): + msg = build_message( + self.acc, + "a@y.ca", + "S", + "B", + attachments=[self.pdf], + date=FIXED_DATE, + msgid=FIXED_MSGID, + ) + part = next(msg.iter_attachments()) + self.assertEqual(part.get_content_type(), "application/pdf") + + def test_unknown_extension_falls_back_to_octet_stream(self): + blob = Path(self.tmp.name) / "donnees.zzz" + blob.write_bytes(b"\x00\x01") + msg = build_message( + self.acc, + "a@y.ca", + "S", + "B", + attachments=[blob], + date=FIXED_DATE, + msgid=FIXED_MSGID, + ) + part = next(msg.iter_attachments()) + self.assertEqual(part.get_content_type(), "application/octet-stream") + + def test_missing_file_raises(self): + with self.assertRaises(SmtpError): + build_message( + self.acc, + "a@y.ca", + "S", + "B", + attachments=[Path(self.tmp.name) / "absent.pdf"], + date=FIXED_DATE, + msgid=FIXED_MSGID, + ) + + def test_body_still_readable(self): + msg = build_message( + self.acc, + "a@y.ca", + "S", + "Bonjour Alice", + attachments=[self.pdf], + date=FIXED_DATE, + msgid=FIXED_MSGID, + ) + self.assertIn("Bonjour Alice", msg.get_body(("plain",)).get_content()) + + +class TestReply(unittest.TestCase): + def setUp(self): + self.acc = account() + + def reply(self, orig=None, **kw): + kw.setdefault("date", FIXED_DATE) + kw.setdefault("msgid", FIXED_MSGID) + return build_reply(self.acc, orig or original(), "Ma réponse", **kw) + + def test_subject_gets_re_prefix(self): + self.assertEqual(self.reply()["Subject"], "Re: Devis") + + def test_subject_not_prefixed_twice(self): + self.assertEqual( + self.reply(original(subject="Re: Devis"))["Subject"], "Re: Devis" + ) + + def test_existing_re_case_insensitive(self): + self.assertEqual( + self.reply(original(subject="RE: Devis"))["Subject"], "RE: Devis" + ) + + def test_recipient_is_the_sender(self): + self.assertEqual(self.reply()["To"], "Alice ") + + def test_reply_to_header_wins(self): + orig = original() + orig["Reply-To"] = "equipe@y.ca" + self.assertEqual(self.reply(orig)["To"], "equipe@y.ca") + + def test_in_reply_to(self): + self.assertEqual(self.reply()["In-Reply-To"], "") + + def test_references_starts_the_chain(self): + self.assertEqual(self.reply()["References"], "") + + def test_references_extends_the_chain(self): + orig = original() + orig["References"] = " " + self.assertEqual( + self.reply(orig)["References"], + " ", + ) + + def test_reply_all_adds_the_others(self): + orig = original(to="moi@x.ca, bob@y.ca", cc="carl@y.ca") + msg = self.reply(orig, reply_all=True) + joined = f"{msg['To']} {msg['Cc']}" + self.assertIn("bob@y.ca", joined) + self.assertIn("carl@y.ca", joined) + + def test_reply_all_drops_my_own_address(self): + orig = original(to="moi@x.ca, bob@y.ca") + msg = self.reply(orig, reply_all=True) + self.assertNotIn("moi@x.ca", f"{msg['To']} {msg['Cc'] or ''}") + + def test_original_is_quoted(self): + self.assertIn("> Le corps d'origine.", self.reply().get_content()) + + def test_survives_an_unrecognised_charset(self): + """Un charset mal étiqueté ne doit pas faire tomber la réponse. + + `str.decode(charset, "replace")` lève `LookupError` si le codec est + inconnu : l'erreur survient à la recherche du codec, AVANT que + `errors="replace"` ne serve. Un seul message mal étiqueté ne doit + pas empêcher d'y répondre. + """ + raw = ( + "From: Alice \r\nTo: moi@x.ca\r\n" + "Subject: Devis\r\nMessage-ID: \r\n" + f"Date: {FIXED_DATE}\r\n" + "Content-Type: text/plain; charset=bogus-charset-xyz\r\n\r\n" + "Le corps d'origine.\r\n" + ) + orig = email.message_from_string(raw) + msg = self.reply(orig) + self.assertIn("Le corps d'origine.", msg.get_content()) + + def test_survives_unknown_8bit(self): + """Étiquette réelle observée en usage, pas seulement un charset + inventé (voir `script/todo/mail/charset.py`).""" + raw = ( + "From: Alice \r\nTo: moi@x.ca\r\n" + "Subject: Devis\r\nMessage-ID: \r\n" + f"Date: {FIXED_DATE}\r\n" + "Content-Type: text/plain; charset=unknown-8bit\r\n\r\n" + "Le corps d'origine.\r\n" + ) + orig = email.message_from_string(raw) + msg = self.reply(orig) + self.assertIn("Le corps d'origine.", msg.get_content()) + + +class TestForward(unittest.TestCase): + def setUp(self): + self.acc = account() + + def forward(self, **kw): + kw.setdefault("date", FIXED_DATE) + kw.setdefault("msgid", FIXED_MSGID) + return build_forward( + self.acc, original(), "bob@z.ca", "Pour info", **kw + ) + + def test_subject_gets_fwd_prefix(self): + self.assertEqual(self.forward()["Subject"], "Fwd: Devis") + + def test_recipient(self): + self.assertEqual(self.forward()["To"], "bob@z.ca") + + def test_original_attached_as_rfc822(self): + types = [ + p.get_content_type() for p in self.forward().iter_attachments() + ] + self.assertIn("message/rfc822", types) + + def test_no_in_reply_to(self): + """Transférer n'est pas répondre : le fil ne doit pas se greffer.""" + self.assertIsNone(self.forward()["In-Reply-To"]) + + +class TestRecipients(unittest.TestCase): + def test_collects_to_cc_and_bcc(self): + msg = build_message( + account(), + "a@y.ca", + "S", + "B", + cc=["b@y.ca"], + bcc=["c@y.ca"], + date=FIXED_DATE, + msgid=FIXED_MSGID, + ) + self.assertEqual( + sorted(recipients(msg)), ["a@y.ca", "b@y.ca", "c@y.ca"] + ) + + def test_strips_display_names(self): + msg = build_message( + account(), + "Alice ", + "S", + "B", + date=FIXED_DATE, + msgid=FIXED_MSGID, + ) + self.assertEqual(recipients(msg), ["a@y.ca"]) + + def test_deduplicates(self): + msg = build_message( + account(), + "a@y.ca", + "S", + "B", + cc=["a@y.ca"], + date=FIXED_DATE, + msgid=FIXED_MSGID, + ) + self.assertEqual(recipients(msg), ["a@y.ca"]) + + +class TestSend(unittest.TestCase): + def setUp(self): + self.acc = account() + self.msg = build_message( + self.acc, + "a@y.ca", + "S", + "B", + bcc=["c@y.ca"], + date=FIXED_DATE, + msgid=FIXED_MSGID, + ) + + def test_passes_envelope_from_and_recipients(self): + smtp = FakeSmtp() + served = send(self.acc, self.msg, smtp) + _, from_addr, to_addrs = smtp.sent[0] + self.assertEqual(from_addr, "moi@x.ca") + self.assertEqual(sorted(to_addrs), ["a@y.ca", "c@y.ca"]) + self.assertEqual(sorted(served), ["a@y.ca", "c@y.ca"]) + + def test_failure_is_wrapped(self): + with self.assertRaises(SmtpError): + send(self.acc, self.msg, FakeSmtp(fail=True)) + + def test_failure_message_keeps_the_server_wording(self): + with self.assertRaises(SmtpError) as ctx: + send(self.acc, self.msg, FakeSmtp(fail=True)) + self.assertIn("550", str(ctx.exception)) + + def test_no_recipient_raises_before_the_network(self): + msg = build_message( + self.acc, "a@y.ca", "S", "B", date=FIXED_DATE, msgid=FIXED_MSGID + ) + del msg["To"] + smtp = FakeSmtp() + with self.assertRaises(SmtpError): + send(self.acc, msg, smtp) + self.assertEqual(smtp.sent, []) + + +class TestWithoutBcc(unittest.TestCase): + """La copie qui part vers Envoyés emprunte IMAP, pas SMTP : elle doit + être assainie elle aussi, sinon le Cci est lisible sur le serveur.""" + + def setUp(self): + self.msg = build_message( + account(), + "a@y.ca", + "Devis", + "Bonjour", + bcc=["secret@y.ca"], + date=FIXED_DATE, + msgid=FIXED_MSGID, + ) + + def test_copy_has_no_internal_bcc_header(self): + self.assertIsNone(without_bcc(self.msg)["X-ERPLibre-Bcc"]) + + def test_bcc_address_is_absent_from_the_serialised_copy(self): + self.assertNotIn(b"secret@y.ca", without_bcc(self.msg).as_bytes()) + + def test_original_is_left_untouched(self): + without_bcc(self.msg) + self.assertIn("secret@y.ca", recipients(self.msg)) + + def test_message_without_bcc_is_returned_as_is(self): + plain = build_message( + account(), "a@y.ca", "S", "B", date=FIXED_DATE, msgid=FIXED_MSGID + ) + self.assertIs(without_bcc(plain), plain) + + def test_body_and_headers_survive(self): + copy = without_bcc(self.msg) + self.assertEqual(copy["Subject"], "Devis") + self.assertIn("Bonjour", copy.get_content()) + + +class TestConnect(unittest.TestCase): + """`connect` choisit la branche SSL/STARTTLS et convertit les erreurs. + + Aucun de ces tests ne joint le réseau : `smtplib` est remplacé. + """ + + def _account(self, security): + acc = account_from_preset("perso", "moi@x.ca", "generic") + acc.smtp.host = "smtp.x.ca" + acc.smtp.port = 465 if security == "ssl" else 587 + acc.smtp.security = security + return acc + + def test_ssl_branch(self): + client = MagicMock() + with patch("smtplib.SMTP_SSL", return_value=client) as ctor: + connect(self._account("ssl"), "hunter2") + ctor.assert_called_once_with("smtp.x.ca", 465, timeout=30) + client.login.assert_called_once_with("moi@x.ca", "hunter2") + + def test_starttls_branch_upgrades(self): + client = MagicMock() + with patch("smtplib.SMTP", return_value=client): + connect(self._account("starttls"), "hunter2") + client.starttls.assert_called_once() + + def test_plain_branch_does_not_upgrade(self): + client = MagicMock() + with patch("smtplib.SMTP", return_value=client): + connect(self._account("none"), "hunter2") + client.starttls.assert_not_called() + + def test_login_failure_becomes_an_smtp_error(self): + client = MagicMock() + client.login.side_effect = OSError("535 refus") + with patch("smtplib.SMTP_SSL", return_value=client): + with self.assertRaises(SmtpError) as ctx: + connect(self._account("ssl"), "mauvais") + self.assertIn("535", str(ctx.exception)) + + def test_connection_failure_becomes_an_smtp_error(self): + with patch("smtplib.SMTP_SSL", side_effect=OSError("injoignable")): + with self.assertRaises(SmtpError): + connect(self._account("ssl"), "hunter2") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_mail_store.py b/test/test_mail_store.py new file mode 100644 index 0000000..8f702b8 --- /dev/null +++ b/test/test_mail_store.py @@ -0,0 +1,636 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import os +import tempfile +import unittest +from pathlib import Path + +from script.todo.mail.accounts import account_from_preset +from script.todo.mail.crypto import CryptoError, new_key +from script.todo.mail.store import ( + EPHEMERAL_PREFIX, + MessageMeta, + Store, + StoreError, + cache_root, + folder_dirname, + resolve_mode, + sweep_orphan_ephemeral, +) + + +def meta(uid, subject="Sujet", frm="a@x.ca", date=1000, flags=""): + return MessageMeta( + uid=uid, + date=date, + size=42, + flags=flags, + msgid=f"<{uid}@x.ca>", + frm=frm, + to="moi@x.ca", + subject=subject, + snippet="debut du corps", + ) + + +class TestResolveMode(unittest.TestCase): + def test_account_override_wins(self): + acc = account_from_preset("perso", "a@x.ca", "generic") + acc.cache_mode = "encrypted" + self.assertEqual( + resolve_mode(acc, lambda k, d=None: "clear"), "encrypted" + ) + + def test_falls_back_to_general_default(self): + acc = account_from_preset("perso", "a@x.ca", "generic") + self.assertEqual( + resolve_mode(acc, lambda k, d=None: "ephemeral"), "ephemeral" + ) + + def test_unknown_general_default_falls_back_to_clear(self): + acc = account_from_preset("perso", "a@x.ca", "generic") + self.assertEqual( + resolve_mode(acc, lambda k, d=None: "magique"), "clear" + ) + + +class TestFolderDirname(unittest.TestCase): + def test_slash_is_escaped(self): + self.assertNotIn("/", folder_dirname("[Gmail]/Sent Mail")) + + def test_is_reversible_enough_to_be_unique(self): + self.assertNotEqual(folder_dirname("A/B"), folder_dirname("A_B")) + + def test_traversal_collapses_to_one_component(self): + self.assertNotIn("/", folder_dirname("../../etc")) + + def test_degenerate_names_cannot_designate_the_parent(self): + """`racine / ".."` remonterait d'un cran : ces noms sont réécrits.""" + for hostile in ("", ".", ".."): + self.assertNotIn(folder_dirname(hostile), ("", ".", "..")) + + def test_dotted_hierarchy_stays_readable(self): + """Le point sépare la hiérarchie chez beaucoup de serveurs IMAP.""" + self.assertEqual(folder_dirname("INBOX.Sent"), "INBOX.Sent") + + +class TestCacheRoot(unittest.TestCase): + def test_persistent_modes_use_base(self): + acc = account_from_preset("perso", "a@x.ca", "generic") + with tempfile.TemporaryDirectory() as tmp: + root = cache_root(acc, "clear", Path(tmp)) + self.assertEqual(root, Path(tmp) / "perso") + + def test_ephemeral_root_carries_the_pid(self): + acc = account_from_preset("perso", "a@x.ca", "generic") + with tempfile.TemporaryDirectory() as tmp: + root = cache_root(acc, "ephemeral", Path(tmp)) + self.assertIn(f"{EPHEMERAL_PREFIX}{os.getpid()}", str(root)) + + +class StoreCase(unittest.TestCase): + """Socle commun : un compte, une base temporaire, mode paramétrable.""" + + mode = "clear" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.account = account_from_preset("perso", "moi@x.ca", "generic") + self.key = new_key() if self.mode != "clear" else None + self.store = Store( + self.account, + mode=self.mode, + key=self.key, + base=Path(self.tmp.name), + ) + self.store.open() + + def tearDown(self): + self.store.close() + self.tmp.cleanup() + + +class TestSchema(StoreCase): + def test_db_file_created(self): + self.assertTrue((self.store.root / "cache.db").exists()) + + def test_root_is_0700(self): + import stat + + self.assertEqual(stat.S_IMODE(os.stat(self.store.root).st_mode), 0o700) + + def test_reopen_is_idempotent(self): + self.store.close() + again = Store( + self.account, + mode=self.mode, + key=self.key, + base=Path(self.tmp.name), + ) + again.open() + again.close() + + +class TestFolders(StoreCase): + def test_upsert_returns_id(self): + fid = self.store.upsert_folder("INBOX", "INBOX", "inbox", 1, 10) + self.assertIsInstance(fid, int) + + def test_upsert_twice_keeps_same_id(self): + first = self.store.upsert_folder("INBOX") + second = self.store.upsert_folder("INBOX") + self.assertEqual(first, second) + + def test_folder_state(self): + self.store.upsert_folder("INBOX", uidvalidity=7) + state = self.store.folder_state("INBOX") + self.assertEqual(state["uidvalidity"], 7) + self.assertEqual(state["last_uid"], 0) + + def test_set_folder_state(self): + self.store.upsert_folder("INBOX") + self.store.set_folder_state("INBOX", last_uid=99, unseen=3) + state = self.store.folder_state("INBOX") + self.assertEqual(state["last_uid"], 99) + self.assertEqual(state["unseen"], 3) + + def test_unknown_folder_state_is_none(self): + self.assertIsNone(self.store.folder_state("ABSENT")) + + def test_folders_lists_them(self): + self.store.upsert_folder("INBOX") + self.store.upsert_folder("Sent") + self.assertEqual( + {f["name"] for f in self.store.folders()}, {"INBOX", "Sent"} + ) + + def test_display_is_null_until_one_is_known(self): + """NULL veut dire « inconnu » : le lecteur retombe sur le nom IMAP.""" + self.store.upsert_folder("INBOX") + self.assertIsNone(self.store.folder_state("INBOX")["display"]) + + def test_partial_upsert_keeps_the_display_name(self): + """Une resync qui ne repasse que le nom IMAP ne doit rien écraser.""" + self.store.upsert_folder("INBOX", "Boîte de réception") + self.store.upsert_folder("INBOX") + self.assertEqual( + self.store.folder_state("INBOX")["display"], "Boîte de réception" + ) + + +class TestMessages(StoreCase): + def setUp(self): + super().setUp() + self.fid = self.store.upsert_folder("INBOX") + + def test_upsert_then_list(self): + self.store.upsert_messages(self.fid, [meta(1), meta(2)]) + got = self.store.list_messages(self.fid) + self.assertEqual({m.uid for m in got}, {1, 2}) + + def test_subject_survives_roundtrip(self): + self.store.upsert_messages(self.fid, [meta(1, subject="Devis révisé")]) + self.assertEqual( + self.store.list_messages(self.fid)[0].subject, "Devis révisé" + ) + + def test_upsert_same_uid_updates(self): + self.store.upsert_messages(self.fid, [meta(1, subject="ancien")]) + self.store.upsert_messages(self.fid, [meta(1, subject="nouveau")]) + got = self.store.list_messages(self.fid) + self.assertEqual(len(got), 1) + self.assertEqual(got[0].subject, "nouveau") + + def test_sorted_by_date_desc(self): + self.store.upsert_messages( + self.fid, [meta(1, date=100), meta(2, date=300), meta(3, date=200)] + ) + self.assertEqual( + [m.uid for m in self.store.list_messages(self.fid)], [2, 3, 1] + ) + + def test_update_flags(self): + self.store.upsert_messages(self.fid, [meta(1)]) + self.store.update_flags(self.fid, 1, "\\Seen") + self.assertEqual(self.store.list_messages(self.fid)[0].flags, "\\Seen") + + def test_known_uids(self): + self.store.upsert_messages(self.fid, [meta(1), meta(2), meta(3)]) + self.assertEqual(sorted(self.store.known_uids(self.fid)), [1, 2, 3]) + + def test_limit_and_offset(self): + self.store.upsert_messages( + self.fid, [meta(i, date=i) for i in range(1, 6)] + ) + self.assertEqual( + [m.uid for m in self.store.list_messages(self.fid, limit=2)], + [5, 4], + ) + self.assertEqual( + [ + m.uid + for m in self.store.list_messages(self.fid, limit=2, offset=2) + ], + [3, 2], + ) + + +class TestBodies(StoreCase): + def test_write_then_read(self): + self.store.upsert_folder("INBOX") + self.store.write_body("INBOX", 1, b"From: a@x.ca\r\n\r\nBonjour") + self.assertEqual( + self.store.read_body("INBOX", 1), b"From: a@x.ca\r\n\r\nBonjour" + ) + + def test_missing_body_is_none(self): + self.assertIsNone(self.store.read_body("INBOX", 404)) + + def test_has_body_flag_is_set(self): + fid = self.store.upsert_folder("INBOX") + self.store.upsert_messages(fid, [meta(1)]) + self.store.write_body("INBOX", 1, b"corps") + self.assertTrue(self.store.list_messages(fid)[0].has_body) + + def test_folder_with_slash(self): + self.store.upsert_folder("[Gmail]/Sent Mail") + self.store.write_body("[Gmail]/Sent Mail", 1, b"corps") + self.assertEqual( + self.store.read_body("[Gmail]/Sent Mail", 1), b"corps" + ) + + def test_no_window_at_the_process_umask(self): + """`write_bytes` puis `chmod` laisserait le corps du message lisible + à l'umask du process le temps entre les deux appels. Au moment où + `chmod` est appelé, le fichier doit déjà être en 0600.""" + import stat + from unittest.mock import patch + + self.store.upsert_folder("INBOX") + path = self.store._body_path("INBOX", 1) + seen = [] + original_chmod = os.chmod + + def spy(target, mode): + if Path(target) == path: + seen.append(stat.S_IMODE(os.stat(target).st_mode)) + return original_chmod(target, mode) + + with patch("os.chmod", side_effect=spy): + self.store.write_body("INBOX", 1, b"corps") + + self.assertEqual(seen, [0o600]) + + +class TestPurge(StoreCase): + def test_purge_folder_drops_rows_and_files(self): + fid = self.store.upsert_folder("INBOX") + self.store.upsert_messages(fid, [meta(1)]) + self.store.write_body("INBOX", 1, b"corps") + self.store.purge_folder("INBOX") + self.assertEqual(self.store.list_messages(fid), []) + self.assertIsNone(self.store.read_body("INBOX", 1)) + + def test_purge_folder_resets_last_uid(self): + self.store.upsert_folder("INBOX") + self.store.set_folder_state("INBOX", last_uid=50) + self.store.purge_folder("INBOX") + self.assertEqual(self.store.folder_state("INBOX")["last_uid"], 0) + + def test_purge_all(self): + fid = self.store.upsert_folder("INBOX") + self.store.upsert_messages(fid, [meta(1)]) + self.store.purge_all() + self.assertEqual(self.store.folders(), []) + + def test_size_bytes_grows(self): + before = self.store.size_bytes() + self.store.upsert_folder("INBOX") + self.store.write_body("INBOX", 1, b"x" * 5000) + self.assertGreater(self.store.size_bytes(), before) + + +class TestEncryptedStore(TestMessages): + """Le même contrat, en chiffré : rien ne doit changer du point de vue de l'appelant.""" + + mode = "encrypted" + + def test_subject_absent_from_db_file(self): + self.store.upsert_messages(self.fid, [meta(1, subject="CONFIDENTIEL")]) + self.store.close() + raw = (self.store.root / "cache.db").read_bytes() + self.assertNotIn(b"CONFIDENTIEL", raw) + self.store.open() + + def test_body_file_is_encrypted(self): + self.store.write_body("INBOX", 1, b"TEXTE SECRET") + path = next((self.store.root).rglob("*.eml*")) + self.assertNotIn(b"TEXTE SECRET", path.read_bytes()) + + def test_date_stays_queryable_in_clear(self): + """Le tri doit rester du SQL : la date n'est pas scellée.""" + self.store.upsert_messages(self.fid, [meta(1, date=12345)]) + rows = self.store._conn.execute( + "SELECT date FROM messages WHERE uid = 1" + ).fetchall() + self.assertEqual(rows[0][0], 12345) + + +class TestWrongKey(unittest.TestCase): + def test_reopening_with_another_key_raises(self): + with tempfile.TemporaryDirectory() as tmp: + acc = account_from_preset("perso", "a@x.ca", "generic") + first = Store(acc, mode="encrypted", key=new_key(), base=Path(tmp)) + first.open() + fid = first.upsert_folder("INBOX") + first.upsert_messages(fid, [meta(1, subject="secret")]) + first.close() + + second = Store( + acc, mode="encrypted", key=new_key(), base=Path(tmp) + ) + second.open() + with self.assertRaises(CryptoError): + second.list_messages(fid) + second.close() + + +class TestEphemeral(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.account = account_from_preset("perso", "a@x.ca", "generic") + + def tearDown(self): + self.tmp.cleanup() + + def test_cleanup_removes_everything(self): + store = Store( + self.account, + mode="ephemeral", + key=new_key(), + base=Path(self.tmp.name), + ) + store.open() + root = store.root + store.write_body("INBOX", 1, b"corps") + self.assertTrue(root.exists()) + store.close() + store.cleanup() + self.assertFalse(root.exists()) + + def test_sweep_removes_dead_pid_dirs(self): + base = Path(self.tmp.name) + dead = base / f"{EPHEMERAL_PREFIX}999999999" + dead.mkdir() + alive = base / f"{EPHEMERAL_PREFIX}{os.getpid()}" + alive.mkdir() + removed = sweep_orphan_ephemeral(base) + self.assertEqual(removed, 1) + self.assertFalse(dead.exists()) + self.assertTrue(alive.exists()) + + def test_sweep_ignores_foreign_dirs(self): + base = Path(self.tmp.name) + (base / "autre-chose").mkdir() + self.assertEqual(sweep_orphan_ephemeral(base), 0) + self.assertTrue((base / "autre-chose").exists()) + + +class TestCorruptDatabase(unittest.TestCase): + """Un open() raté ne doit pas laisser l'objet porteur d'un handle cassé.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.account = account_from_preset("perso", "a@x.ca", "generic") + root = Path(self.tmp.name) / "perso" + root.mkdir(parents=True) + (root / "cache.db").write_bytes(b"ceci n'est pas une base sqlite" * 40) + + def tearDown(self): + self.tmp.cleanup() + + def test_open_raises_store_error(self): + store = Store(self.account, mode="clear", base=Path(self.tmp.name)) + with self.assertRaises(StoreError): + store.open() + + def test_failed_open_does_not_publish_the_connection(self): + """Sinon le open() suivant réussirait en silence sur une base sans schéma.""" + store = Store(self.account, mode="clear", base=Path(self.tmp.name)) + with self.assertRaises(StoreError): + store.open() + with self.assertRaises(StoreError): + store.open() + + +class TestEphemeralIsolation(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.base = Path(self.tmp.name) + self.a = account_from_preset("perso", "a@x.ca", "generic") + self.b = account_from_preset("travail", "b@x.ca", "generic") + + def tearDown(self): + self.tmp.cleanup() + + def _store(self, account): + store = Store(account, mode="ephemeral", key=new_key(), base=self.base) + store.open() + return store + + def test_cleanup_spares_the_sibling_account(self): + """Le dossier par PID est partagé : l'effacer tuerait le voisin.""" + first, second = self._store(self.a), self._store(self.b) + first.write_body("INBOX", 1, b"corps a") + second.write_body("INBOX", 1, b"corps b") + first.cleanup() + self.assertFalse(first.root.exists()) + self.assertTrue(second.root.exists()) + self.assertEqual(second.read_body("INBOX", 1), b"corps b") + second.cleanup() + + def test_last_cleanup_removes_the_pid_directory(self): + first, second = self._store(self.a), self._store(self.b) + pid_dir = first.root.parent + first.cleanup() + self.assertTrue(pid_dir.exists()) + second.cleanup() + self.assertFalse(pid_dir.exists()) + + def test_pid_directory_is_0700(self): + """/dev/shm est en 1777 : le dossier par PID ne doit rien laisser voir.""" + import stat as stat_module + + store = self._store(self.a) + mode = stat_module.S_IMODE(os.stat(store.root.parent).st_mode) + self.assertEqual(mode, 0o700) + store.cleanup() + + def test_symlinked_pid_directory_is_refused(self): + """Un tiers peut pré-créer le chemin : on refuse de le suivre.""" + target = self.base / "ailleurs" + target.mkdir() + (self.base / f"{EPHEMERAL_PREFIX}{os.getpid()}").symlink_to(target) + store = Store(self.a, mode="ephemeral", key=new_key(), base=self.base) + with self.assertRaises(StoreError): + store.open() + + +class TestKeyPersistence(unittest.TestCase): + """Le seul chemin du module qui écrit de la matière de clé sur disque.""" + + class FakeVault: + def __init__(self): + self.data = {} + + def get(self, ref): + return self.data.get(ref) + + def set(self, ref, value): + self.data[ref] = value + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.base = Path(self.tmp.name) + self.account = account_from_preset("perso", "a@x.ca", "generic") + self.vault = self.FakeVault() + + def tearDown(self): + self.tmp.cleanup() + + def _store(self): + store = Store( + self.account, mode="encrypted", secrets=self.vault, base=self.base + ) + store.open() + return store + + def test_first_open_stores_a_key_under_the_cache_key_ref(self): + self._store().close() + self.assertIn(self.account.cache_key_ref(), self.vault.data) + + def test_stored_key_is_base64_of_32_bytes(self): + import base64 + + self._store().close() + raw = base64.b64decode(self.vault.data[self.account.cache_key_ref()]) + self.assertEqual(len(raw), 32) + + def test_second_store_reuses_the_stored_key(self): + first = self._store() + fid = first.upsert_folder("INBOX") + first.upsert_messages(fid, [meta(1, subject="Devis")]) + first.close() + + second = self._store() + self.assertEqual(second.list_messages(fid)[0].subject, "Devis") + second.close() + + def test_key_is_not_regenerated_on_reopen(self): + self._store().close() + stored = self.vault.data[self.account.cache_key_ref()] + self._store().close() + self.assertEqual(self.vault.data[self.account.cache_key_ref()], stored) + + def test_key_never_lands_in_the_cache_file(self): + import base64 + + store = self._store() + fid = store.upsert_folder("INBOX") + store.upsert_messages(fid, [meta(1)]) + root = store.root + store.close() + raw = base64.b64decode(self.vault.data[self.account.cache_key_ref()]) + blob = (root / "cache.db").read_bytes() + self.assertNotIn(raw, blob) + self.assertNotIn(base64.b64encode(raw), blob) + + +class TestThreadSafety(unittest.TestCase): + """Le TUI synchronise dans un thread de travail pendant que l'écran lit. + + Sans `check_same_thread=False` ET le verrou, la toute première passe de + synchronisation lèverait `sqlite3.ProgrammingError`. + """ + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.account = account_from_preset("perso", "a@x.ca", "generic") + self.store = Store( + self.account, mode="clear", base=Path(self.tmp.name) + ) + self.store.open() + self.fid = self.store.upsert_folder("INBOX") + + def tearDown(self): + self.store.close() + self.tmp.cleanup() + + def test_read_from_another_thread(self): + import threading + + erreurs = [] + + def worker(): + try: + self.store.folders() + except Exception as exc: + erreurs.append(f"{type(exc).__name__}: {exc}") + + thread = threading.Thread(target=worker) + thread.start() + thread.join() + self.assertEqual(erreurs, []) + + def test_write_from_another_thread(self): + import threading + + erreurs = [] + + def worker(): + try: + self.store.upsert_messages(self.fid, [meta(1)]) + except Exception as exc: + erreurs.append(f"{type(exc).__name__}: {exc}") + + thread = threading.Thread(target=worker) + thread.start() + thread.join() + self.assertEqual(erreurs, []) + self.assertEqual(len(self.store.list_messages(self.fid)), 1) + + def test_concurrent_writers_all_land(self): + """Le verrou sérialise : aucun upsert ne doit se perdre.""" + import threading + + def worker(start): + self.store.upsert_messages( + self.fid, [meta(uid) for uid in range(start, start + 20)] + ) + + threads = [ + threading.Thread(target=worker, args=(base,)) + for base in (1, 101, 201, 301) + ] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + self.assertEqual( + len(self.store.list_messages(self.fid, limit=500)), 80 + ) + + +class TestKeyRequired(unittest.TestCase): + def test_encrypted_without_key_or_secrets_raises(self): + with tempfile.TemporaryDirectory() as tmp: + acc = account_from_preset("perso", "a@x.ca", "generic") + store = Store(acc, mode="encrypted", base=Path(tmp)) + with self.assertRaises(StoreError): + store.open() + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_mail_sync.py b/test/test_mail_sync.py new file mode 100644 index 0000000..168113c --- /dev/null +++ b/test/test_mail_sync.py @@ -0,0 +1,418 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import tempfile +import unittest +from pathlib import Path + +from script.todo.mail.accounts import account_from_preset +from script.todo.mail.imap_sync import ( + FolderInfo, + HeaderInfo, + SelectInfo, + Syncer, +) +from script.todo.mail.store import Store + + +class FakeImapTransport: + """Un serveur IMAP en mémoire : assez pour exercer tout le moteur.""" + + def __init__(self, folders=None): + # {nom: {"uidvalidity": int, "messages": {uid: HeaderInfo}, + # "bodies": {uid: bytes}}} + self.folders = folders or {} + self.selected = None + self.appended = [] + self.stored_flags = [] + self.logged_out = False + self.select_errors = set() + + # -- helpers de test ------------------------------------------------ + + def add(self, folder, uid, subject="Sujet", flags="", body=b"corps"): + f = self.folders.setdefault( + folder, {"uidvalidity": 1, "messages": {}, "bodies": {}} + ) + f["messages"][uid] = HeaderInfo( + uid=uid, + date=1000 + uid, + size=len(body), + flags=flags, + msgid=f"<{uid}@x.ca>", + frm="alice@x.ca", + to="moi@x.ca", + subject=subject, + ) + f["bodies"][uid] = body + + # -- protocole ------------------------------------------------------ + + def list_folders(self): + return [FolderInfo(name=n) for n in sorted(self.folders)] + + def select(self, folder): + if folder in self.select_errors: + raise OSError(f"select refusé sur {folder}") + self.selected = folder + f = self.folders[folder] + uids = list(f["messages"]) + return SelectInfo( + uidvalidity=f["uidvalidity"], + uidnext=(max(uids) + 1) if uids else 1, + exists=len(uids), + ) + + def search_uids(self, since_uid): + f = self.folders[self.selected] + return sorted(u for u in f["messages"] if u >= since_uid) + + def fetch_headers(self, uids): + f = self.folders[self.selected] + return [f["messages"][u] for u in uids if u in f["messages"]] + + def fetch_flags(self, uids): + f = self.folders[self.selected] + return [ + (u, f["messages"][u].flags) for u in uids if u in f["messages"] + ] + + def fetch_body(self, uid): + return self.folders[self.selected]["bodies"][uid] + + def store_flags(self, uid, add, remove): + self.stored_flags.append((uid, tuple(add), tuple(remove))) + + def append(self, folder, raw, flags): + self.appended.append((folder, raw, tuple(flags))) + + def logout(self): + self.logged_out = True + + +class SyncCase(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.account = account_from_preset("perso", "moi@x.ca", "generic") + self.store = Store( + self.account, mode="clear", base=Path(self.tmp.name) + ) + self.store.open() + self.imap = FakeImapTransport() + self.syncer = Syncer(self.store, self.imap) + + def tearDown(self): + self.store.close() + self.tmp.cleanup() + + def folder_id(self, name): + return self.store.folder_state(name)["id"] + + +class TestFirstSync(SyncCase): + def test_creates_folders(self): + self.imap.add("INBOX", 1) + self.imap.add("Sent", 1) + report = self.syncer.sync() + self.assertEqual(report.folders, 2) + self.assertEqual( + {f["name"] for f in self.store.folders()}, {"INBOX", "Sent"} + ) + + def test_stores_messages(self): + self.imap.add("INBOX", 1, subject="Devis") + self.imap.add("INBOX", 2, subject="Facture") + report = self.syncer.sync() + self.assertEqual(report.new_messages, 2) + subjects = { + m.subject + for m in self.store.list_messages(self.folder_id("INBOX")) + } + self.assertEqual(subjects, {"Devis", "Facture"}) + + def test_records_last_uid(self): + self.imap.add("INBOX", 7) + self.imap.add("INBOX", 9) + self.syncer.sync() + self.assertEqual(self.store.folder_state("INBOX")["last_uid"], 9) + + def test_records_uidvalidity(self): + self.imap.add("INBOX", 1) + self.syncer.sync() + self.assertEqual(self.store.folder_state("INBOX")["uidvalidity"], 1) + + def test_counts_unseen(self): + self.imap.add("INBOX", 1, flags="\\Seen") + self.imap.add("INBOX", 2, flags="") + self.syncer.sync() + self.assertEqual(self.store.folder_state("INBOX")["unseen"], 1) + + def test_empty_folder_is_fine(self): + self.imap.folders["INBOX"] = { + "uidvalidity": 1, + "messages": {}, + "bodies": {}, + } + report = self.syncer.sync() + self.assertEqual(report.new_messages, 0) + + def test_no_body_downloaded_during_sync(self): + self.imap.add("INBOX", 1) + self.syncer.sync() + self.assertFalse( + self.store.list_messages(self.folder_id("INBOX"))[0].has_body + ) + + +class TestIncrementalSync(SyncCase): + def test_second_pass_fetches_only_new(self): + self.imap.add("INBOX", 1) + self.syncer.sync() + self.imap.add("INBOX", 2) + report = self.syncer.sync() + self.assertEqual(report.new_messages, 1) + + def test_nothing_new_reports_zero(self): + self.imap.add("INBOX", 1) + self.syncer.sync() + self.assertEqual(self.syncer.sync().new_messages, 0) + + def test_flags_are_refreshed(self): + self.imap.add("INBOX", 1, flags="") + self.syncer.sync() + self.imap.folders["INBOX"]["messages"][1].flags = "\\Seen" + self.syncer.sync() + self.assertEqual( + self.store.list_messages(self.folder_id("INBOX"))[0].flags, + "\\Seen", + ) + + +class TestUidValidity(SyncCase): + def test_change_purges_and_resyncs(self): + self.imap.add("INBOX", 1, subject="ancien") + self.syncer.sync() + # Le serveur a rebâti la boîte : mêmes UID, autres messages. + self.imap.folders["INBOX"]["uidvalidity"] = 2 + self.imap.folders["INBOX"]["messages"][1].subject = "nouveau" + report = self.syncer.sync() + self.assertIn("INBOX", report.purged) + got = self.store.list_messages(self.folder_id("INBOX")) + self.assertEqual(len(got), 1) + self.assertEqual(got[0].subject, "nouveau") + + def test_same_uidvalidity_does_not_purge(self): + self.imap.add("INBOX", 1) + self.syncer.sync() + self.assertEqual(self.syncer.sync().purged, []) + + +class TestBatching(SyncCase): + def test_large_folder_is_fetched_in_batches(self): + for uid in range(1, 451): + self.imap.add("INBOX", uid) + calls = [] + original = self.imap.fetch_headers + + def spy(uids): + calls.append(len(uids)) + return original(uids) + + self.imap.fetch_headers = spy + report = self.syncer.sync() + self.assertEqual(report.new_messages, 450) + self.assertEqual(calls, [200, 200, 50]) + + +class TestNoselectContainers(SyncCase): + """Signalé depuis un vrai Gmail : « [Gmail] » n'est pas une boîte mais + un NIVEAU de la hiérarchie, marqué `\\Noselect` dans la réponse LIST. + Le SELECTionner répond NO, et cette erreur salissait chaque + synchronisation. Un journal qui crie sur du normal fait rater ce qui ne + l'est pas. + """ + + def _sync_avec_conteneur(self): + transport = FakeImapTransport() + transport.add("[Gmail]/Sent Mail", 1, subject="Envoyé") + reels = transport.list_folders() + transport.list_folders = ( + lambda: [FolderInfo(name="[Gmail]", selectable=False)] + reels + ) + # Le serveur RÉPONDRAIT NO : si le moteur tente quand même, le + # test doit le voir échouer, pas passer par chance. + transport.select_errors.add("[Gmail]") + syncer = Syncer(self.store, transport) + return transport, syncer.sync() + + def test_a_container_produces_no_error(self): + _, report = self._sync_avec_conteneur() + self.assertEqual(report.errors, []) + + def test_the_container_stays_visible_in_the_tree(self): + """On le saute, on ne l'efface pas : c'est un niveau que + l'utilisateur voit dans l'arbre des dossiers.""" + self._sync_avec_conteneur() + noms = [f["name"] for f in self.store.folders()] + self.assertIn("[Gmail]", noms) + + def test_its_selectable_children_are_still_synced(self): + """Le contrôle qui compte : sauter le parent ne doit pas sauter ce + qu'il contient.""" + self._sync_avec_conteneur() + messages = self.store.list_messages( + self.folder_id("[Gmail]/Sent Mail") + ) + self.assertEqual(len(messages), 1) + + +class TestErrors(SyncCase): + def test_failing_folder_does_not_stop_the_others(self): + self.imap.add("INBOX", 1) + self.imap.add("Archives", 1) + self.imap.select_errors.add("Archives") + report = self.syncer.sync() + self.assertEqual(report.new_messages, 1) + self.assertEqual(len(report.errors), 1) + self.assertIn("Archives", report.errors[0]) + + def test_failing_folder_is_logged(self): + """`report.errors` seul ne suffit pas : avant ce correctif, rien + dans `script/todo/mail/` ne journalisait quoi que ce soit (à part un + `_logger` déclaré mais jamais utilisé dans `secrets.py`), donc une + panne perdue au-delà de la ligne de statut ne laissait AUCUNE + trace.""" + self.imap.add("INBOX", 1) + self.imap.add("Archives", 1) + self.imap.select_errors.add("Archives") + with self.assertLogs("script.todo.mail.imap_sync", level="ERROR"): + self.syncer.sync() + + +class TestSyncOne(SyncCase): + """`sync_one` : la sync ciblée qu'utilise `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 (voir + `docs/superpowers/specs/2026-08-02-email-tui-design.md`, ligne 308).""" + + def test_syncs_only_the_named_folder(self): + self.imap.add("INBOX", 1) + self.imap.add("Sent", 1) + report = self.syncer.sync_one("Sent") + self.assertEqual(report.new_messages, 1) + self.assertIsNone(self.store.folder_state("INBOX")) + + def test_report_covers_a_single_folder(self): + self.imap.add("Sent", 1) + self.assertEqual(self.syncer.sync_one("Sent").folders, 1) + + def test_stores_the_message(self): + self.imap.add("Sent", 1, subject="Devis") + self.syncer.sync_one("Sent") + subjects = { + m.subject for m in self.store.list_messages(self.folder_id("Sent")) + } + self.assertEqual(subjects, {"Devis"}) + + def test_does_not_raise_when_the_folder_refuses(self): + self.imap.folders["Sent"] = { + "uidvalidity": 1, + "messages": {}, + "bodies": {}, + } + self.imap.select_errors.add("Sent") + report = self.syncer.sync_one("Sent") # ne doit pas lever + self.assertEqual(len(report.errors), 1) + self.assertIn("Sent", report.errors[0]) + + def test_failure_is_logged(self): + self.imap.folders["Sent"] = { + "uidvalidity": 1, + "messages": {}, + "bodies": {}, + } + self.imap.select_errors.add("Sent") + with self.assertLogs("script.todo.mail.imap_sync", level="ERROR"): + self.syncer.sync_one("Sent") + + def test_does_not_erase_a_previously_known_display_or_role(self): + """`sync_one` ne connaît que le nom du dossier : il ne doit pas + écraser le libellé/rôle déjà appris d'un LIST complet (voir le + COALESCE dans `store.upsert_folder`).""" + self.imap.add("Sent", 1) + self.syncer.sync() # premier passage : enregistre display/role + self.store.upsert_folder("Sent", "Envoyés", "sent") + self.syncer.sync_one("Sent") + state = self.store.folder_state("Sent") + self.assertEqual(state["display"], "Envoyés") + self.assertEqual(state["role"], "sent") + + +class TestProgress(SyncCase): + def test_callback_receives_folder_and_counts(self): + self.imap.add("INBOX", 1) + self.imap.add("INBOX", 2) + seen = [] + self.syncer.sync( + progress=lambda name, done, total: seen.append((name, done, total)) + ) + self.assertEqual(seen[-1], ("INBOX", 2, 2)) + + +class TestFetchBody(SyncCase): + def test_downloads_and_caches(self): + self.imap.add("INBOX", 1, body=b"From: a@x.ca\r\n\r\nBonjour Alice") + self.syncer.sync() + raw = self.syncer.fetch_body("INBOX", 1) + self.assertIn(b"Bonjour Alice", raw) + self.assertEqual(self.store.read_body("INBOX", 1), raw) + + def test_second_call_uses_the_cache(self): + self.imap.add("INBOX", 1, body=b"corps") + self.syncer.sync() + self.syncer.fetch_body("INBOX", 1) + self.imap.fetch_body = lambda uid: self.fail("le réseau a été rappelé") + self.assertEqual(self.syncer.fetch_body("INBOX", 1), b"corps") + + def test_marks_has_body(self): + self.imap.add("INBOX", 1) + self.syncer.sync() + self.syncer.fetch_body("INBOX", 1) + self.assertTrue( + self.store.list_messages(self.folder_id("INBOX"))[0].has_body + ) + + def test_snippet_survives_an_unknown_charset(self): + """Un charset bidon ne doit pas faire tomber l'ouverture du message.""" + from script.todo.mail.imap_sync import snippet_from_raw + + raw = ( + b'Content-Type: text/plain; charset="bogus-charset-xyz"\r\n\r\n' + b"Bonjour Alice" + ) + self.assertIn("Bonjour", snippet_from_raw(raw)) + + def test_snippet_survives_unknown_8bit(self): + """Étiquette réelle observée en usage (voir `decode_header_value` / + `script/todo/mail/charset.py`), pas seulement un charset inventé.""" + from script.todo.mail.imap_sync import snippet_from_raw + + raw = ( + b'Content-Type: text/plain; charset="unknown-8bit"\r\n\r\n' + b"Bonjour Alice" + ) + self.assertIn("Bonjour", snippet_from_raw(raw)) + + def test_fills_the_snippet(self): + self.imap.add( + "INBOX", 1, body=b"Subject: Devis\r\n\r\nBonjour, voici le devis." + ) + self.syncer.sync() + self.syncer.fetch_body("INBOX", 1) + snippet = self.store.list_messages(self.folder_id("INBOX"))[0].snippet + self.assertIn("Bonjour", snippet) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_mail_tui.py b/test/test_mail_tui.py new file mode 100644 index 0000000..74abaec --- /dev/null +++ b/test/test_mail_tui.py @@ -0,0 +1,431 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import tempfile +import unittest +from pathlib import Path + +from script.todo.mail.accounts import account_from_preset +from script.todo.mail.tui import ( + MailboxRef, + Session, + mailbox_refs, + open_sessions, +) + + +class FailingConnect: + def __init__(self, message="serveur injoignable"): + self.message = message + + def __call__(self, account, password): + raise OSError(self.message) + + +class FakeTransport: + def list_folders(self): + return [] + + def logout(self): + pass + + +class FakeSecrets: + def __init__(self, password="hunter2"): + self.password = password + + def get(self, ref): + return self.password + + def set(self, ref, value): + self.password = value + + +class SessionCase(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.base = Path(self.tmp.name) + self.accounts = [ + account_from_preset("perso", "moi@x.ca", "generic"), + account_from_preset("travail", "moi@y.ca", "generic"), + ] + # Épinglé : sans ça, resolve_mode lirait les préférences réelles de la + # machine et le test dépendrait de ~/.erplibre. + for account in self.accounts: + account.cache_mode = "clear" + + def tearDown(self): + self.tmp.cleanup() + + +class TestOpenSessions(SessionCase): + def test_one_session_per_account(self): + sessions = open_sessions( + self.accounts, + FakeSecrets(), + base=self.base, + connect_fn=lambda a, p: FakeTransport(), + ) + self.assertEqual( + [s.account.name for s in sessions], ["perso", "travail"] + ) + for s in sessions: + s.close() + + def test_disabled_account_is_skipped(self): + self.accounts[1].enabled = False + sessions = open_sessions( + self.accounts, + FakeSecrets(), + base=self.base, + connect_fn=lambda a, p: FakeTransport(), + ) + self.assertEqual([s.account.name for s in sessions], ["perso"]) + for s in sessions: + s.close() + + def test_cache_opens_even_when_the_network_fails(self): + """Réseau coupé : la boîte doit rester consultable.""" + sessions = open_sessions( + self.accounts, + FakeSecrets(), + base=self.base, + connect_fn=FailingConnect(), + ) + self.assertTrue(all(not s.online for s in sessions)) + self.assertTrue(all(s.store is not None for s in sessions)) + for s in sessions: + s.close() + + def test_network_error_is_kept_for_display(self): + sessions = open_sessions( + self.accounts, + FakeSecrets(), + base=self.base, + connect_fn=FailingConnect("530 refus"), + ) + self.assertIn("530", sessions[0].error) + for s in sessions: + s.close() + + def test_missing_password_marks_offline(self): + class NoSecret: + def get(self, ref): + return None + + sessions = open_sessions( + self.accounts, + NoSecret(), + base=self.base, + connect_fn=lambda a, p: FakeTransport(), + ) + self.assertFalse(sessions[0].online) + for s in sessions: + s.close() + + def test_online_when_everything_works(self): + sessions = open_sessions( + self.accounts, + FakeSecrets(), + base=self.base, + connect_fn=lambda a, p: FakeTransport(), + ) + self.assertTrue(all(s.online for s in sessions)) + for s in sessions: + s.close() + + +class TestEphemeralCleanupOnSignals(SessionCase): + """`atexit` ne s'exécute pas sur un signal : sans un gestionnaire + `SIGINT`/`SIGTERM` dédié, un cache éphémère survivrait à un `kill` ou un + Ctrl+C — exactement ce que ce mode promet d'éviter. + + On déclenche le gestionnaire installé directement plutôt que d'envoyer un + vrai signal au processus de test, et on restaure l'ancien gestionnaire + dans `tearDown` pour ne pas polluer le reste de la suite. + + Piège vécu : quand la disposition précédente n'est PAS appelable + (`SIG_DFL`, le cas par défaut), le gestionnaire se renvoie maintenant + POUR DE VRAI le signal après le nettoyage — sinon il l'avalerait (voir + `test_sigterm_actually_terminates_the_process`). Appeler ce gestionnaire + directement avec la disposition par défaut en place tuerait donc le + processus de test lui-même : `test_sigterm_removes_the_ephemeral_root` + installe d'abord un `_previous` factice et appelable pour rester une + invocation directe sûre, et laisse la vraie fin de processus au test + suivant, seul endroit sûr pour l'observer (un sous-processus dédié). + """ + + def setUp(self): + super().setUp() + import signal + + self._orig_sigint = signal.getsignal(signal.SIGINT) + self._orig_sigterm = signal.getsignal(signal.SIGTERM) + + def tearDown(self): + import signal + + signal.signal(signal.SIGINT, self._orig_sigint) + signal.signal(signal.SIGTERM, self._orig_sigterm) + super().tearDown() + + def test_sigterm_removes_the_ephemeral_root(self): + import signal + + # Un `_previous` factice mais appelable : la branche de repli qui + # renvoie le signal pour de vrai (cas `SIG_DFL`) n'est PAS sûre à + # emprunter ici, puisqu'on invoque le gestionnaire directement dans + # le processus de test — elle est couverte séparément, en + # sous-processus, par `test_sigterm_actually_terminates_the_process`. + signal.signal(signal.SIGTERM, lambda signum, frame: None) + + for account in self.accounts: + account.cache_mode = "ephemeral" + sessions = open_sessions( + self.accounts, + FakeSecrets(), + base=self.base, + connect_fn=lambda a, p: FakeTransport(), + ) + roots = [s.store.root for s in sessions] + self.assertTrue(all(r.exists() for r in roots)) + + handler = signal.getsignal(signal.SIGTERM) + handler(signal.SIGTERM, None) + + self.assertFalse(any(r.exists() for r in roots)) + + def test_sigterm_actually_terminates_the_process(self): + """Un test qui ne vérifie QUE le nettoyage ne peut pas distinguer + « nettoyé puis sorti » de « nettoyé puis resté vivant » — c'est + exactement cette distinction que la régression a ratée : le + gestionnaire chaîné n'appelait le précédent handler que s'il était + `callable`, or la disposition par défaut de SIGTERM (`SIG_DFL`) est + l'entier 0, pas un appelable — le signal était donc avalé. + + On lance un vrai sous-processus, on lui envoie SIGTERM pour de + vrai (pas un appel direct du handler), et on vérifie qu'il MEURT. + """ + import signal + import subprocess + import sys + + repo_root = Path(__file__).resolve().parent.parent + with tempfile.TemporaryDirectory() as base_dir: + script = f""" +import os +import signal +import sys +import time + +sys.path.insert(0, {str(repo_root)!r}) + +from script.todo.mail.accounts import account_from_preset +from script.todo.mail.tui import open_sessions + + +class FakeTransport: + def list_folders(self): + return [] + + def logout(self): + pass + + +class FakeSecrets: + def get(self, ref): + return "hunter2" + + def set(self, ref, value): + pass + + +account = account_from_preset("perso", "moi@x.ca", "generic") +account.cache_mode = "ephemeral" +sessions = open_sessions( + [account], + FakeSecrets(), + base={str(base_dir)!r}, + connect_fn=lambda a, p: FakeTransport(), +) +print(str(sessions[0].store.root), flush=True) + +os.kill(os.getpid(), signal.SIGTERM) + +# Ne doit JAMAIS s'imprimer : y arriver veut dire que le signal a été avalé. +time.sleep(2) +print("FAILURE: still alive after SIGTERM", flush=True) +""" + script_path = Path(base_dir) / "sigterm_child.py" + script_path.write_text(script) + + result = subprocess.run( + [sys.executable, str(script_path)], + capture_output=True, + text=True, + timeout=10, + ) + + lines = [ + line for line in result.stdout.splitlines() if line.strip() + ] + self.assertTrue( + lines, f"aucune sortie de l'enfant : {result.stderr}" + ) + root = Path(lines[0]) + + self.assertNotIn("still alive", result.stdout) + self.assertEqual(result.returncode, -signal.SIGTERM) + self.assertFalse(root.exists()) + + +class TestMailboxRefs(SessionCase): + def setUp(self): + super().setUp() + self.sessions = open_sessions( + self.accounts, + FakeSecrets(), + base=self.base, + connect_fn=lambda a, p: FakeTransport(), + ) + + def tearDown(self): + for s in self.sessions: + s.close() + super().tearDown() + + def test_empty_when_no_folder(self): + self.assertEqual(mailbox_refs(self.sessions), []) + + def test_lists_folders_of_every_account(self): + self.sessions[0].store.upsert_folder("INBOX", "INBOX", "inbox") + self.sessions[1].store.upsert_folder("INBOX", "INBOX", "inbox") + refs = mailbox_refs(self.sessions) + self.assertEqual( + [(r.account_name, r.folder_name) for r in refs], + [("perso", "INBOX"), ("travail", "INBOX")], + ) + + def test_inbox_comes_first(self): + store = self.sessions[0].store + store.upsert_folder("Archives", "Archives", None) + store.upsert_folder("INBOX", "INBOX", "inbox") + names = [r.folder_name for r in mailbox_refs(self.sessions)] + self.assertEqual(names[0], "INBOX") + + def test_carries_unseen_count(self): + store = self.sessions[0].store + store.upsert_folder("INBOX", "INBOX", "inbox") + store.set_folder_state("INBOX", unseen=4) + self.assertEqual(mailbox_refs(self.sessions)[0].unseen, 4) + + def test_display_falls_back_to_name(self): + self.sessions[0].store.upsert_folder("Projets") + self.assertEqual(mailbox_refs(self.sessions)[0].display, "Projets") + + +class TestBrokenCache(SessionCase): + """Un cache illisible sur UN compte ne doit pas couler les autres.""" + + def _corrupt(self, name): + root = self.base / name + root.mkdir(parents=True, exist_ok=True) + (root / "cache.db").write_bytes(b"pas une base sqlite" * 50) + + def _open(self): + return open_sessions( + self.accounts, + FakeSecrets(), + base=self.base, + connect_fn=lambda a, p: FakeTransport(), + ) + + def test_the_other_accounts_still_open(self): + self._corrupt("perso") + sessions = self._open() + self.assertEqual( + [s.account.name for s in sessions], ["perso", "travail"] + ) + self.assertIsNone(sessions[0].store) + self.assertIsNotNone(sessions[1].store) + for session in sessions: + session.close() + + def test_the_broken_account_keeps_its_error(self): + self._corrupt("perso") + sessions = self._open() + self.assertIn("cache.db", sessions[0].error) + for session in sessions: + session.close() + + def test_the_broken_account_is_offline(self): + self._corrupt("perso") + sessions = self._open() + self.assertFalse(sessions[0].online) + for session in sessions: + session.close() + + def test_mailbox_refs_skips_it_without_raising(self): + self._corrupt("perso") + sessions = self._open() + sessions[1].store.upsert_folder("INBOX", "INBOX", "inbox") + refs = mailbox_refs(sessions) + self.assertEqual([r.account_name for r in refs], ["travail"]) + for session in sessions: + session.close() + + def test_closing_a_broken_session_does_not_raise(self): + self._corrupt("perso") + sessions = self._open() + for session in sessions: + session.close() + + +class TestImportsWithoutTextual(unittest.TestCase): + def test_module_imports_without_textual(self): + """Le module doit rester utilisable là où Textual n'est pas installé.""" + import script.todo.mail.tui as tui + + self.assertTrue(hasattr(tui, "run_tui")) + + +class TestSaveAttachment(unittest.TestCase): + RAW = ( + b'Content-Type: multipart/mixed; boundary="B"\r\n\r\n' + b"--B\r\nContent-Type: text/plain\r\n\r\ncorps\r\n" + b"--B\r\nContent-Type: application/pdf\r\n" + b'Content-Disposition: attachment; filename="devis.pdf"\r\n\r\n' + b"%PDF\r\n--B--\r\n" + ) + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + + def tearDown(self): + self.tmp.cleanup() + + def test_writes_the_file(self): + from script.todo.mail.tui import save_attachment + + target = save_attachment(self.RAW, 0, self.tmp.name) + self.assertTrue(target.exists()) + self.assertEqual(target.name, "devis.pdf") + + def test_unknown_index_raises(self): + from script.todo.mail.tui import save_attachment + + with self.assertRaises(ValueError): + save_attachment(self.RAW, 7, self.tmp.name) + + def test_filename_cannot_escape_the_directory(self): + """Le nom vient du message : il ne doit jamais écrire ailleurs.""" + from script.todo.mail.tui import save_attachment + + hostile = self.RAW.replace(b'"devis.pdf"', b'"../../evade.pdf"') + target = save_attachment(hostile, 0, self.tmp.name) + self.assertEqual(target.parent, Path(self.tmp.name)) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_mail_tui_account.py b/test/test_mail_tui_account.py new file mode 100644 index 0000000..4b59d29 --- /dev/null +++ b/test/test_mail_tui_account.py @@ -0,0 +1,879 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Ajout de compte depuis le TUI : `n` (ou le nœud "+ Ajouter un compte") +ouvre le formulaire, et un compte sauvegardé doit devenir utilisable sans +redémarrer — présent à la fois dans `self.sessions` ET dans l'arbre. + +Comme `test_mail_compose.py` : `on_mount` lit `todo_prefs`, qui crée +`~/.erplibre` s'il est absent, et l'écran d'ajout lit/écrit +`~/.erplibre/mail/accounts.json` par les mêmes fonctions que le CLI. `$HOME` +est donc détourné vers un dossier jetable pour tout le module. +""" +import os +import tempfile +import unittest +from pathlib import Path + +from script.todo.mail import accounts as mail_accounts + + +class FakeConfigFile: + """Un `config_file` minimal — seuls `get_config_value`/`set_config_value` + sont utilisés par `account_setup`, pas besoin du vrai `ConfigFile`.""" + + def __init__(self): + self._values: dict = {} + + def get_config_value(self, keys): + node = self._values + for key in keys: + if not isinstance(node, dict) or key not in node: + return None + node = node[key] + return node + + def set_config_value(self, keys, value): + node = self._values + for key in keys[:-1]: + node = node.setdefault(key, {}) + node[keys[-1]] = value + + +class FakeSecretStore: + """Coffre en mémoire : suffisant pour vérifier ce que le formulaire y + écrit, sans toucher ni pykeepass ni le trousseau système.""" + + def __init__(self): + self._data: dict = {} + + def available_backends(self): + return ["kdbx"] + + def get(self, ref): + return self._data.get(ref) + + def set(self, ref, value): + self._data[ref] = value + + def delete(self, ref): + self._data.pop(ref, None) + + +class FakeTransport: + def list_folders(self): + return [] + + def logout(self): + pass + + +class TuiAccountCase(unittest.IsolatedAsyncioTestCase): + def setUp(self): + # Ces tests comparent des libellés d'arbre en français : ils fixent + # donc la langue au lieu d'hériter de celle que le fichier précédent + # a laissée, sinon ils passent seuls et échouent dans la suite + # complète — ce qui est arrivé. + # + # On écrit la mémoïsation directement : `set_lang()` PERSISTE la + # langue dans ./env_var.sh, un fichier suivi par git, donc l'appeler + # depuis un test modifierait l'arbre de travail. + from script.todo import todo_i18n + + self._old_lang = todo_i18n._current_lang + todo_i18n._current_lang = "fr" + + self.fake_home = tempfile.TemporaryDirectory() + self._old_home = os.environ.get("HOME") + os.environ["HOME"] = self.fake_home.name + + self.cache_dir = tempfile.TemporaryDirectory() + self.config_file = FakeConfigFile() + # Un kdbx déjà configuré : le flux saute `VaultScreen` et va droit à + # `AccountScreen`, ce que couvrent les tests de cette classe. + self.config_file.set_config_value( + ["kdbx", "path"], "/already/configured.kdbx" + ) + self.secret_store = FakeSecretStore() + + def tearDown(self): + from script.todo import todo_i18n + + # Rendre la langue telle qu'on l'a trouvée : ne pas reproduire sur + # les tests suivants la fuite qui a cassé ceux-ci. + todo_i18n._current_lang = self._old_lang + + if self._old_home is None: + os.environ.pop("HOME", None) + else: + os.environ["HOME"] = self._old_home + self.fake_home.cleanup() + self.cache_dir.cleanup() + + async def _mounted_app(self, sessions=None): + # `run_tui(run_app=False, ...)` ne renvoie rien : capter la première + # `App` construite, comme `test_mail_compose.py`. + import textual.app + + from script.todo.mail.tui import run_tui + + captured = [] + orig_init = textual.app.App.__init__ + + def capturing_init(app_self, *a, **kw): + orig_init(app_self, *a, **kw) + captured.append(app_self) + + textual.app.App.__init__ = capturing_init + try: + run_tui( + run_app=False, + sessions=sessions or [], + config_file=self.config_file, + secret_store=self.secret_store, + connect_fn=lambda account, password: FakeTransport(), + base=Path(self.cache_dir.name), + ) + finally: + textual.app.App.__init__ = orig_init + return captured[-1] + + +class TestAccountNodeAndBinding(TuiAccountCase): + async def test_add_account_leaf_is_at_the_bottom_of_the_tree(self): + from textual.widgets import Tree + + app = await self._mounted_app() + async with app.run_test() as pilot: + await pilot.pause() + tree = app.query_one("#folders", Tree) + labels = [str(child.label) for child in tree.root.children] + self.assertTrue( + any("Ajouter un compte" in label for label in labels) + ) + + async def test_n_opens_the_account_form(self): + from textual.screen import ModalScreen + from textual.widgets import Input + + app = await self._mounted_app() + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("n") + await pilot.pause() + self.assertIsInstance(app.screen, ModalScreen) + # Le kdbx est déjà configuré : c'est `AccountScreen`, pas + # `VaultScreen`, qui doit s'ouvrir — la présence de `#acc_name` + # le distingue sans exposer les classes imbriquées. + self.assertIsNotNone(app.screen.query_one("#acc_name", Input)) + + async def test_add_account_node_opens_the_same_screen_as_n(self): + from textual.screen import ModalScreen + from textual.widgets import Input, Tree + + app = await self._mounted_app() + async with app.run_test() as pilot: + await pilot.pause() + tree = app.query_one("#folders", Tree) + add_node = next( + child + for child in tree.root.children + if "Ajouter un compte" in str(child.label) + ) + tree.select_node(add_node) + tree.action_select_cursor() + await pilot.pause() + self.assertIsInstance(app.screen, ModalScreen) + self.assertIsNotNone(app.screen.query_one("#acc_name", Input)) + + +class TestAccountScreenSavesAndGoesLive(TuiAccountCase): + """La couture qui compte : un compte sauvegardé doit apparaître dans + `self.sessions` ET dans l'arbre, sans redémarrer le TUI.""" + + async def test_valid_submission_is_saved_and_usable_immediately(self): + from textual.screen import ModalScreen + from textual.widgets import Input, Select, Tree + + app = await self._mounted_app() + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("n") + await pilot.pause() + + screen = app.screen + screen.query_one("#acc_name", Input).value = "perso" + screen.query_one("#acc_email", Input).value = "moi@x.ca" + screen.query_one("#acc_password", Input).value = "hunter2" + self.assertEqual( + screen.query_one("#acc_preset", Select).value, "generic" + ) + screen.query_one("#acc_imap", Input).value = "imap.x.ca" + screen.query_one("#acc_smtp", Input).value = "smtp.x.ca" + + await pilot.press("ctrl+s") + await pilot.pause() + await app.workers.wait_for_complete() + await pilot.pause() + + # L'écran s'est refermé. + self.assertNotIsInstance(app.screen, ModalScreen) + + # Le fichier de comptes le confirme. + saved = mail_accounts.load() + self.assertEqual([a.name for a in saved], ["perso"]) + self.assertEqual( + self.secret_store.get(saved[0].secret_ref), "hunter2" + ) + + # ET le TUI en tient une session utilisable tout de suite. + self.assertEqual(len(app.sessions), 1) + self.assertEqual(app.sessions[0].account.name, "perso") + + # ET l'arbre la montre, sans redémarrer. + tree = app.query_one("#folders", Tree) + labels = [str(child.label) for child in tree.root.children] + self.assertTrue(any("perso" in label for label in labels)) + + async def test_cancel_creates_nothing(self): + from textual.screen import ModalScreen + from textual.widgets import Input + + app = await self._mounted_app() + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("n") + await pilot.pause() + app.screen.query_one("#acc_name", Input).value = "perso" + + await pilot.press("escape") + await pilot.pause() + + self.assertNotIsInstance(app.screen, ModalScreen) + self.assertEqual(mail_accounts.load(), []) + self.assertEqual(app.sessions, []) + + async def test_invalid_name_is_refused_and_the_screen_stays_open(self): + from textual.screen import ModalScreen + from textual.widgets import Input, Static + + app = await self._mounted_app() + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("n") + await pilot.pause() + + screen = app.screen + # `/` est refusé par `Account.__post_init__` : le message doit + # se lire sur l'écran, pas remonter en exception. + screen.query_one("#acc_name", Input).value = "per/so" + screen.query_one("#acc_email", Input).value = "moi@x.ca" + screen.query_one("#acc_password", Input).value = "hunter2" + + await pilot.press("ctrl+s") + await pilot.pause() + + self.assertIsInstance(app.screen, ModalScreen) + status = screen.query_one("#account_status", Static) + self.assertTrue(str(status.content)) + self.assertEqual(mail_accounts.load(), []) + + async def test_missing_password_is_refused_with_a_message(self): + from textual.screen import ModalScreen + from textual.widgets import Input, Static + + app = await self._mounted_app() + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("n") + await pilot.pause() + + screen = app.screen + screen.query_one("#acc_name", Input).value = "perso" + screen.query_one("#acc_email", Input).value = "moi@x.ca" + + await pilot.press("ctrl+s") + await pilot.pause() + + self.assertIsInstance(app.screen, ModalScreen) + status = screen.query_one("#account_status", Static) + self.assertTrue(str(status.content)) + self.assertEqual(mail_accounts.load(), []) + + +class TestVaultScreenFirst(TuiAccountCase): + """Sans kdbx configuré, `VaultScreen` s'ouvre avant `AccountScreen`, et + l'annuler annule tout le flux.""" + + def setUp(self): + super().setUp() + # Ce groupe teste justement l'ABSENCE de configuration. + self.config_file = FakeConfigFile() + + async def test_no_kdbx_configured_opens_vault_screen_first(self): + from textual.screen import ModalScreen + from textual.widgets import Input + + app = await self._mounted_app() + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("n") + await pilot.pause() + self.assertIsInstance(app.screen, ModalScreen) + self.assertIsNotNone(app.screen.query_one("#vault_path", Input)) + + async def test_cancelling_the_vault_cancels_the_whole_flow(self): + from textual.screen import ModalScreen + + app = await self._mounted_app() + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("n") + await pilot.pause() + + await pilot.press("escape") + await pilot.pause() + + self.assertNotIsInstance(app.screen, ModalScreen) + self.assertIsNone( + self.config_file.get_config_value(["kdbx", "path"]) + ) + + async def test_creating_the_vault_then_proceeds_to_the_account_form(self): + from textual.screen import ModalScreen + from textual.widgets import Input + + app = await self._mounted_app() + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("n") + await pilot.pause() + + vault_path = os.path.join(self.cache_dir.name, "new.kdbx") + app.screen.query_one("#vault_path", Input).value = vault_path + app.screen.query_one("#vault_password", Input).value = "hunter2" + app.screen.query_one("#vault_password_confirm", Input).value = ( + "hunter2" + ) + + await pilot.click("#vault_create") + await pilot.pause() + + self.assertIsInstance(app.screen, ModalScreen) + self.assertIsNotNone(app.screen.query_one("#acc_name", Input)) + self.assertTrue(os.path.isfile(vault_path)) + self.assertEqual( + self.config_file.get_config_value(["kdbx", "path"]), + vault_path, + ) + + async def test_mismatched_vault_passwords_are_refused(self): + from textual.screen import ModalScreen + from textual.widgets import Input, Static + + app = await self._mounted_app() + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("n") + await pilot.pause() + + vault_path = os.path.join(self.cache_dir.name, "new.kdbx") + app.screen.query_one("#vault_path", Input).value = vault_path + app.screen.query_one("#vault_password", Input).value = "hunter2" + app.screen.query_one("#vault_password_confirm", Input).value = ( + "autrechose" + ) + + await pilot.click("#vault_create") + await pilot.pause() + + self.assertIsInstance(app.screen, ModalScreen) + status = app.screen.query_one("#vault_status", Static) + self.assertTrue(str(status.content)) + self.assertFalse(os.path.isfile(vault_path)) + + +class TestVaultScreenSurvivesDiskErrors(TuiAccountCase): + """`_create`/`_choose` do real disk I/O (`create_kdbx`, + `ConfigFile.set_config_value`) and only caught `SecretError` — a plain + `OSError` (disque plein, permission refusée) escaped into Textual's own + handler, which renders every local, including the plaintext vault + password sitting right there in the same frame.""" + + def setUp(self): + super().setUp() + # Ce groupe teste justement l'ABSENCE de configuration : c'est + # `VaultScreen`, pas `AccountScreen`, qui est en cause ici. + self.config_file = FakeConfigFile() + + async def test_create_vault_oserror_keeps_the_screen_open(self): + from unittest.mock import patch + + from textual.screen import ModalScreen + from textual.widgets import Input, Static + + app = await self._mounted_app() + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("n") + await pilot.pause() + + vault_path = os.path.join(self.cache_dir.name, "new.kdbx") + app.screen.query_one("#vault_path", Input).value = vault_path + app.screen.query_one("#vault_password", Input).value = "hunter2" + app.screen.query_one("#vault_password_confirm", Input).value = ( + "hunter2" + ) + + with patch( + "script.todo.mail.account_setup.create_vault", + side_effect=OSError("disque plein"), + ): + await pilot.click("#vault_create") + await pilot.pause() + + # Toujours un ModalScreen : ni plantage, ni fermeture d'écran. + self.assertIsInstance(app.screen, ModalScreen) + status = app.screen.query_one("#vault_status", Static) + self.assertTrue(str(status.content)) + self.assertFalse(os.path.isfile(vault_path)) + + async def test_create_reports_an_unopenable_vault_itself(self): + """`_create` crée le fichier PUIS l'ouvre tout de suite, + symétriquement à `_choose` : un coffre créé mais inouvrable + (mauvaise entropie, corruption immédiate, ...) doit se signaler ICI, + pas plus tard sur `AccountScreen` — l'utilisateur peut encore agir + sur le coffre à cet instant précis.""" + from unittest.mock import patch + + from pykeepass.exceptions import CredentialsError + from textual.screen import ModalScreen + from textual.widgets import Input, Static + + app = await self._mounted_app() + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("n") + await pilot.pause() + + vault_path = os.path.join(self.cache_dir.name, "new.kdbx") + app.screen.query_one("#vault_path", Input).value = vault_path + app.screen.query_one("#vault_password", Input).value = "hunter2" + app.screen.query_one("#vault_password_confirm", Input).value = ( + "hunter2" + ) + + # `create_vault` (donc `create_kdbx`) tourne pour de vrai — le + # fichier existe. Seule l'OUVERTURE qui suit échoue. + with patch( + "pykeepass.PyKeePass", + side_effect=CredentialsError("mauvais mot de passe"), + ): + await pilot.click("#vault_create") + await pilot.pause() + + # Toujours `VaultScreen` : `#vault_path` n'existe que là, + # `AccountScreen` ne l'a jamais poussé. + self.assertIsInstance(app.screen, ModalScreen) + self.assertIsNotNone(app.screen.query_one("#vault_path", Input)) + status = app.screen.query_one("#vault_status", Static) + self.assertTrue(str(status.content)) + self.assertTrue(os.path.isfile(vault_path)) + + async def test_use_existing_vault_oserror_keeps_the_screen_open(self): + from unittest.mock import patch + + from textual.screen import ModalScreen + from textual.widgets import Input, Static + + app = await self._mounted_app() + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("n") + await pilot.pause() + + existing_path = os.path.join(self.cache_dir.name, "existing.kdbx") + with open(existing_path, "wb") as handle: + handle.write(b"not a real kdbx, just a file") + app.screen.query_one("#vault_path", Input).value = existing_path + app.screen.query_one("#vault_password", Input).value = "hunter2" + + with patch( + "script.todo.mail.account_setup.use_existing_vault", + side_effect=OSError("permission refusée"), + ): + await pilot.click("#vault_choose") + await pilot.pause() + + self.assertIsInstance(app.screen, ModalScreen) + status = app.screen.query_one("#vault_status", Static) + self.assertTrue(str(status.content)) + + async def test_guard_covers_the_first_statement_in_create(self): + """Protection structurelle (round 3) : trois manches ont chacune + trouvé un appel qui tournait hors garde parce que le `try` ouvrait + trop tard. Ce test casse la TOUTE PREMIÈRE lecture faite à + l'intérieur du `try` de `_create` (`#vault_path`) — s'il repasse au + rouge, c'est que le `try` a de nouveau été repoussé plus bas.""" + from textual.screen import ModalScreen + from textual.widgets import Input, Static + + app = await self._mounted_app() + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("n") + await pilot.pause() + + screen = app.screen + orig_query_one = screen.query_one + + def failing_query_one(selector, *args, **kwargs): + if selector == "#vault_path": + raise RuntimeError("échec simulé sur la 1re lecture") + return orig_query_one(selector, *args, **kwargs) + + screen.query_one = failing_query_one + + await pilot.click("#vault_create") + await pilot.pause() + + self.assertIsInstance(app.screen, ModalScreen) + status = orig_query_one("#vault_status", Static) + self.assertTrue(str(status.content)) + + async def test_guard_covers_the_first_statement_in_choose(self): + """Même protection que ci-dessus, pour `_choose`.""" + from textual.screen import ModalScreen + from textual.widgets import Input, Static + + app = await self._mounted_app() + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("n") + await pilot.pause() + + screen = app.screen + orig_query_one = screen.query_one + + def failing_query_one(selector, *args, **kwargs): + if selector == "#vault_path": + raise RuntimeError("échec simulé sur la 1re lecture") + return orig_query_one(selector, *args, **kwargs) + + screen.query_one = failing_query_one + + await pilot.click("#vault_choose") + await pilot.pause() + + self.assertIsInstance(app.screen, ModalScreen) + status = orig_query_one("#vault_status", Static) + self.assertTrue(str(status.content)) + + +class TestAccountScreenSurvivesDiskErrors(TuiAccountCase): + """`action_save` reads `mail_accounts.load()` to compute `existing` + BEFORE its own try/except — an `OSError` there (accounts.json illisible) + escaped uncaught, with the plaintext account password still a local in + that same frame.""" + + async def test_load_oserror_keeps_the_screen_open(self): + from unittest.mock import patch + + from textual.screen import ModalScreen + from textual.widgets import Input, Static + + app = await self._mounted_app() + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("n") + await pilot.pause() + + screen = app.screen + screen.query_one("#acc_name", Input).value = "perso" + screen.query_one("#acc_email", Input).value = "moi@x.ca" + screen.query_one("#acc_password", Input).value = "hunter2" + screen.query_one("#acc_imap", Input).value = "imap.x.ca" + screen.query_one("#acc_smtp", Input).value = "smtp.x.ca" + + with patch( + "script.todo.mail.accounts.load", + side_effect=OSError("disque plein"), + ): + await pilot.press("ctrl+s") + await pilot.pause() + + self.assertIsInstance(app.screen, ModalScreen) + status = screen.query_one("#account_status", Static) + self.assertTrue(str(status.content)) + self.assertEqual(app.sessions, []) + + async def test_pykeepass_error_during_save_keeps_the_screen_open(self): + """`secret_store.set()` peut atteindre `PyKeePass(...)` pour la + première fois ici (coffre tout juste créé sans ouverture immédiate, + avant le fix `_create`, ou coffre modifié entre-temps) : pykeepass + lève `CredentialsError`/`HeaderChecksumError`/..., qui ne sont PAS + des `OSError` — le guard doit rester `except Exception` pour ne + jamais laisser passer `password` en clair vers la traceback de + Textual.""" + from pykeepass.exceptions import CredentialsError + from textual.screen import ModalScreen + from textual.widgets import Input, Static + + def raise_credentials_error(ref, value): + raise CredentialsError("mauvais mot de passe") + + self.secret_store.set = raise_credentials_error + + app = await self._mounted_app() + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("n") + await pilot.pause() + + screen = app.screen + screen.query_one("#acc_name", Input).value = "perso" + screen.query_one("#acc_email", Input).value = "moi@x.ca" + screen.query_one("#acc_password", Input).value = "hunter2" + screen.query_one("#acc_imap", Input).value = "imap.x.ca" + screen.query_one("#acc_smtp", Input).value = "smtp.x.ca" + + await pilot.press("ctrl+s") + await pilot.pause() + + self.assertIsInstance(app.screen, ModalScreen) + status = screen.query_one("#account_status", Static) + self.assertTrue(str(status.content)) + self.assertEqual(app.sessions, []) + self.assertEqual(mail_accounts.load(), []) + + async def test_available_backends_error_keeps_the_screen_open(self): + """Le cinquième chemin (round 3) : `self.secret_store + .available_backends()` tournait hors de toute garde, alors que + `password` était déjà une variable locale. Le vrai + `keyring.core.load_config()` peut lever `ModuleNotFoundError` ou + `AttributeError` sur un backend configuré mais cassé — ni l'une ni + l'autre n'est une `OSError`.""" + from textual.screen import ModalScreen + from textual.widgets import Input, Static + + def raise_broken_backend(): + raise ModuleNotFoundError("backend keyring introuvable") + + self.secret_store.available_backends = raise_broken_backend + + app = await self._mounted_app() + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("n") + await pilot.pause() + + screen = app.screen + screen.query_one("#acc_name", Input).value = "perso" + screen.query_one("#acc_email", Input).value = "moi@x.ca" + screen.query_one("#acc_password", Input).value = "hunter2" + screen.query_one("#acc_imap", Input).value = "imap.x.ca" + screen.query_one("#acc_smtp", Input).value = "smtp.x.ca" + + await pilot.press("ctrl+s") + await pilot.pause() + + self.assertIsInstance(app.screen, ModalScreen) + status = screen.query_one("#account_status", Static) + self.assertTrue(str(status.content)) + self.assertEqual(app.sessions, []) + + async def test_guard_covers_the_first_statement_in_action_save(self): + """Protection structurelle (round 3) : casse la TOUTE PREMIÈRE + lecture faite à l'intérieur du `try` d'`action_save` (`#acc_name`) + — si ce test repasse au rouge, c'est que le `try` a de nouveau été + repoussé après cette ligne.""" + from textual.screen import ModalScreen + from textual.widgets import Static + + app = await self._mounted_app() + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("n") + await pilot.pause() + + screen = app.screen + orig_query_one = screen.query_one + + def failing_query_one(selector, *args, **kwargs): + if selector == "#acc_name": + raise RuntimeError("échec simulé sur la 1re lecture") + return orig_query_one(selector, *args, **kwargs) + + screen.query_one = failing_query_one + + await pilot.press("ctrl+s") + await pilot.pause() + + self.assertIsInstance(app.screen, ModalScreen) + status = orig_query_one("#account_status", Static) + self.assertTrue(str(status.content)) + + +class TestPasswordClearedBeforeDismiss(TuiAccountCase): + """Round 4 : `password`/`confirm` ne sont pas scopés au `try` par + Python — ils restent des noms liés dans le cadre de + `_create`/`_choose`/`action_save` jusqu'au retour de la fonction, garde + ou pas. Round 3 affirmait, à tort, que le `try` "s'étend jusqu'au + dismiss compris" ; il ne l'atteint pas textuellement. + + Vérifié avant d'écrire quoi que ce soit (Textual 8.2.8, + `textual/screen.py:130` et `textual/message_pump.py:507-519,695-704`, + puis confirmé empiriquement par un script autonome) : + `Screen.dismiss()` ne rappelle PAS son callback de résultat + directement — `ResultCallback.__call__` fait + `self.requester.call_next(self.callback, result)`, qui EMPILE l'appel + pour le cycle de message SUIVANT. Ce callback (`_after_account_added`/ + `_after_vault_screen`) tourne donc APRÈS que cette méthode soit + retournée pour de bon, dans un cadre d'appel disjoint — une exception + qui y survient n'inclut PAS le cadre de `action_save`/`_create`/ + `_choose` dans sa traceback (vérifié : marcher `exc.__traceback__` + depuis un tel échec ne trouve jamais ce cadre). Le "sixième chemin" tel + que décrit (le cadre appelant vivant pendant un callback synchrone) + n'est donc pas démontré sur cette version de Textual. + + Ce qui reste vrai et vaut la peine d'être gardé : `password`/`confirm` + n'ont plus aucun usage après le `try`, et les mettre à `None` avant + `dismiss()` ne coûte rien — de la défense en profondeur, pas la + fermeture d'une fuite démontrée. Ces tests vérifient donc la propriété + réelle du code : au moment où `dismiss()` est appelé, le mot de passe + n'est plus dans les locales de l'appelant — sans prétendre qu'un + callback en aval y aurait accès de toute façon.""" + + def setUp(self): + super().setUp() + # Par défaut : `_create`/`_choose` exigent l'ABSENCE de kdbx + # configuré. Le test `action_save` reconfigure un kdbx localement + # avant de monter l'app. + self.config_file = FakeConfigFile() + + async def test_password_is_cleared_before_dismiss_in_action_save(self): + import sys + + from textual.widgets import Input + + self.config_file.set_config_value( + ["kdbx", "path"], "/already/configured.kdbx" + ) + + app = await self._mounted_app() + captured = {} + + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("n") + await pilot.pause() + + screen = app.screen + screen.query_one("#acc_name", Input).value = "perso" + screen.query_one("#acc_email", Input).value = "moi@x.ca" + screen.query_one("#acc_password", Input).value = "hunter2" + screen.query_one("#acc_imap", Input).value = "imap.x.ca" + screen.query_one("#acc_smtp", Input).value = "smtp.x.ca" + + orig_dismiss = type(screen).dismiss + + def spying_dismiss(self_screen, result=None): + # Le cadre de l'APPELANT de `dismiss()` est `action_save` + # lui-même : c'est exactement ce qu'on veut inspecter. + caller = sys._getframe(1) + captured["password"] = caller.f_locals.get( + "password", "absent-des-locales" + ) + return orig_dismiss(self_screen, result) + + screen.dismiss = spying_dismiss.__get__(screen) + + await pilot.press("ctrl+s") + await pilot.pause() + + self.assertIn("password", captured) + self.assertIsNone(captured["password"]) + + async def test_password_is_cleared_before_dismiss_in_create(self): + import sys + + from textual.widgets import Input + + app = await self._mounted_app() + captured = {} + + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("n") + await pilot.pause() + + screen = app.screen + vault_path = os.path.join(self.cache_dir.name, "new.kdbx") + screen.query_one("#vault_path", Input).value = vault_path + screen.query_one("#vault_password", Input).value = "hunter2" + screen.query_one("#vault_password_confirm", Input).value = ( + "hunter2" + ) + + orig_dismiss = type(screen).dismiss + + def spying_dismiss(self_screen, result=None): + caller = sys._getframe(1) + captured["password"] = caller.f_locals.get( + "password", "absent-des-locales" + ) + captured["confirm"] = caller.f_locals.get( + "confirm", "absent-des-locales" + ) + return orig_dismiss(self_screen, result) + + screen.dismiss = spying_dismiss.__get__(screen) + + await pilot.click("#vault_create") + await pilot.pause() + + self.assertIn("password", captured) + self.assertIsNone(captured["password"]) + self.assertIsNone(captured["confirm"]) + + async def test_password_is_cleared_before_dismiss_in_choose(self): + import sys + + from pykeepass import create_database + from textual.widgets import Input + + vault_path = os.path.join(self.cache_dir.name, "existing.kdbx") + create_database(vault_path, password="hunter2") + + app = await self._mounted_app() + captured = {} + + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("n") + await pilot.pause() + + screen = app.screen + screen.query_one("#vault_path", Input).value = vault_path + screen.query_one("#vault_password", Input).value = "hunter2" + + orig_dismiss = type(screen).dismiss + + def spying_dismiss(self_screen, result=None): + caller = sys._getframe(1) + captured["password"] = caller.f_locals.get( + "password", "absent-des-locales" + ) + return orig_dismiss(self_screen, result) + + screen.dismiss = spying_dismiss.__get__(screen) + + await pilot.click("#vault_choose") + await pilot.pause() + + self.assertIn("password", captured) + self.assertIsNone(captured["password"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_mail_tui_help.py b/test/test_mail_tui_help.py new file mode 100644 index 0000000..e751b2e --- /dev/null +++ b/test/test_mail_tui_help.py @@ -0,0 +1,627 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""La fenêtre d'aide, touche `h` : les raccourcis du client et quelques +repères, fermée par Échap. + +Le piège que ce fichier existe pour interdire : une aide qui MENT. La liste +des touches est engendrée depuis `MailApp.BINDINGS` ; le test central +(`TestHelpIsGeneratedFromBindings`) vérifie donc l'ÉCRAN contre cette liste, +liaison par liaison, sans jamais répéter une touche en dur — ajouter une +liaison demain la fait apparaître sans toucher à ce fichier, tandis qu'une +liste recopiée à la main dans l'écran d'aide le ferait échouer. + +Comme `test_mail_tui_splitter.py` : ce qui compte se mesure sur +l'application montée pour de vrai, et sur ce qui est RÉELLEMENT rendu +(`Compositor.render_strips`), jamais sur un attribut interne de l'écran. +""" +import os +import re +import tempfile +import unittest +from pathlib import Path + +from script.todo import todo_i18n +from script.todo.mail.accounts import account_from_preset +from script.todo.mail.store import Store +from script.todo.mail.tui import Session + + +def collapse(text: str) -> str: + """Le texte, espaces (et retours à la ligne) réduits à un seul espace. + + Une description longue se replie sur plusieurs lignes DANS sa colonne : + la chercher telle quelle dans le rendu échouerait alors sur un simple + repli, pas sur une vraie absence. Rich replie aux limites de mots, donc + cette normalisation la reconstitue. + """ + return re.sub(r"\s+", " ", text) + + +class HelpCase(unittest.IsolatedAsyncioTestCase): + """Monte `MailApp` pour de vrai, `$HOME` détourné vers un dossier + jetable — même motif que `test_mail_tui_log.py` : `on_mount` lit + `todo_prefs`, qui crée `~/.erplibre` s'il est absent. + + La langue est posée en écrivant `todo_i18n._current_lang`, JAMAIS par + `todo_i18n.set_lang` : celle-ci réécrit `./env_var.sh`, un fichier réel + du dépôt (`EL_LANG="fr"`) — un test ne doit pas changer la langue du + poste de qui le lance. Elle est posée AVANT de monter l'application : + `MailApp` est défini À L'INTÉRIEUR de `run_tui`, donc les `t()` de ses + `BINDINGS` sont évalués à CET appel, pas à l'import du module. + """ + + def setUp(self): + self.fake_home = tempfile.TemporaryDirectory() + self._old_home = os.environ.get("HOME") + os.environ["HOME"] = self.fake_home.name + self._old_lang = todo_i18n._current_lang + + self.cache_dir = tempfile.TemporaryDirectory() + self.account = account_from_preset("perso", "moi@x.ca", "generic") + self.account.cache_mode = "clear" + self.store = Store( + self.account, mode="clear", base=Path(self.cache_dir.name) + ) + self.store.open() + self.session = Session(self.account, self.store, None, password="x") + + def tearDown(self): + self.store.close() + todo_i18n._current_lang = self._old_lang + if self._old_home is None: + os.environ.pop("HOME", None) + else: + os.environ["HOME"] = self._old_home + self.fake_home.cleanup() + self.cache_dir.cleanup() + + async def _mounted_app(self, lang: str = "fr"): + import textual.app + + from script.todo.mail.tui import run_tui + + todo_i18n._current_lang = lang + captured = [] + orig_init = textual.app.App.__init__ + + def capturing_init(app_self, *a, **kw): + orig_init(app_self, *a, **kw) + captured.append(app_self) + + textual.app.App.__init__ = capturing_init + try: + run_tui(run_app=False, sessions=[self.session]) + finally: + textual.app.App.__init__ = orig_init + return captured[-1] + + def screen_lines(self, app) -> list[str]: + """Les lignes RÉELLEMENT à l'écran. + + `Compositor.render_strips` (`textual/_compositor.py:1185`, vérifié + dans la source de Textual 8.2.8) compose tous les widgets visibles de + l'écran courant et rend une bande par ligne — c'est exactement ce que + le terminal recevrait. Un `Static.render()` dirait, lui, ce que le + widget A ENVIE d'afficher, y compris quand il est hors du cadre ou + caché derrière autre chose. + """ + return [strip.text for strip in app.screen._compositor.render_strips()] + + def shortcut_rows(self, lines: list[str]) -> list[str]: + """Les lignes du TABLEAU des raccourcis, découpées entre ses deux + titres — jamais l'écran entier : la prose, elle, nomme aussi des + touches, et un test qui ne saurait pas les distinguer passerait pour + de mauvaises raisons. + """ + from script.todo.todo_i18n import t + + start = next( + index + for index, line in enumerate(lines) + if t("mail_help_keys_heading") in line + ) + end = next( + index + for index, line in enumerate(lines) + if t("mail_help_notes_heading") in line + ) + return [ + line.strip() for line in lines[start + 1 : end] if line.strip() + ] + + def shown_bindings(self, app) -> list: + from textual.binding import Binding + + return [ + binding + for binding in Binding.make_bindings(app.BINDINGS) + if binding.show + ] + + +class TestHelpOpensAndCloses(HelpCase): + async def test_h_opens_the_help_window(self): + from textual.screen import ModalScreen + + from script.todo.todo_i18n import t + + app = await self._mounted_app() + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + await pilot.press("h") + await pilot.pause() + + self.assertIsInstance(app.screen, ModalScreen) + self.assertIn( + t("mail_help_title"), + collapse("\n".join(self.screen_lines(app))), + ) + + async def test_escape_closes_it_and_takes_its_text_off_the_screen(self): + from textual.screen import ModalScreen + + from script.todo.todo_i18n import t + + app = await self._mounted_app() + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + await pilot.press("h") + await pilot.pause() + self.assertIsInstance(app.screen, ModalScreen) + + await pilot.press("escape") + await pilot.pause() + + self.assertNotIsInstance(app.screen, ModalScreen) + # Fermée pour de vrai : plus rien de l'aide n'est rendu — un + # `dismiss()` qui laisserait l'écran empilé passerait le premier + # test (« ce n'est plus un ModalScreen ») sans passer celui-ci. + self.assertNotIn( + t("mail_help_title"), + collapse("\n".join(self.screen_lines(app))), + ) + + async def test_h_pressed_twice_does_not_stack_a_second_help_window(self): + """Un Échap doit suffire à sortir, même après avoir tapé `h` deux + fois — ce que fait quelqu'un qui ne sait plus où il en est, donc + exactement le public de cette fenêtre. + + C'est aussi le garde-fou de l'absence de `priority=True` sur `h` + (voir `MailApp.BINDINGS`) : sans priorité, les liaisons de `MailApp` + ne sont plus consultées dès qu'un écran modal est posé, donc le + second `h` ne fait rien. AVEC une priorité, elles le sont encore + (`App._check_bindings` lit alors la chaîne NON tronquée, + `app.py:3978`), une deuxième aide s'empile, et cet Échap n'en + referme qu'une : l'aide resterait à l'écran. Mesuré dans les deux + sens — le raisonnement seul s'est déjà trompé une fois sur ce + point. + """ + from textual.screen import ModalScreen + + from script.todo.todo_i18n import t + + app = await self._mounted_app() + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + await pilot.press("h") + await pilot.pause() + await pilot.press("h") + await pilot.pause() + + await pilot.press("escape") + await pilot.pause() + + self.assertNotIn( + t("mail_help_title"), + collapse("\n".join(self.screen_lines(app))), + "un second `h` a empilé une deuxième fenêtre d'aide", + ) + self.assertNotIsInstance(app.screen, ModalScreen) + + +class TestHelpIsGeneratedFromBindings(HelpCase): + """Le test qui vaut ce fichier : l'aide est vérifiée CONTRE + `MailApp.BINDINGS`, jamais contre une liste écrite ici. + """ + + async def test_every_shown_binding_is_on_screen_with_its_description(self): + app = await self._mounted_app() + # Fenêtre haute : l'aide défile (voir `TestHelpFitsASmallWindow`), + # or ce test-ci veut voir TOUTES les lignes à la fois. + async with app.run_test(size=(100, 45)) as pilot: + await app.workers.wait_for_complete() + await pilot.press("h") + await pilot.pause() + + bindings = self.shown_bindings(app) + # Garde-fou : une liste vide (ou amputée) ferait passer la boucle + # ci-dessous sans rien vérifier du tout. + self.assertGreaterEqual(len(bindings), 15) + + rows = self.shortcut_rows(self.screen_lines(app)) + joined = collapse(" ".join(rows)) + for binding in bindings: + key_display = app.get_key_display(binding) + self.assertTrue( + any(row.startswith(key_display) for row in rows), + f"touche {binding.key!r} absente de l'aide", + ) + self.assertIn( + collapse(binding.description), + joined, + f"description de {binding.key!r} absente de l'aide", + ) + + async def test_the_table_has_exactly_one_row_per_shown_binding(self): + """Une liaison MANQUANTE, mais aussi une touche EN TROP (celle que + laisserait une liste recopiée après le retrait d'une liaison) : le + compte des lignes du tableau attrape les deux. Vrai parce que la + fenêtre de test est assez large pour qu'aucune description ne se + replie sur une deuxième ligne. + """ + app = await self._mounted_app() + async with app.run_test(size=(100, 45)) as pilot: + await app.workers.wait_for_complete() + await pilot.press("h") + await pilot.pause() + + rows = self.shortcut_rows(self.screen_lines(app)) + self.assertEqual(len(rows), len(self.shown_bindings(app))) + + async def test_a_hidden_binding_is_not_listed_as_a_shortcut(self): + """`escape` (« Retour », le plein écran) est `show=False` : le pied + d'écran ne la montre pas, l'aide non plus. Son rôle DANS cette + fenêtre — fermer — est dit en prose, pas emprunté à cette liaison. + """ + from script.todo.todo_i18n import t + + app = await self._mounted_app() + async with app.run_test(size=(100, 45)) as pilot: + await app.workers.wait_for_complete() + await pilot.press("h") + await pilot.pause() + + rows = self.shortcut_rows(self.screen_lines(app)) + self.assertNotIn(t("mail_back_binding"), " ".join(rows)) + # La prose, elle, dit bien comment sortir. + screen = collapse("\n".join(self.screen_lines(app))) + self.assertIn(collapse(t("mail_help_close_hint")), screen) + + async def test_symbol_keys_are_shown_as_symbols_not_as_words(self): + """`Binding("plus", ...)` doit se lire `+`, pas « plus » — c'est ce + que l'utilisateur presse. + """ + app = await self._mounted_app() + async with app.run_test(size=(100, 45)) as pilot: + await app.workers.wait_for_complete() + await pilot.press("h") + await pilot.pause() + + rows = self.shortcut_rows(self.screen_lines(app)) + bindings = {b.key: b for b in self.shown_bindings(app)} + for key, symbol in ( + ("plus", "+"), + ("minus", "-"), + ("slash", "/"), + ): + description = bindings[key].description + row = next(row for row in rows if description in row) + self.assertTrue( + row.startswith(symbol), + f"{key!r} affichée « {row} » au lieu de « {symbol} »", + ) + self.assertNotIn(key, row) + + async def test_upper_and_lower_case_keys_stay_distinguishable(self): + """`r`/`R` et `a`/`A` sont quatre actions différentes : une aide qui + les afficherait pareil enverrait l'utilisateur presser la mauvaise. + """ + app = await self._mounted_app() + async with app.run_test(size=(100, 45)) as pilot: + await app.workers.wait_for_complete() + await pilot.press("h") + await pilot.pause() + + rows = self.shortcut_rows(self.screen_lines(app)) + bindings = {b.key: b for b in self.shown_bindings(app)} + for lower, upper in (("r", "R"), ("a", "A")): + lower_row = next(row for row in rows if row.startswith(lower)) + upper_row = next(row for row in rows if row.startswith(upper)) + self.assertNotEqual(lower_row, upper_row) + self.assertIn(bindings[lower].description, lower_row) + self.assertIn(bindings[upper].description, upper_row) + + +class TestNoBindingFiresUnderAModalScreen(HelpCase): + """La classe de bogues dont `h` et `z` ne sont que deux cas : SOUS un + écran modal, AUCUNE liaison de `MailApp` ne doit se déclencher. + + Textual ne tronque la chaîne de liaisons au dernier écran modal que pour + les liaisons SANS priorité (`Screen._modal_binding_chain`, + `screen.py:449`, lue par `App._check_bindings`, `app.py:3978`). Une + priorité posée sur n'importe quelle liaison de `MailApp` la ferait donc + tourner pendant qu'un modal est à l'écran — mesuré : `z` y pose + `fullscreen` sur `#panes` SANS que rien ne bouge (le modal couvre), et + la classe est encore là après le renvoi du modal. + + Un test par touche ne garderait la classe que jusqu'où va notre patience + à recopier. Ces deux-ci partent donc de `MailApp.BINDINGS` — la même + source que le tableau de l'aide — et couvrent gratuitement la prochaine + liaison ajoutée. + """ + + # `escape` est la seule touche exclue : sous un modal, elle regarde + # LÉGITIMEMENT le modal (c'est sa liaison à lui qui la sert, et c'est + # ainsi qu'on referme l'aide). Toutes les autres liaisons de `MailApp` + # sont dans la boucle, sans exception — un jour où l'une d'elles + # demanderait un traitement à part, c'est un signal à remonter, pas une + # ligne à ajouter ici. + KEYS_LEFT_TO_THE_MODAL = ("escape",) + + def _keys_under_test(self, app) -> list[tuple[str, str]]: + """Les couples (touche, nom de l'action attendue), dans l'ordre de + `MailApp.BINDINGS`.""" + from textual.binding import Binding + + pairs = [] + for binding in Binding.make_bindings(app.BINDINGS): + # `Binding("home,ctrl+a", ...)` est légal chez Textual : une + # liaison peut porter plusieurs touches. + for key in binding.key.split(","): + if key and key not in self.KEYS_LEFT_TO_THE_MODAL: + pairs.append((key, f"action_{binding.action}")) + return pairs + + def _spy_on_every_action(self, app) -> list[str]: + """Remplace chaque `action_*` visée par une liaison de `MailApp` par + un mouchard qui n'appelle PAS l'action réelle. + + Deux raisons de ne pas rappeler l'original : `q` mettrait fin à + l'application au milieu du test, et `c` empilerait un écran + d'écriture qui avalerait les touches suivantes — la boucle + s'arrêterait de mesurer après la première fuite au lieu de toutes + les lister. Textual résout une action par + `getattr(namespace, f"action_{nom}")`, donc poser l'attribut sur + l'INSTANCE suffit à intercepter. + """ + from textual.binding import Binding + + fired: list[str] = [] + for binding in Binding.make_bindings(app.BINDINGS): + name = f"action_{binding.action}" + + def spy(_name=name): + fired.append(_name) + + setattr(app, name, spy) + return fired + + async def test_the_keys_do_fire_when_no_modal_is_open(self): + """Le contrôle POSITIF, sans lequel le test suivant passerait aussi + bien si `pilot.press` n'envoyait rien du tout. + + Les mêmes touches, la même boucle, mais sans écran modal : chacune + doit déclencher SON action. Ce test garde aussi le mécanisme du + mouchard lui-même (une action nommée autrement qu'en + `action_` ne serait pas interceptée, et se verrait ici). + """ + app = await self._mounted_app() + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + pairs = self._keys_under_test(app) + fired = self._spy_on_every_action(app) + + for key, _action in pairs: + await pilot.press(key) + await pilot.pause() + + # Liste ORDONNÉE, pas un compte : une touche muette compensée + # par une autre qui tirerait deux fois passerait un simple + # décompte, et le contrôle ne contrôlerait plus rien. + self.assertEqual(fired, [action for _key, action in pairs]) + + async def test_no_binding_fires_while_the_help_is_open(self): + """La garantie : aucune de ces touches ne déclenche quoi que ce soit + pendant que l'aide est posée. + """ + app = await self._mounted_app() + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + await pilot.press("h") + await pilot.pause() + # Le mouchard est posé APRÈS l'ouverture : `action_show_help` + # doit avoir tourné pour de vrai, c'est lui qui met le modal en + # place. + fired = self._spy_on_every_action(app) + + for key, _action in self._keys_under_test(app): + await pilot.press(key) + await pilot.pause() + + self.assertEqual( + fired, [], f"des liaisons ont tourné sous le modal : {fired}" + ) + + async def test_the_client_underneath_is_untouched(self): + """Le même parcours, mais avec les VRAIES actions en place : ce que + les mouchards ne peuvent pas montrer, c'est l'état laissé derrière. + + Trois mesures, celles que la tâche 26 a prises à la main : l'aide est + toujours l'écran actif, `#panes` n'a pas pris la classe `fullscreen` + (l'effet SILENCIEUX qu'un `z` prioritaire produirait), et un SEUL + Échap ramène le client entier — trois volets affichés. + """ + from textual.screen import ModalScreen + + from script.todo.todo_i18n import t + + app = await self._mounted_app() + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + await pilot.press("h") + await pilot.pause() + + for key, _action in self._keys_under_test(app): + await pilot.press(key) + await pilot.pause() + + self.assertIn( + t("mail_help_title"), + collapse("\n".join(self.screen_lines(app))), + ) + self.assertFalse( + app.query_one("#panes").has_class("fullscreen"), + "une touche a basculé le plein écran sous le modal", + ) + + await pilot.press("escape") + await pilot.pause() + + self.assertNotIsInstance(app.screen, ModalScreen) + for pane in ("#folders", "#list_pane", "#preview"): + self.assertTrue( + app.query_one(pane).display, + f"{pane} a disparu après le passage sous le modal", + ) + + +class TestHelpDoesNotStealTyping(HelpCase): + async def test_h_typed_in_the_search_box_types_an_h(self): + """Taper « h » dans la recherche doit écrire un « h », jamais ouvrir + l'aide. + + Ce test fixe le COMPORTEMENT, pas le mécanisme : il passe aussi avec + `priority=True` sur `h` (mesuré), parce que `Screen._binding_chain` + (`screen.py:428-435`, Textual 8.2.8) retire les liaisons de tout + caractère imprimable dès que le widget focalisé déclare pouvoir le + consommer (`Input.check_consume_key`) — avant la répartition, y + compris prioritaire. Ce qui interdit vraiment la priorité est écrit + là où elle serait posée (`MailApp.BINDINGS`), et gardé par + `test_h_pressed_twice_does_not_stack_a_second_help_window`. + """ + from textual.screen import ModalScreen + from textual.widgets import Input + + app = await self._mounted_app() + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + await pilot.press("slash") + await pilot.pause() + self.assertIsInstance(app.focused, Input) + + await pilot.press("h") + await pilot.pause() + + self.assertEqual(app.query_one("#search", Input).value, "h") + self.assertNotIsInstance(app.screen, ModalScreen) + + +class TestHelpFitsASmallWindow(HelpCase): + async def test_nothing_is_out_of_reach_in_a_small_terminal(self): + """~19 raccourcis plus la prose ne tiennent pas dans 20 lignes : ce + qui dépasse doit rester ATTEIGNABLE en défilant, jamais coupé pour + toujours — c'est exactement la partie basse de la liste, donc les + touches ajoutées en DERNIER, qu'une fenêtre sans ascenseur + perdrait. + """ + from script.todo.todo_i18n import t + + app = await self._mounted_app() + async with app.run_test(size=(80, 20)) as pilot: + await app.workers.wait_for_complete() + await pilot.press("h") + await pilot.pause() + + body = app.screen.query_one("#help_body") + # Sans ça, le test passerait aussi sur une fenêtre où tout tient + # déjà — il ne prouverait plus rien du défilement. + self.assertGreater(body.max_scroll_y, 0) + first_screen = collapse(" ".join(self.screen_lines(app))) + self.assertNotIn(collapse(t("mail_help_close_hint")), first_screen) + + seen = first_screen + for _ in range(40): + if body.scroll_offset.y >= body.max_scroll_y: + break + body.scroll_relative(y=4, animate=False) + await pilot.pause() + seen += " " + collapse(" ".join(self.screen_lines(app))) + + for binding in self.shown_bindings(app): + self.assertIn( + collapse(binding.description), + seen, + f"« {binding.description} » reste inatteignable", + ) + self.assertIn(collapse(t("mail_help_close_hint")), seen) + + +class TestBindingDescriptionsAreTranslated(HelpCase): + async def test_every_description_comes_from_the_translation_table(self): + """Aucune description de `MailApp` ne doit rester écrite en dur. + + Formulation retenue : chaque description doit être la valeur `fr` ET + `en` d'UNE MÊME clé `mail_*` de `TRANSLATIONS`. Deux formulations + plus simples ont été écartées : + + - « la description diffère entre fr et en » serait FAUSSE pour une + traduction légitimement identique dans les deux langues + (« Sync »). + - « la description est une valeur du dictionnaire » passerait pour + une chaîne en dur comme « Quitter », qui est aussi la valeur `fr` + de la clé générale `Quit`. + + Celle-ci n'a pas ces trous : une description en dur donnerait la même + chaîne française dans les DEUX langues, et il faudrait pour la + laisser passer une clé `mail_*` dont le `fr` et l'`en` valent tous + deux ce français-là. + """ + from textual.binding import Binding + + from script.todo.todo_i18n import TRANSLATIONS + + app_fr = await self._mounted_app(lang="fr") + app_en = await self._mounted_app(lang="en") + fr_bindings = list(Binding.make_bindings(app_fr.BINDINGS)) + en_bindings = list(Binding.make_bindings(app_en.BINDINGS)) + + self.assertGreaterEqual(len(fr_bindings), 15) + self.assertEqual(len(fr_bindings), len(en_bindings)) + for fr_binding, en_binding in zip(fr_bindings, en_bindings): + self.assertEqual(fr_binding.key, en_binding.key) + matches = [ + key + for key, entry in TRANSLATIONS.items() + if key.startswith("mail_") + and entry.get("fr") == fr_binding.description + and entry.get("en") == en_binding.description + ] + self.assertTrue( + matches, + f"description de {fr_binding.key!r} non traduite :" + f" {fr_binding.description!r} (fr) /" + f" {en_binding.description!r} (en)", + ) + + async def test_the_help_window_is_in_english_under_en(self): + """La conséquence visible de la conversion : sous `en`, l'aide — + dont TOUT le contenu vient de ces descriptions — est en anglais. + """ + from script.todo.todo_i18n import t + + app = await self._mounted_app(lang="en") + async with app.run_test(size=(100, 45)) as pilot: + await app.workers.wait_for_complete() + await pilot.press("h") + await pilot.pause() + + screen = collapse("\n".join(self.screen_lines(app))) + self.assertIn(t("mail_help_title"), screen) + self.assertIn(t("mail_add_account_binding"), screen) + self.assertNotIn("Nouveau compte", screen) + self.assertIn(collapse(t("mail_help_sync")), screen) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_mail_tui_layout.py b/test/test_mail_tui_layout.py new file mode 100644 index 0000000..aba6235 --- /dev/null +++ b/test/test_mail_tui_layout.py @@ -0,0 +1,405 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Dispositions de volets commutables (touche `v`) : `columns` (défaut, +dossiers | liste | aperçu), `split` (dossiers à gauche ; liste au-dessus de +l'aperçu) et `stacked` (les trois empilés). + +`resolve_layout`/`next_layout` sont des fonctions pures, testées sans écran. +Le reste — la classe CSS réellement posée sur `#panes`, la persistance dans +`todo_prefs`, et surtout la NON-perturbation de ce que l'utilisateur regarde +— n'a de sens que sur l'application montée pour de vrai, comme +`test_mail_tui_refresh.py`. +""" +import os +import tempfile +import unittest +from pathlib import Path + +from script.todo.mail.accounts import account_from_preset +from script.todo.mail.store import MessageMeta, Store +from script.todo.mail.tui import ( + MAIL_LAYOUTS, + Session, + next_layout, + resolve_layout, +) + + +class TestResolveLayout(unittest.TestCase): + def test_known_value_is_kept(self): + self.assertEqual(resolve_layout("split"), "split") + + def test_unknown_value_falls_back_to_columns(self): + self.assertEqual(resolve_layout("bogus"), "columns") + + def test_empty_value_falls_back_to_columns(self): + self.assertEqual(resolve_layout(""), "columns") + + def test_none_falls_back_to_columns(self): + self.assertEqual(resolve_layout(None), "columns") + + +class TestNextLayout(unittest.TestCase): + def test_cycles_in_declared_order(self): + ids = [layout_id for layout_id, _ in MAIL_LAYOUTS] + self.assertEqual(ids, ["columns", "split", "stacked"]) + self.assertEqual(next_layout("columns"), "split") + self.assertEqual(next_layout("split"), "stacked") + + def test_wraps_around_after_the_last(self): + self.assertEqual(next_layout("stacked"), "columns") + + def test_an_invalid_current_value_starts_the_cycle_from_the_first(self): + self.assertEqual(next_layout("bogus"), "split") + + +class LayoutCase(unittest.IsolatedAsyncioTestCase): + """Monte `MailApp` pour de vrai, `$HOME` détourné — même motif que + `test_mail_tui_refresh.py` : `on_mount` lit `todo_prefs`, qui crée + `~/.erplibre` s'il est absent. + """ + + def setUp(self): + self.fake_home = tempfile.TemporaryDirectory() + self._old_home = os.environ.get("HOME") + os.environ["HOME"] = self.fake_home.name + + self.cache_dir = tempfile.TemporaryDirectory() + self.account = account_from_preset("perso", "moi@x.ca", "generic") + self.account.cache_mode = "clear" + self.store = Store( + self.account, mode="clear", base=Path(self.cache_dir.name) + ) + self.store.open() + # Pas de syncer : ces tests portent sur la disposition et la + # persistance de l'état affiché, jamais sur le réseau. + self.session = Session(self.account, self.store, None, password="x") + + def tearDown(self): + self.store.close() + if self._old_home is None: + os.environ.pop("HOME", None) + else: + os.environ["HOME"] = self._old_home + self.fake_home.cleanup() + self.cache_dir.cleanup() + + def _seed_two_messages(self): + fid = self.store.upsert_folder("INBOX", "INBOX", "inbox") + self.store.upsert_messages( + fid, + [ + MessageMeta( + uid=1, + date=1_000, + size=10, + flags="", + msgid="<1@x.ca>", + frm="a@x.ca", + to="moi@x.ca", + subject="Un", + snippet="", + ), + MessageMeta( + uid=2, + date=2_000, + size=10, + flags="", + msgid="<2@x.ca>", + frm="b@x.ca", + to="moi@x.ca", + subject="Deux", + snippet="", + ), + ], + ) + + async def _mounted_app(self, sessions=None): + import textual.app + + from script.todo.mail.tui import run_tui + + sessions = sessions if sessions is not None else [self.session] + captured = [] + orig_init = textual.app.App.__init__ + + def capturing_init(app_self, *a, **kw): + orig_init(app_self, *a, **kw) + captured.append(app_self) + + textual.app.App.__init__ = capturing_init + try: + run_tui(run_app=False, sessions=sessions) + finally: + textual.app.App.__init__ = orig_init + return captured[-1] + + +class TestDefaultLayout(LayoutCase): + async def test_columns_is_the_default_on_first_run(self): + app = await self._mounted_app() + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + await pilot.pause() + panes = app.query_one("#panes") + self.assertTrue(panes.has_class("layout-columns")) + self.assertEqual(app.mail_layout, "columns") + + +class TestCycleLayout(LayoutCase): + async def test_v_cycles_through_every_layout_and_wraps(self): + app = await self._mounted_app() + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + await pilot.pause() + panes = app.query_one("#panes") + + await pilot.press("v") + await pilot.pause() + self.assertTrue(panes.has_class("layout-split")) + self.assertFalse(panes.has_class("layout-columns")) + + await pilot.press("v") + await pilot.pause() + self.assertTrue(panes.has_class("layout-stacked")) + self.assertFalse(panes.has_class("layout-split")) + + await pilot.press("v") + await pilot.pause() + self.assertTrue(panes.has_class("layout-columns")) + self.assertFalse(panes.has_class("layout-stacked")) + + async def test_the_binding_is_translated_in_the_footer(self): + from script.todo.todo_i18n import t + + app = await self._mounted_app() + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + await pilot.pause() + active = app.screen.active_bindings["v"] + self.assertEqual( + active.binding.description, t("mail_layout_binding") + ) + + async def test_switching_reports_the_new_layout_translated(self): + from textual.widgets import Static + + from script.todo.todo_i18n import t + + app = await self._mounted_app() + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + await pilot.pause() + + await pilot.press("v") + await pilot.pause() + + status = app.query_one("#status", Static) + self.assertIn(t("mail_layout_split"), str(status.content)) + + +class TestLayoutPersistence(LayoutCase): + async def test_the_choice_is_written_to_preferences(self): + from script.todo import todo_prefs + + app = await self._mounted_app() + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + await pilot.pause() + await pilot.press("v") + await pilot.pause() + + self.assertEqual(todo_prefs.get("mail_layout"), "split") + + async def test_a_fresh_mount_reads_back_the_stored_layout(self): + from script.todo import todo_prefs + + todo_prefs.set("mail_layout", "stacked") + app = await self._mounted_app() + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + await pilot.pause() + self.assertEqual(app.mail_layout, "stacked") + self.assertTrue( + app.query_one("#panes").has_class("layout-stacked") + ) + + async def test_a_corrupt_stored_value_falls_back_to_columns(self): + from script.todo import todo_prefs + + todo_prefs.set("mail_layout", "not-a-real-layout") + app = await self._mounted_app() + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + await pilot.pause() + self.assertEqual(app.mail_layout, "columns") + self.assertTrue( + app.query_one("#panes").has_class("layout-columns") + ) + + +class TestLayoutSwitchPreservesState(LayoutCase): + """La propriété qui compte le plus : rien de ce que l'utilisateur + regarde ne doit bouger quand la disposition change — seule la classe + CSS de `#panes` doit changer. + """ + + async def test_folder_selection_survives_a_switch(self): + self._seed_two_messages() + app = await self._mounted_app() + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + await pilot.pause() + ref_before = app.current_ref + + await pilot.press("v") + await pilot.pause() + + self.assertIs(app.current_ref, ref_before) + + async def test_the_highlighted_message_survives_a_switch(self): + from textual.widgets import DataTable + + self._seed_two_messages() + app = await self._mounted_app() + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + await pilot.pause() + + table = app.query_one("#list", DataTable) + table.move_cursor(row=1) + await pilot.pause() + highlighted_before = app.current_meta() + self.assertIsNotNone(highlighted_before) + + await pilot.press("v") + await pilot.pause() + + self.assertEqual(app.current_meta().uid, highlighted_before.uid) + self.assertEqual(table.cursor_row, 1) + + async def test_the_search_filter_survives_a_switch(self): + self._seed_two_messages() + app = await self._mounted_app() + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + await pilot.pause() + + app.query = "Deux" + app.refresh_list() + from textual.widgets import DataTable + + table = app.query_one("#list", DataTable) + self.assertEqual(table.row_count, 1) + + await pilot.press("v") + await pilot.pause() + + self.assertEqual(app.query, "Deux") + table = app.query_one("#list", DataTable) + self.assertEqual(table.row_count, 1) + + async def test_fullscreen_state_survives_a_switch(self): + self._seed_two_messages() + app = await self._mounted_app() + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + await pilot.pause() + + panes = app.query_one("#panes") + panes.add_class("fullscreen") + + await pilot.press("v") + await pilot.pause() + + self.assertTrue(panes.has_class("fullscreen")) + self.assertTrue(panes.has_class("layout-split")) + + async def test_fullscreen_toggle_still_works_in_every_layout(self): + """`enter` lui-même n'atteint `MailApp` que lorsque le focus n'est + ni sur `#folders` (Tree) ni sur `#list` (DataTable) — les deux lient + déjà `enter` à `select_cursor`, et Textual donne priorité à la + liaison la plus proche du focus (voir le commentaire de + `_SearchInput`) — un fait préexistant, sans rapport avec les + dispositions. On passe donc par l'action elle-même pour entrer en + plein écran, symétriquement à `escape` (jamais intercepté par ces + deux widgets), qui lui reste testé au clavier. + """ + self._seed_two_messages() + app = await self._mounted_app() + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + await pilot.pause() + + await pilot.press("v") # split + await pilot.pause() + + app.action_toggle_fullscreen() + await pilot.pause() + panes = app.query_one("#panes") + self.assertTrue(panes.has_class("fullscreen")) + self.assertTrue(panes.has_class("layout-split")) + + await pilot.press("escape") + await pilot.pause() + self.assertFalse(panes.has_class("fullscreen")) + + +class TestFullscreenFillsThePanes(LayoutCase): + """Ce que la revue a trouvé, et qu'aucune assertion sur la classe + `fullscreen` ne peut voir : `.fullscreen #folders, .fullscreen #list` + n'effaçait que les WIDGETS, pas leurs conteneurs enveloppants + (`#list_pane`, `#right`), qui gardaient la taille que leur donne le + bloc CSS de la disposition active — l'aperçu ne prenait donc jamais + tout l'écran. En `columns` cela laissait ~40 % de l'écran vide (le + conteneur de liste, toujours large de 2fr) ; en `split`/`stacked`, + ~49 % (le conteneur de liste, toujours haut de 1fr). Il faut donc + MESURER la région réellement occupée par `#preview`, pas seulement + lire une classe CSS. + """ + + async def test_the_preview_fills_the_panes_area_in_every_layout(self): + app = await self._mounted_app() + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + await pilot.pause() + + panes = app.query_one("#panes") + preview = app.query_one("#preview") + list_pane = app.query_one("#list_pane") + folders = app.query_one("#folders") + + for layout_id, _ in MAIL_LAYOUTS: + while app.mail_layout != layout_id: + await pilot.press("v") + await pilot.pause() + + panes.add_class("fullscreen") + await pilot.pause() + + # La preuve qui compte : l'aperçu occupe TOUTE la région de + # `#panes`, pas seulement une fraction — c'est le contraire + # exact de ce qui a été mesuré avant le correctif (largeur + # ou hauteur de l'aperçu strictement inférieure à celle de + # `#panes`). + self.assertEqual( + preview.region, + panes.region, + f"disposition {layout_id!r} : aperçu {preview.region}," + f" attendu {panes.region}", + ) + # Les deux conteneurs masqués n'occupent plus rien du tout + # (et pas seulement leur CONTENU) — c'est précisément ce que + # `#list_pane` corrige par rapport à l'ancien `#list`. + self.assertEqual(list_pane.region.width, 0) + self.assertEqual(list_pane.region.height, 0) + self.assertEqual(folders.region.width, 0) + self.assertEqual(folders.region.height, 0) + + panes.remove_class("fullscreen") + await pilot.pause() + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_mail_tui_log.py b/test/test_mail_tui_log.py new file mode 100644 index 0000000..bc2c6c2 --- /dev/null +++ b/test/test_mail_tui_log.py @@ -0,0 +1,514 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""La fenêtre de diagnostic, touche `l` : la fin de `~/.erplibre/mail.log`, +et les erreurs de synchronisation de la session en cours — sans quitter le +client pour aller les lire dans un fichier. + +Le piège que ce fichier vérifie explicitement : une fenêtre qui s'ouvre VIDE +reproduit exactement la plainte qui justifie son existence (« j'ai une +erreur, mais aucun log »). Chaque état — absent, vide, illisible, aucune +erreur de session — doit se dire en toutes lettres. +""" +import os +import tempfile +import unittest +from pathlib import Path + +from script.todo.mail.accounts import account_from_preset +from script.todo.mail.store import Store +from script.todo.mail.tui import Session, read_log_tail + + +class TestReadLogTail(unittest.TestCase): + """`read_log_tail` est une fonction pure (aucun import Textual) : elle + se teste seule, sans monter d'écran.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.base = Path(self.tmp.name) + + def tearDown(self): + # Un fichier laissé à 0o000 empêcherait parfois le nettoyage du + # dossier temporaire selon le système de fichiers : on restaure les + # droits avant de nettoyer, plutôt que de dépendre de ce détail. + for entry in self.base.glob("*"): + entry.chmod(0o644) + self.tmp.cleanup() + + def test_missing_file_returns_explicit_message(self): + from script.todo.todo_i18n import t + + lines, message = read_log_tail(self.base / "absent.log") + self.assertEqual(lines, []) + self.assertEqual(message, t("mail_log_missing")) + + def test_empty_file_returns_explicit_message(self): + from script.todo.todo_i18n import t + + path = self.base / "mail.log" + path.write_bytes(b"") + + lines, message = read_log_tail(path) + self.assertEqual(lines, []) + self.assertEqual(message, t("mail_log_empty")) + + def test_unreadable_file_returns_explicit_message(self): + from script.todo.todo_i18n import t + + if os.geteuid() == 0: + self.skipTest( + "racine : les permissions de fichier ne bloquent rien" + ) + + path = self.base / "mail.log" + path.write_text("2026-08-04 boum\n") + path.chmod(0o000) + + lines, message = read_log_tail(path) + self.assertEqual(lines, []) + self.assertTrue(message.startswith(t("mail_log_unreadable"))) + + def test_tail_shows_only_the_last_lines(self): + path = self.base / "mail.log" + path.write_text("".join(f"ligne {i}\n" for i in range(1, 501))) + + lines, message = read_log_tail(path, max_lines=50) + + self.assertEqual(message, "") + self.assertEqual(len(lines), 50) + self.assertEqual(lines[0], "ligne 451") + self.assertEqual(lines[-1], "ligne 500") + + def test_does_not_read_the_whole_file(self): + """La contrainte centrale : un journal qui grossit sans borne ne + doit jamais être chargé en entier pour n'en montrer que la fin. + Vérifié en espionnant les octets RÉELLEMENT lus sur le fichier + cible, pas en devinant depuis le résultat.""" + import builtins + + path = self.base / "big.log" + line = ("x" * 100) + "\n" + with open(path, "w", encoding="utf-8") as handle: + for _ in range(50_000): + handle.write(line) + total_size = path.stat().st_size + self.assertGreater(total_size, 4_000_000) + + read_sizes = [] + orig_open = builtins.open + + def spying_open(*args, **kwargs): + handle = orig_open(*args, **kwargs) + if args and args[0] == path: + orig_read = handle.read + + def spying_read(n=-1, *a, **k): + data = orig_read(n, *a, **k) + read_sizes.append(len(data)) + return data + + handle.read = spying_read + return handle + + builtins.open = spying_open + try: + lines, message = read_log_tail(path, max_lines=20) + finally: + builtins.open = orig_open + + self.assertEqual(message, "") + self.assertEqual(len(lines), 20) + self.assertLess(sum(read_sizes), total_size) + + def test_survives_non_utf8_bytes(self): + """Un vrai journal peut contenir des octets qui ne sont pas de + l'UTF-8 valide (encodage local du serveur distant reproduit tel + quel dans un message d'exception, par exemple) : ça ne doit jamais + faire lever la lecture, seulement remplacer ce qui ne se décode + pas.""" + path = self.base / "mail.log" + with open(path, "wb") as handle: + handle.write(b"2026-08-04 avant\n") + handle.write(b"\xff\xfe pas de l'utf-8 valide\n") + handle.write(b"2026-08-04 apres\n") + + lines, message = read_log_tail(path) + + self.assertEqual(message, "") + self.assertEqual(lines[0], "2026-08-04 avant") + self.assertEqual(lines[-1], "2026-08-04 apres") + + def test_survives_a_multiline_traceback(self): + """Une vraie panne écrit une trace Python sur plusieurs lignes, pas + une ligne bien propre : la dernière ligne de la trace doit rester + visible dans la fin du journal.""" + traceback_text = ( + "2026-08-04 12:00:00 script.todo.mail.imap_sync ERROR sync a échoué\n" + "Traceback (most recent call last):\n" + ' File "imap_sync.py", line 140, in sync\n' + " folder = self._sync_folder(info)\n" + "OSError: 530 refus du serveur\n" + ) + path = self.base / "mail.log" + path.write_text(traceback_text) + + lines, message = read_log_tail(path, max_lines=10) + + self.assertEqual(message, "") + self.assertEqual(lines[-1], "OSError: 530 refus du serveur") + self.assertIn("Traceback (most recent call last):", lines) + + class _FakeStatResult: + def __init__(self, size): + self.st_size = size + + class _ShrunkAfterStatPath: + """Simule une rotation de journal : `stat()` rapporte encore + l'ANCIENNE taille (non nulle), mais le fichier est déjà vide au + moment où `open()` puis `read()` s'exécutent — la lecture par blocs + rend alors `b""`, sans qu'aucune des deux gardes précédentes + (`exists()`, `size == 0`) ne l'ait vu venir.""" + + def exists(self): + return True + + def stat(self): + return TestReadLogTail._FakeStatResult(1000) + + def test_a_file_truncated_between_stat_and_read_is_treated_as_empty(self): + """`size > 0` au moment du `stat()` ne garantit RIEN sur ce que la + lecture rendra ensuite : un journal peut être tronqué entre les deux + (rotation de journal, notamment) — exactement le cas qu'une revue a + retrouvé après qu'une passe précédente eut, à tort, jugé ce garde-fou + mort.""" + import builtins + + from script.todo.todo_i18n import t + + fake_path = self._ShrunkAfterStatPath() + orig_open = builtins.open + + def spying_open(file, *args, **kwargs): + if file is fake_path: + import io + + return io.BytesIO(b"") + return orig_open(file, *args, **kwargs) + + builtins.open = spying_open + try: + lines, message = read_log_tail(fake_path) + finally: + builtins.open = orig_open + + self.assertEqual(lines, []) + self.assertEqual(message, t("mail_log_empty")) + + +class FakeTransport: + def list_folders(self): + return [] + + def logout(self): + pass + + +class LogScreenCase(unittest.IsolatedAsyncioTestCase): + """Monte `MailApp` pour de vrai, `$HOME` détourné vers un dossier + jetable — comme `test_mail_tui_account.py` : `on_mount` lit + `todo_prefs`, qui crée `~/.erplibre` s'il est absent, et le chemin du + journal (`menu.mail_log_path()`) EST sous `~/.erplibre` : sans ce + détournement, monter l'écran toucherait la vraie machine ET risquerait + de lire le vrai journal de l'utilisateur, qui contient les réponses de + son serveur de courriel. + """ + + def setUp(self): + self.fake_home = tempfile.TemporaryDirectory() + self._old_home = os.environ.get("HOME") + os.environ["HOME"] = self.fake_home.name + + self.cache_dir = tempfile.TemporaryDirectory() + self.account = account_from_preset("perso", "moi@x.ca", "generic") + self.account.cache_mode = "clear" + self.store = Store( + self.account, mode="clear", base=Path(self.cache_dir.name) + ) + self.store.open() + + def tearDown(self): + self.store.close() + if self._old_home is None: + os.environ.pop("HOME", None) + else: + os.environ["HOME"] = self._old_home + self.fake_home.cleanup() + self.cache_dir.cleanup() + + def _log_path(self) -> Path: + from script.todo.mail.menu import mail_log_path + + return mail_log_path() + + class _FakeSyncer: + def __init__(self, transport, errors=None): + self.transport = transport + self._errors = errors or [] + + def sync(self, progress=None): + from types import SimpleNamespace + + return SimpleNamespace( + new_messages=0, errors=list(self._errors), purged=[] + ) + + def sync_one(self, folder_name): + from types import SimpleNamespace + + return SimpleNamespace(new_messages=0, errors=[], folders=1) + + def fetch_body(self, folder, uid): + return None + + async def _mounted_app(self, session=None): + import textual.app + + from script.todo.mail.tui import run_tui + + sessions = [session] if session is not None else [] + captured = [] + orig_init = textual.app.App.__init__ + + def capturing_init(app_self, *a, **kw): + orig_init(app_self, *a, **kw) + captured.append(app_self) + + textual.app.App.__init__ = capturing_init + try: + run_tui(run_app=False, sessions=sessions) + finally: + textual.app.App.__init__ = orig_init + return captured[-1] + + +class TestLogScreenOpensAndCloses(LogScreenCase): + async def test_l_opens_the_log_screen(self): + from textual.screen import ModalScreen + + app = await self._mounted_app() + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + await pilot.press("l") + await pilot.pause() + + self.assertIsInstance(app.screen, ModalScreen) + + async def test_escape_closes_it_without_disturbing_the_mail_list(self): + from textual.screen import ModalScreen + from textual.widgets import DataTable + + session = Session( + self.account, + self.store, + self._FakeSyncer(FakeTransport()), + password="hunter2", + ) + self.store.upsert_folder("INBOX", "INBOX", "inbox") + + app = await self._mounted_app(session) + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + table_before = app.query_one("#list", DataTable) + rows_before = table_before.row_count + + await pilot.press("l") + await pilot.pause() + self.assertIsInstance(app.screen, ModalScreen) + + await pilot.press("escape") + await pilot.pause() + + self.assertNotIsInstance(app.screen, ModalScreen) + table_after = app.query_one("#list", DataTable) + self.assertEqual(table_after.row_count, rows_before) + + async def test_binding_is_translated(self): + from script.todo.todo_i18n import t + + app = await self._mounted_app() + binding = next(b for key, b in app._bindings if key == "l") + self.assertEqual(binding.description, t("mail_log_binding")) + + +class TestLogScreenTailContent(LogScreenCase): + async def test_missing_log_file_says_so_explicitly(self): + from textual.widgets import Log + + from script.todo.todo_i18n import t + + app = await self._mounted_app() + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + await pilot.press("l") + await pilot.pause() + + log_widget = app.screen.query_one("#log_tail", Log) + text = "\n".join(str(line) for line in log_widget.lines) + self.assertIn(t("mail_log_missing"), text) + + async def test_tail_of_a_real_log_file_is_shown(self): + from textual.widgets import Log + + log_path = self._log_path() + log_path.parent.mkdir(parents=True, exist_ok=True) + log_path.write_text("".join(f"ligne {i}\n" for i in range(1, 301))) + + app = await self._mounted_app() + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + await pilot.press("l") + await pilot.pause() + + log_widget = app.screen.query_one("#log_tail", Log) + text = "\n".join(str(line) for line in log_widget.lines) + self.assertIn("ligne 300", text) + self.assertNotIn("ligne 1\n", text + "\n") + + +class TestLogScreenSessionErrors(LogScreenCase): + async def test_errors_from_the_last_sync_are_shown(self): + from textual.widgets import Static + + session = Session( + self.account, + self.store, + self._FakeSyncer( + FakeTransport(), errors=["Archives : 501 refus du serveur"] + ), + password="hunter2", + ) + app = await self._mounted_app(session) + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + app._sync([session]) + await pilot.pause() + + await pilot.press("l") + await pilot.pause() + + errors_widget = app.screen.query_one("#log_errors", Static) + self.assertIn( + "Archives : 501 refus du serveur", + str(errors_widget.render()), + ) + + async def test_no_session_errors_says_so_explicitly(self): + from textual.widgets import Static + + from script.todo.todo_i18n import t + + session = Session( + self.account, + self.store, + self._FakeSyncer(FakeTransport(), errors=[]), + password="hunter2", + ) + app = await self._mounted_app(session) + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + app._sync([session]) + await pilot.pause() + + await pilot.press("l") + await pilot.pause() + + errors_widget = app.screen.query_one("#log_errors", Static) + self.assertIn(t("mail_log_no_errors"), str(errors_widget.render())) + + async def test_a_later_clean_sync_clears_a_previous_error(self): + """« la dernière synchronisation » : une erreur d'il y a deux + passes ne doit pas rester affichée comme si elle était toujours + d'actualité.""" + from textual.widgets import Static + + from script.todo.todo_i18n import t + + session = Session( + self.account, + self.store, + self._FakeSyncer(FakeTransport(), errors=["INBOX : 501 refus"]), + password="hunter2", + ) + app = await self._mounted_app(session) + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + app._sync([session]) + await pilot.pause() + + # Deuxième synchronisation, propre cette fois. + session.syncer = self._FakeSyncer(FakeTransport(), errors=[]) + app._sync([session]) + await pilot.pause() + + await pilot.press("l") + await pilot.pause() + + errors_widget = app.screen.query_one("#log_errors", Static) + text = str(errors_widget.render()) + self.assertIn(t("mail_log_no_errors"), text) + self.assertNotIn("INBOX : 501 refus", text) + + +class TestLogScreenTotalSyncFailure(LogScreenCase): + """`session.sync()` peut lever DIRECTEMENT (connexion totalement + perdue), pas seulement rendre un `report.errors` : c'est la panne la + plus grave, celle qu'un utilisateur ouvrirait précisément cette fenêtre + pour diagnostiquer — un vrai `imaplib.IMAP4.abort ... Broken pipe` a été + trouvé dans un journal réel après une telle panne. Le statut affiché au + moment de la panne est éphémère (le prochain message l'efface) ; `l` + existe pour regarder APRÈS coup, donc cette panne doit rester lisible + dans la fenêtre, pas seulement dans la barre de statut du moment.""" + + class _FakeSyncerThatRaises: + def __init__(self, transport): + self.transport = transport + + def sync(self, progress=None): + raise OSError("imaplib.IMAP4.abort ... Broken pipe") + + def sync_one(self, folder_name): + from types import SimpleNamespace + + return SimpleNamespace(new_messages=0, errors=[], folders=1) + + def fetch_body(self, folder, uid): + return None + + async def test_total_failure_is_shown_in_the_window(self): + from textual.widgets import Static + + session = Session( + self.account, + self.store, + self._FakeSyncerThatRaises(FakeTransport()), + password="hunter2", + ) + app = await self._mounted_app(session) + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + app._sync([session]) + await pilot.pause() + + await pilot.press("l") + await pilot.pause() + + errors_widget = app.screen.query_one("#log_errors", Static) + self.assertIn( + "imaplib.IMAP4.abort ... Broken pipe", + str(errors_widget.render()), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_mail_tui_refresh.py b/test/test_mail_tui_refresh.py new file mode 100644 index 0000000..b776359 --- /dev/null +++ b/test/test_mail_tui_refresh.py @@ -0,0 +1,389 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Un message livré par une synchronisation ou un envoi doit apparaître dans +la liste SANS redémarrer le client — le bug réel qui a mené à cette tâche. + +L'APPEND et la synchronisation fonctionnaient déjà : après un redémarrage, le +message est là. C'était donc l'ÉCRAN qui restait périmé — `reload_folders()` +ne rafraîchissait la liste de messages que quand AUCUN dossier n'était +sélectionné, or un dossier est toujours déjà ouvert en pratique. Chaque test +ci-dessous fait tourner le VRAI chemin (`_sync`, ou l'écran de composition +via `ctrl+s`) plutôt que d'appeler `refresh_current_folder()` directement : +un test qui ne franchit pas ce seuil ne prouverait rien sur le bug observé. +""" +import os +import tempfile +import unittest +from pathlib import Path + +from script.todo.mail.accounts import account_from_preset +from script.todo.mail.imap_sync import ( + FolderInfo, + HeaderInfo, + SelectInfo, + Syncer, +) +from script.todo.mail.store import MessageMeta, Store +from script.todo.mail.tui import Session + + +class FakeImapTransport: + """Assez de protocole IMAP en mémoire pour faire tourner le VRAI + `Syncer` (copie allégée de celle de `test_mail_sync.py`) : la garantie + que ces tests prouvent qu'une synchronisation RÉELLE rafraîchit l'écran, + pas une simulation qui écrirait directement dans le store.""" + + def __init__(self): + self.folders = {} + self.selected = None + self.appended = [] + + def add(self, folder, uid, subject="Sujet", date=None, flags=""): + f = self.folders.setdefault(folder, {"uidvalidity": 1, "messages": {}}) + f["messages"][uid] = HeaderInfo( + uid=uid, + date=date if date is not None else 1_700_000_000 + uid, + size=100, + flags=flags, + msgid=f"<{uid}@x.ca>", + frm="eux@x.ca", + to="moi@x.ca", + subject=subject, + ) + + def list_folders(self): + return [FolderInfo(name=n) for n in sorted(self.folders)] + + def select(self, folder): + self.selected = folder + f = self.folders.setdefault(folder, {"uidvalidity": 1, "messages": {}}) + uids = list(f["messages"]) + return SelectInfo( + uidvalidity=f["uidvalidity"], + uidnext=(max(uids) + 1) if uids else 1, + exists=len(uids), + ) + + def search_uids(self, since_uid): + f = self.folders[self.selected] + return sorted(u for u in f["messages"] if u >= since_uid) + + def fetch_headers(self, uids): + f = self.folders[self.selected] + return [f["messages"][u] for u in uids if u in f["messages"]] + + def fetch_flags(self, uids): + f = self.folders[self.selected] + return [ + (u, f["messages"][u].flags) for u in uids if u in f["messages"] + ] + + def append(self, folder, raw, flags): + self.appended.append((folder, raw, flags)) + + def logout(self): + pass + + +class RefreshCase(unittest.IsolatedAsyncioTestCase): + """Monte `MailApp` pour de vrai, `$HOME` détourné — comme + `test_mail_tui_log.py` : `on_mount` lit `todo_prefs`, qui crée + `~/.erplibre` s'il est absent.""" + + def setUp(self): + self.fake_home = tempfile.TemporaryDirectory() + self._old_home = os.environ.get("HOME") + os.environ["HOME"] = self.fake_home.name + + self.cache_dir = tempfile.TemporaryDirectory() + self.account = account_from_preset("perso", "moi@x.ca", "generic") + self.account.cache_mode = "clear" + self.store = Store( + self.account, mode="clear", base=Path(self.cache_dir.name) + ) + self.store.open() + self.imap = FakeImapTransport() + self.syncer = Syncer(self.store, self.imap) + self.session = Session( + self.account, self.store, self.syncer, password="hunter2" + ) + + def tearDown(self): + self.store.close() + if self._old_home is None: + os.environ.pop("HOME", None) + else: + os.environ["HOME"] = self._old_home + self.fake_home.cleanup() + self.cache_dir.cleanup() + + async def _mounted_app(self, sessions=None): + import textual.app + + from script.todo.mail.tui import run_tui + + sessions = sessions if sessions is not None else [self.session] + captured = [] + orig_init = textual.app.App.__init__ + + def capturing_init(app_self, *a, **kw): + orig_init(app_self, *a, **kw) + captured.append(app_self) + + textual.app.App.__init__ = capturing_init + try: + run_tui(run_app=False, sessions=sessions) + finally: + textual.app.App.__init__ = orig_init + return captured[-1] + + +class TestSyncRefreshesTheOpenFolder(RefreshCase): + async def test_a_message_that_arrives_during_sync_appears_without_restart( + self, + ): + from textual.widgets import DataTable + + self.imap.add("INBOX", 1, subject="Ancien") + app = await self._mounted_app() + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + await pilot.pause() + + table = app.query_one("#list", DataTable) + self.assertEqual(table.row_count, 1) + # Le dossier est déjà ouvert — l'état exact où + # `reload_folders()` ratait le rafraîchissement de la liste. + self.assertIsNotNone(app.current_ref) + + self.imap.add("INBOX", 2, subject="Nouveau", date=1_800_000_000) + app._sync([self.session]) + await pilot.pause() + + table = app.query_one("#list", DataTable) + self.assertEqual(table.row_count, 2) + subjects = [table.get_row_at(i)[2] for i in range(table.row_count)] + self.assertIn("Nouveau", subjects) + + +class TestSyncPreservesCursor(RefreshCase): + async def test_cursor_follows_the_same_message_across_a_refresh(self): + from textual.widgets import DataTable + + self.imap.add("INBOX", 1, subject="Un", date=1_000) + self.imap.add("INBOX", 2, subject="Deux", date=2_000) + self.imap.add("INBOX", 3, subject="Trois", date=3_000) + app = await self._mounted_app() + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + await pilot.pause() + + table = app.query_one("#list", DataTable) + self.assertEqual(table.row_count, 3) + # Trié par date décroissante : Trois, Deux, Un. + table.move_cursor(row=1) + await pilot.pause() + highlighted = app.current_meta() + self.assertEqual(highlighted.subject, "Deux") + + # Un message plus récent arrive : il se glisse EN TÊTE de + # liste — sans un suivi par UID, l'index 1 resterait + # sélectionné mais pointerait sur un autre message. + self.imap.add("INBOX", 4, subject="Quatre", date=4_000) + app._sync([self.session]) + await pilot.pause() + + self.assertEqual(table.row_count, 4) + still_highlighted = app.current_meta() + self.assertEqual(still_highlighted.subject, "Deux") + + async def test_cursor_falls_back_sensibly_when_the_message_is_gone(self): + from textual.widgets import DataTable + + self.imap.add("INBOX", 1, subject="Un", date=1_000) + self.imap.add("INBOX", 2, subject="Deux", date=2_000) + app = await self._mounted_app() + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + await pilot.pause() + + table = app.query_one("#list", DataTable) + table.move_cursor(row=0) + await pilot.pause() + self.assertEqual(app.current_meta().subject, "Deux") + + # UIDVALIDITY change côté serveur (boîte recréée/renumérotée) : + # le VRAI `Syncer` vide alors le dossier (`purge_folder`) avant + # de le repeupler — le seul mécanisme réel par lequel un + # message connu peut disparaître du cache. « Deux » ne revient + # pas ; « Trois » prend sa place. + self.imap.folders["INBOX"]["uidvalidity"] = 2 + del self.imap.folders["INBOX"]["messages"][2] + self.imap.add("INBOX", 3, subject="Trois", date=3_000) + app._sync([self.session]) + await pilot.pause() + + # Ne doit pas lever, et doit retomber sur un état affichable — + # celui que `DataTable.clear()` laisse déjà : en tête de liste. + # Vérifié par le CONTENU, pas seulement le compte de lignes : + # sans rafraîchissement du tout, la liste resterait Un + Deux + # (même compte de lignes, même index 0) — seul le contenu + # trahit une liste réellement rechargée. + table = app.query_one("#list", DataTable) + self.assertEqual(table.row_count, 2) # Un + Trois + self.assertEqual(table.cursor_row, 0) + self.assertEqual(table.get_row_at(0)[2], "Trois") + + +class TestSyncPreservesSearchFilter(RefreshCase): + async def test_filtered_out_messages_stay_hidden_after_a_refresh(self): + from textual.widgets import DataTable + + self.imap.add("INBOX", 1, subject="Alpha") + app = await self._mounted_app() + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + await pilot.pause() + + app.query = "Alpha" + app.refresh_list() + table = app.query_one("#list", DataTable) + self.assertEqual(table.row_count, 1) + + self.imap.add("INBOX", 2, subject="Beta") + app._sync([self.session]) + await pilot.pause() + + # Le cache SOUS le filtre doit avoir bougé — sinon ce test ne + # prouverait rien sur le rafraîchissement lui-même, seulement + # que « Beta » reste caché, vrai aussi bien quand rien ne se + # rafraîchit du tout. + self.assertEqual(len(app.metas), 2) + table = app.query_one("#list", DataTable) + self.assertEqual(table.row_count, 1) + self.assertEqual(table.get_row_at(0)[2], "Alpha") + + +class TestRefreshWithNoFolderSelected(RefreshCase): + async def test_does_not_raise_when_nothing_is_selected(self): + app = await self._mounted_app(sessions=[]) + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + await pilot.pause() + + self.assertIsNone(app.current_ref) + app.refresh_current_folder() # ne doit pas lever + + +class TestSendRefreshesTheOpenFolder(RefreshCase): + """`deliver()` classe une copie dans Envoyés via `sync_one` après un + envoi réussi (tâche 19) — sans le correctif de cette tâche, l'écran + reste périmé si Envoyés est déjà ouvert au moment d'envoyer, exactement + comme pour `_sync`.""" + + class _FakeSentSyncOne: + """`sync_one` réel écrit dans le store après l'APPEND — cette + version fait la même écriture, sans re-simuler tout un serveur + IMAP : seul le point sous test compte ici, le rafraîchissement de + l'écran une fois le store à jour.""" + + def __init__(self, store, transport): + self.store = store + self.transport = transport + self.sync_one_calls = [] + + def sync(self, progress=None): + from types import SimpleNamespace + + return SimpleNamespace(new_messages=0, errors=[], purged=[]) + + def sync_one(self, folder_name): + from types import SimpleNamespace + + self.sync_one_calls.append(folder_name) + fid = self.store.upsert_folder(folder_name, folder_name, "sent") + self.store.upsert_messages( + fid, + [ + MessageMeta( + uid=1, + date=1_700_000_000, + size=10, + flags="\\Seen", + msgid="", + frm="moi@x.ca", + to="dest@example.com", + subject="Sujet", + snippet="", + ) + ], + ) + return SimpleNamespace(new_messages=1, errors=[], folders=1) + + def fetch_body(self, folder, uid): + return None + + class _FakeSMTPTransport: + def quit(self): + pass + + async def test_the_sent_folder_refreshes_after_sending(self): + from textual.screen import ModalScreen + from textual.widgets import DataTable, Input, TextArea + + import script.todo.mail.smtp_send as smtp_send_mod + + # Envoyés déjà connu du cache, comme après une synchronisation + # antérieure — le scénario du rapport : l'utilisateur regarde déjà + # ce dossier au moment d'envoyer. + self.store.upsert_folder( + self.account.sent_folder, self.account.sent_folder, "sent" + ) + fake_syncer = self._FakeSentSyncOne(self.store, self.imap) + session = Session( + self.account, self.store, fake_syncer, password="hunter2" + ) + + app = await self._mounted_app(sessions=[session]) + orig_connect, orig_send = smtp_send_mod.connect, smtp_send_mod.send + smtp_send_mod.connect = ( + lambda account, password: self._FakeSMTPTransport() + ) + smtp_send_mod.send = lambda account, msg, transport: [ + "dest@example.com" + ] + try: + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + await pilot.pause() + + self.assertEqual( + app.current_ref.folder_name, self.account.sent_folder + ) + table = app.query_one("#list", DataTable) + self.assertEqual(table.row_count, 0) + + await pilot.press("c") + await pilot.pause() + app.screen.query_one("#to", Input).value = "dest@example.com" + app.screen.query_one("#subject", Input).value = "Sujet" + app.screen.query_one("#body", TextArea).text = "Corps" + + await pilot.press("ctrl+s") + await pilot.pause() + + self.assertNotIsInstance(app.screen, ModalScreen) + self.assertEqual( + fake_syncer.sync_one_calls, [self.account.sent_folder] + ) + + table = app.query_one("#list", DataTable) + self.assertEqual(table.row_count, 1) + finally: + smtp_send_mod.connect = orig_connect + smtp_send_mod.send = orig_send + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_mail_tui_resize.py b/test/test_mail_tui_resize.py new file mode 100644 index 0000000..eef4704 --- /dev/null +++ b/test/test_mail_tui_resize.py @@ -0,0 +1,1048 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Redimensionnement des volets (`+`/`-`/`0`) et bascule plein écran (`z`). + +Comme `test_mail_tui_layout.py` : les fonctions pures (`clamp_pane_size`, +`resolve_pane_sizes`) se testent sans écran ; tout le reste — ce qui est +réellement posé sur les widgets, la persistance, la non-perturbation des +autres dispositions — n'a de sens que sur l'application montée pour de +vrai. +""" +import os +import tempfile +import unittest +from pathlib import Path + +from script.todo.mail.accounts import account_from_preset +from script.todo.mail.store import Store +from script.todo.mail.tui import ( + MAIL_LAYOUTS, + PANE_SIZE_MIN, + Session, + clamp_pane_size, + resolve_pane_sizes, +) + + +class TestClampPaneSize(unittest.TestCase): + def test_a_value_within_bounds_is_kept(self): + self.assertEqual(clamp_pane_size(30, 80), 30) + + def test_a_value_below_the_minimum_is_raised_to_it(self): + self.assertEqual(clamp_pane_size(1, 80, minimum=4), 4) + + def test_a_negative_value_is_raised_to_the_minimum(self): + self.assertEqual(clamp_pane_size(-50, 80, minimum=4), 4) + + def test_a_value_that_would_crush_the_sibling_is_lowered(self): + # total=80, minimum=4 : le voisin doit garder au moins 4 -> plafond 76 + self.assertEqual(clamp_pane_size(9999, 80, minimum=4), 76) + + def test_total_none_cannot_be_bounded(self): + self.assertIsNone(clamp_pane_size(30, None)) + + def test_total_zero_or_negative_cannot_be_bounded(self): + self.assertIsNone(clamp_pane_size(30, 0)) + self.assertIsNone(clamp_pane_size(30, -10)) + + def test_default_minimum_is_the_module_constant(self): + self.assertEqual(clamp_pane_size(1, 80), PANE_SIZE_MIN) + + def test_sibling_minimum_defaults_to_minimum(self): + # sibling_minimum omis == sibling_minimum=minimum : comportement + # inchangé pour tout appelant qui ne le précise pas. + self.assertEqual( + clamp_pane_size(9999, 80, minimum=4), + clamp_pane_size(9999, 80, minimum=4, sibling_minimum=4), + ) + + def test_a_larger_sibling_minimum_lowers_the_ceiling_further(self): + # total=80, minimum=4, sibling_minimum=8 (le voisin est lui-même un + # conteneur à deux enfants) -> plafond 72, pas 76. + self.assertEqual( + clamp_pane_size(9999, 80, minimum=4, sibling_minimum=8), 72 + ) + + +class TestResolvePaneSizes(unittest.TestCase): + def test_missing_store_yields_nothing(self): + self.assertEqual(resolve_pane_sizes({}, "columns"), {}) + + def test_none_store_yields_nothing(self): + self.assertEqual(resolve_pane_sizes(None, "columns"), {}) + + def test_store_not_a_dict_yields_nothing(self): + self.assertEqual(resolve_pane_sizes("bogus", "columns"), {}) + + def test_layout_absent_from_store_yields_nothing(self): + self.assertEqual( + resolve_pane_sizes({"split": {"folders": 30}}, "columns"), {} + ) + + def test_per_layout_entry_not_a_dict_yields_nothing(self): + self.assertEqual( + resolve_pane_sizes({"columns": "bogus"}, "columns"), {} + ) + + def test_valid_entries_are_kept(self): + stored = {"columns": {"folders": 30, "list_pane": 22}} + self.assertEqual( + resolve_pane_sizes(stored, "columns"), + {"folders": 30, "list_pane": 22}, + ) + + def test_a_zero_or_negative_slot_value_is_dropped(self): + stored = {"columns": {"folders": 0, "list_pane": -5}} + self.assertEqual(resolve_pane_sizes(stored, "columns"), {}) + + def test_a_non_numeric_slot_value_is_dropped(self): + stored = {"columns": {"folders": "wide", "list_pane": None}} + self.assertEqual(resolve_pane_sizes(stored, "columns"), {}) + + def test_a_boolean_slot_value_is_dropped(self): + # bool est une sous-classe d'int en Python -- True/False ne sont + # jamais des tailles valides, un piège classique à garder fermé. + stored = {"columns": {"folders": True}} + self.assertEqual(resolve_pane_sizes(stored, "columns"), {}) + + def test_an_unknown_slot_key_is_ignored(self): + stored = {"columns": {"folders": 30, "bogus_slot": 99}} + self.assertEqual( + resolve_pane_sizes(stored, "columns"), {"folders": 30} + ) + + def test_a_float_slot_value_is_kept_as_int(self): + stored = {"columns": {"folders": 30.7}} + result = resolve_pane_sizes(stored, "columns") + self.assertEqual(result["folders"], 30) + self.assertIsInstance(result["folders"], int) + + +class ResizeCase(unittest.IsolatedAsyncioTestCase): + """Monte `MailApp` pour de vrai, `$HOME` détourné — même motif que + `test_mail_tui_layout.py`. + """ + + def setUp(self): + self.fake_home = tempfile.TemporaryDirectory() + self._old_home = os.environ.get("HOME") + os.environ["HOME"] = self.fake_home.name + + self.cache_dir = tempfile.TemporaryDirectory() + self.account = account_from_preset("perso", "moi@x.ca", "generic") + self.account.cache_mode = "clear" + self.store = Store( + self.account, mode="clear", base=Path(self.cache_dir.name) + ) + self.store.open() + self.session = Session(self.account, self.store, None, password="x") + + def tearDown(self): + self.store.close() + if self._old_home is None: + os.environ.pop("HOME", None) + else: + os.environ["HOME"] = self._old_home + self.fake_home.cleanup() + self.cache_dir.cleanup() + + def _fresh_session(self): + """Une DEUXIÈME session sur le MÊME cache disque — pas + `self.session` réutilisée : `MailApp.on_unmount` ferme la session + (donc le `Store`) quand `run_test()` démonte l'appli, si bien que + réutiliser le même objet `Session` pour un second montage + planterait sur un cache déjà fermé. Un vrai redémarrage rouvre le + cache depuis le DISQUE ; ceci en est le double fidèle. + """ + store = Store( + self.account, mode="clear", base=Path(self.cache_dir.name) + ) + store.open() + return Session(self.account, store, None, password="x") + + async def _mounted_app(self, sessions=None): + import textual.app + + from script.todo.mail.tui import run_tui + + sessions = sessions if sessions is not None else [self.session] + captured = [] + orig_init = textual.app.App.__init__ + + def capturing_init(app_self, *a, **kw): + orig_init(app_self, *a, **kw) + captured.append(app_self) + + textual.app.App.__init__ = capturing_init + try: + run_tui(run_app=False, sessions=sessions) + finally: + textual.app.App.__init__ = orig_init + return captured[-1] + + +class TestFocusedPaneSlot(ResizeCase): + async def test_folders_has_focus_on_first_mount(self): + from textual.widgets import Tree + + app = await self._mounted_app() + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + await pilot.pause() + self.assertIsInstance(app.focused, Tree) + self.assertEqual(app._focused_pane_slot(), "folders") + + async def test_the_list_maps_to_the_list_pane_slot(self): + from textual.widgets import DataTable + + app = await self._mounted_app() + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + await pilot.pause() + app.query_one("#list", DataTable).focus() + await pilot.pause() + self.assertEqual(app._focused_pane_slot(), "list_pane") + + async def test_the_search_input_maps_to_the_list_pane_slot(self): + app = await self._mounted_app() + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + await pilot.pause() + app.action_focus_search() + await pilot.pause() + self.assertEqual(app._focused_pane_slot(), "list_pane") + + +class TestGrowShrinkColumns(ResizeCase): + """Disposition par défaut : `#folders`/`#right` se partagent la LARGEUR + de `#panes`, `#list_pane`/`#preview` la largeur de `#right`. + """ + + async def test_growing_the_focused_list_widens_it_and_narrows_preview( + self, + ): + from textual.widgets import DataTable + + app = await self._mounted_app() + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + await pilot.pause() + + list_pane = app.query_one("#list_pane") + preview = app.query_one("#preview") + width_before = list_pane.region.width + preview_before = preview.region.width + + app.query_one("#list", DataTable).focus() + await pilot.pause() + await pilot.press("+") + await pilot.pause() + + self.assertEqual(list_pane.region.width, width_before + 4) + self.assertEqual(preview.region.width, preview_before - 4) + + async def test_shrinking_the_focused_list_narrows_it_and_widens_preview( + self, + ): + from textual.widgets import DataTable + + app = await self._mounted_app() + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + await pilot.pause() + + list_pane = app.query_one("#list_pane") + preview = app.query_one("#preview") + width_before = list_pane.region.width + preview_before = preview.region.width + + app.query_one("#list", DataTable).focus() + await pilot.pause() + await pilot.press("-") + await pilot.pause() + + self.assertEqual(list_pane.region.width, width_before - 4) + self.assertEqual(preview.region.width, preview_before + 4) + + async def test_growing_the_focused_folders_widens_it_and_narrows_right( + self, + ): + from textual.widgets import Tree + + app = await self._mounted_app() + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + await pilot.pause() + + folders = app.query_one("#folders") + right = app.query_one("#right") + self.assertIsInstance(app.focused, Tree) + width_before = folders.region.width + right_before = right.region.width + + await pilot.press("+") + await pilot.pause() + + self.assertEqual(folders.region.width, width_before + 4) + self.assertEqual(right.region.width, right_before - 4) + + async def test_shrinking_stops_at_the_minimum(self): + from textual.widgets import DataTable + + app = await self._mounted_app() + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + await pilot.pause() + + app.query_one("#list", DataTable).focus() + await pilot.pause() + for _ in range(30): + await pilot.press("-") + await pilot.pause() + + list_pane = app.query_one("#list_pane") + self.assertEqual(list_pane.region.width, PANE_SIZE_MIN) + # Une pression de plus ne descend pas sous le plancher. + await pilot.press("-") + await pilot.pause() + self.assertEqual(list_pane.region.width, PANE_SIZE_MIN) + + async def test_growing_is_bounded_so_the_sibling_keeps_the_minimum(self): + from textual.widgets import DataTable + + app = await self._mounted_app() + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + await pilot.pause() + + app.query_one("#list", DataTable).focus() + await pilot.pause() + for _ in range(60): + await pilot.press("+") + await pilot.pause() + + preview = app.query_one("#preview") + self.assertEqual(preview.region.width, PANE_SIZE_MIN) + + async def test_no_op_when_focus_is_outside_any_resizable_slot(self): + """`#preview` n'est pas focalisable aujourd'hui, donc ce chemin + n'est pas atteignable en pratique -- mais `_focused_pane_slot` + doit rendre `None` plutôt que planter si le focus n'est ni dans + `#folders` ni dans `#list_pane`, pour rester correct si un futur + volet focalisable s'ajoute ailleurs. + """ + app = await self._mounted_app() + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + await pilot.pause() + app.set_focus(None) + await pilot.pause() + self.assertIsNone(app._focused_pane_slot()) + # Et l'action elle-même ne lève pas. + app.action_grow_pane() + app.action_shrink_pane() + + +class TestResetPaneSizes(ResizeCase): + async def test_reset_restores_the_layout_defaults(self): + from textual.widgets import DataTable + + app = await self._mounted_app() + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + await pilot.pause() + + list_pane = app.query_one("#list_pane") + width_before = list_pane.region.width + + app.query_one("#list", DataTable).focus() + await pilot.pause() + await pilot.press("+") + await pilot.press("+") + await pilot.pause() + self.assertNotEqual(list_pane.region.width, width_before) + + await pilot.press("0") + await pilot.pause() + + self.assertEqual(list_pane.region.width, width_before) + + async def test_reset_clears_the_stored_customization(self): + from textual.widgets import DataTable + + from script.todo import todo_prefs + + app = await self._mounted_app() + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + await pilot.pause() + + app.query_one("#list", DataTable).focus() + await pilot.pause() + await pilot.press("+") + await pilot.pause() + self.assertIn("columns", todo_prefs.get("mail_pane_sizes", {})) + + await pilot.press("0") + await pilot.pause() + + sizes = todo_prefs.get("mail_pane_sizes", {}) + self.assertEqual(sizes.get("columns", {}), {}) + + +class TestPaneSizePersistence(ResizeCase): + async def test_a_customized_size_survives_a_restart(self): + from textual.widgets import DataTable + + app = await self._mounted_app() + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + await pilot.pause() + app.query_one("#list", DataTable).focus() + await pilot.pause() + await pilot.press("+") + await pilot.press("+") + await pilot.pause() + grown_width = app.query_one("#list_pane").region.width + + app2 = await self._mounted_app(sessions=[self._fresh_session()]) + async with app2.run_test() as pilot: + await app2.workers.wait_for_complete() + await pilot.pause() + self.assertEqual( + app2.query_one("#list_pane").region.width, grown_width + ) + + async def test_a_corrupt_stored_size_falls_back_without_raising(self): + from script.todo import todo_prefs + + todo_prefs.set( + "mail_pane_sizes", {"columns": {"folders": "not-a-size"}} + ) + app = await self._mounted_app() + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + await pilot.pause() + # Ne lève pas, et retombe sur la valeur de la feuille de style + # (28, la même qu'avant cette tâche). + self.assertEqual(app.query_one("#folders").region.width, 28) + + async def test_an_absurdly_large_stored_size_is_clamped(self): + from script.todo import todo_prefs + + todo_prefs.set("mail_pane_sizes", {"columns": {"folders": 99999}}) + app = await self._mounted_app() + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + await pilot.pause() + panes = app.query_one("#panes") + folders = app.query_one("#folders") + self.assertLessEqual( + folders.region.width, panes.region.width - PANE_SIZE_MIN + ) + + async def test_resizing_one_layout_does_not_disturb_another(self): + from textual.widgets import DataTable + + from script.todo import todo_prefs + + app = await self._mounted_app() + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + await pilot.pause() + + app.query_one("#list", DataTable).focus() + await pilot.pause() + await pilot.press("+") + await pilot.press("+") + await pilot.pause() + + await pilot.press("v") # -> split + await pilot.pause() + + sizes = todo_prefs.get("mail_pane_sizes", {}) + self.assertIn("columns", sizes) + self.assertNotIn("split", sizes) + + +class TestFullscreenKey(ResizeCase): + """`enter` est lié à `action_toggle_fullscreen` sur `MailApp`, mais + `Tree`/`DataTable` lient déjà `enter` eux-mêmes (`select_cursor`) et + gagnent toujours -- Textual donne priorité à la liaison la plus proche + du nœud focalisé (`App._check_bindings`, chaîne `focused.ancestors_with_self`, + voir le commentaire de `_SearchInput`). Comme l'un des deux a TOUJOURS le + focus par défaut, `enter` n'atteint donc jamais `MailApp` en pratique -- + `z` (libre, non revendiqué par `Tree`/`DataTable`/`Input`) le remplace. + """ + + async def test_z_toggles_fullscreen_while_folders_has_focus(self): + from textual.widgets import Tree + + app = await self._mounted_app() + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + await pilot.pause() + self.assertIsInstance(app.focused, Tree) + + panes = app.query_one("#panes") + preview = app.query_one("#preview") + await pilot.press("z") + await pilot.pause() + + self.assertTrue(panes.has_class("fullscreen")) + self.assertEqual(preview.region, panes.region) + + await pilot.press("escape") + await pilot.pause() + self.assertFalse(panes.has_class("fullscreen")) + + async def test_z_toggles_fullscreen_while_the_list_has_focus(self): + from textual.widgets import DataTable + + app = await self._mounted_app() + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + await pilot.pause() + app.query_one("#list", DataTable).focus() + await pilot.pause() + + panes = app.query_one("#panes") + await pilot.press("z") + await pilot.pause() + + self.assertTrue(panes.has_class("fullscreen")) + + async def test_enter_no_longer_claims_a_binding_at_the_app_level(self): + """`enter` reste lié à `Tree`/`DataTable` eux-mêmes (sélection) -- + mais `MailApp` ne le revendique plus pour le plein écran, pour ne + pas laisser un pied d'écran annoncer une touche qui, depuis l'état + focalisé par défaut, ne fait jamais rien. + """ + app = await self._mounted_app() + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + await pilot.pause() + bindings = app._bindings.key_to_bindings + self.assertNotIn("enter", bindings) + self.assertIn("z", bindings) + + async def test_the_binding_is_translated_in_the_footer(self): + from script.todo.todo_i18n import t + + app = await self._mounted_app() + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + await pilot.pause() + active = app.screen.active_bindings["z"] + self.assertEqual( + active.binding.description, t("mail_fullscreen_binding") + ) + + +class TestTerminalResizeRespectsMinimum(ResizeCase): + """Fix round : un volet grandi, puis un TERMINAL rétréci sans qu'aucune + touche ne soit pressée, ne repassait par aucune des actions qui + bornent — la surcharge en ligne restait figée à l'ancienne valeur, et + le voisin s'écrasait jusqu'à zéro, sans qu'aucune touche ne puisse le + récupérer (le plafond du volet écrasé se calcule alors contre SA + PROPRE région, déjà nulle). `on_resize` reborne maintenant contre + l'espace RÉELLEMENT disponible à chaque redimensionnement du terminal + — mesuré ici, jamais une classe ni une variable interne. + """ + + async def test_shrinking_the_terminal_keeps_every_pane_above_minimum( + self, + ): + app = await self._mounted_app() + async with app.run_test(size=(80, 24)) as pilot: + await app.workers.wait_for_complete() + await pilot.pause() + + panes = app.query_one("#panes") + folders = app.query_one("#folders") + right = app.query_one("#right") + list_pane = app.query_one("#list_pane") + preview = app.query_one("#preview") + + for layout_id, _ in MAIL_LAYOUTS: + while app.mail_layout != layout_id: + await pilot.press("v") + await pilot.pause() + + # Grandit le volet dossiers (focus par défaut) près du + # maximum permis par le terminal 80x24 de départ. + for _ in range(20): + await pilot.press("+") + await pilot.pause() + + # 40x18 (aire de #panes ~15) : assez pour satisfaire les + # planchers des TROIS volets même en « stacked », le pire + # cas (folders>=4 ET #right>=8, puisque #right y héberge à + # son tour list_pane/preview le long du MÊME axe -- voir + # `_PANE_SIBLING_MIN`) ; en dessous de ~15, ce ne serait + # plus une question de correctif mais de terminal + # physiquement trop petit pour les trois planchers à la + # fois, hors de portée de tout redimensionnement de volet. + await pilot.resize_terminal(40, 18) + await pilot.pause() + await pilot.pause() # laisse le correctif différé tourner + + folders_dim = app._pane_dimension(panes) + list_dim = app._pane_dimension(right) + + for widget, dim, name in ( + (folders, folders_dim, "folders"), + (right, folders_dim, "right"), + (list_pane, list_dim, "list_pane"), + (preview, list_dim, "preview"), + ): + measured = getattr(widget.region, dim) + self.assertGreaterEqual( + measured, + PANE_SIZE_MIN, + f"disposition {layout_id!r} : {name}.{dim} =" + f" {measured}, attendu >= {PANE_SIZE_MIN}", + ) + + # Repart d'un terminal et de tailles propres avant la + # disposition suivante. + await pilot.resize_terminal(80, 24) + await pilot.pause() + await pilot.pause() + await pilot.press("0") + await pilot.pause() + + async def test_growing_the_terminal_back_restores_the_stored_size(self): + from textual.widgets import DataTable + + app = await self._mounted_app() + async with app.run_test(size=(80, 24)) as pilot: + await app.workers.wait_for_complete() + await pilot.pause() + + app.query_one("#list", DataTable).focus() + await pilot.pause() + for _ in range(3): + await pilot.press("+") + await pilot.pause() + + list_pane = app.query_one("#list_pane") + grown_width = list_pane.region.width + + await pilot.resize_terminal(40, 24) + await pilot.pause() + await pilot.pause() + self.assertLess(list_pane.region.width, grown_width) + + await pilot.resize_terminal(80, 24) + await pilot.pause() + await pilot.pause() + # L'INTENTION (stockée, jamais écrasée par le rétrécissement + # temporaire) revient dès que la place existe de nouveau. + self.assertEqual(list_pane.region.width, grown_width) + + +class TestReSettlingDoesNotStarveTheUncustomizedSibling(ResizeCase): + """Round 3 : `_apply_pane_size_for_slot` lisait `pane.region` juste + après son PROPRE `self._clear_pane_size(slot)`, dans le MÊME appel -- + cette région est pré-rafraîchissement, exactement la même classe de + bogue déjà corrigée pour la lecture de `#right`. Reproduit en poussant + `#folders` à son plafond (11 pressions, `#right` = 8, `list_pane` déjà + correctement à 4), puis en redéclenchant `_apply_pane_size_for_slot` + une seconde fois SANS rien changer à `#folders` -- soit par une + pression `+` de plus (déjà au plafond, donc sans effet sur `#folders` + lui-même), soit par un redimensionnement du terminal. `list_pane` + retombait alors à 3 et y restait, PAS transitoirement. + + N'affirme jamais un nombre précis (ce serait passer par parité, comme + le test initial de ce correctif qui ne reproduisait pas ce cas) : + seulement que chaque volet reste >= `PANE_SIZE_MIN`. + """ + + async def _grow_folders_to_ceiling(self, pilot): + for _ in range(11): + await pilot.press("+") + await pilot.pause() + + def _assert_every_pane_at_or_above_minimum(self, app): + folders = app.query_one("#folders") + right = app.query_one("#right") + list_pane = app.query_one("#list_pane") + preview = app.query_one("#preview") + for widget, name in ( + (folders, "folders"), + (right, "right"), + (list_pane, "list_pane"), + (preview, "preview"), + ): + self.assertGreaterEqual( + widget.region.width, + PANE_SIZE_MIN, + f"{name}.width = {widget.region.width}, attendu >=" + f" {PANE_SIZE_MIN}", + ) + + async def test_an_extra_no_op_grow_does_not_starve_list_pane(self): + app = await self._mounted_app() + async with app.run_test(size=(80, 24)) as pilot: + await app.workers.wait_for_complete() + await pilot.pause() + + await self._grow_folders_to_ceiling(pilot) + # #folders est déjà à son plafond : cette pression ne le + # change PAS, mais redéclenche quand même la correction de + # list_pane. + await pilot.press("+") + await pilot.pause() + + self._assert_every_pane_at_or_above_minimum(app) + + async def test_a_clean_resize_after_reaching_the_ceiling_does_not_starve_list_pane( + self, + ): + app = await self._mounted_app() + async with app.run_test(size=(80, 24)) as pilot: + await app.workers.wait_for_complete() + await pilot.pause() + + await self._grow_folders_to_ceiling(pilot) + + await pilot.resize_terminal(81, 24) + await pilot.pause() + await pilot.pause() + + self._assert_every_pane_at_or_above_minimum(app) + + async def test_a_stored_list_pane_size_is_recapped_when_folders_grows( + self, + ): + """Ce que `then=` sert encore à garantir, une fois le plancher + confié à la feuille de style : la taille STOCKÉE de `list_pane` doit + être re-bornée contre le `#right` qui RESTE après l'agrandissement + de `#folders`, pas contre celui d'avant. + + Mesuré par le remplissage EXACT de `#right` par ses trois enfants : + une taille bornée contre un `#right` périmé les fait déborder, ce + qu'aucune assertion de plancher ne verrait — `min-width` maintient + alors chaque volet au-dessus de son plancher pendant que la somme + dépasse le conteneur. + """ + from textual.widgets import DataTable, Tree + + app = await self._mounted_app() + async with app.run_test(size=(80, 24)) as pilot: + await app.workers.wait_for_complete() + await pilot.pause() + + # Une taille de `list_pane` VOULUE par l'utilisateur, donc + # stockée -- sans elle, rien à re-borner ici. + app.query_one("#list", DataTable).focus() + await pilot.pause() + for _ in range(3): + await pilot.press("+") + await pilot.pause() + + app.query_one("#folders", Tree).focus() + await pilot.pause() + await self._grow_folders_to_ceiling(pilot) + + right = app.query_one("#right") + list_pane = app.query_one("#list_pane") + list_splitter = app.query_one("#list_splitter") + preview = app.query_one("#preview") + self.assertEqual( + list_pane.region.width + + list_splitter.region.width + + preview.region.width, + right.region.width, + f"list_pane {list_pane.region.width} + barre" + f" {list_splitter.region.width} + preview" + f" {preview.region.width} != #right" + f" {right.region.width}", + ) + self._assert_every_pane_at_or_above_minimum(app) + + +class _PaneThatMisreportsItsRegion: + """Un volet qui MENT sur sa propre région, et note qu'on la lui a + demandée. Tout le reste (`styles` compris) passe au vrai widget, si bien + qu'une taille posée à travers ce mandataire arrive réellement sur + l'écran. + + Sert à prouver une propriété STRUCTURELLE plutôt qu'à rejouer un + scénario : la taille d'un volet ne doit dépendre en RIEN de la région de + ce volet, parce qu'à l'instant où elle serait lue elle est encore + pré-rafraîchissement (voir `_apply_pane_size_for_slot`). Une propriété + ne se teste pas en attendant qu'une course se produise — 3 % des + exécutions, la raison pour laquelle ce bogue a survécu à trois + corrections. + """ + + # TOUTES les façons d'obtenir la géométrie RENDUE d'un widget, pas la + # seule qui a servi au bogue : n'intercepter que `region` interdirait + # une ORTHOGRAPHE, pas la classe -- `pane.size` rentrerait par la + # fenêtre et les 46 tests resteraient verts. + _MEASURED = frozenset( + { + "region", + "size", + "content_region", + "content_size", + "outer_size", + "container_size", + "virtual_size", + "window_region", + "scrollable_content_region", + } + ) + + def __init__(self, widget, lie, reads): + self._widget = widget + self._lie = lie + self._reads = reads + + def __getattr__(self, name): + # `_widget`/`_lie`/`_reads` sont des attributs d'instance : la + # recherche normale les trouve, `__getattr__` n'est jamais appelé + # pour eux. + if name in self._MEASURED: + self._reads.append(f"{self._widget.id}.{name}") + return self._lie + return getattr(self._widget, name) + + +class TestPaneSizingIgnoresThePanesOwnRegion(ResizeCase): + """Tâche 27. `_settle` prenait la région du volet comme base de bornage + quand rien n'était stocké — une région que son PROPRE + `_clear_pane_size`, deux lignes plus haut, venait d'invalider. Un + `call_after_refresh` rendait la lecture juste presque toujours ; quand + elle ne l'était pas, la base valait l'ancienne surcharge, le bornage + retombait dessus, la branche « rien à corriger » sautait l'écriture, et + `list_pane` restait DÉFINITIVEMENT à la part que la feuille de style lui + donne (`2fr` de `#right`, soit 3 cellules). + + Les deux tests ci-dessous rendent ce cas DÉTERMINISTE. + """ + + async def _grow_folders_to_ceiling(self, pilot): + for _ in range(11): + await pilot.press("+") + await pilot.pause() + + def _assert_every_pane_at_or_above_minimum(self, app): + for name in ("#folders", "#right", "#list_pane", "#preview"): + measured = app.query_one(name).region.width + self.assertGreaterEqual( + measured, + PANE_SIZE_MIN, + f"{name}.width = {measured}, attendu >= {PANE_SIZE_MIN}", + ) + + def _lie_about_pane_regions(self, app, lie, reads, calls): + """Détourne `_pane_widgets` pour rendre un volet menteur, et note + CHAQUE appel — les appels servent de contrôle positif : sans eux, un + test qui n'affirme qu'une absence de lecture passerait tout aussi + bien si le réglage des tailles n'avait pas tourné du tout. + """ + original = app._pane_widgets + + def _patched(slot: str): + calls.append(slot) + pane, parent = original(slot) + return _PaneThatMisreportsItsRegion(pane, lie, reads), parent + + app._pane_widgets = _patched + + async def test_the_floor_holds_even_when_the_pane_misreports_its_size( + self, + ): + """Le volet rapporte EXACTEMENT le plancher : sous l'ancien code, + `clamped == current` faisait sauter l'écriture et la feuille de + style reprenait la main avec 3 cellules. Le plancher ne se mesure + plus (`_PANE_MIN_CSS`), donc mentir ne peut plus l'abaisser. + """ + from textual.geometry import Region + + app = await self._mounted_app() + async with app.run_test(size=(80, 24)) as pilot: + await app.workers.wait_for_complete() + await pilot.pause() + + await self._grow_folders_to_ceiling(pilot) + + reads, calls = [], [] + self._lie_about_pane_regions( + app, + Region(0, 0, PANE_SIZE_MIN, PANE_SIZE_MIN), + reads, + calls, + ) + await pilot.resize_terminal(81, 24) + await pilot.pause() + await pilot.pause() + + self.assertTrue( + calls, "le réglage des tailles n'a pas tourné du tout" + ) + self._assert_every_pane_at_or_above_minimum(app) + + async def test_settling_never_reads_the_region_of_the_pane_it_sizes(self): + """La propriété elle-même, pas une de ses conséquences : aucune + lecture, donc rien à lire trop tôt. Pour qu'une lecture périmée + revienne, il faudrait la réintroduire ici — ce test l'interdit. + """ + from textual.geometry import Region + + app = await self._mounted_app() + async with app.run_test(size=(80, 24)) as pilot: + await app.workers.wait_for_complete() + await pilot.pause() + + await self._grow_folders_to_ceiling(pilot) + + reads, calls = [], [] + # Un redimensionnement du terminal, pas une touche `+`/`-` : + # `_resize_focused_pane` lit LÉGITIMEMENT la région du volet (la + # taille vive que l'utilisateur veut incrémenter), et cette + # lecture-là ne suit aucun effacement. + self._lie_about_pane_regions( + app, Region(0, 0, 999, 999), reads, calls + ) + await pilot.resize_terminal(81, 24) + await pilot.pause() + await pilot.pause() + + self.assertTrue( + calls, "le réglage des tailles n'a pas tourné du tout" + ) + self.assertEqual( + reads, + [], + "le réglage des tailles a lu la région des volets" + f" {reads} — une valeur encore pré-rafraîchissement", + ) + # Et le mensonge n'a rien déréglé : la preuve que le chemin + # exercé est bien le chemin mesuré. + self._assert_every_pane_at_or_above_minimum(app) + + +class TestSmallTerminalsKeepEveryPaneOnScreen(ResizeCase): + """Un volet qui DÉBORDE de son conteneur n'est pas seulement mal + dimensionné : il n'est jamais composité. Aucune barre de défilement + n'apparaît, `Tab` ne l'atteint pas, et rien à l'écran ne dit qu'il + existe. C'est arrivé en « stacked » dès 80x20, dans l'état PAR DÉFAUT, + sans qu'aucun test le voie : tous ceux qui approchent ces tailles + pressent `+` d'abord, et une taille de `#folders` stockée réserve + justement à `#right` de quoi tenir. + + `resolve_fraction_unit` (`_resolve.py:190-214`) épingle à son minimum + tout enfant `fr` qui descendrait sous lui et le RETIRE du réservoir ; + quand tous les frères `fr` s'épinglent, `1fr` vaut TOUT l'espace + restant et chacun reçoit la totalité. Deux volets de 7 dans un + conteneur de 8. + + Ces tests mesurent donc le REMPLISSAGE EXACT et la COMPOSITION, pas des + planchers : un plancher tenu par un volet hors écran est tenu pour + rien. Ils cassent aussi si Textual changeait la soustraction + `border-box` de `_resolve_extrema`, qui décide ce que valent ces + tailles. + """ + + # 13 lignes est le plancher réel de « stacked » : `#folders` ne descend + # pas sous `PANE_SIZE_MIN`, `#right` a besoin d'au moins `PANE_SIZE_MIN` + # + la barre pour que `#preview` garde une ligne, et `#panes` perd + # l'en-tête, l'état et le pied. En dessous, aucune disposition des + # trois volets ne tient — ce n'est plus un défaut de bornage. + SMALL_SIZES = ((80, 20), (80, 18), (80, 16), (80, 14), (40, 18)) + + def _assert_panes_fit_and_paint(self, app, label): + panes = app.query_one("#panes") + right = app.query_one("#right") + outer = app._pane_dimension(panes) + inner = app._pane_dimension(right) + + def measure(selector, dimension): + return getattr(app.query_one(selector).region, dimension) + + outer_sum = ( + measure("#folders", outer) + + measure("#folders_splitter", outer) + + measure("#right", outer) + ) + self.assertEqual( + outer_sum, + measure("#panes", outer), + f"{label} : #folders + barre + #right = {outer_sum} !=" + f" #panes {measure('#panes', outer)} ({outer})", + ) + inner_sum = ( + measure("#list_pane", inner) + + measure("#list_splitter", inner) + + measure("#preview", inner) + ) + self.assertEqual( + inner_sum, + measure("#right", inner), + f"{label} : #list_pane + barre + #preview = {inner_sum} !=" + f" #right {measure('#right', inner)} ({inner})", + ) + + # Le remplissage exact ne suffit pas à prouver qu'on VOIT les + # volets : c'est le compositeur qui décide ce qui est peint. + visible = app.screen._compositor.visible_widgets + for selector in ("#folders", "#list_pane", "#preview"): + self.assertIn( + app.query_one(selector), + visible, + f"{label} : {selector} n'est pas composité — hors écran," + " sans barre de défilement ni accès au clavier", + ) + + async def test_a_fresh_launch_at_80x20_paints_every_pane(self): + """Le scénario exact du signalement : premier lancement, aucune + touche, une disposition par démarrage — pas un redimensionnement + depuis une taille plus grande, qui n'emprunte pas le même chemin de + montage. + """ + from script.todo import todo_prefs + + for layout_id, _ in MAIL_LAYOUTS: + todo_prefs.set("mail_layout", layout_id) + app = await self._mounted_app(sessions=[self._fresh_session()]) + async with app.run_test(size=(80, 20)) as pilot: + await app.workers.wait_for_complete() + await pilot.pause() + await pilot.pause() + + self.assertEqual(app.mail_layout, layout_id) + self._assert_panes_fit_and_paint( + app, f"lancement 80x20 en {layout_id!r}" + ) + + async def test_no_pane_is_pushed_off_screen_at_small_sizes(self): + """La même garantie sur une plage de tailles et les trois + dispositions, toujours SANS personnalisation — c'est l'absence de + taille stockée qui déclenchait le défaut. + """ + app = await self._mounted_app() + async with app.run_test(size=(80, 24)) as pilot: + await app.workers.wait_for_complete() + await pilot.pause() + + for width, height in self.SMALL_SIZES: + await pilot.resize_terminal(width, height) + await pilot.pause() + await pilot.pause() + for layout_id, _ in MAIL_LAYOUTS: + while app.mail_layout != layout_id: + await pilot.press("v") + await pilot.pause() + await pilot.pause() + self._assert_panes_fit_and_paint( + app, f"{width}x{height} en {layout_id!r}" + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_mail_tui_splitter.py b/test/test_mail_tui_splitter.py new file mode 100644 index 0000000..d3e6089 --- /dev/null +++ b/test/test_mail_tui_splitter.py @@ -0,0 +1,566 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Barres de partage glissables à la souris (tâche 25) : un bouton par +frontière ajustable (`#folders_splitter`, `#list_splitter`), qui redimensionne +en direct pendant le glissement et persiste au relâchement — par le MÊME +`_store_pane_size` que `+`/`-`/`0` au clavier (tâche 24), jamais un second +magasin. + +Comme `test_mail_tui_resize.py` : tout ce qui compte ici n'a de sens que sur +l'application montée pour de vrai — les RÉGIONS mesurées avant/après un +glissement, jamais un attribut interne de `MailApp`. `Pilot.mouse_down`/ +`hover`/`mouse_up` composent le glissement ; `hover`/`mouse_up` visent une +coordonnée ÉCRAN absolue (`widget=None`), pas la barre elle-même, parce que +la barre se déplace pendant le glissement (son voisin redimensionné la +pousse) — cibler à nouveau la barre par sélecteur dériverait. +""" +import os +import tempfile +import unittest +from pathlib import Path + +from script.todo.mail.accounts import account_from_preset +from script.todo.mail.store import Store +from script.todo.mail.tui import PANE_SIZE_MIN, Session + + +class SplitterCase(unittest.IsolatedAsyncioTestCase): + """Monte `MailApp` pour de vrai, `$HOME` détourné — même motif que + `test_mail_tui_resize.py`. + """ + + def setUp(self): + self.fake_home = tempfile.TemporaryDirectory() + self._old_home = os.environ.get("HOME") + os.environ["HOME"] = self.fake_home.name + + self.cache_dir = tempfile.TemporaryDirectory() + self.account = account_from_preset("perso", "moi@x.ca", "generic") + self.account.cache_mode = "clear" + self.store = Store( + self.account, mode="clear", base=Path(self.cache_dir.name) + ) + self.store.open() + self.session = Session(self.account, self.store, None, password="x") + + def tearDown(self): + self.store.close() + if self._old_home is None: + os.environ.pop("HOME", None) + else: + os.environ["HOME"] = self._old_home + self.fake_home.cleanup() + self.cache_dir.cleanup() + + async def _mounted_app(self): + import textual.app + + from script.todo.mail.tui import run_tui + + captured = [] + orig_init = textual.app.App.__init__ + + def capturing_init(app_self, *a, **kw): + orig_init(app_self, *a, **kw) + captured.append(app_self) + + textual.app.App.__init__ = capturing_init + try: + run_tui(run_app=False, sessions=[self.session]) + finally: + textual.app.App.__init__ = orig_init + return captured[-1] + + async def _press_down(self, pilot, splitter_id: str): + """`MouseDown` sur la barre `splitter_id`, à sa propre position + (offset (0, 0) relatif à la barre) — rend son point de départ, en + coordonnées ÉCRAN, pour que les étapes suivantes du glissement + (`_move_to`/`_release_at`) ciblent une coordonnée ABSOLUE plutôt que + la barre elle-même, qui se déplace pendant le glissement. + """ + splitter = pilot.app.query_one(f"#{splitter_id}") + start = splitter.region.offset + await pilot.mouse_down(f"#{splitter_id}", offset=(0, 0)) + await pilot.pause() + return start + + async def _move_to(self, pilot, offset): + await pilot.hover(offset=offset) + await pilot.pause() + + async def _release_at(self, pilot, offset): + await pilot.mouse_up(offset=offset) + await pilot.pause() + + async def _drag(self, pilot, splitter_id: str, delta_x=0, delta_y=0): + """Glissement complet (presser, déplacer, relâcher) de `delta_x`/ + `delta_y` cellules ÉCRAN, à partir de la position actuelle de la + barre. + """ + start = await self._press_down(pilot, splitter_id) + target = (start.x + delta_x, start.y + delta_y) + await self._move_to(pilot, target) + await self._release_at(pilot, target) + + +class TestSplitterWidgetsPresent(SplitterCase): + async def test_both_splitters_exist_and_are_not_focusable(self): + app = await self._mounted_app() + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + await pilot.pause() + + folders_splitter = app.query_one("#folders_splitter") + list_splitter = app.query_one("#list_splitter") + self.assertFalse(folders_splitter.can_focus) + self.assertFalse(list_splitter.can_focus) + # Ni l'une ni l'autre ne doit jamais recevoir le focus par + # défaut (`Screen.AUTO_FOCUS`) à la place de `#folders`. + from textual.widgets import Tree + + self.assertIsInstance(app.focused, Tree) + + +class TestDragResizesColumns(SplitterCase): + """Disposition par défaut (`columns`) : les deux barres sont + verticales — glisser HORIZONTALEMENT redimensionne. + """ + + async def test_dragging_folders_splitter_widens_folders_and_narrows_right( + self, + ): + app = await self._mounted_app() + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + await pilot.pause() + + folders = app.query_one("#folders") + right = app.query_one("#right") + width_before = folders.region.width + right_before = right.region.width + + await self._drag(pilot, "folders_splitter", delta_x=6) + + self.assertEqual(folders.region.width, width_before + 6) + self.assertEqual(right.region.width, right_before - 6) + + async def test_dragging_list_splitter_widens_list_and_narrows_preview( + self, + ): + app = await self._mounted_app() + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + await pilot.pause() + + list_pane = app.query_one("#list_pane") + preview = app.query_one("#preview") + width_before = list_pane.region.width + preview_before = preview.region.width + + await self._drag(pilot, "list_splitter", delta_x=5) + + self.assertEqual(list_pane.region.width, width_before + 5) + self.assertEqual(preview.region.width, preview_before - 5) + + async def test_dragging_left_narrows_folders_and_widens_right(self): + app = await self._mounted_app() + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + await pilot.pause() + + folders = app.query_one("#folders") + right = app.query_one("#right") + width_before = folders.region.width + right_before = right.region.width + + await self._drag(pilot, "folders_splitter", delta_x=-6) + + self.assertEqual(folders.region.width, width_before - 6) + self.assertEqual(right.region.width, right_before + 6) + + +class TestDragIsLive(SplitterCase): + async def test_the_pane_resizes_before_release_not_only_after(self): + app = await self._mounted_app() + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + await pilot.pause() + + folders = app.query_one("#folders") + width_before = folders.region.width + + start = await self._press_down(pilot, "folders_splitter") + target = (start.x + 6, start.y) + await self._move_to(pilot, target) + + # Toujours en cours de glissement : le volet a DÉJÀ bougé. + self.assertEqual(folders.region.width, width_before + 6) + + await self._release_at(pilot, target) + self.assertEqual(folders.region.width, width_before + 6) + + +class TestDragPersistenceAndSharedStore(SplitterCase): + async def test_nothing_is_written_to_disk_before_release(self): + from script.todo import todo_prefs + + app = await self._mounted_app() + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + await pilot.pause() + + start = await self._press_down(pilot, "folders_splitter") + target = (start.x + 6, start.y) + await self._move_to(pilot, target) + + # `_store_pane_size` fait un aller-retour disque à chaque appel + # -- il ne doit tourner qu'À LA LEVÉE, jamais pendant le + # glissement lui-même. + self.assertEqual(todo_prefs.get("mail_pane_sizes"), {}) + + await self._release_at(pilot, target) + self.assertIn("columns", todo_prefs.get("mail_pane_sizes")) + + async def test_the_released_size_is_stored_under_the_same_key_only(self): + from script.todo import todo_prefs + + app = await self._mounted_app() + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + await pilot.pause() + + folders = app.query_one("#folders") + await self._drag(pilot, "folders_splitter", delta_x=6) + width_after = folders.region.width + + sizes = todo_prefs.get("mail_pane_sizes") + # AUCUNE autre clé de premier niveau : un seul magasin, celui + # que la tâche 24 a créé -- jamais un second, parallèle. + self.assertEqual(set(sizes.keys()), {"columns"}) + self.assertEqual(set(sizes["columns"].keys()), {"folders"}) + self.assertEqual(sizes["columns"]["folders"], width_after) + + async def test_the_keyboard_sees_the_size_the_mouse_just_set(self): + from textual.widgets import DataTable + + app = await self._mounted_app() + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + await pilot.pause() + + list_pane = app.query_one("#list_pane") + await self._drag(pilot, "list_splitter", delta_x=5) + width_after_drag = list_pane.region.width + + app.query_one("#list", DataTable).focus() + await pilot.pause() + await pilot.press("+") + await pilot.pause() + + # Le clavier reprend EXACTEMENT où la souris a laissé la + # taille -- la preuve qu'il n'y a qu'un seul magasin. + from script.todo.mail.tui import PANE_SIZE_STEP + + self.assertEqual( + list_pane.region.width, width_after_drag + PANE_SIZE_STEP + ) + + +class TestDragStopsAtTheMinimum(SplitterCase): + async def test_dragging_far_left_stops_folders_at_the_minimum(self): + app = await self._mounted_app() + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + await pilot.pause() + + folders = app.query_one("#folders") + await self._press_down(pilot, "folders_splitter") + # Bord gauche de l'écran : un delta négatif bien au-delà de ce + # qu'aucun terminal ne pourrait fournir, mais une coordonnée + # ÉCRAN toujours VALIDE (donc jamais `OutOfBounds`). + await self._move_to(pilot, (0, 0)) + await self._release_at(pilot, (0, 0)) + + self.assertEqual(folders.region.width, PANE_SIZE_MIN) + + async def test_dragging_far_right_leaves_rights_own_children_above_the_minimum( + self, + ): + """`#right` n'est pas une feuille : il héberge à son tour + `list_pane`/`list_splitter`/`preview` (voir `_PANE_SIBLING_MIN`, + tâche 24). Pousser `#folders` jusqu'à son plafond ne doit donc PAS + écraser `#right` à `PANE_SIZE_MIN` -- ce plancher est celui de + `list_pane`/`preview` eux-mêmes, chacun encore mesuré ICI plutôt que + supposé, exactement l'invariant que la tâche 24 a fini par tester + après avoir été mordue une première fois par un nombre figé plutôt + que par l'invariant réel (voir son rapport, « round 2 »). + """ + app = await self._mounted_app() + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + await pilot.pause() + + right = app.query_one("#right") + list_pane = app.query_one("#list_pane") + list_splitter = app.query_one("#list_splitter") + preview = app.query_one("#preview") + screen_width = app.screen.size.width + await self._press_down(pilot, "folders_splitter") + edge = (screen_width - 1, 0) + await self._move_to(pilot, edge) + await self._release_at(pilot, edge) + + self.assertGreaterEqual(list_pane.region.width, PANE_SIZE_MIN) + self.assertGreaterEqual(preview.region.width, PANE_SIZE_MIN) + self.assertEqual( + right.region.width, + list_pane.region.width + + list_splitter.region.width + + preview.region.width, + ) + + async def test_dragging_far_left_stops_list_pane_at_the_minimum(self): + app = await self._mounted_app() + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + await pilot.pause() + + list_pane = app.query_one("#list_pane") + await self._press_down(pilot, "list_splitter") + await self._move_to(pilot, (0, 0)) + await self._release_at(pilot, (0, 0)) + + self.assertEqual(list_pane.region.width, PANE_SIZE_MIN) + + async def test_dragging_far_right_stops_preview_at_the_minimum(self): + app = await self._mounted_app() + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + await pilot.pause() + + preview = app.query_one("#preview") + screen_width = app.screen.size.width + await self._press_down(pilot, "list_splitter") + edge = (screen_width - 1, 0) + await self._move_to(pilot, edge) + await self._release_at(pilot, edge) + + self.assertEqual(preview.region.width, PANE_SIZE_MIN) + + +class TestDragOrientationFollowsLayout(SplitterCase): + """Les deux barres suivent l'orientation RÉELLE du conteneur qu'elles + jouxtent (`MailApp._pane_dimension`), jamais une table par disposition. + """ + + async def test_dragging_in_stacked_resizes_by_height_not_width(self): + app = await self._mounted_app() + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + await pilot.pause() + + await pilot.press("v") # split + await pilot.pause() + await pilot.press("v") # stacked + await pilot.pause() + self.assertTrue( + app.query_one("#panes").has_class("layout-stacked") + ) + + folders = app.query_one("#folders") + right = app.query_one("#right") + width_before = folders.region.width + height_before = folders.region.height + right_height_before = right.region.height + + # `delta_y=1`, pas davantage : en `stacked`, `#panes` ne fait que + # 21 lignes de haut (écran 80x24, moins l'en-tête/le pied) -- + # `#right` doit en garder au moins 9 (`list_pane` + la barre + + # `preview`, chacun `>= PANE_SIZE_MIN`), donc `folders` ne peut + # grandir que de 1 avant de buter sur ce plafond ; un delta plus + # grand serait borné et ce test mesurerait le bornage, pas le + # suivi d'axe qu'il vérifie ici (voir `TestDragStopsAtTheMinimum` + # pour le bornage lui-même). + await self._drag(pilot, "folders_splitter", delta_y=1) + + self.assertEqual(folders.region.height, height_before + 1) + self.assertEqual(right.region.height, right_height_before - 1) + # La largeur, elle, ne bouge pas -- ce n'est plus l'axe partagé. + self.assertEqual(folders.region.width, width_before) + + async def test_dragging_in_split_list_splitter_resizes_by_height(self): + app = await self._mounted_app() + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + await pilot.pause() + + await pilot.press("v") # split + await pilot.pause() + self.assertTrue(app.query_one("#panes").has_class("layout-split")) + + list_pane = app.query_one("#list_pane") + preview = app.query_one("#preview") + height_before = list_pane.region.height + preview_height_before = preview.region.height + + await self._drag(pilot, "list_splitter", delta_y=3) + + self.assertEqual(list_pane.region.height, height_before + 3) + self.assertEqual(preview.region.height, preview_height_before - 3) + + +class TestInterruptedDragDoesNotStick(SplitterCase): + async def test_release_captures_only_via_the_bar_not_the_release_point( + self, + ): + """Le relâchement arrive loin de la barre (une seule cellule de + large) -- la capture de souris doit tout de même router le + `MouseUp` vers elle (voir `Screen._forward_event`), et la libérer : + rien de coincé après. + """ + app = await self._mounted_app() + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + await pilot.pause() + + await self._drag(pilot, "folders_splitter", delta_x=6) + + self.assertIsNone(app.mouse_captured) + + # L'app reste utilisable : un second glissement, ailleurs, + # fonctionne normalement -- la preuve qu'aucun état ne traîne. + list_pane = app.query_one("#list_pane") + width_before = list_pane.region.width + await self._drag(pilot, "list_splitter", delta_x=3) + self.assertEqual(list_pane.region.width, width_before + 3) + + async def test_app_blur_mid_drag_ends_it_and_persists_the_last_value( + self, + ): + from textual import events + + from script.todo import todo_prefs + + app = await self._mounted_app() + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + await pilot.pause() + + start = await self._press_down(pilot, "folders_splitter") + target = (start.x + 6, start.y) + await self._move_to(pilot, target) + + self.assertIsNotNone(app.mouse_captured) + + app.post_message(events.AppBlur()) + await pilot.pause() + + self.assertIsNone(app.mouse_captured) + self.assertIn( + "folders", todo_prefs.get("mail_pane_sizes").get("columns", {}) + ) + + async def test_fullscreen_mid_drag_ends_it_without_getting_stuck(self): + app = await self._mounted_app() + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + await pilot.pause() + + start = await self._press_down(pilot, "folders_splitter") + target = (start.x + 6, start.y) + await self._move_to(pilot, target) + self.assertIsNotNone(app.mouse_captured) + + app.action_toggle_fullscreen() + await pilot.pause() + + self.assertIsNone(app.mouse_captured) + self.assertTrue(app.query_one("#panes").has_class("fullscreen")) + + +class TestSplittersHiddenInFullscreen(SplitterCase): + async def test_both_splitters_vanish_in_fullscreen_in_every_layout(self): + from script.todo.mail.tui import MAIL_LAYOUTS + + app = await self._mounted_app() + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + await pilot.pause() + + panes = app.query_one("#panes") + folders_splitter = app.query_one("#folders_splitter") + list_splitter = app.query_one("#list_splitter") + + for layout_id, _ in MAIL_LAYOUTS: + while app.mail_layout != layout_id: + await pilot.press("v") + await pilot.pause() + + panes.add_class("fullscreen") + await pilot.pause() + + self.assertEqual(folders_splitter.region.width, 0) + self.assertEqual(folders_splitter.region.height, 0) + self.assertEqual(list_splitter.region.width, 0) + self.assertEqual(list_splitter.region.height, 0) + + panes.remove_class("fullscreen") + await pilot.pause() + + +class TestModalPushEndsAnyPendingDrag(SplitterCase): + """`App.push_screen` (`app.py:2937`) revokes mouse capture out from + under a drag that hasn't been released yet -- `App.capture_mouse` + (`app.py:3222`) posts `MouseRelease` to whatever WAS captured whenever + capture changes, including to `None`. `_PaneSplitter` must react to + that (`on_mouse_release`), or `MailApp`'s drag state stays pointed at + the ABANDONED slot: since capture is now cleared, the very first + (synthetic, pre-`MouseDown`) `MouseMove` of the NEXT, entirely + unrelated drag is routed by ordinary hit-testing to whatever's under + the pointer, and gets misapplied to the STALE slot before that new + drag's own `MouseDown` has a chance to reset the state. + """ + + async def test_pushing_a_modal_mid_drag_does_not_corrupt_the_next_drag( + self, + ): + app = await self._mounted_app() + async with app.run_test() as pilot: + await app.workers.wait_for_complete() + await pilot.pause() + + folders = app.query_one("#folders") + list_pane = app.query_one("#list_pane") + preview = app.query_one("#preview") + + # Glissement de `folders` JAMAIS relâché : le bouton de la + # souris est toujours, conceptuellement, enfoncé au moment où + # le modal ci-dessous est poussé. + start = await self._press_down(pilot, "folders_splitter") + target = (start.x + 5, start.y) + await self._move_to(pilot, target) + width_mid_drag = folders.region.width + + await pilot.press("l") # LogScreen (push_screen) + await pilot.pause() + await pilot.press("escape") # ferme LogScreen (dismiss) + await pilot.pause() + + list_width_before = list_pane.region.width + preview_width_before = preview.region.width + + # Glissement SUIVANT, SANS RAPPORT, sur l'AUTRE barre. + await self._drag(pilot, "list_splitter", delta_x=3) + + # `folders` n'a plus bougé depuis le modal -- rien de périmé ne + # devait plus le toucher. + self.assertEqual(folders.region.width, width_mid_drag) + # Le glissement de `list_splitter` a atterri exactement là où + # il atterrirait sans aucun modal impliqué. + self.assertEqual(list_pane.region.width, list_width_before + 3) + self.assertEqual(preview.region.width, preview_width_before - 3) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_mail_tui_text.py b/test/test_mail_tui_text.py new file mode 100644 index 0000000..f6e7087 --- /dev/null +++ b/test/test_mail_tui_text.py @@ -0,0 +1,298 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import datetime +import unittest + +from script.todo.mail.store import MessageMeta +from script.todo.mail.tui_text import ( + extract_body, + filter_messages, + format_date, + format_date_full, + format_size, + html_to_text, + is_unread, + short_addr, + truncate, +) + +# 2026-08-01 10:41:00 UTC +NOW = 1785580860 + + +def meta( + uid=1, subject="Devis", frm="Alice ", snippet="", flags="" +): + return MessageMeta( + uid=uid, + date=NOW, + size=100, + flags=flags, + msgid=f"<{uid}@x.ca>", + frm=frm, + to="moi@x.ca", + subject=subject, + snippet=snippet, + ) + + +class TestHtmlToText(unittest.TestCase): + def test_strips_tags(self): + self.assertEqual(html_to_text("

Bonjour

"), "Bonjour") + + def test_decodes_entities(self): + self.assertEqual( + html_to_text("

café & thé

"), "café & thé" + ) + + def test_drops_script_and_style(self): + out = html_to_text( + "

Salut

" + ) + self.assertEqual(out, "Salut") + + def test_br_becomes_newline(self): + self.assertEqual(html_to_text("a
b"), "a\nb") + + def test_block_tags_separate_lines(self): + self.assertIn("\n", html_to_text("
a
b
")) + + def test_collapses_blank_runs(self): + self.assertNotIn("\n\n\n", html_to_text("

a

\n\n\n\n\n

b

")) + + def test_empty_input(self): + self.assertEqual(html_to_text(""), "") + + +class TestExtractBody(unittest.TestCase): + def test_plain_text(self): + text, atts = extract_body(b"Subject: S\r\n\r\nBonjour Alice") + self.assertEqual(text.strip(), "Bonjour Alice") + self.assertEqual(atts, []) + + def test_prefers_plain_over_html(self): + raw = ( + b'Content-Type: multipart/alternative; boundary="B"\r\n\r\n' + b"--B\r\nContent-Type: text/plain\r\n\r\nversion texte\r\n" + b"--B\r\nContent-Type: text/html\r\n\r\n

version html

\r\n" + b"--B--\r\n" + ) + text, _ = extract_body(raw) + self.assertIn("version texte", text) + self.assertNotIn("html", text) + + def test_falls_back_to_html(self): + raw = b"Content-Type: text/html\r\n\r\n

Bonjour Alice

" + text, _ = extract_body(raw) + self.assertEqual(text.strip(), "Bonjour Alice") + + def test_lists_attachments(self): + raw = ( + b'Content-Type: multipart/mixed; boundary="B"\r\n\r\n' + b"--B\r\nContent-Type: text/plain\r\n\r\ncorps\r\n" + b"--B\r\nContent-Type: application/pdf\r\n" + b'Content-Disposition: attachment; filename="devis.pdf"\r\n\r\n' + b"%PDF\r\n--B--\r\n" + ) + _, atts = extract_body(raw) + self.assertEqual([a.filename for a in atts], ["devis.pdf"]) + self.assertEqual(atts[0].content_type, "application/pdf") + + def test_attachment_without_filename_gets_one(self): + raw = ( + b'Content-Type: multipart/mixed; boundary="B"\r\n\r\n' + b"--B\r\nContent-Type: text/plain\r\n\r\ncorps\r\n" + b"--B\r\nContent-Type: application/pdf\r\n" + b"Content-Disposition: attachment\r\n\r\n%PDF\r\n--B--\r\n" + ) + _, atts = extract_body(raw) + self.assertTrue(atts[0].filename) + + def test_broken_message_does_not_raise(self): + text, atts = extract_body(b"\x00\x01\x02 pas un courriel") + self.assertIsInstance(text, str) + self.assertIsInstance(atts, list) + + def test_decodes_charset(self): + raw = ( + b"Content-Type: text/plain; charset=iso-8859-1\r\n" + b"Content-Transfer-Encoding: 8bit\r\n\r\nCaf\xe9" + ) + text, _ = extract_body(raw) + self.assertIn("Café", text) + + def test_survives_unknown_8bit(self): + """Étiquette réelle observée en usage, pas seulement un charset + inventé (voir `script/todo/mail/charset.py`).""" + raw = ( + b"Content-Type: text/plain; charset=unknown-8bit\r\n\r\n" + b"Bonjour" + ) + text, _ = extract_body(raw) + self.assertIn("Bonjour", text) + + +class TestShortAddr(unittest.TestCase): + def test_display_name_wins(self): + self.assertEqual( + short_addr("Alice Tremblay "), "Alice Tremblay" + ) + + def test_bare_address(self): + self.assertEqual(short_addr("a@y.ca"), "a@y.ca") + + def test_quoted_display_name(self): + self.assertEqual( + short_addr('"Tremblay, Alice" '), "Tremblay, Alice" + ) + + def test_empty(self): + self.assertEqual(short_addr(""), "") + + def test_first_of_several(self): + self.assertEqual(short_addr("a@y.ca, b@y.ca"), "a@y.ca") + + +class TestTruncate(unittest.TestCase): + def test_short_text_untouched(self): + self.assertEqual(truncate("abc", 10), "abc") + + def test_long_text_gets_ellipsis(self): + self.assertEqual(truncate("abcdefghij", 5), "abcd…") + + def test_result_never_exceeds_width(self): + self.assertEqual(len(truncate("abcdefghij", 5)), 5) + + def test_width_of_one(self): + self.assertEqual(truncate("abcdef", 1), "…") + + def test_zero_width(self): + self.assertEqual(truncate("abc", 0), "") + + +class TestFormatDate(unittest.TestCase): + def test_today_shows_time(self): + self.assertRegex(format_date(NOW, NOW), r"^\d{2}:\d{2}$") + + def test_this_year_shows_day_and_month(self): + self.assertRegex(format_date(NOW - 90 * 86400, NOW), r"^\d{2}-\d{2}$") + + def test_older_shows_the_year(self): + self.assertRegex( + format_date(NOW - 800 * 86400, NOW), r"^\d{4}-\d{2}-\d{2}$" + ) + + def test_zero_is_blank(self): + self.assertEqual(format_date(0, NOW), "") + + def test_absurd_epoch_is_blank_and_does_not_raise(self): + """Le contrat du module : une date d'en-tête aberrante ne lève jamais.""" + for hostile in (10**18, 2**63, -(10**18)): + self.assertEqual(format_date(hostile, NOW), "") + + +class TestFormatDateFull(unittest.TestCase): + """`format_date` reste volontairement compact pour la liste ; l'aperçu + d'un message a besoin de la date COMPLÈTE, sans ambiguïté à elle seule. + + Ne réutilise pas `format_date` : sa compacité est une propriété + voulue de la colonne, pas un raccourci disponible ailleurs. + """ + + def test_known_epoch_renders_in_full(self): + # Calculé de la même façon que l'implémentation (heure locale) : + # un `assertEqual` sur une chaîne littérale dépendrait du fuseau + # horaire de la machine qui exécute le test. + expected = datetime.datetime.fromtimestamp(NOW).strftime( + "%Y-%m-%d %H:%M" + ) + self.assertEqual(format_date_full(NOW), expected) + + def test_zero_is_blank(self): + self.assertEqual(format_date_full(0), "") + + def test_absurd_epoch_is_blank_and_does_not_raise(self): + """Mêmes trois valeurs que `test_absurd_epoch_is_blank_and_does_not_raise` + de `TestFormatDate` : ce sont elles qui ont fait lever `format_date` + avant l'ajout de sa garde — `format_date_full` doit tenir la même + promesse.""" + for hostile in (10**18, 2**63, -(10**18)): + self.assertEqual(format_date_full(hostile), "") + + +class TestFormatSize(unittest.TestCase): + def test_bytes(self): + self.assertEqual(format_size(512), "512 o") + + def test_kilobytes(self): + self.assertEqual(format_size(2048), "2.0 ko") + + def test_megabytes(self): + self.assertEqual(format_size(5 * 1024 * 1024), "5.0 Mo") + + def test_zero(self): + self.assertEqual(format_size(0), "0 o") + + +class TestIsUnread(unittest.TestCase): + def test_no_flags_is_unread(self): + self.assertTrue(is_unread("")) + + def test_seen_is_read(self): + self.assertFalse(is_unread("\\Seen")) + + def test_seen_among_others(self): + self.assertFalse(is_unread("\\Answered \\Seen")) + + def test_none_is_unread(self): + self.assertTrue(is_unread(None)) + + +class TestFilterMessages(unittest.TestCase): + def setUp(self): + self.metas = [ + meta(1, subject="Devis révisé", frm="Alice "), + meta( + 2, + subject="CR réunion", + frm="Bob ", + snippet="ordre du jour", + ), + ] + + def test_empty_query_returns_all(self): + self.assertEqual(len(filter_messages(self.metas, "")), 2) + + def test_matches_subject(self): + self.assertEqual( + [m.uid for m in filter_messages(self.metas, "devis")], [1] + ) + + def test_is_case_insensitive(self): + self.assertEqual( + [m.uid for m in filter_messages(self.metas, "DEVIS")], [1] + ) + + def test_matches_sender(self): + self.assertEqual( + [m.uid for m in filter_messages(self.metas, "bob")], [2] + ) + + def test_matches_snippet(self): + self.assertEqual( + [m.uid for m in filter_messages(self.metas, "ordre")], [2] + ) + + def test_accent_insensitive(self): + self.assertEqual( + [m.uid for m in filter_messages(self.metas, "revise")], [1] + ) + + def test_no_match(self): + self.assertEqual(filter_messages(self.metas, "zzz"), []) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_todo.py b/test/test_todo.py index 9e1fb4a..d2790e4 100644 --- a/test/test_todo.py +++ b/test/test_todo.py @@ -4,9 +4,10 @@ import json import os +import subprocess import tempfile import unittest -from unittest.mock import MagicMock, patch +from pathlib import Path from script.todo.todo import ( ANDROID_DIR, @@ -180,9 +181,6 @@ class TestOnDirSelected(unittest.TestCase): todo = TODO() todo.on_dir_selected("/some/path") self.assertEqual(todo.dir_path, "/some/path") - - -class TestExecuteFromConfiguration(unittest.TestCase): def test_with_command(self): todo = TODO() todo.execute = MagicMock() @@ -190,15 +188,6 @@ class TestExecuteFromConfiguration(unittest.TestCase): todo.execute_from_configuration(dct) todo.execute.exec_command_live.assert_called() - def test_with_makefile_cmd(self): - todo = TODO() - todo.execute = MagicMock() - todo.execute.exec_command_live.return_value = 0 - dct = {"makefile_cmd": "run_test"} - todo.execute_from_configuration(dct) - call_args = todo.execute.exec_command_live.call_args - self.assertIn("make run_test", call_args[0][0]) - def test_makefile_cmd_ignored_when_flag(self): todo = TODO() todo.execute = MagicMock() @@ -277,9 +266,6 @@ class TestProcessKillGitDaemon(unittest.TestCase): cmd = todo.execute.exec_command_live.call_args[0][0] self.assertIn("pkill", cmd) self.assertIn("git daemon", cmd) - - -class TestExecuteUnitTests(unittest.TestCase): def test_success_path(self): todo = TODO() todo.execute = MagicMock() @@ -295,10 +281,6 @@ class TestExecuteUnitTests(unittest.TestCase): todo.execute.exec_command_live.return_value = (1, ["FAIL"]) with patch("builtins.print") as mock_print: todo.execute_unit_tests() - # Verify it was called - error handling path - - -class TestKdbxGetExtraCommandUser(unittest.TestCase): def test_empty_kdbx_key(self): todo = TODO() result = todo.kdbx_manager.get_extra_command_user("") @@ -316,16 +298,6 @@ class TestKdbxGetExtraCommandUser(unittest.TestCase): self.assertEqual(result, "") -class TestSetupClaudeCommit(unittest.TestCase): - def test_existing_file_skips(self): - todo = TODO() - with patch("os.path.exists", return_value=True), patch( - "builtins.print" - ) as mock_print: - todo._setup_claude_commit() - # Should print exists message without asking for input - - class TestSelectDatabase(unittest.TestCase): @patch("script.todo.database_manager.click") def test_select_database_returns_name(self, mock_click): @@ -407,5 +379,48 @@ class TestCreateBackupFromDatabase(unittest.TestCase): self.assertIn("test_db", cmd) +class TestModuleLevelAbortExit(unittest.TestCase): + """`click.exceptions.Abort` (raised by `click.prompt` on both Ctrl+C and + Ctrl+D/EOF - see click's own `termui.prompt_func`) is NOT a + `KeyboardInterrupt` subclass. Only the top-level menu's `click.prompt` + call is wrapped locally, inside `run()` (todo.py around line 149) - + every submenu (`prompt_assistant`, etc.) lets `Abort` propagate + uncaught. These tests drive the real script end to end (not a mock of + the dispatch chain) to prove the module-level guard around + `todo.run()` (todo.py around line 7159) now catches it too. + """ + + def _run_todo(self, stdin_text): + repo_root = Path(__file__).resolve().parent.parent + python_bin = repo_root / ".venv.erplibre" / "bin" / "python3" + env = os.environ.copy() + with tempfile.TemporaryDirectory() as home_dir: + env["HOME"] = home_dir + return subprocess.run( + [str(python_bin), "script/todo/todo.py"], + cwd=repo_root, + input=stdin_text, + capture_output=True, + text=True, + env=env, + timeout=30, + ) + + def test_ctrl_d_in_a_submenu_exits_cleanly(self): + # "3" enters the Assistant submenu; the immediate EOF that follows + # raises Abort from a click.prompt() call that run() does not wrap. + result = self._run_todo("3\n") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertNotIn("Traceback", result.stderr) + self.assertNotIn("click.exceptions.Abort", result.stderr) + + def test_ctrl_d_on_the_top_menu_still_exits_cleanly(self): + # Regression guard: the pre-existing local handler in run() must + # keep working once the module-level guard is added alongside it. + result = self._run_todo("") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertNotIn("Traceback", result.stderr) + + if __name__ == "__main__": unittest.main()