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
318 lines
16 KiB
Markdown
318 lines
16 KiB
Markdown
|
|
# 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.
|