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 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. If you want to rotate new mail across a team automatically, see 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?
Section titled “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?
Section titled “How do I read the shared mailbox?”You read a shared mailbox with the same List Messages call 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.
How do I let an agent claim a message?
Section titled “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.
-- 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 NOTHINGRETURNING agent;How do I avoid two agents opening the same message?
Section titled “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.
// 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?
Section titled “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.
How do I sync read and unread state across the team?
Section titled “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.
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 }'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?
Section titled “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.
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
Section titled “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.
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
Section titled “What’s next”- Build a unified inbox to merge several providers into 1 view for a single person
- Round-robin email routing to auto-assign inbound mail across the team
- Mark messages read, unread, or starred for the full message update reference
- Trigger a webhook on new email to drive the inbox without polling
- Send email without SMTP for the full send and reply payload