erplibre/doc/EMAIL.md

319 lines
16 KiB
Markdown
Raw Normal View History

# 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.