aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-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
5 files changed, 192 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.