aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-28 18:37:47 +0200
committerDanilo M. <danix@danix.xyz>2026-08-28 18:37:47 +0200
commit46acb489691e5298181ce52bfe847889ee06fe68 (patch)
treeb1dc608b415c2d6be5296e3a162fb65c37edf230
parent2f902ee6a92456a9951b077796d4ad8828f7f840 (diff)
downloadqtmaildir-46acb489691e5298181ce52bfe847889ee06fe68.tar.gz
qtmaildir-46acb489691e5298181ce52bfe847889ee06fe68.zip
feat: read a thread's digest from the index
Senders, unread messages and an activity histogram for the dashboard, as a plain value struct over a queued signal. Everything comes from the index, so no message file is opened; the unread list is capped and unreadTotal carries the real number.
-rw-r--r--src/notmuchworker.cpp200
-rw-r--r--src/notmuchworker.h34
-rw-r--r--tests/test_notmuchworker.cpp136
3 files changed, 370 insertions, 0 deletions
diff --git a/src/notmuchworker.cpp b/src/notmuchworker.cpp
index 75a4483..821c575 100644
--- a/src/notmuchworker.cpp
+++ b/src/notmuchworker.cpp
@@ -349,11 +349,21 @@ static const int kSortOrderMetaType =
static const int kSenderCountsMetaType =
qRegisterMetaType<QHash<QString, int>>("QHash<QString,int>");
+/// And the same for the dashboard's digest, which crosses the queued
+/// threadDigestLoaded connection. Here beside the signal that carries it
+/// rather than in MainWindow, for the reason SortOrder above records: the
+/// registration belongs to the type, and a caller that never builds a window
+/// still needs it. threaddigest.h is header-only, so this file is where a
+/// static initialiser for it can live.
+static const int kThreadDigestMetaType =
+ qRegisterMetaType<ThreadDigest>("ThreadDigest");
+
NotmuchWorker::NotmuchWorker(const QString &notmuchConfigPath, QObject *parent)
: QObject(parent), m_configPath(notmuchConfigPath)
{
Q_UNUSED(kSortOrderMetaType);
Q_UNUSED(kSenderCountsMetaType);
+ Q_UNUSED(kThreadDigestMetaType);
}
NotmuchWorker::~NotmuchWorker()
@@ -681,6 +691,196 @@ void NotmuchWorker::loadThreadTree(const QString &threadId,
emit threadTreeLoaded(nodes, generation);
}
+QString NotmuchWorker::threadIdForTesting(const QString &query)
+{
+ if (!openReadOnly())
+ return QString();
+
+ NmQuery nmQuery(notmuch_query_create(m_db, query.toUtf8().constData()));
+ if (!nmQuery)
+ return QString();
+
+ notmuch_threads_t *rawThreads = nullptr;
+ if (notmuch_query_search_threads(nmQuery.get(), &rawThreads)
+ != NOTMUCH_STATUS_SUCCESS) {
+ return QString();
+ }
+ NmThreads threads(rawThreads);
+ if (!notmuch_threads_valid(threads.get()))
+ return QString();
+
+ NmThread thread(notmuch_threads_get(threads.get()));
+ if (!thread)
+ return QString();
+ return QString::fromUtf8(notmuch_thread_get_thread_id(thread.get()));
+}
+
+void NotmuchWorker::loadThreadDigest(const QString &threadId,
+ quint64 generation)
+{
+ ThreadDigest digest;
+ digest.threadId = threadId;
+ // Always kBuckets entries, on every path out of here including the failure
+ // ones. The sparkline's geometry is written against a fixed count, so an
+ // empty vector from an unknown thread would be a second shape for the
+ // widget to handle rather than a thread with nothing in it.
+ digest.buckets.assign(ThreadDigest::kBuckets, 0);
+
+ if (!openReadOnly()) {
+ emit threadDigestLoaded(digest, generation);
+ return;
+ }
+
+ const QString query = QStringLiteral("thread:%1").arg(threadId);
+ NmQuery nmQuery(notmuch_query_create(m_db, query.toUtf8().constData()));
+ if (!nmQuery) {
+ emit threadDigestLoaded(digest, generation);
+ return;
+ }
+
+ notmuch_threads_t *rawThreads = nullptr;
+ if (notmuch_query_search_threads(nmQuery.get(), &rawThreads)
+ != NOTMUCH_STATUS_SUCCESS) {
+ emit threadDigestLoaded(digest, generation);
+ return;
+ }
+ NmThreads threads(rawThreads);
+ if (!notmuch_threads_valid(threads.get())) {
+ // An unknown thread id. Emitted rather than dropped, for the reason
+ // loadMessage() documents: a caller that arms state on the request
+ // and disarms it on the reply otherwise waits for ever.
+ emit threadDigestLoaded(digest, generation);
+ return;
+ }
+
+ // Held for the whole walk. Every message pointer below belongs to this
+ // thread and is freed with it (notmuch.h:1637), which is why none of them
+ // is wrapped in NmMessage: that would destroy memory the thread frees
+ // again. The same rule walkReplies follows.
+ NmThread thread(notmuch_threads_get(threads.get()));
+ if (!thread) {
+ emit threadDigestLoaded(digest, generation);
+ return;
+ }
+
+ // Grouped by the bare ADDRESS, labelled with the raw From header.
+ //
+ // The address is the identity: a sender whose display name varies between
+ // messages is still one participant, and the address is what the avatar
+ // hashes, so the dashboard's squircle matches the card's. The label keeps
+ // the display name, which is what a human reads.
+ QHash<QString, int> countByAddress;
+ QHash<QString, QString> labelByAddress;
+ QStringList addressOrder;
+
+ QVector<MessageNode> unread;
+ QVector<qint64> timestamps;
+
+ notmuch_messages_t *messages = notmuch_thread_get_messages(thread.get());
+ for (; notmuch_messages_valid(messages);
+ notmuch_messages_move_to_next(messages)) {
+
+ notmuch_message_t *message = notmuch_messages_get(messages);
+ if (!message)
+ continue;
+
+ ++digest.totalCount;
+
+ const qint64 when = notmuch_message_get_date(message);
+ timestamps.append(when);
+
+ const QString rawFrom =
+ QString::fromUtf8(notmuch_message_get_header(message, "from"));
+ QString address = senderAddressOf(message);
+ if (address.isEmpty())
+ address = rawFrom;
+ const QString key = address.toLower();
+ if (!countByAddress.contains(key)) {
+ addressOrder.append(key);
+ labelByAddress.insert(key, rawFrom.isEmpty() ? address : rawFrom);
+ }
+ countByAddress[key] += 1;
+
+ const QStringList tags = tagsOf(message);
+ if (!tags.contains(QStringLiteral("unread")))
+ continue;
+
+ ++digest.unreadTotal;
+
+ // MessageNode rather than MessageRef, because the dashboard draws
+ // these rows without opening the message. Every field here is served
+ // from the INDEX, so the struct's no-file-opening contract holds.
+ MessageNode node;
+ node.messageId =
+ QString::fromUtf8(notmuch_message_get_message_id(message));
+ node.threadId = threadId;
+ node.from = rawFrom;
+ node.senderAddress = address;
+ node.subject =
+ QString::fromUtf8(notmuch_message_get_header(message, "subject"));
+ node.date = QDateTime::fromSecsSinceEpoch(when);
+ node.tags = tags;
+ node.filePath =
+ QString::fromUtf8(notmuch_message_get_filename(message));
+ unread.append(node);
+ }
+
+ if (digest.totalCount == 0) {
+ emit threadDigestLoaded(digest, generation);
+ return;
+ }
+
+ std::sort(timestamps.begin(), timestamps.end());
+ digest.firstTimestamp = timestamps.first();
+ digest.lastTimestamp = timestamps.last();
+
+ // A zero-width span is the degenerate case: one message, or several sharing
+ // a timestamp. Dividing it into buckets is either a division by zero or a
+ // bucket width of zero that throws everything into the last slot, so the
+ // whole thread lands in bucket 0 instead, which is what "all of it happened
+ // at once" honestly looks like.
+ const qint64 span = digest.lastTimestamp - digest.firstTimestamp;
+ for (qint64 when : timestamps) {
+ int bucket = 0;
+ if (span > 0) {
+ bucket = static_cast<int>(((when - digest.firstTimestamp)
+ * ThreadDigest::kBuckets) / span);
+ bucket = qBound(0, bucket, ThreadDigest::kBuckets - 1);
+ }
+ digest.buckets[bucket] += 1;
+ }
+
+ digest.busiestBucket = 0;
+ for (int i = 1; i < ThreadDigest::kBuckets; ++i) {
+ if (digest.buckets.at(i) > digest.buckets.at(digest.busiestBucket))
+ digest.busiestBucket = i;
+ }
+
+ // Most prolific first, ties broken by the order they were met, so the
+ // list does not reshuffle between two openings of the same thread.
+ std::stable_sort(addressOrder.begin(), addressOrder.end(),
+ [&countByAddress](const QString &a, const QString &b) {
+ return countByAddress.value(a)
+ > countByAddress.value(b);
+ });
+ for (const QString &key : addressOrder) {
+ digest.senders.append(
+ qMakePair(labelByAddress.value(key), countByAddress.value(key)));
+ }
+
+ // Newest first, then capped. unreadTotal already carries the real number,
+ // so the cap costs nothing but the rows nobody would have read.
+ std::stable_sort(unread.begin(), unread.end(),
+ [](const MessageNode &a, const MessageNode &b) {
+ return a.date > b.date;
+ });
+ if (unread.size() > ThreadDigest::kUnreadShown)
+ unread.resize(ThreadDigest::kUnreadShown);
+ digest.unread = unread;
+
+ emit threadDigestLoaded(digest, generation);
+}
+
void NotmuchWorker::loadMessage(const QString &messageId, quint64 generation)
{
// Every failure below emits an EMPTY result as well as its error, and that
diff --git a/src/notmuchworker.h b/src/notmuchworker.h
index 8171f3c..a0c8feb 100644
--- a/src/notmuchworker.h
+++ b/src/notmuchworker.h
@@ -24,6 +24,7 @@
#include <QStringList>
#include <QVector>
+#include "threaddigest.h"
#include "types.h"
struct _notmuch_database;
@@ -110,6 +111,35 @@ public slots:
void loadThreadTree(const QString &threadId, const QString &matchQuery,
quint64 generation);
+ /// Everything the thread dashboard shows, from ONE walk of the index.
+ ///
+ /// Sender counts, the newest unread messages, and an activity histogram,
+ /// all served from the index: no message file is opened, which is the
+ /// contract ThreadDigest states and the reason this can run on every
+ /// selection. See threaddigest.h.
+ ///
+ /// \p generation is the DASHBOARD's own counter, never m_generation's
+ /// query generation. Bumping that one would discard a thread load in
+ /// flight and blank the message pane because the user selected a row,
+ /// which is the rule requestMessageCounts and countSenders already follow.
+ ///
+ /// A thread id the index does not hold yields an EMPTY digest rather than
+ /// silence, for the reason loadMessage() documents: a caller that arms
+ /// state on the request and disarms it on the reply otherwise waits for
+ /// ever.
+ void loadThreadDigest(const QString &threadId, quint64 generation);
+
+public:
+ /// The first thread id matching \p query, for tests.
+ ///
+ /// A test knows a message id and needs the thread id the digest call
+ /// takes; nothing in the UI ever needs this, since every caller there
+ /// already holds a thread id from a query result. Not a slot, so it cannot
+ /// be reached across the thread boundary by accident.
+ QString threadIdForTesting(const QString &query);
+
+public slots:
+
/// Loads ONE message, for a message row selected in the list.
///
/// Emits messageLoaded with an empty vector when the id is unknown, which
@@ -315,6 +345,10 @@ signals:
void threadTreeLoaded(const QVector<MessageNode> &nodes,
quint64 generation);
void messageLoaded(const QVector<MessageRef> &messages, quint64 generation);
+
+ /// The dashboard's digest. `generation` is the dashboard's own counter,
+ /// echoed back so a stale answer can be discarded.
+ void threadDigestLoaded(const ThreadDigest &digest, quint64 generation);
void tagsApplied(const TagChange &change);
/// Carries the ids that ACTUALLY moved, which may be fewer than requested.
diff --git a/tests/test_notmuchworker.cpp b/tests/test_notmuchworker.cpp
index f70a8f5..1766589 100644
--- a/tests/test_notmuchworker.cpp
+++ b/tests/test_notmuchworker.cpp
@@ -21,6 +21,7 @@
#include "notmuchfixture.h"
#include "notmuchworker.h"
+#include "threaddigest.h"
#include "types.h"
/// NotmuchWorker against a throwaway database. This is the only code in the
@@ -120,6 +121,10 @@ private slots:
void aQuerySeesMailIndexedAfterTheWorkerOpened();
+ void aDigestCountsSendersAndUnread();
+ void aDigestCapsItsUnreadListButNotItsCount();
+ void aOneMessageThreadGivesASaneSpan();
+
private:
/// Adds one read message in `folder` and reindexes, for the move tests.
/// Each of those takes its own message, because a move is destructive and
@@ -2178,5 +2183,136 @@ void TestNotmuchWorker::aSplitIndexListsTheMaildirsFolders()
"the index's own directory was listed as a mail folder");
}
+void TestNotmuchWorker::aDigestCountsSendersAndUnread()
+{
+ NotmuchFixture fixture;
+ QVERIFY(fixture.addMessage(QStringLiteral("inbox"),
+ QStringLiteral("d0@example.org"),
+ QStringLiteral("Digest root"),
+ QStringLiteral("alice@example.org"),
+ QStringLiteral("Mon, 24 Aug 2026 10:00:00 +0200"),
+ QStringLiteral("Root."), false));
+ QVERIFY(fixture.addMessage(QStringLiteral("inbox"),
+ QStringLiteral("d1@example.org"),
+ QStringLiteral("Re: Digest root"),
+ QStringLiteral("alice@example.org"),
+ QStringLiteral("Tue, 25 Aug 2026 10:00:00 +0200"),
+ QStringLiteral("Again."), false,
+ QStringLiteral("d0@example.org")));
+ QVERIFY(fixture.addMessage(QStringLiteral("inbox"),
+ QStringLiteral("d2@example.org"),
+ QStringLiteral("Re: Digest root"),
+ QStringLiteral("bob@example.org"),
+ QStringLiteral("Wed, 26 Aug 2026 10:00:00 +0200"),
+ QStringLiteral("Unread one."), true,
+ QStringLiteral("d0@example.org")));
+ QVERIFY2(fixture.index(), qPrintable(fixture.error()));
+
+ NotmuchWorker worker(fixture.configPath());
+ QSignalSpy spy(&worker, &NotmuchWorker::threadDigestLoaded);
+
+ const QString threadId = worker.threadIdForTesting(
+ QStringLiteral("id:d0@example.org"));
+ QVERIFY(!threadId.isEmpty());
+ worker.loadThreadDigest(threadId, 1);
+
+ QCOMPARE(spy.count(), 1);
+ const ThreadDigest digest = spy.at(0).at(0).value<ThreadDigest>();
+
+ QCOMPARE(digest.totalCount, 3);
+ QCOMPARE(digest.unreadTotal, 1);
+ QCOMPARE(digest.unread.size(), 1);
+ QCOMPARE(digest.unread.at(0).messageId, QStringLiteral("d2@example.org"));
+
+ // Alice twice, Bob once, most prolific first.
+ QCOMPARE(digest.senders.size(), 2);
+ QCOMPARE(digest.senders.at(0).second, 2);
+ QCOMPARE(digest.senders.at(1).second, 1);
+
+ QCOMPARE(digest.buckets.size(), ThreadDigest::kBuckets);
+ int summed = 0;
+ for (int n : digest.buckets)
+ summed += n;
+ QCOMPARE(summed, 3);
+
+ // The dashboard reconstructs the busiest bucket's date from the span, so
+ // an index outside the histogram would name a date the thread never saw.
+ QVERIFY(digest.busiestBucket >= 0);
+ QVERIFY(digest.busiestBucket < ThreadDigest::kBuckets);
+ QVERIFY(digest.firstTimestamp <= digest.lastTimestamp);
+}
+
+void TestNotmuchWorker::aDigestCapsItsUnreadListButNotItsCount()
+{
+ NotmuchFixture fixture;
+ QVERIFY(fixture.addMessage(QStringLiteral("inbox"),
+ QStringLiteral("c0@example.org"),
+ QStringLiteral("Cap root"),
+ QStringLiteral("alice@example.org"),
+ QStringLiteral("Mon, 24 Aug 2026 10:00:00 +0200"),
+ QStringLiteral("Root."), false));
+ for (int i = 1; i <= 8; ++i) {
+ QVERIFY(fixture.addMessage(
+ QStringLiteral("inbox"),
+ QStringLiteral("c%1@example.org").arg(i),
+ QStringLiteral("Re: Cap root"),
+ QStringLiteral("bob@example.org"),
+ QStringLiteral("Tue, 25 Aug 2026 %1:00:00 +0200")
+ .arg(i, 2, 10, QLatin1Char('0')),
+ QStringLiteral("Reply."), true, QStringLiteral("c0@example.org")));
+ }
+ QVERIFY2(fixture.index(), qPrintable(fixture.error()));
+
+ NotmuchWorker worker(fixture.configPath());
+ QSignalSpy spy(&worker, &NotmuchWorker::threadDigestLoaded);
+ worker.loadThreadDigest(
+ worker.threadIdForTesting(QStringLiteral("id:c0@example.org")), 1);
+
+ QCOMPARE(spy.count(), 1);
+ const ThreadDigest digest = spy.at(0).at(0).value<ThreadDigest>();
+ QCOMPARE(digest.unreadTotal, 8);
+ QCOMPARE(digest.unread.size(), ThreadDigest::kUnreadShown);
+ // Newest first: c8 is the latest.
+ QCOMPARE(digest.unread.at(0).messageId, QStringLiteral("c8@example.org"));
+}
+
+void TestNotmuchWorker::aOneMessageThreadGivesASaneSpan()
+{
+ // The degenerate case the dashboard would otherwise divide by: one message
+ // is a zero-width span, and a bucket width of zero either divides by zero
+ // or throws every message into the last bucket.
+ NotmuchFixture fixture;
+ QVERIFY(fixture.addMessage(QStringLiteral("inbox"),
+ QStringLiteral("s0@example.org"),
+ QStringLiteral("Alone"),
+ QStringLiteral("alice@example.org"),
+ QStringLiteral("Mon, 24 Aug 2026 10:00:00 +0200"),
+ QStringLiteral("Only."), false));
+ QVERIFY2(fixture.index(), qPrintable(fixture.error()));
+
+ NotmuchWorker worker(fixture.configPath());
+ QSignalSpy spy(&worker, &NotmuchWorker::threadDigestLoaded);
+ worker.loadThreadDigest(
+ worker.threadIdForTesting(QStringLiteral("id:s0@example.org")), 1);
+
+ QCOMPARE(spy.count(), 1);
+ const ThreadDigest digest = spy.at(0).at(0).value<ThreadDigest>();
+ QCOMPARE(digest.totalCount, 1);
+ QCOMPARE(digest.buckets.size(), ThreadDigest::kBuckets);
+ QCOMPARE(digest.buckets.at(0), 1);
+ QCOMPARE(digest.busiestBucket, 0);
+ QCOMPARE(digest.firstTimestamp, digest.lastTimestamp);
+
+ // An unknown thread yields an EMPTY digest rather than nothing at all, so a
+ // caller that arms state on the request always gets its reply.
+ QSignalSpy missing(&worker, &NotmuchWorker::threadDigestLoaded);
+ worker.loadThreadDigest(QStringLiteral("0000000000000000"), 2);
+ QCOMPARE(missing.count(), 1);
+ const ThreadDigest empty = missing.at(0).at(0).value<ThreadDigest>();
+ QCOMPARE(empty.totalCount, 0);
+ QCOMPARE(empty.buckets.size(), ThreadDigest::kBuckets);
+ QCOMPARE(empty.busiestBucket, -1);
+}
+
QTEST_MAIN(TestNotmuchWorker)
#include "test_notmuchworker.moc"