Files
core-api/src/domains/scribe/controller.py
T
jpmschweitzerandClaude Sonnet 5 7c67a4a46b 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>
2026-10-19 22:54:35 +02:00

265 lines
9.8 KiB
Python

"""
Scribe Controller
The Scribe's messaging verbs (dossier core-api T-5): register, list
(addresses), send, mailbox, history, find, mark, tag/untag, archive/trash
a mailbox entry and look one up by message number (core-api T-13/T-9).
Every route is behind an Office or user caller (T-1/T-4); every query
filters on the caller's tenant (D-73) — an address or message in another
tenant answers exactly like one that does not exist.
"""
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from src.domains.auth.callers import Caller, require_caller
from src.domains.scribe.schemas import (
AddressListResponse,
AddressRegisterRequest,
AddressResponse,
HistoryResponse,
MailboxEntryResponse,
MailboxListResponse,
MailboxPatchRequest,
MailboxView,
MessageResponse,
MessageSendRequest,
)
from src.domains.scribe.service import ScribeError, get_scribe_service
from src.shared.base import BaseController
from src.shared.database import get_async_session
from src.shared.logging import get_logger
from sqlalchemy.ext.asyncio import AsyncSession
logger = get_logger(__name__)
require_scribe_reader = require_caller("office", "user")
require_scribe_writer = require_caller("office", "user", identity_required=True)
def _address_response(address, owned: bool) -> AddressResponse:
return AddressResponse(
name=address.name,
owner_consumer=address.owner_consumer,
created_by=address.created_by,
created_at=address.created_at,
owned=owned,
)
def _message_response(message) -> MessageResponse:
return MessageResponse.model_validate(message, from_attributes=True)
def _entry_response(entry, message) -> MailboxEntryResponse:
return MailboxEntryResponse(
id=entry.id,
address=entry.address,
read_at=entry.read_at,
archived_at=entry.archived_at,
trashed_at=entry.trashed_at,
tags=entry.tags,
created_at=entry.created_at,
message=_message_response(message),
)
class ScribeController(BaseController):
"""Controller for the Scribe domain."""
def __init__(self):
super().__init__(prefix="/scribe", tags=["Scribe"])
def create_router(self) -> APIRouter:
router = APIRouter(prefix=self.prefix, tags=self.tags)
service = get_scribe_service()
# =====================================================================
# register / list addresses
# =====================================================================
@router.post(
"/addresses",
response_model=AddressResponse,
summary="Register an address (idempotent for the same consumer)",
)
async def register_address(
data: AddressRegisterRequest,
caller: Caller = Depends(require_scribe_writer),
session: AsyncSession = Depends(get_async_session),
):
try:
address, owned = await service.register_address(session, data.name, caller)
except ScribeError as error:
raise HTTPException(
status_code=error.status_code, detail={"code": error.code, **error.extra}
) from error
return _address_response(address, owned)
@router.get(
"/addresses",
response_model=AddressListResponse,
summary="List addresses in the caller's tenant",
)
async def list_addresses(
caller: Caller = Depends(require_scribe_reader),
session: AsyncSession = Depends(get_async_session),
):
try:
results = await service.list_addresses(session, caller)
except ScribeError as error:
raise HTTPException(
status_code=error.status_code, detail={"code": error.code, **error.extra}
) from error
return AddressListResponse(
addresses=[_address_response(address, owned) for address, owned in results]
)
# =====================================================================
# send
# =====================================================================
@router.post(
"/messages",
response_model=MessageResponse,
status_code=201,
summary="Send a message to one or more addresses",
)
async def send_message(
data: MessageSendRequest,
caller: Caller = Depends(require_scribe_writer),
session: AsyncSession = Depends(get_async_session),
):
try:
message = await service.send_message(session, data, caller)
except ScribeError as error:
raise HTTPException(
status_code=error.status_code, detail={"code": error.code, **error.extra}
) from error
return _message_response(message)
# =====================================================================
# find
# =====================================================================
@router.get(
"/messages",
response_model=MailboxListResponse,
summary="Full text search over messages in the caller identity's mailbox",
)
async def find_messages(
q: str = Query(..., min_length=1),
limit: int = Query(50, ge=1, le=200),
caller: Caller = Depends(require_scribe_reader),
session: AsyncSession = Depends(get_async_session),
):
try:
results, total = await service.find_messages(session, caller, q, limit)
except ScribeError as error:
raise HTTPException(
status_code=error.status_code, detail={"code": error.code, **error.extra}
) from error
return MailboxListResponse(
entries=[_entry_response(entry, message) for entry, message in results],
total=total,
limit=limit,
)
# =====================================================================
# mailbox
# =====================================================================
@router.get(
"/mailbox",
response_model=MailboxListResponse,
summary="The caller identity's mailbox, unread first",
)
async def get_mailbox(
view: MailboxView = Query(
"inbox", description="inbox (default, neither archived nor trashed), archive, trash, all"
),
unread: bool = Query(False),
tag: Optional[str] = Query(None),
limit: int = Query(50, ge=1, le=200),
before_id: Optional[int] = Query(None),
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)",
),
caller: Caller = Depends(require_scribe_reader),
session: AsyncSession = Depends(get_async_session),
):
try:
results, total = await service.get_mailbox(
session,
caller,
view=view,
unread=unread,
tag=tag,
limit=limit,
before_id=before_id,
message_id=message_id,
)
except ScribeError as error:
raise HTTPException(
status_code=error.status_code, detail={"code": error.code, **error.extra}
) from error
return MailboxListResponse(
entries=[_entry_response(entry, message) for entry, message in results],
total=total,
limit=limit,
)
@router.patch(
"/mailbox/{entry_id}",
response_model=MailboxEntryResponse,
summary="Mark read/unread, or tag/untag a mailbox entry",
)
async def patch_mailbox_entry(
entry_id: int,
data: MailboxPatchRequest,
caller: Caller = Depends(require_scribe_writer),
session: AsyncSession = Depends(get_async_session),
):
try:
entry, message = await service.patch_mailbox_entry(session, entry_id, data, caller)
except ScribeError as error:
raise HTTPException(
status_code=error.status_code, detail={"code": error.code, **error.extra}
) from error
return _entry_response(entry, message)
# =====================================================================
# history
# =====================================================================
@router.get(
"/addresses/{name}/history",
response_model=HistoryResponse,
summary="The retained messages sent to an address, oldest first",
)
async def address_history(
name: str,
since_id: Optional[int] = Query(None),
limit: int = Query(100, ge=1, le=500),
caller: Caller = Depends(require_scribe_reader),
session: AsyncSession = Depends(get_async_session),
):
try:
messages = await service.get_history(session, name, caller, since_id, limit)
except ScribeError as error:
raise HTTPException(
status_code=error.status_code, detail={"code": error.code, **error.extra}
) from error
return HistoryResponse(
address=name,
messages=[_message_response(message) for message in messages],
)
return router
# Create controller instance
scribe_controller = ScribeController()