diff options
| -rw-r--r-- | CHANGELOG.md | 70 | ||||
| -rw-r--r-- | README.md | 198 | ||||
| -rw-r--r-- | docs/RELEASING.md | 83 |
3 files changed, 351 insertions, 0 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..90853ce --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,70 @@ +# Changelog + +All notable changes to this project are documented here. + +The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +While the version is below 1.0.0, the configuration file format and the +keybinding action names may change in a minor release. 1.0.0 will mark the +point at which they are stable. + +## [Unreleased] + +Nothing yet. + +## [0.1.0] - 2026-08-03 + +First release. Reads and organizes a local notmuch-indexed Maildir; it does +no network protocol work at all, since fetching and sending are left to +external commands. + +### Added + +- Permanent notmuch query bar with saved-query buttons and per-account + scoping, over a two-pane thread list and message view. +- Threads streamed from the database in batches of 200, so a query over tens + of thousands of threads paints its first rows immediately and fills in + behind. Measured at 21 ms to the first batch against a 36,000-thread + database. +- Whole-thread rendering: every message in a thread renders into one + document, oldest first, with messages that did not match the current query + collapsed to one-line stubs. The last message always renders expanded, so + a thread is never nothing but stubs. +- HTML mail rendered through QtWebEngine, with a locked-down profile: + off-the-record, no cookies, no cache, JavaScript disabled, and a + deny-by-default request interceptor. Remote content is blocked until the + user asks for it, which defeats tracking pixels and read receipts; the + grant applies to one render and is never remembered. +- Inline `cid:` images served from an in-memory map scoped to the displayed + thread. References are namespaced per message, so two newsletters sharing + a Content-ID do not resolve to each other's images. +- Attachment handling that treats MIME filenames as untrusted: names are + reduced to a basename and refused if the resolved path escapes the chosen + directory. +- Tagging (archive, delete, spam, flag, read/unread, custom) over a + multi-thread selection, applied optimistically and reverted if the write + fails. Thread ids are resolved to message ids in a single combined query + rather than one query per thread. +- Undo for tag changes, backed by a `QUndoStack`. Entries store thread ids + and re-resolve them, so undo stays correct after the selection moves on. + There is deliberately no dry-run and no destructive-action confirmation: + undo is the better answer for a human at a GUI. +- Sync by running the user's existing script through `QProcess`, joining the + `flock` that already serializes it against cron rather than reimplementing + mbsync orchestration. +- Configurable keybindings via the `[keys]` section, validated against a + known action list so a typo warns instead of binding silently. +- `--version` and `--help`. + +### Known limitations + +- Compose and send are not implemented; they are planned for v2 and need a + companion send script that does not exist yet. +- A thread renders as a flat chronological list. Reply structure is + available from notmuch but is not drawn as an indented tree. +- MIME parsing happens on the UI thread. Opening a very long thread parses + every message in it, which could stutter; deferred until measured. +- The sync log pane shows what the sync command writes to stdout and stderr. + A script that redirects its own output to a file will leave the pane + empty. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4acd215 --- /dev/null +++ b/README.md @@ -0,0 +1,198 @@ +# qtmaildir + +A Qt6 desktop mail client for reading and organizing a local, +notmuch-indexed Maildir. A GUI counterpart to neomutt for the parts of mail +handling that are easier with a mouse and a real HTML renderer. + +## What it does not do + +This is the important half of the description. + +qtmaildir does **no network protocol work at all**. There is no POP, no +IMAP, no SMTP, and no sync implementation. Mail arrives in your Maildir by +whatever you already use (mbsync, offlineimap, fetchmail) and is indexed by +`notmuch new`. qtmaildir reads the result. + +Fetching is delegated to a command you configure. Running your existing +script means joining the `flock` that already serializes it against cron; a +built-in implementation would sit outside that lock and could run two +`mbsync` processes over one Maildir, which corrupts UID state. + +Sending is not implemented. Compose, reply, forward and send are planned for +v2 and need a companion send script that does not exist yet. + +There is also no dry-run and no "are you sure?" on destructive actions. The +answer for a human at a GUI is undo, which is implemented, and which is +strictly better than a dialog that gets dismissed reflexively. + +## Requirements + +Verified against these versions on Slackware -current: + +| Component | Version | Notes | +|---|---|---| +| Qt6 | 6.11.1 | Widgets and WebEngine. On Slackware both ship in the monolithic `qt6` package; there is no separate `qt6-webengine`. | +| libnotmuch | 0.39 (libnotmuch 5.6) | Installs no `notmuch.pc`, so CMake locates it with `find_path`/`find_library` rather than pkg-config. | +| GMime | 3.2.15 | Does ship `gmime-3.0.pc`. | +| CMake | 4.3.4 | 3.21 or newer. | +| GCC | 15.3.0 | C++17. | + +You also need a notmuch database that is already set up and working from the +command line. qtmaildir does not create or configure one. + +## Building + +```bash +cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release +cmake --build build +./build/src/qtmaildir +``` + +Running the tests: + +```bash +ctest --test-dir build --output-on-failure +``` + +A single test binary, for a tighter loop: + +```bash +./build/tests/test_keymap +``` + +## Configuration + +`~/.config/qtmaildir/qtmaildir.conf`, INI format. + +The Maildir path is deliberately **not** configurable. notmuch already +stores it as `database.path` and libnotmuch reads it; duplicating it here +would create two sources of truth and let the GUI index a different tree +than the CLI. The only escape hatch is pointing at an alternate notmuch +config, so notmuch remains the authority either way. + +Per-account subdirectories *are* configured, because notmuch does not model +accounts at all: it sees one flat tree. An account is a path prefix plus an +identity. + +```ini +[general] +; Optional. Omit to let notmuch resolve its own config, which is what keeps +; the GUI and the CLI pointed at one database. +; notmuch_config = /home/you/.notmuch-config + +[sync] +; Optional. Omit and the Sync button disables itself with a tooltip. +command = /home/you/bin/mailsync.sh + +; Section names use a dot, not a slash: QSettings treats "/" as its own +; group separator, so [account/work] would be parsed as a nested group. +[account.work] +name = Your Name +address = you@example.org +maildir = work-mail ; relative to notmuch's database.path +drafts = Drafts ; recorded for v2; unused today + +[account.personal] +name = Your Name +address = you@example.net +maildir = personal +drafts = Drafts + +[queries] +Inbox = tag:inbox +Unread = tag:unread +Flagged = tag:flagged + +[keys] +j = next_thread +k = prev_thread +Return = open_thread +a = archive +d = delete +N = toggle_unread +F = flag +/ = focus_query +h = toggle_html +u = undo +G = sync +Ctrl+Q = quit +``` + +Saved-query buttons appear in alphabetical order rather than file order: +QSettings returns keys sorted, and preserving file order would mean +hand-rolling an INI parser. + +## Keybindings + +Defaults, all rebindable through `[keys]`: + +| Key | Action | Does | +|---|---|---| +| `j` | `next_thread` | Select the next thread | +| `k` | `prev_thread` | Select the previous thread | +| `Return` | `open_thread` | Focus the thread list | +| `a` | `archive` | Remove `inbox` from every selected thread | +| `d` | `delete` | Add `deleted` | +| `N` | `toggle_unread` | Toggle `unread` | +| `F` | `flag` | Add `flagged` | +| `/` | `focus_query` | Focus and select the query bar | +| `h` | `toggle_html` | Switch the thread between HTML and plain text | +| `u` | `undo` | Undo the last tag change | +| `G` | `sync` | Run the configured sync command | +| `Ctrl+Q` | `quit` | Quit | + +Two further actions exist but have **no default binding**, so they are +unreachable until you bind them: `spam` (adds `spam`, removes `inbox`) and +`load_remote` (the keyboard equivalent of the "Load remote content" +button). + +An unknown action name in `[keys]` produces a warning at startup rather than +binding silently, so a typo is visible. + +Tag actions apply to **every selected thread**, not only the focused one. + +## Security posture of the message view + +A mail client rendering HTML is a browser engine pointed at input from +strangers, and it is treated that way. + +- A dedicated off-the-record `QWebEngineProfile`: no cookies, no cache, + nothing persisted. +- JavaScript disabled. `LocalContentCanAccessRemoteUrls` and + `LocalContentCanAccessFileUrls` both false. Plugins and fullscreen off. +- A request interceptor that **blocks everything by default**. Remote + images, stylesheets and fonts are stopped before a connection opens, + which defeats tracking pixels and read receipts. +- When something was blocked the header shows "Remote content blocked" with + a **Load remote content** button. The grant applies to that one render and + is never remembered. Switching threads discards both the grant and + anything already fetched under it. +- Link clicks are intercepted and handed to the system browser, so a message + can never replace the pane with a page of its own choosing. +- A whole thread renders as one document rather than one web view per + message, which would spawn a Chromium render process per message. + Sharing one document means `cid:` references could collide between + messages, so every reference is namespaced per message; two newsletters + using `cid:logo@example.org` resolve to their own images. +- Attachment filenames are treated as untrusted input. A name from a MIME + header is reduced to its basename and the result is resolved against the + chosen directory; anything escaping that directory is refused. + +## Documentation + +- `CHANGELOG.md` - what changed in each release. +- `docs/RELEASING.md` - versioning policy and the release steps. +- `docs/manual-verification.md` - results of the manual checklist run + against a real mailbox, including the defects it caught. + +## License + +GPL-2.0-only. See `LICENSE` for the full text. + +## Development Approach + +This project is developed using AI-assisted tools. Code is generated with the help of AI based on human-provided specifications, design decisions, and iterative feedback. + +All contributions are reviewed, tested, and curated by the maintainer before being included in the codebase. AI is used as a productivity and exploration tool, while human oversight remains central to all decisions. + +The goal is to combine the flexibility of AI-assisted development with standard open-source practices such as transparency, review, and accountability. diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 0000000..ca1c6d2 --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,83 @@ +# Releasing + +Short enough to follow without thinking, which is the point: releases here +are infrequent enough to forget the order. + +## Versioning + +[Semantic Versioning](https://semver.org/spec/v2.0.0.html). The public +interface of a desktop mail client is not an API; for this project it means: + +- the configuration file format (section names, key names, their meaning) +- the keybinding action names bindable from `[keys]` +- the command-line interface +- where configuration is read from + +Below 1.0.0 those may change in a minor release. 1.0.0 is the point at which +they stop changing under users, which is a decision to make deliberately +rather than a milestone that arrives on its own. + +- **MAJOR**: a config file that worked before now does not, or an action + name is removed or changes meaning. +- **MINOR**: a feature, a new action name, a new config key that older + configs simply do not set. +- **PATCH**: a fix that leaves all of the above alone. + +## Steps + +1. **Confirm the tree is clean and the tests pass.** + + ```bash + git status --short + cmake --build build && ctest --test-dir build --output-on-failure + ``` + +2. **Bump the version.** It is declared in exactly one place, the + `project()` call in the top-level `CMakeLists.txt`; `src/version.h.in` + generates `version.h` from it. Do not write the number anywhere else. + + ```cmake + project(qtmaildir VERSION 0.2.0 LANGUAGES CXX) + ``` + +3. **Move `Unreleased` in `CHANGELOG.md`** to a new dated section, and open + an empty `Unreleased` above it. + + ```markdown + ## [Unreleased] + + Nothing yet. + + ## [0.2.0] - 2026-09-01 + ``` + +4. **Rebuild and check the version actually changed.** The generated header + is a build artifact, so a stale build directory will happily report the + old number. + + ```bash + cmake -S . -B build && cmake --build build + ./build/src/qtmaildir --version + ``` + +5. **Commit and tag.** Tags are annotated and signed, like every commit in + this repository. + + ```bash + git add CMakeLists.txt CHANGELOG.md + git commit -S -m "release: 0.2.0" + git tag -s v0.2.0 -m "qtmaildir 0.2.0" + ``` + +6. **Verify the tag is signed**, then push if there is a remote. + + ```bash + git tag -v v0.2.0 + git push && git push --tags + ``` + +## After a release that changes the config format + +Note it in the changelog under `Changed` with the old and new spelling side +by side. A user whose config silently stops working will not go looking for +a version number to blame. |
