aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rwxr-xr-xmailrules.py95
-rwxr-xr-xtest_mailrules.py94
2 files changed, 189 insertions, 0 deletions
diff --git a/mailrules.py b/mailrules.py
new file mode 100755
index 0000000..c529348
--- /dev/null
+++ b/mailrules.py
@@ -0,0 +1,95 @@
+#!/usr/bin/env python3
+#
+# 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.
+"""Shared notmuch tagging-rule store.
+
+The rules live in ~/.config/mailrules/rules.json and are read by both this
+tool and qtmaildir, so the format belongs to neither: a field one tool does
+not understand is preserved verbatim across a save by the other.
+
+A rule carries NO scope. The post-new hook supplies `tag:new`, a dry run
+supplies nothing and counts against the whole corpus. This is what lets one
+rule serve arrivals, a dry run, and (later) a backfill over history.
+
+Stdlib only, deliberately: this module is imported by a notmuch hook that
+runs on every sync, and mailctl has no dependencies to inherit.
+"""
+
+import json
+import os
+from dataclasses import dataclass, field
+from pathlib import Path
+
+FORMAT_VERSION = 1
+DEFAULT_STAGE = 50
+
+# Fields this version understands. Anything else in a rule object is kept in
+# `unknown` and written back untouched, which is what makes the file neutral
+# rather than this tool's file that another program may read.
+KNOWN_KEYS = {"id", "stage", "enabled", "add", "remove", "query", "note"}
+
+
+@dataclass
+class Rule:
+ id: str
+ query: str
+ add: list = field(default_factory=list)
+ remove: list = field(default_factory=list)
+ stage: int = DEFAULT_STAGE
+ enabled: bool = True
+ note: str = ""
+ unknown: dict = field(default_factory=dict)
+
+
+@dataclass
+class Store:
+ rules: list = field(default_factory=list)
+ warnings: list = field(default_factory=list)
+ unknown: dict = field(default_factory=dict)
+
+
+def default_path():
+ """$XDG_CONFIG_HOME/mailrules/rules.json, or ~/.config/... as fallback.
+
+ No hardcoded home directory: both tools must resolve the same path, and
+ a user with XDG_CONFIG_HOME set expects it honoured.
+ """
+ base = os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config"
+ return Path(base) / "mailrules" / "rules.json"
+
+
+def load(path=None):
+ """Read the store. Never raises for a bad file: problems land in
+ Store.warnings and the offending rule is dropped, so one malformed rule
+ cannot stop the other nineteen from running."""
+ path = Path(path) if path else default_path()
+ store = Store()
+
+ raw = json.loads(path.read_text())
+
+ for obj in raw.get("rules", []):
+ store.rules.append(Rule(
+ id=obj["id"],
+ query=obj["query"],
+ add=list(obj.get("add", [])),
+ remove=list(obj.get("remove", [])),
+ stage=int(obj.get("stage", DEFAULT_STAGE)),
+ enabled=bool(obj.get("enabled", True)),
+ note=obj.get("note", ""),
+ unknown={k: v for k, v in obj.items() if k not in KNOWN_KEYS},
+ ))
+
+ return store
diff --git a/test_mailrules.py b/test_mailrules.py
new file mode 100755
index 0000000..dd491e9
--- /dev/null
+++ b/test_mailrules.py
@@ -0,0 +1,94 @@
+#!/usr/bin/env python3
+#
+# 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.
+"""Self-checks for mailrules.py, the shared tagging-rule store.
+
+The risk in this file is the format, not the notmuch calls: a rule that
+silently loses a field on save, or one that sorts into the wrong stage,
+mis-tags real mail on the next sync and does it quietly.
+
+Run: ./test_mailrules.py
+"""
+
+import json
+import tempfile
+from pathlib import Path
+
+import mailrules
+
+
+def write_rules(tmp, payload):
+ path = Path(tmp) / "rules.json"
+ path.write_text(json.dumps(payload))
+ return path
+
+
+def test_loads_a_rule():
+ with tempfile.TemporaryDirectory() as tmp:
+ path = write_rules(tmp, {
+ "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.",
+ }
+ ],
+ })
+ store = mailrules.load(path)
+ assert store.warnings == [], store.warnings
+ assert len(store.rules) == 1
+ rule = store.rules[0]
+ assert rule.id == "notify-forge"
+ assert rule.stage == 50
+ assert rule.enabled is True
+ assert rule.add == ["notify/forge"]
+ assert rule.remove == []
+ assert rule.query == "from:notifications@example.com"
+ assert rule.note == "All repositories, not one project."
+
+
+def test_defaults_are_applied():
+ """stage, enabled, remove and note are all optional in the file."""
+ with tempfile.TemporaryDirectory() as tmp:
+ path = write_rules(tmp, {
+ "version": 1,
+ "rules": [{"id": "minimal", "add": ["x"],
+ "query": "from:someone@example.com"}],
+ })
+ store = mailrules.load(path)
+ assert store.warnings == [], store.warnings
+ rule = store.rules[0]
+ assert rule.stage == 50
+ assert rule.enabled is True
+ assert rule.remove == []
+ assert rule.note == ""
+
+
+def run_all():
+ for name, fn in sorted(globals().items()):
+ if name.startswith("test_") and callable(fn):
+ fn()
+ print(f"ok {name}")
+
+
+if __name__ == "__main__":
+ run_all()
+ print("\nall passed")