aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-25 18:49:55 +0200
committerDanilo M. <danix@danix.xyz>2026-08-25 18:49:55 +0200
commitdfb36213c03e678178ec1abb2327266d23015bc0 (patch)
tree4c4bf7c50e509af78d9a9be09c819b81c9ba90de /src
parent3955ff80e3b0d558cd854c4517d1835514010b90 (diff)
downloadqtmaildir-dfb36213c03e678178ec1abb2327266d23015bc0.tar.gz
qtmaildir-dfb36213c03e678178ec1abb2327266d23015bc0.zip
feat: empty the trash, the one action that asks first
Item 118, unblocked by 103. Message > Empty trash..., scoped to the account selector, with no default shortcut. purgeMessages() is a separate worker entry point from moveMessages() rather than a flag on it, because the two look alike and only one can be undone. It takes named ids, never a folder sweep, so the blast radius is what the dialog enumerated and the user confirmed, and it deletes every file of a message: notmuch deduplicates by Message-ID, so leaving one behind leaves the message alive in the folder the user emptied. It confirms, naming the count and the account, defaulting to Cancel. That breaks CLAUDE.md's no-confirmation rule deliberately and the rule now records it as its single exception, in the same paragraph: a purge has no inverse to push onto the undo stack, so the protection the rule provides has to come from somewhere, and the dialog is where. Two defects found rather than reasoned. The count claimed messages whose files were already gone, overstating an irreversible action; an absent file is correctly not an error, but that is not the same as destroyed. And the user's hand test found the list still showing mail that no longer existed: a purge removes rows rather than changing them, so there is no optimistic update to apply and nothing was connected to messagesPurged at all. It re-runs the current query now. Verified against the live index after the user emptied one real account's trash: zero files on disk, zero in the index. The suite is 37 of 38, the failure being item 136 on an unrelated path. Ten new strings translated, lrelease reports 0 unfinished. Item 168 is filed from the same hand test, on Delete being offered on mail already in the trash. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HFuRPtzFrSxCQjFk6tq7gD
Diffstat (limited to 'src')
-rw-r--r--src/keymap.cpp4
-rw-r--r--src/mainwindow.cpp109
-rw-r--r--src/mainwindow.h16
-rw-r--r--src/notmuchworker.cpp101
-rw-r--r--src/notmuchworker.h26
5 files changed, 256 insertions, 0 deletions
diff --git a/src/keymap.cpp b/src/keymap.cpp
index 269a7d5..6605882 100644
--- a/src/keymap.cpp
+++ b/src/keymap.cpp
@@ -33,6 +33,10 @@ QStringList KeyMap::knownActions()
QStringLiteral("delete"),
QStringLiteral("restore"),
QStringLiteral("cleanup_stranded"),
+ // Item 118. No default binding, deliberately: this is the one action
+ // that destroys mail with no undo, and a chord is how it would be run
+ // by accident. Menu only, which item 132 made a legitimate choice.
+ QStringLiteral("empty_trash"),
QStringLiteral("spam"),
QStringLiteral("toggle_unread"),
QStringLiteral("mark_all_read"),
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index fd954b0..dd7bc68 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -1588,6 +1588,19 @@ void MainWindow::registerActions()
[this]() {
showStrandedDeletedMail();
});
+ // The ONE irreversible action in this application, and the only one that
+ // asks before it runs (item 118). CLAUDE.md rules out confirmation
+ // dialogs for mutations because every mutation pushes its inverse onto
+ // the undo stack; a purge has no inverse, so the rule does not reach it.
+ // What the rule protects is that the user never loses work to a
+ // keystroke, which here is what the dialog provides.
+ //
+ // No default shortcut, for the same reason: a chord is how this would be
+ // run by accident.
+ addAction(QStringLiteral("empty_trash"), tr("Empt&y trash..."),
+ tr("Permanently delete every message in the trash"), [this]() {
+ emptyTrash();
+ });
addAction(QStringLiteral("spam"), tr("Mark &spam"),
tr("Add spam and remove inbox"), [this]() {
tagSelected({ QStringLiteral("spam") }, { QStringLiteral("inbox") },
@@ -1957,6 +1970,7 @@ void MainWindow::buildMenus()
// It replaces the whole view like a filter does, so a sixth button beside
// the five filters would read as one of them.
messageMenu->addAction(m_actions.value(QStringLiteral("cleanup_stranded")));
+ messageMenu->addAction(m_actions.value(QStringLiteral("empty_trash")));
messageMenu->addAction(m_actions.value(QStringLiteral("tag_rules")));
auto *viewMenu = menuBar()->addMenu(tr("&View"));
@@ -2017,6 +2031,7 @@ void MainWindow::buildMenus()
// nothing, so an icon from the delete family would promise the one
// thing it deliberately does not do.
{ QStringLiteral("cleanup_stranded"), QStringLiteral("system-search") },
+ { QStringLiteral("empty_trash"), QStringLiteral("edit-delete-shred") },
{ QStringLiteral("undo"), QStringLiteral("edit-undo") },
{ QStringLiteral("spam"), QStringLiteral("mail-mark-junk") },
{ QStringLiteral("flag"), QStringLiteral("mail-mark-important") },
@@ -2540,6 +2555,18 @@ void MainWindow::wireWorker()
connect(m_worker, &NotmuchWorker::messagesMovedFrom,
this, &MainWindow::onMessagesMoved);
+ // A purge removes rows rather than changing them, so there is no
+ // optimistic update to apply: the only honest view is the one the query
+ // gives now. Without this the list went on showing mail that no longer
+ // existed until the user refreshed by hand, which is how the user found
+ // it.
+ connect(m_worker, &NotmuchWorker::messagesPurged, this,
+ [this](const QStringList &messageIds) {
+ showTransientStatus(
+ tr("Deleted %n message(s) permanently", "", messageIds.size()));
+ runCurrentQuery();
+ });
+
connect(m_worker, &NotmuchWorker::threadMessagesResolved,
this, &MainWindow::onThreadMessagesResolved);
@@ -5464,6 +5491,11 @@ void MainWindow::onThreadMessagesResolved(const QStringList &messageIds,
const QStringList threadScope = m_pendingThreadScope;
m_pendingThreadScope.clear();
+ if (requestTag == QStringLiteral("empty_trash")) {
+ confirmAndPurge(messageIds);
+ return;
+ }
+
if (requestTag == QStringLiteral("delete_thread")) {
trashMessages(messageIds, pathById, messageIds.size(), threadScope);
return;
@@ -5692,6 +5724,83 @@ void MainWindow::restoreSelectedFromTrash()
Q_ARG(QString, QStringLiteral("restore_messages")));
}
+void MainWindow::purgeForTesting(const QStringList &messageIds)
+{
+ if (!m_worker || messageIds.isEmpty())
+ return;
+ QMetaObject::invokeMethod(m_worker, "purgeMessages", Qt::QueuedConnection,
+ Q_ARG(QStringList, messageIds));
+}
+
+void MainWindow::emptyTrash()
+{
+ // Scoped to the account selector, like every other account-aware surface:
+ // the All accounts view empties every configured trash, a selected
+ // account empties only its own. The user sees which in the dialog.
+ const QString accountKey = m_accountBox->currentData().toString();
+ const QString query = accountKey.isEmpty()
+ ? m_config.allTrashQuery()
+ : m_config.account(accountKey).trashQuery();
+
+ // An account with no trash folder configured produces an EMPTY query, and
+ // an empty notmuch query matches EVERYTHING. Refusing here rather than
+ // relying on the worker's own guard, so the message names the cause.
+ if (query.isEmpty()) {
+ showTransientStatus(tr("No trash folder is configured"));
+ return;
+ }
+
+ if (!m_worker) {
+ showTransientStatus(tr("Not connected to the mail index"));
+ return;
+ }
+
+ // Enumerated before it is counted, and counted from the DATABASE: the
+ // number in the dialog has to be the number destroyed, and the model
+ // holds whatever the current view is showing, which is usually not the
+ // trash at all.
+ QMetaObject::invokeMethod(m_worker, "resolveQueryMessages",
+ Qt::QueuedConnection,
+ Q_ARG(QString, query),
+ Q_ARG(QString, QStringLiteral("empty_trash")));
+}
+
+void MainWindow::confirmAndPurge(const QStringList &messageIds)
+{
+ if (messageIds.isEmpty()) {
+ showTransientStatus(tr("The trash is already empty"));
+ return;
+ }
+
+ const QString accountKey = m_accountBox->currentData().toString();
+ const QString where = accountKey.isEmpty()
+ ? tr("every account")
+ : m_accountBox->currentText();
+
+ QMessageBox box(this);
+ box.setObjectName(QStringLiteral("emptyTrashConfirmation"));
+ box.setIcon(QMessageBox::Warning);
+ box.setWindowTitle(tr("Empty trash"));
+ box.setText(tr("Permanently delete %n message(s) from the trash of %1?",
+ "", messageIds.size())
+ .arg(where));
+ // Said plainly, because it is the only place in this application where it
+ // is true.
+ box.setInformativeText(tr("This cannot be undone."));
+ box.addButton(QMessageBox::Cancel);
+ QPushButton *confirm =
+ box.addButton(tr("Delete permanently"), QMessageBox::DestructiveRole);
+ // Cancel is the default, so Return does not destroy mail.
+ box.setDefaultButton(QMessageBox::Cancel);
+ box.exec();
+
+ if (box.clickedButton() != confirm)
+ return;
+
+ QMetaObject::invokeMethod(m_worker, "purgeMessages", Qt::QueuedConnection,
+ Q_ARG(QStringList, messageIds));
+}
+
void MainWindow::showStrandedDeletedMail()
{
// Not scoped to the selected account, deliberately. The stranded mail is
diff --git a/src/mainwindow.h b/src/mainwindow.h
index d3c5d15..42708d9 100644
--- a/src/mainwindow.h
+++ b/src/mainwindow.h
@@ -169,6 +169,11 @@ public:
/// command was pushed, which is what "this did nothing" has to assert.
int undoDepthForTesting() const { return m_undoStack.count(); }
+ /// Runs a purge without the confirmation, which a test cannot drive: a
+ /// modal blocks the thread it is shown on (item 84). What this exists to
+ /// cover is what happens AFTER the user confirms.
+ void purgeForTesting(const QStringList &messageIds);
+
/// The text of the command on top of the undo stack.
///
/// A test seam for the DIRECTION a toggle chose. Delete and Undelete both
@@ -1032,6 +1037,17 @@ private:
/// without moving.
void showStrandedDeletedMail();
+ /// Asks the worker what is in the trash. The answer arrives at
+ /// onThreadMessagesResolved() tagged `empty_trash` and goes to
+ /// confirmAndPurge(): the count in the dialog has to be what will actually
+ /// be destroyed, so it comes from the database rather than from the model,
+ /// which holds whatever the current view happens to show.
+ void emptyTrash();
+
+ /// The confirmation, and the only one in this application. Destroys
+ /// nothing if the user declines.
+ void confirmAndPurge(const QStringList &messageIds);
+
/// Moves each resolved message home, using the tags and paths the WORKER
/// reported rather than anything the model holds.
void restoreResolvedMessages(const QStringList &messageIds,
diff --git a/src/notmuchworker.cpp b/src/notmuchworker.cpp
index f3a76ea..8ab3ab5 100644
--- a/src/notmuchworker.cpp
+++ b/src/notmuchworker.cpp
@@ -920,6 +920,99 @@ void NotmuchWorker::moveMessages(const QStringList &messageIds,
emit messagesMovedFrom(origins, destFolder);
}
+void NotmuchWorker::purgeMessages(const QStringList &messageIds)
+{
+ if (messageIds.isEmpty())
+ return;
+
+ // Same handle ordering as applyTags() and moveMessages(): notmuch allows
+ // one open handle per process, so the read-only one closes first.
+ 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;
+ }
+
+ QStringList purged;
+ for (const QString &id : messageIds) {
+ notmuch_message_t *raw = nullptr;
+ // find_message reports SUCCESS with a null message for an unknown id,
+ // so both are checked. A stale id does not abort the batch: the live
+ // ids beside it still have to go.
+ if (notmuch_database_find_message(db, id.toUtf8().constData(), &raw)
+ != NOTMUCH_STATUS_SUCCESS || !raw) {
+ continue;
+ }
+ NmMessage message(raw);
+
+ // EVERY file, not just the first. notmuch deduplicates by Message-ID,
+ // so one message can have several files; unlinking one would leave the
+ // message alive in the folder the user emptied, which reads as the
+ // purge having silently skipped it. This is the same one-message,
+ // many-files property that item 166 turned on.
+ QStringList files;
+ for (NmFilenames names(notmuch_message_get_filenames(message.get()));
+ notmuch_filenames_valid(names.get());
+ notmuch_filenames_move_to_next(names.get())) {
+ files.append(QString::fromUtf8(notmuch_filenames_get(names.get())));
+ }
+
+ // The handle is released before the files go out from under it.
+ message.reset();
+
+ bool removedAny = false;
+ for (const QString &file : files) {
+ // A file already gone is not an ERROR: the index can name a path a
+ // sync has since removed, and the goal state (no file) is reached
+ // either way. Reporting it would teach the user to ignore the one
+ // message that matters here.
+ //
+ // It is not a DESTRUCTION either, which is a separate point and
+ // the one a first version got wrong. The count reaches the user as
+ // the size of an irreversible act, so it must say what this run
+ // actually destroyed, not what was already absent when it started.
+ if (!QFile::exists(file)) {
+ notmuch_database_remove_message(db, file.toUtf8().constData());
+ continue;
+ }
+ if (!QFile::remove(file)) {
+ emit errorOccurred(QStringLiteral("Cannot delete %1")
+ .arg(QFileInfo(file).fileName()));
+ continue;
+ }
+ removedAny = true;
+ // The index entry for that path. When the last filename goes, so
+ // does the message and every tag on it, which is exactly what is
+ // wanted here and is the thing moveMessages() has to avoid.
+ notmuch_database_remove_message(db, file.toUtf8().constData());
+ }
+
+ if (removedAny)
+ purged.append(id);
+ }
+
+ notmuch_database_close(db);
+ notmuch_database_destroy(db);
+
+ emit messagesPurged(purged);
+}
+
void NotmuchWorker::indexDraftFile(const QString &path,
const QString &previousPath)
{
@@ -1025,6 +1118,14 @@ void NotmuchWorker::resolveMessages(const QStringList &messageIds,
resolveQuery(terms.join(QStringLiteral(" or ")), requestTag);
}
+void NotmuchWorker::resolveQueryMessages(const QString &query,
+ const QString &requestTag)
+{
+ if (query.isEmpty())
+ return;
+ resolveQuery(query, requestTag);
+}
+
void NotmuchWorker::resolveThreadMessages(const QStringList &threadIds,
const QString &requestTag)
{
diff --git a/src/notmuchworker.h b/src/notmuchworker.h
index 3ccf8e5..2efddaa 100644
--- a/src/notmuchworker.h
+++ b/src/notmuchworker.h
@@ -134,6 +134,22 @@ public slots:
/// it, so removing before indexing loses the message's tags.
void moveMessages(const QStringList &messageIds, const QString &destFolder);
+ /// Destroys mail: removes each file from disk and each message from the
+ /// index. **This is the only irreversible operation in the application**
+ /// (item 118), which is why it is a separate entry point rather than a
+ /// flag on moveMessages(): the two look alike and one of them can be
+ /// undone.
+ ///
+ /// Named ids only, never a folder-wide sweep, so the blast radius is
+ /// whatever the caller enumerated and confirmed. A message with several
+ /// files loses every file it has, since leaving one behind would leave
+ /// the message alive in a folder the user emptied.
+ ///
+ /// The caller is responsible for confirming: CLAUDE.md rules out
+ /// confirmation dialogs for mutations because undo replaces them, and
+ /// this is the one action where undo cannot exist.
+ void purgeMessages(const QStringList &messageIds);
+
/// Indexes one freshly written file, so it appears in a `path:` query
/// without a full `notmuch new` (item 158).
///
@@ -190,6 +206,12 @@ public slots:
void resolveMessages(const QStringList &messageIds,
const QString &requestTag);
+ /// The same walk for an arbitrary QUERY, which is what Empty Trash needs:
+ /// it has to enumerate what it is about to destroy before it can say how
+ /// much that is, and the answer must not come from the model, which holds
+ /// whatever the current view happens to be showing.
+ void resolveQueryMessages(const QString &query, const QString &requestTag);
+
private:
/// The shared walk behind resolveMessages() and resolveThreadMessages():
/// runs `query` and emits threadMessagesResolved() with each match's id,
@@ -273,6 +295,10 @@ signals:
/// than aborting the batch.
void messagesMoved(const QStringList &messageIds, const QString &destFolder);
+ /// What a purge actually destroyed. Unlike a move there is no new path to
+ /// observe afterwards, so this is the only report the UI has.
+ void messagesPurged(const QStringList &messageIds);
+
/// The same move, reported per message with the folder it came FROM.
///
/// Emitted alongside messagesMoved rather than replacing it: that signal's