Skip to content

Mail

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.

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)
Terminal window
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": ""
}
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
Terminal window
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:

  • from must be one of your active mailboxes or aliases (403 sender_not_authorized otherwise) on a verified domain.
  • replyToMessageId threads the send as a reply and sets the reply headers.
  • attachmentNodeIds reference Drive file nodes; total attachment size is capped at 25 MiB.
  • clientMutationId makes retries safe.
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.

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 }
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.

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

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.

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