Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -626,7 +626,7 @@ Issue an API key from **Organization → API Keys**. Keys are workspace-scoped,
Authorization: Bearer bb_studio_...
```

Permission keys: `create_posts`, `publish_directly`, `upload_media`, `view_analytics`. Each endpoint requires the relevant permission; missing permissions return `403`.
Permission keys: `create_posts`, `publish_directly`, `upload_media`, `view_analytics`, `use_inbox`, `reply_from_inbox`. Each endpoint requires the relevant permission; missing permissions return `403`.

### Rate Limits

Expand Down Expand Up @@ -654,6 +654,12 @@ Rate-limit responses (`429`) include `Retry-After`, `X-RateLimit-Limit`, and `X-
| `POST` | `/media` | Upload a media file (multipart) | `upload_media` |
| `GET` | `/media/{media_id}` | Retrieve a media asset | — |
| `GET` | `/media` | List media assets (filter, paginate) | — |
| `GET` | `/inbox` | List inbox messages (filter by status/type/account, paginate) | `use_inbox` |
| `GET` | `/inbox/{message_id}` | Read one inbox message with its reply thread | `use_inbox` |
| `POST` | `/inbox/{message_id}/replies` | Draft a reply (set `send: true` to deliver it now) | `use_inbox` (+ `reply_from_inbox` to send) |
| `PATCH` | `/inbox/replies/{reply_id}` | Edit a draft reply | `use_inbox` |
| `POST` | `/inbox/replies/{reply_id}/send` | Deliver a draft reply to the platform | `reply_from_inbox` |
| `DELETE` | `/inbox/replies/{reply_id}` | Discard a draft reply | `use_inbox` |
| `POST` | `/mcp` | JSON-RPC 2.0 endpoint for MCP clients | — |

All write endpoints accept `idempotency_key` (or `Idempotency-Key` header) for safe retries.
Expand All @@ -676,6 +682,12 @@ The MCP server lives at `POST {APP_URL}/api/v1/mcp` and speaks JSON-RPC 2.0 over
| `upload_media` | Upload a small base64-encoded file (≤ 1 MB raw). For larger files, use REST `POST /media`. | `upload_media` |
| `get_account_analytics` | Channel analytics over a rolling 7–90 day window | `view_analytics` |
| `get_post_analytics` | Per-platform metrics for a single post (safe for polling drafts) | `view_analytics` |
| `list_inbox_messages` | List inbox items (comments, mentions, DMs, reviews) with their reply threads | `use_inbox` |
| `get_inbox_message` | Retrieve one inbox message and its reply thread | `use_inbox` |
| `create_reply_draft` | Draft a reply to an inbox message (saved, not sent) | `use_inbox` |
| `update_reply_draft` | Replace the body of a draft (or failed) reply | `use_inbox` |
| `discard_reply_draft` | Delete a draft (or failed) reply | `use_inbox` |
| `send_reply` | Deliver a reply (`reply_id`, or `message_id` + `body` to draft-and-send) | `reply_from_inbox` |

### Connecting an MCP client

Expand Down
14 changes: 14 additions & 0 deletions apps/api/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from apps.api.auth import ApiKeyAuth, McpAuth
from apps.api.routers.accounts import router as accounts_router
from apps.api.routers.analytics import router as analytics_router
from apps.api.routers.inbox import router as inbox_router
from apps.api.routers.me import router as me_router
from apps.api.routers.media import router as media_router
from apps.api.routers.posts import router as posts_router
Expand Down Expand Up @@ -76,6 +77,7 @@ class NoncedSwagger(Swagger):
api.add_router("/posts", posts_router)
api.add_router("/media", media_router)
api.add_router("/analytics", analytics_router)
api.add_router("/inbox", inbox_router)
# MCP Streamable HTTP transport. Same audit + rate limits as REST, but a
# wider auth class: ``McpAuth`` accepts both bb_studio_ keys AND OAuth 2.1
# access tokens (Claude Desktop's native connector flow). Mounted last so
Expand Down Expand Up @@ -233,6 +235,18 @@ def _action_for_path(method: str, path: str, *, status_code: int) -> str:
return f"media.upload.{status_code}"
if method == "GET":
return f"media.read.{status_code}"
if "/inbox/" in path or path.endswith("/inbox"):
if "/replies" in path or "/reply" in path:
if path.endswith("/send"):
return f"inbox.reply.send.{status_code}"
if method == "POST":
return f"inbox.reply.create.{status_code}"
if method == "PATCH":
return f"inbox.reply.update.{status_code}"
if method == "DELETE":
return f"inbox.reply.discard.{status_code}"
if method == "GET":
return f"inbox.read.{status_code}"
if "/mcp" in path:
return f"mcp.error.{status_code}"
if "/accounts" in path:
Expand Down
222 changes: 222 additions & 0 deletions apps/api/routers/inbox.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
"""``/api/v1/inbox/*`` — read inbox messages and draft / send replies.

The inbox equivalent of :mod:`apps.api.routers.posts`: every route is a
thin adapter over :mod:`apps.inbox.services`, the single source of truth
shared with the HTMX views and the MCP inbox tools. Message and reply
lookups are scoped to the key's workspace **and** its account allowlist,
returning 404 (never 403) for anything outside it so a partial-scope key
can't probe foreign IDs.

Permissions mirror the web inbox: ``use_inbox`` to read and to manage
drafts, ``reply_from_inbox`` to actually deliver a reply to the platform.
"""

