aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/CMakeLists.txt1
-rw-r--r--src/notmuchworker.cpp316
-rw-r--r--src/notmuchworker.h69
-rw-r--r--tests/CMakeLists.txt1
-rw-r--r--tests/notmuchfixture.h114
-rw-r--r--tests/test_notmuchworker.cpp419
6 files changed, 920 insertions, 0 deletions
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index fc368cb..5198429 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -5,6 +5,7 @@ add_library(qtmaildir_lib STATIC
requestinterceptor.cpp
htmlbuilder.cpp
cidschemehandler.cpp
+ notmuchworker.cpp
)
target_include_directories(qtmaildir_lib
diff --git a/src/notmuchworker.cpp b/src/notmuchworker.cpp
new file mode 100644
index 0000000..ae4cafe
--- /dev/null
+++ b/src/notmuchworker.cpp
@@ -0,0 +1,316 @@
+#include "notmuchworker.h"
+
+#include <notmuch.h>
+
+#include <QSet>
+
+#include <cstdlib>
+
+#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;
+}
+
+/// Collects the message ids a query matches. Returns false if the query could
+/// not be run at all, which is different from a query that matched nothing.
+bool collectMessageIds(notmuch_database_t *db, const QString &query,
+ QStringList *ids)
+{
+ NmQuery nmQuery(notmuch_query_create(db, query.toUtf8().constData()));
+ if (!nmQuery)
+ return false;
+
+ notmuch_messages_t *raw = nullptr;
+ if (notmuch_query_search_messages(nmQuery.get(), &raw) != NOTMUCH_STATUS_SUCCESS)
+ return false;
+
+ NmMessages messages(raw);
+ for (; notmuch_messages_valid(messages.get());
+ notmuch_messages_move_to_next(messages.get())) {
+ NmMessage message(notmuch_messages_get(messages.get()));
+ if (message)
+ ids->append(QString::fromUtf8(notmuch_message_get_message_id(message.get())));
+ }
+ return true;
+}
+
+} // namespace
+
+NotmuchWorker::NotmuchWorker(const QString &notmuchConfigPath, QObject *parent)
+ : QObject(parent), m_configPath(notmuchConfigPath)
+{
+}
+
+NotmuchWorker::~NotmuchWorker()
+{
+ close();
+}
+
+/// An empty config path means "let notmuch resolve its own config", which
+/// libnotmuch spells as NULL. The QByteArray is returned by value so callers
+/// keep it alive for as long as they use constData().
+QByteArray NotmuchWorker::configPathArg() const
+{
+ return m_configPath.isEmpty() ? QByteArray() : m_configPath.toLocal8Bit();
+}
+
+bool NotmuchWorker::openReadOnly()
+{
+ if (m_db)
+ return true;
+
+ const QByteArray configPath = configPathArg();
+ char *error = nullptr;
+ const notmuch_status_t status = notmuch_database_open_with_config(
+ nullptr, // let config decide path
+ NOTMUCH_DATABASE_MODE_READ_ONLY,
+ configPath.isEmpty() ? nullptr : configPath.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,
+ const QString &matchQuery,
+ quint64 generation)
+{
+ if (!openReadOnly())
+ return;
+
+ // Which messages of the thread matched the user's query. Running the query
+ // intersected with the thread is cheaper than testing each message.
+ //
+ // haveMatchSet distinguishes "no query was given, so everything counts as
+ // matched" from "a query was given and matched nothing in this thread".
+ // Collapsing those would render a whole thread expanded precisely when the
+ // user filtered it down to nothing.
+ QSet<QString> matchedIds;
+ bool haveMatchSet = false;
+ if (!matchQuery.trimmed().isEmpty()) {
+ const QString intersect =
+ QStringLiteral("thread:%1 and (%2)").arg(threadId, matchQuery);
+ QStringList ids;
+ if (collectMessageIds(m_db, intersect, &ids)) {
+ matchedIds = QSet<QString>(ids.begin(), ids.end());
+ haveMatchSet = true;
+ }
+ }
+
+ 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());
+ ref.matched = !haveMatchSet || matchedIds.contains(ref.messageId);
+ result.append(ref);
+ }
+
+ emit threadLoaded(result, generation);
+}
+
+void NotmuchWorker::applyTagsToThreads(const QStringList &threadIds,
+ const QStringList &add,
+ const QStringList &remove,
+ const QString &description)
+{
+ if (threadIds.isEmpty())
+ return;
+
+ if (!openReadOnly())
+ return;
+
+ // Resolve every thread to its message ids in ONE query. Issuing a query per
+ // thread would reopen the same Xapian cursor hundreds of times on a large
+ // selection.
+ QStringList terms;
+ terms.reserve(threadIds.size());
+ for (const QString &id : threadIds)
+ terms.append(QStringLiteral("thread:%1").arg(id));
+
+ QStringList messageIds;
+ if (!collectMessageIds(m_db, terms.join(QStringLiteral(" or ")), &messageIds)) {
+ emit errorOccurred(QStringLiteral("Cannot resolve selected threads"));
+ return;
+ }
+
+ if (messageIds.isEmpty()) {
+ emit errorOccurred(QStringLiteral("Selected threads contain no messages"));
+ return;
+ }
+
+ applyTags(TagChange{ messageIds, add, remove, description });
+}
+
+void NotmuchWorker::applyTags(const TagChange &change)
+{
+ if (change.messageIds.isEmpty())
+ return;
+
+ // The read-only handle must be closed first: notmuch allows only one open
+ // handle per process.
+ close();
+
+ const QByteArray configPath = configPathArg();
+ notmuch_database_t *db = nullptr;
+ char *error = nullptr;
+ const notmuch_status_t status = notmuch_database_open_with_config(
+ nullptr,
+ NOTMUCH_DATABASE_MODE_READ_WRITE,
+ configPath.isEmpty() ? nullptr : configPath.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;
+ // find_message reports SUCCESS with a null message when the id is not
+ // in the database, so both have to be checked. A stale id must not
+ // abort the batch: the live ids alongside it still need tagging.
+ 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());
+
+ // Renames the file on disk when the seen/flagged tags changed, keeping
+ // the Maildir and the index in agreement for the next `notmuch new`.
+ notmuch_message_tags_to_maildir_flags(message.get());
+ }
+
+ notmuch_database_close(db);
+ notmuch_database_destroy(db);
+
+ emit tagsApplied(change);
+}
diff --git a/src/notmuchworker.h b/src/notmuchworker.h
new file mode 100644
index 0000000..2551004
--- /dev/null
+++ b/src/notmuchworker.h
@@ -0,0 +1,69 @@
+#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 &notmuchConfigPath, 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. matchQuery is the
+ /// user's current query; messages matching it render expanded, the rest
+ /// as stubs.
+ void loadThread(const QString &threadId, const QString &matchQuery,
+ 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);
+
+ /// Batch tagging over whole threads. The UI holds thread ids, not message
+ /// ids, for rows it has not opened, so the resolution happens here where
+ /// the database handle lives. This is the path the archive/flag/delete
+ /// actions use on a multi-row selection.
+ void applyTagsToThreads(const QStringList &threadIds,
+ const QStringList &add,
+ const QStringList &remove,
+ const QString &description);
+
+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();
+
+ QByteArray configPathArg() const;
+
+ QString m_configPath;
+ notmuch_database_t *m_db = nullptr;
+};
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index d0faeec..a24a60b 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -12,3 +12,4 @@ target_compile_definitions(test_mimeparser PRIVATE
FIXTURE_DIR="${CMAKE_CURRENT_SOURCE_DIR}/fixtures")
add_qtmaildir_test(interceptor)
add_qtmaildir_test(htmlbuilder)
+add_qtmaildir_test(notmuchworker)
diff --git a/tests/notmuchfixture.h b/tests/notmuchfixture.h
new file mode 100644
index 0000000..b7287f4
--- /dev/null
+++ b/tests/notmuchfixture.h
@@ -0,0 +1,114 @@
+#pragma once
+
+#include <QDir>
+#include <QFile>
+#include <QProcess>
+#include <QString>
+#include <QTemporaryDir>
+#include <QTextStream>
+
+/// A throwaway notmuch database in a temporary directory.
+///
+/// Builds a Maildir tree, writes a notmuch config pointing at it, and runs
+/// `notmuch new`. Nothing touches the developer's own ~/Mail or
+/// ~/.notmuch-config: the config path is handed to the worker explicitly.
+///
+/// Maildir flags are not decoration. notmuch synchronizes them with tags at
+/// index time, so a file named `...:2,S` ("seen") comes out WITHOUT the unread
+/// tag no matter what [new] tags says. addMessage() takes the unread state and
+/// picks the filename accordingly.
+class NotmuchFixture
+{
+public:
+ /// True when the temporary tree was created. Check before use.
+ bool isValid() const { return m_dir.isValid(); }
+
+ QString configPath() const { return m_dir.filePath(QStringLiteral("config")); }
+ QString maildirPath() const { return m_dir.filePath(QStringLiteral("mail")); }
+
+ /// Writes one message into <folder>/cur (or new/ when unread).
+ ///
+ /// Returns false if the file could not be written. Call index() afterwards.
+ bool addMessage(const QString &folder, const QString &messageId,
+ const QString &subject, const QString &from,
+ const QString &date, const QString &body,
+ bool unread = true, const QString &inReplyTo = QString())
+ {
+ // Unread messages must not carry the maildir "S" flag, so they go to
+ // new/ where no flags exist at all.
+ const QString sub = unread ? QStringLiteral("new") : QStringLiteral("cur");
+ const QString dirPath = maildirPath() + QLatin1Char('/') + folder;
+ QDir dir;
+ if (!dir.mkpath(dirPath + QStringLiteral("/cur"))
+ || !dir.mkpath(dirPath + QStringLiteral("/new"))
+ || !dir.mkpath(dirPath + QStringLiteral("/tmp"))) {
+ return false;
+ }
+
+ // The local part of the id makes a safe, unique, flag-free filename.
+ QString base = messageId;
+ base.remove(QLatin1Char('<')).remove(QLatin1Char('>'));
+ base.replace(QLatin1Char('@'), QLatin1Char('.'));
+ base.replace(QLatin1Char('/'), QLatin1Char('.'));
+ if (!unread)
+ base += QStringLiteral(":2,S");
+
+ QFile file(dirPath + QLatin1Char('/') + sub + QLatin1Char('/') + base);
+ if (!file.open(QIODevice::WriteOnly | QIODevice::Text))
+ return false;
+
+ QTextStream out(&file);
+ out << "From: " << from << "\n"
+ << "To: danix@danix.xyz\n"
+ << "Subject: " << subject << "\n"
+ << "Message-ID: <" << messageId << ">\n"
+ << "Date: " << date << "\n";
+ if (!inReplyTo.isEmpty())
+ out << "In-Reply-To: <" << inReplyTo << ">\n"
+ << "References: <" << inReplyTo << ">\n";
+ out << "\n" << body << "\n";
+ out.flush();
+ file.close();
+ return true;
+ }
+
+ /// Writes the config and runs `notmuch new`. Safe to call repeatedly.
+ /// Returns false (with error() set) if notmuch is missing or fails.
+ bool index()
+ {
+ QFile config(configPath());
+ if (!config.open(QIODevice::WriteOnly | QIODevice::Text)) {
+ m_error = QStringLiteral("cannot write fixture config");
+ return false;
+ }
+ QTextStream out(&config);
+ out << "[database]\n"
+ << "path=" << maildirPath() << "\n"
+ << "[new]\n"
+ << "tags=unread;inbox;\n";
+ out.flush();
+ config.close();
+
+ QProcess proc;
+ QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
+ env.insert(QStringLiteral("NOTMUCH_CONFIG"), configPath());
+ proc.setProcessEnvironment(env);
+ proc.start(QStringLiteral("notmuch"), { QStringLiteral("new") });
+ if (!proc.waitForStarted(5000)) {
+ m_error = QStringLiteral("notmuch not found on PATH");
+ return false;
+ }
+ if (!proc.waitForFinished(30000) || proc.exitCode() != 0) {
+ m_error = QStringLiteral("notmuch new failed: %1")
+ .arg(QString::fromLocal8Bit(proc.readAllStandardError()));
+ return false;
+ }
+ return true;
+ }
+
+ QString error() const { return m_error; }
+
+private:
+ QTemporaryDir m_dir;
+ QString m_error;
+};
diff --git a/tests/test_notmuchworker.cpp b/tests/test_notmuchworker.cpp
new file mode 100644
index 0000000..e31dfcc
--- /dev/null
+++ b/tests/test_notmuchworker.cpp
@@ -0,0 +1,419 @@
+#include <QSignalSpy>
+#include <QtTest>
+
+#include "notmuchfixture.h"
+#include "notmuchworker.h"
+#include "types.h"
+
+/// NotmuchWorker against a throwaway database. This is the only code in the
+/// project that writes to a notmuch index, so applyTags gets the most
+/// attention: a bug there corrupts real mail state.
+class TestNotmuchWorker : public QObject
+{
+ Q_OBJECT
+
+private slots:
+ void initTestCase();
+
+ void queryReturnsAllThreads();
+ void queryFiltersByTag();
+ void queryReportsThreadMetadata();
+ void malformedQueryYieldsNoThreads();
+ void unreadableConfigEmitsError();
+ void queryPassesGenerationThrough();
+
+ void loadThreadReturnsMessagesOldestFirst();
+ void loadThreadMarksMatchedMessages();
+ void loadThreadWithEmptyQueryMatchesEverything();
+ void loadThreadWithNonMatchingQueryMatchesNothing();
+
+ void applyTagsAddsAndRemoves();
+ void applyTagsEmitsTheChange();
+ void applyTagsIgnoresUnknownMessageIds();
+ void applyTagsWithNoIdsDoesNothing();
+ void queryStillWorksAfterWrite();
+
+ void applyTagsToThreadsTagsEveryMessage();
+ void applyTagsToThreadsSpansMultipleThreads();
+ void applyTagsToThreadsWithNoThreadsDoesNothing();
+
+private:
+ /// Tags of one message, read back through a fresh worker query.
+ QStringList tagsOf(const QString &messageId);
+ QVector<MessageRef> messagesOfThread(const QString &threadId,
+ const QString &matchQuery = QString());
+ QVector<ThreadSummary> runQuery(const QString &query);
+ QString threadIdOf(const QString &subject);
+
+ NotmuchFixture m_fixture;
+};
+
+void TestNotmuchWorker::initTestCase()
+{
+ QVERIFY(m_fixture.isValid());
+
+ // Thread A: two messages, a reply. Both read.
+ QVERIFY(m_fixture.addMessage(QStringLiteral("inbox"), QStringLiteral("a1@example.org"),
+ QStringLiteral("Release notes"),
+ QStringLiteral("Alice <alice@example.org>"),
+ QStringLiteral("Mon, 1 Jun 2026 10:00:00 +0000"),
+ QStringLiteral("first message"), false));
+ QVERIFY(m_fixture.addMessage(QStringLiteral("inbox"), QStringLiteral("a2@example.org"),
+ QStringLiteral("Re: Release notes"),
+ QStringLiteral("Bob <bob@example.org>"),
+ QStringLiteral("Tue, 2 Jun 2026 10:00:00 +0000"),
+ QStringLiteral("second message with hamsterwheel"), false,
+ QStringLiteral("a1@example.org")));
+
+ // Thread B: one unread message.
+ QVERIFY(m_fixture.addMessage(QStringLiteral("inbox"), QStringLiteral("b1@example.org"),
+ QStringLiteral("Newsletter"),
+ QStringLiteral("Carol <carol@example.org>"),
+ QStringLiteral("Wed, 3 Jun 2026 10:00:00 +0000"),
+ QStringLiteral("third message")));
+
+ // Thread C: in a different folder, for path-scoped queries.
+ QVERIFY(m_fixture.addMessage(QStringLiteral("archive"), QStringLiteral("c1@example.org"),
+ QStringLiteral("Old thing"),
+ QStringLiteral("Dave <dave@example.org>"),
+ QStringLiteral("Thu, 4 Jun 2026 10:00:00 +0000"),
+ QStringLiteral("fourth message"), false));
+
+ QVERIFY2(m_fixture.index(), qPrintable(m_fixture.error()));
+}
+
+QVector<ThreadSummary> TestNotmuchWorker::runQuery(const QString &query)
+{
+ NotmuchWorker worker(m_fixture.configPath());
+ QSignalSpy ready(&worker, &NotmuchWorker::threadsReady);
+ QSignalSpy finished(&worker, &NotmuchWorker::queryFinished);
+
+ worker.runQuery(query, 1);
+
+ QVector<ThreadSummary> all;
+ for (const QList<QVariant> &args : ready)
+ all += args.at(0).value<QVector<ThreadSummary>>();
+ return all;
+}
+
+QString TestNotmuchWorker::threadIdOf(const QString &subject)
+{
+ const QVector<ThreadSummary> threads = runQuery(QStringLiteral("*"));
+ for (const ThreadSummary &t : threads) {
+ if (t.subject == subject)
+ return t.threadId;
+ }
+ return QString();
+}
+
+QVector<MessageRef> TestNotmuchWorker::messagesOfThread(const QString &threadId,
+ const QString &matchQuery)
+{
+ NotmuchWorker worker(m_fixture.configPath());
+ QSignalSpy loaded(&worker, &NotmuchWorker::threadLoaded);
+ worker.loadThread(threadId, matchQuery, 1);
+ if (loaded.isEmpty())
+ return {};
+ return loaded.first().at(0).value<QVector<MessageRef>>();
+}
+
+QStringList TestNotmuchWorker::tagsOf(const QString &messageId)
+{
+ NotmuchWorker worker(m_fixture.configPath());
+ QSignalSpy loaded(&worker, &NotmuchWorker::threadLoaded);
+ worker.loadThread(QStringLiteral("{id:%1}").arg(messageId), QString(), 1);
+ if (loaded.isEmpty())
+ return {};
+ const auto messages = loaded.first().at(0).value<QVector<MessageRef>>();
+ for (const MessageRef &m : messages) {
+ if (m.messageId == messageId)
+ return m.tags;
+ }
+ return {};
+}
+
+void TestNotmuchWorker::queryReturnsAllThreads()
+{
+ const QVector<ThreadSummary> threads = runQuery(QStringLiteral("*"));
+ QCOMPARE(threads.size(), 3);
+}
+
+void TestNotmuchWorker::queryFiltersByTag()
+{
+ const QVector<ThreadSummary> unread = runQuery(QStringLiteral("tag:unread"));
+ QCOMPARE(unread.size(), 1);
+ QCOMPARE(unread.first().subject, QStringLiteral("Newsletter"));
+ QVERIFY(unread.first().isUnread());
+}
+
+void TestNotmuchWorker::queryReportsThreadMetadata()
+{
+ const QVector<ThreadSummary> threads = runQuery(QStringLiteral("subject:\"Release notes\""));
+ QCOMPARE(threads.size(), 1);
+
+ const ThreadSummary &t = threads.first();
+ QVERIFY(!t.threadId.isEmpty());
+ QCOMPARE(t.subject, QStringLiteral("Release notes"));
+ QVERIFY(t.authors.contains(QStringLiteral("Alice")));
+ QCOMPARE(t.totalCount, 2);
+ QVERIFY(t.date.isValid());
+ QVERIFY(t.tags.contains(QStringLiteral("inbox")));
+}
+
+void TestNotmuchWorker::malformedQueryYieldsNoThreads()
+{
+ NotmuchWorker worker(m_fixture.configPath());
+ QSignalSpy ready(&worker, &NotmuchWorker::threadsReady);
+ QSignalSpy finished(&worker, &NotmuchWorker::queryFinished);
+ QSignalSpy errors(&worker, &NotmuchWorker::errorOccurred);
+
+ // notmuch's query parser is lenient: an unbalanced quote is accepted and
+ // simply matches nothing, rather than failing. Verified against notmuch
+ // 0.39, which exits 0 on this query. So the contract here is "no threads,
+ // no error, one queryFinished with zero" — not an error path.
+ worker.runQuery(QStringLiteral("subject:\"unterminated"), 1);
+
+ QVERIFY(ready.isEmpty());
+ QVERIFY(errors.isEmpty());
+ QCOMPARE(finished.size(), 1);
+ QCOMPARE(finished.first().at(0).toInt(), 0);
+}
+
+void TestNotmuchWorker::unreadableConfigEmitsError()
+{
+ // Fails closed: a bad config path must report an error, never silently
+ // fall through to the user's real database.
+ NotmuchWorker worker(QStringLiteral("/nonexistent/qtmaildir-test/config"));
+ QSignalSpy ready(&worker, &NotmuchWorker::threadsReady);
+ QSignalSpy errors(&worker, &NotmuchWorker::errorOccurred);
+
+ worker.runQuery(QStringLiteral("*"), 1);
+
+ QCOMPARE(errors.size(), 1);
+ QVERIFY(ready.isEmpty());
+}
+
+void TestNotmuchWorker::queryPassesGenerationThrough()
+{
+ NotmuchWorker worker(m_fixture.configPath());
+ QSignalSpy ready(&worker, &NotmuchWorker::threadsReady);
+ QSignalSpy finished(&worker, &NotmuchWorker::queryFinished);
+
+ worker.runQuery(QStringLiteral("*"), 42);
+
+ QCOMPARE(ready.size(), 1);
+ QCOMPARE(ready.first().at(1).value<quint64>(), quint64(42));
+ QCOMPARE(finished.size(), 1);
+ QCOMPARE(finished.first().at(0).toInt(), 3);
+ QCOMPARE(finished.first().at(1).value<quint64>(), quint64(42));
+}
+
+void TestNotmuchWorker::loadThreadReturnsMessagesOldestFirst()
+{
+ const QString threadId = threadIdOf(QStringLiteral("Release notes"));
+ QVERIFY(!threadId.isEmpty());
+
+ const QVector<MessageRef> messages = messagesOfThread(threadId);
+ QCOMPARE(messages.size(), 2);
+ QCOMPARE(messages.at(0).messageId, QStringLiteral("a1@example.org"));
+ QCOMPARE(messages.at(1).messageId, QStringLiteral("a2@example.org"));
+ QVERIFY(QFile::exists(messages.at(0).filePath));
+ QVERIFY(messages.at(0).tags.contains(QStringLiteral("inbox")));
+}
+
+void TestNotmuchWorker::loadThreadMarksMatchedMessages()
+{
+ const QString threadId = threadIdOf(QStringLiteral("Release notes"));
+ QVERIFY(!threadId.isEmpty());
+
+ // Only the second message contains this word.
+ const QVector<MessageRef> messages =
+ messagesOfThread(threadId, QStringLiteral("hamsterwheel"));
+ QCOMPARE(messages.size(), 2);
+ QVERIFY(!messages.at(0).matched);
+ QVERIFY(messages.at(1).matched);
+}
+
+void TestNotmuchWorker::loadThreadWithEmptyQueryMatchesEverything()
+{
+ const QString threadId = threadIdOf(QStringLiteral("Release notes"));
+ const QVector<MessageRef> messages = messagesOfThread(threadId, QString());
+ QCOMPARE(messages.size(), 2);
+ for (const MessageRef &m : messages)
+ QVERIFY(m.matched);
+}
+
+void TestNotmuchWorker::loadThreadWithNonMatchingQueryMatchesNothing()
+{
+ const QString threadId = threadIdOf(QStringLiteral("Release notes"));
+
+ // A real query that matches nothing in this thread must mark every message
+ // unmatched. Treating "no matches" as "everything matched" would render a
+ // whole thread expanded when the user filtered it down to nothing.
+ const QVector<MessageRef> messages =
+ messagesOfThread(threadId, QStringLiteral("tag:thistagdoesnotexist"));
+ QCOMPARE(messages.size(), 2);
+ for (const MessageRef &m : messages)
+ QVERIFY(!m.matched);
+}
+
+void TestNotmuchWorker::applyTagsAddsAndRemoves()
+{
+ NotmuchWorker worker(m_fixture.configPath());
+ QSignalSpy errors(&worker, &NotmuchWorker::errorOccurred);
+
+ const TagChange change{ { QStringLiteral("a1@example.org") },
+ { QStringLiteral("flagged") },
+ { QStringLiteral("inbox") },
+ QStringLiteral("Flag and archive") };
+ worker.applyTags(change);
+ QVERIFY2(errors.isEmpty(), qPrintable(errors.value(0).value(0).toString()));
+
+ const QStringList tags = tagsOf(QStringLiteral("a1@example.org"));
+ QVERIFY(tags.contains(QStringLiteral("flagged")));
+ QVERIFY(!tags.contains(QStringLiteral("inbox")));
+
+ // Put it back so later tests see the original state.
+ NotmuchWorker restore(m_fixture.configPath());
+ restore.applyTags(change.inverted());
+ const QStringList back = tagsOf(QStringLiteral("a1@example.org"));
+ QVERIFY(back.contains(QStringLiteral("inbox")));
+ QVERIFY(!back.contains(QStringLiteral("flagged")));
+}
+
+void TestNotmuchWorker::applyTagsEmitsTheChange()
+{
+ NotmuchWorker worker(m_fixture.configPath());
+ QSignalSpy applied(&worker, &NotmuchWorker::tagsApplied);
+
+ const TagChange change{ { QStringLiteral("b1@example.org") },
+ { QStringLiteral("testtag") },
+ {},
+ QStringLiteral("Add testtag") };
+ worker.applyTags(change);
+
+ QCOMPARE(applied.size(), 1);
+ const TagChange emitted = applied.first().at(0).value<TagChange>();
+ QCOMPARE(emitted.messageIds, change.messageIds);
+ QCOMPARE(emitted.added, change.added);
+ QCOMPARE(emitted.description, change.description);
+
+ NotmuchWorker restore(m_fixture.configPath());
+ restore.applyTags(change.inverted());
+}
+
+void TestNotmuchWorker::applyTagsIgnoresUnknownMessageIds()
+{
+ NotmuchWorker worker(m_fixture.configPath());
+ QSignalSpy applied(&worker, &NotmuchWorker::tagsApplied);
+
+ // A stale id from a since-deleted message must not abort the whole batch:
+ // the real ids alongside it still need tagging.
+ const TagChange change{ { QStringLiteral("nosuchmessage@example.org"),
+ QStringLiteral("b1@example.org") },
+ { QStringLiteral("survivor") },
+ {},
+ QStringLiteral("Partially stale batch") };
+ worker.applyTags(change);
+
+ QCOMPARE(applied.size(), 1);
+ QVERIFY(tagsOf(QStringLiteral("b1@example.org")).contains(QStringLiteral("survivor")));
+
+ NotmuchWorker restore(m_fixture.configPath());
+ restore.applyTags(change.inverted());
+}
+
+void TestNotmuchWorker::applyTagsWithNoIdsDoesNothing()
+{
+ NotmuchWorker worker(m_fixture.configPath());
+ QSignalSpy applied(&worker, &NotmuchWorker::tagsApplied);
+ QSignalSpy errors(&worker, &NotmuchWorker::errorOccurred);
+
+ worker.applyTags(TagChange{ {}, { QStringLiteral("x") }, {}, QStringLiteral("Nothing") });
+
+ QVERIFY(applied.isEmpty());
+ QVERIFY(errors.isEmpty());
+}
+
+void TestNotmuchWorker::queryStillWorksAfterWrite()
+{
+ // applyTags closes the read-only handle to take the write lock. The same
+ // worker must be able to query again afterwards.
+ NotmuchWorker worker(m_fixture.configPath());
+ QSignalSpy ready(&worker, &NotmuchWorker::threadsReady);
+
+ worker.runQuery(QStringLiteral("*"), 1);
+ QCOMPARE(ready.size(), 1);
+
+ const TagChange change{ { QStringLiteral("b1@example.org") },
+ { QStringLiteral("roundtrip") },
+ {},
+ QStringLiteral("Round trip") };
+ worker.applyTags(change);
+
+ worker.runQuery(QStringLiteral("*"), 2);
+ QCOMPARE(ready.size(), 2);
+ QCOMPARE(ready.at(1).at(0).value<QVector<ThreadSummary>>().size(), 3);
+
+ worker.applyTags(change.inverted());
+}
+
+void TestNotmuchWorker::applyTagsToThreadsTagsEveryMessage()
+{
+ const QString threadId = threadIdOf(QStringLiteral("Release notes"));
+ QVERIFY(!threadId.isEmpty());
+
+ NotmuchWorker worker(m_fixture.configPath());
+ QSignalSpy applied(&worker, &NotmuchWorker::tagsApplied);
+
+ worker.applyTagsToThreads({ threadId }, { QStringLiteral("batched") }, {},
+ QStringLiteral("Batch tag"));
+
+ QCOMPARE(applied.size(), 1);
+ // Both messages of the thread, resolved by the worker, not by the caller.
+ const TagChange emitted = applied.first().at(0).value<TagChange>();
+ QCOMPARE(emitted.messageIds.size(), 2);
+ QVERIFY(tagsOf(QStringLiteral("a1@example.org")).contains(QStringLiteral("batched")));
+ QVERIFY(tagsOf(QStringLiteral("a2@example.org")).contains(QStringLiteral("batched")));
+
+ NotmuchWorker restore(m_fixture.configPath());
+ restore.applyTags(emitted.inverted());
+}
+
+void TestNotmuchWorker::applyTagsToThreadsSpansMultipleThreads()
+{
+ const QString threadA = threadIdOf(QStringLiteral("Release notes"));
+ const QString threadB = threadIdOf(QStringLiteral("Newsletter"));
+ QVERIFY(!threadA.isEmpty());
+ QVERIFY(!threadB.isEmpty());
+
+ NotmuchWorker worker(m_fixture.configPath());
+ QSignalSpy applied(&worker, &NotmuchWorker::tagsApplied);
+
+ worker.applyTagsToThreads({ threadA, threadB }, { QStringLiteral("multi") }, {},
+ QStringLiteral("Multi-thread tag"));
+
+ QCOMPARE(applied.size(), 1);
+ const TagChange emitted = applied.first().at(0).value<TagChange>();
+ QCOMPARE(emitted.messageIds.size(), 3);
+ QVERIFY(tagsOf(QStringLiteral("a1@example.org")).contains(QStringLiteral("multi")));
+ QVERIFY(tagsOf(QStringLiteral("b1@example.org")).contains(QStringLiteral("multi")));
+
+ NotmuchWorker restore(m_fixture.configPath());
+ restore.applyTags(emitted.inverted());
+}
+
+void TestNotmuchWorker::applyTagsToThreadsWithNoThreadsDoesNothing()
+{
+ NotmuchWorker worker(m_fixture.configPath());
+ QSignalSpy applied(&worker, &NotmuchWorker::tagsApplied);
+ QSignalSpy errors(&worker, &NotmuchWorker::errorOccurred);
+
+ worker.applyTagsToThreads({}, { QStringLiteral("x") }, {}, QStringLiteral("Nothing"));
+
+ QVERIFY(applied.isEmpty());
+ QVERIFY(errors.isEmpty());
+}
+
+QTEST_MAIN(TestNotmuchWorker)
+#include "test_notmuchworker.moc"