summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-06 16:55:16 +0200
committerDanilo M. <danix@danix.xyz>2026-09-06 16:55:16 +0200
commit6326030b7179580b934ba852b0fd98577bfb464a (patch)
treeba44b32f456d888ab2adc8a86d3a94db916a380f
parent51b8fd5708d23108d532be1cf38e4bb619eeb777 (diff)
downloadqtmaildir-6326030b7179580b934ba852b0fd98577bfb464a.tar.gz
qtmaildir-6326030b7179580b934ba852b0fd98577bfb464a.zip
fix: index the sent copy so it appears in the Sent view at once
ComposeWindow filed the sent copy into the account's sent folder and discarded the path DraftStore::write() returned, using the result only to test for failure. Nothing announced the file, so notmuch never learned it existed, and the Sent view is a path query over the index rather than a listing of the folder: the message was on disk and invisible until the next notmuch new, which here is a cron tick up to ten minutes away. Measured immediately after a send: 65 files in the account's Sent folder, 64 messages indexed for the same path. This is item 158's defect one path over. That item established the rule for drafts, and MainWindow wires both halves of it, so the send path was already telling the worker to DROP the draft's entry while never telling it to add the sent copy's. The missing half is the one the user sees. A sentCopyFiled signal, emitted only where the write succeeded, connected to the worker's existing indexDraftFile. That slot is generic despite its name: it calls notmuch_database_index_file, applies nothing draft-specific, and its previousPath already defaults to empty, which is right for a copy that replaces nothing. The connection is a lambda whose context object is m_worker, and that is load-bearing. indexDraftFile takes two arguments where the signal carries one, so a direct slot connection does not compile; the context object is what queues the call onto the worker's thread and keeps notmuch off the GUI thread. Simplifying it to a plain call reads as tidier and would cross that boundary, so the comment says so. The test asserts on the signal, on the file existing, and on it being inside the Sent folder. Indexing itself is already covered against a real database in test_notmuchworker; what was unproven was that anything ever called it for a sent copy. Announcing a path that was never written is the ghost entry removeIndexedFile exists to undo, which is why the existence check is there. Indexing is not repainting: a Sent view already on screen does not gain the row from this, and whether it should refresh after a send is left as a separate question. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jq9gXquUo9W4KXDagJXMmn
-rw-r--r--CHANGELOG.md6
-rw-r--r--docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md96
-rw-r--r--docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md3
-rw-r--r--src/composewindow.cpp8
-rw-r--r--src/composewindow.h14
-rw-r--r--src/mainwindow.cpp18
-rw-r--r--tests/test_mainwindow.cpp54
7 files changed, 199 insertions, 0 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f024916..5f118b3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -44,6 +44,12 @@ point at which they are stable.
### Fixed
+- **A sent message appears in the Sent view straight away.** The sent copy was
+ written to the Sent folder correctly but never announced to notmuch, and
+ that view is a search over the index rather than a listing of the folder, so
+ the message stayed invisible until the next sync, up to ten minutes later.
+ Saved drafts have been indexed immediately since 0.22.0; the sent copy now
+ is too.
- **A draft no longer becomes a new message every time it is saved.** Each
autosave built the draft under a fresh Message-ID, and because mbsync
uploads each revision to the drafts folder before the next save removes the
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 8de26f2..c37f250 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
@@ -10099,3 +10099,99 @@ on the server. Those are existing messages in the drafts folder, and nothing
here retracts them; the four found on the user's own mail were deleted by hand
as a separate act.
+---
+
+## 192. A sent message does not appear in the Sent view until the next sync
+
+**Observed (user, 2026-09-06, by hand):** "after sending, the sent message
+doesn't appear immediately in the sent view."
+
+**Cause, verified in the code and measured on the index.** `ComposeWindow`
+files the sent copy into the account's sent folder with
+`DraftStore::write(folder, built.bytes, "S")` (`composewindow.cpp:1614`) and
+uses the result only to test `filed.ok()`. **`filed.path` is discarded.** No
+signal carries it, so nothing tells notmuch the file exists, and the Sent
+view is a `path:` query over the INDEX rather than a directory listing. The
+message is on disk and invisible until the next `notmuch new`, which on this
+setup is a cron tick up to ten minutes away (item 184).
+
+Measured immediately after a send: 65 files in the account's `Sent/cur`, 64
+messages in the index for the same path. The one missing was the message just
+sent.
+
+**This is item 158's defect, one path over, and the asymmetry is the whole
+story.** That item established the rule for drafts: a saved draft is indexed
+immediately because the Drafts view is a path query and a sync is too far
+away. `MainWindow` wires both halves of it (`mainwindow.cpp:1273` and
+`:1277`): `draftSaved` to `indexDraftFile`, and `draftRemoved` to
+`removeIndexedFile`. The send path therefore already tells the worker to
+DROP the draft's entry, while never telling it to ADD the sent copy's. Half
+a chain, and the half that was missing is the one the user sees.
+
+**Approach.** A `sentCopyFiled(const QString &path)` signal from
+`ComposeWindow`, emitted where the write succeeds, connected to the worker's
+existing `indexDraftFile` slot exactly as `draftSaved` is.
+
+`indexDraftFile` is generic despite its name: it calls
+`notmuch_database_index_file` and, only when a previous path is given,
+removes that entry. It applies no draft-specific tags or flags, and its
+`previousPath` parameter already defaults to empty, so a sent copy passes
+through it correctly with no second slot and no change to the worker. The
+name is worth a note rather than a rename, since renaming it touches every
+existing caller and test for no behavioural gain.
+
+**Constraints.**
+
+- **Only on the success branch.** `filed.ok()` is already tested and a
+ failure already raises "Sent, but not filed"; indexing a path that was
+ never written would put a ghost in the index, which is the defect
+ `removeIndexedFile` exists to prevent.
+- **An account with no `sent` folder configured files nothing**, so there is
+ no path to emit and the signal must not fire. The existing
+ `!account.sent.isEmpty()` guard already scopes this.
+- **Indexing is not repainting.** Neither this nor the draft path re-runs the
+ query, so a view already on screen does not gain the row by itself. That is
+ a separate question from the one this item answers, and the model cannot
+ insert a row optimistically for a thread its query never returned (the
+ constraint item 170 records). Whether the Sent view should refresh itself
+ after a send is left open here rather than assumed.
+
+**Verification.** The signal and its connection are measurable: a test drives
+a send and asserts the path is emitted. Asserting the message is then FINDABLE
+needs a real index, which `test_notmuchworker` already has for
+`indexDraftFileMakesAFileFindable`; the same property for a sent copy is the
+same call and does not need a second test of libnotmuch's behaviour.
+
+**Built 2026-09-06, and it is the three lines the entry predicted.**
+`ComposeWindow::sentCopyFiled(const QString &path)` is emitted on the success
+branch of the filing write, and `MainWindow` connects it to the worker's
+existing `indexDraftFile`.
+
+**The connection is a lambda with `m_worker` as its CONTEXT OBJECT, and that
+is load-bearing rather than incidental.** `indexDraftFile` takes two arguments
+while the signal carries one, and a default argument does not fill that gap
+across a `connect()`, so a direct slot connection will not compile. The
+context object is what decides the thread: with `m_worker` given, Qt queues
+the lambda onto the worker's thread and notmuch never touches the GUI thread.
+A first attempt at simplifying this to a plain call was reverted for exactly
+that reason, and the comment records it, because the simplification LOOKS
+harmless and reads as tidier.
+
+**One test, asserting on the SIGNAL rather than on a notmuch query.**
+`aFiledSentCopyIsAnnouncedForIndexing` drives a real send through
+`ComposeFixture` and asserts the path is emitted, that the file at that path
+exists, and that it is inside the account's Sent folder. Indexing itself is
+already covered against a real database by
+`indexDraftFileMakesAFileFindable` in `test_notmuchworker`; what was unproven
+was that anything ever CALLS it for a sent copy. Asserting the path exists is
+not decoration: announcing a path that was never written is the ghost entry
+`removeIndexedFile()` exists to undo. Mutation-checked by removing the emit.
+
+**Left open deliberately, and it is a real question rather than an
+omission.** Indexing is not repainting. A Sent view already on screen does not
+gain the row from this, because neither this nor the draft path re-runs the
+query, and the model cannot insert a row optimistically for a thread its query
+never returned (item 170's constraint). The message is now findable the moment
+it is sent, which is what the user reported; whether the view should also
+refresh itself is a separate decision.
+
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 7267ea8..da568c9 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
@@ -265,6 +265,7 @@ taking that too literally.
| 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 |
+| 192 | A sent message does not appear in the Sent view until the next sync | defect | XS | **done 2026-09-06**, unreleased. The sent copy was filed correctly and never announced, so the index did not know it and the Sent view, a path query, could not show it. Measured as 65 files against 64 indexed. One signal to the worker, mirroring what drafts have had since item 158. Section in the closed file |
Sizes are rough: XS under an hour, S a sitting, M a session.
@@ -972,6 +973,8 @@ on it is measurable and belongs in the same test item 189 corrected. The icon is
a visual judgement and belongs to the user, per the rule in `CLAUDE.md`: hand it
over and let them look.
+---
+
## Deferred, unsized, or split out
Items noted while triaging but not part of the original list. Same numbering
diff --git a/src/composewindow.cpp b/src/composewindow.cpp
index 218edef..59228ba 100644
--- a/src/composewindow.cpp
+++ b/src/composewindow.cpp
@@ -1616,6 +1616,14 @@ void ComposeWindow::send()
if (!filed.ok()) {
sentCopyFailed = true;
sentCopyError = filed.error;
+ } else {
+ // Item 192. The Sent view queries the INDEX, so a file
+ // notmuch has not seen is invisible there until the next
+ // sync. The path was previously discarded, which is why a
+ // message just sent did not appear: measured as 65 files
+ // against 64 indexed. Only on the success branch, since
+ // indexing a path that was never written leaves a ghost.
+ emit sentCopyFiled(filed.path);
}
}
diff --git a/src/composewindow.h b/src/composewindow.h
index 87b105a..09b47de 100644
--- a/src/composewindow.h
+++ b/src/composewindow.h
@@ -190,6 +190,20 @@ signals:
/// \p path is the file that was removed, absolute.
void draftRemoved(const QString &path);
+ /// The sent copy was filed, so it must be indexed at once.
+ ///
+ /// Item 192. The Sent view is a `path:` query over the INDEX rather than a
+ /// directory listing, so a correctly written file notmuch has never seen
+ /// is invisible there until the next `notmuch new`, which is a cron tick
+ /// away. This is item 158's rule for drafts applied to the other half of
+ /// the send: that half already emits draftRemoved() so the draft's entry
+ /// goes, while nothing added the sent copy's.
+ ///
+ /// \p path is the file just written, absolute. Emitted ONLY when the write
+ /// succeeded: announcing a path that was never written would put a ghost
+ /// in the index.
+ void sentCopyFiled(const QString &path);
+
/// A send succeeded, and the message it answers should record that.
///
/// Item 68. \p sourceMessageId is the Message-ID of the message replied to
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index 6c6bc23..abc7875 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -1277,6 +1277,24 @@ void MainWindow::openComposer(const ComposeContext &context)
connect(composer, &ComposeWindow::draftRemoved, m_worker,
&NotmuchWorker::removeIndexedFile);
+ // And the sent copy must be indexed at once, for the same reason a draft
+ // is (item 192): the Sent view is a path query over the index, so a file
+ // notmuch has not seen is invisible there until the next sync. This half
+ // was missing while the draft REMOVAL above was already wired.
+ //
+ // indexDraftFile despite the name: it calls notmuch_database_index_file
+ // and applies nothing draft-specific, and its previousPath defaults to
+ // empty, which is exactly right for a sent copy that replaces nothing.
+ // A lambda rather than a direct slot connection, because indexDraftFile
+ // takes two arguments and the signal carries one; a default argument does
+ // not fill the gap across a connect(). The lambda's context object is
+ // m_worker, so it RUNS ON THE WORKER'S THREAD: notmuch never touches the
+ // GUI thread, which is the boundary the whole design rests on.
+ connect(composer, &ComposeWindow::sentCopyFiled, m_worker,
+ [this](const QString &path) {
+ m_worker->indexDraftFile(path);
+ });
+
// Item 68. The R and P Maildir flags, on the message the send answered.
//
// sendMessageTagChange, NOT tagSelected: this deliberately does not go on
diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp
index f20701e..f97b9bd 100644
--- a/tests/test_mainwindow.cpp
+++ b/tests/test_mainwindow.cpp
@@ -544,6 +544,7 @@ private slots:
void aReplySeedsTheHtmlToggleFromTheOriginal();
void aNewMessageSeedsTheHtmlToggleFromConfig();
void disablingInputsCoversEveryFieldAndTheToolbar();
+ void aFiledSentCopyIsAnnouncedForIndexing();
void aFailedSendCanBeRetriedWithoutFilingTheWrongCopy();
void anUnchangedMessageIsNotWrittenAgain();
void closingInsideTheDebounceStillSavesTheDraft();
@@ -15082,6 +15083,59 @@ void TestMainWindow::aNewMessageSeedsTheHtmlToggleFromConfig()
"a Forward seeded from the original rather than from config");
}
+/// Item 192. The sent copy must be announced so it can be indexed at once.
+///
+/// The Sent view is a `path:` query over the INDEX, not a directory listing,
+/// so a file notmuch has never seen is invisible there however correctly it
+/// was written. Measured on real mail right after a send: 65 files in the Sent
+/// folder, 64 messages indexed for the same path. Item 158 established this
+/// for drafts and wired both halves; the send path was already telling the
+/// worker to DROP the draft's entry while never telling it to add the sent
+/// copy's.
+///
+/// Asserted on the SIGNAL rather than on a notmuch query: the worker's
+/// indexDraftFile() is already covered against a real database by
+/// test_notmuchworker, so what is unproven here is that anything calls it.
+void TestMainWindow::aFiledSentCopyIsAnnouncedForIndexing()
+{
+ ComposeFixture fixture;
+ QVERIFY(fixture.build(QStringLiteral("Drafts"), QStringLiteral("Sent"),
+ QStringLiteral("send_delay_ms=0")));
+
+ ComposeContext context = newContext();
+ context.to = { QStringLiteral("someone@example.org") };
+
+ QPointer<ComposeWindow> window =
+ new ComposeWindow(context, fixture.config(), fixture.mailRoot());
+ auto *body = window->findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+ body->setPlainText(QStringLiteral("Text."));
+
+ QSignalSpy filed(window, &ComposeWindow::sentCopyFiled);
+
+ auto *sendAction = window->findChild<QAction *>(QStringLiteral("compose_send"));
+ QVERIFY(sendAction);
+ sendAction->trigger();
+
+ QTRY_VERIFY_WITH_TIMEOUT(window.isNull(), 15000);
+
+ QCOMPARE(filed.size(), 1);
+ const QString announced = filed.first().first().toString();
+ QVERIFY2(!announced.isEmpty(), "the sent copy was announced with no path");
+
+ // The path announced must be the file that was actually written, not the
+ // folder or a stale name: indexing a path that does not exist puts a ghost
+ // in the index, which is the defect removeIndexedFile() exists to undo.
+ QVERIFY2(QFile::exists(announced),
+ qPrintable(QStringLiteral("announced a path that does not exist: %1")
+ .arg(announced)));
+ const QString sentCur =
+ fixture.mailRoot() + QStringLiteral("/acct/Sent/cur");
+ QVERIFY2(announced.startsWith(sentCur),
+ qPrintable(QStringLiteral("announced %1, which is not in %2")
+ .arg(announced, sentCur)));
+}
+
void TestMainWindow::disablingInputsCoversEveryFieldAndTheToolbar()
{
ComposeFixture fixture;