aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md235
-rw-r--r--src/notmuchworker.cpp30
-rw-r--r--tests/notmuchfixture.h33
-rw-r--r--tests/test_notmuchworker.cpp133
4 files changed, 419 insertions, 12 deletions
diff --git a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
index be52da9..4039753 100644
--- a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
+++ b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
@@ -19,8 +19,10 @@ came from one such pass on 2026-08-04 and included two defects that had gone
unrecorded here for a while. Items 39 to 45 came from the 2026-08-05 pass, which
found one more defect (41, a message body silently dropped by the MIME walk) and
one item that cannot be planned at all until the user says where the thing it
-manages lives (44). Compare the two at the start of a session; the procedure is
-in `CLAUDE.md`.
+manages lives (44). Items 121 to 123 came from the 2026-08-20 pass, which found
+that item 74 had closed only half of what its note asked for, and that the
+README had gone stale enough to document a mandatory config key by omitting it.
+Compare the two at the start of a session; the procedure is in `CLAUDE.md`.
Numbering is stable. New items append with the next free number and never
renumber, so a note referring to "item 7" keeps meaning the same thing. An item
@@ -185,6 +187,12 @@ taking that too literally.
| 118 | No way to empty the trash from inside the app | workflow | S | open, 2026-08-17. **Blocked on 103**, which creates the trash in the first place. Deliberately left out of 103's spec at the user's request rather than squeezed in |
| 119 | The unsynced-changes count cannot be opened to see what it counts | information | S | open, 2026-08-19, from the notes. One of the four things it sums carries no message ids at all, so a list cannot be complete without a change to how the count is kept |
+| 121 | The thread list shows nothing while a query is running | feedback | S | open, 2026-08-20, from the notes. Follows item 74, which fixed the status-bar half and left the list itself blank |
+| 122 | The README documents a version of the app that no longer exists | documentation | M | open, 2026-08-20, from the notes. Delete-to-trash is entirely undocumented, including a config key a user must now set |
+| 123 | Sending mail is not designed | v2 | ? | open, 2026-08-20, from the notes. Brainstorm only, explicitly `#plan-only`; the user places most open UX behind it |
+
+| 124 | The worker reads the index directory as the mail root | defect | S | open, 2026-08-20. Blocks moving the index to an SSD. Under a split `mail_root`/`path` config, Delete would move mail INSIDE the index directory, where mbsync cannot see it |
+
Sizes are rough: XS under an hour, S a sitting, M a session.
---
@@ -805,6 +813,229 @@ specifically to find out what those were.
for the fourth, and the item is not complete without it.
+## 121. The thread list shows nothing while a query is running
+
+**Observed (user, from the notes):** "can we show a spinner in the left panel
+while 'Searching' is going? Especially at first run, the loading wait is several
+seconds, and the status bar starts updating 'Searching N threads' after the
+first have already appeared. Before that the program seems broken."
+
+**This is the half of item 74 that was never built**, and the note is precise
+about which half. Item 74 closed on 2026-08-15 having fixed the status bar,
+which used to set "Searching..." once and hold it for the whole walk. The count
+the note describes is that fix working as designed: it is written from
+`m_model->rowCount()` in `onThreadsReady`, so by construction it cannot report
+anything before the first batch has landed.
+
+**Cause (verified in the code).** `MainWindow::runQuery` clears the model and
+sets the status text (`src/mainwindow.cpp:2414`), and nothing else in the view
+changes. The thread list is then an empty `QTreeView` until `appendBatch` runs
+on the first batch, so **a query in progress and a query that matched nothing
+render identically**. There is no busy state on the view at all.
+
+**The gap is measured, and item 74's numbers understate it badly.** Re-measured
+on 2026-08-20 against the user's real inbox, seven minutes after boot, with the
+index verifiably unread (0.0% of 1037 MB resident). Item 74's figures came from
+`posix_fadvise(POSIX_FADV_DONTNEED)` eviction, which does not reproduce a real
+cold boot on this hardware:
+
+| phase | item 74, 2026-08-11 | measured cold, 2026-08-20 | warm |
+|---|---|---|---|
+| `search_threads` returns | 411 ms | **673 ms** | 2 ms |
+| first batch of 200 rows | 642 ms | **2008 ms** | 12 ms |
+| walk complete | 5714 ms | **38618 ms** | 154 ms |
+| threads | 4444 | 4628 | 4628 |
+
+So the list is blank for **two seconds**, and keeps growing for **thirty-eight**,
+on 4% more mail. The user's note said "several seconds" and the note was right.
+
+**The cause is the storage, not the code.** `/data` is `/dev/sda1`, a 7200rpm
+platter (`rotational: 1`); warm, the identical walk is 154 ms, a 250x
+difference. Item 124 is the prerequisite for moving the index to the NVMe SSD
+already in the machine, which would make this gap ~12 ms and reduce this item to
+a nicety.
+
+**Approach.** A busy state on the left pane between `runQuery` and the first
+`onThreadsReady`, cleared by whichever of the first batch or `queryFinished`
+arrives first. The empty-result case must be distinguishable from it: when
+`queryFinished` reports zero, the pane should say so rather than returning to a
+blank list, which is the same ambiguity one step later.
+
+The likely shape is an overlay or a placeholder row rather than a literal
+spinner widget, but that is a design question for the user, not a decision to
+take here. A spinner also has to be animated by the UI thread, which is free
+here since the work is on the worker, but that is worth stating because it is
+the usual reason a spinner does not spin.
+
+**Constraints.**
+
+- **A background refresh must stay silent.** `onThreadsReady` returns early on
+ the refresh branch and `onQueryFinished` does the same, deliberately, so a
+ sync-driven refresh does not flicker the status bar. A busy indicator that
+ ignored that guard would make every cron sync flash the list. That silence is
+ already a test, and it should cover this too.
+- **Item 74's decision not to address the cold cost was taken on wrong
+ numbers** and is worth revisiting, though not here. It judged a 5.7 s wait not
+ worth prefaulting 1.1 GB; the real figure is 38.6 s. The answer is not
+ prefaulting either way: it is item 124 plus moving the index off the platter.
+ This item makes the remaining wait legible, nothing more.
+- Nothing about the query timing may change.
+
+**Size: S.**
+
+## 122. The README documents a version of the app that no longer exists
+
+**Observed (user, from the notes):** "documentation needs updating, EG the
+README.md reports various things not up-to-date anymore."
+
+**Cause (verified).** `README.md` was last touched on 2026-08-15 by b405e32,
+which moved the SlackBuild out to the `my-slackbuilds` repo. Everything released
+since then is absent from it. Releases 0.19.0 through 0.26.1 all landed after
+that commit.
+
+**Measured, by grepping both documents for the same terms:**
+
+| term | README | CHANGELOG |
+|---|---|---|
+| `trash` | 0 | 14 |
+| `restore` | 0 | 7 |
+| `Select all` | 0 | 3 |
+| `deleted-from` | 0 | 0 |
+
+**One of these is worse than stale documentation.** Item 103 made a per-account
+`trash` key MANDATORY: an account without one produces a config warning, and
+Delete cannot work. The README is the only place a user reads about configuring
+an account, and it does not mention the key at all. So the documented config
+produces a warning against the current binary, and the feature that needs it is
+undocumented. The `deleted-from:<folder>` tag is likewise invisible, and a user
+who sees it on a message has nowhere to look it up.
+
+**Approach.** An audit against the changelog rather than a rewrite: walk the
+sections from 0.19.0 forward and check each user-visible change for a README
+home. The config section and the keyboard-shortcut table are the two most
+likely to have drifted, since both enumerate things that have been added to.
+
+**Constraints.**
+
+- **The changelog is the evidence, not memory.** Every entry since b405e32 is
+ written down; work from it.
+- **`### Upgrading` sections are the priority.** They exist precisely because a
+ user's config or habits had to change, and those are the paragraphs whose
+ absence from the README costs the user a broken setup rather than a moment of
+ confusion.
+- The "Development Approach" section at the bottom is required by the user's
+ global preference and must survive any edit.
+- No personal details, per the same preference: account names in examples stay
+ generic.
+
+**Size: M.** The audit is most of it; the writing is small once the list exists.
+
+## 123. Sending mail is not designed
+
+**Observed (user, from the notes):** "we should start brainstorming sending
+emails. Most of the open issues are UX. #v2 #plan-only #new-branch".
+
+**Not a defect and not implementation work.** The note tags it `#plan-only`, and
+this repo's own scope statement agrees: `CLAUDE.md` records that v1 is
+read-and-organize only and that compose and send are v2. Recorded here so the
+backlog stops being silent about the largest thing the user has written down.
+
+**What it blocks.** Item 72 (khard/khal) is explicitly placed after send by the
+user's own note, and cannot be specified before it. The completion machinery
+that would serve recipient completion already exists as `QueryCompleter`, and
+the `QLineEdit::setCompleter` trap in `CLAUDE.md` applies directly to any
+multi-recipient field, so there is prior art to reuse rather than a blank page.
+
+**What it does NOT change.** The architecture note that this application does
+**no network protocol work at all** is load-bearing: fetching is `mbsync` via
+`assets/mailsync.sh`, and sending should be an external script on the same
+model, not an SMTP client written here. A design that puts a socket in this
+process is out of scope regardless of how the UI turns out.
+
+**Approach.** Brainstorm first, on its own branch, producing a spec under
+`docs/superpowers/specs/` before any code. The open questions are all UX, as the
+note says: where a composer lives, how a draft is stored so `notmuch` can see
+it, what reply and forward do to the thread the user is looking at, and how a
+queued message reaches the sending script.
+
+**Constraints.**
+
+- **Ask the user before designing.** They have not said what they pictured, and
+ this is the item where guessing costs the most.
+- Drafts are already visible to the app: item 67 counts them in the placeholder
+ pane, so a draft folder is configured and indexed.
+- `#new-branch` is the user's own tag on it.
+
+**Size: `?`** until the brainstorm has happened. It is the largest open item by
+some distance.
+
+## 124. The worker reads the index directory as the mail root
+
+**Observed (measured, 2026-08-20):** not reported from use. Found while
+measuring item 121's cold-start cost, which established that the 1.1 GB notmuch
+index sits on a 7200rpm platter (`/dev/sda1`, `rotational: 1`) while an NVMe SSD
+sits idle in the same machine. Moving the index to the SSD is a notmuch
+configuration change and needs no code, but qtmaildir does not survive it.
+
+**Cause (verified against notmuch 0.39 and a throwaway database).** notmuch
+supports splitting the index from the mail with two keys:
+
+```ini
+[database]
+mail_root=/data/Mail
+path=/home/you/.local/share/notmuch
+```
+
+Under that layout `notmuch_database_get_path()` returns the **index**
+directory, not the mail root. Measured on a split test database:
+
+| accessor | legacy (`path` only) | split |
+|---|---|---|
+| `notmuch_database_get_path()` | mail root | **index dir** |
+| `notmuch_config_get(NOTMUCH_CONFIG_MAIL_ROOT)` | mail root | mail root |
+
+`notmuchworker.cpp` calls `get_path()` at six sites and treats every one as the
+mail root: lines 297, 755, 900, 901, 1062, and the `relativeFilePath()` calls
+at 364, 380 and 918 that consume them.
+
+**One of those six moves mail, and it is the dangerous one.**
+`moveMessages` composes its destination as
+`root + "/" + destFolder + "/cur"` (`src/notmuchworker.cpp:755`). Under a split
+config `root` is the index directory, so Delete would move the message into
+`<index>/Trash/cur`: outside the Maildir, invisible to mbsync, and gone from
+every other client. That is item 103's stranded-mail failure with a new cause,
+and this repo has already shipped that class of bug once.
+
+The rest degrade rather than destroy. `relativeFilePath()` against the wrong
+root yields `../../../data/Mail/account/cur/...` instead of `account/cur/...`,
+so no path matches an account prefix and every row resolves to no account,
+which is exactly what the comment at line 294 already warns about.
+
+**Approach.** Replace `notmuch_database_get_path()` with
+`notmuch_config_get(m_db, NOTMUCH_CONFIG_MAIL_ROOT)` at the six sites.
+
+**No conditional is needed, and that is the point.** `MAIL_ROOT` returns the
+mail root under BOTH layouts, verified above: under a legacy `path`-only config
+it equals `get_path()`, so the change is a no-op against the current
+configuration and correct against the split one. A fallback to `get_path()` when
+`MAIL_ROOT` is NULL is the tempting belt-and-braces addition and should be
+resisted unless a NULL is actually observed, since it reintroduces the wrong
+answer on the path where it matters.
+
+**Constraints.**
+
+- **`moveMessages` is the site to test hardest.** A wrong root there reaches the
+ mail server, per the "Delete MOVES the file" note in `CLAUDE.md`.
+- The test fixture must build a database whose index is NOT inside the mail
+ root, or it cannot tell the two accessors apart: under the ordinary fixture
+ layout both return the same string and a mutation stays green.
+- `notmuch_config_get` returns a string owned by notmuch (`notmuch.h:2585`);
+ do not free it.
+- Nothing about the legacy layout may change. The migration is the user's to
+ perform, separately, once this ships.
+
+**Size: S.** Six call sites and a fixture that can tell them apart.
+
## Deferred, unsized, or split out
Items noted while triaging but not part of the original list. Same numbering
diff --git a/src/notmuchworker.cpp b/src/notmuchworker.cpp
index 66b408c..d0274cd 100644
--- a/src/notmuchworker.cpp
+++ b/src/notmuchworker.cpp
@@ -35,6 +35,28 @@
namespace {
+/// Where the MAIL lives, which is not always where the index lives.
+///
+/// Item 124. notmuch can be configured with `mail_root` and `path` as separate
+/// keys, which is how the Xapian index moves to faster storage while the
+/// Maildir stays put. Under that layout `notmuch_database_get_path()` returns
+/// the INDEX directory, so every path composed from it lands in the wrong tree:
+/// message paths resolve to `../..` escapes that match no account, and a move
+/// writes into the index instead of the Maildir, where mbsync never sees it.
+///
+/// `NOTMUCH_CONFIG_MAIL_ROOT` is correct under BOTH layouts. With only `path`
+/// set it returns that same directory, so this is not a special case for split
+/// configurations but the right question to ask in every one. Measured against
+/// notmuch 0.39: legacy config, `get_path()` and `MAIL_ROOT` agree; split
+/// config, only `MAIL_ROOT` names the Maildir.
+///
+/// The string is owned by notmuch and must not be freed (notmuch.h:2585).
+QString mailRootOf(notmuch_database_t *db)
+{
+ const char *root = notmuch_config_get(db, NOTMUCH_CONFIG_MAIL_ROOT);
+ return root ? QString::fromUtf8(root) : QString();
+}
+
QStringList tagsOf(notmuch_message_t *message)
{
QStringList result;
@@ -295,7 +317,7 @@ void NotmuchWorker::runQuery(const QString &query, quint64 generation,
// database-relative prefix: comparing the two never matched and left every
// row resolving to no account at all.
const QString dbRoot =
- QDir(QString::fromUtf8(notmuch_database_get_path(m_db))).absolutePath();
+ QDir(mailRootOf(m_db)).absolutePath();
QVector<ThreadSummary> batch;
batch.reserve(kBatchSize);
@@ -752,7 +774,7 @@ void NotmuchWorker::moveMessages(const QStringList &messageIds,
return;
}
- const QString root = QString::fromUtf8(notmuch_database_get_path(db));
+ const QString root = mailRootOf(db);
const QString destDir =
root + QLatin1Char('/') + destFolder + QStringLiteral("/cur");
@@ -898,7 +920,7 @@ void NotmuchWorker::resolveQuery(const QString &query,
// ThreadSummary::firstMessagePath: the UI knows accounts only by their
// maildir, itself a database-relative prefix.
const QString dbRoot =
- QDir(QString::fromUtf8(notmuch_database_get_path(m_db))).absolutePath();
+ QDir(mailRootOf(m_db)).absolutePath();
QStringList messageIds;
QStringList paths;
@@ -1059,7 +1081,7 @@ void NotmuchWorker::requestFolders()
if (!openReadOnly())
return;
- const QString root = QString::fromUtf8(notmuch_database_get_path(m_db));
+ const QString root = mailRootOf(m_db);
if (root.isEmpty()) {
emit errorOccurred(
QStringLiteral("notmuch reports no database path."));
diff --git a/tests/notmuchfixture.h b/tests/notmuchfixture.h
index bb25526..d1bdf62 100644
--- a/tests/notmuchfixture.h
+++ b/tests/notmuchfixture.h
@@ -44,6 +44,23 @@ public:
QString configPath() const { return m_dir.filePath(QStringLiteral("config")); }
QString maildirPath() const { return m_dir.filePath(QStringLiteral("mail")); }
+ /// Where the Xapian index lives. Equal to maildirPath()/.notmuch in the
+ /// ordinary layout; a directory of its own once splitIndex() is called.
+ QString indexPath() const
+ {
+ return m_splitIndex ? m_dir.filePath(QStringLiteral("index"))
+ : maildirPath() + QStringLiteral("/.notmuch");
+ }
+
+ /// Puts the index OUTSIDE the mail root, as notmuch's `mail_root`/`path`
+ /// split does (item 124).
+ ///
+ /// This is opt-in because it is the only layout that can tell
+ /// `notmuch_database_get_path()` apart from the mail root: in the ordinary
+ /// layout the two return the same string, so a test written against it
+ /// passes whichever accessor the code uses. Call before index().
+ void splitIndex() { m_splitIndex = true; }
+
/// Writes one message into <folder>/cur (or new/ when unread).
///
/// Returns false if the file could not be written. Call index() afterwards.
@@ -103,9 +120,18 @@ public:
return false;
}
QTextStream out(&config);
- out << "[database]\n"
- << "path=" << maildirPath() << "\n"
- << "[new]\n"
+ out << "[database]\n";
+ if (m_splitIndex) {
+ // Two keys: the mail stays put and only the index moves. notmuch
+ // reads `path` as the database directory ITSELF here, not as a
+ // parent to create `.notmuch` in.
+ QDir().mkpath(indexPath());
+ out << "mail_root=" << maildirPath() << "\n"
+ << "path=" << indexPath() << "\n";
+ } else {
+ out << "path=" << maildirPath() << "\n";
+ }
+ out << "[new]\n"
<< "tags=unread;inbox;\n";
out.flush();
config.close();
@@ -132,4 +158,5 @@ public:
private:
QTemporaryDir m_dir;
QString m_error;
+ bool m_splitIndex = false;
};
diff --git a/tests/test_notmuchworker.cpp b/tests/test_notmuchworker.cpp
index b5da31a..998696f 100644
--- a/tests/test_notmuchworker.cpp
+++ b/tests/test_notmuchworker.cpp
@@ -92,6 +92,10 @@ private slots:
void moveMessagesReportsOnlyWhatMoved();
void moveMessagesGivesTheFileAFreshMaildirName();
void moveMessagesKeepsTheMaildirFlags();
+
+ void aSplitIndexStillResolvesTheMailRoot();
+ void aSplitIndexMovesIntoTheMaildirNotTheIndex();
+ void aSplitIndexListsTheMaildirsFolders();
void twoMessagesMovedTogetherGetDistinctNames();
private:
@@ -101,7 +105,8 @@ private:
bool addMovableMessage(const QString &folder, const QString &messageId);
/// The single file backing `messageId`, or an empty string when the
/// database does not know the id.
- QString fileOf(const QString &messageId);
+ QString fileOf(const QString &messageId,
+ const QString &configPath = QString());
/// Tags of one message, read back through a fresh worker query.
QStringList tagsOf(const QString &messageId);
@@ -210,9 +215,10 @@ bool TestNotmuchWorker::addMovableMessage(const QString &folder,
return m_fixture.index();
}
-QString TestNotmuchWorker::fileOf(const QString &messageId)
+QString TestNotmuchWorker::fileOf(const QString &messageId,
+ const QString &configPath)
{
- NotmuchWorker worker(m_fixture.configPath());
+ NotmuchWorker worker(configPath.isEmpty() ? m_fixture.configPath() : configPath);
QSignalSpy loaded(&worker, &NotmuchWorker::threadLoaded);
worker.loadThread(QStringLiteral("{id:%1}").arg(messageId), QString(), 1);
if (loaded.isEmpty())
@@ -1377,5 +1383,126 @@ void TestNotmuchWorker::moveMessagesReportsOnlyWhatMoved()
QCOMPARE(inTrash.size(), 1);
}
+
+// Item 124. notmuch can put the Xapian index outside the mail root
+// (`mail_root` + `path`), which is how the index moves to faster storage while
+// the mail stays put. Under that layout `notmuch_database_get_path()` returns
+// the INDEX directory, so any code treating it as the mail root composes paths
+// into the wrong tree entirely.
+//
+// These three need `splitIndex()`, and that is the whole point: in the
+// ordinary layout the index lives inside the mail root and both accessors
+// return the same string, so a test written against it passes whichever one
+// the code uses and a mutation stays green.
+
+void TestNotmuchWorker::aSplitIndexStillResolvesTheMailRoot()
+{
+ NotmuchFixture fixture;
+ QVERIFY(fixture.isValid());
+ fixture.splitIndex();
+ QVERIFY(fixture.addMessage(QStringLiteral("work/INBOX"),
+ QStringLiteral("split1@example.org"),
+ QStringLiteral("Something"),
+ QStringLiteral("Alice <alice@example.org>"),
+ QStringLiteral("Mon, 1 Jun 2026 10:00:00 +0000"),
+ QStringLiteral("body"), false));
+ QVERIFY2(fixture.index(), qPrintable(fixture.error()));
+
+ // The fixture really did split them, or the rest proves nothing.
+ QVERIFY2(!fixture.indexPath().startsWith(fixture.maildirPath()),
+ "the fixture did not put the index outside the mail root");
+ QVERIFY(QDir(fixture.indexPath() + QStringLiteral("/xapian")).exists());
+
+ NotmuchWorker worker(fixture.configPath());
+ QSignalSpy ready(&worker, &NotmuchWorker::threadsReady);
+ QSignalSpy errors(&worker, &NotmuchWorker::errorOccurred);
+
+ worker.runQuery(QStringLiteral("*"), 1, NotmuchWorker::NewestFirst, false);
+ QVERIFY2(errors.isEmpty(), qPrintable(errors.value(0).value(0).toString()));
+ QCOMPARE(ready.size(), 1);
+
+ const auto threads = ready.first().at(0).value<QVector<ThreadSummary>>();
+ QCOMPARE(threads.size(), 1);
+
+ // The path is stored relative to the MAIL ROOT. Resolved against the index
+ // directory it comes back as a "../.." escape, which matches no account
+ // prefix and leaves every row belonging to no account at all.
+ const QString path = threads.first().firstMessagePath;
+ QVERIFY2(!path.startsWith(QStringLiteral("..")),
+ qPrintable(QStringLiteral("path escaped the mail root: %1").arg(path)));
+ QVERIFY2(path.startsWith(QStringLiteral("work/INBOX/")),
+ qPrintable(QStringLiteral("expected a work/INBOX path, got: %1").arg(path)));
+}
+
+void TestNotmuchWorker::aSplitIndexMovesIntoTheMaildirNotTheIndex()
+{
+ NotmuchFixture fixture;
+ QVERIFY(fixture.isValid());
+ fixture.splitIndex();
+ QVERIFY(fixture.addMessage(QStringLiteral("work/INBOX"),
+ QStringLiteral("split2@example.org"),
+ QStringLiteral("Doomed"),
+ QStringLiteral("Alice <alice@example.org>"),
+ QStringLiteral("Mon, 1 Jun 2026 10:00:00 +0000"),
+ QStringLiteral("body"), false));
+ QVERIFY2(fixture.index(), qPrintable(fixture.error()));
+
+ NotmuchWorker worker(fixture.configPath());
+ QSignalSpy moved(&worker, &NotmuchWorker::messagesMoved);
+ QSignalSpy errors(&worker, &NotmuchWorker::errorOccurred);
+
+ worker.moveMessages({ QStringLiteral("split2@example.org") },
+ QStringLiteral("work/Trash"));
+ QVERIFY2(errors.isEmpty(), qPrintable(errors.value(0).value(0).toString()));
+ QCOMPARE(moved.size(), 1);
+
+ // The file must land in the MAILDIR's trash. Composed against the index
+ // directory it lands inside the Xapian tree instead: outside the Maildir,
+ // invisible to mbsync, and gone from every other client. That is item
+ // 103's stranded mail with a new cause, which is why this assertion names
+ // the index directory explicitly rather than only checking the good path.
+ const QString expected =
+ fixture.maildirPath() + QStringLiteral("/work/Trash/cur");
+ QVERIFY2(!QDir(fixture.indexPath() + QStringLiteral("/work")).exists(),
+ "the move created a folder inside the INDEX directory");
+
+ const QString after =
+ fileOf(QStringLiteral("split2@example.org"), fixture.configPath());
+ QVERIFY2(!after.isEmpty(), "the message is not in the database after the move");
+ QCOMPARE(QFileInfo(after).absolutePath(), expected);
+ QVERIFY2(QFile::exists(after), qPrintable(after));
+}
+
+void TestNotmuchWorker::aSplitIndexListsTheMaildirsFolders()
+{
+ NotmuchFixture fixture;
+ QVERIFY(fixture.isValid());
+ fixture.splitIndex();
+ QVERIFY(fixture.addMessage(QStringLiteral("work/INBOX"),
+ QStringLiteral("split3@example.org"),
+ QStringLiteral("Something"),
+ QStringLiteral("Alice <alice@example.org>"),
+ QStringLiteral("Mon, 1 Jun 2026 10:00:00 +0000"),
+ QStringLiteral("body"), false));
+ QVERIFY2(fixture.index(), qPrintable(fixture.error()));
+
+ NotmuchWorker worker(fixture.configPath());
+ QSignalSpy folders(&worker, &NotmuchWorker::foldersReady);
+ QSignalSpy errors(&worker, &NotmuchWorker::errorOccurred);
+
+ worker.requestFolders();
+ QVERIFY2(errors.isEmpty(), qPrintable(errors.value(0).value(0).toString()));
+ QCOMPARE(folders.size(), 1);
+
+ // Scanned from the mail root. Scanned from the index directory the list is
+ // empty, or worse, names Xapian's own subdirectories as mail folders.
+ const QStringList found = folders.first().at(0).toStringList();
+ QVERIFY2(found.contains(QStringLiteral("work/INBOX")),
+ qPrintable(QStringLiteral("expected work/INBOX, got: %1")
+ .arg(found.join(QStringLiteral(", ")))));
+ QVERIFY2(!found.contains(QStringLiteral("xapian")),
+ "the index's own directory was listed as a mail folder");
+}
+
QTEST_MAIN(TestNotmuchWorker)
#include "test_notmuchworker.moc"