Drafts: write now, add files, send later
A draft (dft_) is an email that has not gone out yet. Your code or a person can start one, come back to it, add files, and send it now or at a time you choose. A draft can wait for its recipient: to is only needed when it is sent or scheduled.
Create a draft
POST /v1/drafts saves a draft. It needs no recipient, subject or body yet, so you can save what you have and fill in the rest later.
import { createClient } from "@mailsai/sdk";
const client = createClient(); // reads MAILS_API_KEY
const draft = await client.drafts.create({
agent: "hello",
subject: "Your order has shipped",
body_text: "It should reach you by Friday.",
});
await client.drafts.update(draft.id, { to: "dana@example.com" });
await client.drafts.send(draft.id);from mailsai import create_client
client = create_client() # reads MAILS_API_KEY
draft = client.create_draft(
agent="hello",
subject="Your order has shipped",
body_text="It should reach you by Friday.",
)
client.update_draft(draft["id"], to="dana@example.com")
client.send_draft(draft["id"])curl -X POST "https://api.mails.ai/v1/drafts" \
-H "Authorization: Bearer $MAILS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"agent": "hello",
"subject": "Your order has shipped",
"body_text": "It should reach you by Friday."
}'mails drafts create --agent hello \
--subject "Your order has shipped" \
--text "It should reach you by Friday."
mails drafts update dft_01J9X4B6T1 --to dana@example.com
mails drafts send dft_01J9X4B6T1Sending or scheduling a draft with no to answers 400 missing_field. Every field is in the API reference.
Retry a create safely
Send an Idempotency-Key with POST /v1/drafts, and a retry after a timeout gets the first draft back instead of making a second. The repeat answers with the stored response and Idempotent-Replay: true. Keys are kept for 24 hours.
- The same key with a different body answers
400invalid_idempotency_key. Use a new key for a new request. - The same key while the first request runs answers
409duplicate_resource. Retry shortly. - A request that was refused keeps nothing on its key, so a corrected one can use it.
- Only credentials of the same mode, tied to the same agent or to none, share a key. To any other credential, the same key is a new request.
curl -X POST "https://api.mails.ai/v1/drafts" \
-H "Authorization: Bearer $MAILS_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: draft-order-2741" \
-d '{ "agent": "hello", "subject": "Your order has shipped" }'mails drafts create --agent hello \
--subject "Your order has shipped" \
--idempotency-key draft-order-2741The SDKs and the mails_create_draft MCP tool do not take a key on a create.
Reply or forward as a draft
Set reply_kind and name the email in in_reply_to_message_id, and the draft answers that email. The agent is the one on the email, so you leave out agent.
in_reply_to_message_id(string): Id of the message this one answers (msg_…orrcv_…), named in the email'sIn-Reply-Toheader. Once sent, the draft joins its thread when the same agent sent or received it. Left out, it starts a new thread. An id this workspace does not hold, or a message this key cannot read, is refused (404resource_not_found).reply_kind(string): Make this the reply to, or the forward of, the email named byin_reply_to_message_id, which is then required. It needs thereadscope too. An email has at most one unsent reply and one unsent forward: a second request answers 200 with the first, unchanged. The agent is the email's, andto,ccandsubjectdefault as POST /v1/messages/{id}/reply and /forward set them; a forward'sbody_textdefaults to the quoted email, and its files come along. One of:reply,forward.reply_all(boolean): Withreply_kind: "reply": also copy everyone else on the email, asreply_alldoes on POST /v1/messages/{id}/reply.
import { createClient } from "@mailsai/sdk";
const client = createClient(); // reads MAILS_API_KEY
const reply = await client.drafts.create({
in_reply_to_message_id: "rcv_01J9X4B6T1",
reply_kind: "reply",
reply_all: true,
body_text: "Thanks. We will look into it today.",
});
console.log(reply.id);from mailsai import create_client
client = create_client() # reads MAILS_API_KEY
reply = client.create_draft(
in_reply_to_message_id="rcv_01J9X4B6T1",
reply_kind="reply",
reply_all=True,
body_text="Thanks. We will look into it today.",
)
print(reply["id"])curl -X POST "https://api.mails.ai/v1/drafts" \
-H "Authorization: Bearer $MAILS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"in_reply_to_message_id": "rcv_01J9X4B6T1",
"reply_kind": "reply",
"reply_all": true,
"body_text": "Thanks. We will look into it today."
}'mails drafts create --in-reply-to-message-id rcv_01J9X4B6T1 \
--reply-kind reply --reply-all \
--text "Thanks. We will look into it today."One unsent reply and one unsent forward per email, in each mode. Ask for a second and the answer is 200 with the first draft, unchanged, where a new draft answers 201. So a teammate who starts the same reply meets the same draft. A test key’s reply stays apart from a live one, which a test key could not change.
The email has to be one your key can read. A msg_ or rcv_ id that does not exist, is in another workspace, or belongs to another agent when the key is tied to one answers 404 resource_not_found, with or without reply_kind. A Message-ID from another system, in angle brackets, stays as written on a draft without reply_kind.
A send is gentler. POST /v1/messages and POST /v1/messages/batch leave an id the key cannot read out of the email’s headers, and the mail still goes.
A reply’s recipients and subject are filled in as POST /v1/messages/{id}/reply would set them. A forward names nobody, so give it a to before you send it. Its body starts with the quoted email, and the email’s files come along.
Add files to a draft
A draft takes its files through their own routes, so attachments on POST /v1/drafts or PATCH /v1/drafts/{id} is refused with 422 invalid_field. Each file goes up in chunks:
POST /v1/drafts/{id}/attachmentsnames the file and its size. The answer gives itsid,chunk_bytesandchunk_count.PUT /v1/drafts/{id}/attachments/{attachment_id}/chunks/{index}carries one chunk as the raw request body, numbered from 0. Every chunk ischunk_byteslong except the last. They can arrive in any order. Until the file is finished, sending one again replaces it, so a dropped connection can simply be retried.- The file’s
statusturns fromuploadingtoreadywhen its last chunk arrives. Its bytes cannot change after that: a chunk sent to it answers409duplicate_resourcewithparamindex, and nothing is written. To replace the file, remove it and add it again.
import { readFile } from "node:fs/promises";
import { createClient } from "@mailsai/sdk";
const client = createClient(); // reads MAILS_API_KEY
const draftId = "dft_01J9X4B6T1";
const bytes = await readFile("./invoice.pdf");
const file = await client.drafts.attachments.add(draftId, {
filename: "invoice.pdf",
size: bytes.length,
content_type: "application/pdf",
});
for (let i = 0; i < file.chunk_count; i++) {
const start = i * file.chunk_bytes;
await client.drafts.attachments.putChunk(
draftId,
file.id,
i,
bytes.subarray(start, start + file.chunk_bytes),
);
}from pathlib import Path
from mailsai import create_client
client = create_client() # reads MAILS_API_KEY
draft_id = "dft_01J9X4B6T1"
data = Path("invoice.pdf").read_bytes()
file = client.add_draft_attachment(
draft_id, "invoice.pdf", len(data), "application/pdf"
)
for i in range(file["chunk_count"]):
start = i * file["chunk_bytes"]
client.put_draft_attachment_chunk(
draft_id,
file["id"],
i,
data[start:start + file["chunk_bytes"]],
)# Name the file. The answer gives id, chunk_bytes, chunk_count.
curl -X POST "https://api.mails.ai/v1/drafts/$DRAFT_ID/attachments" \
-H "Authorization: Bearer $MAILS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"filename": "invoice.pdf",
"content_type": "application/pdf",
"size": 1048576
}'
# Smaller than chunk_bytes, so this file is one chunk, number 0.
curl -X PUT "https://api.mails.ai/v1/drafts/$DRAFT_ID/attachments/$FILE_ID/chunks/0" \
-H "Authorization: Bearer $MAILS_API_KEY" \
-H "Content-Type: application/octet-stream" \
--data-binary @invoice.pdf{
"id": "dfa_01J9X6Q4W7",
"filename": "invoice.pdf",
"content_type": "application/pdf",
"size": 1048576,
"status": "uploading",
"chunk_bytes": 3145728,
"chunk_count": 1,
"created_at": "2026-10-31T09:20:41.000Z",
"storage": { "used_bytes": 1048576, "limit_bytes": 209715200 }
}The CLI and the MCP server cannot add files to a draft. GET /v1/drafts/{id}/attachments lists a draft’s files, and DELETE /v1/drafts/{id}/attachments/{attachment_id} takes one off.
How much a draft can hold
- On one draft: 10 files, 25 MB together. Past either, adding a file answers
400invalid_field. - Across your workspace: its drafts can hold 200 MB of files on Free, 2 GB on Pro and 10 GB on Scale. A file counts from when it is added until its draft is sent or deleted. Past that room, adding a file answers
402resource_cap_exceeded. - How much is used:
storageon the answer to adding a file gives what your drafts hold and your plan’s limit, in bytes. - A plan that falls: a chunk gets the same
402if the plan has fallen below what your drafts hold. A failed renewal is held to Free.
What happens to the bytes
- When the draft is sent: its files go with it, and their bytes are deleted once the mail has left. The sent message keeps each file’s name, type and size.
- When the draft is deleted or a file is removed: the bytes are deleted.
- A scheduled draft can carry files. It keeps them until it goes out.
- A draft that failed keeps its files, and they count until you delete it. One that failed only because its agent was paused can be scheduled again with them. A copy of a draft does not bring them along.
- An upload that stops is deleted by a daily sweep, once an hour has passed since its last chunk, or since the file was added if no chunk came.
- A forward’s own files are read from the email when the draft is sent. They hold no bytes on the draft and do not count against your room. They have a
from_message_idand achunk_countof 0.
content_id, so its HTML cannot show one inside the message. To show an image inside an email, send it with POST /v1/messages and a file with a content_id.Change a draft
PATCH /v1/drafts/{id} changes the fields you send and leaves the rest. To be sure nobody changed the draft since you read it, send its updated_at in the If-Match header. A draft that changed answers 409 duplicate_resource with param updated_at, and nothing is written. Adding a file, a file’s last chunk arriving and removing a file each move updated_at too, so read the draft again after any of them.
curl -X PATCH "https://api.mails.ai/v1/drafts/$DRAFT_ID" \
-H "Authorization: Bearer $MAILS_API_KEY" \
-H "Content-Type: application/json" \
-H "If-Match: 2026-10-31T09:20:41.000Z" \
-d '{ "subject": "Your order is on its way" }'DELETE /v1/drafts/{id} takes If-Match too. A draft that changed or was sent since you read it answers 409 duplicate_resource with param updated_at, and is not deleted. A draft that is being sent is never deleted, with the header or without it: that answers 409 duplicate_resource with no param.
Send or schedule it
POST /v1/drafts/{id}/send sends a draft now, or at a future send_at. It takes an Idempotency-Key, so a retry with the same key cannot send the draft twice.
Send only what you last read
Set If-Match to the updated_at you last read, as on a change. A draft that changed since answers 409 duplicate_resource with param updated_at, and nothing goes, whether it was to go now or at a send_at. Without the header, the draft goes as it stands.
curl -X POST "https://api.mails.ai/v1/drafts/$DRAFT_ID/send" \
-H "Authorization: Bearer $MAILS_API_KEY" \
-H "If-Match: 2026-10-31T09:20:41.000Z" \
-H "Idempotency-Key: send-order-2741"mails drafts send dft_01J9X4B6T1 \
--if-match 2026-10-31T09:20:41.000Z \
--idempotency-key send-order-2741If-Match is for the API and the CLI, where mails drafts update and mails drafts delete take it too. The SDKs have no option for it and no MCP tool has an argument for it, so mails_send_draft sends the draft as it stands.
What can stop a send
A draft can be refused for anything that stops a send (see Errors), and for these reasons of its own. The SDKs, the CLI and mails_send_draft all reach this one route, so they get these answers too.
400missing_fieldwithparamto: it names nobody.400invalid_field: it was already sent or canceled, or it failed. Copy it to try once more.403live_mode_required: a test key tried a draft a live key made.403test_mode_required: a live key tried a reply to test mail, which would mail an address a test made up. Send it with a test key. A live key gets the same answer on aPATCHthat schedules one, and on areply_kinddraft asked for on test mail.409duplicate_resourcewithparamupdated_at: the draft changed. ItsIf-Matchno longer matches, or a change or a file landed just as the send began. Read it again.409duplicate_resourcewith noparam: it is already going out, or something changed it while you were scheduling it.409invalid_fieldwithparamattachments: a file is still uploading, or a forwarded file can no longer be read.
Draft events
draft.created, draft.updated and draft.deleted follow a draft, beside the events its sending raises. A webhook endpoint gets those three only when it lists them by name. A PATCH that schedules raises draft.scheduled and then draft.updated, and clearing send_at raises draft.updated alone. Agent and draft events says what each one carries, and how often draft.updated comes while someone types.
In the dashboard
The composer
- Compose, or C, opens the composer at the bottom right of the inbox. On a phone it opens as a full page.
- Autosave: there is no save button. Each change is kept in your browser first, then saved as a draft, and the composer says when a save has not reached us yet. If the draft was changed in another tab or by a teammate, the composer stops and shows what changed before it writes over that.
- Send sends what your screen shows. If someone else changed the draft first, nothing goes, and the composer shows what they changed. Files they added or removed, a new send time or agent, and a change to the HTML, reply address, unsubscribe link or tags already show in it. Only a change to the recipients, subject or body gives you a choice: Keep mine or Use theirs. An address that is not one, or more than 50 on a line, stops the send and says so.
- Deleted elsewhere: if the draft is deleted in another tab or by a teammate while you write, your words become a new draft and the composer says so. Its files went with the old draft, so attach them again. On the draft’s own page, its address, ID, menu and API drawer move to the new draft as soon as it exists. Discard or Delete there acts on the new draft, waiting for it if it is still being made. The title and status catch up when the page is next loaded.
- Minimise turns the composer into a bar that stays while you move around the inbox. Up to three can be open, one showing.
- Close keeps what you wrote in Drafts, which lists the last changed first.
- Attach files, or drag files onto the message. A draft’s limits apply, and uploading goes on while the composer is minimised.
- Open the full page for a send time and a test send.
The composer writes plain text, so it cannot place an image inside the message. Attach it instead.
Reply, Reply all and Forward
Reply, Reply all and Forward sit beneath the last email of a thread and open there. Reply all shows when someone else is on the email. A reply is a draft from its first word, so leaving the thread loses nothing, and anyone on your team who opens the thread finds it.
Test drafts
A draft made with a test key shows Test in Drafts while it waits. Scheduled from the dashboard, it stays a test and delivers nothing, as it does when a test key sends it. Send now on its page sends it for real, unless it answers test mail: that goes out as a test however it is sent.
Discard
Discard takes a draft away and shows Undo. Press it, or ⌘ + Z (Ctrl + Z on Windows), while the message shows, and the draft comes back. Any composer holding the draft closes at once. A scheduled draft stops being scheduled at once, and Undo puts its time back or says why it cannot.
The draft is deleted only as you saw it. If a teammate has changed it, or it is sent, before Undo runs out, it is not deleted, and you are told why.
In Drafts, point at a row and press Discard draft, or tick several rows and press Discard in the bar. The bar discards only unsent drafts and tells you how many it left, because a sent or failed draft is deleted from its own menu, which asks first. Ticking rows is on desktop only, because a phone has no row boxes. On a phone, each row’s … menu discards that one draft.
Copy a draft
Copy to a new draft, in a draft’s … menu, starts a draft with the same recipients, subject and text, and it becomes a draft of its own when you change it. A failed draft is not sent again, so this is how you try once more.
A copy does not bring files. When the draft has any, the page says so, and you add them again there.
Routes
POST /Create a draft. Needs thev1/ drafts sendordraftscope.GET /List drafts. Needs thev1/ drafts readordraftscope.GET /Retrieve a draft. Needs thev1/ drafts/ {id} readordraftscope.PATCH /Update a draft. Needs thev1/ drafts/ {id} manageordraftscope.DELETE /Delete a draft. Needs thev1/ drafts/ {id} manageordraftscope.POST /Send a draft. Needs thev1/ drafts/ {id}/ send sendscope.GET /List a draft's files. Needs thev1/ drafts/ {id}/ attachments readordraftscope.POST /Add a file to a draft. Needs thev1/ drafts/ {id}/ attachments manageordraftscope.PUT /Upload a chunk of a draft's file. Needs thev1/ drafts/ {id}/ attachments/ {attachment_id}/ chunks/ {index} manageordraftscope.DELETE /Remove a file from a draft. Needs thev1/ drafts/ {id}/ attachments/ {attachment_id} manageordraftscope.
Next steps
Was this page helpful?