aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-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>