# Build a shared team inbox

Source: https://developer.nylas.com/docs/cookbook/email/shared-team-inbox/

A support@ or sales@ address that several people work at once is a shared team inbox: one real mailbox, many human agents. The hard part is not reading the mail, it is coordination. Two agents must not both reply to the same message, every conversation needs an owner, and the team needs an internal status that the underlying provider never had. This recipe shows how to build that coordination layer on a single [grant](/docs/v3/auth/) with the Nylas Email API.

The model here is one mailbox, not many. If you instead want to merge several providers for one person, see [build a unified inbox](/docs/cookbook/email/unified-inbox/). If you want to rotate new mail across a team automatically, see [round-robin email routing](/docs/cookbook/use-cases/automate/round-robin-email-routing/). This page covers the piece both of those leave out: claim, ownership, and collision avoidance on a single shared mailbox.

## What is a shared team inbox on one mailbox?

A shared team inbox is one provider mailbox, represented by a single Nylas grant, that multiple agents read and reply from through your application. The Email API gives you the messages and the send and read-state calls. Claim, assignment, and status are your own application state, stored in a database table you control.

The split matters because the provider has no concept of "Bo is handling this thread." Gmail and Outlook show 1 mailbox with no owner field, so a support team of 6 users sharing 1 address has no native way to divide work. You build that layer yourself: a row per message or thread that records owner, status, and a lock. The Email API stays the source of truth for mail content and read state, while your table is the source of truth for who owns what. Keeping those two stores in sync, and keeping the lock honest, is the whole job.

## How do I read the shared mailbox?

You read a shared mailbox with the same [List Messages call](/docs/reference/api/messages/get-messages/) any single mailbox uses: `GET /v3/grants/{grant_id}/messages`. Every agent points at the same `grant_id`, so they all see the same mail. The `unread` query parameter narrows the view to new work, and `page_token` walks older pages. The default page size is 50 messages and the maximum is 200.

The team almost never wants the raw provider list, though. They want it joined against your assignment table so each row shows owner and status. The pattern is to fetch a page from the API, then left-join the returned message IDs against your own rows in 1 query. Messages with no row yet are unclaimed and land in a shared "Unassigned" queue. A typical team page holds 25 to 50 conversations, so this join stays cheap and runs on every inbox refresh. Filter to unread first when you want only fresh inbound mail.

```bash
curl --request GET \
  --url 'https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/messages?limit=50&unread=true' \
  --header 'Authorization: Bearer <NYLAS_API_KEY>'
```

```js [readShared-Node.js SDK]
const { data } = await nylas.messages.list({
  identifier: SHARED_GRANT_ID,
  queryParams: { limit: 50, unread: true },
});

// Join API messages against your own assignment rows.
const owners = await db.assignments.findByMessageIds(data.map((m) => m.id));
const inbox = data.map((m) => ({
  ...m,
  owner: owners[m.id]?.agent ?? null, // null means unassigned
  status: owners[m.id]?.status ?? "new",
}));
```

## How do I let an agent claim a message?

Claiming is an atomic write to your own table, never a Nylas call. An agent opening a conversation triggers a conditional insert or update that succeeds only when the row is still unowned. If 2 agents click the same thread within 200 ms, the database lets exactly 1 win and the other gets a clean rejection your UI can show as "Already claimed by Ana."

The safe shape is a single conditional statement, not a read-then-write. A read-then-write leaves a gap where both agents see "unassigned" and both proceed. Instead use `INSERT ... ON CONFLICT DO NOTHING`, an `UPDATE ... WHERE owner IS NULL`, or a unique constraint on the message ID, and treat 0 affected rows as "someone beat you to it." This one rule prevents the most common shared-inbox bug, where 2 people draft replies to the same customer and 1 wasted reply goes out. The claim is keyed on the message or thread ID returned by the API.

```sql
-- Returns the row only if this agent won the claim.
-- 0 rows back means another agent already owns it.
INSERT INTO assignments (message_id, grant_id, agent, status, claimed_at)
VALUES ($1, $2, $3, 'open', now())
ON CONFLICT (message_id) DO NOTHING
RETURNING agent;
```

## How do I avoid two agents opening the same message?

Collision avoidance combines a hard claim lock with a soft presence signal. The claim from the previous section is the hard guarantee: only 1 owner row can exist per message. Presence is the softer, real-time hint that someone is currently viewing or typing, shown as a "Cy is replying" badge so a teammate backs off before they even try to claim.

Presence is short-lived state, not a permanent record. Store it with a 30-second time-to-live in Redis or an in-memory map, refreshed by a heartbeat while the agent has the conversation open. When the heartbeat stops, the badge clears within 30 seconds and the conversation frees up. Keep this separate from the durable claim row, because presence is allowed to expire and ownership is not. Together the 2 layers cover both races: 2 simultaneous claims resolve through the unique constraint, and 2 agents idly browsing the same thread see each other through presence. For inbound assignment that prevents the collision before anyone clicks, route new mail with [round-robin email routing](/docs/cookbook/use-cases/automate/round-robin-email-routing/).

```js [presence-Node.js]
// Soft presence: expires on its own, never blocks a claim.
async function markViewing(redis, messageId, agent) {
  await redis.set(`viewing:${messageId}`, agent, { EX: 30 });
}

async function whoIsViewing(redis, messageId) {
  return redis.get(`viewing:${messageId}`); // null when nobody is active
}
```

## How do I track internal status without changing the mailbox?

