aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-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())