From c2896d8896235df027dc38d7c93ffe4761d4b02d Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Wed, 12 Aug 2026 11:30:06 +0200 Subject: feat(rules): save atomically, preserving unknown fields Write to a temp file and rename, so the hook can never read a partial file. Fields this version does not understand round-trip untouched, which is what makes the format belong to neither tool. --- mailrules.py | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) (limited to 'mailrules.py') diff --git a/mailrules.py b/mailrules.py index 7df1b42..8c65708 100755 --- a/mailrules.py +++ b/mailrules.py @@ -31,6 +31,7 @@ runs on every sync, and mailctl has no dependencies to inherit. import json import os import re +import tempfile from dataclasses import dataclass, field from pathlib import Path @@ -125,6 +126,59 @@ def load(path=None): return store +def save(store, path=None): + """Write the store atomically: a temp file in the same directory, then + rename. Rename within a filesystem is atomic, so a concurrent reader sees + either the old file or the new one and never a partial write. + + There is no locking. Last writer wins on a true collision, which is + accepted for a single-user setup; the failure that would actually hurt is + a truncated read by the hook, and rename eliminates it. + """ + path = Path(path) if path else default_path() + path.parent.mkdir(parents=True, exist_ok=True) + + payload = dict(store.unknown) + payload["version"] = FORMAT_VERSION + payload["rules"] = [_rule_to_dict(r) for r in store.rules] + + # delete=False plus an explicit replace: NamedTemporaryFile would unlink + # the file on close, and the rename is the whole point. + handle = tempfile.NamedTemporaryFile( + mode="w", dir=path.parent, prefix=".rules-", suffix=".tmp", + delete=False) + try: + with handle: + json.dump(payload, handle, indent=2, ensure_ascii=False) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + os.replace(handle.name, path) + except BaseException: + # A failed write must not leave the temp file beside the real one. + try: + os.unlink(handle.name) + except OSError: + pass + raise + + +def _rule_to_dict(rule): + """Known fields first in a stable order, then anything this version did + not understand. Stable ordering keeps a diff of this file readable.""" + out = { + "id": rule.id, + "stage": rule.stage, + "enabled": rule.enabled, + "add": list(rule.add), + "remove": list(rule.remove), + "query": rule.query, + "note": rule.note, + } + out.update(rule.unknown) + return out + + def ordered(rules): """Enabled rules in execution order: by stage ascending, ties by position. -- cgit v1.2.3