aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/mainwindow.cpp27
-rw-r--r--src/notmuchworker.cpp37
-rw-r--r--src/notmuchworker.h17
-rw-r--r--tests/test_notmuchworker.cpp77
4 files changed, 148 insertions, 10 deletions
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index f622036..e8b4dda 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -1314,18 +1314,16 @@ void MainWindow::showTagRulesDialog()
auto *dialog = new TagRulesDialog(this);
dialog->setAttribute(Qt::WA_DeleteOnClose);
- // The Folder row's dropdown. Config holds the account subdirectories and
- // the dialog holds no Config, so they are handed over here. Account::maildir
- // is the subtree name the Folder term compiles a path: against; the account
- // key is a config section name and would match nothing.
- QStringList folders;
- for (const Account &account : m_config.accounts()) {
- if (!account.maildir.isEmpty() && !folders.contains(account.maildir))
- folders.append(account.maildir);
- }
- dialog->setFolders(folders);
m_tagRulesDialog = dialog;
+ // The Folder row's dropdown, filled from the Maildir tree on disk rather
+ // than from config. Config names one subtree per account and nothing
+ // below it, so the dropdown offered five entries and no way to say Drafts
+ // or Sent, which is a folder a rule wants to target as often as a whole
+ // account. The answer comes back queued, after the dialog is already up;
+ // setFolders refills the rows that exist by then.
+ QMetaObject::invokeMethod(m_worker, "requestFolders", Qt::QueuedConnection);
+
connect(dialog, &TagRulesDialog::countsRequested, this, [this, dialog]() {
QMetaObject::invokeMethod(
m_worker, "requestMessageCounts", Qt::QueuedConnection,
@@ -1425,6 +1423,15 @@ void MainWindow::wireWorker()
connect(m_worker, &NotmuchWorker::messageCountsReady,
this, &MainWindow::onRuleCountsReady);
+ // The rules dialog is the only consumer, and it may have been closed while
+ // the scan was in flight. No generation counter: the tree on disk does not
+ // change under a query, so a late answer is still the right one.
+ connect(m_worker, &NotmuchWorker::foldersReady, this,
+ [this](const QStringList &folders) {
+ if (m_tagRulesDialog)
+ m_tagRulesDialog->setFolders(folders);
+ });
+
// A confirmed write clears the pending revert: without this, a later
// unrelated error would roll back a change that actually succeeded.
connect(m_worker, &NotmuchWorker::tagsApplied,
diff --git a/src/notmuchworker.cpp b/src/notmuchworker.cpp
index 94fb79c..6aae397 100644
--- a/src/notmuchworker.cpp
+++ b/src/notmuchworker.cpp
@@ -20,6 +20,9 @@
#include <notmuch.h>
+#include <QDir>
+#include <QDirIterator>
+#include <QFileInfo>
#include <QSet>
#include <cstdlib>
@@ -678,3 +681,37 @@ void NotmuchWorker::requestMessageCounts(const QStringList &queries,
emit messageCountsReady(counts, generation);
}
+
+void NotmuchWorker::requestFolders()
+{
+ if (!openReadOnly())
+ return;
+
+ const QString root = QString::fromUtf8(notmuch_database_get_path(m_db));
+ if (root.isEmpty()) {
+ emit errorOccurred(
+ QStringLiteral("notmuch reports no database path."));
+ return;
+ }
+
+ // A Maildir folder is a directory holding cur/. Testing for that rather
+ // than listing every directory keeps the plumbing (cur, new, tmp) and an
+ // account's container directory out of the list; neither is somewhere mail
+ // is filed. Hidden directories are skipped, which is what excludes
+ // .notmuch itself.
+ QStringList folders;
+ QDirIterator it(root, QDir::Dirs | QDir::NoDotAndDotDot,
+ QDirIterator::Subdirectories);
+ const QDir rootDir(root);
+ while (it.hasNext()) {
+ const QString path = it.next();
+ if (!QFileInfo::exists(path + QStringLiteral("/cur")))
+ continue;
+ folders.append(rootDir.relativeFilePath(path));
+ }
+
+ // Sorted, so the dropdown keeps one order across openings. QDirIterator
+ // walks in filesystem order, which is neither stable nor alphabetical.
+ folders.sort();
+ emit foldersReady(folders);
+}
diff --git a/src/notmuchworker.h b/src/notmuchworker.h
index b3fbed3..9736ab8 100644
--- a/src/notmuchworker.h
+++ b/src/notmuchworker.h
@@ -151,6 +151,18 @@ public slots:
/// called when the dialog is opened and never on a timer.
void requestDatabaseStats(quint64 generation);
+ /// Every Maildir folder under the database root, as paths relative to it.
+ ///
+ /// From the DISK, not from the index: a folder mbsync created and nothing
+ /// has landed in yet is still a folder a tagging rule may target, and one
+ /// derived from indexed message paths would not offer it.
+ ///
+ /// Here rather than in MainWindow because the database root is
+ /// notmuch's `database.path` and this class owns the only handle that can
+ /// answer for it. Duplicating the path into config is exactly the second
+ /// source of truth the design refuses.
+ void requestFolders();
+
signals:
void threadsReady(const QVector<ThreadSummary> &threads, quint64 generation);
void queryFinished(int totalThreads, quint64 generation);
@@ -175,6 +187,11 @@ signals:
/// renders as unknown rather than as zero.
void databaseStatsReady(const DatabaseStats &stats, quint64 generation);
+ /// Maildir folders relative to the database root, sorted. No generation:
+ /// the tree on disk does not change under a query, and the one consumer
+ /// asks once when its dialog opens.
+ void foldersReady(const QStringList &folders);
+
void errorOccurred(const QString &message);
private:
diff --git a/tests/test_notmuchworker.cpp b/tests/test_notmuchworker.cpp
index 1db26c7..9068ca3 100644
--- a/tests/test_notmuchworker.cpp
+++ b/tests/test_notmuchworker.cpp
@@ -80,6 +80,9 @@ private slots:
void messageCountsCountMessagesNotThreads();
void messageCountsReportAnInvalidQueryAsMinusOne();
+ void requestFoldersListsEveryMaildirFolder();
+ void requestFoldersOnUnreadableConfigEmitsError();
+
private:
/// Tags of one message, read back through a fresh worker query.
QStringList tagsOf(const QString &messageId);
@@ -900,5 +903,79 @@ void TestNotmuchWorker::requestDatabaseStatsOnUnreadableConfigEmitsError()
QVERIFY(ready.isEmpty());
}
+void TestNotmuchWorker::requestFoldersListsEveryMaildirFolder()
+{
+ // Its own fixture rather than the shared one: this needs a NESTED folder,
+ // which is the shape a real account has (<account>/Drafts, not a flat
+ // "drafts"), and adding a message to the shared fixture would move seven
+ // count assertions in other tests for nothing.
+ NotmuchFixture fixture;
+ QVERIFY(fixture.isValid());
+ QVERIFY(fixture.addMessage(QStringLiteral("work/INBOX"),
+ QStringLiteral("g1@example.org"),
+ QStringLiteral("Something"),
+ QStringLiteral("Alice <alice@example.org>"),
+ QStringLiteral("Mon, 1 Jun 2026 10:00:00 +0000"),
+ QStringLiteral("body"), false));
+ QVERIFY(fixture.addMessage(QStringLiteral("work/Drafts"),
+ QStringLiteral("g2@example.org"),
+ QStringLiteral("Half written"),
+ QStringLiteral("You <you@example.org>"),
+ QStringLiteral("Tue, 2 Jun 2026 10:00:00 +0000"),
+ QStringLiteral("body"), false));
+ QVERIFY2(fixture.index(), qPrintable(fixture.error()));
+
+ // An EMPTY folder, created but never written to. mbsync makes these, and a
+ // list derived from indexed messages would not offer it. A rule may
+ // legitimately target a folder that has nothing in it yet.
+ QDir dir;
+ const QString empty = fixture.maildirPath() + QStringLiteral("/work/Archive");
+ QVERIFY(dir.mkpath(empty + QStringLiteral("/cur")));
+ QVERIFY(dir.mkpath(empty + QStringLiteral("/new")));
+ QVERIFY(dir.mkpath(empty + QStringLiteral("/tmp")));
+
+ NotmuchWorker worker(fixture.configPath());
+ QSignalSpy ready(&worker, &NotmuchWorker::foldersReady);
+
+ worker.requestFolders();
+
+ QCOMPARE(ready.count(), 1);
+ const QStringList folders = ready.first().at(0).toStringList();
+
+ // Paths relative to the database root, which is what a Folder term
+ // compiles a path: against. Drafts is the whole point of the item: the
+ // dialog used to offer one entry per account and nothing below it.
+ QVERIFY(folders.contains(QStringLiteral("work/INBOX")));
+ QVERIFY(folders.contains(QStringLiteral("work/Drafts")));
+ QVERIFY(folders.contains(QStringLiteral("work/Archive")));
+
+ // Not the maildir plumbing, which is not a folder anyone files mail into,
+ // and not the account directory itself, which holds no cur/.
+ QVERIFY(!folders.contains(QStringLiteral("work/INBOX/cur")));
+ QVERIFY(!folders.contains(QStringLiteral("work/INBOX/new")));
+ QVERIFY(!folders.contains(QStringLiteral("work")));
+
+ // Sorted, so the dropdown does not reorder itself between openings with
+ // the same tree on disk. QDir's own order is filesystem order.
+ QStringList sorted = folders;
+ sorted.sort();
+ QCOMPARE(folders, sorted);
+}
+
+void TestNotmuchWorker::requestFoldersOnUnreadableConfigEmitsError()
+{
+ // Fails closed like every other entry point. The dialog then leaves the
+ // dropdown as it was rather than emptying it, since an empty list reads as
+ // "this account has no folders".
+ NotmuchWorker worker(QStringLiteral("/nonexistent/qtmaildir-test/config"));
+ QSignalSpy ready(&worker, &NotmuchWorker::foldersReady);
+ QSignalSpy errors(&worker, &NotmuchWorker::errorOccurred);
+
+ worker.requestFolders();
+
+ QCOMPARE(errors.size(), 1);
+ QVERIFY(ready.isEmpty());
+}
+
QTEST_MAIN(TestNotmuchWorker)
#include "test_notmuchworker.moc"