[ADD] mail: read and send email from the TODO CLI

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
This commit is contained in:
Mathieu Benoit 2026-08-10 03:10:50 -04:00
parent 60fb60e156
commit 16aecc2d33
51 changed files with 19632 additions and 71 deletions

678
doc/EMAIL.base.md Normal file
View file

@ -0,0 +1,678 @@
<!---------------------------->
<!-- multilingual suffix: en, fr -->
<!-- no suffix: en -->
<!---------------------------->
<!-- [en] -->
# 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".
<!-- [fr] -->
# 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 ».
<!-- [en] -->
## 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:
<!-- [fr] -->
## 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 :
<!-- [common] -->
```bash
.venv.erplibre/bin/pip install -r requirement/erplibre_require-ments.txt
```
<!-- [en] -->
### 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.
<!-- [fr] -->
### 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.
<!-- [en] -->
## 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 <email>`.
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.
<!-- [fr] -->
## 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é <email>`.
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.
<!-- [en] -->
## 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/<account>/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-<pid>/<account>/` (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.
<!-- [fr] -->
## 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/<compte>/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-<pid>/<compte>/` (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.
<!-- [en] -->
## 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.
<!-- [fr] -->
## 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.
<!-- [en] -->
## 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.
<!-- [fr] -->
## É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é.
<!-- [en] -->
## 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.
<!-- [fr] -->
## 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.
<!-- [en] -->
## 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/<account>/cache.db` | that account's SQLite cache (mode 0600, parent directory 0700) |
| `~/.erplibre/mail/<account>/<folder>/<uid>.eml` (or `.eml.enc` when sealed) | one file per downloaded message body |
| `/dev/shm/erplibre-mail-<pid>/<account>/` | 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) |
<!-- [fr] -->
## 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/<compte>/cache.db` | le cache SQLite de ce compte (mode 0600, dossier parent 0700) |
| `~/.erplibre/mail/<compte>/<dossier>/<uid>.eml` (ou `.eml.enc` s'il est scellé) | un fichier par corps de message téléchargé |
| `/dev/shm/erplibre-mail-<pid>/<compte>/` | 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é) |
<!-- [en] -->
## 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:
<!-- [fr] -->
## 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 :
<!-- [common] -->
```bash
rm -rf ~/.erplibre/mail/<account>/
```
<!-- [en] -->
## 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:
<!-- [fr] -->
## 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 :
<!-- [common] -->
```bash
.venv.erplibre/bin/python -m unittest discover -s test \
-p test_mail_live_server.py -v
```
<!-- [en] -->
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.
<!-- [fr] -->
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.
<!-- [en] -->
## 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.
<!-- [fr] -->
## 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.

340
doc/EMAIL.fr.md Normal file
View file

