aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-18 12:35:05 +0200
committerDanilo M. <danix@danix.xyz>2026-08-18 12:41:45 +0200
commit601159309118cf65c73f5f50bb3cf216be9f1cbb (patch)
tree9cbf374538002193dcd373a46f7825a219617efb
parent4583de009571aaa674e7d161d31ec640860787e1 (diff)
downloadqtmaildir-601159309118cf65c73f5f50bb3cf216be9f1cbb.tar.gz
qtmaildir-601159309118cf65c73f5f50bb3cf216be9f1cbb.zip
feat(trash): restore mail from the trash view
Task 6. Delete moved mail into the trash and the only ways back out were a second press of Delete or Ctrl+Z, both of which act on a row the user has to have deleted in this session. Browsing the trash and putting something back needed an action of its own. `restore` is enabled from the QUERY, not from the selection's tags. The trash view is path-based precisely so that mail trashed by another client appears in it, and such a message carries no tag of ours: deciding from `tag:deleted` would disable Restore on exactly the messages that most need it. isShowingTrash() compares the current query against the trash generator's own, for both the per-account and the all-accounts scope, so it follows the account dropdown like every other filter. A message with NO origin tag is the foreign-trashed case, and it is why this is not simply restoreSelected() under a new name. The two callers want opposite things from a missing origin, which `fallbackToInbox` selects. From the trash view the message is demonstrably in the trash and refusing to move it leaves the user looking at mail they cannot get out, so it goes to the inbox and the status bar says so. From a second press of Delete the message is not in the trash at all and merely wears a stale `deleted` tag from an older version or a hand-written notmuch command; moving that to the inbox would relocate mail the user never asked to move, so the tag comes off and the file stays put. The inbox FOLDER is a new optional per-account `inbox` key, defaulting to "Inbox". It is configurable rather than hardcoded because the name is not ours to assume: naming a folder that does not exist CREATES it, beside the real one, and under mbsync's `Create Both` that folder reaches the mail server. That is not hypothetical, it is what a truncated origin folder did to real mail while this branch was being tested. Unlike `trash` the key is optional, since the default is right for any ordinary Maildir and a wrong value here only affects the fallback. Ctrl+R, which was free. The action is only enabled in the trash view, so the key is inert elsewhere rather than doing something surprising. It sits in the Message menu beside Delete and in the thread context menu, greyed outside the trash rather than hidden: an action that vanishes teaches nothing, while a disabled entry with its shortcut beside it says both that it exists and where it applies. **Adding an action is FIVE places, not four.** knownActions(), defaultBindings() and the icon table are each enforced by a test that fails loudly, and being REACHABLE is a fifth that nothing checked: this shipped registered, bound, iconned, correctly enabled, and present in no menu at all, which a green suite reported as complete. Ctrl+R is not a shortcut anyone guesses, so it was effectively invisible. restoreIsReachableWithoutTheKeyboard() closes that, and deliberately excludes the context menu from its menu-bar assertion, since findChildren returns both and one check would otherwise satisfy the other. Four tests, each mutation-checked. Two worth keeping: the hardcoded "Inbox" mutation fails against the fixture's lowercase folders exactly as it would against a Maildir that spells its inbox differently, and the reachability mutation reproduces the keyboard-only state this shipped in. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
-rw-r--r--src/config.cpp19
-rw-r--r--src/config.h21
-rw-r--r--src/keymap.cpp4
-rw-r--r--src/mainwindow.cpp135
-rw-r--r--src/mainwindow.h23
-rw-r--r--tests/test_mainwindow.cpp211
-rw-r--r--translations/qtmaildir_it_IT.ts26
7 files changed, 429 insertions, 10 deletions
diff --git a/src/config.cpp b/src/config.cpp
index 1799ac6..a2d1cec 100644
--- a/src/config.cpp
+++ b/src/config.cpp
@@ -143,6 +143,18 @@ QString Account::trashQuery() const
return folderQuery(maildir, trash);
}
+QString Account::inboxFolder() const
+{
+ // Never empty: Restore needs a folder to name, and "Inbox" is both the
+ // Maildir convention and what mbsync's own Inbox directive defaults to.
+ return inbox.isEmpty() ? QStringLiteral("Inbox") : inbox;
+}
+
+QString Account::inboxQuery() const
+{
+ return folderQuery(maildir, inboxFolder());
+}
+
QString Config::allSentQuery() const
{
return joinAccountQueries(m_accounts, &Account::sentQuery);
@@ -450,6 +462,13 @@ void Config::load(const QString &path)
account.trash =
settings.value(QStringLiteral("trash")).toString().trimmed();
+ // Optional, unlike trash: inboxFolder() defaults it to "Inbox", which
+ // is right for any ordinary Maildir. Read so an account whose inbox is
+ // named otherwise can say so, rather than having Restore create a
+ // second folder under a name this program assumed.
+ account.inbox =
+ settings.value(QStringLiteral("inbox")).toString().trimmed();
+
// Both optional, and both describe this account's chip in the thread
// list. An account tag is a different taxonomy from a functional one,
// saying which mailbox a thread arrived in rather than what state it
diff --git a/src/config.h b/src/config.h
index f60e7cc..ede5dea 100644
--- a/src/config.h
+++ b/src/config.h
@@ -67,6 +67,20 @@ struct Account
/// reports a missing key through the warnings path.
QString trash;
+ /// The account's inbox folder, relative to maildir. Optional.
+ ///
+ /// Only Restore reads it, as the destination for a message that carries no
+ /// `deleted-from:` origin, which is what mail trashed by another client
+ /// looks like. Defaults to "Inbox", the Maildir convention and mbsync's
+ /// own default.
+ ///
+ /// Configurable rather than hardcoded because the name is not ours to
+ /// assume: naming a folder that does not exist CREATES it, beside the real
+ /// one, and under mbsync's `Create Both` that folder reaches the server.
+ /// Unlike `trash` this is optional, since the default is right for every
+ /// ordinary Maildir and a wrong guess here only affects the fallback.
+ QString inbox;
+
/// Chip colour in the thread list. Invalid when unset, in which case one
/// is generated from the account tag's name.
QColor color;
@@ -114,6 +128,13 @@ struct Account
/// sentQuery(). The query helper still returns empty so callers compose
/// uniformly; it is Config::load() that reports the problem.
QString trashQuery() const;
+
+ /// Matches this account's inbox folder, using inboxFolder().
+ QString inboxQuery() const;
+
+ /// The inbox folder name, which is `inbox` when set and "Inbox"
+ /// otherwise. Never empty, so a caller always has a folder to name.
+ QString inboxFolder() const;
};
/// A named query, stored in queries.json.
diff --git a/src/keymap.cpp b/src/keymap.cpp
index c731bbb..319bc53 100644
--- a/src/keymap.cpp
+++ b/src/keymap.cpp
@@ -31,6 +31,7 @@ QStringList KeyMap::knownActions()
QStringLiteral("open_thread"),
QStringLiteral("archive"),
QStringLiteral("delete"),
+ QStringLiteral("restore"),
QStringLiteral("spam"),
QStringLiteral("toggle_unread"),
QStringLiteral("mark_all_read"),
@@ -98,6 +99,9 @@ QList<QPair<QString, QString>> KeyMap::defaultBindings()
{ QStringLiteral("Return"), QStringLiteral("open_thread") },
{ QStringLiteral("Ctrl+E"), QStringLiteral("archive") },
{ QStringLiteral("Ctrl+D"), QStringLiteral("delete") },
+ // Restore is only enabled in the trash view, so its key is dead
+ // elsewhere rather than doing something surprising.
+ { QStringLiteral("Ctrl+R"), QStringLiteral("restore") },
{ QStringLiteral("Ctrl+Shift+S"), QStringLiteral("spam") },
{ QStringLiteral("Ctrl+U"), QStringLiteral("toggle_unread") },
// Shifted against Ctrl+U, which toggles unread on the selection: this
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index 361c0d4..da7128f 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -849,6 +849,10 @@ void MainWindow::registerActions()
else
trashSelected();
});
+ addAction(QStringLiteral("restore"), tr("&Restore from trash"),
+ tr("Move the selected messages out of the trash"), [this]() {
+ restoreSelected(true);
+ });
addAction(QStringLiteral("spam"), tr("Mark &spam"),
tr("Add spam and remove inbox"), [this]() {
tagSelected({ QStringLiteral("spam") }, { QStringLiteral("inbox") },
@@ -1136,6 +1140,11 @@ void MainWindow::buildMenus()
auto *messageMenu = menuBar()->addMenu(tr("&Message"));
messageMenu->addAction(m_actions.value(QStringLiteral("archive")));
messageMenu->addAction(m_actions.value(QStringLiteral("delete")));
+ // Beside Delete, whose inverse it is. Greyed outside the trash view
+ // rather than hidden: an action that vanishes teaches nothing, while a
+ // disabled entry with its shortcut beside it says both that it exists and
+ // where it applies.
+ messageMenu->addAction(m_actions.value(QStringLiteral("restore")));
messageMenu->addAction(m_actions.value(QStringLiteral("spam")));
messageMenu->addSeparator();
messageMenu->addAction(m_actions.value(QStringLiteral("toggle_unread")));
@@ -1193,6 +1202,9 @@ void MainWindow::buildMenus()
// control: two buttons with different consequences looked identical.
{ QStringLiteral("archive"), QStringLiteral("mail-archive") },
{ QStringLiteral("delete"), QStringLiteral("edit-delete") },
+ // The inverse of delete, and the theme's own name for it: the icon
+ // every desktop uses for taking something back out of the wastebasket.
+ { QStringLiteral("restore"), QStringLiteral("edit-undelete") },
{ QStringLiteral("undo"), QStringLiteral("edit-undo") },
{ QStringLiteral("spam"), QStringLiteral("mail-mark-junk") },
{ QStringLiteral("flag"), QStringLiteral("mail-mark-important") },
@@ -1259,6 +1271,7 @@ void MainWindow::buildMenus()
m_threadContextMenu->setObjectName(QStringLiteral("threadContextMenu"));
m_threadContextMenu->addAction(m_actions.value(QStringLiteral("archive")));
m_threadContextMenu->addAction(m_actions.value(QStringLiteral("delete")));
+ m_threadContextMenu->addAction(m_actions.value(QStringLiteral("restore")));
m_threadContextMenu->addAction(m_actions.value(QStringLiteral("spam")));
m_threadContextMenu->addSeparator();
m_threadContextMenu->addAction(m_actions.value(QStringLiteral("toggle_unread")));
@@ -2459,8 +2472,40 @@ void MainWindow::onQueryFinished(int total, quint64 generation)
applyPendingRecovery();
}
+bool MainWindow::isShowingTrash() const
+{
+ // Compared against the trash GENERATOR's query, not against the word
+ // "trash" or against a tag. The trash view is path-based so that mail
+ // trashed by another client shows up in it; deciding this from
+ // `tag:deleted` instead would disable Restore on exactly the messages
+ // that most need it, which is the case Restore's fallback exists for.
+ //
+ // Both scopes, because the view composes with the account dropdown like
+ // every other filter: one account's trash, or all of them.
+ const QString query = m_lastQuery.trimmed();
+ if (query.isEmpty())
+ return false;
+
+ const QString all = m_config.allTrashQuery().trimmed();
+ if (!all.isEmpty() && query == all)
+ return true;
+
+ for (const Account &account : m_config.accounts()) {
+ const QString trash = account.trashQuery().trimmed();
+ if (!trash.isEmpty() && query == trash)
+ return true;
+ }
+ return false;
+}
+
void MainWindow::updateViewWideActions()
{
+ // Only meaningful on mail that is actually in a trash folder. An enabled
+ // action that does nothing is worse than an absent one, and Restore
+ // outside the trash has nothing to restore from.
+ if (QAction *action = m_actions.value(QStringLiteral("restore")))
+ action->setEnabled(isShowingTrash());
+
// Threads arrive in batches of kBatchSize, so before the query reports its
// total the model holds only what has landed. An action that says "all"
// must not run against a partial set and silently skip the rest, and a
@@ -4458,7 +4503,35 @@ void MainWindow::restoreSelectedThreads()
Q_ARG(QString, QStringLiteral("undelete_thread")));
}
-void MainWindow::restoreSelected()
+QString MainWindow::inboxFolderFor(const Account &account) const
+{
+ // Discovered from the account's OWN inbox query, never hardcoded.
+ //
+ // The casing is not ours to assume: the real Maildir has `Inbox` and a
+ // test fixture has `inbox`, and picking either would create a SECOND
+ // folder beside the real one on whichever side disagreed. That is exactly
+ // the failure a truncated origin folder caused on real mail this morning,
+ // and under mbsync's `Create Both` such a folder can reach the server.
+ //
+ // The inbox query is a generated `path:"<maildir>/<folder>/**"`, so the
+ // folder name is the part between the account prefix and the glob.
+ const QString query = account.inboxQuery();
+ const QString prefix =
+ QStringLiteral("path:\"") + account.maildir + QLatin1Char('/');
+ const QString suffix = QStringLiteral("/**\"");
+ if (query.startsWith(prefix) && query.endsWith(suffix)) {
+ const int from = prefix.length();
+ const int length = query.length() - from - suffix.length();
+ if (length > 0)
+ return query.mid(from, length);
+ }
+
+ // No inbox configured for this account. `Inbox` is the Maildir
+ // convention and is what mbsync's own `Inbox` directive defaults to.
+ return QStringLiteral("Inbox");
+}
+
+void MainWindow::restoreSelected(bool fallbackToInbox)
{
const QModelIndexList rows =
m_threadView->selectionModel()->selectedRows();
@@ -4496,14 +4569,58 @@ void MainWindow::restoreSelected()
}
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")));
+ // No origin recorded. Two quite different situations reach here and
+ // they want opposite things, which is what `fallbackToInbox` selects.
+ //
+ // From the TRASH VIEW the message is demonstrably in the trash, put
+ // there by another client, and refusing to move it leaves the user
+ // looking at a message they cannot get out. Inbox is the documented
+ // fallback, and it is reported, because a guess the user is not told
+ // about is worse than the guess itself.
+ //
+ // From a second press of Delete the message is NOT in the trash: it is
+ // sitting wherever it always was, wearing a stale `deleted` tag from
+ // an older version or from a hand-written notmuch command. Moving it
+ // to the inbox there would relocate mail the user never asked to move.
+ // The tag comes off and the file stays put.
+ if (fallbackToInbox) {
+ QHash<QString, QStringList> byInbox;
+ QStringList stranded;
+ for (const QString &messageId : unknown) {
+ const Account account =
+ accountForMessagePath(m_model->messageById(messageId).filePath);
+ if (account.maildir.isEmpty()) {
+ stranded.append(messageId);
+ continue;
+ }
+ byInbox[account.maildir + QLatin1Char('/')
+ + inboxFolderFor(account)]
+ .append(messageId);
+ }
+
+ for (auto it = byInbox.cbegin(); it != byInbox.cend(); ++it) {
+ sendMove(it.value(), it.key(), {},
+ { QStringLiteral("deleted") }, tr("Restore"));
+ }
+
+ if (!byInbox.isEmpty()) {
+ m_statusLabel->setText(
+ tr("%n message(s) had no record of where they came from "
+ "and were moved to the inbox.", "",
+ int(unknown.size() - stranded.size())));
+ }
+ if (!stranded.isEmpty()) {
+ m_statusLabel->setText(
+ tr("%n message(s) could not be restored: they belong to no "
+ "configured account.", "", int(stranded.size())));
+ }
+ } else {
+ 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) {
diff --git a/src/mainwindow.h b/src/mainwindow.h
index ae87868..937d87c 100644
--- a/src/mainwindow.h
+++ b/src/mainwindow.h
@@ -808,7 +808,28 @@ private:
/// The inverse: moves each selected row's message back to the folder its
/// `deleted-from:` tag names, stripping both tags.
- void restoreSelected();
+ ///
+ /// `fallbackToInbox` decides what happens to a message with NO origin tag,
+ /// and the two callers want opposite things. From the trash view the
+ /// message is demonstrably in the trash, trashed by another client, and
+ /// must still come out: it goes to the inbox, reported. From a second
+ /// press of Delete it is not in the trash at all and merely wears a stale
+ /// tag, so the tag comes off and the file stays where it is.
+ void restoreSelected(bool fallbackToInbox = false);
+
+ /// The account's inbox FOLDER name, discovered from its inbox query.
+ ///
+ /// Never hardcoded: the real Maildir has `Inbox` and a fixture has
+ /// `inbox`, and assuming either would create a second folder beside the
+ /// real one on the side that disagreed.
+ QString inboxFolderFor(const Account &account) const;
+
+ /// Whether the current query IS a trash view, for either scope.
+ ///
+ /// Compared against the trash generator's own query rather than against a
+ /// tag: the view is path-based so mail trashed by another client appears
+ /// in it, and such a message carries no tag of ours.
+ bool isShowingTrash() const;
/// The `deleted-from:` tag naming `dbRelativeFolder`, or empty when no
/// account owns it.
diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp
index a17eba1..cefd686 100644
--- a/tests/test_mainwindow.cpp
+++ b/tests/test_mainwindow.cpp
@@ -135,6 +135,11 @@ public:
<< "maildir=" << accountMaildir << "\n";
if (!accountTrash.isEmpty())
out << "trash=" << accountTrash << "\n";
+ // The fixture's folders are lowercase, unlike the Maildir
+ // convention Account::inboxFolder() defaults to. Stated rather
+ // than assumed, which is the whole point of the key: naming a
+ // folder that does not exist would CREATE it.
+ out << "inbox=inbox\n";
}
}
file.close();
@@ -372,6 +377,10 @@ private slots:
void deletingAThreadRootTwiceRestoresItRatherThanRedeleting();
void deleteThreadMovesEveryMessageAndRepaintsTheRootCard();
void aFolderNameWithASpaceSurvivesTheRoundTrip();
+ void restoreIsReachableWithoutTheKeyboard();
+ void restoreIsOnlyEnabledInTheTrashView();
+ void restoreReturnsAMessageToItsOriginFolder();
+ void restoreFallsBackToInboxWithoutAnOriginTag();
private:
/// Owns the throwaway lock table init() points every test at. A pointer
@@ -9457,6 +9466,208 @@ void TestMainWindow::aFolderNameWithASpaceSurvivesTheRoundTrip()
"messages are somewhere mbsync will never sync");
}
+void TestMainWindow::restoreIsReachableWithoutTheKeyboard()
+{
+ // Restore shipped as a keyboard shortcut and nothing else: registered,
+ // iconned, enabled correctly, and present in no menu at all. A user who
+ // does not read the changelog would never learn it exists, and Ctrl+R is
+ // not a guess anyone makes.
+ //
+ // The four places an action must touch are enforced by tests
+ // (knownActions, defaultBindings, the icon table); being REACHABLE is a
+ // fifth that nothing checked, which is why the gap survived a green suite.
+ const Config config;
+ MainWindow window(config);
+
+ auto *restore = window.findChild<QAction *>(QStringLiteral("restore"));
+ QVERIFY(restore);
+
+ const auto menuContains = [](const QMenu *menu, const QAction *action) {
+ return menu && menu->actions().contains(action);
+ };
+
+ // A menu on the MENU BAR, beside Delete whose inverse it is. The context
+ // menu is excluded here so this assertion cannot be satisfied by the one
+ // the next assertion checks: findChildren finds both.
+ auto *context =
+ window.findChild<QMenu *>(QStringLiteral("threadContextMenu"));
+ QVERIFY(context);
+
+ bool inAMenuBarMenu = false;
+ for (const QMenu *menu : window.findChildren<QMenu *>()) {
+ if (menu != context && menuContains(menu, restore)) {
+ inAMenuBarMenu = true;
+ break;
+ }
+ }
+ QVERIFY2(inAMenuBarMenu,
+ "Restore is in no menu-bar menu, so a user browsing the menus "
+ "would never learn it exists");
+
+ // And the thread list's context menu, which is where the other
+ // message-scoped actions are reached by mouse.
+ QVERIFY2(menuContains(context, restore),
+ "Restore is missing from the thread context menu");
+}
+
+void TestMainWindow::restoreIsOnlyEnabledInTheTrashView()
+{
+ // Restore has no meaning outside the trash, and an enabled action that
+ // does nothing is worse than an absent one.
+ //
+ // Enabled from the QUERY rather than from the selection's tags: a message
+ // trashed by another client carries no tag of ours and must still be
+ // restorable, which is the whole reason the trash view is path-based.
+ WorkerBackedWindow backed;
+ QVERIFY(backed.fixture().addMessage(
+ QStringLiteral("acct/inbox"), QStringLiteral("re1@example.org"),
+ QStringLiteral("In the inbox"), QStringLiteral("sender@example.org"),
+ QStringLiteral("Fri, 14 Aug 2026 10:00:00 +0200"),
+ QStringLiteral("Body text.")));
+ QVERIFY(backed.fixture().addMessage(
+ QStringLiteral("acct/Trash"), QStringLiteral("re2@example.org"),
+ QStringLiteral("In the trash"), QStringLiteral("other@example.org"),
+ QStringLiteral("Fri, 14 Aug 2026 11: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 *queryEdit =
+ window.findChild<QLineEdit *>(QStringLiteral("queryEdit"));
+ auto *restore = window.findChild<QAction *>(QStringLiteral("restore"));
+ QVERIFY(model && queryEdit);
+ QVERIFY2(restore, "there is no restore action");
+
+ // An ordinary view. Both fixture messages carry `inbox`, since the
+ // fixture tags all new mail that way regardless of folder, so this is two
+ // rows rather than one; the count is not what is under test.
+ queryEdit->setText(QStringLiteral("tag:inbox"));
+ queryEdit->returnPressed();
+ QTRY_VERIFY_WITH_TIMEOUT(model->rowCount(QModelIndex()) == 2, 15000);
+ QVERIFY2(!restore->isEnabled(),
+ "Restore is enabled in an ordinary view, where it means nothing");
+
+ // The trash view, which is the account's own generated trash query.
+ queryEdit->setText(QStringLiteral("path:\"acct/Trash/**\""));
+ queryEdit->returnPressed();
+ QTRY_VERIFY_WITH_TIMEOUT(model->rowCount(QModelIndex()) == 1, 15000);
+ QVERIFY2(restore->isEnabled(),
+ "Restore is disabled in the trash view, where it is the point");
+}
+
+void TestMainWindow::restoreReturnsAMessageToItsOriginFolder()
+{
+ WorkerBackedWindow backed;
+ QVERIFY(backed.fixture().addMessage(
+ QStringLiteral("acct/inbox"), QStringLiteral("ro1@example.org"),
+ QStringLiteral("Send 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);
+
+ const QString root = backed.fixture().maildirPath();
+ const QString cfg = backed.fixture().configPath();
+ const QString stem = QStringLiteral("ro1.example.org");
+
+ 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();
+ QTRY_VERIFY_WITH_TIMEOUT(
+ folderHasMessageFile(root + QStringLiteral("/acct/Trash/cur"), stem),
+ 15000);
+
+ // Now from the trash view, through Restore rather than through a second
+ // Delete: this is the action the user reaches for when browsing trash.
+ queryEdit->setText(QStringLiteral("path:\"acct/Trash/**\""));
+ queryEdit->returnPressed();
+ QTRY_VERIFY_WITH_TIMEOUT(model->rowCount(QModelIndex()) == 1, 15000);
+ view->setCurrentIndex(model->index(0, 0, QModelIndex()));
+ window.findChild<QAction *>(QStringLiteral("restore"))->trigger();
+
+ QTRY_VERIFY_WITH_TIMEOUT(
+ folderHasMessageFile(root + QStringLiteral("/acct/inbox/cur"), stem)
+ || folderHasMessageFile(root + QStringLiteral("/acct/inbox/new"),
+ stem),
+ 15000);
+ QTRY_VERIFY_WITH_TIMEOUT(
+ notmuchCount(cfg,
+ QStringLiteral("id:ro1@example.org and tag:deleted")) == 0,
+ 15000);
+
+ QCOMPARE(notmuchCount(cfg, QStringLiteral("id:ro1@example.org")), 1);
+ QCOMPARE(notmuchCount(cfg, QStringLiteral("id:ro1@example.org and "
+ "tag:\"deleted-from:inbox\"")),
+ 0);
+ QVERIFY(!folderHasMessageFile(root + QStringLiteral("/acct/Trash/cur"),
+ stem));
+}
+
+void TestMainWindow::restoreFallsBackToInboxWithoutAnOriginTag()
+{
+ // A message trashed by ANOTHER client: it sits in the trash folder and
+ // carries no `deleted-from:` tag, because nothing here put it there. The
+ // real Maildir has such messages, which is why the trash view is path
+ // based rather than tag based.
+ //
+ // Inbox is the documented fallback. Refusing to move it would leave the
+ // user with a message they can see in the trash and cannot get out.
+ WorkerBackedWindow backed;
+ QVERIFY(backed.fixture().addMessage(
+ QStringLiteral("acct/Trash"), QStringLiteral("foreign@example.org"),
+ QStringLiteral("Trashed elsewhere"), 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);
+
+ const QString root = backed.fixture().maildirPath();
+ const QString cfg = backed.fixture().configPath();
+ const QString stem = QStringLiteral("foreign.example.org");
+
+ // The guard this test needs: no origin tag, so the fallback is what is
+ // under test rather than an ordinary restore.
+ QCOMPARE(notmuchCount(cfg, QStringLiteral("id:foreign@example.org and "
+ "tag:\"deleted-from:inbox\"")),
+ 0);
+
+ queryEdit->setText(QStringLiteral("path:\"acct/Trash/**\""));
+ queryEdit->returnPressed();
+ QTRY_VERIFY_WITH_TIMEOUT(model->rowCount(QModelIndex()) == 1, 15000);
+ view->setCurrentIndex(model->index(0, 0, QModelIndex()));
+ window.findChild<QAction *>(QStringLiteral("restore"))->trigger();
+
+ QTRY_VERIFY_WITH_TIMEOUT(
+ folderHasMessageFile(root + QStringLiteral("/acct/inbox/cur"), stem)
+ || folderHasMessageFile(root + QStringLiteral("/acct/inbox/new"),
+ stem),
+ 15000);
+ QVERIFY2(!folderHasMessageFile(root + QStringLiteral("/acct/Trash/cur"),
+ stem),
+ "the message was copied out of the trash rather than moved");
+}
+
void TestMainWindow::undoMovesTheMessageBack()
{
// Undo is this project's answer to the confirmation dialog it rules out,
diff --git a/translations/qtmaildir_it_IT.ts b/translations/qtmaildir_it_IT.ts
index cc854b2..1c5eedd 100644
--- a/translations/qtmaildir_it_IT.ts
+++ b/translations/qtmaildir_it_IT.ts
@@ -260,6 +260,24 @@
</translation>
</message>
<message>
+ <source>Restore</source>
+ <translation>Ripristina</translation>
+ </message>
+ <message numerus="yes">
+ <source>%n message(s) had no record of where they came from and were moved to the inbox.</source>
+ <translation>
+ <numerusform>%n messaggio non aveva traccia della sua provenienza ed è stato spostato in arrivo.</numerusform>
+ <numerusform>%n messaggi non avevano traccia della loro provenienza e sono stati spostati in arrivo.</numerusform>
+ </translation>
+ </message>
+ <message numerus="yes">
+ <source>%n message(s) could not be restored: they belong to no configured account.</source>
+ <translation>
+ <numerusform>%n messaggio non è stato ripristinato: non appartiene ad alcun account configurato.</numerusform>
+ <numerusform>%n messaggi non sono stati ripristinati: non appartengono ad alcun account configurato.</numerusform>
+ </translation>
+ </message>
+ <message>
<source>Undelete</source>
<translation>Ripristina</translation>
</message>
@@ -370,6 +388,14 @@
<translation>Segna conversazione come &amp;spam</translation>
</message>
<message>
+ <source>&amp;Restore from trash</source>
+ <translation>&amp;Ripristina dal cestino</translation>
+ </message>
+ <message>
+ <source>Move the selected messages out of the trash</source>
+ <translation>Sposta i messaggi selezionati fuori dal cestino</translation>
+ </message>
+ <message>
<source>Add spam and remove inbox on whole threads</source>
<translation>Aggiunge spam e rimuove inbox su intere conversazioni</translation>
</message>