summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-14 18:49:48 +0200
committerDanilo M. <danix@danix.xyz>2026-09-14 18:49:48 +0200
commitc2753771096e7c303da4b351986f7f313917dcf8 (patch)
tree2f96b6c85c4bd5bb74382a2cc3492eeba173e039
parentc1c196ebeef9a17defe8a3070e8f10d548ffdf40 (diff)
downloadqtmaildir-c2753771096e7c303da4b351986f7f313917dcf8.tar.gz
qtmaildir-c2753771096e7c303da4b351986f7f313917dcf8.zip
fix(hooks): spam is not an inbox arrival
notmuch's new.tags applies inbox to every newly indexed file and the Inbox filter is tag:inbox, so spam-folder mail appeared in the Inbox view. Add spam to the post-new hook's NOT_ARRIVALS set so the existing all-files carve-out covers it, and rename the sent_* helpers to not_arrival_* now that the list means more than sent mail. Trash stays out: measured 0 affected files and it is out of scope.
-rwxr-xr-xassets/hooks/post-new68
-rwxr-xr-xassets/hooks/qtmaildirconf.py35
-rwxr-xr-xassets/hooks/test_post_new.py93
-rwxr-xr-xassets/hooks/test_qtmaildirconf.py70
-rw-r--r--docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md39
5 files changed, 232 insertions, 73 deletions
diff --git a/assets/hooks/post-new b/assets/hooks/post-new
index fca31a1..cad566c 100755
--- a/assets/hooks/post-new
+++ b/assets/hooks/post-new
@@ -78,19 +78,19 @@ def _quote(value):
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
-def sent_only(query, folders):
+def not_arrival_only(query, folders):
"""The ids matching `query` whose files are ALL inside `folders`.
None on failure, so the caller leaves `tag:new` in place rather than
stripping on a half-read answer.
- **Every file has to be in a sent folder, not merely one of them.** notmuch
- deduplicates by Message-ID, so mail the user sends to another of their own
- accounts is ONE message with two files: the sender's Sent copy and the
- recipient's Inbox copy. The old query matched on a path and the tag applied
- to the message, so matching the Sent copy stripped `inbox` from the copy
- that had genuinely arrived, and the mail was missing from the account that
- received it (item 166).
+ **Every file has to be in a non-arrival folder, not merely one of them.**
+ notmuch deduplicates by Message-ID, so mail the user sends to another of
+ their own accounts is ONE message with two files: the sender's Sent copy
+ and the recipient's Inbox copy. The old query matched on a path and the tag
+ applied to the message, so matching the Sent copy stripped `inbox` from the
+ copy that had genuinely arrived, and the mail was missing from the account
+ that received it (item 166).
This cannot be expressed as a query, which is why it is a loop. Measured
against a two-file message: `not path:"Inbox/**"` does NOT exclude it, and
@@ -101,7 +101,7 @@ def sent_only(query, folders):
"""
root = mail_root()
if root is None:
- log("no mail root; leaving sent mail alone")
+ log("no mail root; leaving non-arrival mail alone")
return None
prefixes = [root / folder for folder in folders]
@@ -110,10 +110,11 @@ def sent_only(query, folders):
if matched is None:
return None
- # ponytail: one `notmuch search` per matched message. N is the sent mail
- # in tag:new, so an ordinary sync is a handful and a first-run reindex is
- # the whole corpus. Batch by parsing --format=json once if that ever
- # matters; it does not at this size, and the loop is the readable form.
+ # ponytail: one `notmuch search` per matched message. N is the
+ # non-arrival mail in tag:new, so an ordinary sync is a handful and a
+ # first-run reindex is the whole corpus. Batch by parsing --format=json
+ # once if that ever matters; it does not at this size, and the loop is the
+ # readable form.
ids = []
for message_id in matched:
paths = search(f"id:{_quote(message_id)}", "files")
@@ -176,20 +177,21 @@ def search(query, output):
return values
-def strip_inbox_from_sent(run):
- """Take `inbox` off mail the user SENT, and nothing else.
+def strip_inbox_from_non_arrivals(run):
+ """Take `inbox` off mail that did not ARRIVE, 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.
+ tell an arrival from a copy this system filed itself: sent mail, a saved
+ draft, or mail a provider's filter dropped into a spam folder. The result
+ is such 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.
+ the provenance is the file's own path: a message whose files are ALL
+ inside a configured sent, drafts or spam folder never arrived, 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
@@ -198,17 +200,17 @@ def strip_inbox_from_sent(run):
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()
+ folders = qtmaildirconf.not_arrival_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.
+ # No config, or no account keeping non-arrival folders 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)})"
+ query = f"{SCOPE} and ({qtmaildirconf.not_arrival_query(folders)})"
- ids = sent_only(query, folders)
+ ids = not_arrival_only(query, folders)
if ids is None:
return False
@@ -220,7 +222,7 @@ def strip_inbox_from_sent(run):
# "applied" whether it stripped four messages or none, and item 164 is
# exactly the case where that distinction is the whole question: a draft
# that kept `inbox` on a pass whose log claimed the carve-out had run.
- log(f"sent-folder carve-out applied over {len(folders)} folder(s), "
+ log(f"non-arrival carve-out applied over {len(folders)} folder(s), "
f"{len(ids)} message(s)")
return True
@@ -296,11 +298,11 @@ def main():
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
+ # still see its own non-arrival 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")
+ if not strip_inbox_from_non_arrivals(run_tag):
+ log("non-arrival carve-out failed; leaving tag:new in place")
return 1
# Only after every rule succeeded. A failure part way through leaves the
diff --git a/assets/hooks/qtmaildirconf.py b/assets/hooks/qtmaildirconf.py
index e709a28..882c94a 100755
--- a/assets/hooks/qtmaildirconf.py
+++ b/assets/hooks/qtmaildirconf.py
@@ -16,9 +16,9 @@
# 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.
+Only the non-arrival folders are read, and only so the hook can tell mail
+this system filed itself (sent, drafts, spam) from mail that genuinely
+arrived. Everything else in that file belongs to the application.
Stdlib only: this is imported by a notmuch hook that runs on every sync.
@@ -52,7 +52,7 @@ def _accounts(path):
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.
+ not protecting non-arrival mail for one cycle, and the hook logs the miss.
"""
parser = configparser.ConfigParser(
# QSettings writes `;` comments, and `#` appears inside values (a
@@ -77,20 +77,27 @@ def _accounts(path):
# Folders mail does not ARRIVE in: this system put the message there itself.
+# notmuch's `new.tags` applies `inbox` to every file it indexes, so a file in
+# any of these folders would otherwise appear in the tag:inbox Inbox view.
#
-# 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):
+# Trash is deliberately absent. This is scope, not a claim it can never
+# happen: measured 0 trashed files carried `inbox`, and the reported defect is
+# spam (item 202). qtmaildir's own Delete no longer leaves `inbox` on a
+# trashed message either: it strips `unread` and `inbox` (item 168), and
+# Restore re-adds `inbox` from the `moved-from:` origin tag once the message
+# returns to its origin folder. A file moved into the trash folder by another
+# client could still pick up `inbox` from `new.tags`; that is unmeasured and
+# out of scope for now, so it is not in this list.
+NOT_ARRIVALS = ("sent", "drafts", "spam")
+
+
+def not_arrival_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.
+ mail, no drafts or no spam folder locally.
"""
if path is None:
path = default_path()
@@ -107,12 +114,12 @@ def sent_folders(path=None):
return folders
-def sent_query(folders):
+def not_arrival_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.
+ would treat the whole corpus as non-arrival mail.
`path:` is hierarchical, so `<folder>/**` covers `cur/` and `new/` and
any nesting a provider invents underneath.
diff --git a/assets/hooks/test_post_new.py b/assets/hooks/test_post_new.py
index 07728a6..074bf24 100755
--- a/assets/hooks/test_post_new.py
+++ b/assets/hooks/test_post_new.py
@@ -212,7 +212,7 @@ def test_a_protected_removal_is_skipped_whole_and_the_run_continues():
assert count(env, "tag:new") == 0
-def setup_accounts(tmp, sent_config=True, split_index=False):
+def setup_accounts(tmp, sent_config=True, split_index=False, with_spam=False):
"""A maildir laid out as qtmaildir configures it: two accounts, each with
an Inbox and a Sent folder, one message in each.
@@ -220,6 +220,9 @@ def setup_accounts(tmp, sent_config=True, split_index=False):
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.
+
+ `with_spam` adds each account's spam folder and its `spam` key, for the
+ test that a non-arrival folder added later is picked up the same way.
"""
root = Path(tmp) / "Mail"
folders = {
@@ -231,6 +234,10 @@ def setup_accounts(tmp, sent_config=True, split_index=False):
"acct-two/Inbox", "acct-two/[Provider]/Posta inviata"):
for part in ("new", "cur", "tmp"):
(root / sub / part).mkdir(parents=True)
+ if with_spam:
+ for sub in ("acct-one/Spam", "acct-two/[Provider]/Spam"):
+ 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")
@@ -269,8 +276,10 @@ def setup_accounts(tmp, sent_config=True, split_index=False):
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")
+ + ("spam = Spam\n" if with_spam else "")
+ + "[account.two]\nmaildir = acct-two\n"
+ "sent = [Provider]/Posta inviata\n"
+ + ("spam = [Provider]/Spam\n" if with_spam else ""))
subprocess.run(["notmuch", "new"], env=env, capture_output=True,
check=True)
@@ -346,6 +355,84 @@ def test_a_message_that_was_sent_AND_received_keeps_inbox():
assert count(env, "tag:inbox and id:sent-one@example.org") == 0
+def test_spam_mail_does_not_keep_the_inbox_tag():
+ """Spam is not an arrival either. A provider's filter files it into a spam
+ folder, notmuch's new.tags still applies `inbox`, and without the carve-out
+ it shows up in the Inbox view (item 202)."""
+ with tempfile.TemporaryDirectory() as tmp:
+ env = setup_accounts(tmp, with_spam=True)
+ root = Path(tmp) / "Mail"
+ make_message(root / "acct-one/Spam", "spam-one",
+ "spammer@example.net", "cheap stuff")
+ subprocess.run(["notmuch", "new"], env=env, capture_output=True,
+ check=True)
+
+ write_rules(env, [])
+ assert count(env, 'tag:inbox and path:"acct-one/Spam/**"') == 1
+
+ result = subprocess.run([str(HOOK)], env=env, capture_output=True,
+ text=True)
+ assert result.returncode == 0, result.stderr
+ assert count(env, 'tag:inbox and path:"acct-one/Spam/**"') == 0
+ # The arrival is untouched, which fails if the spam prefix is wrong.
+ assert count(env, 'tag:inbox and path:"acct-one/Inbox/**"') == 1
+
+
+def test_a_message_that_is_spam_AND_arrived_keeps_inbox():
+ """The all-files rule, for spam. notmuch deduplicates by Message-ID, so a
+ message with one file in a spam folder and one in an inbox DID arrive and
+ must keep `inbox` (item 202, same constraint as item 166 for sent)."""
+ with tempfile.TemporaryDirectory() as tmp:
+ env = setup_accounts(tmp, with_spam=True)
+ root = Path(tmp) / "Mail"
+ make_message(root / "acct-one/Spam", "spam-and-inbox",
+ "spammer@example.net", "came twice")
+ make_message(root / "acct-one/Inbox", "spam-and-inbox",
+ "spammer@example.net", "came twice")
+ subprocess.run(["notmuch", "new"], env=env, capture_output=True,
+ check=True)
+ assert count(env, "id:spam-and-inbox@example.org") == 1
+ assert files(env, "id:spam-and-inbox@example.org") == 2
+
+ 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 and id:spam-and-inbox@example.org") == 1
+
+
+def test_an_account_without_a_spam_key_contributes_nothing():
+ """`spam` is per account. One account names its spam folder; the other
+ does not. Mail in the unconfigured account's Spam folder is an arrival by
+ the only rule the hook has and keeps `inbox`, which is the empty-path
+ guard: a missing key must not fall back to the account maildir, matching
+ everything under it."""
+ with tempfile.TemporaryDirectory() as tmp:
+ env = setup_accounts(tmp, with_spam=True)
+ root = Path(tmp) / "Mail"
+ make_message(root / "acct-one/Spam", "one-spam",
+ "spammer@example.net", "spam for one")
+ make_message(root / "acct-two/[Provider]/Spam", "two-spam",
+ "spammer@example.net", "spam for two")
+ subprocess.run(["notmuch", "new"], env=env, capture_output=True,
+ check=True)
+
+ # Rewrite the config so only account one carries a spam key.
+ conf = Path(env["XDG_CONFIG_HOME"]) / "qtmaildir" / "qtmaildir.conf"
+ conf.write_text(
+ "[account.one]\nmaildir = acct-one\nsent = Sent\nspam = Spam\n"
+ "[account.two]\nmaildir = acct-two\n"
+ "sent = [Provider]/Posta inviata\n")
+
+ 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 and path:"acct-one/Spam/**"') == 0
+ assert count(
+ env, 'tag:inbox and path:"acct-two/[Provider]/Spam/**"') == 1
+
+
def test_the_carve_out_works_with_the_index_split_from_the_mail():
"""notmuch can hold the Xapian index outside the Maildir, and this user's
does. `database.path` is then the index directory, so a carve-out reading
diff --git a/assets/hooks/test_qtmaildirconf.py b/assets/hooks/test_qtmaildirconf.py
index c8aa78d..89565d9 100755
--- a/assets/hooks/test_qtmaildirconf.py
+++ b/assets/hooks/test_qtmaildirconf.py
@@ -15,7 +15,7 @@
# 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 non-arrival 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
@@ -37,37 +37,51 @@ def write_config(tmp, text):
return path
-def test_sent_folders_are_read_per_account():
+def test_not_arrival_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"]
+ assert qtmaildirconf.not_arrival_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.
+def test_drafts_and_spam_are_excluded_alongside_sent():
+ """Neither a draft nor spam ever arrived, so neither must carry `inbox`.
+ All three keys feed one list: the hook asks a single question, "is this a
+ folder mail arrives in", and sent, drafts and spam 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.
+ Trash is deliberately NOT here: the reported defect is spam and 0 trashed
+ files carried `inbox` (item 202), and qtmaildir's own Delete now strips
+ `unread` and `inbox` (item 168), so its own moves do not leave the tag
+ behind.
"""
with tempfile.TemporaryDirectory() as tmp:
path = write_config(tmp, "[account.work]\n"
"maildir = work\n"
"sent = Sent\n"
"drafts = Drafts\n"
+ "spam = Spam\n"
"trash = Trash\n")
- assert qtmaildirconf.sent_folders(path) == ["work/Sent", "work/Drafts"]
+ assert qtmaildirconf.not_arrival_folders(path) == [
+ "work/Sent", "work/Drafts", "work/Spam"]
+
+
+def test_spam_may_be_the_only_non_arrival_folder():
+ """An account need not keep sent mail or drafts locally, and Gmail's
+ spam folder is one the reader must still pick up on its own."""
+ with tempfile.TemporaryDirectory() as tmp:
+ path = write_config(tmp, "[account.g]\n"
+ "maildir = gmail\n"
+ "spam = [Gmail]/Spam\n")
+ assert qtmaildirconf.not_arrival_folders(path) == [
+ "gmail/[Gmail]/Spam"]
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"]
+ assert qtmaildirconf.not_arrival_folders(path) == ["a/Drafts"]
def test_an_account_section_may_carry_a_dot():
@@ -78,7 +92,7 @@ def test_an_account_section_may_carry_a_dot():
path = write_config(tmp, "[account.provider.name]\n"
"maildir = provider-name\n"
"sent = Sent\n")
- assert qtmaildirconf.sent_folders(path) == ["provider-name/Sent"]
+ assert qtmaildirconf.not_arrival_folders(path) == ["provider-name/Sent"]
def test_a_folder_may_contain_spaces_and_brackets():
@@ -88,7 +102,7 @@ def test_a_folder_may_contain_spaces_and_brackets():
path = write_config(tmp, "[account.g]\n"
"maildir = gmail\n"
"sent = [Gmail]/Posta inviata\n")
- assert qtmaildirconf.sent_folders(path) == [
+ assert qtmaildirconf.not_arrival_folders(path) == [
"gmail/[Gmail]/Posta inviata"]
@@ -99,13 +113,23 @@ def test_an_account_without_a_sent_key_contributes_nothing():
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"]
+ assert qtmaildirconf.not_arrival_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) == []
+ assert qtmaildirconf.not_arrival_folders(path) == []
+
+
+def test_an_account_without_a_spam_key_contributes_nothing():
+ """`spam` is optional like the others. A missing key must not append the
+ bare maildir, and an EMPTY value must not either: either would become an
+ empty-path prefix matching the whole account."""
+ with tempfile.TemporaryDirectory() as tmp:
+ path = write_config(tmp, "[account.a]\nmaildir = a\nsent = Sent\n"
+ "[account.b]\nmaildir = b\nspam =\n")
+ assert qtmaildirconf.not_arrival_folders(path) == ["a/Sent"]
def test_an_account_without_a_maildir_contributes_nothing():
@@ -113,7 +137,7 @@ def test_an_account_without_a_maildir_contributes_nothing():
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) == []
+ assert qtmaildirconf.not_arrival_folders(path) == []
def test_comments_and_other_sections_are_ignored():
@@ -127,14 +151,14 @@ def test_comments_and_other_sections_are_ignored():
"; another comment\n"
"maildir = a\n"
"sent = Sent\n")
- assert qtmaildirconf.sent_folders(path) == ["a/Sent"]
+ assert qtmaildirconf.not_arrival_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") == []
+ assert qtmaildirconf.not_arrival_folders(Path(tmp) / "absent.conf") == []
def test_an_unreadable_file_yields_no_folders():
@@ -143,25 +167,25 @@ def test_an_unreadable_file_yields_no_folders():
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) == []
+ assert qtmaildirconf.not_arrival_folders(path) == []
def test_the_query_scopes_every_folder():
folders = ["a/Sent", "g/[Gmail]/Posta inviata"]
- query = qtmaildirconf.sent_query(folders)
+ query = qtmaildirconf.not_arrival_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([]) == ""
+ assert qtmaildirconf.not_arrival_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"'])
+ query = qtmaildirconf.not_arrival_query(['a/He said "hi"'])
assert query.count('"') % 2 == 0
assert "\\\"" in query or '""' in query
diff --git a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
index 9d3979f..3e22dcd 100644
--- a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
+++ b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
@@ -275,6 +275,7 @@ taking that too literally.
| 200 | qtmaildir cannot be launched at a given account, thread or message | workflow | M | open, **specified 2026-09-13** in `specs/2026-09-13-cli-selectors-design.md`; read that rather than this row. The user settled three things: a second launch STEERS the running window over a `QLocalServer` rather than opening a second one, the selectors are `--account`/`--thread`/`--message` (`--query` dropped as the one with no caller), and a selector matching nothing opens the window normally and says so in the status bar. The design shrank on one side and grew on the other: `recoverStaleThread()` already runs `thread:<id>` with a deferred selection and is reused as a third caller, so the selectors are the small half, while the socket (connect-first ordering, stale-socket recovery, a degrade path when no socket is possible) is the real work and adds `Qt6::Network` to the component list. Original entry: open, 2026-09-13, from the notes ("the program should accept cli parameters like `--account` or `--thread`/`--message`, so that another app can launch qtmaildir opening that account's inbox or a certain message/thread"). Verified: `main.cpp:38-66` hand-rolls a `strcmp` loop over `argv` for `--version` and `--help` only, both answering before `QApplication` exists, which is deliberate and documented. Parsing is the small half and `QCommandLineParser` covers it; the item is bigger than it looks for two reasons. There is NO single-instance mechanism (no `QLocalServer` anywhere in `src/`), so a second launch opens a second window against the same notmuch database rather than steering the running one, and notmuch permits only one open handle per process. And the selector has to reach a query the startup path does not currently take, since `--thread` names a row that may not be in the configured startup view at all. Needs a decision from the user first: whether a second launch should focus the running window (which is the useful behaviour for "another app launches qtmaildir" and is the whole cost of the item) or simply start with a different query |
| 197 | No way to say a message is not spam | workflow | S | open, 2026-09-10, split out of the 187 design at the user's decision rather than built into it. Restore already covers what qtmaildir moved: a message it marked carries `moved-from:` and goes back where it came from. The gap is mail the PROVIDER's filter caught, which was never in an inbox and carries no origin tag, so "not spam" has no recorded destination to return it to. Needs two answers before it can be planned: where such a message goes (the account's inbox is the obvious guess and is a guess), and whether anything should tell the provider its filter was wrong, which is network work this application does not do and would belong in a sidecar like item 194's. No seam is needed in the meantime: `sendMove()` already takes any destination and any tags |
| 201 | A message in the Spam view cannot be un-spammed, even one qtmaildir put there | defect | S | open, 2026-09-14, found while testing the `spam-view` branch. Corrects item 197's claim that Restore already covers what qtmaildir moved: the CAPABILITY does (`restoreSelectedFromTrash()` reads the origin from the database), but no SURFACE offers it for spam, so only Ctrl+Z immediately after the move reverses it. The user asked for it to be built on `spam-view` before that branch merges. See the section |
+| 202 | Mail in a spam folder keeps `inbox`, so it appears in the Inbox view | defect | XS | open, 2026-09-14, found while testing the `spam-view` branch. notmuch's `new.tags` is `new;unread;inbox`, the Inbox filter is `tag:inbox`, and the hook's non-arrival carve-out (`NOT_ARRIVALS` in `assets/hooks/qtmaildirconf.py:84`) lists only sent and drafts, so spam-folder mail keeps `inbox` and shows in the Inbox view. Surfaced as "Not spam is offered in the Inbox view": the action's folder-based predicate is correct, the ROWS are the defect. Measured live: 49 files tagged `inbox` in a `[Gmail]/Spam` folder, 0 in trash/drafts/sent. Fix on `spam-view`. See the section |
Sizes are rough: XS under an hour, S a sitting, M a session.
@@ -1832,3 +1833,41 @@ whether to tell the provider its filter was wrong, stay out of scope here.
mark a message spam, then un-spam it through the new path, asserting the file
returns to the folder it came from, `spam` and `moved-from:` are gone, and the
row leaves the Spam view. Because it is a move, assert the undo as well.
+
+## 202. Mail in a spam folder keeps `inbox`, so it appears in the Inbox view
+
+**Observed (user, 2026-09-14, testing `spam-view`).** "Not spam is available in
+the inbox view; it should appear only when viewing the spam view." The action is
+not at fault: its predicate is folder-based (`everySelectedRowIsInAFolder()`,
+the same rule Restore uses), so it correctly appears on mail whose FILE is in a
+spam folder. Those rows should not have been in the Inbox view at all.
+
+**Cause.** Verified, and not in the application. notmuch's `new.tags` is
+`new;unread;inbox`, so every newly indexed FILE gets `inbox` regardless of the
+folder it sits in. The Inbox built-in filter is `tag:inbox`
+(`Config::generatorTag("inbox")`), not path-scoped, so any file carrying `inbox`
+appears there. The `post-new` hook already corrects this for mail that did not
+ARRIVE: `NOT_ARRIVALS` in `assets/hooks/qtmaildirconf.py:84` is
+`("sent", "drafts")`, and `strip_inbox_from_sent()` removes `inbox` from a
+message whose files are ALL inside one of those folders. `spam` is missing
+because the hook predates the per-account `spam` key this branch adds. Measured
+live: 49 files tagged `inbox` sitting in a `[Gmail]/Spam` folder; 0 in trash,
+drafts or sent.
+
+**Approach.** Add `spam` to `NOT_ARRIVALS` so the existing carve-out covers it
+too, correct the function/doc prose (its names say "sent" while the list means
+"not an arrival"), and extend the hook's tests with the spam cases. Then a
+ONE-TIME cleanup of the existing messages, using the same all-files rule.
+
+**Constraints.** The all-files rule is load-bearing (`sent_only()`): notmuch
+deduplicates by Message-ID, so a message with one file in spam and one in an
+inbox DID genuinely arrive and must keep `inbox`. Only `inbox` is removed;
+`unread` is untouched because `maildir.synchronize_flags` is true. The cleanup
+is a write to the live index and is confirmed with the user before running (it
+was, 2026-09-14). Trash is deliberately out of scope: measured 0 such files, and
+qtmaildir's own Delete strips `inbox` (item 168).
+
+**Verification.** `assets/hooks/test_post_new.py` (and the other hook suites)
+green with a spam-folder case and a two-file (spam + inbox) case; after the
+cleanup, `notmuch search --output=files 'tag:inbox' | grep -i '/spam/'` is
+empty.