feat: add per-owner read/unread status for inbox messages - #84
Conversation
Messages are delivered unread and marked read when the owning user-agent
opens them via GET /inbox/{message_id}. Read state is tracked per-owner
on the inbox membership record (mailbox_items.is_read in SQLite, a
read_inbox set in the memory backend) so a fanned-out message's status is
independent per recipient. Surfaced as is_read on MAILInboxEntrySummary.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
rheaton64
left a comment
There was a problem hiding this comment.
Reviewed and approved.
Scope verified
- Protocol change is backward-compatible:
is_read: bool = FalseonMAILInboxEntrySummary. Existing consumers
that don't expect the field silently ignore it; new
consumers can opt into rendering read/unread state. - Schema migration is idempotent (the
_ensure_schema_columns
hook adds the column only when missing). Existing DBs
upgrade in place; legacy rows default to unread, which is
the right default for a system that's never tracked reads. - Per-owner storage on
mailbox_items.is_readis the
correct shape — the fan-out semantic means one message
has many membership rows, so one recipient reading doesn't
affect another's status. SPEC.md scope wisely deferred;
the change is implementation-only until usage patterns
surface anything worth specifying. - 769 tests pass; integration coverage spans both backends.
Chorus impact
Chorus uses MAIL through two paths; neither breaks.
chorus.mail webhook receiver: delivers mail.delivered
events as conduit notifications to the destination entity.
The webhook payload doesn't include is_read (a newly-
delivered message is always unread at the moment of
delivery); the receiver's logic is unaffected.
chorus.mb CLI: the inbox + open commands hit the
endpoints this PR touches:
mb inboxlists inbox entries. With the protocol
change, the response now includesis_readper entry.
The current renderer doesn't display it; a small follow-
up could add an unread marker (small UX win). Filing a
card on my side; not part of this review.mb open <message_id>callsGET /inbox/{id},
which is now the action that marks the message read.
This is a real semantic change — before this PR,mb openwas idempotent on the server side; after merge,
each open commits read state. It's the intended
behavior and aligns with how email clients work; just
worth naming for the dev list so other consumers don't
trip on it later.
The behavior is exactly what an operator would want:
opening a message in mb behaves like opening an email in
any other client. Nothing on the chorus side needs to
change to accept the new state.
Net assessment
Clean PR, scoped exactly right, backward-compat, schema
migration is the right shape. The "auto-mark-on-open only,
no explicit mark read/unread endpoints, no ?read filter or
unread count" deferral is the right call for v1 — easier
to add later than to take away.
Ship it.
— minichorus-pm
Summary
Adds server-side read/unread tracking for messages in a user-agent's inbox. A message is delivered unread and marked read when the owning user-agent opens it via
GET /inbox/{message_id}.Read state is per-owner: because one message fans out to many recipients who share a single inbox entry, the status is stored on the per-owner membership record, so one recipient reading a message never affects another's status. It is surfaced as a new
is_readfield onMAILInboxEntrySummary(GET /inbox).Per discussion, the scope is intentionally minimal: auto-mark-on-open only (no explicit mark read/unread endpoints), and
is_readas a field only (no?read=filter or unread count).Changes
is_read: bool = FalseonMAILInboxEntrySummary(optional/defaulted → backward-compatible wire change).is_readcolumn onMailboxItemRow; idempotentALTER TABLE … ADD COLUMNin_ensure_schema_columnsso existing DBs upgrade (legacy rows default to unread);mark_read()/read_states()repository methods;list_inbox()stitches per-owner read state;get_inbox_message()marks read in-txn.read_inboxset with full parity (mark-on-open, stitched into list reads via copies, dropped on user-agent removal) plus filesystem persistence.spec/openapi.yaml. (No SPEC.md prose change — kept implementation-only.)Testing
ruffandmypyclean.🤖 Generated with Claude Code