Skip to content

feat(inbox): add draft replies with REST and MCP surfaces - #158

Open
heshanlk wants to merge 1 commit into
brightbeanxyz:mainfrom
heidisoft:feat/inbox-draft-replies
Open

feat(inbox): add draft replies with REST and MCP surfaces#158
heshanlk wants to merge 1 commit into
brightbeanxyz:mainfrom
heidisoft:feat/inbox-draft-replies

Conversation

@heshanlk

Copy link
Copy Markdown
Contributor

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:

  • ModelInboxReply gains status (draft / sent / failed), send_error, nullable sent_at, and created_at / updated_at. Migration 0002 backfills every existing row to sent (previously a row only existed once delivery succeeded).
  • Service layer — new apps/inbox/services.py: create_reply_draft, update_reply_draft, discard_reply_draft, send_reply_now (draft → sent, or → failed on platform refusal, retryable), and send_reply (create + send in one step). The platform-dispatch logic moved here verbatim from views.py.
  • REST — new apps/api/routers/inbox.py at /api/v1/inbox: list/read messages, and create / edit / send / discard replies.
  • MCP — six new tools: list_inbox_messages, get_inbox_message, create_reply_draft, update_reply_draft, discard_reply_draft, send_reply. Shares the InboxMessageResponse / InboxReplyResponse schemas with REST, enforced by test_rest_parity.py.
  • Web UI — the reply composer now lists pending drafts with Send / Discard controls and a Save as draft button; the existing send path is unchanged.

Permissions mirror the web inbox: use_inbox to draft/edit/discard, reply_from_inbox to deliver. Message and reply lookups are scoped to the API key's workspace and its account allowlist, returning 404 (never 403) 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

pytest apps/inbox apps/api/tests/test_inbox_router.py apps/mcp/tests
ruff check . && ruff format --check .
mypy apps/ config/ providers/ tests/ --ignore-missing-imports

Migration — on a DB with an existing (delivered) InboxReply, run python manage.py migrate inbox and confirm the row becomes status="sent"; reverse (migrate inbox 0001) and re-apply cleanly.

MCP (dev server, bb_studio_ key allowlisted to an account with a seeded InboxMessage):

  1. list_inbox_messagescreate_reply_draft (message_id, body)
  2. get_inbox_message → shows the draft in the thread
  3. send_reply with the reply_id → status flips to sent, platform_reply_id populated
  4. A use_inbox-only key calling send_reply is refused with Permission denied: reply_from_inbox

REST — same sequence via curl against /api/v1/inbox/*; a message on a non-allowlisted account returns 404.

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

  • Tests pass (pytest) — 1493 passed, incl. ~53 new inbox/API/MCP/parity/migration tests
  • Lint passes (ruff check . and ruff format --check .)
  • Documentation updated — README REST endpoint table, MCP tools table, and permission-key list

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>
@heshanlk
heshanlk requested a review from JanSchm as a code owner August 30, 2026 11:42

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread apps/inbox/services.py
Comment on lines +163 to +164
if reply.status not in _SENDABLE_STATUSES:
raise ReplyStateError(f"A {reply.get_status_display().lower()} reply cannot be sent again.")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment thread apps/api/routers/inbox.py
Comment on lines +166 to +168
if payload.send:
try:
send_reply_now(reply, actor=request.user if not request.user.is_anonymous else None)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment thread apps/mcp/handlers.py
Comment on lines +1534 to +1536
reply_id = args.get("reply_id")
if reply_id:
reply = _get_inbox_reply_for_key(api_key, reply_id)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment thread apps/api/routers/inbox.py
Comment on lines +173 to +174
except Exception as exc: # platform refused it — reply is left in "failed"
raise HttpError(502, f"Reply not sent: {exc}") from exc

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant