aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
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