# Inbox & Channels

Conversations, messages, team and personal channels, and response templates


Work the unified inbox: conversations and messages scoped by channel membership, team and personal channels, response templates, and conversation-to-card conversion.

Backend modules: `backend/plugins/frontline_api/src/modules/{inbox,channel,response}`. Frontend: `frontend/plugins/frontline_ui/src/modules/{inbox,channels,responseTemplate}` and the `/frontline/inbox` route.

## Concepts

### Conversations and messages

A `Conversation` belongs to one `Integration` and one customer, carries `status` (`new`, `open`, `closed`, `engageVisitorAuto`), `assignedUserId`, `participatedUserIds`, `readUserIds`, `tagIds`, `hasSurvey`, and `automatedReplyControl` (`status`, `reason`, `pausedUntil`). A `ConversationMessage` carries `content`, `attachments`, `internal` (agent-only note), `fromBot`, `botData`, `mailData` for the mail channel, and `extraData` (survey snapshots, Discord polls).

Customer messages land through `receiveInboxMessage` (`modules/inbox/receiveMessage.ts`), which creates the conversation and message, then publishes `conversationClientMessageInserted` to every member of the conversation's channel. One GraphQL subscription covers all of a user's channels.

### Membership-scoped visibility

An agent only sees conversations whose integration sits on a channel they belong to. `Builder` in [`src/conversationQueryBuilder.ts`](https://github.com/erxes/erxes/blob/f91e8c6f4714c910a32c6b6e14fe085f6435c89e/backend/plugins/frontline_api/src/conversationQueryBuilder.ts) maps the caller's `ChannelMembers` rows to integration ids and intersects them with `channelId`, `brandId`, and `integrationId` filters. A `system`-role user bypasses the membership filter. Channels the user does not belong to return an empty `integrationId.$in`; there is no error, just no rows.

### Team vs personal channels

| | Team channel | Personal channel |
| --- | --- | --- |
| `scope` | `team` (default; missing `scope` on legacy records means `team`) | `personal` |
| Members | Many, via `channelAddMembers` | Exactly one: the owner, as `admin` |
| Provisioning | `channelAdd` | Lazy: created the first time `getPersonalChannel` runs or an integration is created without a `channelId` |
| Integrations | Any kind | Any kind; `integrationsCreateExternalIntegration` without `channelId` attaches to the caller's personal channel |

Members carry a `role` of `admin`, `lead`, or `member` (`ChannelMembers`, unique on `(channelId, memberId)`). A partial unique index on `Channels` allows at most one personal channel per `createdBy`, and removing or demoting the last `admin` of a channel is refused.

### Response templates

Per-channel canned replies (`ResponseTemplates`: `name`, `content`, `channelId`, `files`). `conversationMessageAdd` accepts a `responseTemplateId` to send one.

## Conversation filters and counts

Every conversation list/count query accepts the same filter params: `channelId`, `integrationId`, `integrationType`, `status`, `brandId`, `tag`, `customerId`, `segment`, `searchValue`, `startDate`/`endDate`, and the string flags `unassigned`, `participating`, `mentioned`, `unread`, `starred`, `awaitingResponse`, `withSurvey`, `automationStatus`.

- `withSurvey: "true"` keeps only conversations carrying a survey (`hasSurvey`). An `integrationType`-scoped list **without** `withSurvey` excludes them instead, so the inbox's `Messenger` row and `Surveys` row are disjoint.
- `automationStatus` maps `standby` → `handoff_requested` and `handoff` → `human_active` on `automatedReplyControl.status`; `responded` matches any conversation automation touched.
- `awaitingResponse: "true"` filters to `isCustomerRespondedLast: true`.
- `searchValue` matches customers via Core (name/email/phone; ≥4 digits triggers phone search, which also scans `CallCdrs` by `src`/`dst`) plus conversation `content`.

Two builders exist. The Mongo `Builder` (`src/conversationQueryBuilder.ts`) backs `conversations`, `conversationsTotalCount`, `conversationsGetLast`, `conversationsTotalUnreadCount`, and the scalar buckets of `conversationCounts`. The Elasticsearch-backed `CommonBuilder` (`modules/inbox/conversationUtils.ts`) serves the `conversationCounts(only: "byChannels"|"byIntegrationTypes"|"byTags"|"byIntegrations")` buckets. `CommonBuilder.runQueries` currently returns `0` (the `conversations` Elasticsearch index is not populated), so the sidebar reads per-kind counts from `integrationsGetUsedTypesByChannel` instead.

## Queries and mutations

Schema: [`modules/inbox/graphql/schemas/conversation.ts`](https://github.com/erxes/erxes/blob/f91e8c6f4714c910a32c6b6e14fe085f6435c89e/backend/plugins/frontline_api/src/modules/inbox/graphql/schemas/conversation.ts).

| Operation | Kind | Purpose |
| --- | --- | --- |
| `conversations(filter…)` | query | Cursor-paginated inbox list |
| `conversationDetail(_id)` / `conversationMessages(conversationId, …)` | query | Detail and message history |
| `conversationCounts(only, …)` / `conversationsTotalCount` / `conversationsTotalUnreadCount` | query | Sidebar counts |
| `conversationMessageAdd(conversationId, content, internal, attachments, poll, replyToMessageId, …)` | mutation | Agent reply, internal note, or native poll |
| `conversationMessageEdit`, `conversationMarkAsRead` | mutation | Edit / mark read |
| `conversationsAssign` / `conversationsUnassign` / `conversationsChangeStatus` / `conversationsResolve` | mutation | Triage actions |
| `conversationSetAutomatedReplyControl(_id, status, reason, pausedUntil)` | mutation | Pause/hand off automation on a conversation |
| `conversationConvertToCard(_id, type, itemName, stageId, …)` | mutation | Convert to `ticket`, `deal`, or `task` |
| `conversationConvertedItems(_id)` | query | What a conversation was already converted into |
| `getMyChannels` / `getChannels` / `getChannel(_id)` / `getPersonalChannel` / `getChannelMembers` | query | Channel reads |
| `channelAdd` / `channelUpdate` / `channelRemove` / `channelAddMembers` / `channelRemoveMember(s)` / `channelUpdateMember` | mutation | Channel management |
| `responseTemplates(filter)` / `responseTemplatesAdd` / `responseTemplatesEdit` / `responseTemplatesRemove` | query/mutation | Response templates |
| `integrations` / `integrationsGetUsedTypes` / `integrationsGetUsedTypesByChannel` / `integrationDetail` | query | Integration list and sidebar kinds |
| `conversationClientMessageInserted(userId)` | subscription | Live customer messages for the whole inbox |

## Convert a conversation to a ticket, deal, or task

`conversationConvertToCard(_id, type, …)` dispatches to a per-kind handler in `modules/inbox/services/conversationConvertTargets.ts`:

| `type` | Created by | Permission checked | URL returned by `conversationConvertedItems` |
| --- | --- | --- | --- |
| `ticket` | `Ticket.addTicket` in this plugin | `createTicket` + pipeline access | `/frontline/tickets?ticketId=<id>` |
| `deal` | `sales` `deal.createItem` over tRPC | `dealsAdd` | `/sales/deals?boardId=…&pipelineId=…&salesItemId=<id>` |
| `task` | `operation` `task.createFromSource` over tRPC | `taskCreate` | `/operation/tasks/<id>` |

The new item is related to the conversation (and its customer for tickets/tasks) through Core relations. A second item of the same kind for one conversation is refused; `stageId` is required (a status id for tickets/tasks, a stage id for deals).

```graphql
mutation {
  conversationConvertToCard(
    _id: "conv_abc123"
    type: "ticket"
    itemName: "Billing issue from messenger"
    stageId: "status_xyz"
    assignedUserIds: ["user_1"]
    tagIds: ["tag_9"]
  )
}
```

## Widget and client portal resolvers

The unauthenticated messenger widget talks to the `widgets*` operations (`widgetsMessengerConnect`, `widgetsInsertMessage`, `widgetsConversations`, `widgetsConversationDetail`, `widgetsMessages`, `widgetsTotalUnreadCount`, `widgetsMessengerSupporters`, `widgetsGetEngageMessage`, `widgetsSendTypingInfo`, `widgetsReadConversationMessages`) and the ticket-widget operations (`widgetTicketCreated`, `widgetTicketCheckProgress`, `widgetTicketComments`, `widgetTicketActivityLogs`, `widgetTicketsByCustomer`). Signed-in portal users use the `cp*` conversation operations (`cpConnect`, `cpConversations`, `cpInsertMessage`, `cpReadConversationMessages`). For installation, see [Messenger Widget](https://erxes.io/docs/erxes-messenger).

## tRPC procedures

`inbox.*` router highlights ([`src/modules/inbox/trpc/inbox.ts`](https://github.com/erxes/erxes/blob/f91e8c6f4714c910a32c6b6e14fe085f6435c89e/backend/plugins/frontline_api/src/modules/inbox/trpc/inbox.ts)):

| Procedure | Kind | Purpose |
| --- | --- | --- |
| `inbox.createConversationAndMessage` | mutation | Create a conversation and its first message (used by survey submit and other services) |
| `inbox.createOnlyMessage` | mutation | Append a message to an existing conversation |
| `inbox.integrations.receive` | mutation | Inbound webhook entrypoint → `receiveInboxMessage` |
| `inbox.integrations.remove` | mutation | Remove an integration plus its conversations and messages |
| `inbox.conversationClientMessageInserted` | mutation | Publish the live-message subscription event |
| `inbox.getConversationsList` | query | Cursor-paginated conversation list |
| `inbox.conversations.find` / `findOne` / `count` | query | Conversation reads |
| `inbox.conversations.changeStatus` | query | Set `status` (`new`/`open`/`closed`/`resolved`) |
| `inbox.conversationMessages.find` / `findOne` | query | Message reads |
| `inbox.integrations.find` / `findOne` / `count` | query | Integration reads |
| `inbox.getIntegrationKinds` | query | Kind → label map (`messenger`, `lead`, `webhook`, `mail`, `facebook-messenger`, …) |
| `inbox.channels.find`, `inbox.getConversations`, `inbox.removeCustomersConversations`, `inbox.changeCustomer`, `inbox.updateUserChannels`, `inbox.sendNotifications`, `inbox.widgetsGetUnreadMessagesCount` | query/mutation | Service-to-service helpers |

Also merged into the root router: `conversation.find`/`conversation.tag`, `relation.onRelationAdded` (logs a conversation's form submissions onto a related ticket or other entity), and `integration.find`.

## Permissions

Permission modules: `inbox` (`showConversations` always granted, `conversationMessageAdd`, `conversationMessageEdit`, `conversationsAssign`, `conversationsUnassign`, `conversationsChangeStatus`, `conversationsResolve`, `conversationConvertToCard`, `conversationEditCustomFields`), `channel` (`showChannels`, `showAllChannels`, `channelAdd`, `channelUpdate`, `channelRemove`, `channelManageMembers`), `integration` (`showIntegrations`, `integrationsAdd`, `integrationsEdit`, `integrationsRemove`), and `responseTemplate` (`showResponseTemplates`, `responseTemplatesAdd`, `responseTemplatesEdit`, `responseTemplatesRemove`). Default groups: `frontline:admin`, `frontline:user`, `frontline:viewer`.

## Automations, segments, notifications

- Automation triggers: `frontline:inbox.conversations` "Conversation event" (assignee/status/tag changes) and "Erxes Messenger Message" (direct message, Get Started, quick reply, request-create-ticket, ticket-form-submission conditions); action "Send Erxes Messenger Message".
- Segment content types and field definitions are contributed for conversations and messages (`modules/inbox/meta/segments/`).
- Notification events include `conversationAddMessage`, `conversationAssigneeChange`, `conversationCreated`, `conversationParticipantAdded`, `conversationStateChange`, `conversationTagged`.
- `meta/afterProcess` handlers maintain unread counters and channel counts after conversation changes.

