aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.md10
-rw-r--r--docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md134
-rw-r--r--docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md1
-rw-r--r--src/notmuchworker.cpp120
-rw-r--r--src/threadlistmodel.cpp28
-rw-r--r--src/threadlistmodel.h5
-rw-r--r--tests/test_notmuchworker.cpp186
7 files changed, 440 insertions, 44 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 73e938e..2749e0b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -44,6 +44,16 @@ point at which they are stable.
### Fixed
+- **The Sent and Drafts views show every message you sent in a conversation,
+ not just the first.** Both views are lists of your own messages rather than
+ of conversations, but a thread you had replied to twice produced a single
+ row: it was dated by the conversation, opened the OLDER of your two
+ messages, and the newer one could not be reached from the view at all. Each
+ matched message is now its own row, carrying its own date, subject, tags and
+ sender, and the view is ordered by those dates rather than by the dates of
+ the conversations behind them. This also fixes a quieter problem on the same
+ rows, where Delete or Archive would have acted on the wrong message's file.
+
- **A background sync now clears only the accounts it actually carried.** A
sync qtmaildir did not start could only be judged from the log, which cannot
say which accounts a run covered, so a successful run cleared the pending
diff --git a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md
index 26f90fa..af1de0c 100644
--- a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md
+++ b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md
@@ -9807,3 +9807,137 @@ where it is unless the user overrules the reasoning above.
have their own.
**Closed 2026-08-29**, unreleased. Star and Archive added to the ordinary branch; Archive left the main toolbar; `mark_all_read` stays there by the user's decision, and the assertion that it does is now in the suite.
+
+---
+
+## 191. The Sent view collapses two messages you sent in one conversation into one row
+
+**Observed (user, 2026-09-06, by hand against real mail).** The Sent view for
+one account showed a row dated 12:42 today whose subject was right, but whose
+message pane rendered a message from three weeks earlier. The message actually
+sent at 12:42 appeared nowhere in the view. Thunderbird, pointed at the same
+IMAP folder, showed both. The user's own statement of what the view owes them
+settled the fix: "the sent view is for my sent messages, if I sent 2 messages in
+a thread, I want to see both, not threaded (as the view is not threaded),
+correctly tagged and dated."
+
+**Two wrong diagnoses came first and are worth recording**, because both were
+plausible and both were contradicted by measurement. The first read the `draft`
+chip on the row and blamed item 165; the chip is real and unrelated. The second
+read the row's date, found the message in `Sent/`, and concluded the row was
+correct and the user was comparing a thread count against a message count; the
+counts do differ for that reason, but that was not what they were reporting. The
+user's correction, that the pane opened a different message from the one the row
+claimed, is what located the defect. **A row's date and the message its pane
+opens are two separate reads, and a defect can sit precisely in the gap.**
+
+**Cause, verified in the code and against the real index.**
+`NotmuchWorker::walkThreads()` (`notmuchworker.cpp`) is built on
+`notmuch_query_search_threads`, so it emitted exactly one `ThreadSummary` per
+thread. The Sent branch then chose ONE message to stand for that thread:
+
+```
+if (matched) { summary.firstMessageId = ...; break; }
+```
+
+`notmuch_thread_get_messages` walks oldest-first, so a thread the user had
+replied to twice took their OLDEST reply and stopped. The row's `date` and
+`subject` came from the thread (`notmuch_thread_get_newest_date`,
+`notmuch_thread_get_subject`) while `firstMessageId` named that older message,
+which is why the two disagreed on screen.
+
+Measured on the developer's database: the conversation reported
+`[2/9]` — two matched messages of nine — and `notmuch search --output=messages
+--sort=oldest-first` over the sent folder returned the 17 Aug message before the
+6 Sep one. The view is already flat (`setFlatMode(m_sentView)`,
+`mainwindow.cpp`, and `Config::generatorIsFlat` marks `sent` and `drafts`), so
+the model was right about being unthreaded and only the worker was wrong.
+
+**This was also a data-safety defect, which the visible symptom hid.**
+`firstMessagePath`, `firstMessageTags` and `firstMessageSender` were all read
+from the same wrongly chosen message. `moveMessages` composes its destination
+from that path, so Delete or Archive on such a row would have moved the OLDER
+message's file, silently, and mbsync would have carried it to the server. The
+row the user was looking at named a file they were not looking at.
+
+**Fix.** The Sent branch emits one `ThreadSummary` per matched message rather
+than one per thread: it no longer breaks, and for each matched message it copies
+the thread-wide summary and overrides the row's identity —
+`firstMessageId`, `firstMessageTags`, `firstMessageSender`, `firstMessagePath`,
+and now `date` and `subject`, read from the message with
+`notmuch_message_get_date` and `notmuch_message_get_header`. Batching and the
+`total` counter move inside that loop, and the thread iteration `continue`s
+past the single-summary append below.
+
+`withRecipients` continues to select the branch, so Sent and Drafts both get
+this and no new flag can disagree with the flat-mode flag: the two questions are
+answered by one value, which is why the original branch keyed on it.
+
+**One consequence needed a second change, and it is the part that would have
+broken quietly.** Two rows now share a `threadId`, and
+`ThreadListModel::reconcile()` keyed its `QHash<QString,int> present` on exactly
+that. Two rows mapping to one key means the second looks like a thread that has
+vanished, so a sync would have dropped one of the user's sent messages from the
+view again, by a different route. `ThreadListModel::rowKeyFor()` is the single
+answer to "what makes a row unique": the message id in flat mode, the thread id
+otherwise, falling back to the thread id when a flat row carries no message id.
+All four keyed sites in `reconcile()` use it.
+
+**Verification.** Two tests in `test_notmuchworker.cpp`, over a new fixture
+thread F (`f1` sent, `f2` received, `f3` sent) which is the shape the defect
+needs and which no existing fixture had:
+
+- `aSentQueryEmitsOneRowPerMatchedMessage` asserts both ids are present, that
+ the two rows share a thread id (the property that made the reconcile change
+ necessary), and that the SAME messages under a non-Sent query still fold into
+ one row, so this is the flat branch's contract and not a change of meaning
+ for threaded views.
+- `aSentRowCarriesItsOwnMessagesDateAndSubject` asserts each row's date and
+ subject are its own, which is the half the user saw first.
+
+`aSentQueryCarriesTheMatchedMessageNotTheThreadsFirst` was RETARGETED rather
+than retired: its assertion still holds, but it filtered rows by the thread's
+subject, and a flat row is now titled by its own message, so it matched nothing.
+It filters on the message id instead and asserts the subject.
+
+Eight other failures were the new fixture's arithmetic, not the change: three
+messages and one thread added, so hardcoded totals moved 5→6 threads and 6→9
+messages.
+
+**The ordering was a second defect under the same item, found by hand after
+the first fix shipped to the user's screen.** With both rows present, the 17
+August one drew ABOVE the 6 September one. The cause is that
+`notmuch_query_set_sort` is a THREAD sort: it orders the threads the walk
+visits and says nothing about the messages inside one, so every row of a thread
+inherits that thread's single position. Sorting each thread's own rows in place
+is NOT enough either, and the fixture proved it: a message from another thread
+dated between two of a thread's replies still cannot land between them. A flat
+view is a list of messages, so `walkThreads()` collects flat rows in `flatRows`
+and sorts the whole result once, by each row's own date, before emitting.
+`std::stable_sort`, so rows sharing a timestamp keep the walk's order rather
+than swapping between identical queries.
+
+`sentRowsAreOrderedByTheirOwnDate` covers it, in both sort directions and
+across threads. **The cross-thread half of it passed for the wrong reason at
+first**: fixture threads D, E and F happen not to interleave, so per-thread
+sorting satisfied a whole-view order check. Fixture thread G exists purely to
+break that, dated between thread F's two messages, and the assertion failed
+the moment it was added. A mutation check confirms the within-thread half fails
+without the sort.
+
+**One existing test had to be corrected rather than satisfied, and the
+correction is a fact about notmuch worth keeping.**
+`oldestFirstReversesTheOrder` asserted the two sort directions produce exactly
+reversed lists. They do not: `NOTMUCH_SORT_OLDEST_FIRST` orders threads by
+their OLDEST message while `NEWEST_FIRST` orders them by their NEWEST, so the
+lists mirror each other only while no thread's date span contains another's.
+Thread F starts before thread G and ends after it, which is the first fixture
+data to violate that. The test keeps the assertion that does hold
+(`oldest.first()` is `newest.last()`) and replaces the mirror with a
+monotonicity check. It passed on master and fails here for a reason that is not
+a regression: the fixture finally contains the shape that distinguishes the two
+sorts.
+
+**Still open and separate:** the four autosave revisions of that reply sitting
+in `Drafts/` with distinct Message-IDs, which is item 165 and is what puts a
+`draft` chip on the conversation.
diff --git a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
index 8c55aad..43b241e 100644
--- a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
+++ b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
@@ -264,6 +264,7 @@ taking that too literally.
| 188 | Does Empty trash respect the account selector? | question | XS | **answered 2026-08-29** by reading the code, no work needed. It does: `MainWindow::emptyTrash()` (`mainwindow.cpp:6567`) reads `m_accountBox->currentData()` and uses `allTrashQuery()` only for All accounts, and the confirmation names which. Recorded so the notes' question has an answer rather than sitting open |
| 189 | The message bar carries only Reply, Forward and Delete | presentation | S | **done 2026-08-29**, unreleased. Star and Archive joined the bar's ordinary branch, Archive leaving the main toolbar as Delete did. `mark_all_read` deliberately did NOT move, at the user's decision: it is the one action that ignores the selection. Item 140's toolbar test listed `archive` as a list-wide action and had to be corrected, which is the classification this item changed. Section in the closed file. Original entry: Asks for Star (`flag`) and Archive on the bar, and raises Mark all read as a question. Two of the three are selection-scoped and fit the bar's rule as it stands; **`mark_all_read` does not**, since it deliberately ignores the selection and acts on every row in the view, which is the one action in the window that does. Needs a decision from the user on that one and on whether Archive LEAVES the main toolbar the way Delete did |
| 190 | Mark spam is not on the message bar, and its icon was never chosen for one | presentation | XS | open, 2026-09-06, from the notes. The bar's ordinary branch carries Reply, Forward, Star, Archive, Delete after item 189 and `spam` is not among them, though it meets the bar's rule (selection-scoped, undoable). Two halves: put it on the bar, and settle the icon, which the note asks to be "a bug, or a skull, or something that signifies bad/evil" and which is `mail-mark-junk` today, chosen for a menu where the label carries the meaning. **Paired with 187**, which changes what the action DOES (moves the file); ordering is the user's call |
+| 191 | The Sent view collapses two messages you sent in one conversation into one row | defect | S | **done 2026-09-06**, unreleased, from a hand test. The Sent and Drafts views are flat, but the worker emitted one summary per THREAD and picked a single matched message to stand for it, oldest-first. A conversation replied to twice showed one row, dated by the thread and opening the OLDER message, and the newer one was reachable nowhere. Also a data-safety defect: `firstMessagePath` named the wrong file, so Delete would have moved it. A second half, found by hand once the rows appeared: the sort notmuch applies is a THREAD sort, so both rows took their thread's position and an older reply drew above a newer one. Flat rows are now sorted as one list. Section in the closed file |
Sizes are rough: XS under an hour, S a sitting, M a session.
diff --git a/src/notmuchworker.cpp b/src/notmuchworker.cpp
index 3a0b2c1..c8a2485 100644
--- a/src/notmuchworker.cpp
+++ b/src/notmuchworker.cpp
@@ -27,6 +27,8 @@
#include <notmuch.h>
+#include <algorithm>
+
#include <QDateTime>
#include <QDir>
#include <QDirIterator>
@@ -468,6 +470,11 @@ void NotmuchWorker::runQuery(const QString &query, quint64 generation,
batch.reserve(kBatchSize);
int total = 0;
+ // A flat view's rows, held back until every thread has been walked so they
+ // can be ordered against each other rather than thread by thread. Empty
+ // for a threaded query, which emits in notmuch's own order as it goes.
+ QVector<ThreadSummary> flatRows;
+
for (; notmuch_threads_valid(threads.get());
notmuch_threads_move_to_next(threads.get())) {
@@ -500,17 +507,40 @@ void NotmuchWorker::runQuery(const QString &query, quint64 generation,
// Two different questions, and the Sent view asks the second one. A
// normal row stands for the thread's OPENING message. A Sent row
// stands for what the USER sent, usually a reply and often not the
- // opening message at all, so it takes the first message the query
- // MATCHED. withRecipients is exactly the Sent query, which is why it
- // selects between them rather than carrying a second flag that could
- // disagree with it.
+ // opening message at all.
+ //
+ // **One row per MATCHED MESSAGE, not per thread** (item 191). The Sent
+ // and Drafts views are flat (`Config::generatorIsFlat`), so they are
+ // lists of messages and a conversation the user replied to twice owes
+ // them two rows. This branch used to stop at the first match, which
+ // made the second reply reachable NOWHERE in the view: measured on the
+ // developer's real mail, a message sent at 12:42 was missing while the
+ // row above it, dated 12:42, opened a message from three weeks
+ // earlier. The date came from the thread and the body from the one
+ // chosen message, and the two cannot agree once a thread matches
+ // twice.
+ //
+ // `date` and `subject` are therefore overridden per message here.
+ // Everything above this point is thread-wide and correct for a
+ // threaded view; for a flat row the MESSAGE is the row.
//
// Note notmuch_thread_get_matched_messages returns a COUNT, not an
// iterator; there is no matched-messages list. The match state is a
- // per-message flag, so the Sent branch walks in oldest-first order and
- // stops at the first match. Measured at 0.146s against a 0.143s
- // baseline over 4,515 threads: the walk stops early and reads the
- // index, so it is as free as the toplevel call.
+ // per-message flag, so this walks the thread in oldest-first order and
+ // takes every message carrying it. The walk is the same one that was
+ // measured at 0.146s against a 0.143s baseline over 4,515 threads; it
+ // no longer stops early, but it still reads only the index.
+ //
+ // **The rows are collected and sorted as ONE list, because the sort
+ // notmuch applied is a THREAD sort.** `notmuch_query_set_sort` orders
+ // the threads this loop visits; it says nothing about the messages
+ // inside one, and it gives every row of a thread that thread's single
+ // position. Emitting them in the thread's own oldest-first walk put a
+ // reply from three weeks ago ABOVE the one sent today, both sitting
+ // where their shared thread sorted. Sorting each thread's rows
+ // in place does not fix it either: a message from ANOTHER thread dated
+ // between them still cannot land between them. A flat view is a list
+ // of messages, so it has to be ordered as one.
if (withRecipients) {
notmuch_messages_t *all = notmuch_thread_get_messages(thread.get());
for (; all && notmuch_messages_valid(all);
@@ -522,24 +552,43 @@ void NotmuchWorker::runQuery(const QString &query, quint64 generation,
notmuch_message_get_flag_st(message,
NOTMUCH_MESSAGE_FLAG_MATCH,
&matched);
- if (matched) {
- summary.firstMessageId = QString::fromUtf8(
- notmuch_message_get_message_id(message));
- // 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(
- notmuch_message_get_filename(message)));
- break;
- }
+ if (!matched)
+ continue;
+
+ // A copy per matched message, so each row carries the
+ // thread-wide fields set above and its own identity below.
+ ThreadSummary row = summary;
+ row.firstMessageId = QString::fromUtf8(
+ notmuch_message_get_message_id(message));
+ // The card's own tags, beside the thread's union above.
+ // Same walk, same index read, no extra query.
+ row.firstMessageTags = tagsOf(message);
+ // The card's sender, for the avatar hash (item 169). Same
+ // walk, and From is in the index like the tags.
+ row.firstMessageSender = senderAddressOf(message);
+ // Which account this belongs to, for Delete's destination.
+ row.firstMessagePath = QDir(dbRoot).relativeFilePath(
+ QString::fromUtf8(
+ notmuch_message_get_filename(message)));
+ // The row IS this message, so it is dated and titled by it.
+ // Reading the thread's newest date here is what put a sent
+ // message under a stranger's date.
+ row.date = QDateTime::fromSecsSinceEpoch(
+ notmuch_message_get_date(message));
+ const char *subject =
+ notmuch_message_get_header(message, "subject");
+ if (subject && *subject)
+ row.subject = QString::fromUtf8(subject);
+
+ flatRows.append(row);
}
- } else if (notmuch_messages_t *top =
- notmuch_thread_get_toplevel_messages(thread.get())) {
+
+ // Sorted and emitted once the whole query has been walked, below.
+ continue;
+ }
+
+ if (notmuch_messages_t *top =
+ notmuch_thread_get_toplevel_messages(thread.get())) {
if (notmuch_messages_valid(top)) {
if (notmuch_message_t *first = notmuch_messages_get(top)) {
summary.firstMessageId = QString::fromUtf8(
@@ -568,6 +617,27 @@ void NotmuchWorker::runQuery(const QString &query, quint64 generation,
}
}
+ // The flat views (Sent, Drafts) are lists of MESSAGES, so they are ordered
+ // by each row's own date across the whole result. std::stable_sort so rows
+ // sharing a timestamp keep the order the walk found them in rather than
+ // swapping between identical queries.
+ if (!flatRows.isEmpty()) {
+ std::stable_sort(flatRows.begin(), flatRows.end(),
+ [sort](const ThreadSummary &a, const ThreadSummary &b) {
+ return sort == OldestFirst ? a.date < b.date : a.date > b.date;
+ });
+
+ for (const ThreadSummary &row : flatRows) {
+ batch.append(row);
+ ++total;
+ if (batch.size() >= kBatchSize) {
+ emit threadsReady(batch, generation);
+ batch.clear();
+ batch.reserve(kBatchSize);
+ }
+ }
+ }
+
if (!batch.isEmpty())
emit threadsReady(batch, generation);
diff --git a/src/threadlistmodel.cpp b/src/threadlistmodel.cpp
index 5c44dd4..ad1359b 100644
--- a/src/threadlistmodel.cpp
+++ b/src/threadlistmodel.cpp
@@ -820,6 +820,22 @@ void ThreadListModel::appendBatch(const QVector<ThreadSummary> &batch)
endInsertRows();
}
+QString ThreadListModel::rowKeyFor(const ThreadSummary &summary) const
+{
+ // What makes a ROW unique, which is not always its thread. A flat view
+ // (Sent, Drafts) emits one row per matched MESSAGE since item 191, so two
+ // rows can share a threadId and keying on that alone silently drops one of
+ // them on every reconcile: the QHash below would map both to one row and
+ // the second would look like a thread that had vanished.
+ //
+ // Falls back to the thread id when a flat row somehow carries no message
+ // id, which keeps the old behaviour rather than collapsing every such row
+ // onto one empty key.
+ if (m_flatMode && !summary.firstMessageId.isEmpty())
+ return summary.firstMessageId;
+ return summary.threadId;
+}
+
void ThreadListModel::reconcile(const QVector<ThreadSummary> &threads)
{
// Removals first, walking BACKWARDS. Each beginRemoveRows renumbers
@@ -832,14 +848,14 @@ void ThreadListModel::reconcile(const QVector<ThreadSummary> &threads)
QSet<QString> wanted;
wanted.reserve(threads.size());
for (const ThreadSummary &summary : threads)
- wanted.insert(summary.threadId);
+ wanted.insert(rowKeyFor(summary));
for (int row = m_threads.size() - 1; row >= 0; --row) {
- if (wanted.contains(m_threads.at(row).summary.threadId))
+ if (wanted.contains(rowKeyFor(m_threads.at(row).summary)))
continue;
int first = row;
while (first > 0
- && !wanted.contains(m_threads.at(first - 1).summary.threadId))
+ && !wanted.contains(rowKeyFor(m_threads.at(first - 1).summary)))
--first;
beginRemoveRows({}, first, row);
m_threads.remove(first, row - first + 1);
@@ -852,7 +868,7 @@ void ThreadListModel::reconcile(const QVector<ThreadSummary> &threads)
QHash<QString, int> present;
present.reserve(m_threads.size());
for (int row = 0; row < m_threads.size(); ++row)
- present.insert(m_threads.at(row).summary.threadId, row);
+ present.insert(rowKeyFor(m_threads.at(row).summary), row);
// Insertions, forwards, at the position the RESULT gives them. Walking the
// result in order means each new thread is placed against rows already
@@ -860,7 +876,7 @@ void ThreadListModel::reconcile(const QVector<ThreadSummary> &threads)
// know what that order means.
for (int target = 0; target < threads.size(); ++target) {
const ThreadSummary &summary = threads.at(target);
- const auto it = present.constFind(summary.threadId);
+ const auto it = present.constFind(rowKeyFor(summary));
if (it == present.constEnd()) {
const int at = qMin(target, m_threads.size());
@@ -903,7 +919,7 @@ void ThreadListModel::reconcile(const QVector<ThreadSummary> &threads)
if (entry.value() >= target && entry.value() < row)
++entry.value();
}
- present[summary.threadId] = target;
+ present[rowKeyFor(summary)] = target;
row = target;
}
diff --git a/src/threadlistmodel.h b/src/threadlistmodel.h
index fee4a1f..bed099d 100644
--- a/src/threadlistmodel.h
+++ b/src/threadlistmodel.h
@@ -430,6 +430,11 @@ private:
bool loaded = false;
};
+ /// What makes a ROW unique: the message id in a flat view, the thread id
+ /// otherwise. Two flat rows can share a thread since item 191, so
+ /// reconcile() cannot key on threadId alone without dropping one of them.
+ QString rowKeyFor(const ThreadSummary &summary) const;
+
/// A newly arrived thread, with its card's own message seeded from the
/// query.
///
diff --git a/tests/test_notmuchworker.cpp b/tests/test_notmuchworker.cpp
index 7148f0c..970f51d 100644
--- a/tests/test_notmuchworker.cpp
+++ b/tests/test_notmuchworker.cpp
@@ -80,6 +80,9 @@ private slots:
void loadThreadMatchedOnlyWithNoQueryKeepsEverything();
void recipientsAreAbsentUnlessAskedFor();
+ void aSentQueryEmitsOneRowPerMatchedMessage();
+ void aSentRowCarriesItsOwnMessagesDateAndSubject();
+ void sentRowsAreOrderedByTheirOwnDate();
void recipientsAreFoldedWhenAskedFor();
void recipientsCrossAQueuedCall();
void theFirstRecipientsAddressCrossesForTheAvatar();
@@ -205,6 +208,41 @@ void TestNotmuchWorker::initTestCase()
"info@example.net, "
"third@example.org")));
+ // Thread F: a conversation the user replied to TWICE, which is the shape
+ // item 191 exists for. f1 is theirs, f2 is the correspondent's, f3 is
+ // theirs again. A Sent query matches f1 and f3 and must produce TWO rows,
+ // not one row standing for whichever of them the walk reached first.
+ QVERIFY(m_fixture.addMessage(QStringLiteral("sent"), QStringLiteral("f1@example.org"),
+ QStringLiteral("Ticket 1234"),
+ QStringLiteral("You <you@example.org>"),
+ QStringLiteral("Sun, 7 Jun 2026 10:00:00 +0000"),
+ QStringLiteral("my first reply"), false, QString(),
+ QStringLiteral("Support <support@example.org>")));
+ QVERIFY(m_fixture.addMessage(QStringLiteral("inbox"), QStringLiteral("f2@example.org"),
+ QStringLiteral("Re: Ticket 1234"),
+ QStringLiteral("Support <support@example.org>"),
+ QStringLiteral("Mon, 8 Jun 2026 10:00:00 +0000"),
+ QStringLiteral("their answer"), false,
+ QStringLiteral("f1@example.org")));
+ QVERIFY(m_fixture.addMessage(QStringLiteral("sent"), QStringLiteral("f3@example.org"),
+ QStringLiteral("Re: Ticket 1234"),
+ QStringLiteral("You <you@example.org>"),
+ QStringLiteral("Tue, 9 Jun 2026 14:00:00 +0000"),
+ QStringLiteral("my second reply"), false,
+ QStringLiteral("f2@example.org"),
+ QStringLiteral("Support <support@example.org>")));
+
+ // Thread G: one sent message dated BETWEEN thread F's two, which is what
+ // makes the cross-thread ordering assertion mean something. Without it
+ // the fixture's threads happen not to interleave, and sorting each
+ // thread's own rows is enough to pass a whole-view order check.
+ QVERIFY(m_fixture.addMessage(QStringLiteral("sent"), QStringLiteral("g1@example.org"),
+ QStringLiteral("Interleaved"),
+ QStringLiteral("You <you@example.org>"),
+ QStringLiteral("Mon, 8 Jun 2026 12:00:00 +0000"),
+ QStringLiteral("between f1 and f3"), false, QString(),
+ QStringLiteral("Someone <someone@example.org>")));
+
QVERIFY2(m_fixture.index(), qPrintable(m_fixture.error()));
}
@@ -508,12 +546,15 @@ void TestNotmuchWorker::aSentQueryCarriesTheMatchedMessageNotTheThreadsFirst()
runQuery(QStringLiteral("id:a2@example.org"),
NotmuchWorker::NewestFirst, /*withRecipients=*/true);
+ // Matched on the MESSAGE id rather than on the subject: since item 191 a
+ // flat row is titled by its own message, so this row reads "Re: Release
+ // notes" and a subject filter for the thread's title skips it.
bool sawIt = false;
for (const ThreadSummary &t : asSent) {
- if (t.subject != QStringLiteral("Release notes"))
+ if (t.firstMessageId != QStringLiteral("a2@example.org"))
continue;
// The REPLY, because that is what matched. Not a1, the thread's first.
- QCOMPARE(t.firstMessageId, QStringLiteral("a2@example.org"));
+ QCOMPARE(t.subject, QStringLiteral("Re: Release notes"));
sawIt = true;
}
QVERIFY2(sawIt, "the thread was not in the results at all");
@@ -530,6 +571,112 @@ void TestNotmuchWorker::aSentQueryCarriesTheMatchedMessageNotTheThreadsFirst()
}
}
+void TestNotmuchWorker::aSentQueryEmitsOneRowPerMatchedMessage()
+{
+ // Item 191. The Sent view is a list of what the USER sent, and it is flat
+ // (Config::generatorIsFlat), so a conversation they replied to twice owes
+ // them two rows. Before this the walk stopped at the first match and the
+ // second reply was reachable nowhere in the view: measured on the
+ // developer's real mail, a message sent at 12:42 was absent while the row
+ // above it, dated 12:42, opened a message from three weeks earlier.
+ //
+ // Thread F is that shape: f1 and f3 are the user's, f2 is the reply
+ // between them. The query matches only the sent folder, as the real Sent
+ // filter does.
+ const QVector<ThreadSummary> rows =
+ runQuery(QStringLiteral("path:\"sent/**\" and subject:\"Ticket 1234\""),
+ NotmuchWorker::NewestFirst, /*withRecipients=*/true);
+
+ QStringList ids;
+ for (const ThreadSummary &t : rows)
+ ids.append(t.firstMessageId);
+ std::sort(ids.begin(), ids.end());
+
+ const QStringList expected{ QStringLiteral("f1@example.org"),
+ QStringLiteral("f3@example.org") };
+ QCOMPARE(ids, expected);
+
+ // Both rows name the same thread, which is the property that makes this
+ // more than a loop: anything keyed on threadId alone now sees a duplicate.
+ QCOMPARE(rows.size(), 2);
+ QCOMPARE(rows.at(0).threadId, rows.at(1).threadId);
+
+ // And an ordinary query over the same messages still folds them into ONE
+ // row, so this is the Sent branch's contract and not a change of meaning
+ // for the threaded views.
+ const QVector<ThreadSummary> threaded =
+ runQuery(QStringLiteral("subject:\"Ticket 1234\""),
+ NotmuchWorker::NewestFirst, /*withRecipients=*/false);
+ QCOMPARE(threaded.size(), 1);
+}
+
+void TestNotmuchWorker::aSentRowCarriesItsOwnMessagesDateAndSubject()
+{
+ // The half the user reported first: a row dated today that opened a
+ // message from three weeks ago. `date` and `subject` came from the THREAD
+ // (newest date, thread subject) while the pane rendered `firstMessageId`,
+ // so the two could not agree once a thread matched twice.
+ const QVector<ThreadSummary> rows =
+ runQuery(QStringLiteral("path:\"sent/**\" and subject:\"Ticket 1234\""),
+ NotmuchWorker::NewestFirst, /*withRecipients=*/true);
+ QCOMPARE(rows.size(), 2);
+
+ for (const ThreadSummary &t : rows) {
+ if (t.firstMessageId == QStringLiteral("f1@example.org")) {
+ QCOMPARE(t.date.toUTC().toString(QStringLiteral("yyyy-MM-dd")),
+ QStringLiteral("2026-06-07"));
+ QCOMPARE(t.subject, QStringLiteral("Ticket 1234"));
+ } else {
+ QCOMPARE(t.date.toUTC().toString(QStringLiteral("yyyy-MM-dd")),
+ QStringLiteral("2026-06-09"));
+ QCOMPARE(t.subject, QStringLiteral("Re: Ticket 1234"));
+ }
+ }
+}
+
+void TestNotmuchWorker::sentRowsAreOrderedByTheirOwnDate()
+{
+ // The sort notmuch applies is a THREAD sort: notmuch_query_set_sort orders
+ // the threads the walk visits and says nothing about the messages inside
+ // one. Emitting a thread's matches in its own oldest-first walk therefore
+ // put the user's older reply ABOVE their newer one, both sitting at the
+ // position of the thread they share. Reported by hand: a message sent on
+ // 17/08 drawn above one sent on 06/09.
+ //
+ // Thread F holds f1 (7 Jun) and f3 (9 Jun), both the user's.
+ const QString sent =
+ QStringLiteral("path:\"sent/**\" and subject:\"Ticket 1234\"");
+
+ QVector<QDateTime> dates;
+ for (const ThreadSummary &t : runQuery(sent, NotmuchWorker::NewestFirst,
+ /*withRecipients=*/true))
+ dates.append(t.date);
+ QCOMPARE(dates.size(), 2);
+ QVERIFY2(dates.at(0) > dates.at(1),
+ "newest-first put the older of two messages in one thread first");
+
+ dates.clear();
+ for (const ThreadSummary &t : runQuery(sent, NotmuchWorker::OldestFirst,
+ /*withRecipients=*/true))
+ dates.append(t.date);
+ QCOMPARE(dates.size(), 2);
+ QVERIFY2(dates.at(0) < dates.at(1),
+ "oldest-first did not reverse with the requested sort");
+
+ // And the whole view is ordered, not merely each thread internally: the
+ // Sent folder's other messages must interleave correctly with these two.
+ // d1 is 5 Jun and e1 is 6 Jun, so newest-first owes f3, f1, e1, d1.
+ QVector<QDateTime> all;
+ for (const ThreadSummary &t : runQuery(QStringLiteral("path:\"sent/**\""),
+ NotmuchWorker::NewestFirst,
+ /*withRecipients=*/true))
+ all.append(t.date);
+ QCOMPARE(all.size(), 5);
+ for (int i = 1; i < all.size(); ++i)
+ QVERIFY2(all.at(i - 1) >= all.at(i),
+ "the flat view is not in date order across threads");
+}
+
void TestNotmuchWorker::queryCarriesTheFirstMessageSender()
{
// Item 169. The card has no address to hash: `authors` is notmuch's own
@@ -655,7 +802,7 @@ void TestNotmuchWorker::loadThreadTreeCarriesTheFactsARowNeeds()
void TestNotmuchWorker::queryReturnsAllThreads()
{
const QVector<ThreadSummary> threads = runQuery(QStringLiteral("*"));
- QCOMPARE(threads.size(), 5);
+ QCOMPARE(threads.size(), 7);
}
void TestNotmuchWorker::queryFiltersByTag()
@@ -724,7 +871,7 @@ void TestNotmuchWorker::queryPassesGenerationThrough()
QCOMPARE(ready.size(), 1);
QCOMPARE(ready.first().at(1).value<quint64>(), quint64(42));
QCOMPARE(finished.size(), 1);
- QCOMPARE(finished.first().at(0).toInt(), 5);
+ QCOMPARE(finished.first().at(0).toInt(), 7);
QCOMPARE(finished.first().at(1).value<quint64>(), quint64(42));
}
@@ -743,7 +890,20 @@ void TestNotmuchWorker::oldestFirstReversesTheOrder()
QVERIFY(newest.first().date != newest.last().date);
QCOMPARE(oldest.first().threadId, newest.last().threadId);
- QCOMPARE(oldest.last().threadId, newest.first().threadId);
+
+ // NOT the mirror assertion, and the asymmetry is notmuch's rather than
+ // ours: NOTMUCH_SORT_OLDEST_FIRST orders threads by their OLDEST message
+ // while NEWEST_FIRST orders them by their newest, so the two lists are
+ // reverses of each other only while no thread's span contains another
+ // thread's. Fixture thread F starts before thread G and ends after it, so
+ // F is first under newest-first and G is last under oldest-first. Measured
+ // on this fixture; asserting the mirror here would be asserting that
+ // notmuch does something it does not do.
+ //
+ // What still holds in both directions is that each list is monotonic in
+ // the sense its own sort defines, which is what a caller relies on.
+ for (int i = 1; i < newest.size(); ++i)
+ QVERIFY(newest.at(i - 1).date >= newest.at(i).date);
}
void TestNotmuchWorker::theSortOrderCrossesAQueuedCall()
@@ -975,7 +1135,7 @@ void TestNotmuchWorker::queryStillWorksAfterWrite()
worker.runQuery(QStringLiteral("*"), 2);
QCOMPARE(ready.size(), 2);
- QCOMPARE(ready.at(1).at(0).value<QVector<ThreadSummary>>().size(), 5);
+ QCOMPARE(ready.at(1).at(0).value<QVector<ThreadSummary>>().size(), 7);
worker.applyTags(change.inverted());
}
@@ -1323,7 +1483,7 @@ void TestNotmuchWorker::requestCountsAnswersOneCountPerQuery()
// Threads, not messages: thread A holds two messages and must count once,
// which is the number the pane's "N in inbox" line claims to be showing.
const QVector<int> counts = spy.at(0).at(0).value<QVector<int>>();
- QCOMPARE(counts, QVector<int>({ 1, 5, 0 }));
+ QCOMPARE(counts, QVector<int>({ 1, 7, 0 }));
}
void TestNotmuchWorker::requestCountsKeepsPositionOnAnInvalidQuery()
@@ -1353,7 +1513,7 @@ void TestNotmuchWorker::requestCountsKeepsPositionOnAnInvalidQuery()
// The queries either side keep their own answers, which is the property
// the pane depends on.
QCOMPARE(counts.at(0), 1);
- QCOMPARE(counts.at(2), 5);
+ QCOMPARE(counts.at(2), 7);
}
void TestNotmuchWorker::messageCountsCountMessagesNotThreads()
@@ -1379,12 +1539,12 @@ void TestNotmuchWorker::messageCountsCountMessagesNotThreads()
const QVector<int> threadCounts =
threads.first().at(0).value<QVector<int>>();
- QCOMPARE(messageCounts, (QVector<int>{ 6 }));
+ QCOMPARE(messageCounts, (QVector<int>{ 10 }));
// The guard that makes this test mean something: if requestMessageCounts
// were implemented with count_threads it would return 5 here and match
// the thread count, and the assertion above would be the only thing that
// caught it.
- QCOMPARE(threadCounts, (QVector<int>{ 5 }));
+ QCOMPARE(threadCounts, (QVector<int>{ 7 }));
}
void TestNotmuchWorker::messageCountsReportAnInvalidQueryAsMinusOne()
@@ -1411,7 +1571,7 @@ void TestNotmuchWorker::messageCountsReportAnInvalidQueryAsMinusOne()
QCOMPARE(counts.at(0), 0);
// The query beside it keeps its own answer at its own position, which is
// what pairs a count with the rule that produced it.
- QCOMPARE(counts.at(1), 6);
+ QCOMPARE(counts.at(1), 10);
}
void TestNotmuchWorker::requestDatabaseStatsCountsMessagesNotThreads()
@@ -1431,8 +1591,8 @@ void TestNotmuchWorker::requestDatabaseStatsCountsMessagesNotThreads()
// one counts messages, which is what a user means by "how much mail". A
// reimplementation that reused the thread count would report 3 here and be
// confidently wrong under the label "messages".
- QCOMPARE(stats.messages, 6);
- QCOMPARE(stats.threads, 5);
+ QCOMPARE(stats.messages, 10);
+ QCOMPARE(stats.threads, 7);
QVERIFY2(stats.messages != stats.threads,
"messages and threads are equal, so this fixture cannot prove the "
"two counts are distinct: add a reply to it");