From b7f2a4e0f8858b1aa0d86755ebab6826306f3eff Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Mon, 24 Aug 2026 21:57:28 +0200 Subject: fix(drafts): index a saved draft so it appears without a sync Autosave writes the draft to the Maildir drafts folder and stops, while the Drafts view is a notmuch path: query, so a freshly saved draft was invisible until notmuch new ran. saveDraftNow() now emits draftSaved, and MainWindow connects it to a new NotmuchWorker::indexDraftFile() that indexes the one file the way moveMessages() does, with the previous revision removed so a rewrite leaves no ghost. The send path unlinks a draft that was indexed while being composed, so draftRemoved -> removeIndexedFile() drops its entry too. Measured: notmuch_database_index_file assigns NO tags (unlike notmuch new, which adds draft inbox unread), so no tag-stripping is needed and the draft cannot leak into a tag:inbox view. Item 158. --- tests/test_notmuchworker.cpp | 106 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) (limited to 'tests/test_notmuchworker.cpp') diff --git a/tests/test_notmuchworker.cpp b/tests/test_notmuchworker.cpp index 998696f..d02f8bd 100644 --- a/tests/test_notmuchworker.cpp +++ b/tests/test_notmuchworker.cpp @@ -93,6 +93,10 @@ private slots: void moveMessagesGivesTheFileAFreshMaildirName(); void moveMessagesKeepsTheMaildirFlags(); + void indexDraftFileMakesAFileFindable(); + void indexDraftFileRemovesThePreviousFile(); + void removeIndexedFileDropsTheEntry(); + void aSplitIndexStillResolvesTheMailRoot(); void aSplitIndexMovesIntoTheMaildirNotTheIndex(); void aSplitIndexListsTheMaildirsFolders(); @@ -103,6 +107,9 @@ private: /// Each of those takes its own message, because a move is destructive and /// the fixture database is shared by every test in this class. bool addMovableMessage(const QString &folder, const QString &messageId); + /// Writes a draft file into /cur with the "D" flag and returns its + /// path, WITHOUT indexing it, so a test can index just that file. + QString writeDraftFile(const QString &folder, const QString &messageId); /// The single file backing `messageId`, or an empty string when the /// database does not know the id. QString fileOf(const QString &messageId, @@ -215,6 +222,42 @@ bool TestNotmuchWorker::addMovableMessage(const QString &folder, return m_fixture.index(); } +QString TestNotmuchWorker::writeDraftFile(const QString &folder, + const QString &messageId) +{ + const QString dirPath = m_fixture.maildirPath() + QLatin1Char('/') + folder; + QDir dir; + if (!dir.mkpath(dirPath + QStringLiteral("/cur")) + || !dir.mkpath(dirPath + QStringLiteral("/new")) + || !dir.mkpath(dirPath + QStringLiteral("/tmp"))) { + return {}; + } + + // The same filename recipe addMessage() uses, with the draft flag instead + // of the seen flag, matching what DraftStore writes. + QString base = messageId; + base.remove(QLatin1Char('<')).remove(QLatin1Char('>')); + base.replace(QLatin1Char('@'), QLatin1Char('.')); + base.replace(QLatin1Char('/'), QLatin1Char('.')); + base += QStringLiteral(":2,D"); + + const QString path = dirPath + QStringLiteral("/cur/") + base; + QFile file(path); + if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) + return {}; + QTextStream out(&file); + out << "From: You \n" + << "To: someone@example.org\n" + << "Subject: A draft\n" + << "Message-ID: <" << messageId << ">\n" + << "Date: Sun, 7 Jun 2026 10:00:00 +0000\n" + << "\n" + << "draft body\n"; + out.flush(); + file.close(); + return path; +} + QString TestNotmuchWorker::fileOf(const QString &messageId, const QString &configPath) { @@ -1383,6 +1426,69 @@ void TestNotmuchWorker::moveMessagesReportsOnlyWhatMoved() QCOMPARE(inTrash.size(), 1); } +void TestNotmuchWorker::indexDraftFileMakesAFileFindable() +{ + const QString id = QStringLiteral("draft1@example.org"); + const QString path = writeDraftFile(QStringLiteral("drafts"), id); + QVERIFY(!path.isEmpty()); + + // On disk but not indexed: no query sees it, which is item 158's defect. + QCOMPARE(runQuery(QStringLiteral("id:%1").arg(id)).size(), 0); + + NotmuchWorker worker(m_fixture.configPath()); + QSignalSpy errors(&worker, &NotmuchWorker::errorOccurred); + worker.indexDraftFile(path); + QVERIFY2(errors.isEmpty(), qPrintable(errors.value(0).value(0).toString())); + + QCOMPARE(runQuery(QStringLiteral("id:%1").arg(id)).size(), 1); +} + +void TestNotmuchWorker::indexDraftFileRemovesThePreviousFile() +{ + const QString first = QStringLiteral("draft2@example.org"); + const QString second = QStringLiteral("draft3@example.org"); + const QString firstPath = writeDraftFile(QStringLiteral("drafts"), first); + QVERIFY(!firstPath.isEmpty()); + + NotmuchWorker worker(m_fixture.configPath()); + QSignalSpy errors(&worker, &NotmuchWorker::errorOccurred); + worker.indexDraftFile(firstPath); + QVERIFY2(errors.isEmpty(), qPrintable(errors.value(0).value(0).toString())); + QCOMPARE(runQuery(QStringLiteral("id:%1").arg(first)).size(), 1); + + // A rewrite: a new file (a fresh Message-ID) and the old one unlinked, as + // DraftStore does on every autosave. The old entry must not linger. + const QString secondPath = writeDraftFile(QStringLiteral("drafts"), second); + QVERIFY(!secondPath.isEmpty()); + QVERIFY(QFile::remove(firstPath)); + + worker.indexDraftFile(secondPath, firstPath); + QVERIFY2(errors.isEmpty(), qPrintable(errors.value(0).value(0).toString())); + + QCOMPARE(runQuery(QStringLiteral("id:%1").arg(second)).size(), 1); + QCOMPARE(runQuery(QStringLiteral("id:%1").arg(first)).size(), 0); +} + +void TestNotmuchWorker::removeIndexedFileDropsTheEntry() +{ + const QString id = QStringLiteral("draft4@example.org"); + const QString path = writeDraftFile(QStringLiteral("drafts"), id); + QVERIFY(!path.isEmpty()); + + NotmuchWorker worker(m_fixture.configPath()); + QSignalSpy errors(&worker, &NotmuchWorker::errorOccurred); + worker.indexDraftFile(path); + QVERIFY2(errors.isEmpty(), qPrintable(errors.value(0).value(0).toString())); + QCOMPARE(runQuery(QStringLiteral("id:%1").arg(id)).size(), 1); + + // The send path unlinks the draft and drops its entry, so it does not + // linger as a ghost until the next sync. + QVERIFY(QFile::remove(path)); + worker.removeIndexedFile(path); + QVERIFY2(errors.isEmpty(), qPrintable(errors.value(0).value(0).toString())); + QCOMPARE(runQuery(QStringLiteral("id:%1").arg(id)).size(), 0); +} + // Item 124. notmuch can put the Xapian index outside the mail root // (`mail_root` + `path`), which is how the index moves to faster storage while -- cgit v1.2.3