feat(scribe): archive, trash and look up a mailbox entry by message number (T-13/T-9)
The user wants a way to get rid of a message that is no longer useful. A
message is stored once and shared by every recipient; `history` is how
agents rebuild context (D-66), so deleting the message itself would take
it from every other reader. Deletion is therefore per mailbox entry
(D-70): `archive` (kept, out of the default view) and `trash` (out of the
mailbox, restorable). True erasure of content stays `redact` — not this
round.
Migration 009 (down_revision="008") adds
scribe_mailbox_entries.archived_at and trashed_at — independent nullable
timestamps, mirroring book_records.archived_at/trashed_at (migration
005). No data step: every existing entry is neither.
PATCH /scribe/mailbox/{entry_id} takes `archived` and `trashed` booleans
beside `read` (true sets the timestamp if not already set, false clears
it); the response carries both. GET /scribe/mailbox takes `view`: `inbox`
(default, neither archived nor trashed), `archive` (archived and not
trashed), `trash` (trashed, regardless of archived_at — trashing hides an
entry from the archive view too), `all` (no filter on either column).
`find` now excludes trashed entries (archived ones still match).
T-9: GET /scribe/mailbox?message_id=N is a direct lookup, not a filtered
list — the unique constraint on (address, message_id) guarantees zero or
one, in any view, ignoring unread/tag/before_id entirely. That is the
point: a client that already knows the message number never pages the
unread-first/id-descending listing to find it.
Judgement call: the listing's own cursor (`before_id`, a single `id`
comparison) still does not match its two-key sort (unread-first, then id
descending) — the pre-existing gap the dossier flagged as optional to
fix. Left unchanged: T-9's message_id lookup is the actual answer to "a
client pages to find one," and reworking the general cursor is a larger,
separate concern (which field(s) a keyset cursor needs) that risks the
already-passing test_mailbox_lists_unread_first for no requirement this
round asked for.
Judgement call: archived_at and trashed_at are independent timestamps,
never cross-clearing each other. Trashing an already-archived entry does
not clear archived_at, so restoring it (trashed=false) returns it to the
archive view, not the inbox — the view filter's own trash-takes-priority
rule is what keeps it out of view=archive while trashed, not a write-time
side effect.
Tests: 12 new (6 archive/trash flow + 4 message_id lookup + 2 migration
round-trip). Full suite 554 -> 566 passed.
Mutation checks (Edit, reverted after each):
- view=inbox dropped its trashed_at filter ->
test_trash_hides_from_inbox_archive_and_find_shows_in_trash failed
(assert 1 == 0).
- message_id lookup dropped the caller-address filter ->
test_message_id_answers_nothing_for_a_message_not_in_their_mailbox
failed (assert 1 == 0).
- trash updated every mailbox entry for the message instead of the
caller's own row ->
test_trashing_one_recipients_entry_leaves_the_others_untouched failed
(assert 0 == 1).
All three reverted; full suite re-verified green (566 passed).
Migration 008 -> 009 -> 008 -> 009 round-tripped against core_api_test,
both manually (alembic CLI) and in
TestMigration009RoundTrip. Production stays at 008 — every alembic
invocation derived POSTGRES_* from TEST_DATABASE_URL.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
"""Archive and trash a message in your own mailbox (core-api T-13)
|
||||
|
||||
Revision ID: 009
|
||||
Revises: 008
|
||||
Create Date: 2026-10-19
|
||||
|
||||
Deletion is per mailbox entry, never per message (workspace D-66/D-70): a
|
||||
message is retained and shared by every recipient, so only the entry that
|
||||
represents one recipient's copy can be archived or trashed. `archived_at`
|
||||
and `trashed_at` are independent nullable timestamps on
|
||||
`scribe_mailbox_entries`, mirroring `book_records.archived_at`/
|
||||
`trashed_at` (migration 005) — the service layer's `view=trash` takes
|
||||
priority over `view=archive` when both are set, so the column pair alone
|
||||
does not encode that precedence.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "009"
|
||||
down_revision: Union[str, None] = "008"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Add the two nullable timestamp columns. No data step: every existing
|
||||
entry is neither archived nor trashed."""
|
||||
op.add_column(
|
||||
"scribe_mailbox_entries",
|
||||
sa.Column("archived_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"scribe_mailbox_entries",
|
||||
sa.Column("trashed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Drop both columns."""
|
||||
op.drop_column("scribe_mailbox_entries", "trashed_at")
|
||||
op.drop_column("scribe_mailbox_entries", "archived_at")
|
||||
Reference in New Issue
Block a user