aboutsummaryrefslogtreecommitdiffstats
path: root/src/notmuchworker.cpp
diff options
context:
space:
mode:
Diffstat (limited to 'src/notmuchworker.cpp')
-rw-r--r--src/notmuchworker.cpp136
1 files changed, 133 insertions, 3 deletions
diff --git a/src/notmuchworker.cpp b/src/notmuchworker.cpp
index 8ab3ab5..e78d8c8 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 <QtCore/qnamespace.h> #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 <gmime/gmime.h>
+
#include "notmuchworker.h"
#include <notmuch.h>
@@ -74,6 +81,51 @@ QStringList tagsOf(notmuch_thread_t *thread)
return result;
}
+/// The first mailbox address in a raw address header, display names dropped.
+///
+/// Parsed with GMime rather than split: the header is untrusted and a display
+/// name may legally contain an `@`, so "Ian <a@b>" split on `@` yields
+/// nonsense.
+QString firstMailboxOf(const QString &rawHeader)
+{
+ // 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 QByteArray utf8 = rawHeader.trimmed().toUtf8();
+ if (utf8.isEmpty())
+ return QString();
+
+ InternetAddressList *list =
+ internet_address_list_parse(nullptr, utf8.constData());
+ 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;
+}
+
/// Who a thread's messages were addressed to, summarised for one line.
///
/// EXPENSIVE, and only called when a query asks. "To" is not served from
@@ -87,7 +139,7 @@ QStringList tagsOf(notmuch_thread_t *thread)
/// frees again, and the walk finishes before the caller drops the thread. This
/// is the same rule walkReplies follows, and getting it wrong is a double-free
/// rather than a leak.
-QString recipientsOf(notmuch_thread_t *thread)
+QString recipientsOf(notmuch_thread_t *thread, QString *firstAddress)
{
// The first message with a usable To wins. A thread is one conversation,
// and the alternative, folding every message's recipients together, is the
@@ -107,12 +159,33 @@ QString recipientsOf(notmuch_thread_t *thread)
continue;
const QString summary = recipientSummary(QString::fromUtf8(to));
- if (!summary.isEmpty())
+ if (!summary.isEmpty()) {
+ // The same header, for the avatar's hash (item 169). Parsed rather
+ // than split for the reason senderAddressOf() documents: a display
+ // name may legally contain an `@`.
+ if (firstAddress)
+ *firstAddress = firstMailboxOf(QString::fromUtf8(to));
return summary;
+ }
}
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 <a@b>" split on `@` yields nonsense.
+QString senderAddressOf(notmuch_message_t *message)
+{
+ const char *from = notmuch_message_get_header(message, "From");
+ if (!from || !*from)
+ return QString();
+ return firstMailboxOf(QString::fromUtf8(from));
+}
+
/// 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,
@@ -169,6 +242,7 @@ void walkReplies(notmuch_messages_t *messages, int depth,
QString::fromUtf8(notmuch_message_get_filename(message));
node.from =
QString::fromUtf8(notmuch_message_get_header(message, "from"));
+ node.senderAddress = senderAddressOf(message);
node.subject =
QString::fromUtf8(notmuch_message_get_header(message, "subject"));
node.date =
@@ -267,10 +341,19 @@ QString folderOfMessageFile(const QString &root, const QString &filePath)
static const int kSortOrderMetaType =
qRegisterMetaType<NotmuchWorker::SortOrder>("NotmuchWorker::SortOrder");
+/// The same registration for the sender-count map. The QHash crosses the
+/// queued senderCountsReady connection from the worker thread to the UI, and
+/// an unregistered type is dropped there with a warning, exactly like
+/// SortOrder above. Registered with the name invokeMethod/moc resolve, so a
+/// caller that never builds a worker still gets the type.
+static const int kSenderCountsMetaType =
+ qRegisterMetaType<QHash<QString, int>>("QHash<QString,int>");
+
NotmuchWorker::NotmuchWorker(const QString &notmuchConfigPath, QObject *parent)
: QObject(parent), m_configPath(notmuchConfigPath)
{
Q_UNUSED(kSortOrderMetaType);
+ Q_UNUSED(kSenderCountsMetaType);
}
NotmuchWorker::~NotmuchWorker()
@@ -392,7 +475,8 @@ void NotmuchWorker::runQuery(const QString &query, quint64 generation,
summary.matchedCount = notmuch_thread_get_matched_messages(thread.get());
summary.tags = tagsOf(thread.get());
if (withRecipients)
- summary.recipients = recipientsOf(thread.get());
+ summary.recipients =
+ recipientsOf(thread.get(), &summary.firstMessageRecipient);
// The message the row's card stands for. Raw pointers on purpose:
// messages reached through a thread are owned by the THREAD and freed
@@ -434,6 +518,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 +537,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(
@@ -1321,6 +1411,46 @@ void NotmuchWorker::requestMessageCounts(const QStringList &queries,
emit messageCountsReady(counts, generation);
}
+void NotmuchWorker::countSenders(const QString &query)
+{
+ if (!openReadOnly()) {
+ // Answered anyway, with an empty map, so the sync path that asked is
+ // not left waiting on a signal it can never receive.
+ emit senderCountsReady({});
+ return;
+ }
+
+ QHash<QString, int> counts;
+ NmQuery nmQuery(notmuch_query_create(m_db, query.toUtf8().constData()));
+ if (!nmQuery) {
+ emit senderCountsReady(counts);
+ return;
+ }
+
+ notmuch_messages_t *raw = nullptr;
+ if (notmuch_query_search_messages(nmQuery.get(), &raw)
+ != NOTMUCH_STATUS_SUCCESS) {
+ emit senderCountsReady(counts);
+ return;
+ }
+
+ NmMessages messages(raw);
+ for (; notmuch_messages_valid(messages.get());
+ notmuch_messages_move_to_next(messages.get())) {
+ notmuch_message_t *message = notmuch_messages_get(messages.get());
+ if (!message)
+ continue;
+ // The BARE address, lower-cased, because that is the key
+ // BusinessSenders matches on: a display name would defeat the
+ // bulk-sender guess and a mixed-case key would duplicate one sender.
+ const QString sender = senderAddressOf(message);
+ if (!sender.isEmpty())
+ counts[sender.toLower()] += 1;
+ }
+
+ emit senderCountsReady(counts);
+}
+
void NotmuchWorker::requestMailRoot()
{
if (!openReadOnly()) {