Knobs Mail is thread-centric: inbound and outbound messages group into threads per mailbox. The API reads threads and messages, sends outbound mail from any of your active addresses, and exposes the compose AI assists.
All routes are workspace-scoped and accept a session JWT or API key. You only ever see mail addressed to (or sent from) your own mailboxes and aliases.
Threads & messages
Section titled “Threads & messages”| Endpoint | Description |
|---|---|
GET /v1/workspaces/{wid}/mail/threads |
List your threads, newest first; ?folder= (inbox/archive/trash/spam/sent) and ?limit= |
GET /v1/workspaces/{wid}/mail/threads/{tid} |
Thread with its ordered messages |
GET /v1/workspaces/{wid}/mail/threads/{tid}/messages |
The messages in a thread (?limit=) |
GET /v1/workspaces/{wid}/mail/messages/{mid}/raw-url |
Short-lived signed URL for the raw MIME → {url} |
GET /v1/workspaces/{wid}/mail/messages/{mid}/attachments/{idx}/download-url |
Signed URL for attachment idx → {url} (409 until extraction finishes) |
curl -H "Authorization: Bearer knak_..." \ "https://api.knobs.io/v1/workspaces/{wid}/mail/threads?folder=inbox&limit=25"{ "items": [ { "id": "t_8c1f…", "subject": "Q3 planning", "snippet": "Here's the draft agenda…", "folder": "inbox", "unread": true, "starred": false, "lastMessageAt": "2026-07-16T15:02:11Z" } ], "nextPageToken": ""}Sending
Section titled “Sending”| Endpoint | Description |
|---|---|
POST /v1/workspaces/{wid}/mail/messages:send |
Send a message |
POST /v1/workspaces/{wid}/mail/drafts/{rpc...} |
Draft RPCs — currently {draftId}:send, equivalent to messages:send |
Send a message
Section titled “Send a message”curl -X POST https://api.knobs.io/v1/workspaces/{wid}/mail/messages:send \ -H "Authorization: Bearer knak_..." \ -H "Content-Type: application/json" \ -d '{ "from": "jane@acme.knobs.io", "to": ["sam@example.com"], "cc": [], "subject": "Q3 planning", "text": "Draft agenda attached.", "html": "<p>Draft agenda attached.</p>", "clientMutationId": "0d5e0a4e-8f0f-4f57-a2ff-0f6a4b1c9d21", "replyToMessageId": "", "attachmentNodeIds": ["n_77aa…"] }'Returns 201 with the stored message. Notes:
frommust be one of your active mailboxes or aliases (403 sender_not_authorizedotherwise) on a verified domain.replyToMessageIdthreads the send as a reply and sets the reply headers.attachmentNodeIdsreference Drive file nodes; total attachment size is capped at 25 MiB.clientMutationIdmakes retries safe.
Thread actions
Section titled “Thread actions”| Endpoint | Description |
|---|---|
POST /v1/workspaces/{wid}/mail/threads/{tid}/read |
{read: true|false} — mark read/unread |
POST /v1/workspaces/{wid}/mail/threads/{tid}/move |
{folder} — move to inbox/archive/trash/spam |
POST /v1/workspaces/{wid}/mail/threads/{tid}/star |
{starred: true|false} |
Each returns the updated thread.
AI assists
Section titled “AI assists”Synchronous single-turn generations for the composer. Results are ephemeral — returned for review, never persisted. Rate-limited per user (429 resource_exhausted).
| Endpoint | Description |
|---|---|
POST /v1/workspaces/{wid}/mail/ai:suggest-reply |
{threadId, guidance?} → a drafted reply |
POST /v1/workspaces/{wid}/mail/ai:improve-draft |
{text, subject?, threadId?, to?} → a cleaned-up draft |
POST /v1/workspaces/{wid}/mail/ai:summarize-thread |
{threadId} → a thread digest |
All three return the same shape:
{ "text": "Thanks Sam — Wednesday works…", "model": "…", "inputTokens": 812, "outputTokens": 96 }Read aloud
Section titled “Read aloud”| Endpoint | Description |
|---|---|
POST /v1/voice/read-aloud |
{workspaceId, entityType, entityId} → synthesized audio bytes (not JSON; Content-Type is the audio MIME) |
entityType is one of mail.message, mail.thread, mail.thread.summary. The entity is resolved and ACL-checked server-side — you pass a reference, never raw text.
Undelivered mail (admin)
Section titled “Undelivered mail (admin)”Inbound messages to a nonexistent address are shelved for 30 days rather than dropped. Admins can inspect and resolve them:
| Endpoint | Description |
|---|---|
GET /v1/workspaces/{wid}/mail/rejections |
List undelivered mail, newest first. Paged: ?pageSize= (default 50, max 200) + ?pageToken= (?limit= is an alias for pageSize) → {rejections, items, nextPageToken}; an empty nextPageToken means the ledger is exhausted, and an unusable token is 400 invalid_argument |
POST /v1/workspaces/{wid}/mail/rejections/{rid}/redeliver |
Deliver into the (now-created) mailbox — 409 still_unroutable if the address still doesn’t exist |
POST /v1/workspaces/{wid}/mail/rejections/{rid}/dismiss |
Resolve without delivering |
Related
Section titled “Related”- Domains, mailboxes & aliases — where addresses come from.
- Tasks and Calendar — create a task or event from a thread.
Listing and filtering threads
Section titled “Listing and filtering threads”GET /v1/workspaces/{wid}/mail/threads is cursor-paged and filterable.
| Parameter | Notes |
|---|---|
folder |
inbox, sent, archive, spam, trash, starred, or all |
participant |
Repeatable. A bare address; matches threads that address is on |
domain |
Repeatable. A host; matches threads anyone at that domain is on |
since |
RFC 3339; threads whose last message is at or after this time |
pageSize |
Default 50, max 200. limit is accepted as an alias |
pageToken |
Opaque cursor from the previous response’s nextPageToken |
participant and domain are a union, not an intersection: a thread matches if it involves
any named address or anyone at any named domain. That is what a CRM wants — an account’s domain,
plus a few contacts’ personal addresses.
Prefer domain when you can. Firestore caps a filter at 30 values per query and cannot combine
two disjunctive terms, so 300 contact addresses becomes ten queries per page while the same
coverage as ~20 company domains is one. Domain matching also surfaces mail from people at a known
account who are not yet contacts, which for a CRM is usually the point.
⚠ Never roll a free-mail address up to its domain. bob@gmail.com → gmail.com matches the
user’s entire personal correspondence. Send those addresses exactly.
POST /v1/workspaces/{wid}/mail/threads:search
Section titled “POST /v1/workspaces/{wid}/mail/threads:search”The same query with the filters in the body, for when the list is long:
{ "participants": ["bob@acme.com"], "domains": ["acme.com", "globex.com"], "folder": "all", "since": "2026-07-01T00:00:00Z", "pageSize": 100}Use this rather than the query string for anything more than a handful of values — a URL has a length limit, and a customer’s contact list has no business in an access log.
GET /v1/workspaces/{wid}/mail/threads/{tid}
Section titled “GET /v1/workspaces/{wid}/mail/threads/{tid}”Returns {thread, items, messages, nextPageToken} — the thread’s own metadata and its messages.
items/messages are unchanged; thread is additive.
GET /v1/workspaces/{wid}/mail/messages/{mid}
Section titled “GET /v1/workspaces/{wid}/mail/messages/{mid}”One normalized message. A message in a mailbox you cannot read returns 404, never 403.
OAuth scopes
Section titled “OAuth scopes”When calling these routes with a third-party OAuth token rather than your own session or API key, each needs a scope. No scope implies another.
| Routes | Scope |
|---|---|
GET …/mail/threads, …/threads/{tid}, …/threads/{tid}/messages, …/messages/{mid}/raw-url, …/messages/{mid}/attachments/{idx}/download-url |
mail:read |
POST …/mail/messages:send, POST …/mail/drafts/{...} |
mail:send |
POST …/mail/threads/{tid}/read, /move, /star |
mail:modify |
POST …/mail/ai:suggest-reply, :improve-draft, :summarize-thread |
mail:ai |
GET …/mail/rejections and the redeliver/dismiss actions |
none — OAuth tokens are refused; this is an admin operator surface |