from __future__ import annotations

import uuid

from django.db.models import QuerySet
from django.http import Http404, HttpRequest
from django.shortcuts import get_object_or_404
from ninja import Query, Router
from ninja.errors import HttpError

from apps.api.limits import enforce_http_rate_limits
from apps.api.middleware import log_audit_entry
from apps.api.pagination import decode_offset_cursor, encode_offset_cursor
from apps.api.schemas import (
CreateReplyRequest,
InboxMessageResponse,
InboxMessagesListResponse,
InboxReplyResponse,
UpdateReplyRequest,
)
from apps.inbox.models import InboxMessage, InboxReply
from apps.inbox.services import (
ReplyStateError,
create_reply_draft,
discard_reply_draft,
send_reply_now,
update_reply_draft,
)

router = Router(tags=["inbox"])

_LIMIT_DEFAULT = 50
_LIMIT_MAX = 100


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _require_perm(request: HttpRequest, key: str) -> None:
membership = getattr(request, "workspace_membership", None)
if membership is None or not membership.effective_permissions.get(key, False):
raise HttpError(403, f"Permission denied: {key}")


def _allowlisted_account_ids(request: HttpRequest) -> set[uuid.UUID]:
return {sa.id for sa in request.api_key.social_accounts.all()} # type: ignore[attr-defined]


def _visible_messages_qs(request: HttpRequest) -> QuerySet[InboxMessage]:
"""Messages in the key's workspace whose account is in the allowlist."""
return InboxMessage.objects.filter(
workspace_id=request.api_key.workspace_id, # type: ignore[attr-defined]
social_account_id__in=_allowlisted_account_ids(request),
).select_related("social_account")


def _get_message(request: HttpRequest, message_id: uuid.UUID) -> InboxMessage:
return get_object_or_404(_visible_messages_qs(request), id=message_id)


def _get_reply(request: HttpRequest, reply_id: uuid.UUID) -> InboxReply:
reply = get_object_or_404(
InboxReply.objects.select_related("inbox_message", "inbox_message__social_account", "author"),
id=reply_id,
inbox_message__workspace_id=request.api_key.workspace_id, # type: ignore[attr-defined]
)
if reply.inbox_message.social_account_id not in _allowlisted_account_ids(request):
raise Http404()
return reply


# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------


@router.get("/", response=InboxMessagesListResponse, summary="List inbox messages")
def list_messages(
request,
status: str | None = Query(None),
message_type: str | None = Query(None),
social_account_id: uuid.UUID | None = Query(None),
limit: int = Query(_LIMIT_DEFAULT, ge=1, le=_LIMIT_MAX),
cursor: str | None = Query(None),
):
enforce_http_rate_limits(request, is_write=False)
_require_perm(request, "use_inbox")

if status is not None and status not in InboxMessage.Status.values:
raise HttpError(422, f"status must be one of {', '.join(InboxMessage.Status.values)}")
if message_type is not None and message_type not in InboxMessage.MessageType.values:
raise HttpError(422, f"message_type must be one of {', '.join(InboxMessage.MessageType.values)}")

try:
offset = decode_offset_cursor(cursor)
except ValueError as exc:
raise HttpError(422, "cursor is not a valid pagination cursor") from exc

