omni-channel-inbox.md59.3 KBView on GitHub # Omni-Channel Inbox — `/inbox` as a unified sales space
## 1) Introduction — goal, present state, future state
We want to turn Cedar's email client (`/mail`) into an omni-channel sales inbox at `/inbox`, where a single time-sorted "unibox" list interleaves Gmail threads, LinkedIn chats, WhatsApp chats, and conversation-attached Slack messages — each row rendered like today's email `Thread`, openable full-screen, and actionable (snooze, mark done, read/unread, star, label, draft) regardless of channel. Today `/mail` is email-only: the list is fed by `trpc.mail.listThreads` off the `crm_email_threads` Gmail mirror, threads open in-place via a Zustand view-stack, labels are Gmail-native, and the genuinely multi-channel data (`crm_events` + `crm_linkedin_messages` / `crm_whatsapp_messages` / `crm_slack_messages`, surfaced only inside the per-conversation timeline) never reaches the list. We will introduce a normalized `InboxItem` feed (`trpc.inbox.listItems`) that unions all channels server-side with cursor pagination, a channel-agnostic `InboxRow` and full-screen channel view, a Cedar-internal unified label layer that bridges to Gmail for email and stores natively for other channels, cross-channel actions and per-item state, and full draft parity (composer + persisted drafts + agent drafts) for LinkedIn and WhatsApp — with the top bar becoming a `[All channels ▾]` selector alongside the existing folder tabs (Inbox, Agenda, Drafts, GitHub, …).
## 2) Present state
### 2.1 Architecture diagram
```text
apps/mail/app/routes.ts
layout('(routes)/mail/layout.tsx', '/mail', [ index→/mail/inbox, /:folder ])
│
/mail/:folder ──► MailLayout (modules/threads/mail.tsx)
│
┌─────────────────────────────┼──────────────────────────────┐
│ header: SplitInboxTabs │ list: MailList │ full-screen: ActiveViewDisplay
│ (Inbox/Agenda/GitHub tabs) │ (virtua VList of Thread) │ isThreadOpen? → ThreadDisplay
└─────────────────────────────┼──────────────────────────────┘
│
useThreads() → trpc.mail.listThreads (useInfiniteQuery)
│
listThreadsViaMirrorWithFallback (route-list-threads.ts)
│
┌───────────────────┴─────────────────────┐
│ crm_email_threads (Postgres mirror) │ ← EMAIL ONLY
│ + Gmail reconcile/fallback │
└──────────────────────────────────────────┘
OTHER CHANNELS (exist, but NOT in the list):
linkedin_chats / crm_linkedin_messages ─ trpc.linkedin.messaging.{listChats,listChatMessages,contactMessages}
whatsapp_chats / crm_whatsapp_messages ─ trpc.outbound.whatsapp.{inbox,chatMessages}
crm_slack_messages (conversation-attached) ─ full interactive channel already lives in the
conversation view (InboxTab → SlackThreadDisplay + UniversalComposer): reply, reactions,
attachments, read-state all work today — just never surfaced in the mail list.
ALL project into crm_events (event_type email|linkedin|whatsapp|slack|meeting|call), surfaced only
per-conversation, never in the mail list.
```
### 2.2 Step-by-step walkthrough
1. **Route entry** — the `/mail` block in [routes.ts](apps/mail/app/routes.ts) (lines 86–96) wraps `layout('(routes)/mail/layout.tsx')` around `index` (redirects to `/mail/inbox`) and the catch-all `route('/:folder', '(routes)/mail/[folder]/page.tsx')`. There is **no** `:threadId` segment — open state is not in the URL.
- Data: URL `/mail/inbox` → `params.folder = 'inbox'`.
2. **Folder page** — [(routes)/mail/[folder]/page.tsx](apps/mail/app/(routes)/mail/[folder]/page.tsx) validates the slug against `ALLOWED_FOLDERS` + user labels/inboxes, then renders `<MailLayout/>`.
3. **Global nav** — the Mail rail entry lives in [nav-buttons.ts](apps/mail/modules/conversations/components/nav-buttons.ts): `{ id: 'mail', title: 'Mail', icon: Mail, href: '/mail/inbox' }`, rendered by [LeftSidebarContent.tsx](apps/mail/modules/conversations/components/LeftSidebarContent.tsx). Folder links come from [config/navigation.ts](apps/mail/config/navigation.ts) (`/mail/inbox`, `/mail/draft`, `/mail/sent`, …).
4. **Layout shell** — [MailLayout](apps/mail/modules/threads/mail.tsx) draws the header (`SplitInboxTabs` / search / compose) and picks `ScheduledMailView` | `StackedInboxView` | `MailList`.
5. **Folder tabs** — [SplitInboxTabs.tsx](apps/mail/modules/threads/components/SplitInboxTabs.tsx) renders per-inbox tabs from [use-inboxes.ts](apps/mail/modules/threads/hooks/use-inboxes.ts); split queries are Gmail-query strings (`in:archive`, GitHub/Linear/Salesforce templates).
6. **List fetch** — [useThreads()](apps/mail/modules/threads/threadList/hooks/use-threads.ts) (lines 250–279) calls `trpc.mail.listThreads` via `useInfiniteQuery` with `{ q, inboxName, compiledQuery, queryHash, maxResults }`, prefetches `trpc.mail.get` + `trpc.crm.getConversation`, and writes into the Zustand store.
- Data after: `ThreadSummary[]` with preview under `$raw` (`{ conversationId, latestReceivedOn, sender, subject, snippet, labels, hasDraft, messageCount }`).
7. **Backend list** — `mail.listThreads` at [mail.ts:1041](apps/server/src/trpc/routes/mail.ts) → `listThreadsViaMirrorWithFallback` at [route-list-threads.ts](apps/server/src/services/mail/list/route-list-threads.ts) → `listThreadsFromDb` reads `crm_email_threads` ([crm-schema.ts:1919](apps/server/src/db/crm-schema.ts)); a `check-sync.ts` reconcile + Gmail fallback fill gaps. Returns `IGetThreadsResponse { threads, nextPageToken, headChanged }`.
8. **Row render** — [MailList](apps/mail/modules/threads/threadList/components/mail-list.tsx) maps each summary to [Thread](apps/mail/modules/threads/threadList/threadItem/components/thread.tsx): avatar (`BimiAvatar`), participants, subject, `MailLabels`/`RenderLabels`, snippet, date/hover-actions.
9. **Open full-screen** — `handleMailClick` in [mail-list.tsx](apps/mail/modules/threads/threadList/components/mail-list.tsx) (lines 158–204) calls Zustand `selectThreadId(id)` + `setIsThreadOpen(true)`. [MailLayout](apps/mail/modules/threads/mail.tsx) hides the list and renders [ActiveViewDisplay](apps/mail/components/ui/active-view-display.tsx), whose `getActiveView()` switch resolves `'thread' → ThreadDisplay`, `'conversation' → ConversationView`.
10. **Actions** — [useOptimisticActions](apps/mail/modules/threads/rendering/use-optimistic-actions.ts): star/read/important/label via `mail.{toggleStar,markAsRead,modifyLabels}`; move/archive/trash via `mail.modifyLabels`; done via `mail.markDone`; snooze via `mail.setRemind`. All are Gmail-label or email-thread operations.
11. **Multi-channel today** — LinkedIn/WhatsApp/Slack messages project into `crm_events` ([crm-schema.ts:651](apps/server/src/db/crm-schema.ts)) with per-channel detail rows, surfaced **only** per-conversation. Slack is already a **full interactive channel** there: `ConversationView → ConversationBodyLayout → ConversationTabBody → InboxTab` groups events by `threadKey` (`slack:${workspaceId}:${channelId}:${threadTs}`, [inboxThreadKey.ts](apps/mail/modules/conversations/components/timeline/inboxThreadKey.ts)) into [SlackThreadRow](apps/mail/modules/conversations/components/timeline/SlackThreadRow.tsx)s; opening one renders [SlackThreadDisplay](apps/mail/modules/conversations/components/timeline/SlackThreadDisplay.tsx) with the [UniversalComposer](apps/mail/modules/conversations/components/timeline/UniversalComposer.tsx) `SlackPanel` — reply via `integrations.slack.sendMessage`, reactions via `crm.toggleReaction`, attachments via `integrations.slack.uploadFile`, read via `crm.markSlackChannelRead`, sync via `crm.syncSlackChannel`. (The older `ConversationInbox.tsx` / `ConversationBodyContent.tsx` surface was unused and has been deleted.) The unified `crm.getConversation` timeline ([conversations.ts](apps/server/src/services/crm/conversations.ts), joins ~3397–3404) hydrates email/slack/meeting/call/note but **not** `crm_linkedin_messages` / `crm_whatsapp_messages`.
## 3) Designed state
### 3.1 Architecture diagram
```text
apps/mail/app/routes.ts
layout('(routes)/inbox/layout.tsx', '/inbox', [
index → InboxUnibox (all channels), // /inbox
route('/mail' , email-only MailLayout), // /inbox/mail
route('/linkedin', LinkedIn full inbox), // /inbox/linkedin
route('/whatsapp', WhatsApp full inbox), // /inbox/whatsapp
route('/:folder' , unibox filtered) // /inbox/agenda, /inbox/github, …
]) + redirect /mail/* → /inbox/*
│
InboxLayout (modules/inbox/InboxLayout.tsx)
│
┌───────────────────────────────┼──────────────────────────────────┐
│ header: [All channels ▾] + │ list: InboxList │ full-screen: ActiveViewDisplay
│ ChannelSelector + FolderTabs │ (virtua VList of InboxRow) │ thread→ThreadDisplay
│ (unified labels, saved filters)│ │ channel→ChannelThreadView
└───────────────────────────────┼──────────────────────────────────┘
│
useInboxItems() → trpc.inbox.listItems (cursor, time-sorted)
│
assembleInboxFeed (services/inbox/feed.ts) — UNION + merge-sort
┌───────────────┬────────────────┬───────────────┬──────────────────────────┐
│ crm_email_ │ linkedin_chats │ whatsapp_chats │ crm_slack_messages │
│ threads │ │ │ (conversation_id NOT NULL)│
└───────────────┴────────────────┴───────────────┴──────────────────────────┘
│
overlays: cedar_inbox_labels (Gmail-bridged for email) ·
cedar_inbox_item_state (snooze/done/read/star, non-email) ·
draft column on linkedin_chats/whatsapp_chats (one draft per chat; Slack uses its own path)
```
### 3.2 Step-by-step walkthrough
1. **Route entry** — new `/inbox` block in [routes.ts](apps/mail/app/routes.ts) wraps `(routes)/inbox/layout.tsx`; `index` renders the unibox, `/mail` the email-only view, `/linkedin` + `/whatsapp` the per-channel inboxes, `/:folder` a filtered unibox. A `clientLoader` redirect maps every legacy `/mail/*` path to `/inbox/*`.
- Data: `/inbox` → `{ channel: 'all', folder: 'inbox' }`; `/inbox/mail` → `{ channel: 'email' }`.
2. **Nav + shell** — the rail entry in [nav-buttons.ts](apps/mail/modules/conversations/components/nav-buttons.ts) becomes `{ id: 'inbox', title: 'Inbox', icon: Inbox, href: '/inbox' }`; `'inbox'` is added to [shellRoutes.ts](apps/mail/modules/ux/layout/shellRoutes.ts).
3. **Channel + folder header** — new `InboxHeader` renders `ChannelSelector` (`All channels | Email | LinkedIn | WhatsApp | Slack`, default All) beside the folder tabs. Selecting a channel sets `?channel=` and re-queries; folder tabs (Inbox/Agenda/GitHub) become saved filters over the unified feed via `cedar_inbox_labels`.
4. **Feed fetch** — new `useInboxItems()` in [modules/inbox/hooks/use-inbox-items.ts](apps/mail/modules/inbox/hooks/use-inbox-items.ts) calls `trpc.inbox.listItems` (`useInfiniteQuery`, cursor) with `{ channel, folder, labelIds?, query?, cursor?, limit }`.
- Data after: `{ items: InboxItem[], nextCursor?: string }` (see §3.3).
5. **Feed assembly** — new `inbox.listItems` at [apps/server/src/trpc/routes/inbox.ts](apps/server/src/trpc/routes/inbox.ts) → `assembleInboxFeed` at [apps/server/src/services/inbox/feed.ts](apps/server/src/services/inbox/feed.ts). Per requested channel it pulls a time-windowed slice from each source table, normalizes to `InboxItem`, applies label/state/draft overlays, merge-sorts by `sortedAt` desc, and cursors on `(sortedAt, id)`.
- Per-source normalizers (same file): `emailThreadToItem` (wraps existing `crm_email_threads` read), `linkedinChatToItem` (`linkedin_chats` + latest `crm_linkedin_messages`), `whatsappChatToItem` (`whatsapp_chats` + latest `crm_whatsapp_messages`), `slackMessageToItem` (`crm_slack_messages` grouped by `(conversationId, slackChannelId)`, only where `conversation_id IS NOT NULL`).
- **Grain invariant:** every normalizer collapses messages up to their thread/chat — exactly one `InboxItem` per `crm_email_threads.thread_id` / `linkedin_chats.chat_id` / `whatsapp_chats.chat_id` / Slack `threadKey` (latest message → `snippet` + `sortedAt`), **never one row per message** — mirroring `crm_email_threads` being one row per thread. Labels, state, and drafts therefore key on the chat, like email keys on the thread.
- Data after per item:
```json
{ "id": "li:chat_abc", "channel": "linkedin", "sortedAt": "2026-07-26T10:12:00Z",
"counterpart": { "name": "Jane Doe", "avatarUrl": "…", "subtitle": "VP Sales @ Acme" },
"snippet": "Thanks — let's find time next week", "unread": true,
"conversationId": "conv_1", "labelIds": ["lead"], "hasDraft": false,
"ref": { "chatId": "chat_abc", "unipileAccountId": "acc_1" } }
```
6. **Row render** — [InboxList](apps/mail/modules/inbox/components/InboxList.tsx) maps each `InboxItem` to [InboxRow](apps/mail/modules/inbox/components/InboxRow.tsx). Email items delegate to the existing `Thread`; non-email items render the shared layout `[avatar] [name] [channel-icon] [· channel-name for Slack] - [snippet] [labels] [date/hover-actions]` via a `ChannelRow` sub-component sharing `Thread`'s markup/spacing.
7. **Open full-screen** — `handleOpen` in [InboxList](apps/mail/modules/inbox/components/InboxList.tsx) pushes the view-stack by channel: email → `selectThreadId + setIsThreadOpen(true)` (unchanged → `ThreadDisplay`); LinkedIn/WhatsApp/Slack → `onOpenChannel(item)`, lifted to local overlay state in [mail.tsx](apps/mail/modules/threads/mail.tsx) that renders `ChannelThreadView` where `ActiveViewDisplay` sits (folder tabs hidden, exactly like an open email thread).
8. **Channel thread view** — one [ChannelThreadView](apps/mail/modules/inbox/components/ChannelThreadView.tsx) renders **all three** non-email channels, laid out to match the email `ThreadDisplay` (sticky back-gutter, centred `max-w-[75ch]`, sticky header, individual senders with no bubbles/dividers, floating composer). Header **top row** = the linked CRM conversation (or a "No conversation linked, link here" affordance wired to `AttachToConversation`); **second row** (the email-subject slot) = who/what the chat is with — the person for LinkedIn/WhatsApp, the `#channel` for Slack. Messages: LinkedIn/WhatsApp from `trpc.linkedin.messaging.listChatMessages` / `trpc.outbound.whatsapp.chatMessages`; Slack from `trpc.inbox.slackChannelMessages` — a CHANNEL-grain read (owner + workspace + channel, no conversation filter, exactly the grain the row's envelope uses) rendered with resolved senders + `@mentions` via `useSlackPrincipalsForMessages` + `SlackBodyRenderer`. Composer sends via `sendDm` / `whatsapp.sendMessage` / `integrations.slack.sendMessage`. Opening a Slack row runs `crm.catchUpSlackChannel` (once per opened item — the view stays mounted across rows) and then `crm.markSlackChannelRead`, clearing the row's blue dot optimistically via the feed cache.
9. **Unified labels** — new `cedar_inbox_labels` + `cedar_inbox_item_labels` (§3.3). `inbox.setItemLabels` writes: for `channel='email'` it delegates to `mail.modifyLabels` (bridging to the real Gmail label mapped by `gmail_label_id`); for other channels it writes `cedar_inbox_item_labels` directly. Reads merge both so a row shows one label set.
10. **Cross-channel actions** — [use-inbox-item-actions.ts](apps/mail/modules/inbox/hooks/use-inbox-item-actions.ts): email keeps its Gmail routes; non-email snooze/done/star write `cedar_inbox_item_state` via `inbox.setItemState`, optimistically dropping/patching the `inbox.listItems` cache. "Mark done" archives (no hard delete); the server hides done/snoozed items until a newer reply re-surfaces them (`hideDoneOrSnoozed`).
11. **Draft parity** — one draft per chat, stored as a `draft` column on `linkedin_chats` / `whatsapp_chats` (§3.3): loaded with the chat, autosaved by updating the column, and on send cleared. The composer in `ChannelThreadView` sends via `trpc.linkedin.messaging.sendDm` / `trpc.outbound.whatsapp.sendMessage`. `hasDraft` on the `InboxItem` derives from `draft.body` and drives the red "Draft" chip, identical to email; an agent-written draft (`draft.source='agent'`) shows the Agent-Draft chip until edited. (Slack keeps its existing composer draft path — no chat column.)
12. **Timeline hydration** — extend `EventWithTypeData` ([crm.ts:289](apps/server/src/trpc/routes/crm.ts)) + the `getConversation` join ([conversations.ts](apps/server/src/services/crm/conversations.ts)) with `linkedinMessage` / `whatsappMessage`, so an item opened from its conversation shows rich bubbles instead of bare rows.
13. **Open a chat from a task** — a task points at its thread/chat only through `taskActionData` (email `threadId`, LinkedIn/WhatsApp `chatId`+`unipileAccountId`, Slack `channelId`/`threadTs`). New `taskActionDataToInboxRef()` in [modules/inbox/utils/task-open.ts](apps/mail/modules/inbox/utils/task-open.ts) maps it to an `InboxItem.id`/`ref`; the unified `openTask` ([open-task.ts](apps/mail/modules/userTasks/utils/open-task.ts), today email-only) then opens that item through the same `handleItemClick` path — email → `ThreadDisplay`, LinkedIn/WhatsApp → `ChannelThreadView`, Slack → `SlackThreadDisplay`. So a "LinkedIn follow-up" task opens straight into that LinkedIn chat.
### 3.3 Schema
Full schema (new/changed types + tables; `// NEW`/`// CHG` mark additions):
```ts
// ── Normalized feed row (shared type: apps/mail/modules/inbox/types.ts + apps/server) ── // NEW
type InboxChannel = 'email' | 'linkedin' | 'whatsapp' | 'slack';
interface InboxItem {
id: string; // channel-prefixed: "email:<threadId>" | "li:<chatId>" | "wa:<chatId>" | "slack:<convId>:<channelId>"
channel: InboxChannel; // NEW
sortedAt: string; // ISO — latest inbound/outbound message time, the merge-sort key
counterpart: {
name: string;
email?: string; // present for email; optional otherwise
avatarUrl?: string; // BimiAvatar / profile pic / company logo
subtitle?: string; // LinkedIn headline; Slack channel name; else undefined
};
snippet: string; // latest message preview
subject?: string; // email only
unread: boolean;
starred: boolean; // CHG — sourced from Gmail (email) or cedar_inbox_item_state (others)
hasDraft: boolean;
snoozedUntil?: string; // ISO; from setRemind (email) or cedar_inbox_item_state (others)
done: boolean; // archived/done state
conversationId?: string; // crm_conversations.id when attached
aopId?: string; // NEW — derived from crm_conversations.aop_id (email + channels alike); drives the AOP badge + AOP split. Column-based, NOT the Gmail Cedar/aop label.
labelIds: string[]; // unified label ids (see cedar_inbox_labels)
participantCount: number;
ref: EmailRef | LinkedinRef | WhatsappRef | SlackRef; // channel-specific handle for open/actions
}
type EmailRef = { kind: 'email'; threadId: string; connectionId: string };
type LinkedinRef = { kind: 'linkedin'; chatId: string; unipileAccountId: string };
type WhatsappRef = { kind: 'whatsapp'; chatId: string; unipileAccountId: string; phoneE164?: string };
type SlackRef = { kind: 'slack'; conversationId: string; workspaceId: string; slackChannelId: string; slackThreadTs?: string };
// Slack InboxItem.id mirrors the existing threadKey=[redacted] ?? 'root'>"
// ── trpc.inbox.listItems ── // NEW
interface ListInboxItemsInput {
channel: InboxChannel | 'all';
folder?: string; // 'inbox' | 'agenda' | 'github' | … (saved filter over labels/queries)
labelIds?: string[];
query?: string; // free-text search
cursor?: string; // opaque "(sortedAt,id)" cursor
limit?: number; // default 50
}
interface ListInboxItemsResult { items: InboxItem[]; nextCursor?: string }
// ── Task channel extensions (apps/server/src/db/aop-schema.ts + the mirrored slice/crm copies) ── // CHG
type TaskChannel = 'email' | 'slack' | 'multi-action' | 'linkedin' | 'whatsapp'; // CHG — + linkedin, whatsapp
// task_type stays an open string; its Cedar/Task/* projection is Gmail-only. Channel tasks stamp the
// type as a UNIFIED label on the chat (cedar_inbox_item_labels), so the chip shows on the channel row.
type TaskActionData = // discriminated on `channel`; reconcile the 3 drifted copies to this one
| { channel: 'email'; threadId: string; draftId?: string; emailHeaderMessageId?: string }
| { channel: 'slack'; channelId: string; workspaceId?: string; channelName?: string; threadTs?: string; draftId?: string }
| { channel: 'calendar'; eventId: string; calendarId: string; htmlLink?: string; startTime?: string }
| { channel: 'linkedin'; chatId: string; unipileAccountId: string } // NEW — draft lives on the chat row (chatId identifies it)
| { channel: 'whatsapp'; chatId: string; unipileAccountId: string; phoneE164?: string } // NEW
| { channel: 'recommendation'; sourceFieldId?: string }; // server-only variant
```
```sql
-- ── Unified label layer ── -- NEW
CREATE TABLE cedar_inbox_labels (
id text PRIMARY KEY, -- cedar label id (uuid or slug)
organization_id uuid NOT NULL,
user_id text, -- NULL = org-shared label
name text NOT NULL,
color jsonb, -- { backgroundColor, textColor }
gmail_label_id text, -- bridge: when set, email writes go to this Gmail label
is_folder boolean NOT NULL DEFAULT false, -- folder tab vs inline label
filter_query jsonb, -- for is_folder: saved filter (channels, gmail-query, label set)
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (organization_id, user_id, name)
);
CREATE TABLE cedar_inbox_item_labels ( -- non-email membership (email bridges to Gmail) -- NEW
item_id text NOT NULL, -- InboxItem.id
channel text NOT NULL, -- 'linkedin' | 'whatsapp' | 'slack'
label_id text NOT NULL REFERENCES cedar_inbox_labels(id) ON DELETE CASCADE,
user_id text NOT NULL,
PRIMARY KEY (item_id, label_id, user_id)
);
-- ── Per-item state for non-email channels (email keeps Gmail state) ── -- NEW
CREATE TABLE cedar_inbox_item_state (
item_id text NOT NULL,
channel text NOT NULL, -- 'linkedin' | 'whatsapp' | 'slack'
user_id text NOT NULL,
unread boolean NOT NULL DEFAULT true,
starred boolean NOT NULL DEFAULT false,
done boolean NOT NULL DEFAULT false, -- "mark done" (no hard delete)
snoozed_until timestamptz,
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (item_id, user_id)
);
-- ── Channel drafts (LinkedIn / WhatsApp) — one draft per chat, a column on the owned chat row ── -- NEW
-- linkedin_chats / whatsapp_chats are single-seat (one owner_user_id), so no separate table or user_id.
ALTER TABLE linkedin_chats ADD COLUMN draft jsonb; -- { body: text, source: 'user'|'agent', updatedAt: iso } | NULL
ALTER TABLE whatsapp_chats ADD COLUMN draft jsonb; -- same shape; NULL = no draft
-- hasDraft = (draft->>'body') IS NOT NULL AND (draft->>'body') <> ''
-- Slack: no chat column — drafts go through the existing UniversalComposer path (integrations.slack.sendMessage draftId).
```
```ts
// ── Timeline hydration (CHG) — apps/server/src/trpc/routes/crm.ts EventWithTypeData ──
interface EventWithTypeData {
// …existing: emailEvent, slackMessage, meetingEvent, callEvent, noteEvent, customEvent
linkedinMessage?: LinkedinMessageEvent; // NEW — joined from crm_linkedin_messages
whatsappMessage?: WhatsappMessageEvent; // NEW — joined from crm_whatsapp_messages
}
```
Relationship diagram:
```text
┌──────────────────────┐ assembleInboxFeed normalizes each source → InboxItem
│ InboxItem (transient)│ (not a table; built per request in services/inbox/feed.ts)
│ id (channel:key) │
│ channel · sortedAt │
│ ref ▼ contains │
└───────┬──────────────┘
│ ref.kind selects the source row
┌──────┴───────┬─────────────────┬──────────────────┬────────────────────────────┐
▼ ▼ ▼ ▼
crm_email_ linkedin_chats whatsapp_chats crm_slack_messages
threads ─ chat_id PK ─ chat_id PK ─ id PK · slack_channel_id
─ thread_id PK ─ person_id ─ phone_e164 ─ conversation_id ──FK──►┐
─ conversation ──FK──► crm_conversations ◄──FK── conversation_id ────────────┤
_id ─ crm_linkedin_ ─ crm_whatsapp_ │
messages (1:N) messages (1:N) crm_conversations
─ id PK · user_id
OVERLAYS (join on InboxItem.id, user_id): ─ primary_company_id
cedar_inbox_item_labels ──label_id──FK──► cedar_inbox_labels ──gmail_label_id──► (Gmail)
cedar_inbox_item_state (1:1 per item+user: unread/starred/done/snoozed_until)
draft column (1:1 on linkedin_chats/whatsapp_chats: {body,source}; Slack uses its own composer path)
crm_events (event_type email|linkedin|whatsapp|slack|…) ──1:1──► crm_{linkedin,whatsapp}_messages
└─ hydrated into crm.getConversation timeline (EventWithTypeData.linkedinMessage/whatsappMessage)
```
## 4) Implementation phases
### Phase 1 — `/inbox` surface + inbox nav icon ◑ IMPLEMENTED (additive; /mail kept)
**Goal:** Reach the email experience under `/inbox` with an inbox icon; the channel badge switches channels.
- [x] Add an `/inbox` block in [routes.ts](apps/mail/app/routes.ts) reusing the **same** mail chrome — `layout('(routes)/mail/layout.tsx')` over `index` + `route('/mail')` (both → [(routes)/inbox/page.tsx](apps/mail/app/(routes)/inbox/page.tsx), redirect to `/inbox/inbox`) + `route('/:folder')` → the existing `[folder]/page.tsx`. `/mail` kept working (safer than an aggressive rename mid-concurrent-session).
- [x] Change the rail entry in [nav-buttons.ts](apps/mail/modules/conversations/components/nav-buttons.ts) to `{ id: 'mail', title: 'Inbox', icon: Inbox (lucide), href: '/inbox' }`; `isActive('/inbox')` in [LeftSidebarContent.tsx](apps/mail/modules/conversations/components/LeftSidebarContent.tsx) lights on `/inbox` + legacy `/mail`.
- [x] Make [SplitInboxTabs.tsx](apps/mail/modules/threads/components/SplitInboxTabs.tsx) prefix-aware (`base = /inbox | /mail`) so the folder tabs detect + navigate against whichever prefix is active.
- [ ] *(Deferred)* Aggressive `/mail/* → /inbox/*` redirect + repointing every hardcoded `/mail` link (compose, agenda, `LayoutUrlSync`, hamburger folders) — kept both prefixes working for now to avoid disrupting the concurrent session.
**Tests:** manual (routing/UI; no headless surface).
### Phase 2 — Backend normalized feed (`inbox.listItems`, email-only) ✅ DONE (headless-verified)
**Goal:** Introduce the `InboxItem` model + feed route delegating to the existing email mirror; zero behavior change.
- [x] Add the shared `InboxItem` / `InboxChannel` / `*Ref` / `AssembleInboxFeedInput` types in [apps/server/src/services/inbox/types.ts](apps/server/src/services/inbox/types.ts). *(The `apps/mail/modules/inbox/types.ts` frontend mirror is deferred to the frontend phase — no server dependency.)*
- [x] Create the feed, **split for testability**: pure core in [feed-core.ts](apps/server/src/services/inbox/feed-core.ts) (`emailThreadToItem`, `(sortedAt,id)` cursor codec, `mergeAndPage`) — no heavy imports so it unit-tests without the Gmail-driver graph — and the DB-touching [feed.ts](apps/server/src/services/inbox/feed.ts) (`assembleInboxFeed`) wrapping `listThreadsFromDb` ([list-threads-from-db.ts](apps/server/src/services/mail/list/list-threads-from-db.ts)).
- [x] **Email = the "Inbox" SEGMENT, not raw INBOX.** `fetchEmailItems` rebuilds the same query the Inbox tab uses — `label:INBOX -((<github>) OR (<marketing>) OR …)` — via `inboxCompiledQuery`, which loads the user's splits (`listInboxes`), keeps `id!=='important' && enabled && !alsoShowInImportant && query`, strips each split's `-in:CHAT in:inbox` prefix, and subtracts them. Falls back to raw `label:INBOX` if a custom split uses a mirror-unsupported operator (so the unibox never goes empty). Non-default folders keep `in:<folder>`.
- [x] Add `inbox.listItems` query ([apps/server/src/trpc/routes/inbox.ts](apps/server/src/trpc/routes/inbox.ts), `activeConnectionProcedure`) and register `inbox: inboxRouter` in [apps/server/src/trpc/index.ts](apps/server/src/trpc/index.ts).
**Tests:**
- [x] [feed-core.test.ts](apps/server/src/services/inbox/__test__/feed-core.test.ts) — normalization, cursor round-trip, cross-channel merge/paging (no gaps/dupes). **9 passed.**
- [x] [feed-db.test.ts](apps/server/src/services/inbox/__test__/feed-db.test.ts) — **real-DB, read-only, as `<email>`**: `assembleInboxFeed` returns time-sorted email items with gap-free pagination against the live mirror. **1 passed.**
- [x] Headless driver reality: the mail-service graph isn't raw-`tsx` runnable (bundler-dependent — `@barkleapp/css-sanitizer` ESM interop), which is why the repo's own CLIs are thin HTTP clients. The **executable headless proof is `feed-db.test.ts`** run with `DATABASE_URL` (`pnpm vitest run src/services/inbox`), reusing the exact service the tRPC route calls; the `cedar-cli inbox` HTTP verb is deferred to a phase with a running server + `ced_` key.
### Phase 3 — Unibox `InboxList` + `InboxRow` + channel badge ◑ IMPLEMENTED
**Goal:** Render the unified feed through a channel-agnostic list, switchable via the top-row channel badge.
- [x] `useInboxItems()` ([use-inbox-items.ts](apps/mail/modules/inbox/hooks/use-inbox-items.ts)) — infinite query over `trpc.inbox.listItems` (empty-safe: `items` always an array). Types inferred from the server router ([types.ts](apps/mail/modules/inbox/types.ts)).
- [x] [InboxRow](apps/mail/modules/inbox/components/InboxRow.tsx) — self-contained, styled to match `Thread`: `[avatar] [name] [channel icon] [· subtitle] - [snippet] [date]`, unread dot + Draft chip; [InboxList](apps/mail/modules/inbox/components/InboxList.tsx) with loading/error/empty states + "Load more".
- [x] [ChannelSelector](apps/mail/modules/inbox/components/ChannelSelector.tsx) + [channel-icons](apps/mail/modules/inbox/components/channel-icons.tsx) wired into the [mail.tsx](apps/mail/modules/threads/mail.tsx) header (`?channel` query state, defaults to Email). `channel==='email'` → existing `MailList`; otherwise → `InboxList`.
- [ ] *(Deferred)* Delegate email rows to the real `Thread` component + virtua virtualization (the self-contained `InboxRow` is used for all channels for now).
**Tests:** manual (UI). The feed data underneath is headless-proven (Phases 2/5/6/7).
### Phase 4 — Channel full-screen view ◑ IMPLEMENTED (isolated overlay, no core-store change)
**Goal:** Any item opens full-screen; email unchanged, channels get a real chat view.
- [x] [ChannelThreadView](apps/mail/modules/inbox/components/ChannelThreadView.tsx) — one full-screen view for LinkedIn, WhatsApp **and** Slack, matching the email `ThreadDisplay` chrome (back-gutter, `max-w-[75ch]`, floating composer). Header top row = the linked conversation as the **same `FieldBadge` chip** the email `ConversationBadge` uses (`bg-sunken` pill, `ConversationCompanyAvatar` + `company.name || conversation.name` label, click → `openConversationContext`), or an `AttachToConversation` "link here" affordance when none. Second row = counterpart / `#channel`. Messages render Slack-style: per-sender **avatar** (Slack via `useSlackPrincipals` image_192 CDN url; LinkedIn via the counterpart's Unipile attendee `picture_url`, now captured at ingest onto `linkedin_chat_participants.profile_picture_url` and surfaced on `counterpart.avatarUrl` — falling back to `crm_person.profile_picture_url`), name + time header **collapsed on 3-min same-sender continuations**, and a blue "sent" check on our own messages (`last_message_from_self`/`isOutbound`). Slack messages come from the channel-grain `inbox.slackChannelMessages` read (see step 8) rather than the linked conversation's events, so the thread shows exactly the messages the row's envelope is built from — including ones ingested against another conversation, and including channels with no conversation link at all. The panel is `bg-raised`; the thread opens **scrolled to the bottom** and shows only the most recent ~20 messages initially; the composer sits **in document flow** (`shrink-0`, not floating) so you can't scroll past it. Composer uses the shared [SendButton](apps/mail/modules/drafting/components/send-button.tsx). The conversation-view Slack thread ([SlackThreadDisplay](apps/mail/modules/conversations/components/timeline/SlackThreadDisplay.tsx)) got the same `bg-raised` + scroll-to-bottom + central-width treatment. LinkedIn/WhatsApp from `listChatMessages` / `chatMessages`; Slack from `inbox.slackChannelMessages` via `SlackBodyRenderer`; sends via `sendDm` / `whatsapp.sendMessage` / `integrations.slack.sendMessage`.
- [x] Open wiring lifted to [mail.tsx](apps/mail/modules/threads/mail.tsx) local overlay state (`openChannelItem`), **mirrored into `layoutSlice.channelThreadOpen`** so `selectSidebar` flips the right column from the agenda to the **chat** (same 480px as an open email thread) — [computeSidebar](apps/mail/modules/ux/layout/selectSidebar.ts) gained an optional `channelThreadOpen` arg.
- [x] **Slack catch-up on open.** Slack ingest is batched (webhook → `slack_webhook_buffer` → ~30-min cron drain), so the feed can lag. Opening a Slack thread fires the new **agent-free** `crm.catchUpSlackChannel` ([crm.ts](apps/server/src/trpc/routes/crm.ts)) → `backfillSlackChannelHistory` ([slack-sync.ts](apps/server/src/services/integrations/slack/slack-sync.ts)): pulls recent messages straight into `crm_slack_messages` (idempotent upsert, **no LLM workflow** — unlike `syncSlackChannel`), then refetches the conversation. Fires once per open.
- [x] **Slack time fix.** The feed derives the Slack item time from `to_timestamp(slack_message_ts)` (Slack's authoritative unix ts), not the `occurred_at` column — a naive `timestamp without time zone` written as local wall-clock that a raw `postgres.js` read mis-timezones into the future.
**Tests:** manual (UI).
### Phase 5 — LinkedIn into the feed + detail ◑ BACKEND DONE (headless-verified)
**Goal:** LinkedIn chats interleave in the unibox and open full-screen.
- [x] Add `linkedinChatToItem()` (pure, in [feed-core.ts](apps/server/src/services/inbox/feed-core.ts)) + `fetchLinkedinItems()` in [feed.ts](apps/server/src/services/inbox/feed.ts): one item per `linkedin_chats` row (chat grain), counterpart resolved via `crm_person`, snippet/unread from the chat's denormalized `last_message_*`/`unread_count`, **InMail excluded** (`chat_type <> 'inmail'`); unioned into `assembleInboxFeed` (concurrent `Promise.all`) for `channel` `all`/`linkedin`.
- [ ] *(Frontend — deferred)* Add the LinkedIn variant to [InboxRow](apps/mail/modules/inbox/components/InboxRow.tsx): `[avatar] [name] [LinkedIn icon] - [snippet]` reusing `Thread`'s layout/spacing.
- [ ] *(Frontend — deferred)* Fill [ChannelThreadView](apps/mail/modules/inbox/components/ChannelThreadView.tsx) LinkedIn branch by reusing `modules/linkedin/ChatDetail` fed by `trpc.linkedin.messaging.listChatMessages`.
**Tests:**
- [x] Pure normalizer covered in [feed-core.test.ts](apps/server/src/services/inbox/__test__/feed-core.test.ts) (id/grain/unread/fallback-name). Real-DB proof in [feed-db.test.ts](apps/server/src/services/inbox/__test__/feed-db.test.ts): LinkedIn-only feed returns only `li:` chats (no InMail); paging into the LinkedIn era shows LinkedIn **interleaved with email**, one globally time-sorted stream. **13 passed** against jesse's live seat (113 chats).
- [x] `pnpm vitest run src/services/inbox` (with `DATABASE_URL`).
### Phase 6 — WhatsApp into the feed + detail ◑ BACKEND DONE (headless-verified)
**Goal:** WhatsApp chats interleave in the unibox and open full-screen.
- [x] WhatsApp uses a **DB mirror**, exactly like LinkedIn — *unipile → mirror → surface*. New `whatsapp.messaging.syncWhatsappChatsFromUnipile` ([whatsapp/index.ts](apps/server/src/services/integrations/whatsapp/index.ts)) pulls the rep's live Unipile chat list (reusing `listInbox`'s `listAllChats` + attendee fetch) and upserts `whatsapp_chats` (+ `whatsapp_chat_participants` for the name-join), modelled on `ingestTracked`'s out-of-order upsert. **Consent gate preserved:** an envelope (name/time/unread) is mirrored for every chat — the same envelope `listInbox` exposes — but `last_message_snippet` is written only for consent-tracked chats, and message BODIES (`crm_whatsapp_messages`) are never written here. `fetchWhatsappItems` then reads the mirror via `whatsappChatToItem` (SQL, with `hideDoneOrSnoozed` + the participant/`crm_person` name join), identical to `fetchLinkedinItems`. Triggered by `outbound.whatsapp.syncChats` + the on-load hook [use-whatsapp-sync-on-load.ts](apps/mail/modules/inbox/hooks/use-whatsapp-sync-on-load.ts) (mirrors LinkedIn's sync-on-load). Unioned into `assembleInboxFeed` for `all`/`whatsapp`.
- [ ] *(Frontend — deferred)* Add the WhatsApp `InboxRow` variant (`[avatar] [name] [WhatsApp icon] - [snippet]`).
- [ ] *(Frontend — deferred)* Fill the [ChannelThreadView](apps/mail/modules/inbox/components/ChannelThreadView.tsx) WhatsApp branch reusing `modules/whatsapp/ChatDetail` fed by `trpc.outbound.whatsapp.chatMessages`.
**Tests:**
- [x] Pure normalizer in [feed-core.test.ts](apps/server/src/services/inbox/__test__/feed-core.test.ts) (wa: id, phone fallback/ref, person-name preference). Real-DB channel filter in [feed-db.test.ts](apps/server/src/services/inbox/__test__/feed-db.test.ts): `channel:'whatsapp'` returns only `wa:` items (jesse's seat has 0 chats — empty tolerated). **19 passed.**
### Phase 7 — Slack (conversation-attached only) into the feed, reusing the existing full channel ◑ BACKEND DONE (headless-verified)
**Goal:** Only Slack channels attached to a conversation surface (never the firehose) — and opening one drops straight into the existing full interactive Slack channel (reply/reactions/attachments/read), which already works.
- [x] **Slack has a thread-index table now** — `slack_threads` ([migration](apps/server/src/db/migrations/slack_threads.sql), schema in [crm-schema.ts](apps/server/src/db/crm-schema.ts)), the analog of `crm_email_threads` / `linkedin_chats`: one row per `(owner, workspace, channel, thread)` carrying the newest-message envelope (`last_message_ts`/`_snippet`/`_user_name`/`_slack_user_id`, `is_read`, `message_count`, `channel_name`, `conversation_id`). Populated at ingest by an out-of-order-guarded upsert in `ingestSlackMessages` ([slack-events.ts](apps/server/src/services/crm/slack-events.ts)) — one upsert per new message, keyed by `${userId}:${ws}:${channel}:${threadTs ?? 'root'}` — and backfilled from existing `crm_slack_messages` via pure SQL. `fetchSlackItems` ([feed.ts](apps/server/src/services/inbox/feed.ts)) reads `slack_threads` **directly** (no more DISTINCT-ON scan of every message; time from `to_timestamp(last_message_ts)`), and message BODIES load lazily only on open — exactly like email (`crm_email_threads` + lazy `mail.get`). The feed then **aggregates the index to CHANNEL grain** (`GROUP BY workspace, channel`): one inbox row per Slack channel (the sales unit), collapsing its threads — and any split across multiple linked conversations — into a single row (id `slack:${ws}:${channel}`, newest-message envelope, unread when the newest message isn't the owner's own). This took the operator from a 1934-row per-message scan → 89 thread rows → **33 channel rows** (e.g. #cedar-concentrate's 7 thread rows across 2 conversations became 1). Opening the row shows every message in the channel (`ChannelThreadView` matches ws+channel, ignoring thread_ts). The thread index has since been deleted — the channel container is the only Slack read-state grain, and `markSlackChannelRead` clears it.
- [x] **Slack row avatars** — `slack_threads.last_message_slack_user_id` flows onto `SlackRef.slackUserId`, and [use-slack-item-avatars.ts](apps/mail/modules/inbox/hooks/use-slack-item-avatars.ts) resolves the live Slack CDN avatar (batched `integrations.slack.resolveSlackPrincipals` per workspace, same source the thread view uses), passed to `InboxRow` via `avatarUrl`. LinkedIn rows use `counterpart.avatarUrl` (participant `profile_picture_url`). (never the firehose — the conversation link lives on the event, not on `crm_slack_messages`). `SlackRef` carries `conversationId`/`workspaceId`/`slackChannelId`/`slackThreadTs`; unioned in for `all`/`slack`. Channel name resolves from the conversation's `integration_metadata`; when the link never resolved a human name it stores the **id as the name**, so `resolvedChannelName` drops an id-shaped/id-equal value rather than render `#C0BEC8MHPB5` (a data issue on the "Unknown" catch-all conversation, not a code bug — the real fix is re-resolving that channel's name in the Slack sync).
- [ ] *(Frontend — deferred)* Add the Slack `InboxRow` variant mirroring [SlackThreadRow](apps/mail/modules/conversations/components/timeline/SlackThreadRow.tsx) (`counterpart.subtitle` = `#channel`), and the linked-but-unsynced `EmptySlackChannelRow` from `integrationMetadata`.
- [ ] *(Frontend — deferred)* Route Slack row clicks to the existing overlay — `openSlackThread(item.id)` ([use-slack-thread-overlay.ts](apps/mail/modules/conversations/components/timeline/use-slack-thread-overlay.ts)) → [SlackThreadDisplay](apps/mail/modules/conversations/components/timeline/SlackThreadDisplay.tsx) + [UniversalComposer](apps/mail/modules/conversations/components/timeline/UniversalComposer.tsx). **No new Slack detail/composer/reply code.**
**Tests:**
- [x] Pure normalizer in [feed-core.test.ts](apps/server/src/services/inbox/__test__/feed-core.test.ts) (threadKey id, `#channel` subtitle, unread from `is_read`). Real-DB proof in [feed-db.test.ts](apps/server/src/services/inbox/__test__/feed-db.test.ts): `channel:'slack'` returns only `slack:` items, every one conversation-attached, unique per threadKey (jesse: 1917 messages → deduped threads). **19 passed.**
- [ ] *(Frontend — deferred)* `open-slack.test.tsx`: clicking a Slack row opens `SlackThreadDisplay`.
### Phase 8 — Unified label layer + Gmail bridge (same vocabulary as email)
**Goal:** One label set per row across channels, carrying the **same vocabulary email uses** — AOP by default, plus the Agent-Draft chip, Cedar task-type chips, and user labels — rendered with the identical helpers; email writes bridge to real Gmail labels.
- [x] `cedar_inbox_labels` + `cedar_inbox_item_labels` tables ([migration](apps/server/src/db/migrations/inbox_labels.sql), **applied to the shared DB**) + [labels.ts](apps/server/src/services/inbox/labels.ts) (`createInboxLabel` / `listInboxLabels` / `setItemLabels` / `getItemLabelIds`).
- [x] `inbox.listLabels` / `inbox.createLabel` / `inbox.setItemLabels` ([inbox.ts](apps/server/src/trpc/routes/inbox.ts)); `assembleInboxFeed` overlays non-email `labelIds` via `attachItemLabels`. Email items already carry their Gmail label ids from the mirror read, so every row ends with one merged label set. **Email membership stays in Gmail** (`mail.modifyLabels`) — `cedar_inbox_item_labels` is non-email only; `gmail_label_id` is the display-bridge column.
- [ ] *(Frontend — deferred)* Ensure the channel vocabulary includes the same Cedar labels email carries (Agent-Draft, `Cedar/Task/*`) — applied via `cedar_inbox_item_labels` by the agent/draft pipeline; render the identical chip set on [InboxRow](apps/mail/modules/inbox/components/InboxRow.tsx) reusing `Thread`'s logic (AOP badge + `RenderLabels` + Agent-Draft + task-type chips) + a labels popover.
**Tests:**
- [x] Real-DB in [feed-db.test.ts](apps/server/src/services/inbox/__test__/feed-db.test.ts): `createInboxLabel` + `setItemLabels` on a live LinkedIn item → feed shows the labelId merged into `item.labelIds`; membership + label deleted in `finally`. **24 passed.**
### Phase 9 — Unified top row: channel selector + folder tabs + column-based AOP
**Goal:** `[All channels ▾]` selector beside folder tabs, with AOP splitting unified across channels via the `crm_conversations.aop_id` **column** (not the Gmail `Cedar/aop/…` label).
- [ ] Add [ChannelSelector](apps/mail/modules/inbox/components/ChannelSelector.tsx) (All/Email/LinkedIn/WhatsApp/Slack, default All) that sets `?channel=` and re-queries `useInboxItems`.
- [ ] Add [InboxHeader](apps/mail/modules/inbox/components/InboxHeader.tsx) composing `ChannelSelector` + the folder tabs; back folders with `cedar_inbox_labels.is_folder` saved filters (seed Inbox/Agenda/GitHub from the current `SUPERHUMAN_TEMPLATES`).
- [ ] Make `/inbox/:folder` resolve a saved filter and pass `folder`/`labelIds` into `inbox.listItems`.
- [x] In `assembleInboxFeed` ([feed.ts](apps/server/src/services/inbox/feed.ts)) derive `aopId` per item from `conversationId → crm_conversations.aop_id` (email + channels uniformly, via a single batched `attachAopIds` overlay). Verified real-DB: every email item that carries an `aopId` matches its conversation's real `aop_id` column. *(The Gmail `Cedar/aop/…` label is only a mirror of this column; `syncAopLabel` keeps stamping it for Gmail-side parity, but the feed no longer depends on it — sidestepping the name-vs-id token mismatch between [syncAopLabel](apps/server/src/services/mail/labels/labels.ts) and [InboxNavWidget.tsx](apps/mail/components/mail/InboxNavWidget.tsx).)*
- [ ] *(Deferred)* Server-side **AOP filter push-down**: filtering the feed by `aopId` correctly requires pushing the predicate into each source query (a post-collection filter breaks cursor pagination for sparse AOPs). Deferred to a follow-up; the derivation above already carries `aopId` for client-side filtering + the badge.
- [ ] *(Deferred)* **Assign AOP to channel conversations.** `promoteChatOnMessageInner` ([channel-deal-binding.ts](apps/server/src/services/crm/channel-deal-binding.ts)) / `attachChannelMessagesToConversation` ([channel-message-events.ts](apps/server/src/services/crm/channel-message-events.ts)) leave `aop_id` null. Reuse the email AOP-resolution in [services/aop](apps/server/src/services/aop/utils.ts) **without** the Gmail-label stamp. *(Currently moot for jesse's seat — his 113 LinkedIn chats are not conversation-bound at all yet, so there is nothing to assign; this unblocks once the intent-gated binding runs.)*
**Note — no email AI-labeling pass on channels.** Do NOT run [ai-labeling.ts](apps/server/src/services/mail/labels/ai-labeling.ts) / [inbox-classifier.ts](apps/server/src/services/mail/labels/inbox-classifier.ts) on channel messages: they are email-shaped (deterministic from/subject rules writing `Cedar/AI/*` mirror labels, keyed on Gmail threadIds). Channel sales-classification comes from the intent-gate (interested vs no-thanks/OOO) + conversation binding; channel noise is filtered upstream (LinkedIn InMail quarantine, WhatsApp consent gate, cold non-reciprocal DMs staying on Layer 1). Spam stays an ingest-gate concern (Gmail-native for email), optionally a manual "mark spam" via the Cedar label/state layer.
**Tests:**
- [ ] Add [apps/mail/modules/inbox/__tests__/channel-filter.test.tsx](apps/mail/modules/inbox/__tests__/channel-filter.test.tsx): selecting "LinkedIn" requeries with `channel:'linkedin'`; a folder tab applies its filter.
- [ ] Add [apps/server/src/services/inbox/__test__/feed-aop.test.ts](apps/server/src/services/inbox/__test__/feed-aop.test.ts): an item's `aopId` is derived from its conversation's `aop_id` for both an email and a LinkedIn item; the AOP filter returns both.
- [ ] Add [apps/server/src/services/crm/__test__/channel-conversation-aop.test.ts](apps/server/src/services/crm/__test__/channel-conversation-aop.test.ts): a channel-created conversation receives an `aop_id`; no Gmail label write is attempted.
- [ ] `pnpm --filter @cedar/mail test apps/mail/modules/inbox/__tests__/channel-filter.test.tsx && pnpm --filter @cedar/server test src/services/inbox/__test__/feed-aop.test.ts src/services/crm/__test__/channel-conversation-aop.test.ts`
### Phase 10 — Cross-channel actions (snooze, done, read/unread, star) ✅ IMPLEMENTED
**Goal:** Non-email rows get the same actions; "mark done" (no delete) everywhere, and done/snooze re-surface on the next reply.
- [x] `cedar_inbox_item_state` table ([migration](apps/server/src/db/migrations/inbox_item_state.sql), **applied to the shared DB**) + [item-state.ts](apps/server/src/services/inbox/item-state.ts) (`setItemState` upsert + `getItemStates`) + `inbox.setItemState` mutation ([inbox.ts](apps/server/src/trpc/routes/inbox.ts)); `assembleInboxFeed` overlays state via `attachItemStates` for non-email items. **`unread` is tri-state (NULL = use source)** so a star/done write never clobbers the natural unread; `starred`/`done` default false = the natural state.
- [x] **Done/snooze exclusion + reappear-on-reply** — each channel source in [feed.ts](apps/server/src/services/inbox/feed.ts) applies `hideDoneOrSnoozed(userId, itemId, lastActivity)`: a `NOT EXISTS` against `cedar_inbox_item_state` that hides the item when `done` or a live `snoozed_until`, **unless the chat's last-message time is newer than the action** (`updated_at`), so any reply auto-resurfaces it (and auto-unsnoozes).
- [x] Frontend optimistic actions — [use-inbox-item-actions.ts](apps/mail/modules/inbox/hooks/use-inbox-item-actions.ts): `markDone`/`snooze` drop the row from every `inbox.listItems` page immediately (server hides it too); `toggleStar` flips in place; all revert on error. Used by both the row hover-actions and `ChannelThreadView`'s header.
- [x] Hover actions on non-email [InboxRow](apps/mail/modules/inbox/components/InboxRow.tsx) (star / snooze / done, revealed on row hover in the date slot; no Trash — "Done" archives). Email rows keep their own Gmail-backed path.
**Tests:**
- [x] Real-DB round-trip in [feed-db.test.ts](apps/server/src/services/inbox/__test__/feed-db.test.ts): `setItemState({starred})` on a live LinkedIn item → feed overlay reflects `starred:true`, **unread untouched**; and a `done` item is **hidden**, then **reappears** once a later reply post-dates the action. State row cleaned up in `finally`. **26 passed.**
### Phase 11 — Draft parity for LinkedIn & WhatsApp (one draft per chat) ◑ BACKEND DONE (headless-verified)
**Goal:** A composer with a single persisted draft (user or agent) per chat, stored as a column on the chat row, matching email.
- [x] Add a `draft jsonb` column (`ChannelChatDraft = { body, source: 'user'|'agent', updatedAt }`, NULL = none) to `linkedin_chats` + `whatsapp_chats` — drizzle ([outbound-schema.ts](apps/server/src/db/outbound-schema.ts)), idempotent CREATE parity ([outbound-ddl.sql](apps/server/src/db/outbound-ddl.sql)), and migration ([inbox_channel_drafts.sql](apps/server/src/db/migrations/inbox_channel_drafts.sql)) **applied to the shared DB** via [apply-inbox-sql.mjs](apps/server/src/db/migrations/scripts/apply-inbox-sql.mjs). No Slack column — Slack uses the existing composer draft path.
- [x] Add `inbox.saveChannelDraft` / `inbox.clearChannelDraft` ([inbox.ts](apps/server/src/trpc/routes/inbox.ts) → [channel-drafts.ts](apps/server/src/services/inbox/channel-drafts.ts), owner-scoped writes); `assembleInboxFeed` selects `draft` and derives `hasDraft` via `draftHasBody` (non-empty body).
- [ ] *(Frontend — deferred)* Reply composer in [ChannelThreadView](apps/mail/modules/inbox/components/ChannelThreadView.tsx) (load `chat.draft`, debounce-autosave, send via `sendDm`/`sendMessage` then clear).
- [ ] *(Frontend — deferred)* "Draft" / "Agent Draft" chip on channel [InboxRow](apps/mail/modules/inbox/components/InboxRow.tsx)s from `draft.source`.
- [x] Agent drafts land in the same column (`source:'agent'`) via `saveChannelDraft(..., 'agent')` — no separate storage.
**Tests:**
- [x] Pure `draftHasBody`/normalizer coverage in [feed-core.test.ts](apps/server/src/services/inbox/__test__/feed-core.test.ts). Real-DB round-trip in [feed-db.test.ts](apps/server/src/services/inbox/__test__/feed-db.test.ts): `saveChannelDraft` on a live LinkedIn chat → feed shows `hasDraft:true`; `clearChannelDraft` → `false`; **draft reset in `finally` (no residue on jesse's data)**. **22 passed.**
### Phase 12 — Full channel inboxes under `/inbox/linkedin`, `/inbox/whatsapp`
**Goal:** Dedicated per-channel inboxes (no Slack), reusing the unibox with a pinned channel.
- [ ] Add `route('/linkedin')` + `route('/whatsapp')` under the `/inbox` block in [routes.ts](apps/mail/app/routes.ts), rendering `InboxUnibox` with a pinned `channel`; redirect legacy `/linkedin/inbox` + `/whatsapp/inbox` here.
- [ ] Fold the existing [modules/linkedin/LinkedInInbox.tsx](apps/mail/modules/linkedin/LinkedInInbox.tsx) / [modules/whatsapp/WhatsAppInbox.tsx](apps/mail/modules/whatsapp/WhatsAppInbox.tsx) chat-detail/compose panes into `ChannelThreadView` and delete the now-duplicated inbox shells.
- [ ] Extend the timeline: add `linkedinMessage`/`whatsappMessage` to `EventWithTypeData` ([crm.ts:289](apps/server/src/trpc/routes/crm.ts)) and the `getConversation` join ([conversations.ts](apps/server/src/services/crm/conversations.ts)) so opened items show rich bubbles.
**Tests:**
- [ ] Add [apps/mail/modules/inbox/__tests__/channel-inbox.test.tsx](apps/mail/modules/inbox/__tests__/channel-inbox.test.tsx): `/inbox/linkedin` shows only LinkedIn; legacy routes redirect.
- [ ] Add [apps/server/src/services/crm/__test__/timeline-channels.test.ts](apps/server/src/services/crm/__test__/timeline-channels.test.ts): `getConversation` hydrates LinkedIn/WhatsApp message bodies.
- [ ] `pnpm --filter @cedar/mail test apps/mail/modules/inbox/__tests__/channel-inbox.test.tsx && pnpm --filter @cedar/server test src/services/crm/__test__/timeline-channels.test.ts`
### Phase 13 — Channel-aware tasks (LinkedIn / WhatsApp / Slack) ◑ BACKEND DONE (headless-verified)
**Goal:** A task can belong to any channel and its type projects to the same unified label the row shows.
- [x] Extend `TASK_CHANNELS` + the `user_tasks.task_channel` CHECK constraint in [aop-schema.ts](apps/server/src/db/aop-schema.ts) with `'linkedin'`/`'whatsapp'`; live CHECK widened via [migration](apps/server/src/db/migrations/inbox_task_channels.sql) using DROP + ADD `NOT VALID` + `VALIDATE` (tiny write-block window on the live `user_tasks`).
- [x] Add `Linkedin`/`Whatsapp` variants to the `TaskActionData` union in [aop-schema.ts](apps/server/src/db/aop-schema.ts), and **reconcile the two drifted frontend copies** ([userTasksSlice.ts](apps/mail/modules/userTasks/slice/userTasksSlice.ts), [crm/types/index.ts](apps/mail/modules/crm/types/index.ts)) to match (verified no exhaustive-`never` switch breaks; unhandled channels fall through gracefully until the Phase 14 open-path).
- [x] `createTask` already accepts the new channels (uses `z.enum(TASK_CHANNELS)`); added `taskChannel` + `taskType[]` filters to `listUserTasks` ([user-tasks.ts](apps/server/src/trpc/routes/user-tasks.ts)). Agent execution paths write `taskActionData` for channel tasks.
- [ ] *(Deferred)* Extend `getTaskTypeLabel` to stamp a channel task's type as a unified `cedar_inbox_item_labels` label (ties Phase 8 ↔ tasks); needs the task-creation/agent path to call `setItemLabels`.
**Tests:**
- [x] Real-DB in [task-channels.test.ts](apps/server/src/services/inbox/__test__/task-channels.test.ts): inserting a `linkedin` task with linkedin `task_action_data` **succeeds only because the CHECK now accepts it**; the `taskChannel='linkedin'` filter isolates it from an email task; both deleted in `finally`. **1 passed.**
### Phase 14 — Open-to-chat from any task
**Goal:** Clicking any task (list, kanban, or agenda) opens directly to the right thread/chat in the inbox.
- [ ] Add `taskActionDataToInboxRef(actionData)` in [modules/inbox/utils/task-open.ts](apps/mail/modules/inbox/utils/task-open.ts) mapping each channel's `taskActionData` → `InboxItem.id`/`ref`.
- [ ] Unify the email-only [open-task.ts](apps/mail/modules/userTasks/utils/open-task.ts): email → thread panel (as today); LinkedIn/WhatsApp → `openChannelThread`; Slack → load conversation + `openSlackThread`; fall back to `TaskOutputPanel` only when there is no `taskActionData`.
- [ ] Add `linkedin` + `whatsapp` branches to the agenda open-path: `buildArtifactOpenAction` ([agenda-artifact-actions.ts](apps/mail/modules/agentCanvas/utils/agenda-artifact-actions.ts)), `deriveAgendaRightSlot` ([agenda-right-slot-state.ts](apps/mail/modules/agentCanvas/utils/agenda-right-slot-state.ts)), `AgendaTaskNode.handleOpenArtifact` ([AgendaTaskNode.tsx](apps/mail/modules/agentCanvas/extensions/AgendaTaskNode.tsx)), and the task `ChannelIcon` — opening in the new inbox instead of deep-linking out.
**Tests:**
- [ ] Add [apps/mail/modules/inbox/__tests__/open-task-channels.test.tsx](apps/mail/modules/inbox/__tests__/open-task-channels.test.tsx): a LinkedIn task opens `ChannelThreadView` for its chat; a WhatsApp task opens its chat; a Slack task opens `SlackThreadDisplay`; an email task still opens `ThreadDisplay`.
- [ ] `pnpm --filter @cedar/mail test apps/mail/modules/inbox/__tests__/open-task-channels.test.tsx`