Internal status lives entirely in your assignment table, so it never touches the provider mailbox. A column such as `status` holding values like `new`, `open`, `pending`, or `closed` belongs to your app alone. The customer never sees it, and the provider never stores it, which is exactly what you want for private team workflow.

Two stores stay in play and serve different readers. Your status column drives the team view, while the `unread` and `starred` fields on the message drive what the real mailbox shows. Often you want both to move together: when an agent claims and opens a conversation, mark your status `open` and flip the provider read state in the same handler. That mirror keeps the actual mailbox tidy so a 4-person team does not stare at 80 bold unread rows that someone already handled. The read-state call is 1 `PUT` request, covered next and in full in [mark messages read, unread, or starred](/docs/cookbook/email/update-messages/).

## How do I sync read and unread state across the team?

Read state is the one piece of team coordination the provider does store, and you set it with `PUT /v3/grants/{grant_id}/messages/{message_id}`. Send `unread: false` to mark a message read for the whole shared mailbox, or `unread: true` to push it back to the team as new. Because every agent shares 1 grant, that single call updates the view everyone sees, and it syncs to the real mail client too.

This is what keeps a shared mailbox sane. When 1 agent handles a message, marking it read in the same step as the claim clears it from everyone else's unread filter, so a 5-person team is not duplicating triage on already-handled mail. You send only the fields you are changing, so a read-state flip touches just 1 field. The same endpoint also sets `starred`, which teams often repurpose as a shared "needs a second look" flag visible to all agents at once. Pair this with your status column so the provider state and your internal state never drift.

```bash
curl --request PUT \
  --url 'https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/messages/<MESSAGE_ID>' \
  --header 'Authorization: Bearer <NYLAS_API_KEY>' \
  --header 'Content-Type: application/json' \
  --data '{ "unread": false }'
```

```python [syncRead-Python SDK]
nylas.messages.update(
    identifier=SHARED_GRANT_ID,
    message_id=message_id,
    request_body={"unread": False},  # one shared grant, whole team sees it read
)
```

## How does the team reply from the shared address?

Agents reply through `POST /v3/grants/{grant_id}/messages/send` on the shared grant, so every reply leaves from the team address rather than an individual mailbox. The customer sees support@ on the From line whether Ana or Bo wrote it, which is the entire point of a shared inbox. Pass the original `reply_to_message_id` so the response threads cleanly under the existing conversation.

Ownership should gate the send. Check that the requesting agent holds the claim row before you call send, so a teammate cannot accidentally reply to a conversation someone else is mid-draft on. After a successful send, move your status to `pending` or `closed` in the same handler. A disciplined team closes a typical support thread in 3 to 5 messages, and keeping status in lockstep with each send is what makes the queue counts trustworthy. The send payload and reply fields are detailed in [send email without SMTP](/docs/cookbook/use-cases/build/send-email-without-smtp/).

```js [teamReply-Node.js SDK]
async function reply(nylas, agent, inbound, body) {
  if (!(await db.assignments.ownedBy(inbound.id, agent))) {
    throw new Error("Claim the conversation before replying");
  }
  await nylas.messages.send({
    identifier: SHARED_GRANT_ID,
    requestBody: {
      to: inbound.from,
      subject: `Re: ${inbound.subject}`,
      body,
      replyToMessageId: inbound.id,
    },
  });
  await db.assignments.setStatus(inbound.id, "pending");
}
```

## Things to know about a shared team inbox

A shared inbox is a coordination layer your app owns on top of 1 grant. The Email API never tracks owners or status, so the durability and correctness of that layer is on you. The points below cover what breaks once more than 2 agents work the mailbox at the same time.

**Claims must be atomic, not read-then-write.** The single most common production bug is 2 agents both replying to 1 customer. A read of "unassigned" followed by a separate write leaves a race window of a few milliseconds where both reads succeed. Use a unique constraint or a conditional update so the database arbitrates, and treat 0 affected rows as a lost claim.

**Keep your status and the provider read state in sync.** Your `status` column and the API `unread` field answer different questions, but they should move together. When a claim opens a conversation, flip both in the same handler. Letting them drift means the team view says "closed" while the mailbox still shows 12 bold unread rows.

**Drive the inbox with webhooks, not polling.** A shared mailbox with 6 agents refreshing every few seconds burns rate limit fast, and Google throttles per user while Microsoft throttles per mailbox. Subscribe to the `message.created` webhook so new mail pushes into your queue, then read the full message on demand. See [trigger a webhook on new email](/docs/cookbook/use-cases/build/new-email-webhook/).

**Presence is allowed to expire, ownership is not.** Store the "currently viewing" signal with a short time-to-live around 30 seconds and let it lapse on its own. Store the claim durably with no expiry, releasing it only on an explicit hand-off or close. Mixing the 2 lifetimes is how conversations get silently abandoned.

**One grant is your single point of failure.** Because the whole team works through 1 grant, a single revoked or expired token freezes the entire shared inbox. Monitor the grant status and alert on auth errors within minutes, since 1 bad token here affects every agent at once, not just 1 user.

## What's next

- [Build a unified inbox](/docs/cookbook/email/unified-inbox/) to merge several providers into 1 view for a single person
- [Round-robin email routing](/docs/cookbook/use-cases/automate/round-robin-email-routing/) to auto-assign inbound mail across the team
- [Mark messages read, unread, or starred](/docs/cookbook/email/update-messages/) for the full message update reference
- [Trigger a webhook on new email](/docs/cookbook/use-cases/build/new-email-webhook/) to drive the inbox without polling
- [Send email without SMTP](/docs/cookbook/use-cases/build/send-email-without-smtp/) for the full send and reply payload