@ -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é <email>`.
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/<compte>/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-<pid>/<compte>/` (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/<compte>/cache.db` | le cache SQLite de ce compte (mode 0600, dossier parent 0700) |
| `~/.erplibre/mail/<compte>/<dossier>/<uid>.eml` (ou `.eml.enc` s'il est scellé) | un fichier par corps de message téléchargé |
| `/dev/shm/erplibre-mail-<pid>/<compte>/` | 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/<account>/
```
## 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.

318
doc/EMAIL.md Normal file
View file

@ -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 <email>`.
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/<account>/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-<pid>/<account>/` (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/<account>/cache.db` | that account's SQLite cache (mode 0600, parent directory 0700) |
| `~/.erplibre/mail/<account>/<folder>/<uid>.eml` (or `.eml.enc` when sealed) | one file per downloaded message body |
| `/dev/shm/erplibre-mail-<pid>/<account>/` | 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/<account>/
```
## 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.

View file

@ -33,3 +33,11 @@ TODO: having the DB variable configurable
<!-- [fr] -->
À FAIRE : rendre la variable DB configurable
<!-- [en] -->
See also: [EMAIL.md](EMAIL.md) — the mail client built into the TODO CLI
(`Assistant > Mail`).
<!-- [fr] -->
Voir aussi : [EMAIL.fr.md](EMAIL.fr.md) — le client courriel intégré au CLI
TODO (`Assistant > Courriel`).

View file

@ -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
À FAIRE : rendre la variable DB configurable
Voir aussi : [EMAIL.fr.md](EMAIL.fr.md) — le client courriel intégré au CLI
TODO (`Assistant > Courriel`).

View file

@ -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`).

View file

@ -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

View file

@ -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

View file

@ -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).
<!-- [fr] -->
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).

View file

@ -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.
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).

View file

@ -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).

View file

@ -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`.
"""

View file

@ -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)

View file

@ -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

View file

@ -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")

119
script/todo/mail/crypto.py Normal file
View file

@ -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}")

View file

@ -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

View file

@ -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)

550
script/todo/mail/menu.py Normal file
View file

@ -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"))

178
script/todo/mail/secrets.py Normal file
View file

@ -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 : "<coffre>:<chemin>"
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)

View file

@ -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

624
script/todo/mail/store.py Normal file
View file

@ -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)

2701
script/todo/mail/tui.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -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.*?</\1>", re.IGNORECASE | re.DOTALL
)
_BR_RE = re.compile(r"<br\s*/?>", re.IGNORECASE)
_BLOCK_RE = re.compile(
r"</(p|div|tr|li|h[1-6]|table|blockquote)>", 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 <a@y.ca> » → « 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}")
]

View file

@ -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")

View file

@ -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:"},
}

View file

@ -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 : {"<layout>": {"folders":
# <cellules>, "list_pane": <cellules>}}. 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": {},
}

832
test/mail_sandbox.py Normal file
View file

@ -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

View file

@ -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):

View file

@ -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()

233
test/test_mail_accounts.py Normal file
View file

@ -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()

44
test/test_mail_charset.py Normal file
View file

@ -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("<EFBFBD>", decode_bytes(b"caf\xe9", "ascii"))
if __name__ == "__main__":
unittest.main()

1382
test/test_mail_compose.py Normal file

File diff suppressed because it is too large Load diff

120
test/test_mail_crypto.py Normal file
View file

@ -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()

View file

@ -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 <alice@x.ca>\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: <abc@x.ca>\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 <alice@x.ca>")
self.assertEqual(got.to, "moi@x.ca")
def test_msgid(self):
self.assertEqual(
parse_fetch_headers([HEADERS_1, b")"])[0].msgid, "<abc@x.ca>"
)
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")

View file

@ -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 <rene@example.org>\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: <hostile-1@example.org>\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 <alice@example.org>\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: <poli-{uid}@example.org>\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("<EFBFBD>", 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="<fixe@erplibre>",
)
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 <moi@example.ca>")
self.assertEqual(parsed["To"], "alice@example.org")
self.assertEqual(parsed["Cc"], "copie@example.org")
self.assertEqual(parsed["Message-ID"], "<fixe@erplibre>")
@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="<copie@erplibre>",
)
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, "<poli-1@example.org>")
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 <rene@example.org>\n"
"To: moi@example.ca\n"
"Subject: Devis\n"
"Date: Wed, 06 Aug 2026 10:01:00 +0000\n"
"Message-ID: <lf-1@example.org>\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, "<lf-1@example.org>")
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()

821
test/test_mail_menu.py Normal file
View file

@ -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"""(?<![A-Za-z_])t\((["'])(mail_[a-z_]+)\1\)""")
used = set()
for path in Path(mail_pkg.__file__).parent.glob("*.py"):
used |= {m.group(2) for m in pattern.finditer(path.read_text())}
self.assertTrue(
used, "aucune clé trouvée : le motif ne correspond plus"
)
missing = sorted(used - set(TRANSLATIONS))
self.assertEqual(
missing, [], f"clés utilisées mais non traduites : {missing}"
)
class TestTodoWiring(unittest.TestCase):
def test_todo_exposes_prompt_assistant(self):
from script.todo.todo import TODO
self.assertTrue(hasattr(TODO, "prompt_assistant"))
def test_todo_keeps_the_ai_question(self):
from script.todo.todo import TODO
self.assertTrue(hasattr(TODO, "_assistant_question"))
def test_assistant_key_is_translated(self):
from script.todo.todo_i18n import TRANSLATIONS
self.assertIn("Assistant", TRANSLATIONS)
self.assertNotIn("Question", TRANSLATIONS)
def test_one_dispatches_to_assistant_question_only(self):
"""`hasattr` seul ne verrait pas deux branches de menu échangées —
on pilote `click.prompt` et on vérifie que `[1]` appelle
`_assistant_question`, PAS `prompt_execute_mail`.
`_menu_header()` enregistre aussi une télémétrie best-effort dans
`~/.erplibre` : on la neutralise, sinon ce test écrirait pour de
vrai sur la machine.
"""
from unittest.mock import patch
from script.todo.todo import TODO
todo = TODO()
with patch.object(TODO, "_assistant_question") as mock_question, patch(
"script.todo.mail.menu.prompt_execute_mail"
) as mock_mail, patch("click.prompt", side_effect=["1", "0"]), patch(
"script.todo.todo_telemetry.record"
):
todo.prompt_assistant()
mock_question.assert_called_once_with()
mock_mail.assert_not_called()
def test_two_dispatches_to_mail_only(self):
"""Symétrique : `[2]` appelle `prompt_execute_mail`, PAS
`_assistant_question`."""
from unittest.mock import patch
from script.todo.todo import TODO
todo = TODO()
with patch.object(TODO, "_assistant_question") as mock_question, patch(
"script.todo.mail.menu.prompt_execute_mail"
) as mock_mail, patch("script.todo.todo_telemetry.record"), patch(
"click.prompt", side_effect=["2", "0"]
):
todo.prompt_assistant()
mock_mail.assert_called_once_with(todo)
mock_question.assert_not_called()
class TestRetryPassword(unittest.TestCase):
def setUp(self):
self.account = account_from_preset("perso", "a@x.ca", "generic")
self.vault = {}
def _todo(self):
from unittest.mock import MagicMock
return MagicMock()
def test_a_timeout_does_not_blame_the_password(self):
"""Le serveur n'a RIEN dit : la commande est partie, aucune réponse.
Accuser le mot de passe envoie chercher un mot de passe
d'application pour un problème qui est ailleurs — signalé à
l'usage, sur un « The read operation timed out » de Gmail."""
lignes = self._lignes_affichees(
"gmail",
cause="connexion IMAP refusée : The read operation timed out",
)
self.assertNotIn("mot de passe d'application", lignes)
def _invite(self, preset_key):
"""L'invite EXACTE affichée par `getpass`, pas ce qui la précède."""
from unittest.mock import patch
from script.todo.mail.menu import retry_password
vues = []
with patch(
"getpass.getpass", side_effect=lambda p="": vues.append(p) or ""
), patch("script.todo.mail.menu.secret_store_for"), patch(
"builtins.print"
):
retry_password(
self._todo(),
account_from_preset("essai", "a@x.ca", preset_key),
connect_fn=lambda a, p: None,
)
return vues[0]
def test_the_prompt_itself_asks_for_the_app_password(self):
"""La note se lit une fois ; l'invite se relit à CHAQUE tentative.
« Mot de passe : » invitait à saisir celui du compte, que ces
fournisseurs refusent."""
self.assertIn("application", self._invite("gmail"))
def test_a_generic_provider_keeps_the_plain_prompt(self):
self.assertNotIn("application", self._invite("generic"))
def test_the_note_gives_the_address_not_a_menu_path(self):
"""Google cache cette page : un chemin de menu ne suffit pas, et
les intitulés changent. L'URL, elle, se colle."""
lignes = self._lignes_affichees("gmail")
self.assertIn("https://myaccount.google.com/apppasswords", lignes)
def test_googles_own_wording_is_recognised(self):
"""Le cas qui a manqué : une fois la double authentification
active, Gmail répond « [ALERT] Application-specific password
required » — sans « invalid credentials » ni « authentication
failed ». Une liste de libellés attendus est toujours en retard sur
les serveurs réels."""
lignes = self._lignes_affichees(
"gmail",
cause=(
"b'[ALERT] Application-specific password required:"
" https://support.google.com/accounts/answer/185833'"
),
)
self.assertIn("mot de passe d'application", lignes)
def test_an_unknown_refusal_still_shows_the_note(self):
"""La note est un CONSEIL, pas un verdict : la taire à tort coûte
la panne, la donner à tort coûte une ligne. Un serveur dont on ne
connaît pas la formulation doit donc l'obtenir."""
lignes = self._lignes_affichees(
"gmail", cause="b'[NO] something we have never seen before'"
)
self.assertIn("mot de passe d'application", lignes)
def test_an_explicit_refusal_still_blames_the_password(self):
"""Le contrôle symétrique : restreindre l'affichage ne doit pas
l'avoir supprimé dans le cas où il sert."""
lignes = self._lignes_affichees(
"gmail", cause="b'[AUTHENTICATIONFAILED] Invalid credentials'"
)
self.assertIn("mot de passe d'application", lignes)
def _lignes_affichees(self, preset_key, cause=None):
"""Ce que l'utilisateur LIT avant qu'on lui redemande son mot de
passe, quand la connexion vient d'être refusée."""
from unittest.mock import patch
from script.todo.mail.menu import retry_password
compte = account_from_preset("essai", "a@x.ca", preset_key)
vues = []
with patch("getpass.getpass", return_value=""), patch(
"script.todo.mail.menu.secret_store_for"
), patch(
"builtins.print",
side_effect=lambda *a: vues.append(" ".join(map(str, a))),
):
retry_password(
self._todo(),
compte,
connect_fn=lambda a, p: None,
cause=cause,
)
return "\n".join(vues)
def test_a_provider_needing_an_app_password_says_so_before_reasking(self):
"""Gmail répond « Invalid credentials » au mot de passe habituel
exactement comme à une faute de frappe. Sans cette note, l'invite
pousse à retaper le même — et à se le faire refuser trois fois."""
lignes = self._lignes_affichees("gmail")
self.assertIn("mot de passe d'application", lignes)
# L'instruction PRÉCISE, pas seulement le constat : sans elle, on
# sait qu'il faut autre chose sans savoir où le prendre.
self.assertIn("myaccount.google.com", lignes)
def test_a_generic_provider_says_nothing_of_the_kind(self):
"""Le contrôle négatif : sans lui, une note affichée à TOUS
passerait ce test aussi bien."""
self.assertNotIn(
"mot de passe d'application", self._lignes_affichees("generic")
)
def test_updates_the_vault_after_a_good_password(self):
from unittest.mock import patch
from script.todo.mail.menu import retry_password
class OkTransport:
def logout(self):
pass
with patch("getpass.getpass", return_value="bon"), patch(
"script.todo.mail.menu.secret_store_for"
) as store:
store.return_value.set.side_effect = self.vault.__setitem__
ok = retry_password(
self._todo(),
self.account,
connect_fn=lambda a, p: OkTransport(),
)
self.assertTrue(ok)
self.assertEqual(self.vault[self.account.secret_ref], "bon")
def test_does_not_touch_the_vault_when_every_try_fails(self):
from unittest.mock import patch
from script.todo.mail.menu import retry_password
def refuse(account, password):
raise OSError("530 refus")
with patch("getpass.getpass", return_value="faux"), patch(
"script.todo.mail.menu.secret_store_for"
) as store:
store.return_value.set.side_effect = self.vault.__setitem__
ok = retry_password(
self._todo(), self.account, attempts=2, connect_fn=refuse
)
self.assertFalse(ok)
self.assertEqual(self.vault, {})
def test_empty_input_gives_up(self):
from unittest.mock import patch
from script.todo.mail.menu import retry_password
with patch("getpass.getpass", return_value=""):
self.assertFalse(
retry_password(
self._todo(), self.account, connect_fn=lambda a, p: None
)
)
if __name__ == "__main__":
unittest.main()

229
test/test_mail_secrets.py Normal file
View file

@ -0,0 +1,229 @@
#!/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 stat
import tempfile
import unittest
from unittest.mock import MagicMock, patch
from script.todo.mail.secrets import (
SecretError,
SecretStore,
create_kdbx,
keyring_is_safe,
)
class FakeKeyringBackend:
"""Imite un backend keyring, sans toucher au trousseau de la machine."""
def __init__(self, name="keyring.backends.SecretService.Keyring"):
self.__class__.__module__ = name.rsplit(".", 1)[0]
self._name = name
self.store = {}
class TestKeyringSafety(unittest.TestCase):
def _with_backend(self, module_name, class_name):
backend = MagicMock()
type(backend).__module__ = module_name
type(backend).__qualname__ = class_name
return backend
def test_secretservice_is_safe(self):
backend = self._with_backend(
"keyring.backends.SecretService", "Keyring"
)
with patch("keyring.get_keyring", return_value=backend):
self.assertTrue(keyring_is_safe())
def test_macos_is_safe(self):
backend = self._with_backend("keyring.backends.macOS", "Keyring")
with patch("keyring.get_keyring", return_value=backend):
self.assertTrue(keyring_is_safe())
def test_windows_is_safe(self):
backend = self._with_backend(
"keyring.backends.Windows", "WinVaultKeyring"
)
with patch("keyring.get_keyring", return_value=backend):
self.assertTrue(keyring_is_safe())
def test_plaintext_alt_is_refused(self):
backend = self._with_backend("keyrings.alt.file", "PlaintextKeyring")
with patch("keyring.get_keyring", return_value=backend):
self.assertFalse(keyring_is_safe())
def test_fail_backend_is_refused(self):
backend = self._with_backend("keyring.backends.fail", "Keyring")
with patch("keyring.get_keyring", return_value=backend):
self.assertFalse(keyring_is_safe())
def test_unknown_backend_is_refused(self):
"""Par défaut on refuse : un backend qu'on ne connaît pas peut écrire en clair."""
backend = self._with_backend("un.paquet.inconnu", "Keyring")
with patch("keyring.get_keyring", return_value=backend):
self.assertFalse(keyring_is_safe())
class TestCreateKdbxPermissions(unittest.TestCase):
"""`create_database` (pykeepass) écrit d'abord un fichier `.tmp` via
`construct`, avec `open(filename, "w+b")` — donc à l'umask du process —
avant de le déplacer sur la cible. Resserrer l'umask le temps de l'appel
est donc la seule façon de fermer cette fenêtre : un `os.open` sur la
cible ne verrait jamais ce fichier intermédiaire."""
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.path = os.path.join(self.tmp.name, "nested", "test.kdbx")
def tearDown(self):
self.tmp.cleanup()
def test_file_is_0600(self):
create_kdbx(self.path, "motdepasse")
mode = stat.S_IMODE(os.stat(self.path).st_mode)
self.assertEqual(mode, 0o600)
def test_parent_dir_is_0700(self):
create_kdbx(self.path, "motdepasse")
mode = stat.S_IMODE(os.stat(os.path.dirname(self.path)).st_mode)
self.assertEqual(mode, 0o700)
def test_restores_the_process_umask(self):
before = os.umask(0o022)
os.umask(before) # `os.umask` ne peut que remplacer : on relit puis
# on rétablit exactement ce qu'on avait, sans jamais l'avoir changé
# pour de vrai entre les deux appels.
create_kdbx(self.path, "motdepasse")
after = os.umask(before)
os.umask(after)
self.assertEqual(after, before)
def test_umask_is_tightened_while_the_file_is_built(self):
"""La preuve directe : PENDANT `create_database`, l'umask doit être
resserré, sinon le fichier `.tmp` intermédiaire existe, même
brièvement, à l'umask permissif du process."""
seen = {}
def fake_create_database(path, password=None):
seen["umask"] = os.umask(0)
os.umask(seen["umask"])
with open(path, "wb"):
pass
with patch(
"pykeepass.create_database", side_effect=fake_create_database
):
create_kdbx(self.path, "motdepasse")
self.assertEqual(seen["umask"], 0o077)
class TestKdbxRoundtrip(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.path = os.path.join(self.tmp.name, "test.kdbx")
create_kdbx(self.path, "motdepasse")
from pykeepass import PyKeePass
self.kp = PyKeePass(self.path, password="motdepasse")
manager = MagicMock()
manager.get_kdbx.return_value = self.kp
self.store = SecretStore(kdbx_manager=manager, use_keyring=False)
def tearDown(self):
self.tmp.cleanup()
def test_created_file_exists(self):
self.assertTrue(os.path.exists(self.path))
def test_set_then_get(self):
self.store.set("kdbx:ERPLibre/Mail/perso", "hunter2")
self.assertEqual(self.store.get("kdbx:ERPLibre/Mail/perso"), "hunter2")
def test_set_creates_nested_groups(self):
self.store.set("kdbx:ERPLibre/Mail/travail", "s3cr3t")
groups = [g.name for g in self.kp.groups]
self.assertIn("ERPLibre", groups)
self.assertIn("Mail", groups)
def test_set_twice_overwrites(self):
self.store.set("kdbx:ERPLibre/Mail/perso", "ancien")
self.store.set("kdbx:ERPLibre/Mail/perso", "nouveau")
self.assertEqual(self.store.get("kdbx:ERPLibre/Mail/perso"), "nouveau")
def test_get_missing_returns_none(self):
self.assertIsNone(self.store.get("kdbx:ERPLibre/Mail/absent"))
def test_delete(self):
self.store.set("kdbx:ERPLibre/Mail/perso", "hunter2")
self.store.delete("kdbx:ERPLibre/Mail/perso")
self.assertIsNone(self.store.get("kdbx:ERPLibre/Mail/perso"))
def test_binary_key_survives_base64(self):
"""La clé de cache est stockée en base64 : 32 octets bruts doivent revenir intacts."""
import base64
raw = bytes(range(32))
self.store.set(
"kdbx:ERPLibre/Mail/perso/cache-key",
base64.b64encode(raw).decode(),
)
got = self.store.get("kdbx:ERPLibre/Mail/perso/cache-key")
self.assertEqual(base64.b64decode(got), raw)
class TestKeyringBranch(unittest.TestCase):
def setUp(self):
self.store = SecretStore(kdbx_manager=None, use_keyring=True)
def test_set_and_get_through_keyring(self):
vault = {}
with patch(
"script.todo.mail.secrets.keyring_is_safe", return_value=True
), patch(
"keyring.set_password",
side_effect=lambda s, u, p: vault.__setitem__((s, u), p),
), patch(
"keyring.get_password", side_effect=lambda s, u: vault.get((s, u))
):
self.store.set("keyring:perso", "hunter2")
self.assertEqual(self.store.get("keyring:perso"), "hunter2")
def test_refuses_unsafe_backend(self):
# `keyring.get_keyring` est patché AUSSI : le message d'erreur passe par
# keyring_backend_name(), qui interrogerait sinon le vrai trousseau.
with patch(
"script.todo.mail.secrets.keyring_is_safe", return_value=False
), patch("keyring.get_keyring", return_value=MagicMock()):
with self.assertRaises(SecretError) as ctx:
self.store.set("keyring:perso", "hunter2")
# Traduit : on compare à la clé i18n elle-même, pas au mot français,
# pour que le test suive la langue active plutôt que de la figer.
from script.todo.todo_i18n import t
self.assertIn(t("mail_err_keyring_plaintext"), str(ctx.exception))
class TestRefParsing(unittest.TestCase):
def setUp(self):
self.store = SecretStore(kdbx_manager=None, use_keyring=False)
def test_unknown_scheme_raises(self):
with self.assertRaises(SecretError):
self.store.get("magique:perso")
def test_missing_scheme_raises(self):
with self.assertRaises(SecretError):
self.store.get("perso")
def test_no_backend_available_raises(self):
with self.assertRaises(SecretError):
self.store.set("keyring:perso", "x")
if __name__ == "__main__":
unittest.main()

511
test/test_mail_send.py Normal file
View file

@ -0,0 +1,511 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
import email
import tempfile
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch
from script.todo.mail.accounts import account_from_preset
from script.todo.mail.smtp_send import (
SmtpError,
build_forward,
build_message,
build_reply,
connect,
recipients,
send,
without_bcc,
)
FIXED_DATE = "Fri, 01 Aug 2026 10:41:00 +0000"
FIXED_MSGID = "<fixe@erplibre>"
def account():
return account_from_preset(
"perso", "moi@x.ca", "generic", display_name="Mathieu Benoit"
)
def original(subject="Devis", frm="Alice <alice@y.ca>", 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: <origine@y.ca>\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 <moi@x.ca>")
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 <alice@y.ca>")
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"], "<origine@y.ca>")
def test_references_starts_the_chain(self):
self.assertEqual(self.reply()["References"], "<origine@y.ca>")
def test_references_extends_the_chain(self):
orig = original()
orig["References"] = "<premier@y.ca> <second@y.ca>"
self.assertEqual(
self.reply(orig)["References"],
"<premier@y.ca> <second@y.ca> <origine@y.ca>",
)
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 <alice@y.ca>\r\nTo: moi@x.ca\r\n"
"Subject: Devis\r\nMessage-ID: <origine@y.ca>\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 <alice@y.ca>\r\nTo: moi@x.ca\r\n"
"Subject: Devis\r\nMessage-ID: <origine@y.ca>\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 <a@y.ca>",
"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()

636
test/test_mail_store.py Normal file
View file

@ -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()

418
test/test_mail_sync.py Normal file
View file

@ -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()

431
test/test_mail_tui.py Normal file
View file

@ -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()

View file

@ -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()

627
test/test_mail_tui_help.py Normal file
View file

@ -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_<nom>` 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()

View file

@ -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()

514
test/test_mail_tui_log.py Normal file
View file

@ -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()

View file

@ -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="<sent@x.ca>",
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()

1048
test/test_mail_tui_resize.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -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()

298
test/test_mail_tui_text.py Normal file
View file

@ -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 <alice@y.ca>", 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("<p>Bonjour</p>"), "Bonjour")
def test_decodes_entities(self):
self.assertEqual(
html_to_text("<p>caf&eacute; &amp; th&eacute;</p>"), "café & thé"
)
def test_drops_script_and_style(self):
out = html_to_text(
"<style>p{color:red}</style><script>alert(1)</script><p>Salut</p>"
)
self.assertEqual(out, "Salut")
def test_br_becomes_newline(self):
self.assertEqual(html_to_text("a<br>b"), "a\nb")
def test_block_tags_separate_lines(self):
self.assertIn("\n", html_to_text("<div>a</div><div>b</div>"))
def test_collapses_blank_runs(self):
self.assertNotIn("\n\n\n", html_to_text("<p>a</p>\n\n\n\n\n<p>b</p>"))
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<p>version html</p>\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<p>Bonjour <b>Alice</b></p>"
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 <a@y.ca>"), "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" <a@y.ca>'), "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 <a@y.ca>"),
meta(
2,
subject="CR réunion",
frm="Bob <b@y.ca>",
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()

View file

@ -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()