[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:
parent
60fb60e156
commit
16aecc2d33
51 changed files with 19632 additions and 71 deletions
678
doc/EMAIL.base.md
Normal file
678
doc/EMAIL.base.md
Normal 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
340
doc/EMAIL.fr.md
Normal 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
318
doc/EMAIL.md
Normal 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.
|
||||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
11
script/todo/mail/__init__.py
Normal file
11
script/todo/mail/__init__.py
Normal 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`.
|
||||
"""
|
||||
62
script/todo/mail/account_setup.py
Normal file
62
script/todo/mail/account_setup.py
Normal 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)
|
||||
285
script/todo/mail/accounts.py
Normal file
285
script/todo/mail/accounts.py
Normal 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
|
||||
28
script/todo/mail/charset.py
Normal file
28
script/todo/mail/charset.py
Normal 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
119
script/todo/mail/crypto.py
Normal 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}")
|
||||
256
script/todo/mail/imap_sync.py
Normal file
256
script/todo/mail/imap_sync.py
Normal 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
|
||||
339
script/todo/mail/imap_transport.py
Normal file
339
script/todo/mail/imap_transport.py
Normal 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
550
script/todo/mail/menu.py
Normal 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
178
script/todo/mail/secrets.py
Normal 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)
|
||||
293
script/todo/mail/smtp_send.py
Normal file
293
script/todo/mail/smtp_send.py
Normal 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
624
script/todo/mail/store.py
Normal 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
2701
script/todo/mail/tui.py
Normal file
File diff suppressed because it is too large
Load diff
218
script/todo/mail/tui_text.py
Normal file
218
script/todo/mail/tui_text.py
Normal 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}")
|
||||
]
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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:"},
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
832
test/mail_sandbox.py
Normal 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
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
149
test/test_mail_account_setup.py
Normal file
149
test/test_mail_account_setup.py
Normal 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
233
test/test_mail_accounts.py
Normal 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
44
test/test_mail_charset.py
Normal 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
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
120
test/test_mail_crypto.py
Normal 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()
|
||||
436
test/test_mail_imap_transport.py
Normal file
436
test/test_mail_imap_transport.py
Normal 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")
|
||||
670
test/test_mail_live_server.py
Normal file
670
test/test_mail_live_server.py
Normal 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
821
test/test_mail_menu.py
Normal 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
229
test/test_mail_secrets.py
Normal 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
511
test/test_mail_send.py
Normal 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
636
test/test_mail_store.py
Normal 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
418
test/test_mail_sync.py
Normal 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
431
test/test_mail_tui.py
Normal 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()
|
||||
879
test/test_mail_tui_account.py
Normal file
879
test/test_mail_tui_account.py
Normal 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
627
test/test_mail_tui_help.py
Normal 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()
|
||||
405
test/test_mail_tui_layout.py
Normal file
405
test/test_mail_tui_layout.py
Normal 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
514
test/test_mail_tui_log.py
Normal 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()
|
||||
389
test/test_mail_tui_refresh.py
Normal file
389
test/test_mail_tui_refresh.py
Normal 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
1048
test/test_mail_tui_resize.py
Normal file
File diff suppressed because it is too large
Load diff
566
test/test_mail_tui_splitter.py
Normal file
566
test/test_mail_tui_splitter.py
Normal 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
298
test/test_mail_tui_text.py
Normal 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é & thé</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()
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue