feat(scribe): mailbox takes since_message_id, answers newest_message_id (T-16)

The Office's one polling call, glance, answers "has anything changed
since I last looked?" with counts; live core-api ignored the
parameters it needed, so glance could not ship.

GET /scribe/mailbox now takes since_message_id=N: only entries whose
message number is above N (strictly — not >=), combined with
view/unread/tag the same way they combine with each other today.
message_id, which already bypasses those filters, keeps bypassing
since_message_id too.

The response gains newest_message_id: the caller's newest message
number across its whole mailbox, every view, regardless of any filter
the call applied; null when the mailbox is empty. It exists because
the unread-first sort can return an older unread entry on a limit=1
page while a newer message is already read — the exact shape glance
hits. Computed from a scoped MAX(message_id) query (tenant and
address), never from the page itself, so it cannot read stale or leak
across the tenant/address boundary (D-73).

total's meaning is unchanged: it already counted every filter match
independent of limit (the count query runs against the pre-limit
subquery), so since_message_id just becomes one more filter it counts
against.

Three mutations run and reverted to confirm the new tests actually
cover the behavior: message_id > vs >= broke the boundary test;
computing newest_message_id from the sorted page instead of the whole
mailbox broke the older-unread test; dropping the address filter from
the MAX query broke the address-boundary test.

583 -> 592 tests passing; test database left at head (010).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 00:55:31 +02:00
co-authored by Claude Sonnet 5
parent 0055bb8c1c
commit 3026b9214f
5 changed files with 289 additions and 9 deletions
+9 -2
View File
@@ -196,13 +196,18 @@ class ScribeController(BaseController):
message_id: Optional[int] = Query(
None,
description="look up the caller's own entry for this message number directly, "
"in any view, ignoring unread/tag/before_id (core-api T-9)",
"in any view, ignoring unread/tag/before_id/since_message_id (core-api T-9)",
),
since_message_id: Optional[int] = Query(
None,
description="only entries whose message number is above this one, combined "
"with view/unread/tag as usual (ignored when message_id is given) (core-api T-16)",
),
caller: Caller = Depends(require_scribe_reader),
session: AsyncSession = Depends(get_async_session),
):
try:
results, total = await service.get_mailbox(
results, total, newest_message_id = await service.get_mailbox(
session,
caller,
view=view,
@@ -211,6 +216,7 @@ class ScribeController(BaseController):
limit=limit,
before_id=before_id,
message_id=message_id,
since_message_id=since_message_id,
)
except ScribeError as error:
raise HTTPException(
@@ -220,6 +226,7 @@ class ScribeController(BaseController):
entries=[_entry_response(entry, message) for entry, message in results],
total=total,
limit=limit,
newest_message_id=newest_message_id,
)
@router.patch(
+8
View File
@@ -78,6 +78,14 @@ class MailboxListResponse(BaseSchema):
entries: List[MailboxEntryResponse]
total: int
limit: int
# Only `GET /scribe/mailbox` sets this (core-api T-16); `GET /scribe/messages`
# (find) shares this schema and leaves it at the default.
newest_message_id: Optional[int] = Field(
None,
description="the caller's newest message number across the whole mailbox "
"(every view), regardless of the filters this call applied; null when the "
"mailbox is empty",
)
class HistoryResponse(BaseSchema):
+39 -7
View File
@@ -103,6 +103,22 @@ class ScribeService:
messages = (await session.execute(stmt)).scalars().all()
return {message.id: message for message in messages}
async def _newest_message_id(
self, session: AsyncSession, tenant: str, identity: str
) -> Optional[int]:
"""The caller's newest message number across its whole mailbox —
every view, independent of whatever filters the current call
applies (core-api T-16) — scoped to tenant *and* address so it can
never answer with another identity's or tenant's number (D-73).
`func.max` over no rows is SQL NULL, which the driver returns as
`None`: the empty-mailbox case falls out of the query rather than
needing a separate branch."""
stmt = select(func.max(ScribeMailboxEntry.message_id)).where(
ScribeMailboxEntry.owner_tenant == tenant,
ScribeMailboxEntry.address == identity,
)
return (await session.execute(stmt)).scalar()
# =========================================================================
# register / list addresses
# =========================================================================
@@ -241,26 +257,40 @@ class ScribeService:
limit: int = 50,
before_id: Optional[int] = None,
message_id: Optional[int] = None,
) -> Tuple[List[Tuple[ScribeMailboxEntry, ScribeMessage]], int]:
since_message_id: Optional[int] = None,
) -> Tuple[List[Tuple[ScribeMailboxEntry, ScribeMessage]], int, Optional[int]]:
"""`mailbox` — the caller's identity's entries, unread first.
`message_id`, when given, is a direct lookup rather than a filtered
list: zero or one entry (the unique constraint on
(address, message_id) guarantees at most one), in any view — it
ignores `view`/`unread`/`tag`/`before_id` entirely, which is the
point (core-api T-9): a client that already knows the message
number never pages the unread-first/id-descending listing to find
it.
ignores `view`/`unread`/`tag`/`before_id`/`since_message_id`
entirely, which is the point (core-api T-9): a client that already
knows the message number never pages the unread-first/id-descending
listing to find it.
Otherwise `view` selects which of the independent `archived_at`/
`trashed_at` states to show: `inbox` (default) is neither,
`archive` is archived and not trashed, `trash` is trashed
(regardless of archived_at — trashing hides an entry from the
archive view too), `all` applies no filter on either column.
`since_message_id`, when given, keeps only entries whose message
number is above it (strictly — the boundary message itself is not
"since" itself), combined with `view`/`unread`/`tag` the same way
they combine with each other (core-api T-16).
Returns `(results, total, newest_message_id)`. `total` counts
whatever matched the filters above, independent of `limit` — true
before this round too, since the count query already runs against
the pre-`limit` subquery; `since_message_id` becomes one more
filter it counts against, nothing more. `newest_message_id` is
**not** scoped by any of those filters — see `_newest_message_id`.
"""
tenant = await self._require_tenant(session, caller)
await self._require_owned_identity(session, caller, tenant)
newest_message_id = await self._newest_message_id(session, tenant, caller.identity)
if message_id is not None:
lookup_query = select(ScribeMailboxEntry).where(
ScribeMailboxEntry.owner_tenant == tenant,
@@ -270,7 +300,7 @@ class ScribeService:
entries = list((await session.execute(lookup_query)).scalars().all())
messages_by_id = await self._messages_for_entries(session, entries)
results = [(entry, messages_by_id[entry.message_id]) for entry in entries]
return results, len(results)
return results, len(results), newest_message_id
conditions = [
ScribeMailboxEntry.owner_tenant == tenant,
@@ -292,6 +322,8 @@ class ScribeService:
conditions.append(ScribeMailboxEntry.tags.contains([tag]))
if before_id is not None:
conditions.append(ScribeMailboxEntry.id < before_id)
if since_message_id is not None:
conditions.append(ScribeMailboxEntry.message_id > since_message_id)
base_query = select(ScribeMailboxEntry).where(*conditions)
count_query = select(func.count()).select_from(base_query.subquery())
@@ -305,7 +337,7 @@ class ScribeService:
entries = list((await session.execute(query)).scalars().all())
messages_by_id = await self._messages_for_entries(session, entries)
return [(entry, messages_by_id[entry.message_id]) for entry in entries], total
return [(entry, messages_by_id[entry.message_id]) for entry in entries], total, newest_message_id
async def find_messages(
self, session: AsyncSession, caller: Caller, q: str, limit: int = 50