diff options
| author | Danilo M. <danix@danix.xyz> | 2026-08-12 11:27:51 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-08-12 11:27:51 +0200 |
| commit | a0b5c51ff335e36761312c9c9c75156dc33d3913 (patch) | |
| tree | 9f62a8f369ec09b2ac3899ddaa89a762287d6f3a /mailrules.py | |
| parent | 89a41a913d5f0fbf385b70643cba26d64c9ed81c (diff) | |
| download | mailctl-a0b5c51ff335e36761312c9c9c75156dc33d3913.tar.gz mailctl-a0b5c51ff335e36761312c9c9c75156dc33d3913.zip | |
feat(rules): load a shared tagging-rule store
The rules that tag incoming mail live in a notmuch post-new hook as
hand-written shell. This is the first piece of moving them into a JSON
store that both this tool and qtmaildir read.
A rule carries no scope: the hook supplies tag:new, a dry run supplies
nothing. Unknown fields are kept per rule so the format belongs to
neither tool.
Diffstat (limited to 'mailrules.py')
| -rwxr-xr-x | mailrules.py | 95 |
1 files changed, 95 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 |
