Clear the reading panel when its last message leaves

"Mark as Spam" moved the message but left it on screen. Two causes, both fixed
generally rather than for this one action.

`move_job` published message.changed without a thread_id, so the WS handler
invalidated the message list but never the thread the user was reading — the
panel had no reason to refetch. It now names the thread.

And a thread whose last visible message has gone should not linger. MailLayout
closes the panel when the open thread comes back empty, whoever emptied it —
core action or plugin.

`move_to_special` now also publishes the move immediately rather than waiting
for the IMAP job, since a plugin acting on the user's behalf has no client-side
handle to update optimistically the way the core endpoint's caller does.

That exposed an ordering bug: the optimistic event reaches the passive-learning
hook, which would re-teach the lesson /learn had just taught. /learn now records
`learned` before moving, so the hook's dedup stands down — and records it with
an upsert, because provider-filed spam has no verdict row of ours to update.

One real mistake on the way: the event first reported the destination folder as
the source, which would have made every move look like a no-op to anything
reading the pair.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
This commit is contained in:
2026-09-17 19:47:42 +02:00
co-authored by Claude Opus 5
parent c76b186b10
commit 8e3252679a
6 changed files with 186 additions and 15 deletions
@@ -186,3 +186,20 @@ async def test_ui_bundles_are_served_and_gated(ctx: _Ctx) -> None:
assert r.status_code == 200
assert "javascript" in r.headers["content-type"]
assert (await ctx.client_a.get(f"{BASE}/ui/../hook.py")).status_code == 404
async def test_learn_records_even_without_a_prior_verdict(ctx: _Ctx) -> None:
"""Mail the provider filed as spam has no verdict row of ours to update.
The write must therefore upsert — and it must happen before the move, or the
passive-learning hook re-teaches the lesson the move announces.
"""
message_id = uuid.uuid4()
r = await ctx.client_a.post(
f"{BASE}/learn", json={"message_id": str(message_id), "verdict": "spam"}
)
assert r.status_code == 200
assert r.json()["moved"] is False # not this user's message: nothing to move
body = (await ctx.client_a.get(f"{BASE}/context/{message_id}")).json()
assert body["verdict"]["learned"] == "spam"