# 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 #include 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() builds tests/test_.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 #include #include #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 #include #include 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 m_bindings; QStringList m_warnings; }; ``` - [ ] **Step 4: Write src/keymap.cpp** ```cpp #include "keymap.h" #include 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 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 #include #include #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 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 #include #include /// 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 accounts() const { return m_accounts; } Account account(const QString &key) const; QList 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 m_accounts; QList m_savedQueries; QString m_syncCommand; QString m_notmuchConfig; QStringList m_warnings; }; ``` - [ ] **Step 4: Write src/config.cpp** ```cpp #include "config.h" #include #include #include 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 To: Bob Subject: Plain hello Date: Sat, 01 Aug 2026 10:00:00 +0000 Message-ID: Content-Type: text/plain; charset=utf-8 Hello Bob. > quoted line Regards, Alice ``` Create `tests/fixtures/alternative.eml`: ``` From: Alice Subject: Both parts Date: Sat, 01 Aug 2026 10:00:00 +0000 Message-ID: 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 version

--BOUND-- ``` Create `tests/fixtures/inline_image.eml`: ``` From: Alice Subject: Inline image Date: Sat, 01 Aug 2026 10:00:00 +0000 Message-ID: MIME-Version: 1.0 Content-Type: multipart/related; boundary="REL" --REL Content-Type: text/html; charset=utf-8 --REL Content-Type: image/png Content-Transfer-Encoding: base64 Content-ID: iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9 awAAAABJRU5ErkJggg== --REL-- ``` Create `tests/fixtures/attachment.eml`: ``` From: Alice Subject: With attachment Date: Sat, 01 Aug 2026 10:00:00 +0000 Message-ID: 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?= Subject: =?utf-8?Q?Caf=C3=A9_meeting?= Date: Sat, 01 Aug 2026 10:00:00 +0000 Message-ID: Content-Type: text/plain; charset=utf-8 body ``` Create `tests/fixtures/truncated.eml` (deliberately cut off mid-part): ``` From: Alice Subject: Truncated Date: Sat, 01 Aug 2026 10:00:00 +0000 Message-ID: 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 Subject: Hostile attachment name Date: Sat, 01 Aug 2026 10:00:00 +0000 Message-ID: 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 #include #include #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(); void safeFilenameStripsPathComponents(); 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 ")); 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 #include #include #include /// 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 inlineParts; ///< Keyed by Content-ID, no <>. QList 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 #include #include #include #include #include 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(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, and currently UNREACHABLE through this function: // safeFilename() above already reduces any name to a basename, so no // caller-supplied filename can produce a target outside `directory`. // The guard exists so that a future change which stops sanitising, or // which lets a caller pass a subpath, still cannot escape. Do not write // a test that drives saveTo() expecting a refusal: it cannot happen // while safeFilename() runs first. Test safeFilename() instead, which // is the control that actually stops traversal today. // // The comparison must be separator-aware. A bare startsWith() on the // strings would accept "/tmp/safe-evil/x" as being inside "/tmp/safe", // since one is a string prefix of the other with no path boundary // between them. cleanPath() also resolves ".." before comparison rather // than leaving it to be compared textually. const QString cleanDir = QDir::cleanPath(QDir(directory).absolutePath()); const QString cleanTarget = QDir::cleanPath(target); if (cleanTarget != cleanDir && !cleanTarget.startsWith(cleanDir + QLatin1Char('/'))) { if (error) *error = QStringLiteral("Refusing to write outside %1").arg(cleanDir); 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 #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 #include #include /// 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 &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 m_allowedCids; bool m_allowRemote = false; bool m_blockedAnything = false; }; ``` - [ ] **Step 4: Write src/requestinterceptor.cpp** ```cpp #include "requestinterceptor.h" #include 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 #include "htmlbuilder.h" class TestHtmlBuilder : public QObject { Q_OBJECT private slots: void escapesPlainText(); void preservesHtmlBodyWhenHtmlRequested(); void marksQuotedLines(); void plainTextScriptTagIsNeutralised(); void buildsThreadWithAllMessages(); void collapsedMessageShowsStubOnly(); void threadNamespacesCidUrls(); }; 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("

hello

"); const QString html = HtmlBuilder::build(msg, HtmlBuilder::PreferHtml); QVERIFY(html.contains(QStringLiteral("

hello

"))); } 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(""); 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("