diff options
| author | Danilo M. <danix@danix.xyz> | 2026-08-23 21:15:13 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-08-23 21:15:13 +0200 |
| commit | fabcf080652c6e5d57bf234be5e100769a9b965b (patch) | |
| tree | 0de4222c1e2aab58c38d34c9e0e3c37c68298cc8 /assets/hooks/test_post_new.py | |
| parent | c50bea78e036518ce1a2a3eb899bbb5e305affea (diff) | |
| parent | ddcae8d02ef46db522b3cf6c228196c7a66a6432 (diff) | |
| download | qtmaildir-fabcf080652c6e5d57bf234be5e100769a9b965b.tar.gz qtmaildir-fabcf080652c6e5d57bf234be5e100769a9b965b.zip | |
Merge branch 'compose-and-send': composing and sending mail
Item 123, built over 2026-08-20 to 2026-08-23 in thirteen tasks against
docs/superpowers/specs/2026-08-20-compose-and-send-design.md.
The application writes mail now. A composer window per message, markdown as
the body, drafts autosaving into the account's Maildir, and sending through a
per-account command on stdin rather than any network protocol of this
program's own. A countdown with an Undo stands between pressing Send and the
command running.
Two things came in alongside it. The notmuch auto-tagging hooks moved here
from the retiring `mailctl` project and learned that mail this application
files itself never arrived, so sent mail and drafts stop appearing in the
inbox. And the v1/v2 language is retired: semver on the user-visible surface
is the rule, and those labels described a split that composing made obsolete.
Hand tested against a fake send command rather than a real one, deliberately:
New, Reply and Forward all produce correct messages, a forwarded attachment
survives intact, and the sent copy is filed. That testing found the two
defects fixed on this branch, and both were invisible to the suite: a composer
orphaned by quitting the main window, and every sent message tagged `inbox`.
Twenty-two defects were found in the plan document's own draft code while
building it, which is why CLAUDE.md says to treat every code block in a plan
as a draft.
Diffstat (limited to 'assets/hooks/test_post_new.py')
| -rwxr-xr-x | assets/hooks/test_post_new.py | 340 |
1 files changed, 340 insertions, 0 deletions
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") |
