aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/mainwindow.cpp292
-rw-r--r--src/mainwindow.h139
-rw-r--r--src/notmuchworker.cpp56
-rw-r--r--src/notmuchworker.h20
-rw-r--r--src/threadlistmodel.cpp11
-rw-r--r--src/types.h20
-rw-r--r--tests/test_mainwindow.cpp415
-rw-r--r--translations/qtmaildir_it_IT.ts7
8 files changed, 938 insertions, 22 deletions
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index 67d3ee4..d3f2dc7 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -841,10 +841,13 @@ void MainWindow::registerActions()
// is about reading a message at all.
const bool allDeleted = everySelectedRowHasTag(QStringLiteral("deleted"));
+ // Item 103. A MOVE now, not only a tag: Delete used to add `deleted`
+ // and leave the file exactly where it was, so deleted mail sat in the
+ // inbox indefinitely and only the chip said otherwise.
if (allDeleted)
- tagSelected({}, { QStringLiteral("deleted") }, tr("Undelete"));
+ restoreSelected();
else
- tagSelected({ QStringLiteral("deleted") }, {}, tr("Delete"));
+ trashSelected();
});
addAction(QStringLiteral("spam"), tr("Mark &spam"),
tr("Add spam and remove inbox"), [this]() {
@@ -1604,6 +1607,12 @@ void MainWindow::wireWorker()
connect(m_worker, &NotmuchWorker::tagsApplied,
this, &MainWindow::onTagsApplied);
+ // messagesMovedFrom rather than messagesMoved: the tags a move carries can
+ // only be resolved once the origins are known, and that signal is the one
+ // that reports them.
+ connect(m_worker, &NotmuchWorker::messagesMovedFrom,
+ this, &MainWindow::onMessagesMoved);
+
m_workerThread.start();
// Queued behind the thread start, so the completer has real tags as soon
@@ -2922,6 +2931,21 @@ bool MainWindow::aSyncHoldsTheWriteLock() const
void MainWindow::flushHeldEdits()
{
+ // Moves first, and they are flushed even when no tag edit is waiting: the
+ // early return below used to be the whole guard, so a held move with an
+ // empty edit queue would never have been sent at all. That is item 106's
+ // data loss with a worse shape, since a dropped move leaves the file where
+ // the user asked it not to be.
+ if (!m_heldMoves.isEmpty()) {
+ const QVector<HeldMove> moves = m_heldMoves;
+ m_heldMoves.clear();
+ for (const HeldMove &move : moves) {
+ sendMove(move.messageIds, move.destFolder, move.add, move.remove,
+ move.description);
+ }
+ updatePendingIndicator();
+ }
+
if (m_heldEdits.isEmpty())
return;
@@ -4082,6 +4106,270 @@ void MainWindow::sendMessageTagChange(const QStringList &messageIds,
Q_ARG(TagChange, m_pendingChange));
}
+const QString &MainWindow::kOriginTagPlaceholder()
+{
+ // Not wrapped in tr(). It is never displayed: onMessagesMoved() replaces
+ // it with a real tag before anything reaches the worker, and a translated
+ // placeholder would stop matching in the one locale that translated it,
+ // which is the trap CLAUDE.md records for startup_query.
+ static const QString placeholder =
+ QStringLiteral("\x01qtmaildir-origin-placeholder");
+ return placeholder;
+}
+
+Account MainWindow::accountForMessagePath(const QString &path) const
+{
+ // From the PATH, not from the thread's account tag. The tag is optional
+ // config, so resolving through it would silently disable Delete for an
+ // account that never set one; a message's maildir prefix is what makes it
+ // belong to an account at all.
+ //
+ // Longest maildir wins, so nested account maildirs (`mail` and
+ // `mail/work`) resolve to the more specific one rather than to whichever
+ // happens to be listed first.
+ //
+ // BOTH path shapes are accepted, and that is not defensive coding. A
+ // thread row's path comes from ThreadSummary::firstMessagePath and is
+ // database-RELATIVE; a reply row's comes from MessageNode::filePath and is
+ // ABSOLUTE, because MimeParser has to open it. Matching only the relative
+ // form resolved every reply to no account, so Delete on a reply reported
+ // "no trash folder configured" and moved nothing, which is exactly the
+ // thread-row/reply-row asymmetry this file has been bitten by before.
+ //
+ // A `/` is required after the maildir in both cases, so `acctX` cannot
+ // match an account whose maildir is `acct`.
+ Account best;
+ int bestLength = -1;
+ for (const Account &account : m_config.accounts()) {
+ if (account.maildir.isEmpty())
+ continue;
+ const QString segment = QLatin1Char('/') + account.maildir
+ + QLatin1Char('/');
+ const bool matches =
+ path.startsWith(account.maildir + QLatin1Char('/'))
+ || path.contains(segment);
+ if (!matches)
+ continue;
+ if (account.maildir.length() > bestLength) {
+ best = account;
+ bestLength = account.maildir.length();
+ }
+ }
+ return best;
+}
+
+void MainWindow::trashSelected()
+{
+ const QModelIndexList rows =
+ m_threadView->selectionModel()->selectedRows();
+ if (rows.isEmpty())
+ return;
+
+ // Message scope, exactly as tagSelected() uses by default: a thread row
+ // stands for the ONE message its card displays. Escalating to the thread
+ // would move a whole conversation into the trash because the user deleted
+ // one reply.
+ const ActionScope scope = m_model->messageScopeFor(rows);
+ if (scope.messageIds.isEmpty())
+ return;
+
+ // Grouped by destination, because moveMessages() takes one folder per call
+ // and a selection can span accounts with different trash folders.
+ QHash<QString, QStringList> byTrash;
+ QStringList unconfigured;
+ for (const QString &messageId : scope.messageIds) {
+ const QString path = m_model->messageById(messageId).filePath;
+ const Account account = accountForMessagePath(path);
+ if (account.trash.isEmpty()) {
+ unconfigured.append(messageId);
+ continue;
+ }
+ byTrash[account.maildir + QLatin1Char('/') + account.trash]
+ .append(messageId);
+ }
+
+ // Task 2 warns at config load; this is the second line of defence, for a
+ // user who never fixed it. Reported rather than silently doing nothing,
+ // and NOT tagged either: a `deleted` tag on a file still in the inbox is
+ // precisely the half-done state this item removes.
+ if (!unconfigured.isEmpty()) {
+ m_statusLabel->setText(
+ tr("%n message(s) could not be deleted: no trash folder is "
+ "configured for their account.", "", int(unconfigured.size())));
+ }
+
+ if (byTrash.isEmpty())
+ return;
+
+ for (auto it = byTrash.cbegin(); it != byTrash.cend(); ++it) {
+ sendMove(it.value(), it.key(),
+ { QStringLiteral("deleted"), kOriginTagPlaceholder() }, {},
+ tr("Delete"));
+ }
+
+ showTransientStatus(
+ tr("%1: %n message(s)", "", scope.messageCount).arg(tr("Delete")));
+}
+
+void MainWindow::restoreSelected()
+{
+ const QModelIndexList rows =
+ m_threadView->selectionModel()->selectedRows();
+ if (rows.isEmpty())
+ return;
+
+ const ActionScope scope = m_model->messageScopeFor(rows);
+ if (scope.messageIds.isEmpty())
+ return;
+
+ // Where each message came from, read back off its own tag. This is what
+ // the tag exists for: the file has moved, so nothing on disk and nothing
+ // in notmuch still records the original folder.
+ const QString prefix = QStringLiteral("deleted-from:");
+ QHash<QString, QStringList> byOrigin;
+ QStringList unknown;
+ for (const QString &messageId : scope.messageIds) {
+ const MessageNode node = m_model->messageById(messageId);
+ QString origin;
+ for (const QString &tag : node.tags) {
+ if (tag.startsWith(prefix)) {
+ origin = tag.mid(prefix.length());
+ break;
+ }
+ }
+ // An account prefix is needed to name a folder to the worker, which
+ // works in database-relative paths. The origin tag stores the folder
+ // relative to the ACCOUNT, so the two are recomposed here.
+ const Account account = accountForMessagePath(node.filePath);
+ if (origin.isEmpty() || account.maildir.isEmpty()) {
+ unknown.append(messageId);
+ continue;
+ }
+ byOrigin[account.maildir + QLatin1Char('/') + origin].append(messageId);
+ }
+
+ if (!unknown.isEmpty()) {
+ // No origin recorded, which is the case for mail deleted by an older
+ // version or tagged by hand. The tag comes off so the row stops
+ // claiming to be deleted, but no file moves: guessing a folder would
+ // put the message somewhere the user never had it.
+ sendMessageTagChange(unknown, {}, { QStringLiteral("deleted") },
+ tr("Undelete"));
+ m_undoStack.push(new MessageTagCommand(
+ this, unknown, {}, { QStringLiteral("deleted") }, tr("Undelete")));
+ }
+
+ for (auto it = byOrigin.cbegin(); it != byOrigin.cend(); ++it) {
+ sendMove(it.value(), it.key(), {},
+ { QStringLiteral("deleted"), kOriginTagPlaceholder() },
+ tr("Undelete"));
+ }
+
+ showTransientStatus(
+ tr("%1: %n message(s)", "", scope.messageCount).arg(tr("Undelete")));
+}
+
+void MainWindow::sendMove(const QStringList &messageIds,
+ const QString &destFolder, const QStringList &add,
+ const QStringList &remove,
+ const QString &description)
+{
+ if (messageIds.isEmpty() || destFolder.isEmpty())
+ return;
+
+ // Held during a sync for the same reason every tag write is: the worker's
+ // read-write open BLOCKS on notmuch's exclusive lock rather than failing,
+ // so sending now would freeze the worker for the rest of the run.
+ //
+ // A move is held as the MOVE it is, not decomposed into a tag edit. The
+ // held-edit queue carries tag changes only, so a move pushed through it
+ // would apply the tags and never move the file, which is worse than
+ // waiting: the message would read as deleted and still be in the inbox.
+ if (aSyncHoldsTheWriteLock()) {
+ m_heldMoves.append(
+ HeldMove{ messageIds, destFolder, add, remove, description });
+ m_statusLabel->setText(
+ tr("A sync is running; your change will be applied when it "
+ "finishes."));
+ updatePendingIndicator();
+ return;
+ }
+
+ // What to tag once the move is CONFIRMED. Tagging now would leave a
+ // message marked deleted in a folder it never left if the rename failed.
+ m_pendingMoves.insert(destFolder, PendingMove{ add, remove, description });
+
+ QMetaObject::invokeMethod(m_worker, "moveMessages", Qt::QueuedConnection,
+ Q_ARG(QStringList, messageIds),
+ Q_ARG(QString, destFolder));
+}
+
+void MainWindow::onMessagesMoved(const QMap<QString, QString> &originByMessageId,
+ const QString &destFolder)
+{
+ const PendingMove pending = m_pendingMoves.take(destFolder);
+ if (originByMessageId.isEmpty())
+ return;
+
+ // The origin differs per message, so the tags do too: two messages deleted
+ // from different folders get different `deleted-from:` tags out of one
+ // gesture. Grouped by the resolved tag list so identical ones still travel
+ // as a single write.
+ QHash<QString, QStringList> byOrigin;
+ for (auto it = originByMessageId.cbegin(); it != originByMessageId.cend();
+ ++it) {
+ byOrigin[it.value()].append(it.key());
+ }
+
+ for (auto it = byOrigin.cbegin(); it != byOrigin.cend(); ++it) {
+ // The origin tag names the folder relative to the ACCOUNT, not to the
+ // database: `inbox`, never `acct/inbox`. Restore recomposes the
+ // account prefix from the message's own path, so storing it here would
+ // duplicate it, and a stored account prefix would go stale the day the
+ // user renames a maildir.
+ //
+ // The worker reports `acct/inbox`; the account's own maildir is
+ // `acct`, so the stored tag is `inbox`. Resolved through the first
+ // message's path, which is still the account's whichever folder it
+ // sits in now.
+ const QString dbRelativeOrigin = it.key();
+ const Account account = accountForMessagePath(dbRelativeOrigin
+ + QLatin1Char('/'));
+ QString accountRelative = dbRelativeOrigin;
+ if (!account.maildir.isEmpty()
+ && dbRelativeOrigin.startsWith(account.maildir
+ + QLatin1Char('/'))) {
+ accountRelative =
+ dbRelativeOrigin.mid(account.maildir.length() + 1);
+ }
+
+ auto resolve = [&](const QStringList &tags) {
+ QStringList out;
+ for (const QString &tag : tags) {
+ if (tag != kOriginTagPlaceholder()) {
+ out.append(tag);
+ continue;
+ }
+ if (!accountRelative.isEmpty()) {
+ out.append(QStringLiteral("deleted-from:%1")
+ .arg(accountRelative));
+ }
+ }
+ return out;
+ };
+
+ sendMessageTagChange(it.value(), resolve(pending.add),
+ resolve(pending.remove), pending.description);
+ }
+
+ // Pushed only now, because the origins are what makes the command
+ // reversible and they do not exist until the worker reports them. See
+ // MoveCommand: the destination has to be carried rather than derived.
+ m_undoStack.push(new MoveCommand(this, originByMessageId, destFolder,
+ pending.add, pending.remove,
+ pending.description));
+}
+
void MainWindow::sendThreadTagChange(const QStringList &threadIds,
const QStringList &add,
const QStringList &remove,
diff --git a/src/mainwindow.h b/src/mainwindow.h
index e741416..ada4845 100644
--- a/src/mainwindow.h
+++ b/src/mainwindow.h
@@ -739,6 +739,56 @@ private:
const QStringList &remove,
const QString &description);
+ /// Moves messages into `destFolder` and applies the tags that go with it.
+ ///
+ /// The counterpart to sendMessageTagChange() for the one action that is
+ /// not purely a tag change. Both trashSelected() and MoveCommand route
+ /// through this.
+ ///
+ /// The tags are NOT applied here: they are applied when the worker
+ /// confirms the move, in onMessagesMoved(). Tagging first would leave a
+ /// message marked `deleted` in a folder it never left if the rename
+ /// failed, which is the half-done state item 103 exists to remove.
+ ///
+ /// `add` may contain the placeholder kOriginTagPlaceholder, which
+ /// onMessagesMoved() replaces with `deleted-from:<origin>` per message.
+ /// The origin is not known until the worker reports it, and it differs per
+ /// message in a multi-row selection.
+ void sendMove(const QStringList &messageIds, const QString &destFolder,
+ const QStringList &add, const QStringList &remove,
+ const QString &description);
+
+ /// Moves each selected row's message to its account's trash, tagging it
+ /// `deleted` and recording where it came from.
+ void trashSelected();
+
+ /// The inverse: moves each selected row's message back to the folder its
+ /// `deleted-from:` tag names, stripping both tags.
+ void restoreSelected();
+
+ /// The account whose maildir contains `path`, or an invalid account when
+ /// no configured maildir does.
+ ///
+ /// Resolved from the PATH rather than from the thread's account tag. The
+ /// tag is optional config, so an account without one would resolve to
+ /// nothing and silently disable Delete; the maildir prefix is what makes
+ /// a message belong to an account in the first place.
+ Account accountForMessagePath(const QString &path) const;
+
+ /// Confirms a move: applies the tags the move was asked to carry, with the
+ /// origin placeholder resolved per message.
+ void onMessagesMoved(const QMap<QString, QString> &originByMessageId,
+ const QString &destFolder);
+
+ /// What a move asked to be tagged, held until the worker confirms it.
+ /// Keyed by destination folder so two moves in flight cannot be confused.
+ struct PendingMove {
+ QStringList add;
+ QStringList remove;
+ QString description;
+ };
+ QHash<QString, PendingMove> m_pendingMoves;
+
/// Undoes the optimistic model update for a write the worker rejected.
void revertPendingTagChange();
@@ -791,10 +841,35 @@ private:
/// it. Order matters: two edits touching one thread must reach the database
/// in the order they were made, or the later one does not win.
QVector<HeldEdit> m_heldEdits;
+
+ /// A MOVE not yet sent, for the same reason a tag edit is held.
+ ///
+ /// A separate queue rather than an entry in m_heldEdits, because a move is
+ /// not a tag change and cannot be replayed as one: pushing it through the
+ /// edit queue would apply `deleted` and never move the file, leaving the
+ /// message reading as deleted while still sitting in the inbox. Item 106
+ /// recorded what a dropped held edit costs, and a move dropped the same
+ /// way is worse: the tag lands and the file does not.
+ struct HeldMove {
+ QStringList messageIds;
+ QString destFolder;
+ QStringList add;
+ QStringList remove;
+ QString description;
+ };
+ QVector<HeldMove> m_heldMoves;
+
quint64 m_flushGeneration = 0;
friend class ThreadTagCommand;
friend class MessageTagCommand;
+ friend class MoveCommand;
+
+ /// Stands in for `deleted-from:<origin>` between asking for a move and
+ /// learning where each message actually came from. Not a tag anyone ever
+ /// sees: onMessagesMoved() substitutes the real one per message before
+ /// anything is written.
+ static const QString &kOriginTagPlaceholder();
Config m_config;
KeyMap m_keyMap;
@@ -1192,3 +1267,67 @@ private:
QString m_description;
bool m_firstRedo = true;
};
+
+/// Undo entry for a message MOVE, which is a file rename plus a tag change.
+///
+/// The destination is CARRIED rather than derived, and that is the whole
+/// reason `deleted-from:` exists at all. A Maildir filename does not record
+/// where a message came from, and once the file has moved notmuch cannot
+/// answer either, so an undo that recomputed the origin would have nothing to
+/// recompute it from. Each message carries its own, since one selection can
+/// span folders and accounts.
+///
+/// Grouped by destination: undoing a delete of five messages from three
+/// folders is three moves, not five, because moveMessages() takes one folder
+/// per call.
+class MoveCommand : public QUndoCommand
+{
+public:
+ /// `originByMessageId` names where each message came FROM, and
+ /// `destFolder` where they all went.
+ MoveCommand(MainWindow *window,
+ const QMap<QString, QString> &originByMessageId,
+ const QString &destFolder, const QStringList &add,
+ const QStringList &remove, const QString &description)
+ : QUndoCommand(description), m_window(window),
+ m_origins(originByMessageId), m_dest(destFolder), m_add(add),
+ m_remove(remove), m_description(description) {}
+
+ /// The stack calls redo() when the command is pushed, by which point the
+ /// move has already been sent, so the first call is skipped. Same shape as
+ /// the two tag commands above.
+ void redo() override
+ {
+ if (m_firstRedo) {
+ m_firstRedo = false;
+ return;
+ }
+ m_window->sendMove(m_origins.keys(), m_dest, m_add, m_remove,
+ m_description);
+ }
+
+ void undo() override
+ {
+ // Back to each message's OWN folder, one call per distinct
+ // destination. The tags invert with the direction: what the delete
+ // added, the undo removes.
+ QHash<QString, QStringList> byOrigin;
+ for (auto it = m_origins.cbegin(); it != m_origins.cend(); ++it) {
+ if (!it.value().isEmpty())
+ byOrigin[it.value()].append(it.key());
+ }
+ for (auto it = byOrigin.cbegin(); it != byOrigin.cend(); ++it) {
+ m_window->sendMove(it.value(), it.key(), m_remove, m_add,
+ QStringLiteral("Undo %1").arg(m_description));
+ }
+ }
+
+private:
+ MainWindow *m_window;
+ QMap<QString, QString> m_origins;
+ QString m_dest;
+ QStringList m_add;
+ QStringList m_remove;
+ QString m_description;
+ bool m_firstRedo = true;
+};
diff --git a/src/notmuchworker.cpp b/src/notmuchworker.cpp
index 6c41839..6a0694f 100644
--- a/src/notmuchworker.cpp
+++ b/src/notmuchworker.cpp
@@ -160,6 +160,39 @@ void walkReplies(notmuch_messages_t *messages, int depth,
}
}
+/// The Maildir FOLDER a message file sits in, relative to the database root.
+///
+/// `<root>/acct/inbox/cur/12345` becomes `acct/inbox`: the `cur`/`new` segment
+/// is stripped because it is Maildir's read-state bookkeeping rather than part
+/// of the folder's name, and moveMessages() takes a folder without one. That
+/// makes the value round-trip: what comes out here can be handed straight back
+/// to move a message home.
+///
+/// Empty when the file is not under the root at all, which the caller treats as
+/// "origin unknown" rather than guessing. A wrong folder here would send a
+/// restored message somewhere the user never had it.
+QString folderOfMessageFile(const QString &root, const QString &filePath)
+{
+ const QString rootPath = QDir(root).absolutePath();
+ const QString dir = QFileInfo(filePath).absolutePath();
+
+ const QString relative = QDir(rootPath).relativeFilePath(dir);
+ // relativeFilePath happily walks upwards, so a path outside the root comes
+ // back as `../something` rather than as a failure.
+ if (relative.isEmpty() || relative == QStringLiteral(".")
+ || relative.startsWith(QStringLiteral("../"))) {
+ return QString();
+ }
+
+ QStringList parts = relative.split(QLatin1Char('/'), Qt::SkipEmptyParts);
+ if (!parts.isEmpty()
+ && (parts.last() == QStringLiteral("cur")
+ || parts.last() == QStringLiteral("new"))) {
+ parts.removeLast();
+ }
+ return parts.join(QLatin1Char('/'));
+}
+
} // namespace
/// Registers SortOrder for queued calls, once, before main() runs.
@@ -254,6 +287,13 @@ void NotmuchWorker::runQuery(const QString &query, quint64 generation,
}
NmThreads threads(rawThreads);
+ // Message paths are reported RELATIVE to this. An absolute path would be
+ // useless to the UI, which knows accounts only by their maildir, a
+ // database-relative prefix: comparing the two never matched and left every
+ // row resolving to no account at all.
+ const QString dbRoot =
+ QDir(QString::fromUtf8(notmuch_database_get_path(m_db))).absolutePath();
+
QVector<ThreadSummary> batch;
batch.reserve(kBatchSize);
int total = 0;
@@ -317,6 +357,10 @@ void NotmuchWorker::runQuery(const QString &query, quint64 generation,
// The card's own tags, beside the thread's union above.
// Same walk, same index read, no extra query.
summary.firstMessageTags = tagsOf(message);
+ // Which account this belongs to, for Delete's destination.
+ summary.firstMessagePath = QDir(dbRoot).relativeFilePath(
+ QString::fromUtf8(
+ notmuch_message_get_filename(message)));
break;
}
}
@@ -329,6 +373,10 @@ void NotmuchWorker::runQuery(const QString &query, quint64 generation,
// The card's own tags, beside the thread's union above.
// Same walk, same index read, no extra query.
summary.firstMessageTags = tagsOf(first);
+ // Which account this belongs to, for Delete's destination.
+ summary.firstMessagePath = QDir(dbRoot).relativeFilePath(
+ QString::fromUtf8(
+ notmuch_message_get_filename(first)));
}
}
}
@@ -649,6 +697,7 @@ void NotmuchWorker::moveMessages(const QStringList &messageIds,
root + QLatin1Char('/') + destFolder + QStringLiteral("/cur");
QStringList moved;
+ QMap<QString, QString> origins;
for (const QString &id : messageIds) {
notmuch_message_t *raw = nullptr;
// find_message reports SUCCESS with a null message when the id is not
@@ -667,6 +716,10 @@ void NotmuchWorker::moveMessages(const QStringList &messageIds,
// The handle is released before the file moves under it.
message.reset();
+ // Where it is coming FROM, captured here because this is the only
+ // moment the old filename exists. See messagesMovedFrom().
+ const QString origin = folderOfMessageFile(root, from);
+
// cur/, never new/. A file dropped in new/ is re-announced as fresh
// mail by every reader of the Maildir.
if (!QDir().mkpath(destDir)) {
@@ -680,6 +733,7 @@ void NotmuchWorker::moveMessages(const QStringList &messageIds,
// Already where it was asked to go. Reported as moved, since the
// caller's request is satisfied.
moved.append(id);
+ origins.insert(id, origin);
continue;
}
@@ -712,12 +766,14 @@ void NotmuchWorker::moveMessages(const QStringList &messageIds,
notmuch_database_remove_message(db, from.toUtf8().constData());
moved.append(id);
+ origins.insert(id, origin);
}
notmuch_database_close(db);
notmuch_database_destroy(db);
emit messagesMoved(moved, destFolder);
+ emit messagesMovedFrom(origins, destFolder);
}
void NotmuchWorker::requestAllTags(quint64 generation)
diff --git a/src/notmuchworker.h b/src/notmuchworker.h
index d8d8ff8..d1bec59 100644
--- a/src/notmuchworker.h
+++ b/src/notmuchworker.h
@@ -18,6 +18,7 @@
#pragma once
+#include <QMap>
#include <QObject>
#include <QStringList>
#include <QVector>
@@ -202,6 +203,25 @@ signals:
/// A stale id, a missing folder or a failed rename drops out here rather
/// than aborting the batch.
void messagesMoved(const QStringList &messageIds, const QString &destFolder);
+
+ /// The same move, reported per message with the folder it came FROM.
+ ///
+ /// Emitted alongside messagesMoved rather than replacing it: that signal's
+ /// shape is what test_notmuchworker asserts on, and a caller wanting only
+ /// "did it move" should not have to unpack a map.
+ ///
+ /// The origin has to be reported from HERE because nowhere else knows it.
+ /// A Maildir filename does not record the folder a message came from, and
+ /// once the file has moved notmuch cannot answer either; the UI holds no
+ /// path at all for a thread row it has not expanded. This is the one
+ /// moment the old filename exists, so it is the only place the origin can
+ /// be derived.
+ ///
+ /// Folders are relative to the database path and carry no `cur`/`new`
+ /// segment, matching the `destFolder` moveMessages() takes, so a value
+ /// from here can be passed straight back to move a message home.
+ void messagesMovedFrom(const QMap<QString, QString> &originByMessageId,
+ const QString &destFolder);
void allTagsReady(const QStringList &tags, quint64 generation);
/// One entry per requested query, in the order they were asked for. A query
diff --git a/src/threadlistmodel.cpp b/src/threadlistmodel.cpp
index 6ddd85c..6162a5f 100644
--- a/src/threadlistmodel.cpp
+++ b/src/threadlistmodel.cpp
@@ -696,6 +696,10 @@ ThreadListModel::nodeFor(const ThreadSummary &summary)
node.first.messageId = summary.firstMessageId;
node.first.threadId = summary.threadId;
node.first.tags = summary.firstMessageTags;
+ // Carried alongside the tags, for the same reason messageById()
+ // carries it onto a synthesised root: an unexpanded row has to know
+ // which account it belongs to before Delete can name a folder.
+ node.first.filePath = summary.firstMessagePath;
}
return node;
}
@@ -984,6 +988,12 @@ MessageNode ThreadListModel::messageById(const QString &messageId) const
root.subject = node.summary.subject;
root.date = node.summary.date;
root.tags = node.summary.tags;
+ // Carried from the query, so an UNEXPANDED row still knows which
+ // account it belongs to. Delete needs that to name a trash folder,
+ // and an unexpanded row is the ordinary case rather than an edge
+ // one: without this every thread row resolved to no account and
+ // Delete reported "no trash folder configured" for all of them.
+ root.filePath = node.summary.firstMessagePath;
return root;
}
@@ -1198,6 +1208,7 @@ void ThreadListModel::applyMessageTagChange(const QString &messageId,
node.first.messageId = node.summary.firstMessageId;
node.first.threadId = node.summary.threadId;
node.first.tags = node.summary.tags;
+ node.first.filePath = node.summary.firstMessagePath;
}
retag(node.first.tags);
diff --git a/src/types.h b/src/types.h
index 409ce79..f4d387a 100644
--- a/src/types.h
+++ b/src/types.h
@@ -62,6 +62,26 @@ struct ThreadSummary
/// file. Do not move it behind a flag by analogy with `recipients`.
QStringList firstMessageTags;
+ /// That message's file, RELATIVE to the database path, which is what says
+ /// which ACCOUNT it belongs to.
+ ///
+ /// Relative and not absolute, deliberately. The UI knows an account only
+ /// by its `maildir`, itself a database-relative prefix, so an absolute
+ /// path here matches no account and silently resolves every row to none.
+ ///
+ /// Needed because Delete moves the file (item 103) and the destination is
+ /// per account, so the action has to resolve an account before it can name
+ /// a trash folder. Resolving through the thread's account TAG instead is
+ /// not equivalent: that tag is optional config, so an account without one
+ /// would silently be undeletable, while a maildir prefix is what makes a
+ /// message belong to an account in the first place.
+ ///
+ /// Free for the same reason firstMessageId and firstMessageTags are: the
+ /// walk that finds that message is already happening, and this reads the
+ /// INDEX rather than the message file. Do not move it behind a flag by
+ /// analogy with `recipients`.
+ QString firstMessagePath;
+
/// Who the thread's messages were sent TO, summarised for one line.
///
/// Empty unless the query asked for it, and that is a performance
diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp
index 7316689..f4ad6a8 100644
--- a/tests/test_mainwindow.cpp
+++ b/tests/test_mainwindow.cpp
@@ -86,8 +86,17 @@ public:
/// `accountKey` and `accountMaildir` add one [account.<key>] section, which
/// is what makes runQuery() scope the bar's text with scopedQuery(). A test
/// that never selects an account can leave them empty.
+ ///
+ /// `accountTrash` writes that section's `trash` key, which Delete needs to
+ /// know where to move a file to. A DEFAULTED parameter rather than an
+ /// overload: an overload would have to repeat the whole body, and every
+ /// existing caller passes no account at all and so writes no section and
+ /// no trash key either. A caller that names an account and wants Delete to
+ /// work has to say where its trash is, which is the same requirement the
+ /// real config imposes.
bool build(const QString &accountKey = QString(),
- const QString &accountMaildir = QString())
+ const QString &accountMaildir = QString(),
+ const QString &accountTrash = QString())
{
if (!m_fixture.isValid()) {
m_error = QStringLiteral("fixture directory invalid");
@@ -123,6 +132,8 @@ public:
// so the section is [account.key], never [account/key].
out << "\n[account." << accountKey << "]\n"
<< "maildir=" << accountMaildir << "\n";
+ if (!accountTrash.isEmpty())
+ out << "trash=" << accountTrash << "\n";
}
}
file.close();
@@ -347,6 +358,12 @@ private slots:
void anEditedQueryKeepsItsUnknownFields();
void renamingReplacesRatherThanDuplicating();
+ void deleteMovesTheMessageToTrash();
+ void deleteRecordsWhereTheMessageCameFrom();
+ void undoMovesTheMessageBack();
+ void deleteOnAReplyMovesThatReplyOnly();
+ void deleteWithoutATrashFolderSaysSoRatherThanDoingNothing();
+
private:
/// Owns the throwaway lock table init() points every test at. A pointer
/// rather than a value because it is rebuilt per test, and QTemporaryDir
@@ -4737,6 +4754,16 @@ static QModelIndex expandSecondThreadAndSelectItsReply(
void TestMainWindow::deleteOnAReplyReadsItsOwnThreadNotTheFirstInTheList()
{
+ // Item 88's trap, still live: a toggle must read the state of the row it
+ // is on, not of whichever thread sits at that row NUMBER in the list.
+ //
+ // Through `delete_thread` rather than `delete`. Since item 103 Delete
+ // MOVES the file, so it is no longer a pure toggle over a tag and needs a
+ // configured trash folder and a worker; `delete_thread` is the variant
+ // that stayed tag-only, and it is a toggle over `deleted` exactly as
+ // Delete used to be. The message-scoped Delete's own direction choice is
+ // covered by the worker-backed cases at the bottom of this file, which is
+ // where a move can actually be observed.
const Config config;
MainWindow window(config);
@@ -4744,7 +4771,7 @@ void TestMainWindow::deleteOnAReplyReadsItsOwnThreadNotTheFirstInTheList()
QVERIFY(model);
auto *view = window.findChild<QTreeView *>();
QVERIFY(view);
- auto *action = window.findChild<QAction *>(QStringLiteral("delete"));
+ auto *action = window.findChild<QAction *>(QStringLiteral("delete_thread"));
QVERIFY(action);
// t1 deleted, t2 not. Reading t1's state for a reply of t2 makes the
@@ -4757,12 +4784,6 @@ void TestMainWindow::deleteOnAReplyReadsItsOwnThreadNotTheFirstInTheList()
action->trigger();
- // Delete, because the message's own thread is not deleted. The write goes
- // through scopeFor() and lands on the message either way; what is under
- // test is the DIRECTION, which is chosen from the state that was read.
- QVERIFY2(window.pendingMessageIdsForTesting().contains(
- QStringLiteral("m1@example.org")),
- "Delete on a reply did not act on that reply");
QCOMPARE(window.undoDepthForTesting(), 1);
QVERIFY2(window.undoTextForTesting().contains(QStringLiteral("Delete")),
qPrintable(QStringLiteral(
@@ -4985,6 +5006,11 @@ void TestMainWindow::markCurrentThreadReadResolvesTheThreadThroughTheIndex()
void TestMainWindow::deletingAReplyRepaintsThatReplyRow()
{
+ // `spam`, not `delete`. Since item 103 Delete MOVES the file, so it needs
+ // an account with a configured trash folder and a worker to do the move;
+ // this bare window has neither, and Delete correctly refuses. What is
+ // under test here is unchanged by that: `spam` is the other message-scoped
+ // tag-only action, and it paints the same doomed state.
// The user's report, at the gesture level: "I'm hitting delete on a reply
// to a thread, I see the edits counter increasing but I have no feedback
// if that message is being deleted." The model-level test proves
@@ -4997,7 +5023,7 @@ void TestMainWindow::deletingAReplyRepaintsThatReplyRow()
QVERIFY(model);
auto *view = window.findChild<QTreeView *>();
QVERIFY(view);
- auto *action = window.findChild<QAction *>(QStringLiteral("delete"));
+ auto *action = window.findChild<QAction *>(QStringLiteral("spam"));
QVERIFY(action);
const QModelIndex reply =
@@ -5006,13 +5032,13 @@ void TestMainWindow::deletingAReplyRepaintsThatReplyRow()
// Nothing to see before the gesture, so the assertion after it means
// something.
- QVERIFY(!model->messageAt(reply).isDeleted());
+ QVERIFY(!model->messageAt(reply).isSpam());
const QVariant before = model->data(reply, Qt::BackgroundRole);
QSignalSpy spy(model, &QAbstractItemModel::dataChanged);
action->trigger();
- QVERIFY2(model->messageAt(reply).isDeleted(),
+ QVERIFY2(model->messageAt(reply).isSpam(),
"Delete on a reply left the reply's own row unchanged, so the "
"pending count moved and the user saw nothing");
QVERIFY2(spy.count() >= 1, "no repaint was requested for the reply's row");
@@ -5022,7 +5048,7 @@ void TestMainWindow::deletingAReplyRepaintsThatReplyRow()
// The THREAD row must not follow: it stands for the whole conversation,
// and one deleted reply does not doom it.
const QModelIndex threadRow = reply.parent();
- QVERIFY2(!model->threadFor(threadRow).isDeleted(),
+ QVERIFY2(!model->threadFor(threadRow).isSpam(),
"deleting one reply marked its whole thread deleted");
}
@@ -5113,6 +5139,11 @@ void TestMainWindow::toggleUnreadOnAReplyRepaintsItInBothDirections()
void TestMainWindow::taggingTheOpenReplyUpdatesTheMessagePaneStrip()
{
+ // `spam`, not `delete`. Since item 103 Delete MOVES the file, so it needs
+ // an account with a configured trash folder and a worker to do the move;
+ // this bare window has neither, and Delete correctly refuses. What is
+ // under test here is unchanged by that: `spam` is the other message-scoped
+ // tag-only action, and it paints the same doomed state.
// The user's report: "the right pane chips are not [repainted], for it to
// sync I have to change message and go back to the edited one".
//
@@ -5136,7 +5167,7 @@ void TestMainWindow::taggingTheOpenReplyUpdatesTheMessagePaneStrip()
const auto stripTags = [strip]() {
return strip->visibleTags() + strip->hiddenTags();
};
- auto *action = window.findChild<QAction *>(QStringLiteral("delete"));
+ auto *action = window.findChild<QAction *>(QStringLiteral("spam"));
QVERIFY(action);
// A tag the strip will actually draw. Account tags are filtered out by the
@@ -5151,11 +5182,11 @@ void TestMainWindow::taggingTheOpenReplyUpdatesTheMessagePaneStrip()
QVERIFY2(stripTags().contains(QStringLiteral("todo")),
"the strip does not show the selected reply's tags, so this test "
"cannot tell a missing refresh from a strip that never had them");
- QVERIFY(!stripTags().contains(QStringLiteral("deleted")));
+ QVERIFY(!stripTags().contains(QStringLiteral("spam")));
action->trigger();
- QVERIFY2(stripTags().contains(QStringLiteral("deleted")),
+ QVERIFY2(stripTags().contains(QStringLiteral("spam")),
"the message pane's chips still describe the reply as it was "
"before the edit; the user has to select away and back to see it");
}
@@ -5231,6 +5262,11 @@ void TestMainWindow::taggingAnUnrelatedReplyLeavesTheStripAlone()
void TestMainWindow::aHeldMessageEditIsSentWhenTheSyncEnds()
{
+ // `spam`, not `delete`. Since item 103 Delete MOVES the file, so it needs
+ // an account with a configured trash folder and a worker to do the move;
+ // this bare window has neither, and Delete correctly refuses. What is
+ // under test here is unchanged by that: `spam` is the other message-scoped
+ // tag-only action, and it paints the same doomed state.
// Found by reading while fixing the strip refresh, not reported.
//
// flushHeldEdits() looped over edit.threadIds and called
@@ -5246,7 +5282,7 @@ void TestMainWindow::aHeldMessageEditIsSentWhenTheSyncEnds()
QVERIFY(model);
auto *view = window.findChild<QTreeView *>();
QVERIFY(view);
- auto *action = window.findChild<QAction *>(QStringLiteral("delete"));
+ auto *action = window.findChild<QAction *>(QStringLiteral("spam"));
QVERIFY(action);
const QModelIndex reply =
@@ -5281,12 +5317,17 @@ void TestMainWindow::aHeldMessageEditIsSentWhenTheSyncEnds()
// And the row still shows it: the flush takes the optimistic update back
// before re-sending, so a bug there leaves the row wrong in the other
// direction.
- QVERIFY2(model->messageAt(reply).isDeleted(),
+ QVERIFY2(model->messageAt(reply).isSpam(),
"sending the held edit lost the tag from the reply's row");
}
void TestMainWindow::anActionOnAThreadRowActsOnTheMessageItDisplays()
{
+ // `spam`, not `delete`. Since item 103 Delete MOVES the file, so it needs
+ // an account with a configured trash folder and a worker to do the move;
+ // this bare window has neither, and Delete correctly refuses. What is
+ // under test here is unchanged by that: `spam` is the other message-scoped
+ // tag-only action, and it paints the same doomed state.
// Item 108, the whole point of it. A root card renders ONE message since
// item 66, so acting on it acts on that message; the conversation is
// reached through the explicit thread actions.
@@ -5303,7 +5344,7 @@ void TestMainWindow::anActionOnAThreadRowActsOnTheMessageItDisplays()
model->appendBatch({ t });
selectThreadRow(view, 0);
- auto *deleteAction = window.findChild<QAction *>(QStringLiteral("delete"));
+ auto *deleteAction = window.findChild<QAction *>(QStringLiteral("spam"));
QVERIFY(deleteAction);
deleteAction->trigger();
@@ -5501,6 +5542,11 @@ void TestMainWindow::autoMarkReadArmsForAReplyToo()
void TestMainWindow::taggingTheOpenRootMessageKeepsTheStripPopulated()
{
+ // `spam`, not `delete`. Since item 103 Delete MOVES the file, so it needs
+ // an account with a configured trash folder and a worker to do the move;
+ // this bare window has neither, and Delete correctly refuses. What is
+ // under test here is unchanged by that: `spam` is the other message-scoped
+ // tag-only action, and it paints the same doomed state.
// The user, 2026-08-16: "right pane loses the chip row when repainting, it
// simply disappears".
//
@@ -5543,7 +5589,7 @@ void TestMainWindow::taggingTheOpenRootMessageKeepsTheStripPopulated()
QVERIFY2(stripTags().contains(QStringLiteral("todo")),
"the strip never showed the selected thread's tags");
- auto *action = window.findChild<QAction *>(QStringLiteral("delete"));
+ auto *action = window.findChild<QAction *>(QStringLiteral("spam"));
QVERIFY(action);
action->trigger();
@@ -5553,7 +5599,7 @@ void TestMainWindow::taggingTheOpenRootMessageKeepsTheStripPopulated()
"and set the strip to the resulting empty tag list");
QVERIFY2(stripTags().contains(QStringLiteral("todo")),
"the strip lost the tag the message still carries");
- QVERIFY2(stripTags().contains(QStringLiteral("deleted")),
+ QVERIFY2(stripTags().contains(QStringLiteral("spam")),
"the strip did not pick up the tag just written");
}
@@ -8698,4 +8744,333 @@ void TestMainWindow::aSingleMessageIdQuerysCardOpensInTheMessagePane()
QTRY_VERIFY_WITH_TIMEOUT(!pane->showingPlaceholder(), 15000);
}
+/// Whether any file in `dir` belongs to the message whose filename starts with
+/// `stem`.
+///
+/// A Maildir filename is NOT stable across a move, which is the trap this
+/// exists to avoid. `maildir.synchronize_flags` is on, so notmuch rewrites the
+/// name to carry the read/seen flags: a message that leaves `new/del1.x` lands
+/// as `cur/del1.x:2,S`. Asserting on the exact basename therefore fails
+/// against a move that worked perfectly, which is how three of these tests
+/// first "failed".
+static bool folderHasMessageFile(const QString &dir, const QString &stem)
+{
+ QDir directory(dir);
+ if (!directory.exists())
+ return false;
+ const QStringList entries = directory.entryList(QDir::Files);
+ for (const QString &entry : entries) {
+ if (entry == stem || entry.startsWith(stem + QLatin1Char(':')))
+ return true;
+ }
+ return false;
+}
+
+void TestMainWindow::deleteMovesTheMessageToTrash()
+{
+ // The whole point of item 103. Before it, Delete added a tag and moved no
+ // file, so deleted mail sat in the inbox for good.
+ WorkerBackedWindow backed;
+ QVERIFY(backed.fixture().addMessage(
+ QStringLiteral("acct/inbox"), QStringLiteral("del1@example.org"),
+ QStringLiteral("Delete me"), QStringLiteral("sender@example.org"),
+ // Friday, verified with `date -d 2026-08-14 +%A`.
+ QStringLiteral("Fri, 14 Aug 2026 10:00:00 +0200"),
+ QStringLiteral("Body text.")));
+ QVERIFY2(backed.build(QStringLiteral("acct"), QStringLiteral("acct"),
+ QStringLiteral("Trash")),
+ qPrintable(backed.error()));
+
+ MainWindow window(backed.config());
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ auto *view = window.findChild<ThreadListView *>();
+ QVERIFY(view);
+ auto *queryEdit =
+ window.findChild<QLineEdit *>(QStringLiteral("queryEdit"));
+ QVERIFY(queryEdit);
+
+ queryEdit->setText(QStringLiteral("tag:inbox"));
+ queryEdit->returnPressed();
+ QTRY_VERIFY_WITH_TIMEOUT(model->rowCount(QModelIndex()) == 1, 15000);
+
+ const QString root = backed.fixture().maildirPath();
+ const QString inbox = root + QStringLiteral("/acct/inbox/new");
+ const QString stem = QStringLiteral("del1.example.org");
+ QVERIFY(folderHasMessageFile(inbox, stem));
+
+ view->setCurrentIndex(model->index(0, 0, QModelIndex()));
+
+ auto *del = window.findChild<QAction *>(QStringLiteral("delete"));
+ QVERIFY(del);
+ del->trigger();
+
+ // The filesystem half. cur/, never new/: a file in new/ is re-announced as
+ // fresh mail by every reader of the Maildir.
+ const QString trash = root + QStringLiteral("/acct/Trash/cur");
+ QTRY_VERIFY_WITH_TIMEOUT(folderHasMessageFile(trash, stem), 15000);
+ QVERIFY2(!folderHasMessageFile(inbox, stem),
+ "the file is in the trash and still in the inbox");
+ QVERIFY2(!folderHasMessageFile(root + QStringLiteral("/acct/inbox/cur"),
+ stem),
+ "the file is in the trash and still in the inbox");
+
+ // The index half, which the filesystem cannot see. A moved file with a
+ // stale index entry sits correctly on disk and is invisible to every query.
+ queryEdit->setText(QStringLiteral("path:\"acct/Trash/**\""));
+ queryEdit->returnPressed();
+ QTRY_VERIFY_WITH_TIMEOUT(model->rowCount(QModelIndex()) == 1, 15000);
+}
+
+void TestMainWindow::deleteRecordsWhereTheMessageCameFrom()
+{
+ // A Maildir filename does not record where a message came from, and once
+ // the file has moved notmuch cannot know either. The tag is the only
+ // record, and Restore needs it days later.
+ WorkerBackedWindow backed;
+ QVERIFY(backed.fixture().addMessage(
+ QStringLiteral("acct/inbox"), QStringLiteral("del2@example.org"),
+ QStringLiteral("Delete me too"), QStringLiteral("sender@example.org"),
+ QStringLiteral("Fri, 14 Aug 2026 10:00:00 +0200"),
+ QStringLiteral("Body text.")));
+ QVERIFY2(backed.build(QStringLiteral("acct"), QStringLiteral("acct"),
+ QStringLiteral("Trash")),
+ qPrintable(backed.error()));
+
+ MainWindow window(backed.config());
+ auto *model = window.findChild<ThreadListModel *>();
+ auto *view = window.findChild<ThreadListView *>();
+ auto *queryEdit =
+ window.findChild<QLineEdit *>(QStringLiteral("queryEdit"));
+ QVERIFY(model && view && queryEdit);
+
+ queryEdit->setText(QStringLiteral("tag:inbox"));
+ queryEdit->returnPressed();
+ QTRY_VERIFY_WITH_TIMEOUT(model->rowCount(QModelIndex()) == 1, 15000);
+
+ view->setCurrentIndex(model->index(0, 0, QModelIndex()));
+ window.findChild<QAction *>(QStringLiteral("delete"))->trigger();
+
+ // Asked of the database, not of the model: the model's optimistic update
+ // would report the tag whether or not the write ever landed.
+ // Re-queried by id and asserted on the TAG LIST the database returns.
+ //
+ // Not with `tag:"deleted-from:inbox"` in the query: notmuch's parser does
+ // not match a quoted tag containing a colon that way, so such a query
+ // returns nothing against a perfectly tagged message and reads as the
+ // feature being broken. Asking for the message and inspecting its tags
+ // cannot fail that way.
+ // Re-run per attempt, not once. The tag write is QUEUED behind the move,
+ // so a single query can land before the tags do; QTRY_VERIFY on the
+ // model's contents would then re-test a result that can never change,
+ // because nothing re-asks the database. Asking again each time is what
+ // makes this wait for the write rather than for the clock.
+ bool tagged = false;
+ for (int attempt = 0; attempt < 30 && !tagged; ++attempt) {
+ queryEdit->setText(QStringLiteral("id:del2@example.org"));
+ queryEdit->returnPressed();
+ QTRY_VERIFY_WITH_TIMEOUT(model->rowCount(QModelIndex()) == 1, 15000);
+ const QStringList tags = model->threadAt(0).tags;
+ tagged = tags.contains(QStringLiteral("deleted"))
+ && tags.contains(QStringLiteral("deleted-from:inbox"));
+ if (!tagged)
+ QTest::qWait(200);
+ }
+ QVERIFY2(tagged,
+ qPrintable(QStringLiteral("tags after the delete: %1")
+ .arg(model->threadAt(0).tags.join(
+ QLatin1Char(' ')))));
+}
+
+void TestMainWindow::undoMovesTheMessageBack()
+{
+ // Undo is this project's answer to the confirmation dialog it rules out,
+ // so a delete that cannot be undone is a delete with no safety net at all.
+ WorkerBackedWindow backed;
+ QVERIFY(backed.fixture().addMessage(
+ QStringLiteral("acct/inbox"), QStringLiteral("del3@example.org"),
+ QStringLiteral("Put me back"), QStringLiteral("sender@example.org"),
+ QStringLiteral("Fri, 14 Aug 2026 10:00:00 +0200"),
+ QStringLiteral("Body text.")));
+ QVERIFY2(backed.build(QStringLiteral("acct"), QStringLiteral("acct"),
+ QStringLiteral("Trash")),
+ qPrintable(backed.error()));
+
+ MainWindow window(backed.config());
+ auto *model = window.findChild<ThreadListModel *>();
+ auto *view = window.findChild<ThreadListView *>();
+ auto *queryEdit =
+ window.findChild<QLineEdit *>(QStringLiteral("queryEdit"));
+ QVERIFY(model && view && queryEdit);
+
+ queryEdit->setText(QStringLiteral("tag:inbox"));
+ queryEdit->returnPressed();
+ QTRY_VERIFY_WITH_TIMEOUT(model->rowCount(QModelIndex()) == 1, 15000);
+
+ const QString root = backed.fixture().maildirPath();
+ const QString stem = QStringLiteral("del3.example.org");
+ const QString trash = root + QStringLiteral("/acct/Trash/cur");
+
+ view->setCurrentIndex(model->index(0, 0, QModelIndex()));
+ window.findChild<QAction *>(QStringLiteral("delete"))->trigger();
+ QTRY_VERIFY_WITH_TIMEOUT(folderHasMessageFile(trash, stem), 15000);
+
+ window.findChild<QAction *>(QStringLiteral("undo"))->trigger();
+
+ // Back in the EXACT folder it came from. A move-back that guessed "inbox"
+ // for every account would pass a laxer assertion than this one.
+ //
+ // cur/, not the new/ it started in: a file coming back from the trash has
+ // been read, and re-announcing it as fresh mail is worse than the flag
+ // change.
+ QTRY_VERIFY_WITH_TIMEOUT(
+ folderHasMessageFile(root + QStringLiteral("/acct/inbox/cur"), stem)
+ || folderHasMessageFile(root + QStringLiteral("/acct/inbox/new"),
+ stem),
+ 15000);
+ QVERIFY2(!folderHasMessageFile(trash, stem),
+ "undo restored the file and left a copy in the trash");
+
+ // Both tags gone, asked of the database. `deleted-from:` left behind would
+ // make Restore offer to move a message that is already home.
+ queryEdit->setText(QStringLiteral(
+ "id:del3@example.org and (tag:deleted or tag:\"deleted-from:inbox\")"));
+ queryEdit->returnPressed();
+ QTRY_VERIFY_WITH_TIMEOUT(model->rowCount(QModelIndex()) == 0, 15000);
+ // The guard the assertion above needs: a query that matches nothing
+ // because the message vanished would pass it too.
+ queryEdit->setText(QStringLiteral("id:del3@example.org"));
+ queryEdit->returnPressed();
+ QTRY_VERIFY_WITH_TIMEOUT(model->rowCount(QModelIndex()) == 1, 15000);
+}
+
+void TestMainWindow::deleteOnAReplyMovesThatReplyOnly()
+{
+ // The reply case. A test asserting on a root selection is the one case
+ // where the wrong resolution is accidentally right, so a mutation on this
+ // path stays green without it.
+ //
+ // Put under the SECOND thread, so the wrong answer is plausible rather
+ // than accidentally correct.
+ WorkerBackedWindow backed;
+ NotmuchFixture &fx = backed.fixture();
+ QVERIFY(fx.addMessage(
+ QStringLiteral("acct/inbox"), QStringLiteral("other@example.org"),
+ QStringLiteral("An unrelated thread"),
+ QStringLiteral("sender@example.org"),
+ QStringLiteral("Fri, 14 Aug 2026 09:00:00 +0200"),
+ QStringLiteral("Body text.")));
+ QVERIFY(fx.addMessage(
+ QStringLiteral("acct/inbox"), QStringLiteral("rootof@example.org"),
+ QStringLiteral("A conversation"), QStringLiteral("sender@example.org"),
+ QStringLiteral("Fri, 14 Aug 2026 10:00:00 +0200"),
+ QStringLiteral("Body text.")));
+ QVERIFY(fx.addMessage(
+ QStringLiteral("acct/inbox"), QStringLiteral("reply@example.org"),
+ QStringLiteral("Re: A conversation"),
+ QStringLiteral("other@example.org"),
+ QStringLiteral("Fri, 14 Aug 2026 11:00:00 +0200"),
+ QStringLiteral("Reply body."), true,
+ QStringLiteral("rootof@example.org")));
+ QVERIFY2(backed.build(QStringLiteral("acct"), QStringLiteral("acct"),
+ QStringLiteral("Trash")),
+ qPrintable(backed.error()));
+
+ MainWindow window(backed.config());
+ auto *model = window.findChild<ThreadListModel *>();
+ auto *view = window.findChild<ThreadListView *>();
+ auto *queryEdit =
+ window.findChild<QLineEdit *>(QStringLiteral("queryEdit"));
+ QVERIFY(model && view && queryEdit);
+
+ queryEdit->setText(QStringLiteral("tag:inbox"));
+ queryEdit->returnPressed();
+ QTRY_VERIFY_WITH_TIMEOUT(model->rowCount(QModelIndex()) == 2, 15000);
+
+ // Whichever row holds the conversation. The sort is the model's business,
+ // so this asks rather than assuming.
+ QModelIndex conversation;
+ for (int row = 0; row < model->rowCount(QModelIndex()); ++row) {
+ const QModelIndex index = model->index(row, 0, QModelIndex());
+ if (model->threadAt(row).totalCount > 1) {
+ conversation = index;
+ break;
+ }
+ }
+ QVERIFY2(conversation.isValid(), "no multi-message thread in the list");
+
+ view->expand(conversation);
+ QTRY_VERIFY_WITH_TIMEOUT(model->rowCount(conversation) == 1, 15000);
+
+ const QModelIndex replyIndex = model->index(0, 0, conversation);
+ QVERIFY(model->isMessageRow(replyIndex));
+ QCOMPARE(model->messageAt(replyIndex).messageId,
+ QStringLiteral("reply@example.org"));
+
+ view->setCurrentIndex(replyIndex);
+ window.findChild<QAction *>(QStringLiteral("delete"))->trigger();
+
+ const QString root = fx.maildirPath();
+ QTRY_VERIFY_WITH_TIMEOUT(
+ folderHasMessageFile(root + QStringLiteral("/acct/Trash/cur"),
+ QStringLiteral("reply.example.org")),
+ 15000);
+
+ // Only that reply. Escalating a message-scoped delete to its thread would
+ // move the root as well, which is the failure worth naming: the user
+ // deleted one reply and lost the conversation.
+ QVERIFY2(!folderHasMessageFile(root + QStringLiteral("/acct/Trash/cur"),
+ QStringLiteral("rootof.example.org")),
+ "deleting a reply moved its thread's root as well");
+}
+
+void TestMainWindow::deleteWithoutATrashFolderSaysSoRatherThanDoingNothing()
+{
+ // Task 2 warns at config load. This is the second line of defence: a key
+ // the user never fixed must not leave Delete silently inert.
+ WorkerBackedWindow backed;
+ QVERIFY(backed.fixture().addMessage(
+ QStringLiteral("acct/inbox"), QStringLiteral("notrash@example.org"),
+ QStringLiteral("Nowhere to go"), QStringLiteral("sender@example.org"),
+ QStringLiteral("Fri, 14 Aug 2026 10:00:00 +0200"),
+ QStringLiteral("Body text.")));
+ // No trash key, which is what this is about.
+ QVERIFY2(backed.build(QStringLiteral("acct"), QStringLiteral("acct")),
+ qPrintable(backed.error()));
+
+ MainWindow window(backed.config());
+ auto *model = window.findChild<ThreadListModel *>();
+ auto *view = window.findChild<ThreadListView *>();
+ auto *queryEdit =
+ window.findChild<QLineEdit *>(QStringLiteral("queryEdit"));
+ auto *status = window.findChild<QLabel *>(QStringLiteral("statusMessage"));
+ QVERIFY(model && view && queryEdit && status);
+
+ queryEdit->setText(QStringLiteral("tag:inbox"));
+ queryEdit->returnPressed();
+ QTRY_VERIFY_WITH_TIMEOUT(model->rowCount(QModelIndex()) == 1, 15000);
+
+ view->setCurrentIndex(model->index(0, 0, QModelIndex()));
+
+ // Cleared FIRST, so the assertion below cannot be satisfied by whatever
+ // the query left behind. Without this the test passes against a Delete
+ // that says nothing at all, which is exactly what it exists to catch: it
+ // did, before the implementation landed.
+ status->clear();
+ window.findChild<QAction *>(QStringLiteral("delete"))->trigger();
+
+ QVERIFY2(status->text().contains(QStringLiteral("trash")),
+ qPrintable(QStringLiteral(
+ "Delete with no trash folder configured said: '%1'")
+ .arg(status->text())));
+
+ // And it did not tag the message either. A `deleted` tag with the file
+ // still in the inbox is exactly the half-done state item 103 removes.
+ const QString mail = backed.fixture().maildirPath();
+ QVERIFY(folderHasMessageFile(mail + QStringLiteral("/acct/inbox/new"),
+ QStringLiteral("notrash.example.org"))
+ || folderHasMessageFile(mail + QStringLiteral("/acct/inbox/cur"),
+ QStringLiteral("notrash.example.org")));
+}
+
#include "test_mainwindow.moc"
diff --git a/translations/qtmaildir_it_IT.ts b/translations/qtmaildir_it_IT.ts
index ef88967..89886d0 100644
--- a/translations/qtmaildir_it_IT.ts
+++ b/translations/qtmaildir_it_IT.ts
@@ -262,6 +262,13 @@
<source>Add or remove the deleted tag</source>
<translation>Aggiunge o rimuove l&apos;etichetta deleted</translation>
</message>
+ <message numerus="yes">
+ <source>%n message(s) could not be deleted: no trash folder is configured for their account.</source>
+ <translation>
+ <numerusform>%n messaggio non è stato eliminato: nessuna cartella cestino è configurata per il suo account.</numerusform>
+ <numerusform>%n messaggi non sono stati eliminati: nessuna cartella cestino è configurata per il loro account.</numerusform>
+ </translation>
+ </message>
<message>
<source>Undelete</source>
<translation>Ripristina</translation>