diff options
| -rw-r--r-- | .gitignore | 3 | ||||
| -rw-r--r-- | docs/superpowers/specs/2026-08-02-qtmaildir-design.md | 430 |
2 files changed, 433 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6945ca5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.superpowers/ +build/ +HANDOFF.md diff --git a/docs/superpowers/specs/2026-08-02-qtmaildir-design.md b/docs/superpowers/specs/2026-08-02-qtmaildir-design.md new file mode 100644 index 0000000..fd577c0 --- /dev/null +++ b/docs/superpowers/specs/2026-08-02-qtmaildir-design.md @@ -0,0 +1,430 @@ +# qtmaildir — Design (v1) + +Date: 2026-08-02 +Status: approved, not yet implemented + +## 1. Purpose + +A Qt6 desktop mail client backed by notmuch: a GUI counterpart to neomutt. + +It does no network protocol work. There is no POP, IMAP, or SMTP code path. +Mail arrives in the local Maildir by an external sync script, and qtmaildir +reads and organizes what is already there. + +v1 is read and organize only. Compose, reply, and send are v2. + +The motivating feature is proper HTML mail rendering, which terminal clients +cannot provide. + +## 2. Scope + +### In scope for v1 + +1. Query bar with saved queries; thread list; message pane. +2. Message reading: headers, HTML and plain bodies, inline images, + attachment listing and save-to-disk. +3. Tag operations: archive, flag, read/unread, delete, spam, arbitrary + add/remove, with undo. +4. Manual sync by shelling out to a configured sync command. +5. Configurable keybindings, neomutt-flavoured defaults. +6. Multi-account awareness for query scoping and identity. + +### Out of scope for v1 + +Compose, reply, forward, send. Threading tree view. Address book. GPG/PGP. +Search-as-you-type. Automatic or scheduled sync. Filesystem-level message +deletion (expunge). + +`+deleted` is a tag. No code path in v1 unlinks a mail file. + +## 3. Decisions + +Recorded with reasoning, since each closed off alternatives. + +**Relationship to mailctl.** qtmaildir links libnotmuch directly for search, +read, and tag operations, and shells out only for sync. It does not call +`mailctl.py`. mailctl's safety gates (dry-run, `--confirm-destructive`, +forced account scoping) exist to restrain an agent that cannot see what it is +about to do; a human looking at a selected row does not need them. A +subprocess per query would also make the UI feel slow. + +qtmaildir does not depend on the mailctl repository. The concepts it borrows +(the account map, the sync script contract) are reimplemented as config. + +**Language and toolkit.** C++ with Qt6 Widgets. Qt6 is C++-native, so there +is no binding layer; libnotmuch and GMime are C libraries that link directly. +Rejected: Python + PySide6, which would be roughly half the lines but adds a +binding layer and per-keystroke overhead on a virtual list over 100k +messages. + +**Layout.** Permanent query bar across the top with saved-query buttons, +above a two-pane thread list and message view. Notmuch is query-first, so the +query belongs in the primary position rather than hidden in a search box. +Rejected: classic three-pane folder layout (wrong model for notmuch); +full-window index-to-pager like neomutt (discards the preview pane, the main +thing a GUI buys). + +**HTML engine.** QtWebEngine (Chromium), locked down. It is the only +maintained embeddable engine that renders real-world mail correctly. +Verified already installed as part of the monolithic `qt6` package on this +system, so it adds a link line rather than a new dependency. + +Rejected: `QTextDocument`, which is dependency-free but supports only an +HTML4 subset and renders modern newsletters badly, defeating the project's +main purpose. Rejected: QtWebKit forks, which lag on security patches and are +unsuitable for hostile input. Gecko/Firefox is not an option at all — Mozilla +removed the embedding API, and Thunderbird gets Gecko only by being part of +the Mozilla build. + +**MIME parsing.** GMime 3.0. Hand-rolling MIME means reimplementing RFC 2047 +encoded words, RFC 2231 parameter continuations, transfer encodings, charset +conversion, and tolerance for malformed real-world mail. Rejected: kmime and +mimetreeparser, which are installed but pull in the KDE PIM stack and tie +releases to KF6. + +**Config format.** INI via `QSettings`. Built into Qt, hand-editable, +plain-text and git-friendly. `QKeySequence::fromString` parses key names for +free, including chords. Rejected: TOML (no Qt parser, would need a +dependency); JSON (no comments, unpleasant to hand-edit). + +**Threading.** A dedicated worker thread owns the notmuch database handle. +libnotmuch is not thread-safe and queries over a large database block. All +notmuch access happens on that thread; the UI never blocks. + +**Sync.** qtmaildir runs a configured external command rather than +reimplementing `mbsync` orchestration. The decisive reason is the `flock` +guard in the existing script: it is the shared mutex between the user's +hourly cron sync and any manual sync. Reimplementing the sync internally +would place qtmaildir outside that mutex, and two concurrent `mbsync -a` runs +on one Maildir corrupt UID state. Calling the script joins the mutex for +free. Reimplementing would also not remove the dependency, since `mbsync` +remains the actual requirement. + +## 4. Dependencies + +All verified present on the development machine (Slackware 15.0+/-current) +on 2026-08-02: + +| Dependency | Version | Notes | +|---|---|---| +| Qt6 Widgets, WebEngineWidgets | 6.11.1 | Ships inside the monolithic `qt6` package. There is no separate `qt6-webengine` package. | +| libnotmuch | 0.39 (`libnotmuch.so.5.6.0`) | Header at `/usr/include/notmuch.h`. | +| GMime | 3.2.15 | `gmime-3.0.pc` present. | + +**Build note.** notmuch installs no `notmuch.pc` (verified absent: not on +disk, `pkg-config --exists notmuch` fails, absent from the package file +list). This is upstream behaviour, not a packaging fault. CMake must locate +it manually: + +```cmake +find_path(NOTMUCH_INCLUDE_DIR notmuch.h) +find_library(NOTMUCH_LIBRARY NAMES notmuch) +if(NOT NOTMUCH_INCLUDE_DIR OR NOT NOTMUCH_LIBRARY) + message(FATAL_ERROR "libnotmuch not found (need notmuch.h and libnotmuch)") +endif() + +pkg_check_modules(GMIME REQUIRED gmime-3.0) +``` + +The build asserts `LIBNOTMUCH_MAJOR_VERSION` from `notmuch.h` so an API +change fails at compile time rather than crashing at runtime. + +## 5. Architecture + +One process, two threads. + +``` +UI thread Worker thread +───────── ───────────── +MainWindow NotmuchWorker + ├ QueryBar └ owns notmuch_database_t* + ├ SavedQueryBar + ├ ThreadListView ── ThreadListModel + └ MessageView + ├ HeaderWidget + ├ MessagePage (QWebEngineView) + └ AttachmentBar + +Config (INI) MailSync (QProcess) +KeyMap MimeParser (GMime) +``` + +No `notmuch_*` pointer crosses the thread boundary. Data crosses as plain +value structs: + +```cpp +struct ThreadSummary { + QString threadId, subject, authors; + QDateTime date; + int totalCount, matchedCount; + QStringList tags; +}; + +struct MessageRef { + QString messageId, filePath; + QStringList tags; +}; +``` + +Communication is queued signals in both directions. + +### Files + +Each has one purpose. + +| File | Responsibility | +|---|---| +| `notmuchworker.{h,cpp}` | The only file that includes `notmuch.h`. RAII wrappers (`NmQuery`, `NmThreads`, `NmMessage`) so C handles cannot leak. | +| `threadlistmodel.{h,cpp}` | `QAbstractTableModel`, batch append. | +| `messageview.{h,cpp}` | Assembles a message into HTML, drives the web view. | +| `mimeparser.{h,cpp}` | Message file to parts tree, bodies, attachments. | +| `config.{h,cpp}` | INI read/write, accounts, saved queries. | +| `keymap.{h,cpp}` | Key sequence to action dispatch. | +| `mailsync.{h,cpp}` | `QProcess` wrapper around the sync command. | +| `mainwindow.{h,cpp}` | Wiring only, no logic. | + +## 6. Configuration + +Location: `~/.config/qtmaildir/qtmaildir.conf`. + +The Maildir path is **not** configured. notmuch already stores it as +`database.path` in its own config, and libnotmuch reads it. Duplicating it +would create two sources of truth and allow the GUI to index a different tree +than the CLI. The only escape hatch is an alternate notmuch config file, 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 use notmuch's own database.path +; notmuch_config = /home/danix/.notmuch-config + +[sync] +command = /home/danix/bin/mailsync.sh + +[account/privateemail-danix] +name = Danilo M. +address = danix@danix.xyz +maildir = privateemail-danix ; relative to notmuch's database.path +drafts = Drafts + +[account/gmail] +name = Danilo M. +address = <the gmail address> +maildir = gmail +drafts = [Gmail]/Bozze + +[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 +``` + +`drafts` is unused in v1 (send is v2) but recorded now, since the account +layer exists in v1 and the field costs nothing. + +If `sync.command` is unset or missing on disk, the Sync button disables +itself with an explanatory tooltip rather than failing at click time. + +## 7. Data flow + +### Query to list + +Submitting a query bumps a generation counter, clears the model, and emits +`queryRequested(text, generation)`. The worker creates the notmuch query, +sorts newest-first by default, and iterates threads, emitting +`threadsReady(batch, generation)` every 200 threads. The UI discards batches +whose generation is stale. A 10k-thread query paints its first screenful in +milliseconds and fills in behind the user. + +Account scoping rewrites the query as `path:"<maildir>/**" and (<query>)`. +The query bar shows what the user typed; an account chip shows the scope. + +No search-as-you-type. Enter runs the query. A notmuch query per keystroke +thrashes the database. + +### List to message + +Selecting a row emits `threadRequested(threadId)`. The worker returns +`QVector<MessageRef>`. The UI then parses those files on the UI thread: +parsing one message is fast, and it keeps MIME work out of the database +thread. (Large-message parsing is a known future optimization, to be marked +in code rather than pre-built.) + +v1 renders a thread as a flat chronological list, oldest first, matched +messages expanded and unmatched ones collapsed to a one-line stub. Notmuch +provides reply structure, but an indented tree is significant work that does +not change what is readable. + +### Message to HTML + +`MimeParser` walks the MIME tree and produces a chosen body part, inline CID +parts, and attachments. Selection prefers `text/html` from a +`multipart/alternative` when HTML display is on, otherwise `text/plain`. A +per-message toggle switches to the plain version. + +Plain text is escaped and wrapped in minimal HTML (`white-space: pre-wrap`) +and rendered through the same web view, so there is one render path rather +than two. Quoted lines are muted, and quote levels past the first collapse +behind a toggle. + +## 8. Web view security + +The most security-sensitive component in the program: a browser engine +pointed at input from strangers. + +- A dedicated off-the-record `QWebEngineProfile`. No cookies, no cache, no + local storage, nothing persistent. +- JavaScript disabled. `LocalContentCanAccessRemoteUrls` and + `LocalContentCanAccessFileUrls` both false. +- A `QWebEngineUrlRequestInterceptor` that **blocks every request by + default**. Only the initial document load and `cid:` references resolved + from the current message's own parts are permitted. Remote images, CSS, + and fonts are blocked before a connection opens, which defeats tracking + pixels and read receipts. +- When anything was blocked, the header bar shows "Remote content blocked" + with a **Load remote content** button. Clicking it re-renders that one + message with remote loads permitted. The grant is never sticky and never + remembered. +- `QWebEnginePage::acceptNavigationRequest` intercepts every link click and + hands the URL to `QDesktopServices::openUrl`, so a message can never + replace the pane with an arbitrary page. +- A `QWebEngineUrlSchemeHandler` serves `cid:` from an in-memory map scoped + to the currently displayed message only. + +**Attachment filenames are untrusted input.** A filename in a MIME header is +attacker-controlled and may contain path separators or `..`. On save, the +name is reduced to its basename, stripped of separators, and the result is +resolved against the chosen directory; a path escaping that directory is +refused. An empty or fully stripped name falls back to a generated one. + +## 9. Mutations + +Queries use a read-only database handle. Tagging requires read-write, and +notmuch's write lock is exclusive process-wide, so holding it open would +block the user's cron `notmuch new`. The worker therefore opens read-write, +applies, and closes immediately, holding the lock for milliseconds. + +All actions funnel through one `applyTags(msgIds, add, remove)` path: + +| Action | Tags | +|---|---| +| Archive | `-inbox` | +| Mark read / unread | `-unread` / `+unread` | +| Flag | `+flagged` | +| Delete | `+deleted` | +| Spam | `+spam -inbox` | +| Custom | user-entered add/remove | + +There is deliberately no dry-run and no destructive-action confirmation. Those +gates exist in mailctl to restrain an agent that cannot see its own target. +The GUI equivalent is **undo**: each applied mutation pushes its inverse onto +a `QUndoStack`, so the last tag change can be reversed. For a human, undo is +strictly better than a confirmation dialog. + +The UI updates optimistically and reverts with a status-bar error if the +worker reports failure. + +## 10. Sync + +`QProcess` runs the configured command. The button becomes a spinner; stdout +and stderr accumulate into an openable log pane. On exit code 0 the current +query re-runs so new mail appears. On non-zero, the status bar shows the last +stderr lines with a "Show log" link. + +Sync never runs automatically in v1: no timer, no sync on startup. The user's +cron already syncs hourly, and a second scheduler competing with the first is +exactly what the script's `flock` guard exists to prevent. The button means +"now". + +The UI stays usable during sync, since reads do not require the write lock. + +## 11. Keybindings + +Actions are named strings (`next_thread`, `archive`, `toggle_html`, +`focus_query`). `KeyMap` holds a `QHash<QKeySequence, QString>` built from +defaults and then overridden by the INI `[keys]` section. A single event +filter on the main window maps sequence to action name, dispatched through a +`QHash<QString, std::function<void()>>` that widgets register into. + +Chords such as `g,i` work because `QKeySequence::fromString` parses them. + +An unknown action name in the config is a startup warning, not a crash. A +binding colliding with a Qt shortcut loses, and that is logged. + +## 12. Error handling + +| Class | Treatment | +|---|---| +| Config problems (missing sync command, bad account maildir, unparseable key) | Collected at startup, shown once in a dismissible banner. The app runs degraded. | +| Notmuch failures (database locked by cron, malformed query, database missing) | Status bar message, keep running. A malformed query is normal user input: the query bar turns red and reports what notmuch said. | +| Message failures (unparseable MIME, file missing because sync moved it) | An error card in the message pane showing the raw file path, with an "open raw" action. The pane never crashes. | + +The only hard failure is libnotmuch missing or a major-version mismatch, +which aborts at startup with a clear message. + +## 13. Testing + +Qt Test, three targets, all runnable without a real mailbox. + +1. **`test_mimeparser`** — fixture `.eml` files under `tests/fixtures/`: + multipart/alternative, nested multipart, CID inline images, RFC 2047 + encoded subjects, quoted-printable, and a truncated/malformed message. + Asserts part selection, decoded text, attachment enumeration, and that a + decoded attachment written to a temporary directory matches its expected + bytes (covering the save-to-disk path, including a hostile filename that + must not escape the chosen directory). +2. **`test_interceptor`** — the security-critical test. Asserts that remote + `http`/`https` are blocked, `cid:` for the current message is allowed, + `cid:` for a foreign id is blocked, `file:` is blocked, and that enabling + the allow-remote flag permits `http` while still blocking `file:`. +3. **`test_keymap`** — defaults load, INI overrides win, unknown actions warn + without crashing, chords parse. + +## 14. Known gaps + +- `NotmuchWorker` is not unit-tested. Testing it requires a real notmuch + database; v1 tests pure logic and leaves the database layer to manual + verification. +- Large-message MIME parsing happens on the UI thread and could stutter on + pathological messages. Deferred until measured. +- Threading structure is flattened for display. +- The `QtWebEngineProcess` sandbox helper's installed location was not + verified during design. CMake handles it, but it is unconfirmed. + +## 15. v2 and beyond + +Compose, reply, forward, and send. Sending requires a companion script that +does not yet exist; per the project's separation of concerns, that script is +mailctl-side work and a separate project. The account and identity layer +built in v1 is what v2 builds on. + +## License + +To be added before first release. Default GPLv2 (v2-only) unless decided +otherwise. + +## 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. |
