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>
265 lines
9.8 KiB
Python
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()
|