aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.gitignore3
-rwxr-xr-xassets/hooks/mailrules.py261
-rwxr-xr-xassets/hooks/post-new188
-rwxr-xr-xassets/hooks/qtmaildirconf.py134
-rwxr-xr-xassets/hooks/test_mailrules.py278
-rwxr-xr-xassets/hooks/test_post_new.py340
-rwxr-xr-xassets/hooks/test_qtmaildirconf.py179
-rw-r--r--tests/CMakeLists.txt20
8 files changed, 1403 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
index 3437dad..6054f6c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,6 @@ HANDOFF.md
# The .ts is tracked; the .qm is generated from it by lrelease.
*.qm
+
+# Python bytecode from the notmuch hooks in assets/hooks/.
+__pycache__/
diff --git a/assets/hooks/mailrules.py b/assets/hooks/mailrules.py
new file mode 100755
index 0000000..dbb80a5
--- /dev/null
+++ b/assets/hooks/mailrules.py
@@ -0,0 +1,261 @@
+#!/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
+import re
+import tempfile
+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"}
+
+# An id is a handle, not a display name: a UI selects on it and a diff tracks
+# it. Tags may contain '/' and may be renamed; ids may not.
+ID_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$")
+
+
+@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)
+ # Distinguishes "no file yet" from "a file that would not load". The hook
+ # treats them differently: the first is a fresh install, the second must
+ # not consume tag:new.
+ missing: bool = False
+ failed: bool = False
+
+
+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()
+
+ if not path.exists():
+ store.missing = True
+ return store
+
+ try:
+ raw = json.loads(path.read_text())
+ except (json.JSONDecodeError, OSError) as exc:
+ store.warnings.append(f"{path}: cannot read: {exc}")
+ store.failed = True
+ return store
+
+ if not isinstance(raw, dict):
+ store.warnings.append(f"{path}: top level is not an object")
+ store.failed = True
+ return store
+
+ version = raw.get("version", FORMAT_VERSION)
+ if version != FORMAT_VERSION:
+ store.warnings.append(
+ f"{path}: format version {version} is newer than this tool "
+ f"understands ({FORMAT_VERSION}); refusing to guess")
+ store.failed = True
+ return store
+
+ store.unknown = {k: v for k, v in raw.items()
+ if k not in ("version", "rules")}
+
+ seen = set()
+ for index, obj in enumerate(raw.get("rules", [])):
+ rule = _parse_rule(obj, index, seen, store.warnings)
+ if rule is not None:
+ seen.add(rule.id)
+ store.rules.append(rule)
+
+ return store
+
+
+def scoped_query(rule, scope):
+ """The rule's query narrowed by `scope`, or the bare query when scope is
+ empty.
+
+ The parentheses are load-bearing. notmuch's `and` binds tighter than
+ `or`, so `tag:new and a or b` means `(tag:new and a) or b`: a rule that
+ is a disjunction of senders would escape its scope and match the whole
+ corpus. Do not remove them, and do not build this string anywhere else.
+ """
+ if not scope:
+ return rule.query
+ return f"{scope} and ({rule.query})"
+
+
+def tag_arguments(rule):
+ """The +tag/-tag arguments for `notmuch tag`, adds before removes."""
+ return [f"+{t}" for t in rule.add] + [f"-{t}" for t in rule.remove]
+
+
+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.
+
+ `sorted` is stable, so sorting on stage alone preserves file order within
+ a stage. That is the tie-break the format promises, and it is why this
+ does not sort on (stage, id): an id-sorted tie would reorder rules a user
+ deliberately sequenced.
+ """
+ return sorted([r for r in rules if r.enabled], key=lambda r: r.stage)
+
+
+def _parse_rule(obj, index, seen, warnings):
+ """One rule, or None with a warning appended. `index` names the rule when
+ it has no usable id of its own."""
+ where = f"rule #{index + 1}"
+
+ if not isinstance(obj, dict):
+ warnings.append(f"{where}: not an object; dropped")
+ return None
+
+ rule_id = obj.get("id", "")
+ if not isinstance(rule_id, str) or not ID_RE.match(rule_id):
+ warnings.append(
+ f"{where}: id '{rule_id}' is missing or not lowercase "
+ f"letters, digits and dashes; dropped")
+ return None
+
+ if rule_id in seen:
+ warnings.append(f"rule '{rule_id}': duplicate id; keeping the first")
+ return None
+
+ query = obj.get("query", "")
+ if not isinstance(query, str) or not query.strip():
+ warnings.append(f"rule '{rule_id}': no query; dropped")
+ return None
+
+ add = [t for t in obj.get("add", []) if isinstance(t, str) and t.strip()]
+ remove = [t for t in obj.get("remove", []) if isinstance(t, str) and t.strip()]
+ if not add and not remove:
+ warnings.append(
+ f"rule '{rule_id}': adds and removes nothing; dropped")
+ return None
+
+ try:
+ stage = int(obj.get("stage", DEFAULT_STAGE))
+ except (TypeError, ValueError):
+ warnings.append(
+ f"rule '{rule_id}': stage '{obj.get('stage')}' is not a "
+ f"number; using {DEFAULT_STAGE}")
+ stage = DEFAULT_STAGE
+
+ return Rule(
+ id=rule_id,
+ query=query,
+ add=add,
+ remove=remove,
+ stage=stage,
+ enabled=bool(obj.get("enabled", True)),
+ note=obj.get("note", "") if isinstance(obj.get("note", ""), str) else "",
+ unknown={k: v for k, v in obj.items() if k not in KNOWN_KEYS},
+ )
diff --git a/assets/hooks/post-new b/assets/hooks/post-new
new file mode 100755
index 0000000..5102103
--- /dev/null
+++ b/assets/hooks/post-new
@@ -0,0 +1,188 @@
+#!/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
+import qtmaildirconf
+
+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.
+# NOT the same list as mailctl.py's PROTECTED_REMOVALS, and the two must not be
+# merged. That one is `{inbox}` and is a GATE: a human can override it with
+# --confirm-destructive. This one is `{unread, inbox}` and is a REFUSAL, because
+# there is no human present to confirm anything when cron runs a sync.
+PROTECTED_REMOVALS = frozenset({"unread", "inbox"})
+
+
+def log(message):
+ print(f"post-new: {message}", file=sys.stderr)
+
+
+def strip_inbox_from_sent(run):
+ """Take `inbox` off mail the user SENT, and nothing else.
+
+ `notmuch new` applies new.tags to every file it indexes, and it cannot
+ tell an arrival from the copy qtmaildir files into a sent folder after a
+ send. The result is sent mail carrying `inbox`, which puts it in an inbox
+ view it never arrived in and in any hand-typed `tag:inbox` search.
+
+ This is NOT a relaxation of PROTECTED_REMOVALS below, and the difference
+ is the whole reason it can run unattended. That guard is about a RULE
+ removing `inbox` from mail whose provenance the hook cannot judge. Here
+ the provenance is the file's own path: a message inside a configured sent
+ folder is one this system sent, and `inbox` was never true of it. Nothing
+ the user could act on is being hidden.
+
+ Only `inbox`. `unread` is untouched, because maildir.synchronize_flags is
+ true and removing it rewrites Maildir filenames, which reaches the server
+ on the next mbsync.
+
+ Scoped to tag:new like every rule, so a sync never rewrites tags across
+ the whole corpus. Mail already indexed keeps whatever it has.
+ """
+ folders = qtmaildirconf.sent_folders()
+ if not folders:
+ # No config, or no account keeping sent mail locally. Nothing to
+ # protect, and this must NOT fall through to an empty query: notmuch
+ # reads that as "match everything", which would strip `inbox` from
+ # every newly indexed message on the system.
+ return True
+
+ query = f"{SCOPE} and ({qtmaildirconf.sent_query(folders)})"
+ if not run(["-inbox"], query):
+ return False
+
+ log(f"sent-folder carve-out applied over {len(folders)} folder(s)")
+ return True
+
+
+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
+
+ # AFTER the rules and BEFORE the marker is consumed. After, so a rule can
+ # still see its own sent mail with `inbox` on it and match the way it
+ # always did; before, because the marker is what scopes this to newly
+ # indexed mail and consuming it first would leave nothing to match.
+ if not strip_inbox_from_sent(run_tag):
+ log("sent-folder carve-out failed; leaving tag:new in place")
+ return 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())
diff --git a/assets/hooks/qtmaildirconf.py b/assets/hooks/qtmaildirconf.py
new file mode 100755
index 0000000..e709a28
--- /dev/null
+++ b/assets/hooks/qtmaildirconf.py
@@ -0,0 +1,134 @@
+#!/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.
+"""Reads the account layout out of qtmaildir.conf, for the post-new hook.
+
+Only the sent folders are read, and only so the hook can tell mail the user
+SENT from mail that arrived. Everything else in that file belongs to the
+application.
+
+Stdlib only: this is imported by a notmuch hook that runs on every sync.
+
+The file is written by QSettings rather than by configparser, and the two
+disagree in one place that matters here. QSettings treats `/` in a section
+name as a group separator, so accounts are `[account.<key>]` with a DOT, and
+that key may itself contain dots (`[account.provider.name]`). The account key
+is therefore everything after the FIRST dot, never a split on the last one.
+"""
+
+import configparser
+from pathlib import Path
+
+ACCOUNT_PREFIX = "account."
+
+
+def default_path():
+ """~/.config/qtmaildir/qtmaildir.conf, honouring XDG_CONFIG_HOME.
+
+ Read through the environment rather than hardcoded so a test can point
+ at a throwaway config, which is how the hook's own tests reach it.
+ """
+ import os
+ base = os.environ.get("XDG_CONFIG_HOME") or (Path.home() / ".config")
+ return Path(base) / "qtmaildir" / "qtmaildir.conf"
+
+
+def _accounts(path):
+ """Every `[account.*]` section as a dict, or nothing at all.
+
+ A file that will not parse yields NO accounts rather than raising. The
+ caller is a hook running after `notmuch new` has already indexed the
+ mail: failing the sync over a malformed application config is worse than
+ not protecting sent mail for one cycle, and the hook logs the miss.
+ """
+ parser = configparser.ConfigParser(
+ # QSettings writes `;` comments, and `#` appears inside values (a
+ # colour is `#2f6fa8`), so `#` must NOT introduce a comment.
+ comment_prefixes=(";",),
+ # A value may contain `%` and `$`; neither is an interpolation here.
+ interpolation=None,
+ # `[Gmail]/Posta inviata` is a legal value. Nothing in this file
+ # relies on duplicate keys, but tolerating them beats raising.
+ strict=False)
+ try:
+ # Explicit UTF-8: QSettings writes it, and the C locale would
+ # otherwise decide.
+ with open(path, encoding="utf-8") as handle:
+ parser.read_file(handle)
+ except (OSError, UnicodeDecodeError, configparser.Error):
+ return []
+
+ return [(name[len(ACCOUNT_PREFIX):], parser[name])
+ for name in parser.sections()
+ if name.startswith(ACCOUNT_PREFIX)]
+
+
+# Folders mail does not ARRIVE in: this system put the message there itself.
+#
+# Trash is deliberately absent. qtmaildir's own Delete leaves `inbox` on a
+# trashed message so Restore can put it back where it came from, and stripping
+# it here would fight that.
+NOT_ARRIVALS = ("sent", "drafts")
+
+
+def sent_folders(path=None):
+ """Every folder mail does not arrive in, relative to the mail root.
+
+ An account contributes nothing unless it names a maildir: a bare `Sent`
+ would match every account's folder of that name at once. Each of the keys
+ in NOT_ARRIVALS is optional on its own, since an account may keep no sent
+ mail or no drafts locally.
+ """
+ if path is None:
+ path = default_path()
+
+ folders = []
+ for _key, section in _accounts(path):
+ maildir = section.get("maildir", "").strip()
+ if not maildir:
+ continue
+ for key in NOT_ARRIVALS:
+ folder = section.get(key, "").strip()
+ if folder:
+ folders.append(f"{maildir}/{folder}")
+ return folders
+
+
+def sent_query(folders):
+ """A notmuch query matching everything inside the given folders.
+
+ Empty for an empty list, and the caller MUST check: an empty query means
+ "match everything" to notmuch, so handing this straight to a tag command
+ would treat the whole corpus as sent mail.
+
+ `path:` is hierarchical, so `<folder>/**` covers `cur/` and `new/` and
+ any nesting a provider invents underneath.
+ """
+ if not folders:
+ return ""
+
+ terms = [f'path:"{_quote(folder)}/**"' for folder in folders]
+ return " or ".join(terms)
+
+
+def _quote(value):
+ """Escape a folder name for a double-quoted notmuch term.
+
+ Backslashes BEFORE quotes: the other order escapes the backslashes just
+ added. Same rule as SearchTerm::quote() in the application, and the same
+ reason.
+ """
+ return value.replace("\\", "\\\\").replace('"', '\\"')
diff --git a/assets/hooks/test_mailrules.py b/assets/hooks/test_mailrules.py
new file mode 100755
index 0000000..b1f31c9
--- /dev/null
+++ b/assets/hooks/test_mailrules.py
@@ -0,0 +1,278 @@
+#!/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.
+"""Self-checks for mailrules.py, the shared tagging-rule store.
+
+The risk in this file is the format, not the notmuch calls: a rule that
+silently loses a field on save, or one that sorts into the wrong stage,
+mis-tags real mail on the next sync and does it quietly.
+
+Run: ./test_mailrules.py
+"""
+
+import json
+import tempfile
+from pathlib import Path
+
+import mailrules
+
+
+def write_rules(tmp, payload):
+ path = Path(tmp) / "rules.json"
+ path.write_text(json.dumps(payload))
+ return path
+
+
+def test_loads_a_rule():
+ with tempfile.TemporaryDirectory() as tmp:
+ path = write_rules(tmp, {
+ "version": 1,
+ "rules": [
+ {
+ "id": "notify-forge",
+ "stage": 50,
+ "enabled": True,
+ "add": ["notify/forge"],
+ "remove": [],
+ "query": "from:notifications@example.com",
+ "note": "All repositories, not one project.",
+ }
+ ],
+ })
+ store = mailrules.load(path)
+ assert store.warnings == [], store.warnings
+ assert len(store.rules) == 1
+ rule = store.rules[0]
+ assert rule.id == "notify-forge"
+ assert rule.stage == 50
+ assert rule.enabled is True
+ assert rule.add == ["notify/forge"]
+ assert rule.remove == []
+ assert rule.query == "from:notifications@example.com"
+ assert rule.note == "All repositories, not one project."
+
+
+def test_defaults_are_applied():
+ """stage, enabled, remove and note are all optional in the file."""
+ with tempfile.TemporaryDirectory() as tmp:
+ path = write_rules(tmp, {
+ "version": 1,
+ "rules": [{"id": "minimal", "add": ["x"],
+ "query": "from:someone@example.com"}],
+ })
+ store = mailrules.load(path)
+ assert store.warnings == [], store.warnings
+ rule = store.rules[0]
+ assert rule.stage == 50
+ assert rule.enabled is True
+ assert rule.remove == []
+ assert rule.note == ""
+
+
+def test_a_bad_rule_is_dropped_and_the_rest_survive():
+ """One malformed rule must not stop the others. The hook runs every ten
+ minutes on real mail; losing all tagging because of one typo is worse
+ than losing one rule."""
+ with tempfile.TemporaryDirectory() as tmp:
+ path = write_rules(tmp, {
+ "version": 1,
+ "rules": [
+ {"id": "good", "add": ["x"], "query": "from:a@example.com"},
+ {"id": "no-query", "add": ["y"]},
+ {"id": "no-tags", "query": "from:b@example.com"},
+ {"id": "bad id!", "add": ["z"], "query": "from:c@example.com"},
+ {"add": ["w"], "query": "from:d@example.com"},
+ ],
+ })
+ store = mailrules.load(path)
+ assert [r.id for r in store.rules] == ["good"]
+ assert len(store.warnings) == 4, store.warnings
+ joined = " ".join(store.warnings)
+ assert "no-query" in joined
+ assert "no-tags" in joined
+ assert "bad id!" in joined
+
+
+def test_duplicate_ids_keep_the_first():
+ with tempfile.TemporaryDirectory() as tmp:
+ path = write_rules(tmp, {
+ "version": 1,
+ "rules": [
+ {"id": "dup", "add": ["first"], "query": "from:a@example.com"},
+ {"id": "dup", "add": ["second"], "query": "from:b@example.com"},
+ ],
+ })
+ store = mailrules.load(path)
+ assert len(store.rules) == 1
+ assert store.rules[0].add == ["first"]
+ assert any("dup" in w for w in store.warnings)
+
+
+def test_a_missing_file_is_empty_not_an_error():
+ """qtmaildir must open on a machine that has never written this file."""
+ with tempfile.TemporaryDirectory() as tmp:
+ store = mailrules.load(Path(tmp) / "absent.json")
+ assert store.rules == []
+ assert store.warnings == []
+ assert store.missing is True
+
+
+def test_unparseable_json_warns_and_yields_no_rules():
+ with tempfile.TemporaryDirectory() as tmp:
+ path = Path(tmp) / "rules.json"
+ path.write_text("{not json")
+ store = mailrules.load(path)
+ assert store.rules == []
+ assert len(store.warnings) == 1
+ assert store.failed is True
+
+
+def test_a_newer_format_version_is_refused():
+ """Guessing at semantics a later version defined is how a rule silently
+ changes meaning. Refuse instead."""
+ with tempfile.TemporaryDirectory() as tmp:
+ path = write_rules(tmp, {
+ "version": 2,
+ "rules": [{"id": "x", "add": ["a"], "query": "from:a@example.com"}],
+ })
+ store = mailrules.load(path)
+ assert store.rules == []
+ assert store.failed is True
+ assert any("version" in w for w in store.warnings)
+
+
+def test_ordered_sorts_by_stage_then_file_position():
+ """Account tags must run before topic rules. Ties keep file order, so
+ the file still reads as a sequence."""
+ with tempfile.TemporaryDirectory() as tmp:
+ path = write_rules(tmp, {
+ "version": 1,
+ "rules": [
+ {"id": "topic-b", "stage": 50, "add": ["b"],
+ "query": "from:b@example.com"},
+ {"id": "account", "stage": 10, "add": ["acct"],
+ "query": "path:\"work/**\""},
+ {"id": "topic-a", "stage": 50, "add": ["a"],
+ "query": "from:a@example.com"},
+ ],
+ })
+ store = mailrules.load(path)
+ assert [r.id for r in mailrules.ordered(store.rules)] == [
+ "account", "topic-b", "topic-a"]
+
+
+def test_ordered_skips_disabled_rules():
+ with tempfile.TemporaryDirectory() as tmp:
+ path = write_rules(tmp, {
+ "version": 1,
+ "rules": [
+ {"id": "on", "add": ["a"], "query": "from:a@example.com"},
+ {"id": "off", "add": ["b"], "query": "from:b@example.com",
+ "enabled": False},
+ ],
+ })
+ store = mailrules.load(path)
+ assert [r.id for r in mailrules.ordered(store.rules)] == ["on"]
+ # The disabled rule is still LOADED, so a UI can show and re-enable it.
+ assert [r.id for r in store.rules] == ["on", "off"]
+
+
+def test_save_round_trips_unknown_fields():
+ """The neutrality guarantee. If this tool strips a field qtmaildir
+ added, the file is this tool's file that qtmaildir may read."""
+ with tempfile.TemporaryDirectory() as tmp:
+ path = write_rules(tmp, {
+ "version": 1,
+ "future_top_level": {"set_by": "another tool"},
+ "rules": [{
+ "id": "keeper",
+ "add": ["x"],
+ "query": "from:a@example.com",
+ "future_field": [1, 2, 3],
+ }],
+ })
+ store = mailrules.load(path)
+ assert store.rules[0].unknown == {"future_field": [1, 2, 3]}
+
+ mailrules.save(store, path)
+
+ raw = json.loads(path.read_text())
+ assert raw["future_top_level"] == {"set_by": "another tool"}
+ assert raw["rules"][0]["future_field"] == [1, 2, 3]
+ assert raw["rules"][0]["id"] == "keeper"
+ assert raw["version"] == 1
+
+
+def test_save_is_atomic():
+ """A reader must never see a half-written file: the hook runs every ten
+ minutes and a truncated read would be a failed sync."""
+ with tempfile.TemporaryDirectory() as tmp:
+ path = Path(tmp) / "rules.json"
+ store = mailrules.Store(rules=[
+ mailrules.Rule(id="a", query="from:a@example.com", add=["x"])])
+ mailrules.save(store, path)
+ # The temp file the write went through must not be left behind.
+ assert [p.name for p in Path(tmp).iterdir()] == ["rules.json"]
+ assert json.loads(path.read_text())["rules"][0]["id"] == "a"
+
+
+def test_save_creates_the_directory():
+ with tempfile.TemporaryDirectory() as tmp:
+ path = Path(tmp) / "nested" / "rules.json"
+ mailrules.save(mailrules.Store(), path)
+ assert path.exists()
+ assert json.loads(path.read_text()) == {"version": 1, "rules": []}
+
+
+def test_scoped_query_parenthesises_the_rule():
+ """Without the parentheses `tag:new and a or b` binds as
+ `(tag:new and a) or b`, and the rule matches every message in the corpus
+ satisfying b rather than only new arrivals. Several real rules are a
+ disjunction of senders, so this is the difference between tagging four
+ messages and tagging four thousand."""
+ rule = mailrules.Rule(
+ id="disjunction",
+ query="from:a@example.com or from:b@example.com",
+ add=["promo"])
+ assert mailrules.scoped_query(rule, "tag:new") == (
+ "tag:new and (from:a@example.com or from:b@example.com)")
+
+
+def test_scoped_query_with_no_scope_is_the_bare_query():
+ """A dry run counts against the whole corpus, which is what makes the
+ same rule answer 'what would this tag on arrival' and 'what does this
+ match in all my mail'."""
+ rule = mailrules.Rule(id="x", query="from:a@example.com", add=["y"])
+ assert mailrules.scoped_query(rule, None) == "from:a@example.com"
+ assert mailrules.scoped_query(rule, "") == "from:a@example.com"
+
+
+def test_tag_arguments():
+ rule = mailrules.Rule(id="x", query="from:a@example.com",
+ add=["one", "two"], remove=["three"])
+ assert mailrules.tag_arguments(rule) == ["+one", "+two", "-three"]
+
+
+def run_all():
+ for name, fn in sorted(globals().items()):
+ if name.startswith("test_") and callable(fn):
+ fn()
+ print(f"ok {name}")
+
+
+if __name__ == "__main__":
+ run_all()
+ print("\nall passed")
diff --git a/assets/hooks/test_post_new.py b/assets/hooks/test_post_new.py
new file mode 100755
index 0000000..a0228aa
--- /dev/null
+++ b/assets/hooks/test_post_new.py
@@ -0,0 +1,340 @@
+#!/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.
+"""End-to-end checks for the post-new hook against a throwaway notmuch
+database. Nothing here touches the user's real mail: NOTMUCH_CONFIG points at
+a generated maildir under a temp directory.
+
+The properties worth proving are the ones that cannot be unit-tested from
+mailrules.py alone:
+
+ - a rule actually tags the mail its query matches, and only that mail
+ - the tag:new marker is consumed on success
+ - the marker SURVIVES when a rule fails, so a re-run catches up
+ - a rule removing a protected tag is skipped whole, and the run continues
+
+Run: ./test_post_new.py (requires notmuch on PATH)
+"""
+
+import json
+import os
+import subprocess
+import tempfile
+from pathlib import Path
+
+HOOK = Path(__file__).resolve().parent / "post-new"
+
+
+def make_message(maildir, name, sender, subject):
+ path = maildir / "new" / name
+ path.write_text(
+ f"From: {sender}\n"
+ f"To: you@example.org\n"
+ f"Subject: {subject}\n"
+ f"Message-Id: <{name}@example.org>\n"
+ f"Date: Mon, 11 Aug 2026 10:00:00 +0000\n"
+ f"\nbody\n")
+
+
+def setup_database(tmp):
+ """A maildir with three messages, indexed, every message carrying the
+ `new` marker the rules key off."""
+ maildir = Path(tmp) / "Mail"
+ for sub in ("new", "cur", "tmp"):
+ (maildir / sub).mkdir(parents=True)
+
+ make_message(maildir, "one", "notifications@example.com", "a notification")
+ make_message(maildir, "two", "friend@example.org", "a real message")
+ make_message(maildir, "three", "promo@example.net", "an advertisement")
+
+ config = Path(tmp) / "notmuch-config"
+ config.write_text(
+ f"[database]\npath={maildir}\n\n"
+ f"[new]\ntags=new;unread;inbox\n\n"
+ f"[user]\nname=Test\nprimary_email=you@example.org\n")
+
+ env = dict(os.environ)
+ env["NOTMUCH_CONFIG"] = str(config)
+ env["XDG_CONFIG_HOME"] = str(Path(tmp) / "config")
+ subprocess.run(["notmuch", "new"], env=env, capture_output=True, check=True)
+ return env
+
+
+def write_rules(env, rules):
+ path = Path(env["XDG_CONFIG_HOME"]) / "mailrules" / "rules.json"
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(json.dumps({"version": 1, "rules": rules}))
+
+
+def count(env, query):
+ out = subprocess.run(["notmuch", "count", query], env=env,
+ capture_output=True, text=True, check=True)
+ return int(out.stdout.strip())
+
+
+def test_a_rule_tags_only_what_it_matches():
+ with tempfile.TemporaryDirectory() as tmp:
+ env = setup_database(tmp)
+ write_rules(env, [{
+ "id": "notify",
+ "add": ["notify/forge"],
+ "query": "from:notifications@example.com",
+ }])
+ assert count(env, "tag:new") == 3
+
+ result = subprocess.run([str(HOOK)], env=env, capture_output=True,
+ text=True)
+ assert result.returncode == 0, result.stderr
+
+ assert count(env, "tag:notify/forge") == 1
+ assert count(env, "tag:notify/forge and from:friend@example.org") == 0
+ # The marker is consumed, so the next sync's rules see only new mail.
+ assert count(env, "tag:new") == 0
+
+
+def test_stage_order_is_honoured():
+ with tempfile.TemporaryDirectory() as tmp:
+ env = setup_database(tmp)
+ write_rules(env, [
+ {"id": "late", "stage": 50, "add": ["second"],
+ "query": "tag:first"},
+ {"id": "early", "stage": 10, "add": ["first"],
+ "query": "from:notifications@example.com"},
+ ])
+ subprocess.run([str(HOOK)], env=env, capture_output=True, check=True)
+ # `late` matches only what `early` tagged, so a wrong order gives 0.
+ assert count(env, "tag:second") == 1
+
+
+def test_a_failing_rule_leaves_the_marker_in_place():
+ """The property that makes a re-run safe. An invalid query fails the
+ notmuch call, and tag:new must survive so the next run catches up.
+
+ The query has to be one notmuch genuinely rejects, which is a narrower
+ set than it looks: notmuch 0.39's parser accepts unbalanced parentheses
+ and bare punctuation without complaint, tags nothing, and exits 0. A
+ malformed date range is rejected by the date parser and does exit
+ non-zero, which is why the fixture uses one.
+ """
+ with tempfile.TemporaryDirectory() as tmp:
+ env = setup_database(tmp)
+ write_rules(env, [{
+ "id": "broken",
+ "add": ["x"],
+ "query": "date:zzz..zzz",
+ }])
+ result = subprocess.run([str(HOOK)], env=env, capture_output=True,
+ text=True)
+ assert result.returncode == 1
+ assert count(env, "tag:new") == 3
+
+
+def test_a_disjunction_stays_inside_its_scope():
+ """The parenthesisation guard, end to end. Both senders are already
+ indexed and out of tag:new after a first run; a rule that escaped its
+ scope would tag them anyway."""
+ with tempfile.TemporaryDirectory() as tmp:
+ env = setup_database(tmp)
+ write_rules(env, [{"id": "noop", "add": ["pass-one"],
+ "query": "from:nobody@example.invalid"}])
+ subprocess.run([str(HOOK)], env=env, capture_output=True, check=True)
+ assert count(env, "tag:new") == 0
+
+ write_rules(env, [{
+ "id": "disjunction",
+ "add": ["promo"],
+ "query": "from:friend@example.org or from:promo@example.net",
+ }])
+ subprocess.run([str(HOOK)], env=env, capture_output=True, check=True)
+ # Nothing carries tag:new any more, so a correctly scoped rule tags
+ # nothing. Unparenthesised, the `or` branch would tag one message.
+ assert count(env, "tag:promo") == 0
+
+
+def test_a_protected_removal_is_skipped_whole_and_the_run_continues():
+ """The PROTECTED_REMOVALS guard, end to end.
+
+ Four assertions in one run, because three of them pass against a guard
+ that is broken in a different way. A guard that skipped only the removal
+ would still apply the rule's adds; a guard that aborted the run would
+ starve every later rule; and a guard that aborted before the consumer
+ would strand tag:new, so every future sync would refuse the same rule
+ again and nothing would ever be tagged after it.
+ """
+ with tempfile.TemporaryDirectory() as tmp:
+ env = setup_database(tmp)
+ write_rules(env, [
+ {"id": "over-reaching", "stage": 10,
+ "add": ["archived"], "remove": ["unread", "inbox"],
+ "query": "from:notifications@example.com"},
+ {"id": "well-behaved", "stage": 20, "add": ["promo"],
+ "query": "from:promo@example.net"},
+ ])
+ assert count(env, "tag:unread") == 3
+ assert count(env, "tag:inbox") == 3
+
+ result = subprocess.run([str(HOOK)], env=env, capture_output=True,
+ text=True)
+ assert result.returncode == 0, result.stderr
+ assert "over-reaching" in result.stderr, result.stderr
+
+ # 1. the protected tags survive on the message the rule matched
+ assert count(env, "tag:unread and from:notifications@example.com") == 1
+ assert count(env, "tag:inbox and from:notifications@example.com") == 1
+ # 2. the rule is skipped ENTIRELY, so its adds never land either
+ assert count(env, "tag:archived") == 0
+ # 3. a later, well-behaved rule still runs
+ assert count(env, "tag:promo") == 1
+ # 4. the marker is still consumed, so the next sync is not stuck
+ assert count(env, "tag:new") == 0
+
+
+def setup_accounts(tmp, sent_config=True):
+ """A maildir laid out as qtmaildir configures it: two accounts, each with
+ an Inbox and a Sent folder, one message in each.
+
+ Separate from setup_database() because the sent carve-out is the only
+ thing that cares where a file sits. The folder names are the awkward
+ ones deliberately: a bracketed, spaced provider folder is what the real
+ config carries, and a flat `Sent` is what the other half carries.
+ """
+ root = Path(tmp) / "Mail"
+ folders = {
+ "one": ("acct-one/Inbox", "acct-one/Sent"),
+ "two": ("acct-two/[Provider]/Posta inviata",
+ "acct-two/[Provider]/Posta inviata"),
+ }
+ for sub in ("acct-one/Inbox", "acct-one/Sent",
+ "acct-two/Inbox", "acct-two/[Provider]/Posta inviata"):
+ for part in ("new", "cur", "tmp"):
+ (root / sub / part).mkdir(parents=True)
+
+ make_message(root / "acct-one/Inbox", "arrived-one",
+ "friend@example.org", "an arrival")
+ make_message(root / "acct-one/Sent", "sent-one",
+ "you@example.org", "something sent")
+ make_message(root / "acct-two/Inbox", "arrived-two",
+ "friend@example.org", "another arrival")
+ make_message(root / "acct-two/[Provider]/Posta inviata", "sent-two",
+ "you@example.org", "something else sent")
+
+ config = Path(tmp) / "notmuch-config"
+ config.write_text(
+ f"[database]\npath={root}\n\n"
+ f"[new]\ntags=new;unread;inbox\n\n"
+ f"[user]\nname=Test\nprimary_email=you@example.org\n")
+
+ env = dict(os.environ)
+ env["NOTMUCH_CONFIG"] = str(config)
+ env["XDG_CONFIG_HOME"] = str(Path(tmp) / "config")
+
+ if sent_config:
+ conf = Path(env["XDG_CONFIG_HOME"]) / "qtmaildir" / "qtmaildir.conf"
+ conf.parent.mkdir(parents=True, exist_ok=True)
+ conf.write_text(
+ "[account.one]\nmaildir = acct-one\nsent = Sent\n"
+ "[account.two]\nmaildir = acct-two\n"
+ "sent = [Provider]/Posta inviata\n")
+
+ subprocess.run(["notmuch", "new"], env=env, capture_output=True,
+ check=True)
+ return env
+
+
+def test_sent_mail_does_not_keep_the_inbox_tag():
+ """The carve-out. notmuch's new.tags applies `inbox` to every file it
+ indexes, including the copy the composer files into a sent folder, so
+ mail the user SENT shows up in an inbox view it never arrived in.
+
+ Both accounts are asserted, because the folder shapes differ and a
+ reader that mishandles the bracketed, spaced one would still pass on the
+ flat `Sent`.
+ """
+ with tempfile.TemporaryDirectory() as tmp:
+ env = setup_accounts(tmp)
+ write_rules(env, [])
+ assert count(env, "tag:inbox") == 4
+
+ result = subprocess.run([str(HOOK)], env=env, capture_output=True,
+ text=True)
+ assert result.returncode == 0, result.stderr
+
+ # The two sent copies lose it...
+ assert count(env, 'tag:inbox and path:"acct-one/Sent/**"') == 0
+ assert count(
+ env,
+ 'tag:inbox and path:"acct-two/[Provider]/Posta inviata/**"') == 0
+ # ...and the two arrivals keep it. This is the half that fails if the
+ # query is unscoped, which is the expensive mistake here.
+ assert count(env, "tag:inbox") == 2
+ assert count(env, 'tag:inbox and path:"acct-one/Inbox/**"') == 1
+ assert count(env, 'tag:inbox and path:"acct-two/Inbox/**"') == 1
+
+
+def test_sent_mail_keeps_every_other_tag():
+ """Only `inbox` is stripped. `unread` in particular must survive:
+ maildir.synchronize_flags is true, so removing it rewrites Maildir
+ filenames and reaches the server on the next mbsync.
+ """
+ with tempfile.TemporaryDirectory() as tmp:
+ env = setup_accounts(tmp)
+ write_rules(env, [])
+ subprocess.run([str(HOOK)], env=env, capture_output=True, check=True)
+ assert count(env, 'tag:unread and path:"acct-one/Sent/**"') == 1
+
+
+def test_the_carve_out_only_touches_newly_indexed_mail():
+ """Scoped to tag:new like every rule, so the hook never rewrites tags
+ across the whole corpus on a sync. A sent message whose `inbox` tag was
+ put back by hand stays that way until it is reindexed.
+ """
+ with tempfile.TemporaryDirectory() as tmp:
+ env = setup_accounts(tmp)
+ write_rules(env, [])
+ subprocess.run([str(HOOK)], env=env, capture_output=True, check=True)
+ assert count(env, 'tag:inbox and path:"acct-one/Sent/**"') == 0
+
+ subprocess.run(["notmuch", "tag", "+inbox", "--",
+ 'path:"acct-one/Sent/**"'], env=env, check=True)
+ subprocess.run([str(HOOK)], env=env, capture_output=True, check=True)
+ assert count(env, 'tag:inbox and path:"acct-one/Sent/**"') == 1
+
+
+def test_no_qtmaildir_config_leaves_every_tag_alone():
+ """The hook must run on a system with no qtmaildir config: it then
+ protects nothing rather than failing the sync, and above all does not
+ treat an empty folder list as "every path", which is what an empty
+ notmuch query means.
+ """
+ with tempfile.TemporaryDirectory() as tmp:
+ env = setup_accounts(tmp, sent_config=False)
+ write_rules(env, [])
+ result = subprocess.run([str(HOOK)], env=env, capture_output=True,
+ text=True)
+ assert result.returncode == 0, result.stderr
+ assert count(env, "tag:inbox") == 4
+
+
+def run_all():
+ for name, fn in sorted(globals().items()):
+ if name.startswith("test_") and callable(fn):
+ fn()
+ print(f"ok {name}")
+
+
+if __name__ == "__main__":
+ run_all()
+ print("\nall passed")
diff --git a/assets/hooks/test_qtmaildirconf.py b/assets/hooks/test_qtmaildirconf.py
new file mode 100755
index 0000000..c8aa78d
--- /dev/null
+++ b/assets/hooks/test_qtmaildirconf.py
@@ -0,0 +1,179 @@
+#!/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.
+"""Unit checks for the qtmaildir.conf reader the post-new hook uses to find
+the sent folders.
+
+The file is written by QSettings, not by configparser, so the cases that
+matter are the ones where the two disagree: a section name carrying a dot, a
+comment introduced by `;`, and a key present but empty.
+
+Run: ./test_qtmaildirconf.py
+"""
+
+import tempfile
+from pathlib import Path
+
+import qtmaildirconf
+
+
+def write_config(tmp, text):
+ path = Path(tmp) / "qtmaildir" / "qtmaildir.conf"
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(text)
+ return path
+
+
+def test_sent_folders_are_read_per_account():
+ with tempfile.TemporaryDirectory() as tmp:
+ path = write_config(tmp, "[account.work]\n"
+ "maildir = work\n"
+ "sent = Sent\n"
+ "trash = Trash\n")
+ assert qtmaildirconf.sent_folders(path) == ["work/Sent"]
+
+
+def test_drafts_are_excluded_alongside_sent():
+ """A draft never arrived either, so it must not carry `inbox`. Both keys
+ feed one list: the hook asks a single question, "is this a folder mail
+ arrives in", and sent and drafts answer it the same way.
+
+ Trash is deliberately NOT here. qtmaildir's own Delete leaves `inbox` on
+ a trashed message so Restore can put it back where it came from, and
+ stripping it here would fight that.
+ """
+ with tempfile.TemporaryDirectory() as tmp:
+ path = write_config(tmp, "[account.work]\n"
+ "maildir = work\n"
+ "sent = Sent\n"
+ "drafts = Drafts\n"
+ "trash = Trash\n")
+ assert qtmaildirconf.sent_folders(path) == ["work/Sent", "work/Drafts"]
+
+
+def test_an_account_with_only_drafts_still_contributes():
+ with tempfile.TemporaryDirectory() as tmp:
+ path = write_config(tmp, "[account.a]\nmaildir = a\ndrafts = Drafts\n")
+ assert qtmaildirconf.sent_folders(path) == ["a/Drafts"]
+
+
+def test_an_account_section_may_carry_a_dot():
+ """QSettings writes `[account.a.b]` for the key `a.b`, and the account
+ key is everything after the first dot. Splitting on the LAST dot names
+ an account that does not exist and finds no folder."""
+ with tempfile.TemporaryDirectory() as tmp:
+ path = write_config(tmp, "[account.provider.name]\n"
+ "maildir = provider-name\n"
+ "sent = Sent\n")
+ assert qtmaildirconf.sent_folders(path) == ["provider-name/Sent"]
+
+
+def test_a_folder_may_contain_spaces_and_brackets():
+ """`[Gmail]/Posta inviata` is a real folder name here. The brackets are
+ the provider's, not INI syntax, because they are in a VALUE."""
+ with tempfile.TemporaryDirectory() as tmp:
+ path = write_config(tmp, "[account.g]\n"
+ "maildir = gmail\n"
+ "sent = [Gmail]/Posta inviata\n")
+ assert qtmaildirconf.sent_folders(path) == [
+ "gmail/[Gmail]/Posta inviata"]
+
+
+def test_an_account_without_a_sent_key_contributes_nothing():
+ """`sent` is optional: an account may keep no sent mail locally. It must
+ not contribute an entry, since a bare `maildir/` prefix would match the
+ whole account."""
+ with tempfile.TemporaryDirectory() as tmp:
+ path = write_config(tmp, "[account.a]\nmaildir = a\ntrash = Trash\n"
+ "[account.b]\nmaildir = b\nsent = Sent\n")
+ assert qtmaildirconf.sent_folders(path) == ["b/Sent"]
+
+
+def test_an_empty_sent_value_contributes_nothing():
+ with tempfile.TemporaryDirectory() as tmp:
+ path = write_config(tmp, "[account.a]\nmaildir = a\nsent =\n")
+ assert qtmaildirconf.sent_folders(path) == []
+
+
+def test_an_account_without_a_maildir_contributes_nothing():
+ """Without the account's own subdirectory the folder cannot be located,
+ and a bare `Sent` would match every account's sent folder at once."""
+ with tempfile.TemporaryDirectory() as tmp:
+ path = write_config(tmp, "[account.a]\nsent = Sent\n")
+ assert qtmaildirconf.sent_folders(path) == []
+
+
+def test_comments_and_other_sections_are_ignored():
+ with tempfile.TemporaryDirectory() as tmp:
+ path = write_config(tmp, "; a comment\n"
+ "[general]\n"
+ "language = it\n"
+ "[sync]\n"
+ "command = /bin/true\n"
+ "[account.a]\n"
+ "; another comment\n"
+ "maildir = a\n"
+ "sent = Sent\n")
+ assert qtmaildirconf.sent_folders(path) == ["a/Sent"]
+
+
+def test_a_missing_file_yields_no_folders():
+ """The hook must run on a system with no qtmaildir config at all: it
+ then protects nothing, rather than failing the sync."""
+ with tempfile.TemporaryDirectory() as tmp:
+ assert qtmaildirconf.sent_folders(Path(tmp) / "absent.conf") == []
+
+
+def test_an_unreadable_file_yields_no_folders():
+ """A malformed config must not fail the sync. notmuch new has already
+ run at this point; refusing to tag is worse than not protecting sent
+ mail for one cycle."""
+ with tempfile.TemporaryDirectory() as tmp:
+ path = write_config(tmp, "this is not an ini file\n[[[\n")
+ assert qtmaildirconf.sent_folders(path) == []
+
+
+def test_the_query_scopes_every_folder():
+ folders = ["a/Sent", "g/[Gmail]/Posta inviata"]
+ query = qtmaildirconf.sent_query(folders)
+ assert query == ('path:"a/Sent/**" or path:"g/[Gmail]/Posta inviata/**"')
+
+
+def test_the_query_is_empty_when_no_folder_is_configured():
+ """An empty query means "match everything" to notmuch, so the caller
+ must be able to tell "nothing to protect" from "protect the world"."""
+ assert qtmaildirconf.sent_query([]) == ""
+
+
+def test_a_folder_containing_a_quote_cannot_break_out_of_the_query():
+ """The folder name reaches a notmuch query as a quoted string. A stray
+ double quote would end the term and let the rest be read as syntax."""
+ query = qtmaildirconf.sent_query(['a/He said "hi"'])
+ assert query.count('"') % 2 == 0
+ assert "\\\"" in query or '""' in query
+
+
+def main():
+ tests = [value for name, value in sorted(globals().items())
+ if name.startswith("test_") and callable(value)]
+ for test in tests:
+ test()
+ print(f"ok {test.__name__}")
+ print(f"\n{len(tests)} passed")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index fc19b01..1af49bb 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -82,3 +82,23 @@ add_qtmaildir_test(translations)
# only as English in a running Italian UI.
target_compile_definitions(test_translations PRIVATE
TRANSLATIONS_DIR="${CMAKE_SOURCE_DIR}/translations")
+
+# The notmuch hooks (assets/hooks/), which are Python rather than C++ and are
+# therefore registered directly rather than through add_qtmaildir_test().
+#
+# They run against the user's REAL mail on every sync, so they belong in the
+# suite rather than beside it as scripts someone remembers to run. Two of the
+# three need `notmuch` on PATH and build a throwaway database in a temp
+# directory; none of them touches the real one.
+#
+# No QT_QPA_PLATFORM here: nothing Qt is involved.
+find_package(Python3 COMPONENTS Interpreter)
+if(Python3_Interpreter_FOUND)
+ foreach(hook_test mailrules post_new qtmaildirconf)
+ add_test(NAME hooks_${hook_test}
+ COMMAND ${Python3_EXECUTABLE}
+ ${CMAKE_SOURCE_DIR}/assets/hooks/test_${hook_test}.py)
+ endforeach()
+else()
+ message(STATUS "Python3 not found: the notmuch hook tests will not run")
+endif()