summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-02 16:34:48 +0200
committerDanilo M. <danix@danix.xyz>2026-08-02 16:34:48 +0200
commit56f6cf488f8b495aa79c07186ec3d10ce218e617 (patch)
treef791c3923b4a7ec5b27dc6ead4b07abbcad659db
downloadqtmaildir-56f6cf488f8b495aa79c07186ec3d10ce218e617.tar.gz
qtmaildir-56f6cf488f8b495aa79c07186ec3d10ce218e617.zip
docs: add qtmaildir v1 design spec
Qt6/notmuch mail client, GUI counterpart to neomutt. Read and organize only in v1; compose and send deferred to v2. Key decisions and their reasoning are recorded in the spec: C++/Qt6 over PySide6, QtWebEngine over QTextDocument for HTML mail, GMime for parsing, direct libnotmuch rather than shelling out to mailctl, and calling the existing sync script rather than reimplementing it so qtmaildir stays inside the flock guard shared with the hourly cron sync. Dependency availability verified on the development machine: Qt6 6.11.1 (WebEngine included in the monolithic package), libnotmuch 0.39, GMime 3.2.15. Notably notmuch installs no notmuch.pc, so CMake must locate it without pkg-config. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
-rw-r--r--.gitignore3
-rw-r--r--docs/superpowers/specs/2026-08-02-qtmaildir-design.md430
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.