Files
lukasandClaude Opus 5 8e3252679a 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
2026-09-17 19:47:42 +02:00

119 lines
3.9 KiB
Python

"""`move_to_special` must describe the move it just made.
The reading panel is keyed by thread and the passive-learning hook reads the
folder pair, so an event that names neither leaves a moved message on screen and
teaches nothing.
"""
from __future__ import annotations
import datetime as dt
import uuid
from collections.abc import AsyncIterator
from typing import Any
import pytest_asyncio
from sqlalchemy import delete
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.config import get_settings
from app.identity.passwords import hash_password
from app.models import Account, Mailbox, Message, Thread, User
from app.plugins.actions import PluginActions
class _Ctx:
user_id: uuid.UUID
message_id: uuid.UUID
thread_id: uuid.UUID
@pytest_asyncio.fixture
async def ctx() -> AsyncIterator[_Ctx]:
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
sm = async_sessionmaker(engine, expire_on_commit=False)
c = _Ctx()
async with sm() as s:
user = User(handle=f"mv{uuid.uuid4().hex[:8]}@test.local", password_hash=hash_password("p"))
s.add(user)
await s.flush()
account = Account(
user_id=user.id,
email="a@b.test",
imap_config={},
smtp_config={},
secret_ct=b"x",
discovery_src="manual",
)
s.add(account)
await s.flush()
inbox = Mailbox(account_id=account.id, user_id=user.id, path="INBOX", special_use="inbox")
junk = Mailbox(account_id=account.id, user_id=user.id, path="SPAM", special_use="junk")
thread = Thread(
user_id=user.id,
subject_norm="t",
last_message_at=dt.datetime.now(dt.UTC),
)
s.add_all([inbox, junk, thread])
await s.flush()
msg = Message(
user_id=user.id,
account_id=account.id,
mailbox_id=inbox.id,
thread_id=thread.id,
uid=1,
message_id_hdr=f"<{uuid.uuid4()}@t>",
subject="t",
from_addr=[{"email": "s@x.test"}],
to_addr=[],
flags=[],
internal_date=dt.datetime.now(dt.UTC),
blob_sha256="0" * 64,
snippet="t",
)
s.add(msg)
await s.commit()
c.user_id, c.message_id, c.thread_id = user.id, msg.id, thread.id
try:
yield c
finally:
async with sm() as s:
await s.execute(delete(User).where(User.id == c.user_id))
await s.commit()
await engine.dispose()
async def test_move_event_names_the_thread_and_both_folders(
ctx: _Ctx, monkeypatch: pytest_asyncio.plugin.pytest.MonkeyPatch
) -> None:
published: list[tuple[str, dict[str, Any]]] = []
enqueued: list[tuple[Any, ...]] = []
class _Bus:
async def publish(self, event: str, payload: dict[str, Any]) -> None:
published.append((event, payload))
async def _enqueue(*args: Any, **kw: Any) -> None:
enqueued.append(args)
monkeypatch.setattr("app.plugins.actions.get_event_bus", lambda: _Bus())
monkeypatch.setattr("app.queue.enqueue_job", _enqueue)
moved = await PluginActions().move_to_special(ctx.user_id, ctx.message_id, "junk")
assert moved is True
assert enqueued, "the IMAP move must still be queued"
event, payload = published[0]
assert event == "message.changed"
# The panel is keyed by thread: without this it never refetches.
assert payload["thread_id"] == str(ctx.thread_id)
# The pair is the transition passive learning reads.
assert payload["previous_special_use"] == "inbox"
assert payload["special_use"] == "junk"
async def test_no_event_when_there_is_nowhere_to_move(ctx: _Ctx) -> None:
"""An account with no such folder: no move, and nothing announced."""
assert await PluginActions().move_to_special(ctx.user_id, ctx.message_id, "archive") is False