From 3afbce6e2f7d711ab58985d64c3e236484cc7345 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Wed, 26 Aug 2026 15:27:15 +0200 Subject: feat: carry the first message's sender address on a thread summary --- src/notmuchworker.cpp | 59 ++++++++++++++++++++++++++++++++++++++++++++ src/types.h | 19 ++++++++++++++ tests/test_notmuchworker.cpp | 36 +++++++++++++++++++++++++++ 3 files changed, 114 insertions(+) diff --git a/src/notmuchworker.cpp b/src/notmuchworker.cpp index 8ab3ab5..43f4758 100644 --- a/src/notmuchworker.cpp +++ b/src/notmuchworker.cpp @@ -16,6 +16,13 @@ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. */ +// gmime.h pulls in glib's gio headers, which declare a struct field named +// "signals". Qt's #defines "signals" to "Q_SIGNALS" +// (unless QT_NO_KEYWORDS is set), so gmime.h must be included before any Qt +// header in this translation unit to avoid a macro collision. That means +// before notmuchworker.h too, which includes Qt headers. +#include + #include "notmuchworker.h" #include @@ -113,6 +120,52 @@ QString recipientsOf(notmuch_thread_t *thread) return QString(); } +/// The bare address of a message's From, with any display name discarded. +/// +/// Index-served, unlike recipientsOf() above, which is why this is not behind +/// the withRecipients flag: `From` is in notmuch's index and `To` is not. +/// +/// The header is untrusted, so it is parsed rather than split: a display name +/// may legally contain an `@`, and "Ian " split on `@` yields nonsense. +QString senderAddressOf(notmuch_message_t *message) +{ + // GMime must be initialised once per process before any parse, or the + // first internet_address_list_parse() call dereferences an uninitialised + // type registry and SEGVs. Function-local static, exactly as the other + // gmime-using units do, so this file does not lean on libnotmuch having + // initialised it as a side effect (measured 2026-08-26: it currently + // does, but that is not documented anywhere). + static const bool initialised = [] { + g_mime_init(); + return true; + }(); + Q_UNUSED(initialised); + + const char *from = notmuch_message_get_header(message, "From"); + if (!from || !*from) + return QString(); + + InternetAddressList *list = internet_address_list_parse(nullptr, from); + if (!list) + return QString(); + + QString address; + const int count = internet_address_list_length(list); + for (int i = 0; i < count; ++i) { + InternetAddress *entry = internet_address_list_get_address(list, i); + if (!entry || !INTERNET_ADDRESS_IS_MAILBOX(entry)) + continue; + const char *addr = + internet_address_mailbox_get_addr(INTERNET_ADDRESS_MAILBOX(entry)); + if (addr && *addr) { + address = QString::fromUtf8(addr); + break; + } + } + g_object_unref(list); + return address; +} + /// 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, @@ -434,6 +487,9 @@ void NotmuchWorker::runQuery(const QString &query, quint64 generation, // The card's own tags, beside the thread's union above. // Same walk, same index read, no extra query. summary.firstMessageTags = tagsOf(message); + // The card's sender, for the avatar hash (item 169). Same + // walk, and From is in the index like the tags. + summary.firstMessageSender = senderAddressOf(message); // Which account this belongs to, for Delete's destination. summary.firstMessagePath = QDir(dbRoot).relativeFilePath( QString::fromUtf8( @@ -450,6 +506,9 @@ void NotmuchWorker::runQuery(const QString &query, quint64 generation, // The card's own tags, beside the thread's union above. // Same walk, same index read, no extra query. summary.firstMessageTags = tagsOf(first); + // The card's sender, for the avatar hash (item 169). Same + // walk, and From is in the index like the tags. + summary.firstMessageSender = senderAddressOf(first); // Which account this belongs to, for Delete's destination. summary.firstMessagePath = QDir(dbRoot).relativeFilePath( QString::fromUtf8( diff --git a/src/types.h b/src/types.h index cf2411d..47ddb78 100644 --- a/src/types.h +++ b/src/types.h @@ -62,6 +62,25 @@ struct ThreadSummary /// file. Do not move it behind a flag by analogy with `recipients`. QStringList firstMessageTags; + /// That message's sender, as a BARE ADDRESS with no display name. + /// + /// `authors` above is notmuch's own summarised string and carries display + /// names ONLY: measured against the real index, 'Ryanair' and 'The Hacker + /// News tramite LinkedIn', with no `@` anywhere. A card therefore has no + /// address to hash for its avatar and nothing for the business-sender list + /// to match, which is why this exists (item 169). + /// + /// Hashing the display name instead was rejected: notmuch BUILDS those + /// strings, so one sender's identity varies as the string does. + /// + /// Free, for the same reason `firstMessageId` and `firstMessageTags` are: + /// the walk that finds that message is already happening and From is + /// served from the INDEX, not the message file. Measured 2026-08-26 on the + /// developer's database: 1322 distinct senders in 12 ms, 5105 messages + /// enumerated in 76 ms. Do not move it behind a flag by analogy with + /// `recipients`. + QString firstMessageSender; + /// That message's file, RELATIVE to the database path, which is what says /// which ACCOUNT it belongs to. /// diff --git a/tests/test_notmuchworker.cpp b/tests/test_notmuchworker.cpp index 3f75898..2d19da5 100644 --- a/tests/test_notmuchworker.cpp +++ b/tests/test_notmuchworker.cpp @@ -65,6 +65,7 @@ private slots: void loadMessageOnAnUnknownIdReturnsNothing(); void aQueryCarriesEachThreadsFirstMessageId(); void aSentQueryCarriesTheMatchedMessageNotTheThreadsFirst(); + void queryCarriesTheFirstMessageSender(); void loadThreadTreeReportsReplyDepth(); void loadThreadTreeCarriesTheFactsARowNeeds(); @@ -515,6 +516,41 @@ void TestNotmuchWorker::aSentQueryCarriesTheMatchedMessageNotTheThreadsFirst() } } +void TestNotmuchWorker::queryCarriesTheFirstMessageSender() +{ + // Item 169. The card has no address to hash: `authors` is notmuch's own + // summarised string and carries display names only, measured on the real + // index as 'Ryanair' and 'The Hacker News tramite LinkedIn', with no `@` + // anywhere. firstMessageSender is the BARE address of the one message the + // root card stands for. + // + // The From header carries a display name on purpose: a wrong + // implementation returning the display name or the authors string fails + // this test. + NotmuchFixture fixture; + QVERIFY(fixture.isValid()); + QVERIFY(fixture.addMessage(QStringLiteral("inbox"), + QStringLiteral("sender-probe@example.org"), + QStringLiteral("Probe subject"), + QStringLiteral("Probe "), + QStringLiteral("Mon, 8 Jun 2026 10:00:00 +0000"), + QStringLiteral("body"))); + QVERIFY2(fixture.index(), qPrintable(fixture.error())); + + NotmuchWorker worker(fixture.configPath()); + + QSignalSpy spy(&worker, &NotmuchWorker::threadsReady); + worker.runQuery(QStringLiteral("subject:\"Probe subject\""), 1, + NotmuchWorker::NewestFirst, false); + QVERIFY(spy.count() > 0); + + const auto threads = spy.first().at(0).value>(); + QCOMPARE(threads.size(), 1); + // The bare address, not the display name and not notmuch's authors string. + QCOMPARE(threads.first().firstMessageSender, + QStringLiteral("sender-probe@example.org")); +} + void TestNotmuchWorker::loadThreadTreeReportsReplyDepth() { // Thread A is a root plus one reply carrying In-Reply-To, which is what -- cgit v1.2.3