Skip to main content

Drafts: write now, add files, send later

Save an email before it is ready. Add its recipient and files later, then send it or schedule it.

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);

Sending 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 400 invalid_idempotency_key. Use a new key for a new request.
  • The same key while the first request runs answers 409 duplicate_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" }'

The 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_… or rcv_…), named in the email's In-Reply-To header. 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 (404 resource_not_found).
  • reply_kind (string): Make this the reply to, or the forward of, the email named by in_reply_to_message_id, which is then required. It needs the read scope 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, and to, cc and subject default as POST /v1/messages/{id}/reply and /forward set them; a forward's body_text defaults to the quoted email, and its files come along. One of: reply, forward.
  • reply_all (boolean): With reply_kind: "reply": also copy everyone else on the email, as reply_all does 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);

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:

  1. POST /v1/drafts/{id}/attachments names the file and its size. The answer gives its id, chunk_bytes and chunk_count.
  2. PUT /v1/drafts/{id}/attachments/{attachment_id}/chunks/{index} carries one chunk as the raw request body, numbered from 0. Every chunk is chunk_bytes long 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.
  3. The file’s status turns from uploading to ready when its last chunk arrives. Its bytes cannot change after that: a chunk sent to it answers 409 duplicate_resource with param index, 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),
  );
}
{
  "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 400 invalid_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 402 resource_cap_exceeded.
  • How much is used: storage on 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 402 if 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_id and a chunk_count of 0.
Inline images are not supported on drafts. A draft’s files are attachments, with no 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"

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

  • 400 missing_field with param to: it names nobody.
  • 400 invalid_field: it was already sent or canceled, or it failed. Copy it to try once more.
  • 403 live_mode_required: a test key tried a draft a live key made.
  • 403 test_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 a PATCH that schedules one, and on a reply_kind draft asked for on test mail.
  • 409 duplicate_resource with param updated_at: the draft changed. Its If-Match no longer matches, or a change or a file landed just as the send began. Read it again.
  • 409 duplicate_resource with no param: it is already going out, or something changed it while you were scheduling it.
  • 409 invalid_field with param attachments: 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

Next steps

Was this page helpful?