diff options
| author | Danilo M. <danix@danix.xyz> | 2026-08-03 10:56:58 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-08-03 10:56:58 +0200 |
| commit | dc06e733376b6ceb54a566a675580f183843d522 (patch) | |
| tree | aa06f47a37b4d260c05e4fa3b22e438b721ec1da | |
| parent | 9ada636d77599c7b22a5f1b1236c0409c72187fb (diff) | |
| download | qtmaildir-dc06e733376b6ceb54a566a675580f183843d522.tar.gz qtmaildir-dc06e733376b6ceb54a566a675580f183843d522.zip | |
docs: add README, changelog and release procedurev0.1.0
README covers what the project deliberately does not do (no POP, IMAP or
SMTP, no send in v1, no confirmation dialogs), the verified dependency
versions, build and test commands, the full config format with the reasons
behind its two surprises, the keybinding table, and the security posture of
the message view.
Two facts in it were checked rather than assumed: the `spam` and
`load_remote` actions are registered but have no default binding, so they
are documented as unreachable until bound; and the attachment path guard
does exist as described. The Release build was also verified to pass all 11
test binaries, which matters because Q_ASSERT compiles out there and one of
the cid: invariants leans on an assertion in debug.
CHANGELOG.md follows Keep a Changelog and records 0.1.0 along with the
current known limitations. docs/RELEASING.md records what semver means for
a mail client (the config format and action names are the public interface)
and the steps, including that the version is bumped in exactly one place.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| -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. |
