Source:
docs/plan.md
A multi-tenant shared inbox for WhatsApp (and future channels). This document tracks what's shipped, what's in flight, and the phased roadmap.
| Layer | Status |
|---|---|
| Auth (cookie JWT, refresh, registration) | shipped |
| UtopiaSpace SSO (login + account linking) | shipped |
| Organizations + RBAC (system + custom roles) | shipped |
| Channel lifecycle (pair / QR / connect / disconnect) | shipped |
| Inbox domain (contacts, conversations, messages — inbound) | shipped |
| Realtime (Socket.IO, org-scoped rooms) | shipped |
| Web UI (auth, inbox, full settings surface) | shipped |
| Outbound send (queued → sent → delivered → read → failed) | shipped |
| Media download pipeline (image, sticker, video, video note, audio, voice note, document) | shipped |
| Read receipts (inbound side) | shipped |
| Group chats (read, send, @-mentions, rename, leave, live metadata sync) | shipped |
| Reactions (receive + send) + emoji picker in composer | shipped |
| Outbound media (image / video / audio / document, drag-and-drop) | shipped |
| Send-job idempotency (Redis send-guard prevents duplicate WhatsApp ships on retry) | shipped |
| Location cards (inbound static + live) + outbound static via map picker | shipped |
| Tags / canned replies | next |
| Email-link / token invites for new users | not started (an invite-link slice was prototyped then reverted) |
Local stack runs natively on macOS via Homebrew (Postgres 16, Redis, MinIO). The infra/docker/compose.yaml stack is also wired but currently blocked by a Docker Desktop arm64 exec-format bug on the maintainer's machine.
┌────────────┐ cookie JWT ┌──────────────────┐
│ apps/web │ ───────────────▶ │ apps/api │
│ Next.js │ ◀── Socket.IO ── │ FastAPI │
└────────────┘ │ + python-socketio
└──────────────────┘
│ ▲
│ │ HMAC HTTP
▼ │ (X-Replybox-Sig)
┌─────┐ ┌─────────────────────┐
│Redis│ ◀────│ apps/baileys-connector
│ │BullMQ│ Node + Baileys │
└─────┘ └─────────────────────┘
┌─────────┐ ▲
│MinIO/S3 │ ◀───────┘ AES-256-GCM
│ │ session blobs
└─────────┘
┌─────────┐
│Postgres │ single source of truth
└─────────┘
Conventions worth keeping consistent:
organization_id and user-facing public endpoints enforce membership. The pinned bearer-authenticated disciplinary provider routes are the documented server-to-server exception and expose no inbox API./internal/v1/... (timestamp + sig over {ts}\n{METHOD}\n{path}\n{body}). Inbound channel events + inbound messages + outbound status updates (by-message-id and by-provider-id) + media-fetch status all share this transport.whatsapp queue (connect, disconnect, send, send_media, send_location, download_media, resync_history, mark_read, group_subject_update, group_leave, send_reaction); idempotency keys: send:{message_id} (shared across text/media/location — a Message row is mutually exclusive across the three send shapes), download:{message_id}, mark_read:{conversation_id}:{last_message_id}, group_subject:{group_jid}, group_leave:{group_jid}, react:{channel_id}:{target_provider_message_id}. The disciplinary bridge's groupMetadata() and onWhatsApp() calls are read-only with respect to WhatsApp over private HMAC HTTP; the roster probe may refresh the connector's in-memory LID map, and neither route sends a WhatsApp message.send / send_media / send_location workers consult a Redis send-guard (send:done:{message_id} → provider_message_id, 24h TTL) BEFORE calling sock.sendMessage. On BullMQ retry of a job whose status-webhook 5xx'd, the cache hit re-posts the cached provider_message_id instead of re-shipping the message — prevents duplicate WhatsApp messages when the API hiccups mid-send. Best-effort: Redis flakes degrade to the old (rare) duplicate-on-retry risk rather than blocking sends.org:{id}); events: channel.qr, channel.status, channel.sync, message.created (re-emitted on every outbound status transition AND on media_status flips), conversation.updated.channels/{id}/auth.bin.MEDIA_OBJECT_KEY (AES-256-GCM, same [iv(12) | tag(16) | ciphertext] layout) at channels/{id}/media/{message_id}. Two distinct keys means leaking either key doesn't compromise the other domain.apps/{api,web,baileys-connector}, infra/docker).users / organizations / organization_members / roles / permissions / role_permissions.owner, admin, agent, viewer) cloned per org on signup; custom roles editable.require_permission() dependency gating REST endpoints.channels table with lifecycle state machine (disconnected → connecting → awaiting_qr → connected | failed | logged_out).whatsapp queue producer (via the official bullmq Python port — wire-compatible with the Node worker).CHANNEL_SESSION_KEY; one encrypted blob per channel at channels/{id}/auth.bin in MinIO/S3.POST /internal/v1/channels/{id}/events (event types: qr, connecting, connected, disconnected, logged_out, failure).org:{id} room.contacts, conversations, messages schema with idempotent ingest on (channel_id, provider_message_id).messages.upsert handler maps Baileys WAMessage → wire payload, filters out groups (@g.us), broadcasts, and protocol/reaction/poll updates.POST /internal/v1/channels/{id}/messages batch endpoint; emits message.created and conversation.updated.before=msg_id), PATCH status/assignee.channel.qr, channel.status, message.created, conversation.updated all fold into React Query caches; no refetch storm.role_key; axios error interceptor.next/font; next-themes light/dark/system toggle in sidebar; cmdk command palette (⌘K); global keyboard shortcuts (G I/D/S, J/K, E, ?); skeleton loaders; framer-motion "jump to latest" pill; conversation contact-details right rail; unread badge on Inbox nav + clearer per-row unread state; auth screens rebuilt with react-hook-form + zod; sonner replaces the bespoke toaster; favicon + apple-icon assets.Lifecycle: queued → sent → delivered → read → failed. Each transition emits Socket.IO message.created again (same event drives create + update via id-replace in the client), so the existing MessageBubble status icons (Clock / Check / CheckCheck / AlertCircle) animate in place.
API
POST /api/v1/organizations/{org}/conversations/{conv}/messages (perm message:send, 409 when channel isn't connected). Body: {body (≤4096), client_message_id?}. Returns the canonical MessagePublic with client_message_id echoed back so the web client can reconcile its optimistic temp row.services/inbox.insert_outbound_message() inserts a direction='outbound', status='queued' row with a placeholder provider_message_id = 'pending:{message.id}' (the column is NOT NULL; Baileys assigns the real WhatsApp key.id only after sock.sendMessage() resolves).messages.status_detail column (alembic 0006) carries failure reasons from the connector.app/api/internal/outbound_status.py:
POST /internal/v1/channels/{channel_id}/messages/{message_id}/status — keyed by our UUID; used for the initial sent (or failed) transition where the connector knows our id.POST /internal/v1/channels/{channel_id}/messages/by-provider-id/status — keyed by WhatsApp's key.id; used by messages.update ack events (delivered/read) where the connector only sees the provider id.apply_outbound_status() is idempotent and direction-aware: failed is sticky, ack ranks gate regressions (a stale sent doesn't overwrite an already-delivered row).Connector
manager.ts::getSocket(channelId) exposes the live WASocket per paired channel.worker.ts::case "send": calls sock.sendMessage(jid, { text: body }), posts status=sent with the WhatsApp key.id on success. A worker.on("failed", …) listener guarantees a terminal status=failed webhook even when all BullMQ retries exhaust.messages.update listener filters to fromMe keys and maps Baileys ack numbers (3 → delivered, 4 / 5 → read) → postOutboundReceipt.job_id=send:{message_id} for idempotent enqueue; retries inherit the producer's attempts:3, exponential backoff.Web
useSendMessage mutation: generates a client_message_id, optimistically inserts a queued bubble, POSTs with the id, replaces by either temp id or echoed client_message_id on success, marks failed with status_detail on error.useRealtime message.created handler changed from "skip if exists" to "replace in place," matching against id, incoming client_message_id → existing row id, or existing row's client_message_id. Dedupes by id after replacement so a SIO event that races ahead of the POST response can't leave two bubbles.⌘↵ / Ctrl+↵ submits, plain Enter inserts newline, char counter near the 4096 limit, hard-disabled when channel.status !== "connected" with an inline ComposerDisabledBanner linking to Settings → Channels.destructive/15 with an inline error and a "Retry" link that re-sends the body as a fresh row via Composer.sendImmediate(). The failed bubble stays for audit.Permission gating in UI: deferred. The server enforces require_permission("message:send"); viewer-role members see the composer but get a 403 toast on send. Revisit when /auth/me returns the effective permission set.
Lifecycle: media_status: none → pending → fetched | failed. Live (non-history) inbound media messages trigger a download_media BullMQ job; the connector pulls bytes from WhatsApp via Baileys downloadMediaMessage, encrypts under a dedicated MEDIA_OBJECT_KEY, uploads to MinIO, and posts back. The API then flips status to fetched and re-emits message.created. A new authenticated streaming endpoint decrypts on demand.
Scope shipped: image, sticker, video, video note (PTV), audio, voice note (PTT), document. History-backfilled media is intentionally skipped (rows stay pending until a future lazy-on-view path).
API
0007_message_media: adds messages.media_size_bytes, voice_note, video_note (all nullable).MediaFetchedUpdate / MediaFailedUpdate payloads in app/schemas/inbox.py. MessagePublic surfaces voice_note, video_note, media_size_bytes.app/services/storage.py — async aiobotocore S3 client (AsyncIterator-style s3_client() context manager + get_object_bytes() + head_object_size()).app/security/media_crypto.py — AES-256-GCM decrypt_media() keyed by base64 MEDIA_OBJECT_KEY.enqueue_download_media(message_id, channel_id, provider_message_id) producer; job_id=download:{message_id} for idempotency.inbound_messages ingest now enqueues download_media for live media (skips is_history=true), and persists voice_note / video_note flags on the row.app/api/internal/media.py — HMAC-verified POST /internal/v1/channels/{channel_id}/messages/{message_id}/media taking the {status: "fetched", ...} or {status: "failed", error_detail} discriminated union. Calls apply_media_fetched / apply_media_failed service helpers, re-emits message.created.GET /organizations/{org}/conversations/{conv}/messages/{msg}/media — conversation:read-gated, tenancy-checked via get_conversation_with_contact, 404 if not fetched, 413 if over MEDIA_MAX_SIZE_BYTES (100MB default), buffers in memory, returns Response with Content-Type from media_mime and Content-Disposition: inline for media / attachment for documents.Connector
mapWAMessage extracts audioMessage.ptt → media.voice_note and videoMessage.ptv → media.video_note.manager.ts (5000 entries, 5-min TTL) keyed by ${channelId}:${providerMessageId}, populated alongside the inbound POST. The download_media worker reads from it to recover the raw WAMessage payload.whatsapp/media.ts::downloadAndUpload() — Baileys download → encryptMedia() → PutObject to channels/{id}/media/{messageId}.session/media_crypto.ts — AES-256-GCM keyed by MEDIA_OBJECT_KEY (same [iv | tag | ciphertext] layout as the session crypto).case "download_media": handler + a BullMQ failed listener that posts terminal status=failed after retries exhaust.postMediaStatus() HMAC client helper.Web
Message type + messagePreview extended for voice_note / video_note / media_size_bytes. Voice-note vs audio-file and video-note vs video distinguished in the list-row preview text.lib/inbox/media.ts — authenticated mediaUrl() builder + formatBytes().components/inbox/MessageMedia.tsx — switches on (kind, voice_note, video_note, media_status):
pending → kind-specific Skeleton.failed → "Couldn't load media" pill with tooltip showing status_detail.fetched → <img> for image, smaller transparent-bg <img> for sticker, <video controls> for video, round 200px player for video note, native <audio controls> for audio + voice note (with mic icon for voice note), file-card for document with download link.MessageBubble mounts MessageMedia and drops the rounded-bubble chrome for "naked" media kinds (image / sticker / video). Captions render beneath the media.components/inbox/Lightbox.tsx — Radix Dialog opened from image / video clicks. Prev/next chevrons + arrow-key nav + counter; outside-click closes (explicit handler on the wrapper because the 90vw × 90vh DialogContent covers most of the screen, so Radix's overlay-click default would never fire); video controls + nav buttons stop click propagation so they don't accidentally close the dialog.ConversationThread lifts Lightbox state and memoizes the ordered list of fetched image/video media for prev/next nav.<img> / <video> / <audio> tags carry crossOrigin="use-credentials" so the browser attaches the auth cookie on cross-origin (localhost:3000 → localhost:8000) requests. CORS middleware already echoes the right origin + credentials.Encryption posture: MEDIA_OBJECT_KEY is a separate AES-256 key from CHANNEL_SESSION_KEY. Generated with python3 -c "import secrets,base64; print(base64.b64encode(secrets.token_bytes(32)).decode())"; must match across apps/api/.env and apps/baileys-connector/.env.
Known limitations: history backfill media isn't downloaded; the API buffers the full plaintext in memory (capped at 100MB) rather than streaming with range support; Baileys' reuploadRequest for expired media URLs is stubbed to throw (so very-old media may surface as failed).
When an agent opens a conversation thread, all unread inbound messages are stamped read_at, unread_count resets to 0, the org's other tabs/agents are notified via conversation.updated, and a single BullMQ mark_read job tells the Baileys connector to call sock.readMessages(keys) so the original sender sees blue ticks.
API
0009_message_read_at: adds nullable messages.read_at TIMESTAMPTZ, partial index ix_messages_conv_unread (conversation_id) WHERE direction='inbound' AND read_at IS NULL, and a one-shot backfill stamping every existing inbound row with read_at = sent_at (treats pre-feature messages as already-read).status enum is not overloaded — inbound stays terminal at "received", and read state is tracked exclusively via read_at. This keeps the outbound _STATUS_ORDER ranking machinery clean.ingest_inbound_message pre-stamps read_at = sent_at for is_history=true rows, so history backfill never enters the read-pending query — protects against the first post-deploy open of any chat with backfill dumping hundreds of read receipts onto WhatsApp at once.services/inbox.mark_conversation_read() selects direction='inbound' AND read_at IS NULL, bulk-stamps read_at, zeroes unread_count, and returns Baileys-shaped key dicts ({remoteJid, id, fromMe: false, participant?}). Idempotent: a second call finds zero rows.enqueue_mark_read() producer with deterministic job_id=mark_read:{conversation_id}:{last_message_id} — rapid re-opens collapse to a single job; a new inbound between opens advances the id and gets a fresh job.POST /organizations/{org}/conversations/{conv}/read (perm conversation:read). 200 no-op on an already-read conversation; on real work it commits, emits conversation.updated, and enqueues mark_read.Connector
worker.ts extends WhatsAppJobName with "mark_read", adds a ReadKey interface, and dispatches to handleMarkRead. Handler resolves getSocket(channelId) and calls sock.readMessages(keys). Disconnected channels log + return {ok: true, skipped: true} instead of throwing — read receipts are best-effort, and burning all three BullMQ retries for a signal the sender can live without is the wrong tradeoff. No webhook callback (Baileys provides no reliable ack for readMessages).Web
useMarkConversationRead() mutation in useInbox.ts with full optimistic patching: zeroes unread_count on both the single-conversation cache (inboxKeys.conversation) and every cached conversation-list query before the network round-trip; rolls back on error.ConversationThread mounts a useEffect keyed on [conv?.id, conv?.unread_count] that fires the mutation whenever the visible conversation has unread_count > 0. A useRef<Set<string>> guard with key {conv.id}:{unread_count} prevents re-firing across renders but allows a new inbound during reading (which bumps the count via realtime) to re-fire for just the delta. The ref is cleared in the existing conversation-switch effect.Lifts the @g.us filter at the connector and API. Group conversations appear in the inbox with the group subject as the title and per-sender labels on inbound bubbles; agents can @-mention participants from the composer, rename the group, and leave it. Live groups.update and group-participants.update events keep the subject and roster honest in real time. Direct chats and existing read-receipt/outbound-send paths are untouched.
Schema
0010_groups: adds conversations.kind ('direct' | 'group', server-default 'direct'); messages.mentioned_jids (JSONB nullable); new conversation_participants(id, conversation_id, contact_id, role, timestamps) keyed unique on (conversation_id, contact_id) with an index on conversation_id. role is the Baileys admin enum collapsed to member | admin | superadmin.wa_jid = @g.us, display_name = group subject, phone_e164 = NULL stands in for the group. The existing (channel_id, contact_id) uniqueness on Conversation stays valid.API
ingest_inbound_message branches on is_group: upserts the group Contact (with display_name from the connector-attached subject), flips Conversation.kind = 'group' on first sight, upserts the sender Contact via payload.sender_jid + payload.push_name, and ensures the participants row. upsert_contact gained an optional display_name arg so this all flows through one helper.apply_group_metadata_snapshot() (full sync — used by webhook + on first-message attach), apply_group_participants_delta() (add/remove/promote/demote), list_participants(), set_group_subject(). _apply_participants_snapshot does an idempotent diff so re-applying a snapshot is a no-op.app/api/internal/groups.py:
POST /internal/v1/channels/{id}/groups/{jid}/metadata — full snapshot (subject + roster).POST /internal/v1/channels/{id}/groups/{jid}/participants — delta with action ∈ {add, remove, promote, demote} + participant JID list.conversations.py:
GET /v1/.../{conv}/participants (perm conversation:read).PATCH /v1/.../{conv}/group (perm conversation:manage, body {subject}, 25-char cap matching WhatsApp's limit) — optimistically updates Contact.display_name, enqueues group_subject_update, emits conversation.updated. 409 when kind != 'group'.POST /v1/.../{conv}/leave (perm conversation:manage) — enqueues group_leave, flips status to closed. 409 when kind != 'group'.OutboundMessageCreate extended with optional mentioned_jids: list[str]. insert_outbound_message persists it on messages.mentioned_jids; enqueue_send_message threads it onto the BullMQ payload so the connector can hand it to Baileys.Connector
mapWAMessage no longer early-returns on @g.us; sets is_group: true on the mapped payload (sender resolution via m.key.participant ?? jid was already group-friendly). Also extracts inbound mentionedJid from contextInfo on text, image, video, and document inner messages.whatsapp/group-cache.ts: 5k-entry LRU with 5-min TTL, keyed ${channelId}:${groupJid}. getOrFetch() lazily calls sock.groupMetadata() on miss; markRosterShipped() flips a per-entry flag so the FULL roster is attached only on the first inbound from a group per session.manager.ts::enrichGroupsAndPost() resolves every unique group JID in a batch in parallel through the cache, attaches group: { subject, participants? } to the mapped payload, then POSTs. Used by both live (messages.upsert) and history (messaging-history.set) ingest.groups.update — when subject changes: invalidate the cache, re-fetch metadata, POST .../metadata (full snapshot).group-participants.update — invalidate cache, POST .../participants with the delta (add/remove/promote/demote).worker.ts extends WhatsAppJobName with group_subject_update and group_leave; handlers call sock.groupUpdateSubject / sock.groupLeave. Disconnected channels log + return {skipped: true} (no retry burn). handleSend now passes mentions: job.data.mentionedJids when present.api-client.ts gained HMAC-signed postGroupMetadata and postGroupParticipants.Web
ConversationKind, ParticipantRole, Participant. Conversation.kind and Message.mentioned_jids exposed.useInbox.ts: useParticipants(orgId, convId, enabled) (cache key inboxKeys.participants(...)); useUpdateGroupSubject (optimistic patch on both the single-conversation cache and every cached list); useLeaveGroup. useSendMessage accepts mentionedJids and includes it on POST.ConversationListItem: small Users icon prefix when kind === 'group'.ConversationThread: header subtitle is "{n} members" for groups; the same-run detector now splits on sender_jid for inbound group runs so each participant's contiguous bubbles read as their own run with one label up top. New dropdown items Rename group and Leave group (group-only).MessageBubble: a small bold sender label sits above the first inbound bubble of each same-sender run when conversationKind === 'group'; text bodies render with @<digits> tokens highlighted via a participants Map<jid, Participant> lookup.ContactDetailsRail: branches on conv.kind. For groups the Contact card is replaced by a Participants list (avatar + display name + phone, with an admin/superadmin pill on elevated roles); the Conversation metadata block (channel, status, assignee, last message, started) is unchanged.Composer: detects @ at word boundary, opens a positioned popover below the textarea with arrow-key + Enter selection. Inserting a participant writes @<digits> into the buffer and records the full JID; on submit the body is re-scanned and only mentions whose token still appears in the final text are emitted to the API.GroupRenameDialog (Radix Dialog + Input, 25-char limit) wired to useUpdateGroupSubject; Leave group uses the existing ConfirmDialog with destructive variant.v1 limitations / known sharp edges
groupUpdateSubject / groupLeave fail inside Baileys. The connector worker logs the failure but doesn't post a rollback webhook today, so the optimistic UI update sticks until the next groups.update overwrites it. Tracked for a follow-up.Lifts the connector's reactionMessage drop so reactions flow both ways: inbound reactions render as pill rows under each message bubble, agents can hover any bubble to open an emoji picker and react, and the previously disabled Smile button in the composer is now a working emoji-mart picker that inserts at the textarea caret. Composer + bubble share the same picker component.
Schema
0011_message_reactions: new message_reactions(id, message_id FK ondelete cascade, reactor_jid TEXT, emoji TEXT, from_me BOOL default false, reacted_at TIMESTAMPTZ NULL, timestamps) with UNIQUE(message_id, reactor_jid) (each reactor has at most one reaction per WhatsApp's model — also makes upsert trivial) and an index on message_id.contacts, mirroring Message.sender_jid. Group reactors who aren't yet org Contacts otherwise force a Contact upsert per reaction event; display names resolve at render time via the existing per-thread participants map. from_me is denormalized so the UI labels "You" without a JID compare.API
MessageReaction model + Message.reactions = relationship(..., lazy="selectin", cascade="all, delete-orphan"). selectin is async-safe (no MissingGreenlet from late Pydantic attribute access). Newly-inserted Message instances explicitly set msg.reactions = [] so the in-memory collection is initialized without a load.ReactionPublic rides on MessagePublic.reactions (defaults to []) — every existing emit_message_created site continues to work unchanged because selectin eagerly populates the field on each Message load._set_reaction_row (in-memory upsert/delete on msg.reactions), apply_inbound_reaction(db, channel, payload) (resolves the target by (channel_id, target_provider_message_id); unknown targets log + skip), set_own_reaction(db, channel, message, emoji) (uses _own_jid_for_channel for the reactor JID; 409 if not yet observed). Empty emoji is a clear.POST /internal/v1/channels/{id}/messages/by-provider-id/reactions taking an InboundReactionBatch (max 200). Dedupes touched messages and re-emits message.created once per target.conversations.py:
PUT /v1/.../{conv}/messages/{msg}/reaction (perm message:send, body {emoji}, ≤32 chars).DELETE /v1/.../{conv}/messages/{msg}/reaction (perm message:send)._apply_own_reaction helper that resolves message + channel under tenancy, writes via the service, commits, emits message.created, and enqueues send_reaction. target_participant is set only when the message's sender_jid differs from the conversation contact's wa_jid (forward-compat for group @lid targets).enqueue_send_reaction(channel_id, contact_jid, target_provider_message_id, target_from_me, emoji, target_participant?) with deterministic react:{channel_id}:{target_provider_message_id} job_id — rapid swap of the reaction emoji on the same target collapses to a single replayed job, so the connector doesn't fan stale states out to WhatsApp.Connector
mapWAMessage keeps the early-return on protocol/poll-update inner messages and now also returns null for reactionMessage (handled in a separate path).mapWAReaction(m): extracts reactor (m.key.participant ?? m.key.remoteJid), from_me from the outer key, the target's provider_message_id/fromMe/participant from inner.reactionMessage.key, emoji from inner.reactionMessage.text (empty = clear), and a reaction timestamp (senderTimestampMs falling back to message timestamp, normalized to unix seconds).manager.ts::messages.upsert splits each batch into messages + reactions; reactions ship via the new postInboundReactions HMAC helper. History reactions (type === "append") are skipped — ephemeral state isn't worth a backfill pass.api-client.ts gains postInboundReactions(channelId, reactions).worker.ts extends the job-name union with send_reaction; handleSendReaction calls sock.sendMessage(jid, { react: { text: emoji, key } }). Disconnected-channel branch returns {skipped: true} — best-effort, no retry burn (same pattern as mark_read).Web
@emoji-mart/data, @emoji-mart/react, emoji-mart. The emoji-mart <Picker> is next/dynamic-imported with ssr: false; the data module is loaded on first picker mount so the inbox initial bundle stays clean.EmojiPicker.tsx — wraps emoji-mart in a Radix Popover. Reads resolvedTheme from next-themes. Used by both the composer and per-bubble react button.Composer.tsx: the previously-disabled Smile button is now an EmojiPicker trigger. On select, the native emoji is inserted at the textarea's current selectionStart/selectionEnd and focus is restored after a requestAnimationFrame.useSetReaction(orgId) / useClearReaction(orgId) hooks with optimistic patches across every cached thread page (patchMessageInThreads walks all ["inbox", "thread", orgId, ...] query entries and mutates the target message's reactions array). useSetReaction drops any existing from_me row before appending the new one with a sentinel reactor_jid="__self__"; the canonical row arrives via the API response + realtime message.created. Both rollback on error.MessageBubble.tsx:
group/bubble so a hover-only SmilePlus icon button on either side of the bubble (mirrored across direction) fades in via group-hover/bubble:opacity-100. Click opens an EmojiPicker → toggles the reaction.ReactionPills sub-component renders below each bubble: pills grouped by emoji preserving first-seen order, count shown only when ≥2, primary outline on whichever pill contains our from_me row, tooltip lists reactor display names via the existing participantsByJid map ("You" for from_me; digits-only JID fallback for unknown group reactors). Click a pill to toggle our reaction matching that emoji.Message.reactions type added; defaults to [] server-side so older code paths don't need null-guards.v1 limitations / known sharp edges
messaging-history.set) are intentionally skipped — they're ephemeral and not worth a backfill pass.send_reaction: no active socket; skipping and the recipient doesn't see the reaction until our paired account next reconnects and re-syncs (no replay queue).reactor_jid is the sentinel "__self__" (we don't track the channel's own JID client-side). The server response replaces the row with the canonical reactor JID; rendering keys off from_me, not the JID, so the swap is seamless.Lifts the disabled Paperclip placeholder in the composer. Agents attach an image, video, audio file, or document (single file per message, optional caption); the API encrypts the bytes under MEDIA_OBJECT_KEY and stores at channels/{channel_id}/media/{message_id} (same key namespace as inbound media); a new BullMQ send_media job tells the connector to download, decrypt, and ship via the appropriate Baileys envelope per kind. Drag-and-drop onto the conversation thread funnels into the same attach handler. In-browser voice-note recording is deferred — .ogg/.mp3/.m4a uploads still play correctly as audio.
API
security/media_crypto.py: new encrypt_media(plaintext) mirroring the existing decrypt + the connector's encryptMedia. Same [iv | tag | ciphertext] envelope.services/storage.py: new put_object_bytes(object_key, body, content_type?) reusing the existing aiobotocore session helper.schemas/inbox.py: kind_from_mime derives image|video|audio|document server-side (client mime is hint-only).services/inbox.py: insert_outbound_media_message(...) parallels insert_outbound_message; takes a pre-generated message_id so the MinIO key path is deterministic before INSERT; sets media_status="fetched" at t=0 (bytes are durable when the row is born); voice_note / video_note are honored only when the derived kind matches. Tightened the existing ingest's media_status so location + unknown rows stay "none".api/v1/conversations.py: new POST /{conv}/messages/media accepting multipart/form-data (file + caption + client_message_id + voice_note + video_note + JSON-encoded mentioned_jids). Reads the upload, validates size + non-empty + mime; encrypts → uploads → inserts → commits → emits → enqueues send_media. 5xx on encryption/upload failure means no DB row is written.queue/producer.py: enqueue_send_media(message_id, channel_id, jid, kind, media_object_key, mime, filename?, caption?, voice_note?, video_note?, mentioned_jids?). Shares send:{message_id} job_id with text and location.Connector
whatsapp/media.ts: new downloadAndDecryptMedia(channelId, messageId) — fetches the ciphertext via the existing session/s3.ts::getObject and decrypts with session/media_crypto.ts::decryptMedia.queue/worker.ts::handleSendMedia: send-guard cache check first (skip if a prior attempt already shipped to WhatsApp); on miss, decrypt → build per-kind Baileys envelope (image/video with optional ptv, audio with optional ptt, document with fileName) → sock.sendMessage → markSent BEFORE the status post → post status="sent". Disconnected channel posts terminal failed; the failed listener handles retry exhaustion uniformly for send / send_media / send_location.Web
lib/inbox/types.ts: Message.client_blob_url (client-side only) for optimistic blob-URL previews.hooks/useInbox.ts::useSendMediaMessage(orgId) — FormData POST, optimistic insert with URL.createObjectURL(file) as the bubble's source so the user sees the file render immediately; canonical row replaces on success (blob revoked 5s later); failed flip on error.Composer.tsx: paperclip opens a hidden <input type="file"> with a friendly accept filter; chosen file appears as a chip above the textarea (name + size + remove ×). Submit routes to media or text based on pendingFile. New composerHandle.attach(file) lets the thread drop zone populate.MessageMedia.tsx: srcUrl = message.client_blob_url || mediaUrl so optimistic media renders from the local blob until the authenticated stream URL takes over.ConversationThread.tsx: drag-enter/over/leave/drop handlers with a depth counter on the scroller div, a translucent "Drop to attach" overlay, single-file enforcement (e.dataTransfer.files[0] only; toast on multi-drop).Critical follow-up fix shipped in the same slice
serialize_message_for_emit(db, msg) helper added to services/inbox.py. The reactions slice's lazy="selectin" strategy gets EXPIRED by db.refresh(msg) after a commit — the next MessagePublic.model_validate(msg) would trip an async lazy-load and 500. The helper re-fetches with selectinload(Message.reactions). Retrofitted into outbound_status.py::_emit_message_change, media.py::_emit_message_change, and three sites in conversations.py (text send, media send, reaction toggle). Without this, the first outbound media test 500'd at the status webhook and BullMQ retried — which is what exposed the duplicate-send risk.Send-job idempotency (apps/baileys-connector/src/queue/send-guard.ts — new)
getCachedProviderId(messageId) and markSent(messageId, providerMessageId) backed by a dedicated IORedis client. Key shape send:done:{messageId} with a 24h TTL (BullMQ retries finish in seconds; the long TTL just protects against pathological cases). Best-effort: Redis errors degrade to the old behavior (rare duplicate-on-retry) rather than blocking sends.handleSend / handleSendMedia (and later handleSendLocation) all start with a guard check; on hit they re-post the cached provider_message_id to the status webhook and return without calling sock.sendMessage. After a successful first send, markSent(...) runs BEFORE the status post — if the post 5xx's and BullMQ retries, attempt 2 finds the cache and skips re-sending.sent on a row already in delivered/read is gated by _STATUS_ORDER ranking in apply_outbound_status — the higher state wins.v1 limitations / known sharp edges
MEDIA_MAX_SIZE_BYTES); large files surface a toast before upload.Lifts the connector's "static location → body='lat,lng'" hack and the dropped liveLocationMessage filter. Static location cards render as a Leaflet/OSM map thumbnail with name/address subtitle and "Open in Maps" link. Live location streams render the same card with a Live · expires in {N}m badge — subsequent position updates from Baileys mutate the same row's location_data and re-emit message.created, so the marker moves without inserting new rows. Agents send static one-shot locations via a map picker with click-to-pin or browser geolocation. Outbound live-location is intentionally out of scope (WhatsApp's live-location is a phone-side feature).
Schema
0012_message_location: new messages.location_data JSONB NULL holding {lat, lng, name?, address?, accuracy_m?, live_until?, sequence?, speed_mps?, heading_deg?}. One-shot backfill parses legacy body = "lat,lng" rows into structured JSON via split_part(body, ',', N)::float gated by a regex.body keeps its meaning as the user-facing preview/caption fallback. New inbound location rows set body = name ?? address ?? null so the [location] emoji label in format.ts fires cleanly for the conversation-list rollup.API
schemas/inbox.py: LocationData (full structured shape, exposed on MessagePublic.location_data); InboundLocation (wire payload from the connector on the inbound-messages batch); OutboundLocationCreate (lat/lng with -90..90 / -180..180 bounds, optional name/address); LiveLocationUpdatePayload (internal webhook body keyed by provider_message_id).services/inbox.py: ingest_inbound_message populates location_data from payload.location and overrides body to the name/address fallback. New insert_outbound_location_message(...) mirrors the media path with kind="location", media_status="none". New apply_live_location_update(db, channel, payload) resolves by (channel_id, provider_message_id), gates on sequence (drops out-of-order updates when both rows carry a sequence), mutates location_data in place (preserving live_until/name/address), and returns the message.app/api/internal/locations.py — HMAC-protected POST /internal/v1/channels/{cid}/messages/by-provider-id/location-update. Unknown targets respond 200 with applied:false (a target row may be ingested moments later; transient out-of-order isn't worth a 4xx).api/v1/conversations.py: new POST /{conv}/messages/location (perm message:send). 409 if channel disconnected. Inserts row, commits, re-fetches via serialize_message_for_emit, emits, enqueues send_location.queue/producer.py::enqueue_send_location(...) — send:{message_id} shared job_id with text/media.Connector
whatsapp/messages.ts: extracts structured location from both inner.locationMessage (static) and inner.liveLocationMessage (initial live share, with expirationTimestamp → live_until and sequenceNumber). Sets body = name ?? address ?? caption ?? null. New MappedMessage.location field carries the structured payload to the API on the normal inbound-messages batch.whatsapp/manager.ts::messages.update subscriber gained a live-location branch: when update.message?.liveLocationMessage is present, extracts lat/lng/accuracy/sequence/speed/heading and fires postLiveLocationUpdate(channelId, ...). The existing outbound-receipt branch is unchanged and runs after.internal/api-client.ts::postLiveLocationUpdate HMAC helper.queue/worker.ts::handleSendLocation — send-guard pattern shared with handleSend and handleSendMedia: cache hit re-posts status without re-sending; on miss, sock.sendMessage(jid, { location: { degreesLatitude, degreesLongitude, name?, address? } }) → markSent → status post. Failed-listener extended to cover send_location.Web
leaflet, react-leaflet@^4 (React 18 compat — v5 requires React 19), @types/leaflet.app/layout.tsx imports leaflet/dist/leaflet.css once.components/inbox/leaflet/icons.ts — idempotent shim merging default marker URLs to unpkg-hosted PNGs (Webpack/Next don't bundle Leaflet's PNG assets by default).components/inbox/leaflet/StaticMap.tsx — non-interactive 288×140 thumbnail (drag/zoom/keyboard disabled), next/dynamic({ ssr: false }).components/inbox/leaflet/PickerMap.tsx — interactive 640×400 picker with click-to-pin via useMapEvents, parent-controlled marker state, and a CenterOn helper that recenters on geolocation.components/inbox/MessageLocation.tsx — renders the bubble card: map thumbnail + name/address subtitle + "Open in Maps" link to maps.google.com/?q=lat,lng. Live shares get a top-right pill (Live · 14m left → "Live ended" once live_until passes) with a 30s setInterval tick. Falls back to parsing legacy body = "lat,lng" when location_data is null.components/inbox/LocationPickerDialog.tsx — Radix Dialog wrapping PickerMap, "Use my current position" button (navigator.geolocation with permission-denied toast fallback), Name/Address inputs (capped to match server validators), Send/Cancel. Closes via its own onSuccess.Composer.tsx: new MapPin button in the toolbar next to Paperclip/Smile, opens the picker, gated on channelConnected.MessageBubble.tsx: when kind === "location", renders MessageLocation instead of the text body; treats location as "naked" (no rounded chrome). Caption is suppressed since the card already shows name/address.hooks/useInbox.ts::useSendLocationMessage — POSTs the new endpoint and invalidates the conversations list. No optimistic insert (canonical coords matter; server response + realtime echo populate the thread).v1 limitations / known sharp edges
public/.live_until and stops the countdown. Late updates from Baileys (post-expiration) still apply to the row but the UI no longer ticks.provider_message_id); WhatsApp itself treats them as separate streams."Login with UtopiaSpace" sits alongside the existing email/password login (which stays). ReplyBox
never talks to Supabase Auth directly — it trusts only a UtopiaSpace Supabase Edge Function, exchanged
via a one-time ticket. Existing JWT cookies, refresh, org membership, and RBAC are unchanged; an SSO
session is indistinguishable downstream because everything keys on users.id.
Flow: ReplyBox /auth/utopia/login → utopia-hub /replybox-sso → (Google session) → Edge Function
replybox-sso-issue mints a one-time ticket → ReplyBox /auth/utopia/callback redeems it server-to-
server against replybox-sso-verify (HMAC-signed, UTOPIA_SSO_SHARED_SECRET) → ReplyBox issues its own
cookies. The ticket is opaque (only its sha256 is stored), single-use, 120s TTL; profile_id never
appears in any URL.
Schema — Alembic 0056_utopia_sso + 0057_utopia_profile_snapshot: users.utopia_profile_id (partial-unique, one ReplyBox user per
Utopia profile), utopia_linked_at, auth_provider; password_hash made nullable (SSO-only users have
no password). Password-login guard rejects passwordless accounts.
API — app/api/v1/auth_utopia.py (/auth/utopia/status|login|link|callback|unlink) +
app/services/utopia_sso.py (redeem_ticket, find_or_link_user, link_to_user, unlink_user,
signed state cookie for CSRF). Config: UTOPIA_SSO_ENABLED, UTOPIA_LOGIN_URL, UTOPIA_SSO_VERIFY_URL,
UTOPIA_SSO_SHARED_SECRET, UTOPIA_SSO_REDIRECT_PATH.
Linking — same email → auto-link onto the existing account (password login still works);
different email → authenticated "Connect UtopiaSpace" link flow (/auth/utopia/link); brand-new →
create user with no org (empty state until an admin adds them). The profile↔user link is 1:1.
UtopiaSpace (utopia-hub) — Edge Functions replybox-sso-issue / replybox-sso-verify,
replybox_sso_tickets table + user_has_permission() + replybox.access permission (granted to all
roles), and the /replybox-sso SPA hand-off route. verify_jwt=false on the verify function (it's
HMAC-authed). Verify returns only active, non-deleted profiles. Docs: docs/utopia-sso-design.md,
docs/utopia-sso-deploy.md.
Deferred: a token-based invite-link flow for provisioning brand-new users was prototyped and then
reverted; new-user provisioning today is "SSO creates an orgless user → admin adds them via Settings →
Members." No automated tests yet (the repo has no test infra).
Accept Baileys messages.upsert with type: "append" (currently filtered out). Dedupe is already in place. Add a per-channel history_synced_at timestamp to bound the backfill, and limit the size of the initial batch we forward to the API. This makes a newly-paired channel show your existing WhatsApp chats instead of starting empty.
Estimate: ~2h.
organization_invites table: {id, organization_id, email, role_id, token (random 32 bytes, indexed), invited_by, expires_at, accepted_at}. POST creates the row + sends an email (Resend or Postmark — TBD). POST /auth/accept-invite?token=... registers the user (or attaches to an existing account by email) and creates the membership in one transaction. Web: new /invite/[token] route.
Estimate: ~half day, plus email-provider setup.
Both permissions already seeded. Two parallel slices, similar shape: small tags and canned_replies tables scoped per org, M:N join for tags-on-conversations, REST CRUD, sidebar filter chips for tags, / shortcut in composer to pick a canned reply.
Estimate: ~1 day each.
Postgres full-text index over messages.body. Endpoint GET /search?q=...&conversation_id?&channel_id?. Web: command-K palette over conversations + messages.
Estimate: ~half day.
Browser Notifications API + visibility detection. Per-user preference: all / mentions / none. Service worker so notifications work when the tab is backgrounded.
Estimate: ~half day.
GET /organizations/{org}/contacts (paginated, searchable by phone + name); web /contacts page (sidebar link restoration); contact detail drawer with conversation history. Update Sidebar.tsx TODO.
Estimate: ~half day.
infra/docker/compose.yaml still works on machines with a healthy runtime, but consider documenting colima / OrbStack as alternatives.structlog is already a dep) with org_id / user_id / channel_id context; access log filter for noisy health probes; OpenTelemetry traces when/if we deploy.RATE_LIMIT_REDIS_URL). Apply at least to /auth/* and /internal/*.One message → many contacts, each delivered as an ordinary 1:1 chat so replies
land in the inbox as normal conversations. Explicitly not WhatsApp's
broadcast-list feature.
ContactSelection — the same shape the Contactsbroadcast_recipients. Deliberate: a campaign runs for{{contact.name}} variables andstart_run.broadcasts queue, ticks spaced60/messages_per_minute apart. Send window is minutes-from-local-midnightBROADCAST_SWEEP_SECONDS re-drives it and releases recipients stranded insending.broadcast:read / broadcast:manage / broadcast:delete.0109_broadcasts seeds them and grants to admin + manager./broadcasts is a dashboard, not a list: four headline rates, a
table/calendar working surface, and a right rail of live + urgent context
(GET /broadcasts/dashboard, one payload so the tiles, pills and outcome bar
can't disagree with each other).
messages.status — WhatsApp's own receipts — not fromsent_count, which only means "handed to the connector". Rates are overbroadcasts.record_replies, hooked intois_not rules areMAX_RECIPIENT_ATTEMPTS per person so an unroutable number can't be retriedDeferred: A/B variants, a UI for window_timezone (captured from the
composer's browser), and operator-configurable reply-rate targets — the
attention panel's 25% threshold is currently a constant.
AsyncRedisManager covers single-Redis horizontally, but cross-region replication isn't designed in yet.apps/api/app/
api/v1/ public REST (auth, orgs, members, roles, channels, conversations)
api/internal/ HMAC-protected connector callbacks (channel events, inbound messages)
models/ SQLAlchemy ORM
schemas/ Pydantic I/O models
services/ business logic (auth, membership, channels, inbox)
queue/producer.py BullMQ-wire-compatible producer (Python `bullmq` port)
realtime/sio.py Socket.IO server (org-scoped rooms)
realtime/emit.py typed event emitters
security/ hashing, JWT, HMAC, cookies
seeds/permissions.py permission catalog + system role templates
apps/baileys-connector/src/
whatsapp/manager.ts per-channel Baileys socket lifecycle
whatsapp/messages.ts WAMessage → wire payload mapper
session/{crypto,s3,store}.ts AES-256-GCM + S3 + Baileys-compatible auth state
internal/api-client.ts HMAC-signed HTTP client
queue/worker.ts BullMQ worker
http/health.ts /health + /ready
apps/web/
app/(app)/ authenticated routes (dashboard, inbox, settings)
components/{inbox,settings,ui,layout}/
hooks/ useAuth, useInbox, useRealtime, useMembers, useRoles, useOrganization, useChannelsAdmin
lib/api.ts axios client + refresh + toast interceptors
lib/socket.ts socket.io-client singleton
stores/ Zustand stores (orgStore, toastStore)