summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-24 21:57:28 +0200
committerDanilo M. <danix@danix.xyz>2026-08-24 21:57:28 +0200
commitb7f2a4e0f8858b1aa0d86755ebab6826306f3eff (patch)
tree542934c034716b69b15258bf06e491798408af84
parentf6ceeacad8e1fe15c30db66dcfde8efa9dfb4758 (diff)
downloadqtmaildir-b7f2a4e0f8858b1aa0d86755ebab6826306f3eff.tar.gz
qtmaildir-b7f2a4e0f8858b1aa0d86755ebab6826306f3eff.zip
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.
-rw-r--r--src/composewindow.cpp6
-rw-r--r--src/composewindow.h11
-rw-r--r--src/mainwindow.cpp11
-rw-r--r--src/notmuchworker.cpp93
-rw-r--r--src/notmuchworker.h22
-rw-r--r--tests/test_composewindow.cpp54
-rw-r--r--tests/test_notmuchworker.cpp106
7 files changed, 302 insertions, 1 deletions
diff --git a/src/composewindow.cpp b/src/composewindow.cpp
index d781e52..c35bb5d 100644
--- a/src/composewindow.cpp
+++ b/src/composewindow.cpp
@@ -1018,6 +1018,7 @@ bool ComposeWindow::saveDraftNow()
const QString folder = QDir(m_mailRoot).absoluteFilePath(
account.maildir + QLatin1Char('/') + account.drafts);
+ const QString previousPath = m_draftPath;
const DraftStore::Result written =
DraftStore::write(folder, built.bytes, QStringLiteral("D"), m_draftPath);
@@ -1038,6 +1039,10 @@ bool ComposeWindow::saveDraftNow()
m_dirty = false;
m_saveFailed = false;
m_banner->hide();
+
+ // The write is done and the previous revision already unlinked; hand both
+ // paths up so the owner indexes the new one and drops the old (item 158).
+ emit draftSaved(written.path, previousPath);
return true;
}
@@ -1200,6 +1205,7 @@ void ComposeWindow::send()
dialog->setStage(SendDialog::Stage::RemovingDraft);
if (!m_draftPath.isEmpty()) {
QFile::remove(m_draftPath);
+ emit draftRemoved(m_draftPath);
m_draftPath.clear();
}
diff --git a/src/composewindow.h b/src/composewindow.h
index 918d4e3..caff011 100644
--- a/src/composewindow.h
+++ b/src/composewindow.h
@@ -168,6 +168,17 @@ signals:
/// pointer before WA_DeleteOnClose destroys the window.
void closed(ComposeWindow *window);
+ /// A draft was written to disk, so the window's owner can index it and it
+ /// appears in the Drafts view without a full sync (item 158).
+ ///
+ /// \p path is the file just written, absolute. \p previousPath is the file
+ /// the write replaced, empty on the first save of a new draft.
+ void draftSaved(const QString &path, const QString &previousPath);
+
+ /// A draft file was unlinked (sent), so its index entry must go too.
+ /// \p path is the file that was removed, absolute.
+ void draftRemoved(const QString &path);
+
protected:
/// The one place the registry is told, whichever route closes the window.
void closeEvent(QCloseEvent *event) override;
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index 99eb2a3..af3b817 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -1138,6 +1138,17 @@ void MainWindow::openComposer(const ComposeContext &context)
});
});
+ // A saved draft is indexed immediately (item 158): the Drafts view is a
+ // path query, and without this the draft is invisible until the next sync.
+ // The worker lives on another thread, so this is a queued connection and
+ // notmuch stays on its own thread.
+ connect(composer, &ComposeWindow::draftSaved, m_worker,
+ &NotmuchWorker::indexDraftFile);
+
+ // A draft unlinked on send must leave no ghost entry behind.
+ connect(composer, &ComposeWindow::draftRemoved, m_worker,
+ &NotmuchWorker::removeIndexedFile);
+
composer->show();
}
diff --git a/src/notmuchworker.cpp b/src/notmuchworker.cpp
index fca0a5a..16df4ed 100644
--- a/src/notmuchworker.cpp
+++ b/src/notmuchworker.cpp
@@ -820,8 +820,99 @@ void NotmuchWorker::moveMessages(const QStringList &messageIds,
emit messagesMovedFrom(origins, destFolder);
}
+void NotmuchWorker::indexDraftFile(const QString &path,
+ const QString &previousPath)
+{
+ if (path.isEmpty())
+ return;
+
+ // Same ordering as applyTags() and moveMessages(): notmuch allows one open
+ // handle per process, so the read-only one must close before the write.
+ close();
+
+ const QByteArray configPath = configPathArg();
+ notmuch_database_t *db = nullptr;
+ char *error = nullptr;
+ const notmuch_status_t status = notmuch_database_open_with_config(
+ nullptr,
+ NOTMUCH_DATABASE_MODE_READ_WRITE,
+ configPath.isEmpty() ? nullptr : configPath.constData(),
+ nullptr,
+ &db,
+ &error);
+
+ if (status != NOTMUCH_STATUS_SUCCESS) {
+ emit errorOccurred(
+ QStringLiteral("Cannot open database for writing: %1")
+ .arg(QString::fromUtf8(error ? error
+ : notmuch_status_to_string(status))));
+ free(error);
+ return;
+ }
+
+ notmuch_message_t *indexed = nullptr;
+ const notmuch_status_t added = notmuch_database_index_file(
+ db, path.toUtf8().constData(), nullptr, &indexed);
+ if (indexed)
+ notmuch_message_destroy(indexed);
+
+ // DUPLICATE_MESSAGE_ID is success here, exactly as in moveMessages(): the
+ // file reached the database, it is only the id that was already known.
+ if (added != NOTMUCH_STATUS_SUCCESS
+ && added != NOTMUCH_STATUS_DUPLICATE_MESSAGE_ID) {
+ notmuch_database_close(db);
+ notmuch_database_destroy(db);
+ emit errorOccurred(
+ QStringLiteral("Cannot index %1: %2")
+ .arg(QFileInfo(path).fileName(),
+ QString::fromUtf8(notmuch_status_to_string(added))));
+ return;
+ }
+
+ // The previous revision, if any, is already unlinked from disk; its entry
+ // must not linger as a ghost draft with a filename that no longer exists.
+ if (!previousPath.isEmpty() && previousPath != path)
+ notmuch_database_remove_message(db, previousPath.toUtf8().constData());
+
+ notmuch_database_close(db);
+ notmuch_database_destroy(db);
+}
+
+void NotmuchWorker::removeIndexedFile(const QString &path)
+{
+ if (path.isEmpty())
+ return;
+
+ close();
+
+ const QByteArray configPath = configPathArg();
+ notmuch_database_t *db = nullptr;
+ char *error = nullptr;
+ const notmuch_status_t status = notmuch_database_open_with_config(
+ nullptr,
+ NOTMUCH_DATABASE_MODE_READ_WRITE,
+ configPath.isEmpty() ? nullptr : configPath.constData(),
+ nullptr,
+ &db,
+ &error);
+
+ if (status != NOTMUCH_STATUS_SUCCESS) {
+ emit errorOccurred(
+ QStringLiteral("Cannot open database for writing: %1")
+ .arg(QString::fromUtf8(error ? error
+ : notmuch_status_to_string(status))));
+ free(error);
+ return;
+ }
+
+ notmuch_database_remove_message(db, path.toUtf8().constData());
+
+ notmuch_database_close(db);
+ notmuch_database_destroy(db);
+}
+
void NotmuchWorker::resolveMessages(const QStringList &messageIds,
- const QString &requestTag)
+ const QString &requestTag)
{
if (messageIds.isEmpty())
return;
diff --git a/src/notmuchworker.h b/src/notmuchworker.h
index 8ed878f..3ccf8e5 100644
--- a/src/notmuchworker.h
+++ b/src/notmuchworker.h
@@ -134,6 +134,28 @@ public slots:
/// it, so removing before indexing loses the message's tags.
void moveMessages(const QStringList &messageIds, const QString &destFolder);
+ /// Indexes one freshly written file, so it appears in a `path:` query
+ /// without a full `notmuch new` (item 158).
+ ///
+ /// The draft-save path writes the file and stops, and the Drafts view is a
+ /// path query, so an unindexed draft is invisible until the next sync. A
+ /// draft rewrite writes a NEW file (MessageBuilder generates a fresh
+ /// Message-ID on every build) and unlinks the old, so \p previousPath is
+ /// removed after the new one is indexed, mirroring moveMessages()'s
+ /// ordering: the old entry must not linger as a ghost draft.
+ ///
+ /// \p path is absolute, as DraftStore::write() returns it. The Maildir
+ /// flags on the file (the "D" flag a draft carries) drive its tags exactly
+ /// as they would on a later `notmuch new`.
+ void indexDraftFile(const QString &path, const QString &previousPath = {});
+
+ /// Removes one file from the index, without touching the file on disk.
+ ///
+ /// The counterpart to indexDraftFile() for the send path: a draft that was
+ /// indexed while being composed is unlinked when it is sent, and its entry
+ /// must not linger as a ghost draft until the next sync.
+ void removeIndexedFile(const QString &path);
+
/// Batch tagging over whole threads. The UI holds thread ids, not message
/// ids, for rows it has not opened, so the resolution happens here where
/// the database handle lives. This is the path the archive/flag/delete
diff --git a/tests/test_composewindow.cpp b/tests/test_composewindow.cpp
index 47f7d87..472c103 100644
--- a/tests/test_composewindow.cpp
+++ b/tests/test_composewindow.cpp
@@ -22,6 +22,7 @@
#include <QFile>
#include <QMenu>
#include <QPlainTextEdit>
+#include <QSignalSpy>
#include <QTemporaryDir>
#include <QTextStream>
#include <QToolButton>
@@ -47,6 +48,7 @@ private slots:
void changingTheAccountFollowsItsSignature();
void changingTheAccountStopsFollowingOnceTheSwitchIsUsed();
void aResumedDraftDoesNotReseedOnAnAccountChange();
+ void savingADraftEmitsItsPathAndTheReplacedOne();
private:
/// A config pointing at a signatures directory holding \p files, with one
@@ -393,5 +395,57 @@ void TestComposeWindow::aResumedDraftDoesNotReseedOnAnAccountChange()
QVERIFY(!body->toPlainText().contains(QStringLiteral("Home sig")));
}
+void TestComposeWindow::savingADraftEmitsItsPathAndTheReplacedOne()
+{
+ // A config whose account has a drafts folder, which makeConfig() does not
+ // set, so the save can actually write somewhere.
+ const QString confPath = m_dir->path() + QStringLiteral("/qtmaildir.conf");
+ {
+ QString conf;
+ QTextStream out(&conf);
+ out << "[account.work]\n"
+ << "name = Someone\n"
+ << "address = someone@example.org\n"
+ << "maildir = work\n"
+ << "drafts = Drafts\n"
+ << "send_command = /bin/cat\n";
+ writeFile(confPath, conf);
+ }
+ Config config;
+ config.load(confPath);
+
+ ComposeContext context;
+ context.kind = ComposeContext::Kind::New;
+ context.accountKey = QStringLiteral("work");
+
+ ComposeWindow window(context, config, m_dir->path());
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+
+ QSignalSpy saved(&window, &ComposeWindow::draftSaved);
+
+ body->setPlainText(QStringLiteral("First revision."));
+ QVERIFY(window.saveDraftNow());
+
+ QCOMPARE(saved.size(), 1);
+ const QString first = saved.first().at(0).toString();
+ const QString firstPrevious = saved.first().at(1).toString();
+ QVERIFY(!first.isEmpty());
+ QVERIFY(firstPrevious.isEmpty());
+ QVERIFY(QFile::exists(first));
+
+ // A rewrite writes a fresh file and unlinks the old; the previous path
+ // comes back so the owner can drop the old index entry.
+ body->setPlainText(QStringLiteral("Second revision."));
+ QVERIFY(window.saveDraftNow());
+
+ QCOMPARE(saved.size(), 2);
+ const QString second = saved.at(1).at(0).toString();
+ const QString secondPrevious = saved.at(1).at(1).toString();
+ QVERIFY(!second.isEmpty());
+ QCOMPARE(secondPrevious, first);
+ QVERIFY2(second != first, "a rewrite reused the old filename");
+}
+
QTEST_MAIN(TestComposeWindow)
#include "test_composewindow.moc"
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 <folder>/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 <you@example.org>\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