feat(inbox): add draft replies with REST and MCP surfaces - #158
Conversation
Replies to inbox messages could only be composed in the web view and were sent to the platform immediately, with no way to draft one for review or to create one programmatically. Give InboxReply a draft -> sent/failed lifecycle (migration 0002 backfills existing rows to "sent") and add apps/inbox/services.py as the single source of truth the web views, a new /api/v1/inbox REST router, and six new MCP tools all delegate to. Drafting is gated on use_inbox; delivering a reply is gated on reply_from_inbox. Message/reply lookups are scoped to the API key's workspace and account allowlist. The web composer now shows pending drafts with Send / Discard controls. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8749d05ec1
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if reply.status not in _SENDABLE_STATUSES: | ||
| raise ReplyStateError(f"A {reply.get_status_display().lower()} reply cannot be sent again.") |
There was a problem hiding this comment.
Serialize a draft before dispatching it
When two requests send the same draft concurrently (for example, a double-click or overlapping REST/MCP retries), both loaded instances can pass this status check before either saves sent, so both call the platform and the customer receives the reply twice. Atomically claim or lock and re-read the reply before dispatch so only one caller can transition a draft/failed row into the sending path.
Useful? React with 👍 / 👎.
| if payload.send: | ||
| try: | ||
| send_reply_now(reply, actor=request.user if not request.user.is_anonymous else None) |
There was a problem hiding this comment.
Honor idempotency when creating and sending replies
This new write path performs creation and optional platform delivery without claiming the repository's idempotency slot, and CreateReplyRequest does not accept idempotency_key while an Idempotency-Key header is ignored. If a client retries after losing the response—despite the documented safe-retry contract—a new reply row is created and send: true delivers the same text to the customer again; integrate the existing claim/replay/finalize flow before these mutations.
Useful? React with 👍 / 👎.
| reply_id = args.get("reply_id") | ||
| if reply_id: | ||
| reply = _get_inbox_reply_for_key(api_key, reply_id) |
There was a problem hiding this comment.
Reject mixed send-reply argument modes
When an MCP caller supplies both reply_id and message_id/body, the published schema accepts the request but this branch silently prioritizes reply_id, causing the previously saved draft body—not the supplied body—to be sent externally. Since the tool contract says these are alternative modes, enforce a oneOf/exclusive validation or explicitly reject mixed inputs before delivery.
Useful? React with 👍 / 👎.
| except Exception as exc: # platform refused it — reply is left in "failed" | ||
| raise HttpError(502, f"Reply not sent: {exc}") from exc |
There was a problem hiding this comment.
Return the sanitized platform failure reason
When a provider rejects a send, this response exposes str(exc) directly to authenticated API clients even though _reply_failure_reason explicitly treats provider exception text as internal diagnostics that may contain raw API JSON or trace IDs. The second REST send path and MCP handler repeat the same leak; return the stable sanitized reason already stored in reply.send_error while retaining the original exception only in logs.
Useful? React with 👍 / 👎.
What does this PR do?
Adds a draft-reply lifecycle to the Unified Social Inbox, exposed over three surfaces that all delegate to one new service layer:
InboxReplygainsstatus(draft/sent/failed),send_error, nullablesent_at, andcreated_at/updated_at. Migration0002backfills every existing row tosent(previously a row only existed once delivery succeeded).apps/inbox/services.py:create_reply_draft,update_reply_draft,discard_reply_draft,send_reply_now(draft → sent, or → failed on platform refusal, retryable), andsend_reply(create + send in one step). The platform-dispatch logic moved here verbatim fromviews.py.apps/api/routers/inbox.pyat/api/v1/inbox: list/read messages, and create / edit / send / discard replies.list_inbox_messages,get_inbox_message,create_reply_draft,update_reply_draft,discard_reply_draft,send_reply. Shares theInboxMessageResponse/InboxReplyResponseschemas with REST, enforced bytest_rest_parity.py.Permissions mirror the web inbox:
use_inboxto draft/edit/discard,reply_from_inboxto deliver. Message and reply lookups are scoped to the API key's workspace and its account allowlist, returning404(never403) for anything out of scope so a partial-scope key can't probe foreign IDs.Why?
Replies to inbox messages could only be composed in the web view and were sent to the platform immediately — there was no way to draft one for human review, and the inbox had zero REST/MCP surface. This lets an agent (or script) draft replies for a human to approve and send, mirroring the existing "agent drafts a post, human schedules it" flow.
How to test
Automated
Migration — on a DB with an existing (delivered)
InboxReply, runpython manage.py migrate inboxand confirm the row becomesstatus="sent"; reverse (migrate inbox 0001) and re-apply cleanly.MCP (dev server,
bb_studio_key allowlisted to an account with a seededInboxMessage):list_inbox_messages→create_reply_draft(message_id, body)get_inbox_message→ shows the draft in the threadsend_replywith thereply_id→ status flips tosent,platform_reply_idpopulateduse_inbox-only key callingsend_replyis refused withPermission denied: reply_from_inboxREST — same sequence via
curlagainst/api/v1/inbox/*; a message on a non-allowlisted account returns404.Web — open a message in the inbox, create a draft via MCP, reload the detail panel, see the pending draft with Send / Discard, click Send, confirm it moves into the sent thread. Confirm the existing "Send Reply" button still works.
Checklist
pytest) — 1493 passed, incl. ~53 new inbox/API/MCP/parity/migration testsruff check .andruff format --check .)