aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-17 19:58:51 +0200
committerDanilo M. <danix@danix.xyz>2026-08-17 19:58:51 +0200
commitd5e9ec157946635294001b57ce8353a109ec5051 (patch)
tree74a2f72973d1535d684b76cb24d4bc3118bb35a3
parent9899b9af523ad2aef55300c9132d21a687a0a19d (diff)
downloadqtmaildir-d5e9ec157946635294001b57ce8353a109ec5051.tar.gz
qtmaildir-d5e9ec157946635294001b57ce8353a109ec5051.zip
feat(config): add the trash query generator
Adds trash as a fifth built-in query filter beside Unread, Inbox, Important and Sent, composing per-account exactly as Sent does: Config::resolvedQuery() asks each account for its own trashQuery() rather than wrapping the all-accounts union, and an account with no trash folder resolves to matchNothingQuery() rather than "match everything". Also gives the Trash button a toolbar icon (user-trash) and a trash key to the mainwindow fixture that asserts every filter button carries one; without it the button is skipped from the row entirely (no account configured a trash folder), and the existing icon test found no button to check.
-rw-r--r--src/config.cpp27
-rw-r--r--src/config.h7
-rw-r--r--src/mainwindow.cpp1
-rw-r--r--tests/test_config.cpp55
-rw-r--r--tests/test_mainwindow.cpp16
-rw-r--r--translations/qtmaildir_it_IT.ts4
6 files changed, 104 insertions, 6 deletions
diff --git a/src/config.cpp b/src/config.cpp
index 8294e4b..1799ac6 100644
--- a/src/config.cpp
+++ b/src/config.cpp
@@ -60,7 +60,8 @@ constexpr int kQueriesFormatVersion = 1;
const QStringList kQueryGenerators = { QStringLiteral("unread"),
QStringLiteral("inbox"),
QStringLiteral("flagged"),
- QStringLiteral("sent") };
+ QStringLiteral("sent"),
+ QStringLiteral("trash") };
/// The tag a generator matches, for the three filters that are a plain tag
/// query. Empty for "sent", which composes from each account's folder instead
@@ -152,6 +153,11 @@ QString Config::allDraftsQuery() const
return joinAccountQueries(m_accounts, &Account::draftsQuery);
}
+QString Config::allTrashQuery() const
+{
+ return joinAccountQueries(m_accounts, &Account::trashQuery);
+}
+
QString Config::defaultPath()
{
const QString base =
@@ -737,6 +743,8 @@ QString Config::resolvedQuery(const SavedQuery &query) const
if (query.isGenerated()) {
if (query.generated == QStringLiteral("sent"))
return allSentQuery();
+ if (query.generated == QStringLiteral("trash"))
+ return allTrashQuery();
// An unknown generator was reported on load. Empty rather than the
// bare stored query, which for a generated entry is empty anyway and
// would otherwise run as "match everything".
@@ -809,6 +817,11 @@ SavedQuery Config::builtinFilter(const QString &generator)
// thread would fold the user's sent message back into the conversation
// it belongs to, which is item 63's finding.
filter.flat = true;
+ } else if (generator == QStringLiteral("trash")) {
+ filter.name = tr("Trash");
+ // NOT flat, unlike Sent. A deleted message still belongs to its
+ // conversation, and folding it back is what Sent had to avoid rather
+ // than something every folder filter wants.
}
return filter;
@@ -833,6 +846,10 @@ QString Config::resolvedQuery(const SavedQuery &query,
const QString all = allSentQuery();
return all.isEmpty() ? matchNothingQuery() : all;
}
+ if (query.generated == QStringLiteral("trash")) {
+ const QString all = allTrashQuery();
+ return all.isEmpty() ? matchNothingQuery() : all;
+ }
return QStringLiteral("tag:%1").arg(generatorTag(query.generated));
}
@@ -853,6 +870,14 @@ QString Config::resolvedQuery(const SavedQuery &query,
return sent.isEmpty() ? matchNothingQuery() : sent;
}
+ if (query.generated == QStringLiteral("trash")) {
+ // The account's OWN trash query, for the reason spelled out above the
+ // sent case: wrapping the all-accounts query in this account's path
+ // works by accident of path: being hierarchical.
+ const QString trash = scope.trashQuery();
+ return trash.isEmpty() ? matchNothingQuery() : trash;
+ }
+
// A tag filter carries no path of its own, so scoping is exactly what
// scopedQuery() does. Its parentheses are load-bearing: `path:... and a or
// b` binds as `(path:... and a) or b`.
diff --git a/src/config.h b/src/config.h
index 1feeeda..f60e7cc 100644
--- a/src/config.h
+++ b/src/config.h
@@ -291,6 +291,13 @@ public:
/// open-coded at the call site.
QString allSentQuery() const;
+ /// Matches every configured account's trash, or empty when none has one.
+ ///
+ /// Joins only the NON-EMPTY trashQuery() results, for the same reason
+ /// allSentQuery() does: notmuch accepts a bare "or" without complaint and
+ /// silently answers a different question.
+ QString allTrashQuery() const;
+
/// Matches every configured account's drafts, or empty when none has one.
///
/// Joins only the NON-EMPTY draftsQuery() results, for the same reason
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index 594535b..67d3ee4 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -1893,6 +1893,7 @@ void MainWindow::buildSavedQueryRow(QWidget *parent, QVBoxLayout *layout)
{ QStringLiteral("inbox"), QStringLiteral("mail-inbox") },
{ QStringLiteral("flagged"), QStringLiteral("starred") },
{ QStringLiteral("sent"), QStringLiteral("mail-folder-sent") },
+ { QStringLiteral("trash"), QStringLiteral("user-trash") },
};
button->setIcon(
QIcon::fromTheme(filterIcons.value(filter.generated)));
diff --git a/tests/test_config.cpp b/tests/test_config.cpp
index 10dcf68..ea5c363 100644
--- a/tests/test_config.cpp
+++ b/tests/test_config.cpp
@@ -119,6 +119,8 @@ private slots:
void anAccountCarriesItsTrashFolder();
void aBracketedTrashFolderIsQuoted();
void anAccountWithoutATrashFolderWarns();
+ void theTrashFilterComposesPerAccount();
+ void theTrashFilterMatchesNothingWithoutAFolder();
};
static QString writeIni(const QTemporaryDir &dir, const QString &body)
@@ -1009,6 +1011,54 @@ void TestConfig::anAccountWithoutATrashFolderWarns()
QVERIFY(joined.contains(QStringLiteral("trash")));
}
+void TestConfig::theTrashFilterComposesPerAccount()
+{
+ // Two accounts, one with a plain folder and one nested under a bracketed
+ // parent, since the real setup has both shapes.
+ QTemporaryDir dir;
+ Config config;
+ config.load(writeIni(dir, QStringLiteral(
+ "[account.work]\n"
+ "maildir=work\n"
+ "trash=Trash\n"
+ "\n"
+ "[account.personal]\n"
+ "maildir=personal\n"
+ "trash=[Provider]/Cestino\n")));
+
+ const SavedQuery trash = Config::builtinFilter(QStringLiteral("trash"));
+ QVERIFY(trash.isGenerated());
+
+ // All accounts: the union, never a bare path that would match one account.
+ const QString all = config.resolvedQuery(trash, QString());
+ QVERIFY(all.contains(QStringLiteral("path:\"work/Trash/**\"")));
+ QVERIFY(all.contains(
+ QStringLiteral("path:\"personal/[Provider]/Cestino/**\"")));
+
+ // One account: that account's OWN query. Asserting on the STRING, not on a
+ // row count: the all-accounts query wrapped in this account's path returns
+ // exactly the right rows, because path: is hierarchical, so a count passes
+ // against the wrong thing. Config::resolvedQuery documents this trap.
+ const QString scoped = config.resolvedQuery(trash, QStringLiteral("work"));
+ QCOMPARE(scoped, QStringLiteral("path:\"work/Trash/**\""));
+ QVERIFY(!scoped.contains(QStringLiteral("personal")));
+}
+
+void TestConfig::theTrashFilterMatchesNothingWithoutAFolder()
+{
+ // An empty query means "match everything" to notmuch, so a filter with
+ // nothing to match must say so explicitly. A button labelled Trash that
+ // showed the whole Maildir is the failure this prevents.
+ QTemporaryDir dir;
+ Config config;
+ config.load(writeIni(dir, QStringLiteral(
+ "[account.work]\n"
+ "maildir=work\n")));
+
+ const SavedQuery trash = Config::builtinFilter(QStringLiteral("trash"));
+ QCOMPARE(config.resolvedQuery(trash, QString()), Config::matchNothingQuery());
+}
+
void TestConfig::sentQueryComposesWithScopedQuery()
{
// A Sent view under one account must not show another account's sent mail.
@@ -1432,7 +1482,7 @@ void TestConfig::everyBuiltinFilterIsAKnownGenerator()
Config config;
const QList<SavedQuery> filters = config.builtinFilters();
- QCOMPARE(filters.size(), 4);
+ QCOMPARE(filters.size(), 5);
QStringList names;
for (const SavedQuery &filter : filters) {
@@ -1453,7 +1503,8 @@ void TestConfig::everyBuiltinFilterIsAKnownGenerator()
QCOMPARE(names, (QStringList{ QStringLiteral("Unread"),
QStringLiteral("Inbox"),
QStringLiteral("Important"),
- QStringLiteral("Sent") }));
+ QStringLiteral("Sent"),
+ QStringLiteral("Trash") }));
}
void TestConfig::aFilterAcrossAllAccountsIsTheUnscopedQuery()
diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp
index b188ef4..7316689 100644
--- a/tests/test_mainwindow.cpp
+++ b/tests/test_mainwindow.cpp
@@ -7922,10 +7922,20 @@ void TestMainWindow::everyBuiltinFilterButtonCarriesAnIconAndItsText()
// which is the same argument the Save button records.
QTemporaryDir dir;
QVERIFY(dir.isValid());
- Config config;
- config.load(writeSentConfig(dir, {
+ const QString path = writeSentConfig(dir, {
{QStringLiteral("work"), QStringLiteral("Sent")},
- }));
+ });
+ // A trash key too, or the Trash filter finds nothing and is skipped from
+ // the row entirely (item 103), leaving no trashButton for this loop to
+ // find.
+ {
+ QSettings s(path, QSettings::IniFormat);
+ s.beginGroup(QStringLiteral("account.work"));
+ s.setValue(QStringLiteral("trash"), QStringLiteral("Trash"));
+ s.endGroup();
+ }
+ Config config;
+ config.load(path);
MainWindow window(config);
diff --git a/translations/qtmaildir_it_IT.ts b/translations/qtmaildir_it_IT.ts
index fd51547..ef88967 100644
--- a/translations/qtmaildir_it_IT.ts
+++ b/translations/qtmaildir_it_IT.ts
@@ -95,6 +95,10 @@
<source>Sent</source>
<translation>Inviati</translation>
</message>
+ <message>
+ <source>Trash</source>
+ <translation>Cestino</translation>
+ </message>
</context>
<context>
<name>HtmlBuilder</name>