qs = _visible_messages_qs(request).prefetch_related("replies__author")
if status:
qs = qs.filter(status=status)
if message_type:
qs = qs.filter(message_type=message_type)
if social_account_id is not None:
if social_account_id not in _allowlisted_account_ids(request):
raise HttpError(403, "social_account_id is not in this key's allowlist.")
qs = qs.filter(social_account_id=social_account_id)
qs = qs.order_by("-received_at", "id")

rows = list(qs[offset : offset + limit + 1])
has_more = len(rows) > limit
rows = rows[:limit]
log_audit_entry(request, action="inbox.list", target_id=None, status_code=200)
return InboxMessagesListResponse(
messages=[InboxMessageResponse.from_message(m, include_replies=True) for m in rows],
limit=limit,
next_cursor=encode_offset_cursor(offset + limit) if has_more else None,
)


@router.get("/{message_id}", response=InboxMessageResponse, summary="Read one inbox message")
def retrieve_message(request, message_id: uuid.UUID):
enforce_http_rate_limits(request, is_write=False)
_require_perm(request, "use_inbox")
message = _get_message(request, message_id)
log_audit_entry(request, action="inbox.read", target_id=message.id, status_code=200)
return InboxMessageResponse.from_message(message, include_replies=True)


@router.post(
"/{message_id}/replies",
response={201: InboxReplyResponse},
summary="Create a draft reply (optionally send it)",
)
def create_reply(request, message_id: uuid.UUID, payload: CreateReplyRequest):
enforce_http_rate_limits(request, is_write=True)
_require_perm(request, "use_inbox")
if payload.send:
_require_perm(request, "reply_from_inbox")

message = _get_message(request, message_id)
try:
reply = create_reply_draft(
message=message,
body=payload.body,
author=request.user if not request.user.is_anonymous else None,
)
except ValueError as exc:
raise HttpError(422, str(exc)) from exc

if payload.send:
try:
send_reply_now(reply, actor=request.user if not request.user.is_anonymous else None)
Comment on lines +166 to +168

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

except NotImplementedError:
pass # provider has no reply API; the local draft is recorded as sent
except ReplyStateError as exc:
raise HttpError(409, str(exc)) from exc
except Exception as exc: # platform refused it — reply is left in "failed"
raise HttpError(502, f"Reply not sent: {exc}") from exc
Comment on lines +173 to +174

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


log_audit_entry(request, action="inbox.reply.create", target_id=reply.id, status_code=201)
return 201, InboxReplyResponse.from_reply(reply)


@router.patch("/replies/{reply_id}", response=InboxReplyResponse, summary="Edit a draft reply")
def update_reply(request, reply_id: uuid.UUID, payload: UpdateReplyRequest):
enforce_http_rate_limits(request, is_write=True)
_require_perm(request, "use_inbox")
reply = _get_reply(request, reply_id)
try:
update_reply_draft(reply, body=payload.body)
except ReplyStateError as exc:
raise HttpError(409, str(exc)) from exc
except ValueError as exc:
raise HttpError(422, str(exc)) from exc
log_audit_entry(request, action="inbox.reply.update", target_id=reply.id, status_code=200)
return InboxReplyResponse.from_reply(reply)


@router.post("/replies/{reply_id}/send", response=InboxReplyResponse, summary="Send a draft reply")
def send_reply(request, reply_id: uuid.UUID):
enforce_http_rate_limits(request, is_write=True)
_require_perm(request, "reply_from_inbox")
reply = _get_reply(request, reply_id)
try:
send_reply_now(reply, actor=request.user if not request.user.is_anonymous else None)
except NotImplementedError:
pass
except ReplyStateError as exc:
raise HttpError(409, str(exc)) from exc
except Exception as exc:
raise HttpError(502, f"Reply not sent: {exc}") from exc
log_audit_entry(request, action="inbox.reply.send", target_id=reply.id, status_code=200)
return InboxReplyResponse.from_reply(reply)


@router.delete("/replies/{reply_id}", response={204: None}, summary="Discard a draft reply")
def delete_reply(request, reply_id: uuid.UUID):
enforce_http_rate_limits(request, is_write=True)
_require_perm(request, "use_inbox")
reply = _get_reply(request, reply_id)
try:
discard_reply_draft(reply)
except ReplyStateError as exc:
raise HttpError(409, str(exc)) from exc
log_audit_entry(request, action="inbox.reply.discard", target_id=reply_id, status_code=204)
return 204, None
Loading
Loading