Mutations and sending
Modify provider state, manage drafts and mailboxes, and create durable Sync submissions from RFC 5322 messages.
Writes are intent-shaped: an app says archive, move, mark spam, or send and Supermail translates that intent to classical IMAP/SMTP, Gmail, or Microsoft Graph. Every call is double-gated by the live grant and the account's working capability.
Staged content
Draft and send bytes never enter JSON-RPC or Gateway storage. Upload the exact RFC 5322 bytes first:
POST /gateway/content?account_id=account_01H9X...&purpose=send
Authorization: Bearer <access_token>
Content-Type: message/rfc822
From: me@example.com
To: you@example.net
Message-ID: <compose-42@example.com>
Hello.{ "upload_ref": "up_01H..." }Use purpose=modify for Drafts/put (mail.modify) and purpose=send for
MessageSubmissions/create (mail.send). Handles are account-bound,
short-lived, and deleted when a submission reaches a terminal state. The send
upload must contain an RFC 5322 Message-ID header; it is the crash-ambiguity
check that prevents a second provider effect.
Message mutations
All require mail.modify plus working.modify on the account.
| Method | Request |
|---|---|
Messages/set_flags | account_id, provider_id, at least one of seen / flagged |
Messages/patch_labels | account_id, provider_id, add and remove user-label ID arrays |
Messages/archive / unarchive | account_id, provider_id |
Messages/trash / restore | account_id, provider_id |
Messages/mark_spam / unmark_spam | account_id, provider_id |
Messages/delete | account_id, provider_id |
Messages/move | account_id, provider_id, folder_id |
Every message mutation requires an idempotency_key. Retry the same intended
action with the same key; a changed target or payload with that key is refused.
A later deliberate action gets a new key. provider_id is the opaque
ProviderId returned by email events.
patch_labels supports Gmail custom labels independently of message location.
Each array admits up to 100 exact label IDs; either may be empty, their union
must be nonempty, and duplicates or overlap are refused. System labels retain
the dedicated read/star/archive/spam/trash actions. The native adapter validates
the labels under the same account; draft targets are refused.
Labels/create refuses a name the provider cannot store with
label_name_unsupported, for example an IMAP name that maps to a reserved
keyword ($…, Junk), a Gmail system label's name (Sent, Chats, in any
case) or an Outlook category name with a comma (on the master list also a
semicolon). A name another Gmail label or Outlook category already has is
refused with label_name_taken. A color outside the
account's scheme (for Gmail, outside its label palette) is refused with
label_color_unsupported before any request. The refusal is permanent: retrying the same name cannot
succeed, so keep such a label in your own store instead. Timeouts and rate
limits are transient and may be retried.
IMAP stores a label as the keyword Thunderbird derives for a tag of that name:
letters, digits and safe symbols stay, every other UTF-8 byte becomes =xx,
the keyword is lowercased, and one over 50 characters becomes &x-moz- and a
128-bit SHA-256 prefix. "Project X" is stored as project=20x; the created
label's id is the keyword.
Each call returns the original Operation. RETRYING retains a transient
preflight failure before any provider write; the same operation retries with
backoff. RUNNING means execution is in progress, UNCERTAIN means the
provider outcome is being established automatically, and RECONCILING means
the provider confirmed the action while Inbox convergence is pending.
AWAITING_OWNER means automatic resolution is exhausted: owner_decision
names the typed decision the mailbox owner makes; Supermail never repeats the
write on its own. Every operation carries a positive version that owner
decisions name as expected_version. SUCCEEDED, FAILED, and CANCELED are terminal. Use Operations/get to
refresh status, or Operations/list filtered by idempotency_key to recover a
lost acceptance response. An uncertain action must never be replaced silently
with a fresh key.
Owner decisions
There is no operator. Supermail resolves a write whose outcome is unknown by itself: it reads the target back, applies the write again when the readback proves it was not applied (every mail change sets a state, so this is safe), and re-syncs the account from the provider before a final readback. Only what stays unknowable after that becomes an owner decision, and nobody but the mailbox owner is ever asked.
owner_decision.kind | Decided by | Choices |
|---|---|---|
review_change (with evidence and retry_available) | Operations/decide | retry (only when retry_available) or dismiss |
confirm_delivery (with submission_id) | MessageSubmissions/decide_delivery | send_again, already_sent or discard |
resolve_sent_copy (with submission_id and cause) | MessageSubmissions/resolve_filing | retry or abandon |
repair_account (with cause) | Accounts/repair | Rebuild the account's local copy from the provider |
Every decision request carries an idempotency_key and the expected_version
the owner saw. A replay with the same key returns the first answer. A stale
version is refused with STATE_MISMATCH, and that refusal is replayed too. A
transient failure (rate limit, unavailable dependency, lost reply) binds
nothing, so the same request may be sent again. Operations/list and
MessageSubmissions/list accept an optional statuses filter, for example
OPERATION_STATUS_AWAITING_OWNER to collect every pending decision.
retry applies the change again on the owner's authority. dismiss keeps the
mailbox as the provider shows it and cancels the operation. Accounts/repair
rebuilds the account's local copy from the provider; credentials, drafts and
submissions are kept, and the repair settles the account_repair operation.
Keys are account-wide across operation kinds. Mail requests may carry a
source_scope containing the exact original stream_id and lineage_id.
Fresh admission refuses a replaced or closed source; request-bound replay of an
already accepted operation remains stable. Inbox always retains this condition
with its original local-ID request and native translation.
Relocation results retain a provider-acknowledged successor message identity when available. Inbox observes current visibility separately from provider success; an operation cursor is not an Inbox applied-Commit receipt. Missing native correlation remains explicit and allows refreshing or safely dismissing the completed action, without guessing from a name or Message-ID.
Drafts
Draft methods require mail.modify plus working.modify.
| Method | Request |
|---|---|
Drafts/put | account_id, upload_ref from a purpose=modify upload; optional draft_id and expected_revision to replace an existing draft |
Drafts/get | account_id, draft_id |
Drafts/list | account_id; optional page (size, token) |
Drafts/delete | account_id, draft_id, expected_revision |
Drafts/put and Drafts/delete accept an optional idempotency_key and return
an Operation.
Mailbox mutations
Mailboxes/create, Mailboxes/rename, and Mailboxes/delete require
mail.modify and working.modify. Create takes account_id + name; rename
takes account_id + folder_id + name; delete takes account_id +
folder_id. Each requires an idempotency_key and returns an
Operation. Mailboxes/list reads an account's current folders.
Sending
After a send-purpose upload, create a durable submission:
{
"jsonrpc": "2.0",
"id": 1,
"method": "MessageSubmissions/create",
"params": {
"account_id": "account_01H9X...",
"upload_ref": "up_01H...",
"idempotency_key": "compose-42"
}
}Requires mail.send plus working.send. The idempotency key is required and
scoped to the account: replaying it returns the same sub_ row and never creates
a second submission. To send an existing draft instead, call
MessageSubmissions/create_from_draft with account_id, draft_id,
expected_revision, and idempotency_key.
The result is a MessageSubmission whose initial status is
MESSAGE_SUBMISSION_STATUS_ACCEPTED or
MESSAGE_SUBMISSION_STATUS_TRANSMITTING. Completion appends exactly one
email.sent or email.send_failed event, and each status change appends a
message_submission.changed event.
Submission status
Call MessageSubmissions/get with account_id and submission_id. It requires
mail.send, the grant must still cover the submission's account, and that
account must still have a working send capability. MessageSubmissions/list
pages through an account's submissions, and MessageSubmissions/cancel takes
the same account_id + submission_id pair.
Terminal states are MESSAGE_SUBMISSION_STATUS_PROVIDER_ACCEPTED,
MESSAGE_SUBMISSION_STATUS_FAILED (with failure),
MESSAGE_SUBMISSION_STATUS_CANCELED, and MESSAGE_SUBMISSION_STATUS_RESENT.
Treat non-terminal states as pending and retry get; retrying create with
the same idempotency key is also safe.
A lost provider reply never causes another send. MESSAGE_SUBMISSION_STATUS_UNCERTAIN
means Supermail is searching the provider's Sent folder for the message's
Message-ID; a unique copy confirms the send. When the searches cannot confirm
it, the status becomes MESSAGE_SUBMISSION_STATUS_AWAITING_SENDER_DECISION
with delivery_evidence: when the send was attempted, why its outcome is
unknown, and every search with its time and result. A daily search continues
for a week and confirms the send by itself if a copy appears. The sender
decides with MessageSubmissions/decide_delivery:
send_againcreates a new submission with byte-identical content and the same Message-ID; some recipients may receive the message twice. The original becomesMESSAGE_SUBMISSION_STATUS_RESENTwithsuperseded_by, and the new submission names it inretransmission_of. The response carries both assubmissionandsuccessor, andMessageSubmissions/getreads the successor with the same grant. The original's operation endsOPERATION_STATUS_CANCELEDwith itstargetset to the successor (typemessage_submission); the successor's own operation reports the delivery from then on.already_sentmarks it sent.discardcancels it.
While a Sent search of that delivery is running, a decision is refused with
RETRY_LATER and its key stays unbound; the same request succeeds once the
search settles (unless the search found the message, which delivers it).
Errors
| Reason | Meaning |
|---|---|
NOT_SUPPORTED | The account profile/provider cannot perform the verb. |
UPLOAD_REF_STALE | The staged handle expired, belongs to another account, or was consumed. Upload again. |
SEND_REJECTED | The provider permanently refused the message. Fix the message rather than retrying unchanged. |
A missing grant scope is PERMISSION_DENIED; a scope-valid write whose account
capability is currently down is FAILED_PRECONDITION.