Outbound email is governed by a four-scope ladder, so a key holds exactly as much send capability as its owner meant to grant — from "prepare drafts only" up to "compose to anyone". You choose the rung when you create the key.
| Scope | Grants | Risk shape |
|---|---|---|
drafts.write | Create Gmail drafts (on a thread, or brand-new). Can never send. | None — a human reviews and sends from Gmail. |
drafts.send | Send an existing draft by id. | "Approve by sending" — one key prepares, another releases. |
email.reply | Reply on an existing thread, body only — recipients are derived server-side and cannot be supplied. | Content can never leave the thread's audience. The safe send for agents. |
email.send | Compose and send to arbitrary recipients. | The widest capability — grant deliberately. |
Sends are irreversible: always pass an Idempotency-Key so a retried request can't send twice. No attachments on any compose operation (v1 — deliberate).
On a thread, only body is required — recipients default to the conversation and the subject to Re: <thread subject>. The draft lands in Gmail's Drafts folder, where the user can edit, send or discard it.
curl -X POST "https://api.sortd.com/v2/email-threads/18c2a5b7f3d94e01/drafts" \
-H "Authorization: Bearer sk_live_…" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: <unique-key>" \
-d '{ "body": "Thanks — updated proposal attached to the previous mail. Does Thursday work?" }'
# curl — a brand-new draft (fresh conversation): to + subject required
curl -X POST "https://api.sortd.com/v2/email/drafts" \
-H "Authorization: Bearer sk_live_…" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: <unique-key>" \
-d '{ "to": ["jane@acme.com"], "subject": "Q3 renewal", "body": "Hi Jane — …" }'
const res = await fetch(
"https://api.sortd.com/v2/email-threads/18c2a5b7f3d94e01/drafts",
{
method: "POST",
headers: {
Authorization: "Bearer sk_live_…",
"Content-Type": "application/json",
"Idempotency-Key": crypto.randomUUID(),
},
body: JSON.stringify({ body: "Thanks — does Thursday work?" }),
}
);
const { data } = await res.json(); // data.id is the Gmail draft id
import requests, uuid
res = requests.post(
"https://api.sortd.com/v2/email-threads/18c2a5b7f3d94e01/drafts",
headers={
"Authorization": "Bearer sk_live_…",
"Idempotency-Key": str(uuid.uuid4()),
},
json={"body": "Thanks — does Thursday work?"},
)
draft = res.json()["data"]
Sends a previously created draft. The split is the human-in-the-loop pattern for automation: an agent key with drafts.write prepares, and a human — or a separate, more trusted key with drafts.send — releases.
curl -X POST "https://api.sortd.com/v2/email-threads/18c2a5b7f3d94e01/drafts/r-123456/send" \
-H "Authorization: Bearer sk_live_…" \
-H "Idempotency-Key: <unique-key>"
# List a thread's drafts first: GET /email-threads/{threadId}/drafts
You control only the body. Recipients are derived server-side from the thread (honouring Reply-To); a request containing to/cc/bcc is rejected with a 400. So even a prompt-injected agent can't mail thread content to an outside address — it can only reach people already on the conversation. mode is reply (sender only, the default) or reply_all (keep the To/Cc audience).
curl -X POST "https://api.sortd.com/v2/email-threads/18c2a5b7f3d94e01/reply" \
-H "Authorization: Bearer sk_live_…" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: <unique-key>" \
-d '{ "body": "Confirmed for Thursday 10:00.", "mode": "reply_all" }'
await fetch("https://api.sortd.com/v2/email-threads/18c2a5b7f3d94e01/reply", {
method: "POST",
headers: {
Authorization: "Bearer sk_live_…",
"Content-Type": "application/json",
"Idempotency-Key": crypto.randomUUID(),
},
body: JSON.stringify({ body: "Confirmed for Thursday 10:00." }),
});
import requests, uuid
requests.post(
"https://api.sortd.com/v2/email-threads/18c2a5b7f3d94e01/reply",
headers={
"Authorization": "Bearer sk_live_…",
"Idempotency-Key": str(uuid.uuid4()),
},
json={"body": "Confirmed for Thursday 10:00.", "mode": "reply"},
)
A new message to recipients you specify, from the key owner's mailbox — optionally onto an existing thread via thread_id. This is the highest-risk email scope: anything that can read the mailbox and send to arbitrary addresses can exfiltrate it, so prefer a reply or a draft when they'd do.
curl -X POST "https://api.sortd.com/v2/email/send" \
-H "Authorization: Bearer sk_live_…" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: <unique-key>" \
-d '{
"to": ["jane@acme.com"],
"cc": ["sam@acme.com"],
"subject": "Q3 renewal — proposal",
"body": "Hi Jane — proposal below. …"
}'
await fetch("https://api.sortd.com/v2/email/send", {
method: "POST",
headers: {
Authorization: "Bearer sk_live_…",
"Content-Type": "application/json",
"Idempotency-Key": crypto.randomUUID(),
},
body: JSON.stringify({
to: ["jane@acme.com"],
subject: "Q3 renewal — proposal",
body: "Hi Jane — proposal below. …",
}),
});
import requests, uuid
requests.post(
"https://api.sortd.com/v2/email/send",
headers={
"Authorization": "Bearer sk_live_…",
"Idempotency-Key": str(uuid.uuid4()),
},
json={"to": ["jane@acme.com"], "subject": "Q3 renewal — proposal", "body": "…"},
)
drafts.write only. It can prepare; a human sends from Gmail.email.reply. Real sends, structurally unable to reach anyone outside the thread.email.send, granted deliberately to that one key, revocable on its own.The same ladder governs the MCP tools: a connected assistant only ever sees the send tools its key's scopes allow.
Next: receive webhooks — including thread.reply_received to hear when the answer arrives.