aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-12 11:37:23 +0200
committerDanilo M. <danix@danix.xyz>2026-08-12 11:37:23 +0200
commit7ecd9056f7b0f8261b44f7865f45749fbceb5d39 (patch)
treeb21beb50455f37d905b78aa5e7cb77569eeae858
parent436efafb13079538f3ad5ab1b3df90e02311143c (diff)
downloadmailctl-7ecd9056f7b0f8261b44f7865f45749fbceb5d39.tar.gz
mailctl-7ecd9056f7b0f8261b44f7865f45749fbceb5d39.zip
feat(rules): post-new hook driven by the rule store
Applies each enabled rule scoped to tag:new in stage order, then consumes the marker. Two properties are deliberate and must survive any edit: A rules file that will not load leaves tag:new alone and exits non-zero. Clearing the marker while the rules did not run would permanently orphan that mail, silently, and the gap would surface months later. The marker is consumed only after every rule succeeded, so a failure part way through leaves it set and a re-run finishes the work. Removing `unread` or `inbox` is refused. maildir.synchronize_flags is true, so removing `unread` rewrites Maildir filenames and reaches the server on the next mbsync, and `inbox` is what keeps mail visible at all; neither belongs in an unattended job. A rule that asks is skipped rather than aborting the run, which would both cost the other rules their tagging and strand tag:new forever. The restriction is expected to be lifted once there is a way to confirm such a rule before it runs.
-rwxr-xr-xpost-new137
1 files changed, 137 insertions, 0 deletions
diff --git a/post-new b/post-new
new file mode 100755
index 0000000..c10d6d7
--- /dev/null
+++ b/post-new
@@ -0,0 +1,137 @@
+#!/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.
+"""notmuch post-new hook: auto-tag incoming mail from the shared rule store.
+
+Runs after every `notmuch new`. Reads ~/.config/mailrules/rules.json and
+applies each enabled rule scoped to `tag:new`, in stage order, then consumes
+the `tag:new` marker.
+
+Rules may add any tag and remove most, but this hook REFUSES to remove `unread`
+or `inbox` unattended and skips any rule that asks: see PROTECTED_REMOVALS
+below for why, and for the conditions under which that restriction should be
+lifted. It is expected to be relaxed once there is a story for confirming such
+a rule before it runs.
+
+Requires `new` in [new] tags= in ~/.notmuch-config. Without it every scoped
+query matches nothing and this silently no-ops.
+
+Install: copy to <database.path>/.notmuch/hooks/post-new, with mailrules.py
+importable (same directory, or on PYTHONPATH).
+"""
+
+import subprocess
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+
+import mailrules
+
+SCOPE = "tag:new"
+
+# Tags this hook refuses to REMOVE, whatever a rule says.
+#
+# maildir.synchronize_flags is true, so `unread` is not just an index entry:
+# removing it rewrites Maildir filenames and propagates to the server on the
+# next mbsync. `inbox` is what keeps mail visible at all. Unattended, on every
+# sync, either one silently reorganizes a mailbox in a way that is tedious to
+# undo and reaches other clients before anyone notices.
+#
+# Adding these tags is untouched, and so is removing anything else: a rule may
+# still strip `promo` or any tag of its own making.
+#
+# DELIBERATELY CONSERVATIVE, AND EXPECTED TO BE RELAXED. The rules in use today
+# only add tags, so this forbids nothing anyone is doing. It exists because the
+# hook runs unattended and a mistake here is expensive, not because removing
+# `unread` is wrong in principle: an "archive anything in notify/* older than
+# 90 days" rule is a reasonable thing to want and would need this list revised.
+# When that day comes, the question to answer first is what confirms the rule
+# before it runs, not whether the guard is annoying.
+PROTECTED_REMOVALS = frozenset({"unread", "inbox"})
+
+
+def log(message):
+ print(f"post-new: {message}", file=sys.stderr)
+
+
+def protected_removals(rule):
+ """The protected tags this rule would remove, if any."""
+ return sorted(PROTECTED_REMOVALS.intersection(rule.remove))
+
+
+def run_tag(arguments, query):
+ result = subprocess.run(["notmuch", "tag"] + arguments + ["--", query],
+ capture_output=True, text=True)
+ if result.returncode != 0:
+ log(f"notmuch tag failed: {result.stderr.strip()}")
+ return False
+ return True
+
+
+def main():
+ store = mailrules.load()
+
+ # A file that will not load must NOT reach the consumer below. If the
+ # marker were cleared while the rules did not run, that mail could never
+ # be tagged by these rules again: the failure is silent, permanent, and
+ # invisible until someone notices a gap months later. Leaving tag:new in
+ # place makes the next successful run catch up instead.
+ if store.failed:
+ for warning in store.warnings:
+ log(warning)
+ log("rules did not load; leaving tag:new in place")
+ return 1
+
+ if store.missing:
+ log("no rules file; nothing to do")
+ return 0
+
+ # A dropped rule is not fatal, but it must be visible: this goes to the
+ # sync log, which is where someone looks when a tag stops appearing.
+ for warning in store.warnings:
+ log(warning)
+
+ applied = 0
+ for rule in mailrules.ordered(store.rules):
+ # Skip the rule, do not abort the run. A single over-reaching rule
+ # must not cost the tagging every other rule would have done, and
+ # aborting here would also leave tag:new set forever: the rule would
+ # be refused again on every subsequent sync and the marker would never
+ # be consumed.
+ refused = protected_removals(rule)
+ if refused:
+ log(f"rule '{rule.id}' would remove {', '.join(refused)}; "
+ f"skipped, this hook does not remove those unattended")
+ continue
+
+ query = mailrules.scoped_query(rule, SCOPE)
+ if not run_tag(mailrules.tag_arguments(rule), query):
+ log(f"rule '{rule.id}' failed; leaving tag:new in place")
+ return 1
+ applied += 1
+
+ # Only after every rule succeeded. A failure part way through leaves the
+ # marker set, so re-running the hook is safe and finishes the work.
+ if not run_tag(["-new"], SCOPE):
+ return 1
+
+ log(f"applied {applied} rule(s)")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())