diff options
| author | Danilo M. <danix@danix.xyz> | 2026-08-26 16:22:49 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-08-26 16:22:49 +0200 |
| commit | 496e174e65c06563aff426bdbbeeba006102e89d (patch) | |
| tree | c43c434598fbfca36055a1cd7487fecb1dffce5f | |
| parent | 409faeadf013952420963fa1b740d44526d8a95e (diff) | |
| download | qtmaildir-496e174e65c06563aff426bdbbeeba006102e89d.tar.gz qtmaildir-496e174e65c06563aff426bdbbeeba006102e89d.zip | |
feat: propose business senders from newly synced mail
| -rw-r--r-- | src/mainwindow.cpp | 22 | ||||
| -rw-r--r-- | src/notmuchworker.cpp | 49 | ||||
| -rw-r--r-- | src/notmuchworker.h | 15 | ||||
| -rw-r--r-- | tests/test_notmuchworker.cpp | 39 |
4 files changed, 125 insertions, 0 deletions
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index ec43b3c..a09a572 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -2589,6 +2589,20 @@ void MainWindow::wireWorker() connect(m_worker, &NotmuchWorker::messageCountsReady, this, &MainWindow::onRuleCountsReady); + // Sender counts feed the business-senders candidate list after a sync. + // The connection is queued, so the QHash argument must be a registered + // metatype; notmuchworker.cpp registers it beside SortOrder. + connect(m_worker, &NotmuchWorker::senderCountsReady, this, + [this](const QHash<QString, int> &counts) { + // Never applies anything: appendCandidates writes commented + // lines only, so nothing on screen changes until the user + // uncomments one. The list is then reloaded so an entry they + // uncommented by hand takes effect without a restart. + BusinessSenders::appendCandidates( + BusinessSenders::defaultPath(), counts); + loadBusinessSenders(); + }); + // 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. @@ -4351,6 +4365,14 @@ void MainWindow::onSyncFinished(bool success, int exitCode) refreshCurrentQuery(); // A sync is the usual way new tags enter the database. requestAllTags(); + + // Propose new business-sender candidates from the mail this sync + // delivered. Scoped by scanQuery: a week of mail once the file + // exists, everything on the first run. + QMetaObject::invokeMethod( + m_worker, "countSenders", Qt::QueuedConnection, + Q_ARG(QString, + BusinessSenders::scanQuery(BusinessSenders::defaultPath()))); } else if (exitCode == kSyncSkippedExitCode) { // Skipped means the lock was never ours: some other run holds it. If // both started inside the same poll interval the monitor will have diff --git a/src/notmuchworker.cpp b/src/notmuchworker.cpp index 28834df..3de79e1 100644 --- a/src/notmuchworker.cpp +++ b/src/notmuchworker.cpp @@ -321,10 +321,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 ¬muchConfigPath, QObject *parent) : QObject(parent), m_configPath(notmuchConfigPath) { Q_UNUSED(kSortOrderMetaType); + Q_UNUSED(kSenderCountsMetaType); } NotmuchWorker::~NotmuchWorker() @@ -1381,6 +1390,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()) { diff --git a/src/notmuchworker.h b/src/notmuchworker.h index 2efddaa..77b14ec 100644 --- a/src/notmuchworker.h +++ b/src/notmuchworker.h @@ -18,6 +18,7 @@ #pragma once +#include <QHash> #include <QMap> #include <QObject> #include <QStringList> @@ -281,6 +282,16 @@ public slots: /// root does not change while the application runs. void requestMailRoot(); + /// Counts messages per sender address over `query`. + /// + /// Index-served, so it is cheap: measured 2026-08-26 on the developer's + /// database, 1322 distinct senders in 12 ms over 5105 messages. It does + /// NOT touch m_generation, which is the QUERY generation: bumping it would + /// discard a thread load in flight and blank the message pane because the + /// user synced. Item 169, following the same rule requestMessageCounts + /// already follows. + void countSenders(const QString &query); + signals: void threadsReady(const QVector<ThreadSummary> &threads, quint64 generation); void queryFinished(int totalThreads, quint64 generation); @@ -356,6 +367,10 @@ signals: /// treat as "cannot compose a path yet" rather than as the root being "". void mailRootReady(const QString &mailRoot); + /// One sender per entry, lower-cased, with how many messages over `query` + /// came from it. The candidate list for the business-senders file. + void senderCountsReady(const QHash<QString, int> &counts); + void errorOccurred(const QString &message); private: diff --git a/tests/test_notmuchworker.cpp b/tests/test_notmuchworker.cpp index 2d19da5..4fa8629 100644 --- a/tests/test_notmuchworker.cpp +++ b/tests/test_notmuchworker.cpp @@ -66,6 +66,7 @@ private slots: void aQueryCarriesEachThreadsFirstMessageId(); void aSentQueryCarriesTheMatchedMessageNotTheThreadsFirst(); void queryCarriesTheFirstMessageSender(); + void sendersAreCountedForTheCandidateList(); void loadThreadTreeReportsReplyDepth(); void loadThreadTreeCarriesTheFactsARowNeeds(); @@ -551,6 +552,44 @@ void TestNotmuchWorker::queryCarriesTheFirstMessageSender() QStringLiteral("sender-probe@example.org")); } +void TestNotmuchWorker::sendersAreCountedForTheCandidateList() +{ + // The counts that BusinessSenders::appendCandidates() consumes: per-sender + // totals over a query, keyed by the lower-cased BARE address (a display + // name would defeat the bulk-sender guess). Two messages from one sender + // must count twice. + NotmuchFixture fixture; + QVERIFY(fixture.isValid()); + QVERIFY(fixture.addMessage(QStringLiteral("inbox"), + QStringLiteral("n1@example.org"), + QStringLiteral("Receipt one"), + QStringLiteral("noreply@shop.example"), + QStringLiteral("Mon, 8 Jun 2026 10:00:00 +0000"), + QStringLiteral("body"))); + QVERIFY(fixture.addMessage(QStringLiteral("inbox"), + QStringLiteral("n2@example.org"), + QStringLiteral("Receipt two"), + QStringLiteral("noreply@shop.example"), + QStringLiteral("Tue, 9 Jun 2026 10:00:00 +0000"), + QStringLiteral("body"))); + QVERIFY(fixture.addMessage(QStringLiteral("inbox"), + QStringLiteral("j1@example.org"), + QStringLiteral("Hello"), + QStringLiteral("john@example.org"), + QStringLiteral("Wed, 10 Jun 2026 10:00:00 +0000"), + QStringLiteral("body"))); + QVERIFY2(fixture.index(), qPrintable(fixture.error())); + + NotmuchWorker worker(fixture.configPath()); + QSignalSpy spy(&worker, &NotmuchWorker::senderCountsReady); + worker.countSenders(QStringLiteral("*")); + QVERIFY(spy.count() > 0); + + const auto counts = spy.first().at(0).value<QHash<QString, int>>(); + QCOMPARE(counts.value(QStringLiteral("noreply@shop.example")), 2); + QCOMPARE(counts.value(QStringLiteral("john@example.org")), 1); +} + void TestNotmuchWorker::loadThreadTreeReportsReplyDepth() { // Thread A is a root plus one reply carrying In-Reply-To, which is what |
