"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
119 lines
3.9 KiB
Python
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
|