aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/CMakeLists.txt1
-rw-r--r--src/tagrules.cpp229
-rw-r--r--src/tagrules.h87
-rw-r--r--tests/CMakeLists.txt1
-rw-r--r--tests/test_tagrules.cpp245
5 files changed, 563 insertions, 0 deletions
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index 6f5101e..4df9adf 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -12,6 +12,7 @@ add_library(qtmaildir_lib STATIC
tagchip.cpp
tagcolors.cpp
tagdialog.cpp
+ tagrules.cpp
tagstrip.cpp
threadlistmodel.cpp
threadlistview.cpp
diff --git a/src/tagrules.cpp b/src/tagrules.cpp
new file mode 100644
index 0000000..39cc529
--- /dev/null
+++ b/src/tagrules.cpp
@@ -0,0 +1,229 @@
+/*
+ * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+ */
+
+#include "tagrules.h"
+
+#include <QCoreApplication>
+#include <QDir>
+#include <QFile>
+#include <QFileInfo>
+#include <QJsonArray>
+#include <QJsonDocument>
+#include <QJsonParseError>
+#include <QRegularExpression>
+#include <QSaveFile>
+#include <algorithm>
+
+namespace {
+
+constexpr int kFormatVersion = 1;
+constexpr int kDefaultStage = 50;
+
+/// Fields this version understands. Anything else is preserved.
+bool isKnownKey(const QString &key)
+{
+ static const QStringList known{
+ QStringLiteral("id"), QStringLiteral("stage"),
+ QStringLiteral("enabled"), QStringLiteral("add"),
+ QStringLiteral("remove"), QStringLiteral("query"),
+ QStringLiteral("note"),
+ };
+ return known.contains(key);
+}
+
+QStringList stringsOf(const QJsonValue &value)
+{
+ QStringList out;
+ const QJsonArray array = value.toArray();
+ for (const QJsonValue &entry : array) {
+ const QString text = entry.toString().trimmed();
+ if (!text.isEmpty())
+ out.append(text);
+ }
+ return out;
+}
+
+} // namespace
+
+QString TagRules::defaultPath()
+{
+ // Not QStandardPaths::ConfigLocation: that appends the organization and
+ // application names, which would put the file under qtmaildir's own
+ // directory. mailctl reads this path too, so it must be tool-neutral.
+ QString base = qEnvironmentVariable("XDG_CONFIG_HOME");
+ if (base.isEmpty())
+ base = QDir::homePath() + QStringLiteral("/.config");
+ return base + QStringLiteral("/mailrules/rules.json");
+}
+
+void TagRules::load(const QString &path)
+{
+ const QString target = path.isEmpty() ? defaultPath() : path;
+
+ m_rules.clear();
+ m_warnings.clear();
+ m_unknown = QJsonObject();
+ m_missing = false;
+
+ QFile file(target);
+ if (!file.exists()) {
+ m_missing = true;
+ return;
+ }
+
+ if (!file.open(QIODevice::ReadOnly)) {
+ m_warnings.append(
+ QObject::tr("Cannot read %1: %2").arg(target, file.errorString()));
+ return;
+ }
+
+ QJsonParseError error{};
+ const QJsonDocument document =
+ QJsonDocument::fromJson(file.readAll(), &error);
+ if (error.error != QJsonParseError::NoError || !document.isObject()) {
+ m_warnings.append(QObject::tr("Cannot read %1: %2")
+ .arg(target, error.errorString()));
+ return;
+ }
+
+ const QJsonObject root = document.object();
+
+ const int version = root.value(QStringLiteral("version"))
+ .toInt(kFormatVersion);
+ if (version != kFormatVersion) {
+ m_warnings.append(
+ QObject::tr("%1 uses format version %2, newer than this version "
+ "understands (%3); refusing to guess")
+ .arg(target).arg(version).arg(kFormatVersion));
+ return;
+ }
+
+ for (auto it = root.begin(); it != root.end(); ++it) {
+ if (it.key() != QStringLiteral("version")
+ && it.key() != QStringLiteral("rules")) {
+ m_unknown.insert(it.key(), it.value());
+ }
+ }
+
+ // An id is a handle: a UI selects on it and a diff tracks it.
+ static const QRegularExpression idPattern(
+ QStringLiteral("^[a-z0-9][a-z0-9-]*$"));
+
+ QStringList seen;
+ const QJsonArray array = root.value(QStringLiteral("rules")).toArray();
+ for (int index = 0; index < array.size(); ++index) {
+ const QJsonObject object = array.at(index).toObject();
+ const QString where = QObject::tr("rule #%1").arg(index + 1);
+
+ TagRule rule;
+ rule.id = object.value(QStringLiteral("id")).toString();
+ if (!idPattern.match(rule.id).hasMatch()) {
+ m_warnings.append(
+ QObject::tr("%1: id '%2' is missing or not lowercase letters, "
+ "digits and dashes; dropped")
+ .arg(where, rule.id));
+ continue;
+ }
+
+ if (seen.contains(rule.id)) {
+ m_warnings.append(QObject::tr("Rule '%1': duplicate id; keeping "
+ "the first").arg(rule.id));
+ continue;
+ }
+
+ rule.query = object.value(QStringLiteral("query")).toString().trimmed();
+ if (rule.query.isEmpty()) {
+ m_warnings.append(
+ QObject::tr("Rule '%1': no query; dropped").arg(rule.id));
+ continue;
+ }
+
+ rule.add = stringsOf(object.value(QStringLiteral("add")));
+ rule.remove = stringsOf(object.value(QStringLiteral("remove")));
+ if (rule.add.isEmpty() && rule.remove.isEmpty()) {
+ m_warnings.append(QObject::tr("Rule '%1': adds and removes "
+ "nothing; dropped").arg(rule.id));
+ continue;
+ }
+
+ rule.stage = object.value(QStringLiteral("stage")).toInt(kDefaultStage);
+ rule.enabled = object.value(QStringLiteral("enabled")).toBool(true);
+ rule.note = object.value(QStringLiteral("note")).toString();
+
+ for (auto it = object.begin(); it != object.end(); ++it) {
+ if (!isKnownKey(it.key()))
+ rule.unknown.insert(it.key(), it.value());
+ }
+
+ seen.append(rule.id);
+ m_rules.append(rule);
+ }
+}
+
+bool TagRules::save(const QString &path) const
+{
+ const QString target = path.isEmpty() ? defaultPath() : path;
+
+ QDir().mkpath(QFileInfo(target).absolutePath());
+
+ QJsonArray array;
+ for (const TagRule &rule : m_rules) {
+ QJsonObject object;
+ object.insert(QStringLiteral("id"), rule.id);
+ object.insert(QStringLiteral("stage"), rule.stage);
+ object.insert(QStringLiteral("enabled"), rule.enabled);
+ object.insert(QStringLiteral("add"),
+ QJsonArray::fromStringList(rule.add));
+ object.insert(QStringLiteral("remove"),
+ QJsonArray::fromStringList(rule.remove));
+ object.insert(QStringLiteral("query"), rule.query);
+ object.insert(QStringLiteral("note"), rule.note);
+ for (auto it = rule.unknown.begin(); it != rule.unknown.end(); ++it)
+ object.insert(it.key(), it.value());
+ array.append(object);
+ }
+
+ QJsonObject root = m_unknown;
+ root.insert(QStringLiteral("version"), kFormatVersion);
+ root.insert(QStringLiteral("rules"), array);
+
+ // QSaveFile writes a temporary and renames on commit, which is the same
+ // atomicity mailrules.py gets from os.replace. The hook must never read a
+ // half-written file.
+ QSaveFile file(target);
+ if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate))
+ return false;
+ file.write(QJsonDocument(root).toJson(QJsonDocument::Indented));
+ return file.commit();
+}
+
+QList<TagRule> TagRules::ordered() const
+{
+ QList<TagRule> out;
+ for (const TagRule &rule : m_rules) {
+ if (rule.enabled)
+ out.append(rule);
+ }
+ // stable_sort, not sort: ties must keep file order, which is the tie-break
+ // the format promises and what lets a user sequence rules within a stage.
+ std::stable_sort(out.begin(), out.end(),
+ [](const TagRule &a, const TagRule &b) {
+ return a.stage < b.stage;
+ });
+ return out;
+}
diff --git a/src/tagrules.h b/src/tagrules.h
new file mode 100644
index 0000000..8f75b38
--- /dev/null
+++ b/src/tagrules.h
@@ -0,0 +1,87 @@
+/*
+ * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+ */
+
+#pragma once
+
+#include <QJsonObject>
+#include <QList>
+#include <QString>
+#include <QStringList>
+
+/// One auto-tagging rule, as stored in ~/.config/mailrules/rules.json.
+///
+/// A rule carries NO scope. The notmuch post-new hook supplies `tag:new`, a
+/// dry run supplies nothing and counts against the whole corpus. That split is
+/// what lets one rule answer both "what would this tag on arrival" and "what
+/// does this match in all my mail".
+struct TagRule
+{
+ QString id; ///< Stable handle, [a-z0-9-]. Never the tag name: tags
+ ///< contain '/' and can be renamed.
+ QString query; ///< notmuch query, unscoped.
+ QString note; ///< Why the rule is shaped this way. Shown in the dialog.
+ QStringList add;
+ QStringList remove;
+ int stage = 50; ///< Ascending. Account tags 10, topic rules 50.
+ bool enabled = true;
+
+ /// Fields this version of qtmaildir does not understand, kept verbatim and
+ /// written back on save. Without this, one save from here silently strips
+ /// whatever a newer mailctl wrote, and the shared format would belong to
+ /// whichever tool saved last.
+ QJsonObject unknown;
+};
+
+/// Reads and writes the shared rule store.
+///
+/// Degrades rather than refusing, exactly as Config does: a malformed rule is
+/// dropped with a warning and the rest still load, because one typo must not
+/// cost every rule. qtmaildir must never fail to open because of this file.
+class TagRules
+{
+public:
+ /// $XDG_CONFIG_HOME/mailrules/rules.json, or ~/.config/... as fallback.
+ /// Deliberately not under qtmaildir's own config directory: mailctl reads
+ /// the same file and neither tool owns it.
+ static QString defaultPath();
+
+ /// Replaces the current contents. Never throws; see warnings().
+ void load(const QString &path = QString());
+
+ /// Atomic: QSaveFile writes a temporary and renames, so the hook can never
+ /// read a partial file. Returns false if the write failed.
+ bool save(const QString &path = QString()) const;
+
+ QList<TagRule> rules() const { return m_rules; }
+ void setRules(const QList<TagRule> &rules) { m_rules = rules; }
+
+ /// Enabled rules in execution order: stage ascending, ties in file order.
+ QList<TagRule> ordered() const;
+
+ QStringList warnings() const { return m_warnings; }
+
+ /// No file yet, as distinct from a file that would not load. A fresh
+ /// install is not an error and must not be reported as one.
+ bool missing() const { return m_missing; }
+
+private:
+ QList<TagRule> m_rules;
+ QStringList m_warnings;
+ QJsonObject m_unknown; ///< Unrecognised top-level keys.
+ bool m_missing = false;
+};
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index 06cc739..9c65abd 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -52,3 +52,4 @@ add_qtmaildir_test(mainwindow)
add_qtmaildir_test(messageview)
add_qtmaildir_test(querycompleter)
add_qtmaildir_test(tagdialog)
+add_qtmaildir_test(tagrules)
diff --git a/tests/test_tagrules.cpp b/tests/test_tagrules.cpp
new file mode 100644
index 0000000..8c7e1a9
--- /dev/null
+++ b/tests/test_tagrules.cpp
@@ -0,0 +1,245 @@
+/*
+ * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+ */
+
+#include <QTemporaryDir>
+#include <QtTest>
+
+#include "tagrules.h"
+
+/// The risk in TagRules is the format, not painting: a field silently dropped
+/// on save mis-tags real mail on the next sync, and does it quietly. These
+/// tests are therefore about round-trips and rejections, and the file they
+/// read is byte-for-byte what mailctl writes.
+class TestTagRules : public QObject
+{
+ Q_OBJECT
+
+private slots:
+ void aRuleLoadsWithEveryField();
+ void absentFieldsTakeTheirDefaults();
+ void aMalformedRuleIsDroppedWithAWarning();
+ void unknownFieldsSurviveASave();
+ void stageOrderPutsAccountsFirst();
+ void aQueryWithQuotesRoundTrips();
+ void aMissingFileIsEmptyNotAnError();
+ void aNewerVersionIsRefused();
+
+private:
+ QString writeRules(const QString &json);
+ QTemporaryDir m_dir;
+};
+
+QString TestTagRules::writeRules(const QString &json)
+{
+ const QString path = m_dir.filePath(QStringLiteral("rules.json"));
+ QFile file(path);
+ if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate))
+ return QString();
+ file.write(json.toUtf8());
+ file.close();
+ return path;
+}
+
+void TestTagRules::aRuleLoadsWithEveryField()
+{
+ const QString path = writeRules(R"({
+ "version": 1,
+ "rules": [{
+ "id": "notify-forge",
+ "stage": 50,
+ "enabled": true,
+ "add": ["notify/forge"],
+ "remove": [],
+ "query": "from:notifications@example.com",
+ "note": "All repositories, not one project."
+ }]
+ })");
+
+ TagRules rules;
+ rules.load(path);
+
+ QVERIFY2(rules.warnings().isEmpty(),
+ qPrintable(rules.warnings().join(QStringLiteral("; "))));
+ QCOMPARE(rules.rules().size(), 1);
+
+ const TagRule &rule = rules.rules().first();
+ QCOMPARE(rule.id, QStringLiteral("notify-forge"));
+ QCOMPARE(rule.stage, 50);
+ QVERIFY(rule.enabled);
+ QCOMPARE(rule.add, QStringList{ QStringLiteral("notify/forge") });
+ QVERIFY(rule.remove.isEmpty());
+ QCOMPARE(rule.query, QStringLiteral("from:notifications@example.com"));
+ QCOMPARE(rule.note, QStringLiteral("All repositories, not one project."));
+}
+
+void TestTagRules::absentFieldsTakeTheirDefaults()
+{
+ const QString path = writeRules(R"({
+ "version": 1,
+ "rules": [{"id": "minimal", "add": ["x"],
+ "query": "from:a@example.com"}]
+ })");
+
+ TagRules rules;
+ rules.load(path);
+
+ QVERIFY(rules.warnings().isEmpty());
+ const TagRule &rule = rules.rules().first();
+ QCOMPARE(rule.stage, 50);
+ QVERIFY(rule.enabled);
+ QVERIFY(rule.remove.isEmpty());
+ QVERIFY(rule.note.isEmpty());
+}
+
+void TestTagRules::aMalformedRuleIsDroppedWithAWarning()
+{
+ // One bad rule must not cost the others. Four separate defects, and the
+ // good rule sits first so a parser that stops at the first problem is
+ // caught by the count rather than by an empty list.
+ const QString path = writeRules(R"({
+ "version": 1,
+ "rules": [
+ {"id": "good", "add": ["x"], "query": "from:a@example.com"},
+ {"id": "no-query", "add": ["y"]},
+ {"id": "no-tags", "query": "from:b@example.com"},
+ {"id": "Bad Id", "add": ["z"], "query": "from:c@example.com"}
+ ]
+ })");
+
+ TagRules rules;
+ rules.load(path);
+
+ QCOMPARE(rules.rules().size(), 1);
+ QCOMPARE(rules.rules().first().id, QStringLiteral("good"));
+ QCOMPARE(rules.warnings().size(), 3);
+}
+
+void TestTagRules::unknownFieldsSurviveASave()
+{
+ // The neutrality guarantee. If qtmaildir strips a field mailctl wrote,
+ // the file is qtmaildir's file that mailctl may read.
+ const QString path = writeRules(R"({
+ "version": 1,
+ "future_top_level": {"set_by": "another tool"},
+ "rules": [{
+ "id": "keeper",
+ "add": ["x"],
+ "query": "from:a@example.com",
+ "future_field": [1, 2, 3]
+ }]
+ })");
+
+ TagRules rules;
+ rules.load(path);
+ QVERIFY(rules.save(path));
+
+ QFile file(path);
+ QVERIFY(file.open(QIODevice::ReadOnly));
+ const QJsonObject root =
+ QJsonDocument::fromJson(file.readAll()).object();
+
+ QCOMPARE(root.value(QStringLiteral("future_top_level"))
+ .toObject().value(QStringLiteral("set_by")).toString(),
+ QStringLiteral("another tool"));
+
+ const QJsonObject saved =
+ root.value(QStringLiteral("rules")).toArray().first().toObject();
+ QCOMPARE(saved.value(QStringLiteral("future_field")).toArray().size(), 3);
+ QCOMPARE(saved.value(QStringLiteral("id")).toString(),
+ QStringLiteral("keeper"));
+}
+
+void TestTagRules::stageOrderPutsAccountsFirst()
+{
+ const QString path = writeRules(R"({
+ "version": 1,
+ "rules": [
+ {"id": "topic-b", "stage": 50, "add": ["b"],
+ "query": "from:b@example.com"},
+ {"id": "account", "stage": 10, "add": ["acct"],
+ "query": "path:\"work/**\""},
+ {"id": "topic-a", "stage": 50, "add": ["a"],
+ "query": "from:a@example.com"},
+ {"id": "off", "stage": 20, "add": ["c"],
+ "query": "from:c@example.com", "enabled": false}
+ ]
+ })");
+
+ TagRules rules;
+ rules.load(path);
+
+ QStringList ids;
+ for (const TagRule &rule : rules.ordered())
+ ids.append(rule.id);
+
+ // Stage ascending, ties in file order, disabled excluded.
+ QCOMPARE(ids, (QStringList{ QStringLiteral("account"),
+ QStringLiteral("topic-b"),
+ QStringLiteral("topic-a") }));
+ // Still loaded, so the dialog can show and re-enable it.
+ QCOMPARE(rules.rules().size(), 4);
+}
+
+void TestTagRules::aQueryWithQuotesRoundTrips()
+{
+ // Not hypothetical: every account rule is written path:"account/**".
+ const QString path = writeRules(R"({
+ "version": 1,
+ "rules": [{"id": "account", "add": ["acct"],
+ "query": "path:\"work-account/**\""}]
+ })");
+
+ TagRules rules;
+ rules.load(path);
+ QCOMPARE(rules.rules().first().query,
+ QStringLiteral("path:\"work-account/**\""));
+
+ QVERIFY(rules.save(path));
+
+ TagRules reloaded;
+ reloaded.load(path);
+ QVERIFY(reloaded.warnings().isEmpty());
+ QCOMPARE(reloaded.rules().first().query,
+ QStringLiteral("path:\"work-account/**\""));
+}
+
+void TestTagRules::aMissingFileIsEmptyNotAnError()
+{
+ // qtmaildir must open on a machine that has never written this file.
+ TagRules rules;
+ rules.load(m_dir.filePath(QStringLiteral("absent.json")));
+ QVERIFY(rules.rules().isEmpty());
+ QVERIFY(rules.warnings().isEmpty());
+ QVERIFY(rules.missing());
+}
+
+void TestTagRules::aNewerVersionIsRefused()
+{
+ const QString path = writeRules(R"({
+ "version": 2,
+ "rules": [{"id": "x", "add": ["a"], "query": "from:a@example.com"}]
+ })");
+
+ TagRules rules;
+ rules.load(path);
+ QVERIFY(rules.rules().isEmpty());
+ QCOMPARE(rules.warnings().size(), 1);
+}
+
+QTEST_MAIN(TestTagRules)
+#include "test_tagrules.moc"