#!/usr/bin/env python3 # # Copyright (C) 2026 Danilo M. # # 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