diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-29 17:08:56 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-29 17:08:56 +0200 |
| commit | 0402b5b683c7412daace2414f4b903b677b5405c (patch) | |
| tree | 897846e05de9e784fb2b9d584c82a3e67916c1f8 | |
| parent | 0cf2c008d46ca2e935987ecfa51a3e712d40420e (diff) | |
| download | qtmaildir-0402b5b683c7412daace2414f4b903b677b5405c.tar.gz qtmaildir-0402b5b683c7412daace2414f4b903b677b5405c.zip | |
feat: resolve a Message-ID to its thread id
For --message (item 200), which knows an id and needs the conversation:
opening a message means opening its thread with that message selected, never
an id: query showing one card out of a conversation (item 91).
The id is quoted before it reaches notmuch, unlike every other id in this
class. Those came out of notmuch; this one comes off another program's command
line, and notmuch parses garbage happily while matching nothing, so an id
carrying query syntax would be read AS syntax with no error anywhere. The
quoting is local rather than SearchTerm::quote(): that escapes an embedded
quote with a backslash, which notmuch does not honour inside a phrase, so the
quote closed the phrase and the injected query matched a real thread (the new
test failed against it). notmuch escapes a quote by doubling it.
threadIdForTesting() keeps its name and gains a shared helper rather than being
promoted: it is documented as not being a slot, and the new entry point has to
answer asynchronously.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| -rw-r--r-- | src/notmuchworker.cpp | 24 | ||||
| -rw-r--r-- | src/notmuchworker.h | 28 | ||||
| -rw-r--r-- | tests/test_notmuchworker.cpp | 59 |
3 files changed, 111 insertions, 0 deletions
diff --git a/src/notmuchworker.cpp b/src/notmuchworker.cpp index 2672f6a..2588f4e 100644 --- a/src/notmuchworker.cpp +++ b/src/notmuchworker.cpp @@ -764,6 +764,30 @@ void NotmuchWorker::loadThreadTree(const QString &threadId, QString NotmuchWorker::threadIdForTesting(const QString &query) { + return firstThreadIdMatching(query); +} + +void NotmuchWorker::resolveThreadForMessage(const QString &messageId) +{ + if (messageId.isEmpty()) { + emit threadForMessageResolved(messageId, QString()); + return; + } + // This id came off another program's command line, and notmuch reads + // unquoted query syntax AS syntax. Quoted here rather than through + // SearchTerm::quote(): that escapes an embedded quote with a backslash, + // which notmuch's (Xapian's) parser does not honour inside a phrase, so + // the quote closes the phrase and the rest is parsed as query. notmuch + // escapes a quote by DOUBLING it. + QString escaped = messageId; + escaped.replace(QLatin1Char('"'), QStringLiteral("\"\"")); + emit threadForMessageResolved( + messageId, + firstThreadIdMatching(QStringLiteral("id:\"%1\"").arg(escaped))); +} + +QString NotmuchWorker::firstThreadIdMatching(const QString &query) +{ if (!openReadOnly()) return QString(); diff --git a/src/notmuchworker.h b/src/notmuchworker.h index b4e9707..3183729 100644 --- a/src/notmuchworker.h +++ b/src/notmuchworker.h @@ -138,6 +138,13 @@ public: /// be reached across the thread boundary by accident. QString threadIdForTesting(const QString &query); +private: + /// The first thread id matching \p query, or empty. Shared by + /// threadIdForTesting() and resolveThreadForMessage(). + QString firstThreadIdMatching(const QString &query); + +public: + public slots: /// Loads ONE message, for a message row selected in the list. @@ -147,6 +154,21 @@ public slots: /// reporting. void loadMessage(const QString &messageId, quint64 generation); + /// Answers which thread a Message-ID belongs to (item 200). + /// + /// For `--message`, which knows an id and needs the conversation: opening + /// a message means opening its thread with that message selected, never an + /// `id:` query showing one card out of a conversation (item 91). + /// + /// Answers with an EMPTY thread id when the message is unknown rather than + /// staying silent, since the window reports the miss and a slot that never + /// replies would leave it waiting forever. + /// + /// **The id is quoted before it reaches notmuch.** Unlike every other id in + /// this class, this one came from argv rather than from notmuch itself, and + /// notmuch parses garbage happily while matching nothing. + void resolveThreadForMessage(const QString &messageId); + /// 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`. @@ -346,6 +368,12 @@ signals: quint64 generation); void messageLoaded(const QVector<MessageRef> &messages, quint64 generation); + /// The answer to resolveThreadForMessage(). The message id is echoed back + /// so a caller can tell which request this answers; the thread id is empty + /// when nothing matched. + void threadForMessageResolved(const QString &messageId, + const QString &threadId); + /// 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); diff --git a/tests/test_notmuchworker.cpp b/tests/test_notmuchworker.cpp index b2810d1..10f8dbc 100644 --- a/tests/test_notmuchworker.cpp +++ b/tests/test_notmuchworker.cpp @@ -70,6 +70,9 @@ private slots: void loadMessageReturnsOnlyThatMessage(); void loadMessageOnAnUnknownIdReturnsNothing(); + void resolvingAMessageIdAnswersItsThreadId(); + void resolvingAnUnknownMessageIdAnswersEmpty(); + void resolvingAMessageIdQuotesTheId(); void aQueryCarriesEachThreadsFirstMessageId(); void aSentQueryCarriesTheMatchedMessageNotTheThreadsFirst(); void queryCarriesTheFirstMessageSender(); @@ -459,6 +462,62 @@ void TestNotmuchWorker::loadMessageOnAnUnknownIdReturnsNothing() QCOMPARE(errors.count(), 0); } +void TestNotmuchWorker::resolvingAMessageIdAnswersItsThreadId() +{ + // What --message needs (item 200): the CLI knows a Message-ID and the + // window needs the thread id, because opening the message means opening + // its conversation with that message selected. + NotmuchWorker worker(m_fixture.configPath()); + QSignalSpy resolved(&worker, &NotmuchWorker::threadForMessageResolved); + + worker.resolveThreadForMessage(QStringLiteral("a2@example.org")); + + QCOMPARE(resolved.count(), 1); + QCOMPARE(resolved.first().at(0).toString(), QStringLiteral("a2@example.org")); + + // a2 is a REPLY, so its thread id is the thread's, not its own. It must + // agree with the thread a1 resolves to. + const QString threadId = resolved.first().at(1).toString(); + QVERIFY(!threadId.isEmpty()); + QCOMPARE(threadId, + worker.threadIdForTesting(QStringLiteral("id:a1@example.org"))); +} + +void TestNotmuchWorker::resolvingAnUnknownMessageIdAnswersEmpty() +{ + // Answers rather than staying silent: the window shows the miss in the + // status bar, and a slot that never replies would leave it waiting. + NotmuchWorker worker(m_fixture.configPath()); + QSignalSpy resolved(&worker, &NotmuchWorker::threadForMessageResolved); + QSignalSpy errors(&worker, &NotmuchWorker::errorOccurred); + + worker.resolveThreadForMessage(QStringLiteral("nonexistent@example.org")); + + QCOMPARE(resolved.count(), 1); + QCOMPARE(resolved.first().at(0).toString(), + QStringLiteral("nonexistent@example.org")); + QVERIFY(resolved.first().at(1).toString().isEmpty()); + QCOMPARE(errors.count(), 0); +} + +void TestNotmuchWorker::resolvingAMessageIdQuotesTheId() +{ + // The id comes from argv, not from notmuch, and notmuch's parser rejects + // almost nothing: an unquoted id carrying query syntax would be PARSED as + // syntax. It must stay a miss, with no error. + NotmuchWorker worker(m_fixture.configPath()); + QSignalSpy resolved(&worker, &NotmuchWorker::threadForMessageResolved); + QSignalSpy errors(&worker, &NotmuchWorker::errorOccurred); + + worker.resolveThreadForMessage( + QStringLiteral("a2@example.org\" or from:alice or \"x")); + + QCOMPARE(resolved.count(), 1); + QVERIFY2(resolved.first().at(1).toString().isEmpty(), + "an id carrying query syntax resolved to a thread: it was not quoted"); + QCOMPARE(errors.count(), 0); +} + void TestNotmuchWorker::aQueryCarriesEachThreadsFirstMessageId() { // The root card IS the thread's first message, so selecting it must be |
