Unified inbox
Read, search, and triage incoming mail across every mailbox, then reply, label, snooze, and manage scheduled sends.
The unified inbox (unibox) is the org-wide view of everything that lands in your connected mailboxes. These endpoints power the dashboard's inbox list, thread view, scope rail, conversation labels, snoozes, and scheduled-send queue. Every route is gated on the unified-inbox feature, so the calling organization needs an active trial or paid subscription; without it the endpoint returns 403.
The list, thread, overview, conversation-label, and snooze data is org-scoped, not per-user. Two members of the same organization see the same inbox, the same unread badge, the same threads, and the same conversation labels, whoever applied them. Snoozes are the one exception: they are attached to the calling user.
The list, thread, message, overview, unread-count and conversation-label reads are bounded at 30 seconds, including any wait for a database connection; a read that runs past it is abandoned and answers with the standard 500 error body.
List incoming mail
GET /unibox
Returns the inbox list, collapsed to one row per thread (the newest message), with filtering and cursor pagination. Auth: Scope READ_UNIBOX · Org permission access_unibox.
| Parameter | In | Type | Description |
|---|---|---|---|
cursor | query | string | Opaque pagination cursor from a previous response. |
limit | query | integer | Page size. Clamped to the server's min/max. |
from | query | string | Filter by sender address (substring). |
folder | query | string | One of inbox, sent, drafts, archive, spam, trash. Omit for every folder except spam and trash (junk never bleeds into the combined view). An unknown value returns 400. |
subject | query | string | Filter by subject (substring). |
unseen | query | boolean | true returns only threads with unread messages. |
awaiting_reply | query | boolean | true returns only threads where the latest message was sent by you (recipient has not replied). |
snoozed | query | string | true returns only snoozed threads. Omit to exclude snoozed threads (default). |
since | query | string | Lower bound on date, YYYY-MM-DD. |
until | query | string | Upper bound on date, YYYY-MM-DD. |
email_id | query | string | Restrict to a single mailbox by UUID. |
email_ids | query | string | Comma-separated mailbox UUIDs. A thread matches if it landed in any of them. Invalid UUIDs are dropped. |
category_ids | query | string | Comma-separated conversation-label UUIDs. A thread matches if it carries any of them. |
uncategorized | query | boolean | true returns only threads carrying no conversation labels. |
automated | query | boolean | true returns only conversations no person wrote in: security alerts, notifications, bounces and auto-replies, as judged by automatic inbox tagging. false leaves them out, which is how the dashboard's Inbox reads. Omit for both. A conversation counts as automated only while every message in it that is not yours was judged automated, so a reply brings it back. |
The response is a data plus pagination envelope. Each row summarises the whole thread behind it (message_count, has_unread) plus the conversation's labels.
Response
{
"data": [
{
"id": "9b6f0e2a-3c4d-4f1a-8b2e-1a2b3c4d5e6f",
"email_id": "2a1b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"thread_id": "thread-af83b21",
"from_addr": ["Jane Doe <[email protected]>"],
"to_addr": ["[email protected]"],
"subject": "Re: Following up on pricing",
"snippet": "Thanks for the details, this looks great...",
"internal_date": "2026-06-11T14:22:09Z",
"seen": false,
"message_count": 4,
"has_unread": true,
"labels": [
{ "id": "c0ffee00-0000-4000-8000-000000000001", "title": "Interested", "color": "#16a34a" }
]
}
],
"pagination": {
"next_cursor": "eyJpZCI6Ii4uLiJ9",
"has_more": true
}
}Get unread count
GET /unibox/count
Returns the number of unread conversations in the Inbox folder, optionally scoped to one mailbox. Snoozed conversations, automated conversations (see automated above) and mail in other folders (sent, drafts, archive, spam, trash) are not counted, so the number matches what the Inbox view lists as unread. Backs the inbox badge. Auth: Scope READ_UNIBOX · Org permission access_unibox.
| Parameter | In | Type | Description |
|---|---|---|---|
email_id | query | string | Optional mailbox UUID to count unread for a single mailbox. |
Response
{
"count": 37
}Get inbox overview
GET /unibox/overview
Rolls up the scope rail and top metric strip in one call: unread, today, week, snoozed, awaiting-reply, and pending-scheduled counts, plus per-folder, per-mailbox, per-tag, and per-conversation-label breakdowns. The folders array always lists all six canonical folders, zero-filled, in sidebar order; the headline counts exclude spam and trash. All counts are threads, not messages, except scheduled_pending, which counts the sends queued from the organization's mailboxes against the workspace cap in scheduled_pending_max. Automated conversations are counted in automated and automated_unread and left out of unread, today, week, the inbox folder and the per-mailbox and per-tag counts; total and the label counts still include them. Concurrent requests from the same organization share one computation, and its result is served for up to one second after it started (generated_at is when it started). Auth: Scope READ_UNIBOX · Org permission access_unibox.
Response
{
"total": 1284,
"unread": 37,
"today": 12,
"week": 88,
"snoozed": 3,
"awaiting_reply": 9,
"automated": 41,
"automated_unread": 6,
"scheduled_pending": 2,
"scheduled_pending_max": 10000,
"folders": [
{ "folder": "inbox", "unread": 31, "total": 812 },
{ "folder": "sent", "unread": 0, "total": 402 },
{ "folder": "drafts", "unread": 0, "total": 4 },
{ "folder": "archive", "unread": 6, "total": 66 },
{ "folder": "spam", "unread": 2, "total": 9 },
{ "folder": "trash", "unread": 0, "total": 3 }
],
"mailboxes": [
{
"id": "2a1b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"email": "[email protected]",
"name": "Sales",
"unread": 21,
"total": 640
}
],
"tags": [
{
"id": "1f2e3d4c-5b6a-7980-a1b2-c3d4e5f60718",
"title": "Outbound",
"color": "#2563eb",
"unread": 14,
"total": 410
}
],
"categories": [
{
"id": "c0ffee00-0000-4000-8000-000000000001",
"title": "Interested",
"color": "#16a34a",
"unread": 5,
"total": 62
}
],
"generated_at": "2026-06-11T14:25:00Z",
"window_today_start": "2026-06-11T00:00:00Z",
"window_week_start": "2026-06-05T00:00:00Z"
}Get a thread
GET /unibox/thread
Returns every message in a single conversation, oldest-first style message rows, with cursor pagination. The mailbox filter is optional: with no email_id the thread is read across every mailbox in the organization (the natural unified view). Auth: Scope READ_UNIBOX · Org permission access_unibox.
| Parameter | In | Type | Description |
|---|---|---|---|
thread_id | query | string | Required. The thread to read. Also accepted as id. |
email_id | query | string | Optional mailbox UUID to scope the thread to one mailbox. Also accepted as email. |
cursor | query | string | Opaque pagination cursor. |
limit | query | integer | Page size. Out-of-range values return 400. |
The response is a data plus pagination envelope. Each item is a full message (envelope plus body). A message that answers an email sent from another workspace mailbox, as a reply in a shared reply inbox does, carries that mailbox's id in answers_mailbox_id; the field is absent otherwise.
Response
{
"data": [
{
"id": "9b6f0e2a-3c4d-4f1a-8b2e-1a2b3c4d5e6f",
"email_id": "2a1b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"mailbox": 1,
"thread_id": "thread-af83b21",
"message_id": "<[email protected]>",
"gmail_id": "18f0c2a9b7d4e5f6",
"parent_id": "<[email protected]>",
"uid": 4821,
"mod_seq": 90210,
"flags": ["\\Seen"],
"bcc": [],
"cc": [],
"from_addr": ["Jane Doe <[email protected]>"],
"in_reply_to": ["<[email protected]>"],
"reply_to": [],
"to_addr": ["[email protected]"],
"answers_mailbox_id": "5e11d0c4-0000-0000-0000-000000000003",
"subject": "Re: Following up on pricing",
"size": 18422,
"internal_date": "2026-06-11T14:22:09Z",
"sent_date": "2026-06-11T14:22:00Z",
"snippet": "Thanks for the details, this looks great...",
"seen": true,
"body_plain": "Thanks for the details...",
"body_html": "<p>Thanks for the details...</p>",
"updated_at": "2026-06-11T14:22:10Z",
"created_at": "2026-06-11T14:22:10Z"
}
],
"pagination": {
"next_cursor": null,
"has_more": false
}
}Get thread labels
GET /unibox/thread/labels
Returns the labels attached to a thread, drawn from the same workspace list as contact labels (managed through /categories). Auth: Scope READ_UNIBOX · Org permission access_unibox.
| Parameter | In | Type | Description |
|---|---|---|---|
thread_id | query | string | Required. The thread to read labels for. Also accepted as id. |
The response wraps the labels in a data array.
Response
{
"data": [
{ "id": "c0ffee00-0000-4000-8000-000000000001", "title": "Interested", "color": "#16a34a" }
]
}Set thread labels
PUT /unibox/thread/labels
Replaces the full conversation-label set on a thread, for the whole workspace. The body's category_ids is the desired set, so the call is idempotent and retries are naturally safe. Only the workspace's own labels are attached; an id belonging to another organization is dropped. Auth: Scope WRITE_UNIBOX · Org permission access_unibox.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
thread_id | string | Yes | The thread to label. |
category_ids | string[] | No | The full desired set of label UUIDs. An empty array clears all labels. |
{
"thread_id": "thread-af83b21",
"category_ids": [
"c0ffee00-0000-4000-8000-000000000001",
"c0ffee00-0000-4000-8000-000000000002"
]
}Response
Returns the resulting label set in a data array.
{
"data": [
{ "id": "c0ffee00-0000-4000-8000-000000000001", "title": "Interested", "color": "#16a34a" },
{ "id": "c0ffee00-0000-4000-8000-000000000002", "title": "Demo booked", "color": "#7c3aed" }
]
}Mark messages seen
PATCH /unibox/seen
Marks messages as read or unread, org-wide: an explicit batch of up to 500 message ids, up to 500 conversations, or a whole canonical folder at once. email_ids and thread_ids can be sent together; folder cannot be combined with either and returns 400. Auth: Scope WRITE_UNIBOX · Org permission access_unibox.
Marking a conversation read reads every message in it. Marking one unread marks only its newest received message unread (one outside spam and trash when there is one). If it has no received message, its newest sent copy turns unread instead; otherwise sent copies stay read. Drafts are never marked unread. Explicit email_ids and folder sweeps never mark sent or draft copies unread.
The change is also carried out to the mailbox itself, so a message marked read here stops showing as unread in Gmail, Outlook or an IMAP mailbox. Opening a message with GET /unibox/:id marks it read the same way, relay included. Only messages whose state actually changed are relayed, and the relay is best-effort: it happens after the response, on the worker holding the mailbox, and a mailbox that is unplaced at that moment keeps its own read state until something changes it again. Filing a conversation is relayed the same way.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
email_ids | string[] | No | Message UUIDs to update (max 500). |
thread_ids | string[] | No | Conversations to update (max 500). |
folder | string | No | Sweep every message in this folder instead of an id list: one of inbox, sent, drafts, archive, spam, trash. |
seen | boolean | No | true marks as read, false marks as unread. |
{
"email_ids": [
"9b6f0e2a-3c4d-4f1a-8b2e-1a2b3c4d5e6f",
"7a8b9c0d-1e2f-3a4b-5c6d-7e8f9a0b1c2d"
],
"seen": true
}Response
Echoes the request back.
{
"email_ids": [
"9b6f0e2a-3c4d-4f1a-8b2e-1a2b3c4d5e6f",
"7a8b9c0d-1e2f-3a4b-5c6d-7e8f9a0b1c2d"
],
"seen": true
}Reply from the inbox
POST /unibox/reply
Sends or schedules a reply or a forward from any mailbox in the organization. The send is routed through the per-mailbox scheduler according to send_mode. Requires an active organization. Auth: Scope WRITE_UNIBOX · Org permission access_unibox.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
email_account_id | string | Yes | UUID of the sending mailbox. Any active mailbox in the organization, not only the one holding the thread. |
to | string[] | Yes | Recipient addresses (at least one). |
cc | string[] | No | CC addresses. |
bcc | string[] | No | BCC addresses. |
subject | string | Yes | Subject line. |
body_html | string | No | HTML body. |
body_plain | string | No | Plain-text body. Leave it empty with body_html set and the plain-text part is rendered from the HTML, link destinations included. |
in_reply_to | string[] | No | Message-ID(s) this reply threads under. When omitted, the newest Message-ID in thread_id is used. |
thread_id | string | No | The conversation being answered. |
send_mode | string | No | instant (default), smart (next mailbox gap), or scheduled (use scheduled_at). |
scheduled_at | string | No | RFC 3339 timestamp. Required when send_mode is scheduled; must be in the future. |
forward_message_id | string | No | UUID of a stored message (the id from GET /unibox/:id) to forward. See Forwarding a message. |
{
"email_account_id": "2a1b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"to": ["[email protected]"],
"subject": "Re: Following up on pricing",
"body_html": "<p>Happy to hop on a call this week.</p>",
"in_reply_to": ["<[email protected]>"],
"thread_id": "thread-af83b21",
"send_mode": "instant"
}Response
{
"task_id": "5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f",
"scheduled_at": "2026-06-11T14:30:00Z",
"send_mode": "instant"
}Instant sends are held for the sender's undo window (5 to 120 seconds, default 30) before they actually leave, so scheduled_at is that far in the future. Within the window the send can still be cancelled with DELETE /unibox/scheduled/:task_id.
Only Gmail has a provider thread handle, and it belongs to the mailbox that holds the thread. So the reply files into the Gmail thread thread_id names only when email_account_id holds a message in it; a mailbox that already replied into the conversation from outside continues the thread that reply started. Otherwise it goes out with the same In-Reply-To and References headers and the conversation's subject, which keeps it in the same conversation for the recipient, and starts a conversation of its own in the sending mailbox. The queued send still belongs to thread_id, so it is listed with that conversation's scheduled sends.
Forwarding a message
Set forward_message_id to forward a message from the inbox. Warmbly attaches the stored message itself, so body_html and body_plain are an optional note and can be left empty. The message is copied when the request is accepted, so a scheduled forward still carries it if the original is later deleted. What goes out, in both the HTML and the plain-text part:
- your note, if any
- the sending mailbox's signature, when signature sync is on (a forward is signed even without a note)
- a
---------- Forwarded message ---------block with the original From, Date, Subject, To and Cc lines, then the original body
The Date line is written in the sending mailbox's time zone (its own, else the workspace's, else UTC). The original HTML keeps its formatting: its stylesheet is folded into inline styles and it is sanitized like a message opened in Unibox, so scripts, event handlers and Warmbly's own open-tracking pixels are dropped. When the original has only a text part, it is forwarded as text. Links in the forwarded message are never rewritten for click tracking; only your note is tracked, when the mailbox tracks direct mail. Forwarding one of your own tracked sends (from Sent, say) swaps its tracked links back to their real destinations, so the new recipient's clicks are not counted against the original send.
Two limits:
- attachments on the original are not forwarded
- a message whose full body is not stored (the reader shows "Only a preview of this message is stored") is forwarded with that preview
Forwarding discloses the message, so it takes READ_UNIBOX as well as WRITE_UNIBOX (for a dashboard session, the access_unibox permission covers both), and an API key restricted to certain mailboxes must be allowed the mailbox the message belongs to as well as the sending mailbox. Either missing answers 403. A forward_message_id that names no message in the organization answers 404.
{
"email_account_id": "2a1b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"to": ["[email protected]"],
"subject": "Fwd: Following up on pricing",
"body_plain": "Can you take a look at this?",
"forward_message_id": "7f8e9d0c-1b2a-4c3d-9e8f-0a1b2c3d4e5f"
}List active snoozes
GET /unibox/snoozes
Returns your active thread snoozes. Auth: Scope READ_UNIBOX · Org permission access_unibox.
The response wraps the snoozes in a data array.
Response
{
"data": [
{
"id": "3a4b5c6d-7e8f-4a0b-9c1d-2e3f4a5b6c7d",
"user_id": "1e2f3a4b-5c6d-7e8f-9a0b-1c2d3e4f5a6b",
"thread_id": "thread-af83b21",
"snoozed_until": "2026-06-12T09:00:00Z",
"created_at": "2026-06-11T14:00:00Z",
"updated_at": "2026-06-11T14:00:00Z"
}
]
}Snooze a thread
POST /unibox/snooze
Hides a thread from your inbox until snoozed_until passes. Upsert semantics: a second call on the same thread updates the time in place. Auth: Scope WRITE_UNIBOX · Org permission access_unibox.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
thread_id | string | Yes | The thread to snooze. |
snoozed_until | string | Yes | RFC 3339 timestamp to un-hide the thread. |
{
"thread_id": "thread-af83b21",
"snoozed_until": "2026-06-12T09:00:00Z"
}Response
{
"id": "3a4b5c6d-7e8f-4a0b-9c1d-2e3f4a5b6c7d",
"user_id": "1e2f3a4b-5c6d-7e8f-9a0b-1c2d3e4f5a6b",
"thread_id": "thread-af83b21",
"snoozed_until": "2026-06-12T09:00:00Z",
"created_at": "2026-06-11T14:00:00Z",
"updated_at": "2026-06-11T14:00:00Z"
}Unsnooze a thread
DELETE /unibox/snooze
Un-snoozes a thread immediately. Idempotent: deleting a snooze that does not exist still succeeds with 204. Auth: Scope WRITE_UNIBOX · Org permission access_unibox.
| Parameter | In | Type | Description |
|---|---|---|---|
thread_id | query | string | Required. The thread to un-snooze. |
Response
204 No Content with an empty body.
List scheduled sends
GET /unibox/scheduled
Returns the outbound emails queued from the organization's mailboxes but not yet sent, whichever member queued them. An API key limited to certain mailboxes sees only their sends. Pass thread_id to scope to a single conversation (used to render queued replies inline); the response shape is identical either way. Auth: Scope READ_UNIBOX · Org permission access_unibox.
| Parameter | In | Type | Description |
|---|---|---|---|
thread_id | query | string | Optional. Restrict to scheduled sends queued into one thread. |
The response wraps the items in a data array. Each item is a preview of the queued message.
Response
{
"data": [
{
"task_id": "5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f",
"scheduled_at": "2026-06-12T09:15:00Z",
"created_at": "2026-06-11T14:30:00Z",
"account_id": "2a1b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"account_email": "[email protected]",
"account_name": "Sales",
"to": ["[email protected]"],
"subject": "Re: Following up on pricing",
"snippet": "Happy to hop on a call this week.",
"thread_id": "thread-af83b21"
}
]
}Cancel a scheduled send
DELETE /unibox/scheduled/:task_id
Cancels a pending scheduled send from any of the organization's mailboxes (for an API key limited to certain mailboxes, from those) before it fires. The queued task is marked cancelled and short-circuits to a no-op when its run time arrives. Auth: Scope WRITE_UNIBOX · Org permission access_unibox.
| Parameter | In | Type | Description |
|---|---|---|---|
task_id | path | string | UUID of the scheduled task to cancel. |
Response
204 No Content with an empty body.
Get a message by id
GET /unibox/:id
Returns a single message by its UUID, including the full envelope and body. Auth: Scope READ_UNIBOX · Org permission access_unibox.
| Parameter | In | Type | Description |
|---|---|---|---|
id | path | string | UUID of the message. |
Response
{
"id": "9b6f0e2a-3c4d-4f1a-8b2e-1a2b3c4d5e6f",
"gmail_id": "18f0c2a9b7d4e5f6",
"uid": 4821,
"parent_id": "<[email protected]>",
"thread_id": "thread-af83b21",
"flags": ["\\Seen"],
"bcc": [],
"cc": [],
"date": "2026-06-11T14:22:00Z",
"from": ["Jane Doe <[email protected]>"],
"in_reply_to": ["<[email protected]>"],
"message_id": "<[email protected]>",
"ReplyTo": [],
"to": ["[email protected]"],
"subject": "Re: Following up on pricing",
"size": 18422,
"internal_date": "2026-06-11T14:22:09Z",
"mod_seq": 90210,
"body_plain": "Thanks for the details...",
"body_html": "<p>Thanks for the details...</p>"
}Errors
All error responses follow the standard envelope. See error codes for the full list.
{
"error": "forbidden",
"message": "Unibox requires an active trial or paid subscription",
"code": "FORBIDDEN",
"request_id": "req_8f3a1c2b9d"
}Common cases: 403 when the organization lacks unified-inbox access, 400 for a missing thread_id, an invalid cursor or limit, or no organization selected, and 400 when marking more than 500 messages seen in one call.