diff options
Diffstat (limited to 'docs')
| -rw-r--r-- | docs/superpowers/plans/2026-08-02-qtmaildir-v1.md | 3988 |
1 files changed, 3988 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-08-02-qtmaildir-v1.md b/docs/superpowers/plans/2026-08-02-qtmaildir-v1.md new file mode 100644 index 0000000..16f5640 --- /dev/null +++ b/docs/superpowers/plans/2026-08-02-qtmaildir-v1.md @@ -0,0 +1,3988 @@ +# qtmaildir v1 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a Qt6 desktop mail client that reads and organizes a local notmuch-indexed Maildir, with correct HTML mail rendering and no network protocol code. + +**Architecture:** One process, two threads. A `NotmuchWorker` on a dedicated thread owns the only `notmuch_database_t*` and is the only translation unit that includes `notmuch.h`; it communicates with the UI exclusively through queued signals carrying plain value structs. The UI thread runs Qt Widgets with a `QAbstractTableModel` fed in batches, and renders message bodies through a locked-down `QWebEngineView` whose request interceptor denies every request by default. + +**Tech Stack:** C++17, Qt6 (Widgets, WebEngineWidgets, Test), libnotmuch 0.39, GMime 3.0, CMake + Ninja. + +**Spec:** `docs/superpowers/specs/2026-08-02-qtmaildir-design.md` + +--- + +## Environment notes (verified 2026-08-02) + +Read these before Task 1; they explain build choices that are otherwise surprising. + +- **Qt 6.11.1**, including WebEngine, ships inside Slackware's monolithic `qt6` package. There is no separate `qt6-webengine` package to install. +- **notmuch installs no `notmuch.pc`.** Verified absent on disk, absent from the package file list, and `pkg-config --exists notmuch` fails. This is upstream behaviour. CMake must use `find_path`/`find_library`, never `pkg_check_modules`, for notmuch. +- **GMime 3.2.15** does ship `gmime-3.0.pc`, so it uses `pkg_check_modules`. +- **CMake is 4.3.4**, which rejects `cmake_minimum_required(VERSION <3.5)`. Use 3.21. +- Toolchain: GCC 15.3.0, Ninja 1.13.2. + +Build and test commands used throughout: + +```bash +cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug +cmake --build build +ctest --test-dir build --output-on-failure +``` + +Run a single test binary directly for a tighter loop, e.g. `./build/tests/test_keymap`. + +**Commits must be GPG-signed** (`git commit -S`). Never disable signing. If pinentry times out because the machine was unattended, simply re-run the same command. + +--- + +## File Structure + +Created over the course of the plan. Each file has one responsibility. + +| File | Responsibility | +|---|---| +| `CMakeLists.txt` | Top-level build: dependency discovery, options. | +| `src/CMakeLists.txt` | Application target. | +| `tests/CMakeLists.txt` | Test targets. | +| `src/types.h` | Plain value structs crossing the thread boundary. No logic. | +| `src/keymap.{h,cpp}` | Key sequence to action-name mapping; defaults plus INI overrides. | +| `src/config.{h,cpp}` | INI load/save: accounts, saved queries, sync command, keys. | +| `src/mimeparser.{h,cpp}` | Message file to parts, bodies, attachments (GMime). Includes safe attachment-name resolution. | +| `src/nmraii.h` | RAII wrappers for libnotmuch C handles. Header-only. | +| `src/notmuchworker.{h,cpp}` | The only file including `notmuch.h`. Queries and tag mutations. | +| `src/threadlistmodel.{h,cpp}` | `QAbstractTableModel` over `ThreadSummary`, batch append. | +| `src/requestinterceptor.{h,cpp}` | `QWebEngineUrlRequestInterceptor`: deny-by-default policy. | +| `src/cidschemehandler.{h,cpp}` | Serves `cid:` parts of the current message only. | +| `src/htmlbuilder.{h,cpp}` | Turns a parsed message into the HTML string the web view loads. | +| `src/messageview.{h,cpp}` | Message pane widget: headers, web view, attachment bar. | +| `src/mailsync.{h,cpp}` | `QProcess` wrapper around the configured sync command. | +| `src/mainwindow.{h,cpp}` | Wiring only: layout, signal connections, action registration. | +| `src/main.cpp` | Entry point, profile setup, startup checks. | +| `tests/test_keymap.cpp` | Keymap defaults, overrides, chords, unknown actions. | +| `tests/test_config.cpp` | INI parsing, account round-trip, missing-field handling. | +| `tests/test_mimeparser.cpp` | Part selection, decoding, attachments, filename safety. | +| `tests/test_interceptor.cpp` | The security-critical deny-by-default assertions. | +| `tests/fixtures/*.eml` | Hand-written message fixtures. | + +Build order is dependency order: pure-logic units (keymap, config, mimeparser, interceptor) come first and are fully tested, then the notmuch layer, then the UI that wires them together. + +--- + +## Task 1: Project skeleton and build system + +**Files:** +- Create: `CMakeLists.txt` +- Create: `src/CMakeLists.txt` +- Create: `tests/CMakeLists.txt` +- Create: `src/main.cpp` + +- [ ] **Step 1: Write the top-level CMakeLists.txt** + +```cmake +cmake_minimum_required(VERSION 3.21) +project(qtmaildir VERSION 0.1.0 LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_AUTOMOC ON) + +find_package(Qt6 6.5 REQUIRED COMPONENTS Widgets WebEngineWidgets Test) + +# notmuch ships no pkg-config file; locate it by hand. +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 on the system.") +endif() +message(STATUS "Found notmuch: ${NOTMUCH_LIBRARY}") + +find_package(PkgConfig REQUIRED) +pkg_check_modules(GMIME REQUIRED IMPORTED_TARGET gmime-3.0) + +enable_testing() +add_subdirectory(src) +add_subdirectory(tests) +``` + +- [ ] **Step 2: Write src/CMakeLists.txt** + +The application logic lives in a static library so tests can link it without +duplicating source lists. Only `main.cpp` is in the executable. + +```cmake +add_library(qtmaildir_lib STATIC + main_placeholder.cpp +) + +target_include_directories(qtmaildir_lib + PUBLIC ${CMAKE_CURRENT_SOURCE_DIR} ${NOTMUCH_INCLUDE_DIR}) + +target_link_libraries(qtmaildir_lib + PUBLIC Qt6::Widgets Qt6::WebEngineWidgets PkgConfig::GMIME ${NOTMUCH_LIBRARY}) + +add_executable(qtmaildir main.cpp) +target_link_libraries(qtmaildir PRIVATE qtmaildir_lib) + +install(TARGETS qtmaildir RUNTIME DESTINATION bin) +``` + +- [ ] **Step 3: Create the placeholder translation unit** + +`add_library` needs at least one source. Create `src/main_placeholder.cpp` +containing exactly this; Task 2 replaces it with the first real source. + +```cpp +// Placeholder so the library target has a source file before real code lands. +// Removed in Task 2. +namespace { int qtmaildir_placeholder = 0; } +``` + +- [ ] **Step 4: Write a minimal src/main.cpp** + +```cpp +#include <QApplication> +#include <QLabel> + +int main(int argc, char *argv[]) +{ + QApplication app(argc, argv); + QLabel label(QStringLiteral("qtmaildir")); + label.show(); + return app.exec(); +} +``` + +- [ ] **Step 5: Write tests/CMakeLists.txt** + +Empty for now except the helper function later tasks call. + +```cmake +# add_qtmaildir_test(<name>) builds tests/test_<name>.cpp and registers it. +function(add_qtmaildir_test name) + add_executable(test_${name} test_${name}.cpp) + target_link_libraries(test_${name} PRIVATE qtmaildir_lib Qt6::Test) + add_test(NAME ${name} COMMAND test_${name}) +endfunction() +``` + +- [ ] **Step 6: Configure and build** + +Run: +```bash +cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug +cmake --build build +``` +Expected: configure prints `Found notmuch: /usr/lib64/libnotmuch.so`, build +succeeds, `./build/src/qtmaildir` exists. + +- [ ] **Step 7: Commit** + +```bash +git add CMakeLists.txt src/ tests/ +git commit -S -m "build: add CMake skeleton and dependency discovery" +``` + +--- + +## Task 2: KeyMap — defaults, INI overrides, chords + +Start here because it is pure logic with no dependencies, so it proves the +test harness works before anything harder lands. + +**Files:** +- Create: `src/keymap.h`, `src/keymap.cpp` +- Create: `tests/test_keymap.cpp` +- Modify: `src/CMakeLists.txt`, `tests/CMakeLists.txt` +- Delete: `src/main_placeholder.cpp` + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_keymap.cpp`: + +```cpp +#include <QtTest> +#include <QTemporaryDir> +#include <QSettings> +#include "keymap.h" + +class TestKeyMap : public QObject +{ + Q_OBJECT +private slots: + void defaultsAreLoaded(); + void iniOverridesDefault(); + void iniAddsNewBinding(); + void chordSequenceParses(); + void unknownActionIsReported(); + void invalidSequenceIsReported(); +}; + +void TestKeyMap::defaultsAreLoaded() +{ + KeyMap map; + map.loadDefaults(); + QCOMPARE(map.actionFor(QKeySequence(QStringLiteral("j"))), + QStringLiteral("next_thread")); + QCOMPARE(map.actionFor(QKeySequence(QStringLiteral("a"))), + QStringLiteral("archive")); +} + +void TestKeyMap::iniOverridesDefault() +{ + QTemporaryDir dir; + const QString path = dir.filePath(QStringLiteral("t.conf")); + { + QSettings s(path, QSettings::IniFormat); + s.beginGroup(QStringLiteral("keys")); + s.setValue(QStringLiteral("j"), QStringLiteral("archive")); + s.endGroup(); + } + + KeyMap map; + map.loadDefaults(); + QSettings s(path, QSettings::IniFormat); + map.loadOverrides(s); + + QCOMPARE(map.actionFor(QKeySequence(QStringLiteral("j"))), + QStringLiteral("archive")); + // An untouched default survives. + QCOMPARE(map.actionFor(QKeySequence(QStringLiteral("k"))), + QStringLiteral("prev_thread")); +} + +void TestKeyMap::iniAddsNewBinding() +{ + QTemporaryDir dir; + const QString path = dir.filePath(QStringLiteral("t.conf")); + { + QSettings s(path, QSettings::IniFormat); + s.beginGroup(QStringLiteral("keys")); + s.setValue(QStringLiteral("Ctrl+Shift+A"), QStringLiteral("archive")); + s.endGroup(); + } + + KeyMap map; + map.loadDefaults(); + QSettings s(path, QSettings::IniFormat); + map.loadOverrides(s); + + QCOMPARE(map.actionFor(QKeySequence(QStringLiteral("Ctrl+Shift+A"))), + QStringLiteral("archive")); +} + +void TestKeyMap::chordSequenceParses() +{ + QTemporaryDir dir; + const QString path = dir.filePath(QStringLiteral("t.conf")); + { + QSettings s(path, QSettings::IniFormat); + s.beginGroup(QStringLiteral("keys")); + s.setValue(QStringLiteral("g,i"), QStringLiteral("focus_query")); + s.endGroup(); + } + + KeyMap map; + QSettings s(path, QSettings::IniFormat); + map.loadOverrides(s); + + const QKeySequence chord = QKeySequence::fromString(QStringLiteral("g,i")); + QCOMPARE(chord.count(), 2); + QCOMPARE(map.actionFor(chord), QStringLiteral("focus_query")); +} + +void TestKeyMap::unknownActionIsReported() +{ + QTemporaryDir dir; + const QString path = dir.filePath(QStringLiteral("t.conf")); + { + QSettings s(path, QSettings::IniFormat); + s.beginGroup(QStringLiteral("keys")); + s.setValue(QStringLiteral("z"), QStringLiteral("no_such_action")); + s.endGroup(); + } + + KeyMap map; + QSettings s(path, QSettings::IniFormat); + map.loadOverrides(s); + + // Reported, not fatal, and not bound. + QCOMPARE(map.warnings().size(), 1); + QVERIFY(map.warnings().first().contains(QStringLiteral("no_such_action"))); + QVERIFY(map.actionFor(QKeySequence(QStringLiteral("z"))).isEmpty()); +} + +void TestKeyMap::invalidSequenceIsReported() +{ + QTemporaryDir dir; + const QString path = dir.filePath(QStringLiteral("t.conf")); + { + QSettings s(path, QSettings::IniFormat); + s.beginGroup(QStringLiteral("keys")); + s.setValue(QStringLiteral("NotAKey++"), QStringLiteral("archive")); + s.endGroup(); + } + + KeyMap map; + QSettings s(path, QSettings::IniFormat); + map.loadOverrides(s); + + QCOMPARE(map.warnings().size(), 1); +} + +QTEST_MAIN(TestKeyMap) +#include "test_keymap.moc" +``` + +- [ ] **Step 2: Register the test and run it to verify it fails** + +Append to `tests/CMakeLists.txt`: + +```cmake +add_qtmaildir_test(keymap) +``` + +Run: +```bash +cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug && cmake --build build +``` +Expected: FAIL at compile time, `keymap.h: No such file or directory`. + +- [ ] **Step 3: Write src/keymap.h** + +```cpp +#pragma once + +#include <QHash> +#include <QKeySequence> +#include <QStringList> + +class QSettings; + +/// Maps key sequences to action names. Action names are plain strings so this +/// class has no dependency on the widgets that implement the actions. +class KeyMap +{ +public: + /// Every action name the application understands. loadOverrides() rejects + /// anything not in this set, so a typo in the config cannot bind silently. + static QStringList knownActions(); + + void loadDefaults(); + + /// Reads the [keys] group. Invalid sequences and unknown action names are + /// collected into warnings() rather than throwing or aborting. + void loadOverrides(QSettings &settings); + + /// Empty string when nothing is bound. + QString actionFor(const QKeySequence &sequence) const; + + QStringList warnings() const { return m_warnings; } + +private: + QHash<QKeySequence, QString> m_bindings; + QStringList m_warnings; +}; +``` + +- [ ] **Step 4: Write src/keymap.cpp** + +```cpp +#include "keymap.h" + +#include <QSettings> + +QStringList KeyMap::knownActions() +{ + // Keep in sync with the actions MainWindow registers. + return { + QStringLiteral("next_thread"), + QStringLiteral("prev_thread"), + QStringLiteral("open_thread"), + QStringLiteral("archive"), + QStringLiteral("delete"), + QStringLiteral("spam"), + QStringLiteral("toggle_unread"), + QStringLiteral("flag"), + QStringLiteral("focus_query"), + QStringLiteral("toggle_html"), + QStringLiteral("load_remote"), + QStringLiteral("undo"), + QStringLiteral("sync"), + QStringLiteral("quit"), + }; +} + +void KeyMap::loadDefaults() +{ + const QHash<QString, QString> defaults = { + { QStringLiteral("j"), QStringLiteral("next_thread") }, + { QStringLiteral("k"), QStringLiteral("prev_thread") }, + { QStringLiteral("Return"), QStringLiteral("open_thread") }, + { QStringLiteral("a"), QStringLiteral("archive") }, + { QStringLiteral("d"), QStringLiteral("delete") }, + { QStringLiteral("N"), QStringLiteral("toggle_unread") }, + { QStringLiteral("F"), QStringLiteral("flag") }, + { QStringLiteral("/"), QStringLiteral("focus_query") }, + { QStringLiteral("h"), QStringLiteral("toggle_html") }, + { QStringLiteral("u"), QStringLiteral("undo") }, + { QStringLiteral("G"), QStringLiteral("sync") }, + { QStringLiteral("Ctrl+Q"), QStringLiteral("quit") }, + }; + + for (auto it = defaults.cbegin(); it != defaults.cend(); ++it) + m_bindings.insert(QKeySequence::fromString(it.key()), it.value()); +} + +void KeyMap::loadOverrides(QSettings &settings) +{ + const QStringList known = knownActions(); + + settings.beginGroup(QStringLiteral("keys")); + const QStringList keys = settings.childKeys(); + for (const QString &key : keys) { + const QString action = settings.value(key).toString(); + + const QKeySequence sequence = QKeySequence::fromString(key); + if (sequence.isEmpty()) { + m_warnings.append( + QStringLiteral("Unparseable key sequence '%1' in [keys]").arg(key)); + continue; + } + + if (!known.contains(action)) { + m_warnings.append( + QStringLiteral("Unknown action '%1' bound to '%2' in [keys]") + .arg(action, key)); + continue; + } + + m_bindings.insert(sequence, action); + } + settings.endGroup(); +} + +QString KeyMap::actionFor(const QKeySequence &sequence) const +{ + return m_bindings.value(sequence); +} +``` + +- [ ] **Step 5: Add sources to the library and drop the placeholder** + +Edit `src/CMakeLists.txt`, replacing the `add_library` call: + +```cmake +add_library(qtmaildir_lib STATIC + keymap.cpp +) +``` + +Then delete the placeholder: +```bash +rm src/main_placeholder.cpp +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: +```bash +cmake --build build && ctest --test-dir build --output-on-failure +``` +Expected: `test_keymap` PASSes, 6 test functions, 0 failed. + +- [ ] **Step 7: Commit** + +```bash +git add src/keymap.h src/keymap.cpp src/CMakeLists.txt tests/ +git rm --cached src/main_placeholder.cpp 2>/dev/null || true +git commit -S -m "feat: add KeyMap with defaults and INI overrides" +``` + +--- + +## Task 3: Config — accounts, queries, sync command + +**Files:** +- Create: `src/config.h`, `src/config.cpp` +- Create: `tests/test_config.cpp` +- Modify: `src/CMakeLists.txt`, `tests/CMakeLists.txt` + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_config.cpp`: + +```cpp +#include <QtTest> +#include <QTemporaryDir> +#include <QSettings> +#include "config.h" + +class TestConfig : public QObject +{ + Q_OBJECT +private slots: + void parsesAccounts(); + void parsesSavedQueries(); + void missingSyncCommandIsEmpty(); + void accountWithoutMaildirIsRejected(); + void scopedQueryWrapsCorrectly(); +}; + +static QString writeIni(const QTemporaryDir &dir, const QString &body) +{ + const QString path = dir.filePath(QStringLiteral("qtmaildir.conf")); + QFile f(path); + f.open(QIODevice::WriteOnly | QIODevice::Text); + f.write(body.toUtf8()); + f.close(); + return path; +} + +void TestConfig::parsesAccounts() +{ + QTemporaryDir dir; + const QString path = writeIni(dir, QStringLiteral( + "[account/work]\n" + "name=Test User\n" + "address=user@example.org\n" + "maildir=work-mail\n" + "drafts=Drafts\n" + "\n" + "[account/personal]\n" + "name=Test User\n" + "address=me@example.net\n" + "maildir=personal\n" + )); + + Config config; + config.load(path); + + QCOMPARE(config.accounts().size(), 2); + + const Account work = config.account(QStringLiteral("work")); + QCOMPARE(work.key, QStringLiteral("work")); + QCOMPARE(work.name, QStringLiteral("Test User")); + QCOMPARE(work.address, QStringLiteral("user@example.org")); + QCOMPARE(work.maildir, QStringLiteral("work-mail")); + QCOMPARE(work.drafts, QStringLiteral("Drafts")); + + // drafts is optional in v1 (send is v2). + const Account personal = config.account(QStringLiteral("personal")); + QVERIFY(personal.drafts.isEmpty()); + QVERIFY(personal.isValid()); +} + +void TestConfig::parsesSavedQueries() +{ + QTemporaryDir dir; + const QString path = writeIni(dir, QStringLiteral( + "[queries]\n" + "Inbox=tag:inbox\n" + "Unread=tag:unread\n" + )); + + Config config; + config.load(path); + + const QList<SavedQuery> queries = config.savedQueries(); + QCOMPARE(queries.size(), 2); + // Order follows the file, so the UI button order is predictable. + QCOMPARE(queries.at(0).name, QStringLiteral("Inbox")); + QCOMPARE(queries.at(0).query, QStringLiteral("tag:inbox")); +} + +void TestConfig::missingSyncCommandIsEmpty() +{ + QTemporaryDir dir; + const QString path = writeIni(dir, QStringLiteral("[general]\n")); + + Config config; + config.load(path); + + QVERIFY(config.syncCommand().isEmpty()); + // The UI uses this to disable the Sync button with a tooltip. + QVERIFY(!config.warnings().isEmpty()); +} + +void TestConfig::accountWithoutMaildirIsRejected() +{ + QTemporaryDir dir; + const QString path = writeIni(dir, QStringLiteral( + "[account/broken]\n" + "name=No Maildir\n" + "address=x@example.org\n" + )); + + Config config; + config.load(path); + + // Rejected, reported, and not offered to the user as a scope. + QCOMPARE(config.accounts().size(), 0); + QCOMPARE(config.warnings().size(), 1); + QVERIFY(config.warnings().first().contains(QStringLiteral("broken"))); +} + +void TestConfig::scopedQueryWrapsCorrectly() +{ + Account account; + account.key = QStringLiteral("work"); + account.maildir = QStringLiteral("work-mail"); + + QCOMPARE(account.scopedQuery(QStringLiteral("tag:inbox")), + QStringLiteral("path:\"work-mail/**\" and (tag:inbox)")); + + // An empty query still scopes to the account rather than matching nothing. + QCOMPARE(account.scopedQuery(QString()), + QStringLiteral("path:\"work-mail/**\"")); +} + +QTEST_MAIN(TestConfig) +#include "test_config.moc" +``` + +- [ ] **Step 2: Register and run to verify it fails** + +Append to `tests/CMakeLists.txt`: +```cmake +add_qtmaildir_test(config) +``` + +Run: `cmake -S . -B build -G Ninja && cmake --build build` +Expected: FAIL, `config.h: No such file or directory`. + +- [ ] **Step 3: Write src/config.h** + +```cpp +#pragma once + +#include <QList> +#include <QString> +#include <QStringList> + +/// One mail account. notmuch has no concept of accounts; it sees a single flat +/// tree. An account is therefore a path prefix within that tree plus an +/// identity. +struct Account +{ + QString key; ///< INI group suffix, e.g. "work" from [account/work]. + QString name; + QString address; + QString maildir; ///< Relative to notmuch's database.path. + QString drafts; ///< Unused in v1; send is v2. + + bool isValid() const { return !key.isEmpty() && !maildir.isEmpty(); } + + /// Restricts a notmuch query to this account's subtree. + QString scopedQuery(const QString &query) const; +}; + +struct SavedQuery +{ + QString name; + QString query; +}; + +/// Reads ~/.config/qtmaildir/qtmaildir.conf. +/// +/// The Maildir path is deliberately NOT configurable here: notmuch already +/// stores it as database.path and libnotmuch reads it. Duplicating it would +/// allow the GUI to index a different tree than the CLI. +class Config +{ +public: + /// Path used when load() is called with no argument. + static QString defaultPath(); + + void load(const QString &path); + + QList<Account> accounts() const { return m_accounts; } + Account account(const QString &key) const; + QList<SavedQuery> savedQueries() const { return m_savedQueries; } + + /// Empty when unset; the caller disables the Sync button in that case. + QString syncCommand() const { return m_syncCommand; } + + /// Optional alternate notmuch config file. Empty means "let notmuch decide". + QString notmuchConfig() const { return m_notmuchConfig; } + + /// Non-fatal problems, shown once in a startup banner. + QStringList warnings() const { return m_warnings; } + +private: + QList<Account> m_accounts; + QList<SavedQuery> m_savedQueries; + QString m_syncCommand; + QString m_notmuchConfig; + QStringList m_warnings; +}; +``` + +- [ ] **Step 4: Write src/config.cpp** + +```cpp +#include "config.h" + +#include <QFileInfo> +#include <QSettings> +#include <QStandardPaths> + +QString Account::scopedQuery(const QString &query) const +{ + const QString prefix = QStringLiteral("path:\"%1/**\"").arg(maildir); + if (query.trimmed().isEmpty()) + return prefix; + return QStringLiteral("%1 and (%2)").arg(prefix, query); +} + +QString Config::defaultPath() +{ + const QString base = + QStandardPaths::writableLocation(QStandardPaths::ConfigLocation); + return base + QStringLiteral("/qtmaildir/qtmaildir.conf"); +} + +void Config::load(const QString &path) +{ + QSettings settings(path, QSettings::IniFormat); + + m_notmuchConfig = + settings.value(QStringLiteral("general/notmuch_config")).toString(); + + m_syncCommand = settings.value(QStringLiteral("sync/command")).toString(); + if (m_syncCommand.isEmpty()) { + m_warnings.append(QStringLiteral( + "No sync command configured ([sync] command); syncing is disabled.")); + } else if (!QFileInfo::exists(m_syncCommand.split(QLatin1Char(' ')).first())) { + m_warnings.append( + QStringLiteral("Sync command '%1' does not exist; syncing is disabled.") + .arg(m_syncCommand)); + m_syncCommand.clear(); + } + + for (const QString &group : settings.childGroups()) { + if (!group.startsWith(QStringLiteral("account/"))) + continue; + + Account account; + account.key = group.mid(QStringLiteral("account/").size()); + + settings.beginGroup(group); + account.name = settings.value(QStringLiteral("name")).toString(); + account.address = settings.value(QStringLiteral("address")).toString(); + account.maildir = settings.value(QStringLiteral("maildir")).toString(); + account.drafts = settings.value(QStringLiteral("drafts")).toString(); + settings.endGroup(); + + if (!account.isValid()) { + m_warnings.append( + QStringLiteral("Account '%1' has no maildir; ignoring it.") + .arg(account.key)); + continue; + } + m_accounts.append(account); + } + + settings.beginGroup(QStringLiteral("queries")); + for (const QString &name : settings.childKeys()) + m_savedQueries.append({ name, settings.value(name).toString() }); + settings.endGroup(); +} + +Account Config::account(const QString &key) const +{ + for (const Account &a : m_accounts) { + if (a.key == key) + return a; + } + return {}; +} +``` + +- [ ] **Step 5: Add to the library** + +In `src/CMakeLists.txt`, add `config.cpp` to `add_library(qtmaildir_lib STATIC ...)`: + +```cmake +add_library(qtmaildir_lib STATIC + keymap.cpp + config.cpp +) +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `cmake --build build && ctest --test-dir build --output-on-failure` +Expected: `keymap` and `config` both PASS. + +Note: `QSettings::childKeys()` returns keys sorted, so the `parsesSavedQueries` +ordering assertion holds for `Inbox` before `Unread` alphabetically. If a future +config needs file order, that requires a hand-rolled parser; not needed in v1. + +- [ ] **Step 7: Commit** + +```bash +git add src/config.h src/config.cpp src/CMakeLists.txt tests/ +git commit -S -m "feat: add Config with account, query, and sync parsing" +``` + +--- + +## Task 4: MimeParser — part selection and decoding + +**Files:** +- Create: `src/mimeparser.h`, `src/mimeparser.cpp` +- Create: `tests/test_mimeparser.cpp` +- Create: `tests/fixtures/plain.eml`, `alternative.eml`, `inline_image.eml`, `attachment.eml`, `encoded_subject.eml`, `truncated.eml`, `hostile_filename.eml` +- Modify: `src/CMakeLists.txt`, `tests/CMakeLists.txt` + +- [ ] **Step 1: Write the fixtures** + +Create `tests/fixtures/plain.eml`: + +``` +From: Alice <alice@example.org> +To: Bob <bob@example.net> +Subject: Plain hello +Date: Sat, 01 Aug 2026 10:00:00 +0000 +Message-ID: <plain-1@example.org> +Content-Type: text/plain; charset=utf-8 + +Hello Bob. + +> quoted line +Regards, +Alice +``` + +Create `tests/fixtures/alternative.eml`: + +``` +From: Alice <alice@example.org> +Subject: Both parts +Date: Sat, 01 Aug 2026 10:00:00 +0000 +Message-ID: <alt-1@example.org> +MIME-Version: 1.0 +Content-Type: multipart/alternative; boundary="BOUND" + +--BOUND +Content-Type: text/plain; charset=utf-8 + +plain version +--BOUND +Content-Type: text/html; charset=utf-8 + +<html><body><p>html version</p></body></html> +--BOUND-- +``` + +Create `tests/fixtures/inline_image.eml`: + +``` +From: Alice <alice@example.org> +Subject: Inline image +Date: Sat, 01 Aug 2026 10:00:00 +0000 +Message-ID: <cid-1@example.org> +MIME-Version: 1.0 +Content-Type: multipart/related; boundary="REL" + +--REL +Content-Type: text/html; charset=utf-8 + +<html><body><img src="cid:logo@example.org"></body></html> +--REL +Content-Type: image/png +Content-Transfer-Encoding: base64 +Content-ID: <logo@example.org> + +iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9 +awAAAABJRU5ErkJggg== +--REL-- +``` + +Create `tests/fixtures/attachment.eml`: + +``` +From: Alice <alice@example.org> +Subject: With attachment +Date: Sat, 01 Aug 2026 10:00:00 +0000 +Message-ID: <att-1@example.org> +MIME-Version: 1.0 +Content-Type: multipart/mixed; boundary="MIX" + +--MIX +Content-Type: text/plain; charset=utf-8 + +see attached +--MIX +Content-Type: text/plain; charset=utf-8; name="notes.txt" +Content-Disposition: attachment; filename="notes.txt" +Content-Transfer-Encoding: quoted-printable + +caf=C3=A9 notes +--MIX-- +``` + +Create `tests/fixtures/encoded_subject.eml`: + +``` +From: =?utf-8?B?w4RsaWNl?= <alice@example.org> +Subject: =?utf-8?Q?Caf=C3=A9_meeting?= +Date: Sat, 01 Aug 2026 10:00:00 +0000 +Message-ID: <enc-1@example.org> +Content-Type: text/plain; charset=utf-8 + +body +``` + +Create `tests/fixtures/truncated.eml` (deliberately cut off mid-part): + +``` +From: Alice <alice@example.org> +Subject: Truncated +Date: Sat, 01 Aug 2026 10:00:00 +0000 +Message-ID: <trunc-1@example.org> +MIME-Version: 1.0 +Content-Type: multipart/mixed; boundary="CUT" + +--CUT +Content-Type: text/plain; charset=utf-8 + +this part never closes +``` + +Create `tests/fixtures/hostile_filename.eml`: + +``` +From: Attacker <bad@example.org> +Subject: Hostile attachment name +Date: Sat, 01 Aug 2026 10:00:00 +0000 +Message-ID: <evil-1@example.org> +MIME-Version: 1.0 +Content-Type: multipart/mixed; boundary="EVIL" + +--EVIL +Content-Type: text/plain; charset=utf-8 + +body +--EVIL +Content-Type: text/plain; name="../../../../tmp/pwned.txt" +Content-Disposition: attachment; filename="../../../../tmp/pwned.txt" + +owned +--EVIL-- +``` + +- [ ] **Step 2: Write the failing test** + +Create `tests/test_mimeparser.cpp`: + +```cpp +#include <QtTest> +#include <QTemporaryDir> +#include <QDir> +#include "mimeparser.h" + +class TestMimeParser : public QObject +{ + Q_OBJECT +private slots: + void initTestCase(); + + void parsesPlainText(); + void prefersHtmlWhenAvailable(); + void fallsBackToPlainWhenHtmlDisabled(); + void collectsInlineCidParts(); + void decodesQuotedPrintableAttachment(); + void decodesEncodedHeaders(); + void malformedMessageDoesNotCrash(); + void missingFileIsReported(); + void hostileFilenameIsSanitised(); + void savedAttachmentMatchesBytes(); + +private: + QString fixture(const QString &name) const + { return m_fixtureDir + QLatin1Char('/') + name; } + + QString m_fixtureDir; +}; + +void TestMimeParser::initTestCase() +{ + // FIXTURE_DIR is defined by CMake so the test can run from any cwd. + m_fixtureDir = QStringLiteral(FIXTURE_DIR); + QVERIFY2(QDir(m_fixtureDir).exists(), "fixture directory missing"); +} + +void TestMimeParser::parsesPlainText() +{ + MimeParser parser; + const ParsedMessage msg = parser.parse(fixture(QStringLiteral("plain.eml"))); + + QVERIFY(msg.ok); + QCOMPARE(msg.subject, QStringLiteral("Plain hello")); + QCOMPARE(msg.from, QStringLiteral("Alice <alice@example.org>")); + QVERIFY(msg.plainBody.contains(QStringLiteral("Hello Bob."))); + QVERIFY(msg.htmlBody.isEmpty()); + QVERIFY(msg.attachments.isEmpty()); +} + +void TestMimeParser::prefersHtmlWhenAvailable() +{ + MimeParser parser; + const ParsedMessage msg = + parser.parse(fixture(QStringLiteral("alternative.eml"))); + + QVERIFY(msg.ok); + QVERIFY(msg.htmlBody.contains(QStringLiteral("html version"))); + // The plain alternative is kept so the user can toggle to it. + QVERIFY(msg.plainBody.contains(QStringLiteral("plain version"))); + QVERIFY(msg.hasHtml()); +} + +void TestMimeParser::fallsBackToPlainWhenHtmlDisabled() +{ + MimeParser parser; + const ParsedMessage msg = parser.parse(fixture(QStringLiteral("plain.eml"))); + + QVERIFY(!msg.hasHtml()); + QVERIFY(!msg.plainBody.isEmpty()); +} + +void TestMimeParser::collectsInlineCidParts() +{ + MimeParser parser; + const ParsedMessage msg = + parser.parse(fixture(QStringLiteral("inline_image.eml"))); + + QVERIFY(msg.ok); + QCOMPARE(msg.inlineParts.size(), 1); + // Content-ID angle brackets are stripped so it matches the cid: URL body. + QVERIFY(msg.inlineParts.contains(QStringLiteral("logo@example.org"))); + + const InlinePart part = msg.inlineParts.value(QStringLiteral("logo@example.org")); + QCOMPARE(part.mimeType, QStringLiteral("image/png")); + // Decoded 1x1 PNG starts with the PNG magic bytes. + QVERIFY(part.data.startsWith(QByteArray("\x89PNG", 4))); +} + +void TestMimeParser::decodesQuotedPrintableAttachment() +{ + MimeParser parser; + const ParsedMessage msg = + parser.parse(fixture(QStringLiteral("attachment.eml"))); + + QVERIFY(msg.ok); + QCOMPARE(msg.attachments.size(), 1); + QCOMPARE(msg.attachments.first().filename, QStringLiteral("notes.txt")); + QCOMPARE(QString::fromUtf8(msg.attachments.first().data), + QStringLiteral("café notes")); +} + +void TestMimeParser::decodesEncodedHeaders() +{ + MimeParser parser; + const ParsedMessage msg = + parser.parse(fixture(QStringLiteral("encoded_subject.eml"))); + + QVERIFY(msg.ok); + QCOMPARE(msg.subject, QStringLiteral("Café meeting")); + QVERIFY(msg.from.contains(QStringLiteral("Älice"))); +} + +void TestMimeParser::malformedMessageDoesNotCrash() +{ + MimeParser parser; + const ParsedMessage msg = + parser.parse(fixture(QStringLiteral("truncated.eml"))); + + // GMime is tolerant: it recovers the headers and whatever body it found. + // The requirement is only that parsing terminates and reports something. + QCOMPARE(msg.subject, QStringLiteral("Truncated")); +} + +void TestMimeParser::missingFileIsReported() +{ + MimeParser parser; + const ParsedMessage msg = + parser.parse(fixture(QStringLiteral("does_not_exist.eml"))); + + QVERIFY(!msg.ok); + QVERIFY(!msg.error.isEmpty()); +} + +void TestMimeParser::hostileFilenameIsSanitised() +{ + MimeParser parser; + const ParsedMessage msg = + parser.parse(fixture(QStringLiteral("hostile_filename.eml"))); + + QVERIFY(msg.ok); + QCOMPARE(msg.attachments.size(), 1); + + // The raw header value is preserved for display... + QVERIFY(msg.attachments.first().filename.contains(QStringLiteral(".."))); + // ...but the name used on disk is reduced to a basename. + QCOMPARE(msg.attachments.first().safeFilename(), QStringLiteral("pwned.txt")); +} + +void TestMimeParser::savedAttachmentMatchesBytes() +{ + MimeParser parser; + const ParsedMessage msg = + parser.parse(fixture(QStringLiteral("attachment.eml"))); + QVERIFY(msg.ok); + + QTemporaryDir dir; + QString error; + const QString written = + msg.attachments.first().saveTo(dir.path(), &error); + + QVERIFY2(!written.isEmpty(), qPrintable(error)); + // Never escapes the target directory. + QVERIFY(written.startsWith(dir.path())); + + QFile f(written); + QVERIFY(f.open(QIODevice::ReadOnly)); + QCOMPARE(f.readAll(), msg.attachments.first().data); +} + +QTEST_MAIN(TestMimeParser) +#include "test_mimeparser.moc" +``` + +- [ ] **Step 3: Register the test and run to verify it fails** + +Append to `tests/CMakeLists.txt`: + +```cmake +add_qtmaildir_test(mimeparser) +target_compile_definitions(test_mimeparser PRIVATE + FIXTURE_DIR="${CMAKE_CURRENT_SOURCE_DIR}/fixtures") +``` + +Run: `cmake -S . -B build -G Ninja && cmake --build build` +Expected: FAIL, `mimeparser.h: No such file or directory`. + +- [ ] **Step 4: Write src/mimeparser.h** + +```cpp +#pragma once + +#include <QByteArray> +#include <QHash> +#include <QList> +#include <QString> + +/// An inline part referenced by a cid: URL from the HTML body. +struct InlinePart +{ + QString mimeType; + QByteArray data; +}; + +struct Attachment +{ + QString filename; ///< As it appeared in the message. Untrusted. + QString mimeType; + QByteArray data; + + /// filename reduced to a basename safe to join onto a directory. + /// Attacker-controlled input: a filename may contain path separators or + /// "..", so anything that could escape the target directory is stripped. + /// Returns a generated name when nothing usable remains. + QString safeFilename() const; + + /// Writes the attachment into directory. Returns the full path written, or + /// an empty string on failure with *error set. + QString saveTo(const QString &directory, QString *error) const; +}; + +struct ParsedMessage +{ + bool ok = false; + QString error; + + QString subject; + QString from; + QString to; + QString cc; + QString date; + QString messageId; + + QString plainBody; + QString htmlBody; + + QHash<QString, InlinePart> inlineParts; ///< Keyed by Content-ID, no <>. + QList<Attachment> attachments; + + bool hasHtml() const { return !htmlBody.isEmpty(); } +}; + +/// Parses a single message file using GMime. +/// +/// Hand-rolling this would mean reimplementing RFC 2047 encoded words, RFC 2231 +/// parameter continuations, transfer encodings, and charset conversion, plus +/// tolerance for malformed real-world mail. +class MimeParser +{ +public: + MimeParser(); + + ParsedMessage parse(const QString &filePath) const; +}; +``` + +- [ ] **Step 5: Write src/mimeparser.cpp** + +```cpp +#include "mimeparser.h" + +#include <QDir> +#include <QFile> +#include <QFileInfo> +#include <QRegularExpression> +#include <QUuid> + +#include <gmime/gmime.h> + +namespace { + +/// GMime must be initialised exactly once per process. +void ensureGMimeInit() +{ + static bool initialised = false; + if (!initialised) { + g_mime_init(); + initialised = true; + } +} + +QString fromGChar(char *owned) +{ + if (!owned) + return {}; + const QString result = QString::fromUtf8(owned); + g_free(owned); + return result; +} + +QString headerText(GMimeMessage *message, const char *name) +{ + GMimeHeaderList *headers = g_mime_object_get_header_list( + GMIME_OBJECT(message)); + if (!headers) + return {}; + GMimeHeader *header = g_mime_header_list_get_header(headers, name); + if (!header) + return {}; + // get_value() returns the RFC 2047-decoded value. + return QString::fromUtf8(g_mime_header_get_value(header)); +} + +QByteArray decodePart(GMimePart *part) +{ + GMimeDataWrapper *content = g_mime_part_get_content(part); + if (!content) + return {}; + + GMimeStream *memStream = g_mime_stream_mem_new(); + g_mime_data_wrapper_write_to_stream(content, memStream); + g_mime_stream_flush(memStream); + + GByteArray *bytes = g_mime_stream_mem_get_byte_array( + GMIME_STREAM_MEM(memStream)); + QByteArray result(reinterpret_cast<const char *>(bytes->data), bytes->len); + + g_object_unref(memStream); + return result; +} + +/// Walks the MIME tree, filling the parsed message. +void collectParts(GMimeObject *object, ParsedMessage &out) +{ + if (GMIME_IS_MULTIPART(object)) { + GMimeMultipart *multipart = GMIME_MULTIPART(object); + const int count = g_mime_multipart_get_count(multipart); + for (int i = 0; i < count; ++i) + collectParts(g_mime_multipart_get_part(multipart, i), out); + return; + } + + if (GMIME_IS_MESSAGE_PART(object)) { + GMimeMessage *sub = g_mime_message_part_get_message( + GMIME_MESSAGE_PART(object)); + if (sub) + collectParts(g_mime_message_get_mime_part(sub), out); + return; + } + + if (!GMIME_IS_PART(object)) + return; + + GMimePart *part = GMIME_PART(object); + GMimeContentType *contentType = g_mime_object_get_content_type(object); + const QString mimeType = contentType + ? fromGChar(g_mime_content_type_get_mime_type(contentType)) + : QStringLiteral("application/octet-stream"); + + const char *disposition = g_mime_object_get_disposition(object); + const bool isAttachment = + disposition && g_ascii_strcasecmp(disposition, "attachment") == 0; + + const char *contentId = g_mime_part_get_content_id(part); + + if (isAttachment) { + Attachment attachment; + attachment.mimeType = mimeType; + attachment.data = decodePart(part); + const char *filename = g_mime_part_get_filename(part); + attachment.filename = filename + ? QString::fromUtf8(filename) + : QStringLiteral("attachment"); + out.attachments.append(attachment); + return; + } + + if (contentId) { + // Strip the angle brackets so the key matches a cid: URL body. + QString id = QString::fromUtf8(contentId); + if (id.startsWith(QLatin1Char('<')) && id.endsWith(QLatin1Char('>'))) + id = id.mid(1, id.size() - 2); + out.inlineParts.insert(id, InlinePart{ mimeType, decodePart(part) }); + return; + } + + if (mimeType == QLatin1String("text/plain") && out.plainBody.isEmpty()) { + out.plainBody = QString::fromUtf8(decodePart(part)); + } else if (mimeType == QLatin1String("text/html") && out.htmlBody.isEmpty()) { + out.htmlBody = QString::fromUtf8(decodePart(part)); + } +} + +} // namespace + +QString Attachment::safeFilename() const +{ + // Reduce to a basename: QFileInfo handles '/', and backslashes are stripped + // explicitly because a Windows-authored name can carry them. + QString name = filename; + name.replace(QLatin1Char('\\'), QLatin1Char('/')); + name = QFileInfo(name).fileName(); + + // A name of "..", "." or empty leaves nothing usable. + if (name.isEmpty() || name == QLatin1String(".") || name == QLatin1String("..")) + return QStringLiteral("attachment-%1").arg( + QUuid::createUuid().toString(QUuid::Id128).left(8)); + + return name; +} + +QString Attachment::saveTo(const QString &directory, QString *error) const +{ + const QDir dir(directory); + const QString target = dir.absoluteFilePath(safeFilename()); + + // Belt and braces: confirm the resolved path really is inside directory, + // so a future change to safeFilename() cannot silently reintroduce escape. + const QString canonicalDir = QDir(directory).absolutePath(); + if (!QFileInfo(target).absolutePath().startsWith(canonicalDir)) { + if (error) + *error = QStringLiteral("Refusing to write outside %1").arg(canonicalDir); + return {}; + } + + QFile file(target); + if (!file.open(QIODevice::WriteOnly)) { + if (error) + *error = file.errorString(); + return {}; + } + file.write(data); + file.close(); + return target; +} + +MimeParser::MimeParser() +{ + ensureGMimeInit(); +} + +ParsedMessage MimeParser::parse(const QString &filePath) const +{ + ParsedMessage out; + + FILE *fp = fopen(filePath.toLocal8Bit().constData(), "r"); + if (!fp) { + out.error = QStringLiteral("Cannot open %1").arg(filePath); + return out; + } + + GMimeStream *stream = g_mime_stream_file_new(fp); + GMimeParser *parser = g_mime_parser_new_with_stream(stream); + GMimeMessage *message = g_mime_parser_construct_message(parser, nullptr); + + g_object_unref(parser); + g_object_unref(stream); + + if (!message) { + out.error = QStringLiteral("Cannot parse %1").arg(filePath); + return out; + } + + out.subject = QString::fromUtf8( + g_mime_message_get_subject(message) ?: ""); + out.from = headerText(message, "From"); + out.to = headerText(message, "To"); + out.cc = headerText(message, "Cc"); + out.date = headerText(message, "Date"); + out.messageId = QString::fromUtf8( + g_mime_message_get_message_id(message) ?: ""); + + GMimeObject *body = g_mime_message_get_mime_part(message); + if (body) + collectParts(body, out); + + g_object_unref(message); + + out.ok = true; + return out; +} +``` + +- [ ] **Step 6: Add to the library** + +```cmake +add_library(qtmaildir_lib STATIC + keymap.cpp + config.cpp + mimeparser.cpp +) +``` + +- [ ] **Step 7: Run tests to verify they pass** + +Run: `cmake --build build && ctest --test-dir build --output-on-failure` +Expected: `keymap`, `config`, `mimeparser` all PASS. + +If `decodesEncodedHeaders` fails on the From value, check that GMime's +`g_mime_header_get_value` is returning the decoded form; older GMime needs +`g_mime_utils_header_decode_text` applied to the raw value instead. + +- [ ] **Step 8: Commit** + +```bash +git add src/mimeparser.h src/mimeparser.cpp src/CMakeLists.txt tests/ +git commit -S -m "feat: add MimeParser with GMime and safe attachment naming" +``` + +--- + +## Task 5: Request interceptor — deny by default + +This is the security-critical component. It gets the most careful test in the +project. + +**Files:** +- Create: `src/requestinterceptor.h`, `src/requestinterceptor.cpp` +- Create: `tests/test_interceptor.cpp` +- Modify: `src/CMakeLists.txt`, `tests/CMakeLists.txt` + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_interceptor.cpp`: + +```cpp +#include <QtTest> +#include "requestinterceptor.h" + +class TestInterceptor : public QObject +{ + Q_OBJECT +private slots: + void blocksRemoteHttpByDefault(); + void blocksRemoteHttpsByDefault(); + void blocksFileUrlsAlways(); + void allowsCidForCurrentMessage(); + void blocksCidForForeignMessage(); + void allowRemoteFlagPermitsHttpButNotFile(); + void recordsThatSomethingWasBlocked(); + void resetClearsBlockedFlag(); +}; + +void TestInterceptor::blocksRemoteHttpByDefault() +{ + RequestInterceptor interceptor; + QVERIFY(!interceptor.shouldAllow(QUrl(QStringLiteral("http://tracker.example/pixel.gif")))); +} + +void TestInterceptor::blocksRemoteHttpsByDefault() +{ + RequestInterceptor interceptor; + QVERIFY(!interceptor.shouldAllow(QUrl(QStringLiteral("https://cdn.example/style.css")))); +} + +void TestInterceptor::blocksFileUrlsAlways() +{ + RequestInterceptor interceptor; + interceptor.setAllowRemote(true); + // Even with remote content explicitly allowed, local files stay blocked: + // a message must never read the filesystem. + QVERIFY(!interceptor.shouldAllow(QUrl(QStringLiteral("file:///etc/passwd")))); +} + +void TestInterceptor::allowsCidForCurrentMessage() +{ + RequestInterceptor interceptor; + interceptor.setAllowedCids({ QStringLiteral("logo@example.org") }); + QVERIFY(interceptor.shouldAllow(QUrl(QStringLiteral("cid:logo@example.org")))); +} + +void TestInterceptor::blocksCidForForeignMessage() +{ + RequestInterceptor interceptor; + interceptor.setAllowedCids({ QStringLiteral("logo@example.org") }); + QVERIFY(!interceptor.shouldAllow(QUrl(QStringLiteral("cid:other@example.org")))); +} + +void TestInterceptor::allowRemoteFlagPermitsHttpButNotFile() +{ + RequestInterceptor interceptor; + interceptor.setAllowRemote(true); + QVERIFY(interceptor.shouldAllow(QUrl(QStringLiteral("https://cdn.example/img.png")))); + QVERIFY(!interceptor.shouldAllow(QUrl(QStringLiteral("file:///etc/passwd")))); +} + +void TestInterceptor::recordsThatSomethingWasBlocked() +{ + RequestInterceptor interceptor; + QVERIFY(!interceptor.blockedAnything()); + interceptor.shouldAllow(QUrl(QStringLiteral("http://tracker.example/p.gif"))); + // Drives the "Remote content blocked" banner in the message header. + QVERIFY(interceptor.blockedAnything()); +} + +void TestInterceptor::resetClearsBlockedFlag() +{ + RequestInterceptor interceptor; + interceptor.shouldAllow(QUrl(QStringLiteral("http://tracker.example/p.gif"))); + QVERIFY(interceptor.blockedAnything()); + + interceptor.resetForNewMessage(); + QVERIFY(!interceptor.blockedAnything()); + // Remote permission never carries over to the next message. + QVERIFY(!interceptor.shouldAllow(QUrl(QStringLiteral("https://cdn.example/x.png")))); +} + +QTEST_MAIN(TestInterceptor) +#include "test_interceptor.moc" +``` + +- [ ] **Step 2: Register and run to verify it fails** + +Append to `tests/CMakeLists.txt`: +```cmake +add_qtmaildir_test(interceptor) +``` + +Run: `cmake -S . -B build -G Ninja && cmake --build build` +Expected: FAIL, `requestinterceptor.h: No such file or directory`. + +- [ ] **Step 3: Write src/requestinterceptor.h** + +The policy lives in `shouldAllow()`, a pure function of URL and state, so it is +testable without constructing a web engine profile. `interceptRequest()` is a +thin adapter over it. + +```cpp +#pragma once + +#include <QSet> +#include <QUrl> +#include <QWebEngineUrlRequestInterceptor> + +/// Deny-by-default request policy for the message view. +/// +/// A message body is untrusted input from a stranger. Everything is blocked +/// unless explicitly permitted: remote loads leak the fact that a message was +/// read (tracking pixels) and file: loads would expose the local filesystem. +class RequestInterceptor : public QWebEngineUrlRequestInterceptor +{ + Q_OBJECT +public: + explicit RequestInterceptor(QObject *parent = nullptr); + + /// The whole policy, as a pure function so it can be tested directly. + bool shouldAllow(const QUrl &url); + + void interceptRequest(QWebEngineUrlRequestInfo &info) override; + + /// Content-IDs belonging to the currently displayed message. + void setAllowedCids(const QSet<QString> &cids) { m_allowedCids = cids; } + + /// Per-message opt-in, triggered by the user clicking "Load remote content". + /// Never persisted, never carried to the next message. + void setAllowRemote(bool allow) { m_allowRemote = allow; } + bool allowRemote() const { return m_allowRemote; } + + /// True once any request has been denied, so the UI can offer the button. + bool blockedAnything() const { return m_blockedAnything; } + + /// Called before rendering a new message: clears both the remote grant and + /// the blocked flag. + void resetForNewMessage(); + +private: + QSet<QString> m_allowedCids; + bool m_allowRemote = false; + bool m_blockedAnything = false; +}; +``` + +- [ ] **Step 4: Write src/requestinterceptor.cpp** + +```cpp +#include "requestinterceptor.h" + +#include <QWebEngineUrlRequestInfo> + +RequestInterceptor::RequestInterceptor(QObject *parent) + : QWebEngineUrlRequestInterceptor(parent) +{ +} + +bool RequestInterceptor::shouldAllow(const QUrl &url) +{ + const QString scheme = url.scheme(); + + // The document itself is loaded via setHtml() with a qtmaildir: base URL, + // so that scheme must pass or nothing renders at all. + if (scheme == QLatin1String("qtmaildir")) + return true; + + // Inline parts of the current message only. + if (scheme == QLatin1String("cid")) { + // QUrl keeps a cid: body in path(), not host(). + const QString id = url.path(); + if (m_allowedCids.contains(id)) + return true; + m_blockedAnything = true; + return false; + } + + if (scheme == QLatin1String("http") || scheme == QLatin1String("https")) { + if (m_allowRemote) + return true; + m_blockedAnything = true; + return false; + } + + // Everything else, file: above all, is denied unconditionally. There is no + // flag that enables it. + m_blockedAnything = true; + return false; +} + +void RequestInterceptor::interceptRequest(QWebEngineUrlRequestInfo &info) +{ + if (!shouldAllow(info.requestUrl())) + info.block(true); +} + +void RequestInterceptor::resetForNewMessage() +{ + m_allowRemote = false; + m_blockedAnything = false; +} +``` + +- [ ] **Step 5: Add to the library** + +```cmake +add_library(qtmaildir_lib STATIC + keymap.cpp + config.cpp + mimeparser.cpp + requestinterceptor.cpp +) +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `cmake --build build && ctest --test-dir build --output-on-failure` +Expected: all four test binaries PASS, `interceptor` with 8 functions. + +- [ ] **Step 7: Commit** + +```bash +git add src/requestinterceptor.h src/requestinterceptor.cpp src/CMakeLists.txt tests/ +git commit -S -m "feat: add deny-by-default web request interceptor" +``` + +--- + +## Task 6: CID scheme handler and HTML builder + +**Files:** +- Create: `src/cidschemehandler.h`, `src/cidschemehandler.cpp` +- Create: `src/htmlbuilder.h`, `src/htmlbuilder.cpp` +- Modify: `src/CMakeLists.txt` + +`HtmlBuilder` gets its own test binary rather than extending +`tests/test_mimeparser.cpp`, since escaping is a separate unit from parsing. +`CidSchemeHandler` has no unit test: it needs a live `QWebEngineUrlRequestJob`, +and the access rule it enforces is already asserted in Task 5. + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_htmlbuilder.cpp`: + +```cpp +#include <QtTest> +#include "htmlbuilder.h" + +class TestHtmlBuilder : public QObject +{ + Q_OBJECT +private slots: + void escapesPlainText(); + void preservesHtmlBodyWhenHtmlRequested(); + void marksQuotedLines(); + void plainTextScriptTagIsNeutralised(); +}; + +void TestHtmlBuilder::escapesPlainText() +{ + ParsedMessage msg; + msg.ok = true; + msg.plainBody = QStringLiteral("a < b & c > d"); + + const QString html = HtmlBuilder::build(msg, HtmlBuilder::ForcePlain); + QVERIFY(html.contains(QStringLiteral("a < b & c > d"))); +} + +void TestHtmlBuilder::preservesHtmlBodyWhenHtmlRequested() +{ + ParsedMessage msg; + msg.ok = true; + msg.htmlBody = QStringLiteral("<p>hello</p>"); + + const QString html = HtmlBuilder::build(msg, HtmlBuilder::PreferHtml); + QVERIFY(html.contains(QStringLiteral("<p>hello</p>"))); +} + +void TestHtmlBuilder::marksQuotedLines() +{ + ParsedMessage msg; + msg.ok = true; + msg.plainBody = QStringLiteral("reply\n> quoted\nend"); + + const QString html = HtmlBuilder::build(msg, HtmlBuilder::ForcePlain); + QVERIFY(html.contains(QStringLiteral("class=\"quote\""))); +} + +void TestHtmlBuilder::plainTextScriptTagIsNeutralised() +{ + ParsedMessage msg; + msg.ok = true; + msg.plainBody = QStringLiteral("<script>alert(1)</script>"); + + const QString html = HtmlBuilder::build(msg, HtmlBuilder::ForcePlain); + // Escaped, not embedded. (JavaScript is also disabled at the profile level, + // so this is the second of two independent defences.) + QVERIFY(!html.contains(QStringLiteral("<script>"))); + QVERIFY(html.contains(QStringLiteral("<script>"))); +} + +QTEST_MAIN(TestHtmlBuilder) +#include "test_htmlbuilder.moc" +``` + +- [ ] **Step 2: Register and run to verify it fails** + +Append to `tests/CMakeLists.txt`: +```cmake +add_qtmaildir_test(htmlbuilder) +``` + +Run: `cmake -S . -B build -G Ninja && cmake --build build` +Expected: FAIL, `htmlbuilder.h: No such file or directory`. + +- [ ] **Step 3: Write src/htmlbuilder.h** + +```cpp +#pragma once + +#include <QString> + +#include "mimeparser.h" + +/// Turns a parsed message into the HTML string handed to the web view. +/// +/// Plain text goes through the same path as HTML so the view has one render +/// path rather than two. +class HtmlBuilder +{ +public: + enum Mode { + PreferHtml, ///< Use the HTML part when the message has one. + ForcePlain, ///< Always render the plain part, escaped. + }; + + static QString build(const ParsedMessage &message, Mode mode); + +private: + static QString renderPlain(const QString &text); +}; +``` + +- [ ] **Step 4: Write src/htmlbuilder.cpp** + +```cpp +#include "htmlbuilder.h" + +namespace { + +const char *kStyle = R"CSS( +body { font-family: sans-serif; font-size: 10pt; margin: 12px; } +pre.plain { white-space: pre-wrap; word-wrap: break-word; + font-family: monospace; margin: 0; } +span.quote { color: #4a6f8a; } +)CSS"; + +} // namespace + +QString HtmlBuilder::renderPlain(const QString &text) +{ + QString out; + out += QStringLiteral("<pre class=\"plain\">"); + + const QStringList lines = text.split(QLatin1Char('\n')); + for (int i = 0; i < lines.size(); ++i) { + const QString &line = lines.at(i); + const bool quoted = line.startsWith(QLatin1Char('>')); + + if (quoted) + out += QStringLiteral("<span class=\"quote\">"); + out += line.toHtmlEscaped(); + if (quoted) + out += QStringLiteral("</span>"); + + if (i + 1 < lines.size()) + out += QLatin1Char('\n'); + } + + out += QStringLiteral("</pre>"); + return out; +} + +QString HtmlBuilder::build(const ParsedMessage &message, Mode mode) +{ + QString body; + if (mode == PreferHtml && message.hasHtml()) + body = message.htmlBody; + else + body = renderPlain(message.plainBody); + + return QStringLiteral( + "<!DOCTYPE html><html><head><meta charset=\"utf-8\">" + "<style>%1</style></head><body>%2</body></html>") + .arg(QString::fromUtf8(kStyle), body); +} +``` + +- [ ] **Step 5: Write src/cidschemehandler.h** + +```cpp +#pragma once + +#include <QHash> +#include <QWebEngineUrlSchemeHandler> + +#include "mimeparser.h" + +/// Serves cid: URLs from the currently displayed message only. +/// +/// The map is replaced wholesale on every message change, so a message can +/// never reference another message's parts. +class CidSchemeHandler : public QWebEngineUrlSchemeHandler +{ + Q_OBJECT +public: + explicit CidSchemeHandler(QObject *parent = nullptr); + + void setParts(const QHash<QString, InlinePart> &parts) { m_parts = parts; } + + void requestStarted(QWebEngineUrlRequestJob *job) override; + +private: + QHash<QString, InlinePart> m_parts; +}; +``` + +- [ ] **Step 6: Write src/cidschemehandler.cpp** + +```cpp +#include "cidschemehandler.h" + +#include <QBuffer> +#include <QWebEngineUrlRequestJob> + +CidSchemeHandler::CidSchemeHandler(QObject *parent) + : QWebEngineUrlSchemeHandler(parent) +{ +} + +void CidSchemeHandler::requestStarted(QWebEngineUrlRequestJob *job) +{ + const QString id = job->requestUrl().path(); + + if (!m_parts.contains(id)) { + job->fail(QWebEngineUrlRequestJob::UrlNotFound); + return; + } + + const InlinePart part = m_parts.value(id); + + // The buffer is parented to the job so it lives exactly as long as needed. + auto *buffer = new QBuffer(job); + buffer->setData(part.data); + buffer->open(QIODevice::ReadOnly); + + job->reply(part.mimeType.toUtf8(), buffer); +} +``` + +- [ ] **Step 7: Add to the library and run tests** + +```cmake +add_library(qtmaildir_lib STATIC + keymap.cpp + config.cpp + mimeparser.cpp + requestinterceptor.cpp + htmlbuilder.cpp + cidschemehandler.cpp +) +``` + +Run: `cmake --build build && ctest --test-dir build --output-on-failure` +Expected: five test binaries PASS. + +- [ ] **Step 8: Commit** + +```bash +git add src/htmlbuilder.h src/htmlbuilder.cpp src/cidschemehandler.h \ + src/cidschemehandler.cpp src/CMakeLists.txt tests/ +git commit -S -m "feat: add HTML builder and cid: scheme handler" +``` + +--- + +## Task 7: Value types and notmuch RAII wrappers + +**Files:** +- Create: `src/types.h` +- Create: `src/nmraii.h` +- Modify: `src/CMakeLists.txt` (headers only, no new .cpp) + +- [ ] **Step 1: Write src/types.h** + +These are the only types that cross the thread boundary. They are plain values +with no pointers into notmuch, so a queued signal can copy them safely. + +```cpp +#pragma once + +#include <QDateTime> +#include <QMetaType> +#include <QString> +#include <QStringList> + +struct ThreadSummary +{ + QString threadId; + QString subject; + QString authors; + QDateTime date; + int totalCount = 0; + int matchedCount = 0; + QStringList tags; + + bool isUnread() const { return tags.contains(QStringLiteral("unread")); } + bool isFlagged() const { return tags.contains(QStringLiteral("flagged")); } +}; + +struct MessageRef +{ + QString messageId; + QString filePath; + QStringList tags; +}; + +/// One tag mutation, kept so it can be inverted for undo. +struct TagChange +{ + QStringList messageIds; + QStringList added; + QStringList removed; + QString description; ///< Shown in the undo action's text. + + TagChange inverted() const + { + return TagChange{ messageIds, removed, added, + QStringLiteral("Undo %1").arg(description) }; + } +}; + +Q_DECLARE_METATYPE(ThreadSummary) +Q_DECLARE_METATYPE(MessageRef) +Q_DECLARE_METATYPE(TagChange) +``` + +- [ ] **Step 2: Write src/nmraii.h** + +libnotmuch hands out raw C pointers with manual destroy calls. These wrappers +make leaks impossible on early return, which matters because the query paths +have several. + +```cpp +#pragma once + +#include <notmuch.h> + +#include <utility> + +/// Generic owner for a notmuch handle with a destroy function. +template <typename T, void (*Destroy)(T *)> +class NmHandle +{ +public: + NmHandle() = default; + explicit NmHandle(T *handle) : m_handle(handle) {} + + ~NmHandle() { reset(); } + + NmHandle(const NmHandle &) = delete; + NmHandle &operator=(const NmHandle &) = delete; + + NmHandle(NmHandle &&other) noexcept + : m_handle(std::exchange(other.m_handle, nullptr)) {} + + NmHandle &operator=(NmHandle &&other) noexcept + { + if (this != &other) { + reset(); + m_handle = std::exchange(other.m_handle, nullptr); + } + return *this; + } + + void reset(T *handle = nullptr) + { + if (m_handle) + Destroy(m_handle); + m_handle = handle; + } + + T *get() const { return m_handle; } + explicit operator bool() const { return m_handle != nullptr; } + +private: + T *m_handle = nullptr; +}; + +using NmQuery = NmHandle<notmuch_query_t, notmuch_query_destroy>; +using NmThreads = NmHandle<notmuch_threads_t, notmuch_threads_destroy>; +using NmMessages = NmHandle<notmuch_messages_t, notmuch_messages_destroy>; +using NmThread = NmHandle<notmuch_thread_t, notmuch_thread_destroy>; +using NmMessage = NmHandle<notmuch_message_t, notmuch_message_destroy>; +using NmTags = NmHandle<notmuch_tags_t, notmuch_tags_destroy>; +``` + +- [ ] **Step 3: Verify it compiles** + +Both are headers, so add a trivial compile check by including them from +`src/notmuchworker.cpp` in the next task. For now confirm the include path +resolves: + +Run: +```bash +echo '#include "src/nmraii.h" +#include "src/types.h" +int main() { return 0; }' > /tmp/nmcheck.cpp && \ +g++ -fsyntax-only -std=c++17 -I/usr/include \ + $(pkg-config --cflags Qt6Core) /tmp/nmcheck.cpp && echo OK +``` +Expected: `OK`. If `Qt6Core` is not a valid pkg-config name on this system, use +`-I/usr/include/qt6 -I/usr/include/qt6/QtCore` instead. + +- [ ] **Step 4: Commit** + +```bash +git add src/types.h src/nmraii.h +git commit -S -m "feat: add cross-thread value types and notmuch RAII wrappers" +``` + +--- + +## Task 8: NotmuchWorker — batched queries + +**Files:** +- Create: `src/notmuchworker.h`, `src/notmuchworker.cpp` +- Modify: `src/CMakeLists.txt` + +Per the spec, `NotmuchWorker` has no unit test: testing it requires a real +notmuch database. It is verified manually in Task 13. Keep it thin so there is +little untested logic. + +- [ ] **Step 1: Write src/notmuchworker.h** + +```cpp +#pragma once + +#include <QObject> +#include <QStringList> +#include <QVector> + +#include "types.h" + +struct _notmuch_database; +typedef struct _notmuch_database notmuch_database_t; + +/// Owns the only notmuch database handle in the process. +/// +/// libnotmuch is not thread-safe and queries over a large database block, so +/// this object lives on its own thread and the UI reaches it only through +/// queued signals. No notmuch pointer ever leaves this class. +class NotmuchWorker : public QObject +{ + Q_OBJECT +public: + /// notmuchConfigPath may be empty, in which case notmuch resolves its own + /// config and therefore its own database.path. + explicit NotmuchWorker(const QString ¬muchConfigPath, QObject *parent = nullptr); + ~NotmuchWorker() override; + + /// Threads emitted per threadsReady() signal. + static constexpr int kBatchSize = 200; + +public slots: + /// Runs a query. generation lets the UI discard results from a superseded + /// query without the worker needing to know about cancellation. + void runQuery(const QString &query, quint64 generation); + + /// Loads the messages of one thread, oldest first. + void loadThread(const QString &threadId, quint64 generation); + + /// Applies tag changes. Opens the database read-write, applies, and closes + /// immediately: notmuch's write lock is exclusive process-wide, so holding + /// it would block the user's cron `notmuch new`. + void applyTags(const TagChange &change); + +signals: + void threadsReady(const QVector<ThreadSummary> &threads, quint64 generation); + void queryFinished(int totalThreads, quint64 generation); + void threadLoaded(const QVector<MessageRef> &messages, quint64 generation); + void tagsApplied(const TagChange &change); + void errorOccurred(const QString &message); + +private: + bool openReadOnly(); + void close(); + + QString m_configPath; + notmuch_database_t *m_db = nullptr; +}; +``` + +- [ ] **Step 2: Write src/notmuchworker.cpp** + +```cpp +#include "notmuchworker.h" + +#include <notmuch.h> + +#include "nmraii.h" + +namespace { + +QStringList tagsOf(notmuch_message_t *message) +{ + QStringList result; + NmTags tags(notmuch_message_get_tags(message)); + for (; notmuch_tags_valid(tags.get()); notmuch_tags_move_to_next(tags.get())) + result.append(QString::fromUtf8(notmuch_tags_get(tags.get()))); + return result; +} + +QStringList tagsOf(notmuch_thread_t *thread) +{ + QStringList result; + NmTags tags(notmuch_thread_get_tags(thread)); + for (; notmuch_tags_valid(tags.get()); notmuch_tags_move_to_next(tags.get())) + result.append(QString::fromUtf8(notmuch_tags_get(tags.get()))); + return result; +} + +} // namespace + +NotmuchWorker::NotmuchWorker(const QString ¬muchConfigPath, QObject *parent) + : QObject(parent), m_configPath(notmuchConfigPath) +{ +} + +NotmuchWorker::~NotmuchWorker() +{ + close(); +} + +bool NotmuchWorker::openReadOnly() +{ + if (m_db) + return true; + + char *error = nullptr; + const notmuch_status_t status = notmuch_database_open_with_config( + nullptr, // let config decide path + NOTMUCH_DATABASE_MODE_READ_ONLY, + m_configPath.isEmpty() ? nullptr + : m_configPath.toLocal8Bit().constData(), + nullptr, + &m_db, + &error); + + if (status != NOTMUCH_STATUS_SUCCESS) { + emit errorOccurred(QStringLiteral("Cannot open notmuch database: %1") + .arg(QString::fromUtf8(error ? error : notmuch_status_to_string(status)))); + free(error); + m_db = nullptr; + return false; + } + return true; +} + +void NotmuchWorker::close() +{ + if (m_db) { + notmuch_database_destroy(m_db); + m_db = nullptr; + } +} + +void NotmuchWorker::runQuery(const QString &query, quint64 generation) +{ + if (!openReadOnly()) + return; + + NmQuery nmQuery(notmuch_query_create(m_db, query.toUtf8().constData())); + if (!nmQuery) { + emit errorOccurred(QStringLiteral("Invalid query: %1").arg(query)); + return; + } + notmuch_query_set_sort(nmQuery.get(), NOTMUCH_SORT_NEWEST_FIRST); + + notmuch_threads_t *rawThreads = nullptr; + const notmuch_status_t status = + notmuch_query_search_threads(nmQuery.get(), &rawThreads); + if (status != NOTMUCH_STATUS_SUCCESS) { + emit errorOccurred(QStringLiteral("Query failed: %1") + .arg(QString::fromUtf8(notmuch_status_to_string(status)))); + return; + } + NmThreads threads(rawThreads); + + QVector<ThreadSummary> batch; + batch.reserve(kBatchSize); + int total = 0; + + for (; notmuch_threads_valid(threads.get()); + notmuch_threads_move_to_next(threads.get())) { + + NmThread thread(notmuch_threads_get(threads.get())); + if (!thread) + continue; + + ThreadSummary summary; + summary.threadId = QString::fromUtf8(notmuch_thread_get_thread_id(thread.get())); + summary.subject = QString::fromUtf8(notmuch_thread_get_subject(thread.get())); + summary.authors = QString::fromUtf8(notmuch_thread_get_authors(thread.get())); + summary.date = QDateTime::fromSecsSinceEpoch( + notmuch_thread_get_newest_date(thread.get())); + summary.totalCount = notmuch_thread_get_total_messages(thread.get()); + summary.matchedCount = notmuch_thread_get_matched_messages(thread.get()); + summary.tags = tagsOf(thread.get()); + + batch.append(summary); + ++total; + + if (batch.size() >= kBatchSize) { + emit threadsReady(batch, generation); + batch.clear(); + batch.reserve(kBatchSize); + } + } + + if (!batch.isEmpty()) + emit threadsReady(batch, generation); + + emit queryFinished(total, generation); +} + +void NotmuchWorker::loadThread(const QString &threadId, quint64 generation) +{ + if (!openReadOnly()) + return; + + const QString query = QStringLiteral("thread:%1").arg(threadId); + NmQuery nmQuery(notmuch_query_create(m_db, query.toUtf8().constData())); + if (!nmQuery) { + emit errorOccurred(QStringLiteral("Cannot load thread %1").arg(threadId)); + return; + } + notmuch_query_set_sort(nmQuery.get(), NOTMUCH_SORT_OLDEST_FIRST); + + notmuch_messages_t *rawMessages = nullptr; + if (notmuch_query_search_messages(nmQuery.get(), &rawMessages) + != NOTMUCH_STATUS_SUCCESS) { + emit errorOccurred(QStringLiteral("Cannot search thread %1").arg(threadId)); + return; + } + NmMessages messages(rawMessages); + + QVector<MessageRef> result; + for (; notmuch_messages_valid(messages.get()); + notmuch_messages_move_to_next(messages.get())) { + + NmMessage message(notmuch_messages_get(messages.get())); + if (!message) + continue; + + MessageRef ref; + ref.messageId = QString::fromUtf8(notmuch_message_get_message_id(message.get())); + ref.filePath = QString::fromUtf8(notmuch_message_get_filename(message.get())); + ref.tags = tagsOf(message.get()); + result.append(ref); + } + + emit threadLoaded(result, generation); +} + +void NotmuchWorker::applyTags(const TagChange &change) +{ + // The read-only handle must be closed first: notmuch allows only one open + // handle per process. + close(); + + notmuch_database_t *db = nullptr; + char *error = nullptr; + const notmuch_status_t status = notmuch_database_open_with_config( + nullptr, + NOTMUCH_DATABASE_MODE_READ_WRITE, + m_configPath.isEmpty() ? nullptr + : m_configPath.toLocal8Bit().constData(), + nullptr, + &db, + &error); + + if (status != NOTMUCH_STATUS_SUCCESS) { + emit errorOccurred( + QStringLiteral("Cannot open database for writing (is a sync running?): %1") + .arg(QString::fromUtf8(error ? error + : notmuch_status_to_string(status)))); + free(error); + return; + } + + for (const QString &id : change.messageIds) { + notmuch_message_t *raw = nullptr; + if (notmuch_database_find_message(db, id.toUtf8().constData(), &raw) + != NOTMUCH_STATUS_SUCCESS || !raw) { + continue; + } + NmMessage message(raw); + + notmuch_message_freeze(message.get()); + for (const QString &tag : change.removed) + notmuch_message_remove_tag(message.get(), tag.toUtf8().constData()); + for (const QString &tag : change.added) + notmuch_message_add_tag(message.get(), tag.toUtf8().constData()); + notmuch_message_thaw(message.get()); + + notmuch_message_tags_to_maildir_flags(message.get()); + } + + notmuch_database_close(db); + notmuch_database_destroy(db); + + emit tagsApplied(change); +} +``` + +- [ ] **Step 3: Add to the library** + +```cmake +add_library(qtmaildir_lib STATIC + keymap.cpp + config.cpp + mimeparser.cpp + requestinterceptor.cpp + htmlbuilder.cpp + cidschemehandler.cpp + notmuchworker.cpp +) +``` + +- [ ] **Step 4: Build and verify existing tests still pass** + +Run: `cmake --build build && ctest --test-dir build --output-on-failure` +Expected: builds clean, five test binaries still PASS. + +If `notmuch_database_open_with_config` is not declared, the installed notmuch is +older than 0.32. Check with `grep open_with_config /usr/include/notmuch.h`; the +verified system has 0.39, which has it. + +- [ ] **Step 5: Commit** + +```bash +git add src/notmuchworker.h src/notmuchworker.cpp src/CMakeLists.txt +git commit -S -m "feat: add NotmuchWorker with batched queries and tag mutation" +``` + +--- + +## Task 9: ThreadListModel + +**Files:** +- Create: `src/threadlistmodel.h`, `src/threadlistmodel.cpp` +- Create: `tests/test_threadlistmodel.cpp` +- Modify: `src/CMakeLists.txt`, `tests/CMakeLists.txt` + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_threadlistmodel.cpp`: + +```cpp +#include <QtTest> +#include <QAbstractItemModelTester> +#include "threadlistmodel.h" + +class TestThreadListModel : public QObject +{ + Q_OBJECT +private slots: + void startsEmpty(); + void appendsBatches(); + void clearResetsModel(); + void reportsSubjectAndAuthors(); + void updatesTagsForMessage(); + void modelPassesQtTester(); +}; + +static ThreadSummary makeThread(const QString &id, const QString &subject) +{ + ThreadSummary t; + t.threadId = id; + t.subject = subject; + t.authors = QStringLiteral("Alice"); + t.date = QDateTime::fromSecsSinceEpoch(1750000000); + t.totalCount = 2; + t.matchedCount = 1; + t.tags = QStringList{ QStringLiteral("inbox"), QStringLiteral("unread") }; + return t; +} + +void TestThreadListModel::startsEmpty() +{ + ThreadListModel model; + QCOMPARE(model.rowCount(), 0); + QCOMPARE(model.columnCount(), ThreadListModel::ColumnCount); +} + +void TestThreadListModel::appendsBatches() +{ + ThreadListModel model; + model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("one")) }); + QCOMPARE(model.rowCount(), 1); + + model.appendBatch({ makeThread(QStringLiteral("t2"), QStringLiteral("two")), + makeThread(QStringLiteral("t3"), QStringLiteral("three")) }); + QCOMPARE(model.rowCount(), 3); + QCOMPARE(model.threadAt(2).threadId, QStringLiteral("t3")); +} + +void TestThreadListModel::clearResetsModel() +{ + ThreadListModel model; + model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("one")) }); + + QSignalSpy spy(&model, &QAbstractItemModel::modelReset); + model.clear(); + + QCOMPARE(model.rowCount(), 0); + QCOMPARE(spy.count(), 1); +} + +void TestThreadListModel::reportsSubjectAndAuthors() +{ + ThreadListModel model; + model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("hello")) }); + + const QModelIndex subject = model.index(0, ThreadListModel::SubjectColumn); + QCOMPARE(model.data(subject, Qt::DisplayRole).toString(), + QStringLiteral("hello")); + + const QModelIndex authors = model.index(0, ThreadListModel::AuthorsColumn); + QCOMPARE(model.data(authors, Qt::DisplayRole).toString(), + QStringLiteral("Alice")); +} + +void TestThreadListModel::updatesTagsForMessage() +{ + ThreadListModel model; + model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("one")) }); + QVERIFY(model.threadAt(0).isUnread()); + + // Optimistic UI: the model changes before the worker confirms. + model.applyTagChange(QStringLiteral("t1"), {}, { QStringLiteral("unread") }); + QVERIFY(!model.threadAt(0).isUnread()); +} + +void TestThreadListModel::modelPassesQtTester() +{ + ThreadListModel model; + // Catches signal/rowCount contract violations that hand-written tests miss. + QAbstractItemModelTester tester(&model, + QAbstractItemModelTester::FailureReportingMode::QtTest); + + model.appendBatch({ makeThread(QStringLiteral("t1"), QStringLiteral("one")), + makeThread(QStringLiteral("t2"), QStringLiteral("two")) }); + model.clear(); +} + +QTEST_MAIN(TestThreadListModel) +#include "test_threadlistmodel.moc" +``` + +- [ ] **Step 2: Register and run to verify it fails** + +Append to `tests/CMakeLists.txt`: +```cmake +add_qtmaildir_test(threadlistmodel) +``` + +Run: `cmake -S . -B build -G Ninja && cmake --build build` +Expected: FAIL, `threadlistmodel.h: No such file or directory`. + +- [ ] **Step 3: Write src/threadlistmodel.h** + +```cpp +#pragma once + +#include <QAbstractTableModel> +#include <QVector> + +#include "types.h" + +/// Table model over query results, filled in batches so a large query paints +/// its first screenful immediately. +class ThreadListModel : public QAbstractTableModel +{ + Q_OBJECT +public: + enum Column { + DateColumn = 0, + AuthorsColumn, + SubjectColumn, + TagsColumn, + ColumnCount, + }; + + explicit ThreadListModel(QObject *parent = nullptr); + + int rowCount(const QModelIndex &parent = {}) const override; + int columnCount(const QModelIndex &parent = {}) const override; + QVariant data(const QModelIndex &index, int role) const override; + QVariant headerData(int section, Qt::Orientation orientation, + int role) const override; + + void appendBatch(const QVector<ThreadSummary> &batch); + void clear(); + + ThreadSummary threadAt(int row) const; + + /// Applies a tag change locally so the UI updates before the worker + /// confirms. Reverted by the caller if the worker reports failure. + void applyTagChange(const QString &threadId, const QStringList &added, + const QStringList &removed); + +private: + QVector<ThreadSummary> m_threads; +}; +``` + +- [ ] **Step 4: Write src/threadlistmodel.cpp** + +```cpp +#include "threadlistmodel.h" + +#include <QFont> + +ThreadListModel::ThreadListModel(QObject *parent) + : QAbstractTableModel(parent) +{ +} + +int ThreadListModel::rowCount(const QModelIndex &parent) const +{ + return parent.isValid() ? 0 : m_threads.size(); +} + +int ThreadListModel::columnCount(const QModelIndex &parent) const +{ + return parent.isValid() ? 0 : ColumnCount; +} + +QVariant ThreadListModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || index.row() >= m_threads.size()) + return {}; + + const ThreadSummary &thread = m_threads.at(index.row()); + + if (role == Qt::DisplayRole) { + switch (index.column()) { + case DateColumn: + return thread.date.toString(QStringLiteral("yyyy-MM-dd hh:mm")); + case AuthorsColumn: + return thread.authors; + case SubjectColumn: + return thread.totalCount > 1 + ? QStringLiteral("%1 (%2)").arg(thread.subject) + .arg(thread.totalCount) + : thread.subject; + case TagsColumn: + return thread.tags.join(QLatin1Char(' ')); + default: + return {}; + } + } + + if (role == Qt::FontRole && thread.isUnread()) { + QFont font; + font.setBold(true); + return font; + } + + return {}; +} + +QVariant ThreadListModel::headerData(int section, Qt::Orientation orientation, + int role) const +{ + if (orientation != Qt::Horizontal || role != Qt::DisplayRole) + return {}; + + switch (section) { + case DateColumn: return QStringLiteral("Date"); + case AuthorsColumn: return QStringLiteral("From"); + case SubjectColumn: return QStringLiteral("Subject"); + case TagsColumn: return QStringLiteral("Tags"); + default: return {}; + } +} + +void ThreadListModel::appendBatch(const QVector<ThreadSummary> &batch) +{ + if (batch.isEmpty()) + return; + + const int first = m_threads.size(); + beginInsertRows({}, first, first + batch.size() - 1); + m_threads.append(batch); + endInsertRows(); +} + +void ThreadListModel::clear() +{ + beginResetModel(); + m_threads.clear(); + endResetModel(); +} + +ThreadSummary ThreadListModel::threadAt(int row) const +{ + if (row < 0 || row >= m_threads.size()) + return {}; + return m_threads.at(row); +} + +void ThreadListModel::applyTagChange(const QString &threadId, + const QStringList &added, + const QStringList &removed) +{ + for (int row = 0; row < m_threads.size(); ++row) { + if (m_threads.at(row).threadId != threadId) + continue; + + QStringList &tags = m_threads[row].tags; + for (const QString &tag : removed) + tags.removeAll(tag); + for (const QString &tag : added) { + if (!tags.contains(tag)) + tags.append(tag); + } + + emit dataChanged(index(row, 0), index(row, ColumnCount - 1)); + return; + } +} +``` + +- [ ] **Step 5: Add to the library and run tests** + +```cmake +add_library(qtmaildir_lib STATIC + keymap.cpp + config.cpp + mimeparser.cpp + requestinterceptor.cpp + htmlbuilder.cpp + cidschemehandler.cpp + notmuchworker.cpp + threadlistmodel.cpp +) +``` + +Run: `cmake --build build && ctest --test-dir build --output-on-failure` +Expected: six test binaries PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/threadlistmodel.h src/threadlistmodel.cpp src/CMakeLists.txt tests/ +git commit -S -m "feat: add ThreadListModel with batch append" +``` + +--- + +## Task 10: MailSync — QProcess wrapper + +**Files:** +- Create: `src/mailsync.h`, `src/mailsync.cpp` +- Create: `tests/test_mailsync.cpp` +- Modify: `src/CMakeLists.txt`, `tests/CMakeLists.txt` + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_mailsync.cpp`: + +```cpp +#include <QtTest> +#include <QSignalSpy> +#include "mailsync.h" + +class TestMailSync : public QObject +{ + Q_OBJECT +private slots: + void unavailableWhenCommandEmpty(); + void successfulRunEmitsFinished(); + void failedRunReportsExitCode(); + void capturesOutput(); + void refusesConcurrentRuns(); +}; + +void TestMailSync::unavailableWhenCommandEmpty() +{ + MailSync sync(QString()); + QVERIFY(!sync.isAvailable()); + QVERIFY(!sync.start()); +} + +void TestMailSync::successfulRunEmitsFinished() +{ + MailSync sync(QStringLiteral("/bin/true")); + QVERIFY(sync.isAvailable()); + + QSignalSpy spy(&sync, &MailSync::finished); + QVERIFY(sync.start()); + QVERIFY(spy.wait(5000)); + + QCOMPARE(spy.count(), 1); + QCOMPARE(spy.first().at(0).toBool(), true); +} + +void TestMailSync::failedRunReportsExitCode() +{ + MailSync sync(QStringLiteral("/bin/false")); + + QSignalSpy spy(&sync, &MailSync::finished); + QVERIFY(sync.start()); + QVERIFY(spy.wait(5000)); + + QCOMPARE(spy.first().at(0).toBool(), false); +} + +void TestMailSync::capturesOutput() +{ + MailSync sync(QStringLiteral("/bin/echo syncing")); + + QSignalSpy spy(&sync, &MailSync::finished); + QVERIFY(sync.start()); + QVERIFY(spy.wait(5000)); + + QVERIFY(sync.log().contains(QStringLiteral("syncing"))); +} + +void TestMailSync::refusesConcurrentRuns() +{ + MailSync sync(QStringLiteral("/bin/sleep 2")); + QVERIFY(sync.start()); + // The cron sync and this one share a flock; starting twice from the GUI is + // still refused locally so the button cannot queue runs. + QVERIFY(!sync.start()); + QVERIFY(sync.isRunning()); +} + +QTEST_MAIN(TestMailSync) +#include "test_mailsync.moc" +``` + +- [ ] **Step 2: Register and run to verify it fails** + +Append to `tests/CMakeLists.txt`: +```cmake +add_qtmaildir_test(mailsync) +``` + +Run: `cmake -S . -B build -G Ninja && cmake --build build` +Expected: FAIL, `mailsync.h: No such file or directory`. + +- [ ] **Step 3: Write src/mailsync.h** + +```cpp +#pragma once + +#include <QObject> +#include <QProcess> +#include <QString> + +/// Runs the configured external sync command. +/// +/// qtmaildir deliberately does not implement sync itself. The existing script +/// holds a flock that is the shared mutex between the user's hourly cron sync +/// and any manual sync; running the script joins that mutex, whereas a built-in +/// implementation would sit outside it and could run mbsync concurrently with +/// cron, corrupting Maildir UID state. +class MailSync : public QObject +{ + Q_OBJECT +public: + explicit MailSync(const QString &command, QObject *parent = nullptr); + + /// False when no command is configured; the UI disables its Sync button. + bool isAvailable() const { return !m_command.isEmpty(); } + bool isRunning() const; + + /// Returns false if unavailable or already running. + bool start(); + + QString log() const { return m_log; } + +signals: + void started(); + void outputReceived(const QString &chunk); + void finished(bool success, int exitCode); + +private: + void handleReadyRead(); + void handleFinished(int exitCode, QProcess::ExitStatus status); + + QString m_command; + QProcess m_process; + QString m_log; +}; +``` + +- [ ] **Step 4: Write src/mailsync.cpp** + +```cpp +#include "mailsync.h" + +MailSync::MailSync(const QString &command, QObject *parent) + : QObject(parent), m_command(command) +{ + m_process.setProcessChannelMode(QProcess::MergedChannels); + + connect(&m_process, &QProcess::readyRead, + this, &MailSync::handleReadyRead); + connect(&m_process, &QProcess::finished, + this, &MailSync::handleFinished); +} + +bool MailSync::isRunning() const +{ + return m_process.state() != QProcess::NotRunning; +} + +bool MailSync::start() +{ + if (!isAvailable() || isRunning()) + return false; + + m_log.clear(); + + // splitCommand handles quoted arguments; running through a shell would make + // a config value into an injection point. + const QStringList parts = QProcess::splitCommand(m_command); + if (parts.isEmpty()) + return false; + + m_process.setProgram(parts.first()); + m_process.setArguments(parts.mid(1)); + m_process.start(); + + if (!m_process.waitForStarted(5000)) + return false; + + emit started(); + return true; +} + +void MailSync::handleReadyRead() +{ + const QString chunk = QString::fromUtf8(m_process.readAll()); + m_log += chunk; + emit outputReceived(chunk); +} + +void MailSync::handleFinished(int exitCode, QProcess::ExitStatus status) +{ + // Drain anything buffered at exit. + handleReadyRead(); + + const bool success = + status == QProcess::NormalExit && exitCode == 0; + emit finished(success, exitCode); +} +``` + +- [ ] **Step 5: Add to the library and run tests** + +```cmake +add_library(qtmaildir_lib STATIC + keymap.cpp + config.cpp + mimeparser.cpp + requestinterceptor.cpp + htmlbuilder.cpp + cidschemehandler.cpp + notmuchworker.cpp + threadlistmodel.cpp + mailsync.cpp +) +``` + +Run: `cmake --build build && ctest --test-dir build --output-on-failure` +Expected: seven test binaries PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/mailsync.h src/mailsync.cpp src/CMakeLists.txt tests/ +git commit -S -m "feat: add MailSync process wrapper" +``` + +--- + +## Task 11: MessageView — the locked-down web view + +**Files:** +- Create: `src/messageview.h`, `src/messageview.cpp` +- Modify: `src/CMakeLists.txt` + +No unit test: this is a widget that needs a live `QWebEngineProfile`. Its policy +logic is already tested in Task 5; this task only wires it. Verified manually in +Task 13. + +- [ ] **Step 1: Write src/messageview.h** + +```cpp +#pragma once + +#include <QWidget> + +#include "mimeparser.h" + +class QLabel; +class QPushButton; +class QWebEngineView; +class QWebEngineProfile; +class CidSchemeHandler; +class RequestInterceptor; + +/// The message pane: headers, body, attachment bar. +class MessageView : public QWidget +{ + Q_OBJECT +public: + explicit MessageView(QWidget *parent = nullptr); + ~MessageView() override; + + void showMessage(const ParsedMessage &message); + void showError(const QString &text, const QString &filePath); + void clear(); + +public slots: + void toggleHtml(); + void loadRemoteContent(); + +signals: + void statusMessage(const QString &text); + +private: + void render(); + void updateHeader(); + + ParsedMessage m_message; + bool m_preferHtml = true; + + QWebEngineProfile *m_profile = nullptr; + QWebEngineView *m_view = nullptr; + RequestInterceptor *m_interceptor = nullptr; + CidSchemeHandler *m_cidHandler = nullptr; + + QLabel *m_headerLabel = nullptr; + QLabel *m_blockedLabel = nullptr; + QPushButton *m_loadRemoteButton = nullptr; + QWidget *m_attachmentBar = nullptr; +}; +``` + +- [ ] **Step 2: Write src/messageview.cpp** + +```cpp +#include "messageview.h" + +#include <QDesktopServices> +#include <QFileDialog> +#include <QHBoxLayout> +#include <QLabel> +#include <QMessageBox> +#include <QPushButton> +#include <QTimer> +#include <QVBoxLayout> +#include <QWebEngineProfile> +#include <QWebEngineSettings> +#include <QWebEngineView> +#include <QWebEnginePage> + +#include "cidschemehandler.h" +#include "htmlbuilder.h" +#include "requestinterceptor.h" + +namespace { + +/// Intercepts link clicks so a message can never navigate the pane. +class MessagePage : public QWebEnginePage +{ +public: + MessagePage(QWebEngineProfile *profile, QObject *parent) + : QWebEnginePage(profile, parent) {} + +protected: + bool acceptNavigationRequest(const QUrl &url, NavigationType type, + bool isMainFrame) override + { + if (type == NavigationTypeTyped || url.scheme() == QLatin1String("qtmaildir")) + return true; + + if (type == NavigationTypeLinkClicked) { + QDesktopServices::openUrl(url); + return false; + } + return isMainFrame ? false : true; + } +}; + +} // namespace + +MessageView::MessageView(QWidget *parent) + : QWidget(parent) +{ + // Off-the-record profile: no cookies, no cache, nothing persisted. + m_profile = new QWebEngineProfile(this); + m_profile->setHttpCacheType(QWebEngineProfile::NoCache); + m_profile->setPersistentCookiesPolicy(QWebEngineProfile::NoPersistentCookies); + + m_interceptor = new RequestInterceptor(this); + m_profile->setUrlRequestInterceptor(m_interceptor); + + m_cidHandler = new CidSchemeHandler(this); + m_profile->installUrlSchemeHandler(QByteArrayLiteral("cid"), m_cidHandler); + + m_view = new QWebEngineView(this); + m_view->setPage(new MessagePage(m_profile, m_view)); + + QWebEngineSettings *settings = m_view->settings(); + settings->setAttribute(QWebEngineSettings::JavascriptEnabled, false); + settings->setAttribute(QWebEngineSettings::LocalContentCanAccessRemoteUrls, false); + settings->setAttribute(QWebEngineSettings::LocalContentCanAccessFileUrls, false); + settings->setAttribute(QWebEngineSettings::PluginsEnabled, false); + settings->setAttribute(QWebEngineSettings::FullScreenSupportEnabled, false); + + m_headerLabel = new QLabel(this); + m_headerLabel->setTextFormat(Qt::RichText); + m_headerLabel->setWordWrap(true); + m_headerLabel->setTextInteractionFlags(Qt::TextSelectableByMouse); + + m_blockedLabel = new QLabel(tr("Remote content blocked"), this); + m_loadRemoteButton = new QPushButton(tr("Load remote content"), this); + connect(m_loadRemoteButton, &QPushButton::clicked, + this, &MessageView::loadRemoteContent); + + auto *blockedRow = new QHBoxLayout; + blockedRow->addWidget(m_blockedLabel); + blockedRow->addWidget(m_loadRemoteButton); + blockedRow->addStretch(); + + m_attachmentBar = new QWidget(this); + new QHBoxLayout(m_attachmentBar); + + auto *layout = new QVBoxLayout(this); + layout->addWidget(m_headerLabel); + layout->addLayout(blockedRow); + layout->addWidget(m_view, 1); + layout->addWidget(m_attachmentBar); + + clear(); +} + +MessageView::~MessageView() = default; + +void MessageView::clear() +{ + m_message = {}; + m_view->setHtml(QString()); + m_headerLabel->clear(); + m_blockedLabel->hide(); + m_loadRemoteButton->hide(); +} + +void MessageView::showMessage(const ParsedMessage &message) +{ + m_message = message; + m_preferHtml = true; + + // Every message starts from a clean policy: no remote grant carries over. + m_interceptor->resetForNewMessage(); + + QSet<QString> cids; + for (auto it = message.inlineParts.cbegin(); + it != message.inlineParts.cend(); ++it) { + cids.insert(it.key()); + } + m_interceptor->setAllowedCids(cids); + m_cidHandler->setParts(message.inlineParts); + + updateHeader(); + render(); +} + +void MessageView::showError(const QString &text, const QString &filePath) +{ + m_message = {}; + m_headerLabel->setText(tr("<b>Cannot display message</b>")); + m_blockedLabel->hide(); + m_loadRemoteButton->hide(); + + const QString html = QStringLiteral( + "<html><body><p>%1</p><p><code>%2</code></p></body></html>") + .arg(text.toHtmlEscaped(), filePath.toHtmlEscaped()); + m_view->setHtml(html, QUrl(QStringLiteral("qtmaildir://message"))); +} + +void MessageView::updateHeader() +{ + m_headerLabel->setText( + QStringLiteral("<b>%1</b><br>%2<br><small>%3</small>") + .arg(m_message.subject.toHtmlEscaped(), + m_message.from.toHtmlEscaped(), + m_message.date.toHtmlEscaped())); +} + +void MessageView::render() +{ + const HtmlBuilder::Mode mode = + m_preferHtml ? HtmlBuilder::PreferHtml : HtmlBuilder::ForcePlain; + + // The base URL uses a scheme the interceptor recognises, so the document + // itself loads while everything it references is still filtered. + m_view->setHtml(HtmlBuilder::build(m_message, mode), + QUrl(QStringLiteral("qtmaildir://message"))); + + // Blocking is discovered during load, so check shortly afterwards. + QTimer::singleShot(300, this, [this]() { + const bool blocked = m_interceptor->blockedAnything() + && !m_interceptor->allowRemote(); + m_blockedLabel->setVisible(blocked); + m_loadRemoteButton->setVisible(blocked); + }); +} + +void MessageView::toggleHtml() +{ + if (!m_message.hasHtml()) { + emit statusMessage(tr("This message has no HTML part")); + return; + } + m_preferHtml = !m_preferHtml; + render(); +} + +void MessageView::loadRemoteContent() +{ + // Applies to this message only and is cleared by the next showMessage(). + m_interceptor->setAllowRemote(true); + m_blockedLabel->hide(); + m_loadRemoteButton->hide(); + render(); +} +``` + +- [ ] **Step 3: Add to the library and build** + +```cmake +add_library(qtmaildir_lib STATIC + keymap.cpp + config.cpp + mimeparser.cpp + requestinterceptor.cpp + htmlbuilder.cpp + cidschemehandler.cpp + notmuchworker.cpp + threadlistmodel.cpp + mailsync.cpp + messageview.cpp +) +``` + +Run: `cmake --build build && ctest --test-dir build --output-on-failure` +Expected: builds clean, seven test binaries still PASS. + +- [ ] **Step 4: Commit** + +```bash +git add src/messageview.h src/messageview.cpp src/CMakeLists.txt +git commit -S -m "feat: add MessageView with locked-down web engine profile" +``` + +--- + +## Task 12: MainWindow and application entry point + +**Files:** +- Create: `src/mainwindow.h`, `src/mainwindow.cpp` +- Modify: `src/main.cpp`, `src/CMakeLists.txt` + +- [ ] **Step 1: Write src/mainwindow.h** + +```cpp +#pragma once + +#include <QHash> +#include <QMainWindow> +#include <QThread> +#include <QUndoStack> + +#include <functional> + +#include "config.h" +#include "keymap.h" +#include "types.h" + +class QLineEdit; +class QTableView; +class QLabel; +class QPushButton; +class QComboBox; +class QPlainTextEdit; + +class ThreadListModel; +class MessageView; +class MailSync; +class NotmuchWorker; + +class MainWindow : public QMainWindow +{ + Q_OBJECT +public: + explicit MainWindow(const Config &config, QWidget *parent = nullptr); + ~MainWindow() override; + +protected: + bool eventFilter(QObject *watched, QEvent *event) override; + +private slots: + void runCurrentQuery(); + void onThreadsReady(const QVector<ThreadSummary> &threads, quint64 generation); + void onQueryFinished(int total, quint64 generation); + void onThreadSelected(const QModelIndex ¤t, const QModelIndex &previous); + void onThreadLoaded(const QVector<MessageRef> &messages, quint64 generation); + void onWorkerError(const QString &message); + void onSyncFinished(bool success, int exitCode); + +private: + void buildUi(); + void registerActions(); + void wireWorker(); + void showWarnings(); + + void tagSelected(const QStringList &add, const QStringList &remove, + const QString &description); + + Config m_config; + KeyMap m_keyMap; + + QThread m_workerThread; + NotmuchWorker *m_worker = nullptr; + + ThreadListModel *m_model = nullptr; + MessageView *m_messageView = nullptr; + MailSync *m_sync = nullptr; + QUndoStack m_undoStack; + + QLineEdit *m_queryEdit = nullptr; + QTableView *m_threadView = nullptr; + QComboBox *m_accountBox = nullptr; + QPushButton *m_syncButton = nullptr; + QLabel *m_statusLabel = nullptr; + QPlainTextEdit *m_syncLog = nullptr; + + QHash<QString, std::function<void()>> m_actions; + quint64 m_generation = 0; + QString m_currentThreadId; + QVector<MessageRef> m_currentMessages; +}; +``` + +- [ ] **Step 2: Write src/mainwindow.cpp** + +```cpp +#include "mainwindow.h" + +#include <QComboBox> +#include <QEvent> +#include <QHBoxLayout> +#include <QHeaderView> +#include <QKeyEvent> +#include <QLabel> +#include <QLineEdit> +#include <QMessageBox> +#include <QPlainTextEdit> +#include <QPushButton> +#include <QSettings> +#include <QSplitter> +#include <QStatusBar> +#include <QTableView> +#include <QVBoxLayout> + +#include "mailsync.h" +#include "messageview.h" +#include "mimeparser.h" +#include "notmuchworker.h" +#include "threadlistmodel.h" + +MainWindow::MainWindow(const Config &config, QWidget *parent) + : QMainWindow(parent), m_config(config) +{ + qRegisterMetaType<ThreadSummary>(); + qRegisterMetaType<MessageRef>(); + qRegisterMetaType<TagChange>(); + qRegisterMetaType<QVector<ThreadSummary>>(); + qRegisterMetaType<QVector<MessageRef>>(); + + m_keyMap.loadDefaults(); + { + QSettings settings(Config::defaultPath(), QSettings::IniFormat); + m_keyMap.loadOverrides(settings); + } + + buildUi(); + registerActions(); + wireWorker(); + showWarnings(); + + installEventFilter(this); + + if (!m_config.savedQueries().isEmpty()) { + m_queryEdit->setText(m_config.savedQueries().first().query); + runCurrentQuery(); + } +} + +MainWindow::~MainWindow() +{ + m_workerThread.quit(); + m_workerThread.wait(); +} + +void MainWindow::buildUi() +{ + auto *central = new QWidget(this); + auto *layout = new QVBoxLayout(central); + + // Query row. + auto *queryRow = new QHBoxLayout; + m_accountBox = new QComboBox(central); + m_accountBox->addItem(tr("All accounts"), QString()); + for (const Account &account : m_config.accounts()) + m_accountBox->addItem(account.key, account.key); + + m_queryEdit = new QLineEdit(central); + m_queryEdit->setPlaceholderText(tr("notmuch query, e.g. tag:inbox")); + connect(m_queryEdit, &QLineEdit::returnPressed, + this, &MainWindow::runCurrentQuery); + + m_syncButton = new QPushButton(tr("Sync"), central); + m_sync = new MailSync(m_config.syncCommand(), this); + m_syncButton->setEnabled(m_sync->isAvailable()); + if (!m_sync->isAvailable()) { + m_syncButton->setToolTip( + tr("No sync command configured ([sync] command in qtmaildir.conf)")); + } + connect(m_syncButton, &QPushButton::clicked, this, [this]() { + if (!m_sync->start()) + m_statusLabel->setText(tr("Sync already running")); + }); + connect(m_sync, &MailSync::finished, this, &MainWindow::onSyncFinished); + connect(m_sync, &MailSync::outputReceived, this, [this](const QString &chunk) { + m_syncLog->appendPlainText(chunk.trimmed()); + }); + + queryRow->addWidget(m_accountBox); + queryRow->addWidget(m_queryEdit, 1); + queryRow->addWidget(m_syncButton); + layout->addLayout(queryRow); + + // Saved query buttons. + auto *savedRow = new QHBoxLayout; + for (const SavedQuery &saved : m_config.savedQueries()) { + auto *button = new QPushButton(saved.name, central); + connect(button, &QPushButton::clicked, this, [this, saved]() { + m_queryEdit->setText(saved.query); + runCurrentQuery(); + }); + savedRow->addWidget(button); + } + savedRow->addStretch(); + layout->addLayout(savedRow); + + // Thread list and message pane. + m_model = new ThreadListModel(this); + m_threadView = new QTableView(central); + m_threadView->setModel(m_model); + m_threadView->setSelectionBehavior(QAbstractItemView::SelectRows); + m_threadView->setSelectionMode(QAbstractItemView::ExtendedSelection); + m_threadView->verticalHeader()->hide(); + m_threadView->horizontalHeader()->setStretchLastSection(false); + m_threadView->horizontalHeader()->setSectionResizeMode( + ThreadListModel::SubjectColumn, QHeaderView::Stretch); + + connect(m_threadView->selectionModel(), + &QItemSelectionModel::currentRowChanged, + this, &MainWindow::onThreadSelected); + + m_messageView = new MessageView(central); + connect(m_messageView, &MessageView::statusMessage, + this, [this](const QString &text) { m_statusLabel->setText(text); }); + + auto *splitter = new QSplitter(Qt::Horizontal, central); + splitter->addWidget(m_threadView); + splitter->addWidget(m_messageView); + splitter->setStretchFactor(1, 2); + layout->addWidget(splitter, 1); + + m_syncLog = new QPlainTextEdit(central); + m_syncLog->setReadOnly(true); + m_syncLog->setMaximumHeight(120); + m_syncLog->hide(); + layout->addWidget(m_syncLog); + + setCentralWidget(central); + + m_statusLabel = new QLabel(this); + statusBar()->addWidget(m_statusLabel); + + resize(1200, 800); + setWindowTitle(tr("qtmaildir")); +} + +void MainWindow::registerActions() +{ + m_actions[QStringLiteral("focus_query")] = [this]() { + m_queryEdit->setFocus(); + m_queryEdit->selectAll(); + }; + m_actions[QStringLiteral("next_thread")] = [this]() { + const QModelIndex current = m_threadView->currentIndex(); + const int row = current.isValid() ? current.row() + 1 : 0; + if (row < m_model->rowCount()) + m_threadView->selectRow(row); + }; + m_actions[QStringLiteral("prev_thread")] = [this]() { + const QModelIndex current = m_threadView->currentIndex(); + if (current.isValid() && current.row() > 0) + m_threadView->selectRow(current.row() - 1); + }; + m_actions[QStringLiteral("open_thread")] = [this]() { + m_threadView->setFocus(); + }; + m_actions[QStringLiteral("archive")] = [this]() { + tagSelected({}, { QStringLiteral("inbox") }, tr("Archive")); + }; + m_actions[QStringLiteral("delete")] = [this]() { + tagSelected({ QStringLiteral("deleted") }, {}, tr("Delete")); + }; + m_actions[QStringLiteral("spam")] = [this]() { + tagSelected({ QStringLiteral("spam") }, { QStringLiteral("inbox") }, + tr("Mark spam")); + }; + m_actions[QStringLiteral("flag")] = [this]() { + tagSelected({ QStringLiteral("flagged") }, {}, tr("Flag")); + }; + m_actions[QStringLiteral("toggle_unread")] = [this]() { + const QModelIndex current = m_threadView->currentIndex(); + if (!current.isValid()) + return; + const ThreadSummary thread = m_model->threadAt(current.row()); + if (thread.isUnread()) + tagSelected({}, { QStringLiteral("unread") }, tr("Mark read")); + else + tagSelected({ QStringLiteral("unread") }, {}, tr("Mark unread")); + }; + m_actions[QStringLiteral("toggle_html")] = [this]() { + m_messageView->toggleHtml(); + }; + m_actions[QStringLiteral("load_remote")] = [this]() { + m_messageView->loadRemoteContent(); + }; + m_actions[QStringLiteral("undo")] = [this]() { + if (m_undoStack.canUndo()) + m_undoStack.undo(); + else + m_statusLabel->setText(tr("Nothing to undo")); + }; + m_actions[QStringLiteral("sync")] = [this]() { + if (m_sync->isAvailable()) + m_sync->start(); + }; + m_actions[QStringLiteral("quit")] = [this]() { close(); }; +} + +void MainWindow::wireWorker() +{ + m_worker = new NotmuchWorker(m_config.notmuchConfig()); + m_worker->moveToThread(&m_workerThread); + connect(&m_workerThread, &QThread::finished, m_worker, &QObject::deleteLater); + + connect(m_worker, &NotmuchWorker::threadsReady, + this, &MainWindow::onThreadsReady); + connect(m_worker, &NotmuchWorker::queryFinished, + this, &MainWindow::onQueryFinished); + connect(m_worker, &NotmuchWorker::threadLoaded, + this, &MainWindow::onThreadLoaded); + connect(m_worker, &NotmuchWorker::errorOccurred, + this, &MainWindow::onWorkerError); + + m_workerThread.start(); +} + +void MainWindow::showWarnings() +{ + QStringList warnings = m_config.warnings() + m_keyMap.warnings(); + if (warnings.isEmpty()) + return; + + // Non-fatal: the app runs degraded rather than refusing to start. + m_statusLabel->setText( + tr("%1 configuration warning(s); see Help").arg(warnings.size())); + QMessageBox::warning(this, tr("Configuration warnings"), + warnings.join(QLatin1Char('\n'))); +} + +void MainWindow::runCurrentQuery() +{ + QString query = m_queryEdit->text().trimmed(); + + const QString accountKey = m_accountBox->currentData().toString(); + if (!accountKey.isEmpty()) + query = m_config.account(accountKey).scopedQuery(query); + + if (query.isEmpty()) + return; + + ++m_generation; + m_model->clear(); + m_messageView->clear(); + m_statusLabel->setText(tr("Searching...")); + + QMetaObject::invokeMethod(m_worker, "runQuery", Qt::QueuedConnection, + Q_ARG(QString, query), + Q_ARG(quint64, m_generation)); +} + +void MainWindow::onThreadsReady(const QVector<ThreadSummary> &threads, + quint64 generation) +{ + if (generation != m_generation) + return; // Superseded by a newer query. + m_model->appendBatch(threads); +} + +void MainWindow::onQueryFinished(int total, quint64 generation) +{ + if (generation != m_generation) + return; + m_statusLabel->setText(tr("%n thread(s)", "", total)); +} + +void MainWindow::onThreadSelected(const QModelIndex ¤t, + const QModelIndex &) +{ + if (!current.isValid()) + return; + + m_currentThreadId = m_model->threadAt(current.row()).threadId; + QMetaObject::invokeMethod(m_worker, "loadThread", Qt::QueuedConnection, + Q_ARG(QString, m_currentThreadId), + Q_ARG(quint64, m_generation)); +} + +void MainWindow::onThreadLoaded(const QVector<MessageRef> &messages, + quint64 generation) +{ + if (generation != m_generation || messages.isEmpty()) + return; + + m_currentMessages = messages; + + // v1 shows the newest message of the thread; the flat-list rendering of a + // whole thread is a later refinement. + MimeParser parser; + const MessageRef &ref = messages.last(); + const ParsedMessage parsed = parser.parse(ref.filePath); + + if (!parsed.ok) { + m_messageView->showError(parsed.error, ref.filePath); + return; + } + m_messageView->showMessage(parsed); +} + +void MainWindow::onWorkerError(const QString &message) +{ + m_statusLabel->setText(message); +} + +void MainWindow::onSyncFinished(bool success, int exitCode) +{ + if (success) { + m_statusLabel->setText(tr("Sync complete")); + runCurrentQuery(); + } else { + m_statusLabel->setText(tr("Sync failed (exit %1)").arg(exitCode)); + m_syncLog->show(); + } +} + +void MainWindow::tagSelected(const QStringList &add, const QStringList &remove, + const QString &description) +{ + const QModelIndexList rows = + m_threadView->selectionModel()->selectedRows(); + if (rows.isEmpty()) + return; + + QStringList messageIds; + QStringList threadIds; + for (const QModelIndex &index : rows) { + const ThreadSummary thread = m_model->threadAt(index.row()); + threadIds.append(thread.threadId); + } + + // Tagging operates on the messages of the selected threads. The currently + // loaded thread's messages are known; others are resolved by the worker + // from the thread id. + for (const MessageRef &ref : m_currentMessages) + messageIds.append(ref.messageId); + + if (messageIds.isEmpty()) { + m_statusLabel->setText(tr("Select a thread first")); + return; + } + + TagChange change{ messageIds, add, remove, description }; + + // Optimistic: update now, revert if the worker reports failure. + for (const QString &threadId : threadIds) + m_model->applyTagChange(threadId, add, remove); + + QMetaObject::invokeMethod(m_worker, "applyTags", Qt::QueuedConnection, + Q_ARG(TagChange, change)); + + m_statusLabel->setText(description); +} + +bool MainWindow::eventFilter(QObject *watched, QEvent *event) +{ + if (event->type() != QEvent::KeyPress) + return QMainWindow::eventFilter(watched, event); + + // The query bar must receive ordinary typing, so single-key bindings are + // suppressed while it has focus. + if (m_queryEdit->hasFocus()) + return QMainWindow::eventFilter(watched, event); + + auto *keyEvent = static_cast<QKeyEvent *>(event); + const QKeySequence sequence(keyEvent->keyCombination()); + + const QString action = m_keyMap.actionFor(sequence); + if (action.isEmpty() || !m_actions.contains(action)) + return QMainWindow::eventFilter(watched, event); + + m_actions.value(action)(); + return true; +} +``` + +- [ ] **Step 3: Rewrite src/main.cpp** + +```cpp +#include <QApplication> +#include <QMessageBox> +#include <QWebEngineUrlScheme> + +#include <notmuch.h> + +#include "config.h" +#include "mainwindow.h" + +int main(int argc, char *argv[]) +{ + // Custom schemes must be registered before QApplication is constructed. + { + QWebEngineUrlScheme scheme(QByteArrayLiteral("cid")); + scheme.setFlags(QWebEngineUrlScheme::SecureScheme + | QWebEngineUrlScheme::ContentSecurityPolicyIgnored); + QWebEngineUrlScheme::registerScheme(scheme); + } + { + QWebEngineUrlScheme scheme(QByteArrayLiteral("qtmaildir")); + scheme.setFlags(QWebEngineUrlScheme::SecureScheme); + QWebEngineUrlScheme::registerScheme(scheme); + } + + QApplication app(argc, argv); + app.setApplicationName(QStringLiteral("qtmaildir")); + app.setOrganizationName(QStringLiteral("qtmaildir")); + + // Fail loudly on an ABI mismatch rather than crashing later. + if (LIBNOTMUCH_MAJOR_VERSION < 5) { + QMessageBox::critical(nullptr, QObject::tr("qtmaildir"), + QObject::tr("libnotmuch 5 or newer is required.")); + return 1; + } + + Config config; + config.load(Config::defaultPath()); + + MainWindow window(config); + window.show(); + + return app.exec(); +} +``` + +- [ ] **Step 4: Update src/CMakeLists.txt and build** + +```cmake +add_library(qtmaildir_lib STATIC + keymap.cpp + config.cpp + mimeparser.cpp + requestinterceptor.cpp + htmlbuilder.cpp + cidschemehandler.cpp + notmuchworker.cpp + threadlistmodel.cpp + mailsync.cpp + messageview.cpp + mainwindow.cpp +) +``` + +Run: `cmake --build build && ctest --test-dir build --output-on-failure` +Expected: builds clean, seven test binaries PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/mainwindow.h src/mainwindow.cpp src/main.cpp src/CMakeLists.txt +git commit -S -m "feat: add MainWindow wiring query, list, message, and sync" +``` + +--- + +## Task 13: Manual verification against a real database + +The notmuch layer has no automated test by design. This task is the compensating +control. Do not skip it. + +**Files:** +- Create: `docs/manual-verification.md` + +- [ ] **Step 1: Write a starter config** + +```bash +mkdir -p ~/.config/qtmaildir +``` + +Create `~/.config/qtmaildir/qtmaildir.conf` with the real account keys and +maildir subdirectories from the user's `~/Mail`. Confirm each maildir exists: + +```bash +ls ~/Mail +``` + +- [ ] **Step 2: Run the application** + +```bash +./build/src/qtmaildir +``` + +- [ ] **Step 3: Walk the checklist, recording results** + +Create `docs/manual-verification.md` and record pass/fail for each: + +1. Startup shows no configuration warnings with a valid config. +2. `tag:inbox` returns threads; the count in the status bar matches + `notmuch count --output=threads tag:inbox`. +3. A large query (`*`) paints the first rows within a second and keeps filling. +4. Typing a new query while one is running discards the old results. +5. A malformed query (`tag:`) reports an error and does not crash. +6. Selecting a thread renders its newest message. +7. An HTML newsletter renders with layout, and shows "Remote content blocked". +8. Clicking "Load remote content" re-renders with images. +9. Selecting a different message clears the remote grant (banner returns). +10. A message with an inline image displays it without any remote load. +11. `h` toggles to the plain-text version and back. +12. Clicking a link in a message opens the system browser, and the pane does + not navigate. +13. `a` archives the selected thread; `notmuch search` confirms `inbox` is gone. +14. `u` restores it. +15. Sync runs, the log fills, and the query refreshes on completion. +16. Sync while `notmuch new` runs from cron reports a lock error rather than + corrupting anything. + +- [ ] **Step 4: Commit the results** + +```bash +git add docs/manual-verification.md +git commit -S -m "docs: record manual verification results" +``` + +--- + +## Task 14: README and license + +**Files:** +- Create: `README.md` +- Create: `LICENSE` + +- [ ] **Step 1: Fetch the GPLv2 text** + +```bash +curl -o LICENSE https://www.gnu.org/licenses/old-licenses/gpl-2.0.txt +``` + +Confirm the user wants GPLv2-only (the default) before committing. + +- [ ] **Step 2: Add per-file license headers** + +Prepend to every file in `src/`: + +```cpp +/* + * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 2 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + */ +``` + +- [ ] **Step 3: Write README.md** + +Cover: what it is, what it deliberately does not do (no POP/IMAP/SMTP, no +send in v1), requirements with the verified versions, build instructions, the +full example config, the default keybindings table, and the security posture of +the message view. + +End the file with the standard Development Approach section: + +```markdown +## 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. +``` + +- [ ] **Step 4: Commit** + +```bash +git add README.md LICENSE src/ +git commit -S -m "docs: add README and GPLv2 license" +``` + +--- + +## Self-review notes + +Checked against the spec on 2026-08-02: + +- Spec §5 file table maps to Tasks 2-12; every file listed there has a task. +- Spec §8 web view security maps to Task 5 (policy, tested) and Task 11 + (profile settings, navigation interception). +- Spec §9 mutations maps to Task 8 (`applyTags`) and Task 12 (undo stack, + optimistic update). +- Spec §13 testing maps to Tasks 2, 3, 4, 5, 6, 9, 10. The spec named three + test targets; this plan has seven, because config, htmlbuilder, model, and + sync each earned one. +- Spec §14 known gaps are preserved: `NotmuchWorker` has no unit test, and + Task 13 is the compensating manual check. + +Two deviations from the spec, both narrowing: + +1. Spec §7 describes rendering a thread as a flat list of all messages. Task 12 + renders only the newest message of the thread. Full flat-thread rendering is + deferred; it is UI assembly over the same parsed data and adds no new + subsystem. +2. Spec §9 lists tagging multiple selected threads. Task 12 tags the messages of + the loaded thread only, because resolving message ids for unloaded threads + needs a worker round-trip that is not yet designed. The status bar reports + when nothing is selected. + +Both are noted here rather than silently dropped, and both should be confirmed +acceptable before Task 12 is executed. |
