aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-31 11:51:24 +0100
committerDanilo M. <danix@danix.xyz>2026-10-31 11:51:24 +0100
commitf0786b2bdd9a29531e43f0aa1573c0ab51fc02e1 (patch)
tree4eb139ba5e279bf190ad2a5157e5371639d06691
parent027a50abe9f84c833994a35c428d22262a0698b7 (diff)
downloadqtmaildir-f0786b2bdd9a29531e43f0aa1573c0ab51fc02e1.tar.gz
qtmaildir-f0786b2bdd9a29531e43f0aa1573c0ab51fc02e1.zip
feat: apply a rule from the rules dialog, and opt a rule into archiving
Apply saves first, so only what the hook would run is applied, then asks the owner to apply the saved rule. A checkbox maps to allow_archive, and the banner names every rule the sync hook will skip. Items 212 and 213. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--src/tagrules.h3
-rw-r--r--src/tagrulesdialog.cpp112
-rw-r--r--src/tagrulesdialog.h38
-rw-r--r--tests/test_tagrules.cpp100
4 files changed, 237 insertions, 16 deletions
diff --git a/src/tagrules.h b/src/tagrules.h
index e2c8667..fadac2c 100644
--- a/src/tagrules.h
+++ b/src/tagrules.h
@@ -20,6 +20,7 @@
#include <QJsonObject>
#include <QList>
+#include <QMetaType>
#include <QString>
#include <QStringList>
@@ -119,3 +120,5 @@ private:
QJsonObject m_unknown; ///< Unrecognised top-level keys.
bool m_missing = false;
};
+
+Q_DECLARE_METATYPE(TagRule)
diff --git a/src/tagrulesdialog.cpp b/src/tagrulesdialog.cpp
index e037e37..5f503ea 100644
--- a/src/tagrulesdialog.cpp
+++ b/src/tagrulesdialog.cpp
@@ -173,6 +173,13 @@ TagRulesDialog::TagRulesDialog(const TagRule &seed, QWidget *parent)
form->addRow(QString(), m_enabled);
form->addRow(tr("Add tags"), m_add);
form->addRow(tr("Remove tags"), m_remove);
+ m_archive = new QCheckBox(
+ tr("Archive matching mail on arrival (removes inbox)"), this);
+ m_archive->setToolTip(
+ tr("Without this, the sync hook skips a rule that removes inbox, "
+ "since it runs unattended."));
+ m_archive->setVisible(false);
+ form->addRow(QString(), m_archive);
m_builder = new QWidget(this);
auto *builderLayout = new QVBoxLayout(m_builder);
builderLayout->setContentsMargins(0, 0, 0, 0);
@@ -263,6 +270,13 @@ TagRulesDialog::TagRulesDialog(const TagRule &seed, QWidget *parent)
tr("Run this rule's query in the main window, to see which mail it "
"collects. This does not tag anything."));
buttons->addWidget(m_previewButton);
+ m_applyButton = new QPushButton(tr("&Apply to existing mail"), this);
+ m_applyButton->setToolTip(
+ tr("Save, then apply this rule to mail already in the index. Asks "
+ "first, naming how many messages would change. Undoable from the "
+ "main window."));
+ m_applyButton->setEnabled(false);
+ buttons->addWidget(m_applyButton);
buttons->addWidget(refreshButton);
layout->addLayout(buttons);
@@ -358,6 +372,13 @@ TagRulesDialog::TagRulesDialog(const TagRule &seed, QWidget *parent)
this, &TagRulesDialog::applyEditsToCurrentRule);
connect(m_enabled, &QCheckBox::toggled,
this, &TagRulesDialog::applyEditsToCurrentRule);
+ connect(m_applyButton, &QPushButton::clicked,
+ this, &TagRulesDialog::onApply);
+ connect(m_remove, &QLineEdit::textChanged, this, [this](const QString &text) {
+ m_archive->setVisible(splitTags(text).contains(QStringLiteral("inbox")));
+ });
+ connect(m_archive, &QCheckBox::toggled,
+ this, &TagRulesDialog::applyEditsToCurrentRule);
// QPlainTextEdit has no editingFinished: it is a multi-line editor, where
// Return inserts a newline rather than committing. Without this the note
@@ -545,18 +566,52 @@ void TagRulesDialog::setWarning(const QString &text)
void TagRulesDialog::showWarnings()
{
+ QStringList parts;
const QStringList warnings = m_rules.warnings();
- if (warnings.isEmpty()) {
- setWarning(QString());
- return;
- }
// Not "skipped" any more: a rule with a bad name is loaded repaired, and
// saying it was skipped would send the user looking for something that is
// in front of them. What is true of every warning here is that the file on
// disk is not yet what the hook will run.
- setWarning(tr("%n rule(s) in the file need attention: %1. Save to write "
- "them back.", "", warnings.size())
- .arg(warnings.join(QStringLiteral("; "))));
+ if (!warnings.isEmpty()) {
+ parts.append(tr("%n rule(s) in the file need attention: %1. Save to "
+ "write them back.", "", warnings.size())
+ .arg(warnings.join(QStringLiteral("; "))));
+ }
+ // One banner for both sources. The hook refusing a rule is the case
+ // CLAUDE.md asks the UI to say out loud rather than fail silently.
+ m_lastSkipWarning = hookSkipWarning();
+ if (!m_lastSkipWarning.isEmpty())
+ parts.append(m_lastSkipWarning);
+ setWarning(parts.join(QStringLiteral(" ")));
+}
+
+QString TagRulesDialog::hookSkipWarning() const
+{
+ QStringList skipped;
+ for (const TagRule &rule : m_working) {
+ if (rule.enabled && TagRules::hookWouldSkip(rule))
+ skipped.append(rule.id);
+ }
+ if (skipped.isEmpty())
+ return {};
+ return tr("The sync hook skips %n rule(s), which remove inbox or unread "
+ "on mail nobody has looked at: %1. Tick \"Archive matching "
+ "mail on arrival\" to let a rule remove inbox; unread is never "
+ "removed on arrival.", "", skipped.size())
+ .arg(skipped.join(QStringLiteral(", ")));
+}
+
+void TagRulesDialog::updateApplyButton()
+{
+ const int index = currentIndex();
+ if (index < 0 || index >= m_working.size()) {
+ m_applyButton->setEnabled(false);
+ return;
+ }
+ const TagRule &current = m_working.at(index);
+ m_applyButton->setEnabled(current.enabled && !current.query.isEmpty()
+ && !(current.add.isEmpty()
+ && current.remove.isEmpty()));
}
void TagRulesDialog::fillItem(QTreeWidgetItem *item, const TagRule &rule) const
@@ -644,6 +699,8 @@ void TagRulesDialog::onSelectionChanged()
m_enabled->setChecked(rule.enabled);
m_add->setText(rule.add.join(QStringLiteral(", ")));
m_remove->setText(rule.remove.join(QStringLiteral(", ")));
+ m_archive->setChecked(rule.allowArchive);
+ m_archive->setVisible(rule.remove.contains(QStringLiteral("inbox")));
m_query->setText(rule.query);
m_note->setPlainText(rule.note);
@@ -666,6 +723,7 @@ void TagRulesDialog::onSelectionChanged()
rebuildRows(m_loadedQuery);
m_reloading = wasReloading;
+ updateApplyButton();
}
void TagRulesDialog::applyEditsToCurrentRule()
@@ -700,6 +758,10 @@ void TagRulesDialog::applyEditsToCurrentRule()
rule.enabled = m_enabled->isChecked();
rule.add = splitTags(m_add->text());
rule.remove = splitTags(m_remove->text());
+ // Cleared in the editor when inbox leaves the list, so a flag that does
+ // nothing never reaches the file (item 213).
+ rule.allowArchive = m_archive->isChecked()
+ && rule.remove.contains(QStringLiteral("inbox"));
if (m_textMode->isChecked()) {
rule.query = m_query->text().trimmed();
} else {
@@ -713,6 +775,10 @@ void TagRulesDialog::applyEditsToCurrentRule()
rule.note = m_note->toPlainText();
fillItem(m_list->topLevelItem(index), rule);
+ updateApplyButton();
+
+ if (hookSkipWarning() != m_lastSkipWarning)
+ showWarnings();
}
void TagRulesDialog::onAddRule()
@@ -808,7 +874,7 @@ void TagRulesDialog::setCounts(const QVector<int> &counts)
}
}
-void TagRulesDialog::onSave()
+bool TagRulesDialog::saveRules()
{
applyEditsToCurrentRule();
@@ -823,7 +889,7 @@ void TagRulesDialog::onSave()
setWarning(tr("%n rule(s) cannot be saved as they are: %1", "",
problems.size())
.arg(problems.join(QStringLiteral("; "))));
- return;
+ return false;
}
m_rules.setRules(m_working);
@@ -831,9 +897,33 @@ void TagRulesDialog::onSave()
QMessageBox::warning(this, tr("Tagging rules"),
tr("Could not write %1.")
.arg(TagRules::defaultPath()));
- return;
+ return false;
}
- accept();
+ return true;
+}
+
+void TagRulesDialog::onSave()
+{
+ if (saveRules())
+ accept();
+}
+
+void TagRulesDialog::onApply()
+{
+ // Saved first, decided with the user (item 212): the rule applied is the
+ // rule the hook runs, and a failed save stops here exactly as OK does.
+ // The dialog stays open; only OK closes it.
+ if (!saveRules())
+ return;
+ const int index = currentIndex();
+ if (index < 0 || index >= m_working.size())
+ return;
+ emit applyRequested(m_working.at(index));
+}
+
+void TagRulesDialog::setArchiveForTest(bool on)
+{
+ m_archive->setChecked(on); // toggled -> applyEditsToCurrentRule
}
QString TagRulesDialog::queryLineForTest() const
diff --git a/src/tagrulesdialog.h b/src/tagrulesdialog.h
index 3bfb3d5..b43e79a 100644
--- a/src/tagrulesdialog.h
+++ b/src/tagrulesdialog.h
@@ -40,11 +40,10 @@ class QVBoxLayout;
/// Views and edits the shared auto-tagging rules.
///
-/// Does NOT apply rules to existing mail. The counts are shown so a rule can be
-/// judged before it runs, but the only thing that tags mail is the notmuch
-/// post-new hook. Backfill is deliberately out of this version: a rule that is
-/// safe against arrivals is not automatically safe unscoped, and the
-/// confirmation story for a bulk write does not exist yet.
+/// Does not tag mail itself. The post-new hook applies rules to arriving mail;
+/// applying one to existing mail (item 212) is asked of the owner through
+/// applyRequested(), after the rule has been saved, and the owner confirms
+/// and performs the write.
class TagRulesDialog : public QDialog
{
Q_OBJECT
@@ -95,6 +94,10 @@ public:
void setRowValueForTest(int index, const QString &value);
/// Runs the Save path without showing the dialog.
void saveForTest() { onSave(); }
+ /// Runs the Apply path without a click.
+ void applyForTest() { onApply(); }
+ /// Ticks or clears the archive box, as a click would.
+ void setArchiveForTest(bool on);
/// Types into the query field and toggles the mode, so the refusal path
/// can be reached without a synthetic click. The refusal reports through
@@ -198,6 +201,11 @@ signals:
/// since `tag:new` is set only on mail that has just arrived.
void previewRequested(const QString &query);
+ /// Asks the owner to apply one SAVED rule to existing mail (item 212).
+ /// Emitted only after the dialog has written rules.json, so the rule
+ /// applied is exactly the rule the hook will run.
+ void applyRequested(const TagRule &rule);
+
public slots:
/// Corpus counts, positionally paired with countQueries().
void setCounts(const QVector<int> &counts);
@@ -209,6 +217,7 @@ private slots:
void onDeleteRule();
void applyEditsToCurrentRule();
void onSave();
+ void onApply();
public slots:
/// Saves the window's size and column widths on the way out. Overridden
@@ -222,6 +231,17 @@ private:
void saveUiState();
void showWarnings();
+ /// Validates and writes the working rules. False, with the reason shown,
+ /// when nothing was written. Shared by Save and Apply so neither can
+ /// write what the other would refuse.
+ bool saveRules();
+
+ /// Names every enabled rule the post-new hook will skip, or empty.
+ QString hookSkipWarning() const;
+
+ /// Enables Apply only for a rule worth applying.
+ void updateApplyButton();
+
/// The only way the warning label is written. An empty string hides it.
/// One route in so the icon and the red styling cannot drift between the
/// load path, the save refusal and the text-mode notice.
@@ -300,6 +320,14 @@ private:
QCheckBox *m_enabled = nullptr;
QLabel *m_warningLabel = nullptr;
QPushButton *m_saveButton = nullptr;
+ /// Maps to TagRule::allowArchive; shown only while Remove tags holds inbox.
+ QCheckBox *m_archive = nullptr;
+ QPushButton *m_applyButton = nullptr;
+
+ /// The skip sentence last shown, so an edit re-renders the banner only
+ /// when that sentence changed and a dismissed banner is not resurrected
+ /// by every keystroke.
+ QString m_lastSkipWarning;
QRadioButton *m_matchAll = nullptr;
QRadioButton *m_matchAny = nullptr;
diff --git a/tests/test_tagrules.cpp b/tests/test_tagrules.cpp
index 860d433..27496ce 100644
--- a/tests/test_tagrules.cpp
+++ b/tests/test_tagrules.cpp
@@ -35,6 +35,10 @@ class TestTagRules : public QObject
Q_OBJECT
private slots:
+ void applySavesFirstAndEmitsTheSavedRule();
+ void applyEmitsNothingWhenTheSaveFails();
+ void theArchiveBoxMapsToAllowArchive();
+ void aRuleTheHookWillSkipIsNamedInTheWarning();
void allowArchiveRoundTripsAndIsOmittedWhenFalse();
void allowArchiveIsDroppedFromARuleThatKeepsInbox();
void hookWouldSkipMirrorsThePostNewRefusal();
@@ -1578,5 +1582,101 @@ void TestTagRules::hookWouldSkipMirrorsThePostNewRefusal()
QVERIFY(!TagRules::hookWouldSkip(rule));
}
+static QString writeDialogRules(const QTemporaryDir &home, const QByteArray &json)
+{
+ QDir().mkpath(home.filePath(QStringLiteral("mailrules")));
+ const QString path = home.filePath(QStringLiteral("mailrules/rules.json"));
+ QFile out(path);
+ if (out.open(QIODevice::WriteOnly))
+ out.write(json);
+ return path;
+}
+
+void TestTagRules::applySavesFirstAndEmitsTheSavedRule()
+{
+ // Item 212, decided with the user: only what the hook would run is
+ // applied, so Apply writes the file before asking for anything.
+ QTemporaryDir home;
+ QVERIFY(home.isValid());
+ qputenv("XDG_CONFIG_HOME", home.path().toUtf8());
+ const QString path = writeDialogRules(home, R"({"version": 1, "rules": [
+ {"id": "promo", "add": ["promo"], "query": "from:a@example.org"}]})");
+
+ TagRulesDialog dialog;
+ QSignalSpy spy(&dialog, &TagRulesDialog::applyRequested);
+ dialog.selectRuleForTest(0);
+ dialog.setTagsForTest(QStringLiteral("promo, sale")); // unsaved edit
+ dialog.applyForTest();
+
+ QCOMPARE(spy.size(), 1);
+ const TagRule emitted = spy.first().at(0).value<TagRule>();
+ QCOMPARE(emitted.add, (QStringList{ QStringLiteral("promo"),
+ QStringLiteral("sale") }));
+
+ TagRules onDisk;
+ onDisk.load(path);
+ QCOMPARE(onDisk.rules().at(0).add, emitted.add);
+}
+
+void TestTagRules::applyEmitsNothingWhenTheSaveFails()
+{
+ QTemporaryDir home;
+ QVERIFY(home.isValid());
+ qputenv("XDG_CONFIG_HOME", home.path().toUtf8());
+ writeDialogRules(home, R"({"version": 1, "rules": [
+ {"id": "promo", "add": ["promo"], "query": "from:a@example.org"}]})");
+
+ TagRulesDialog dialog;
+ QSignalSpy spy(&dialog, &TagRulesDialog::applyRequested);
+ dialog.selectRuleForTest(0);
+ dialog.setTagsForTest(QString()); // adds and removes nothing
+ dialog.applyForTest();
+
+ QVERIFY(spy.isEmpty());
+ QVERIFY(!dialog.warningTextForTest().isEmpty());
+}
+
+void TestTagRules::theArchiveBoxMapsToAllowArchive()
+{
+ QTemporaryDir home;
+ QVERIFY(home.isValid());
+ qputenv("XDG_CONFIG_HOME", home.path().toUtf8());
+ const QString path = writeDialogRules(home, R"({"version": 1, "rules": [
+ {"id": "promo", "add": ["promo"], "remove": ["inbox"],
+ "query": "from:a@example.org"}]})");
+
+ {
+ TagRulesDialog dialog;
+ dialog.selectRuleForTest(0);
+ dialog.setArchiveForTest(true);
+ dialog.saveForTest();
+ }
+ TagRules onDisk;
+ onDisk.load(path);
+ QVERIFY(onDisk.rules().at(0).allowArchive);
+}
+
+void TestTagRules::aRuleTheHookWillSkipIsNamedInTheWarning()
+{
+ // CLAUDE.md: a rule the hook will refuse must be flagged in the UI.
+ QTemporaryDir home;
+ QVERIFY(home.isValid());
+ qputenv("XDG_CONFIG_HOME", home.path().toUtf8());
+ writeDialogRules(home, R"({"version": 1, "rules": [
+ {"id": "promo", "add": ["promo"], "remove": ["inbox"],
+ "query": "from:a@example.org"},
+ {"id": "fine", "add": ["x"], "query": "from:b@example.org"}]})");
+
+ TagRulesDialog dialog;
+ QVERIFY2(dialog.warningTextForTest().contains(QStringLiteral("promo")),
+ qPrintable(dialog.warningTextForTest()));
+ QVERIFY(!dialog.warningTextForTest().contains(QStringLiteral("fine")));
+
+ dialog.selectRuleForTest(0);
+ dialog.setArchiveForTest(true);
+ QVERIFY2(!dialog.warningTextForTest().contains(QStringLiteral("promo")),
+ qPrintable(dialog.warningTextForTest()));
+}
+
QTEST_MAIN(TestTagRules)
#include "test_tagrules.moc"