Mailbox (Webmail & Mobile)¶
The mailbox-holder surface -- what the open-source webmail and the mobile app are built on. Everything lives under the /api/v1/mailbox prefix and is authenticated by a mailbox session (see Authentication -- Mailbox Sessions): the mailyte_mailbox_session cookie the login sets, or Authorization: Bearer <token> for a native client.
No route here carries an account id. The session says which mailbox the request is for, which makes cross-mailbox access structurally impossible rather than a per-route check. Responses use the standard envelope ({"type", "msg", "data"}); some errors additionally carry a stable error_code the client can branch on. A 502 always means the mail server itself was unreachable -- nothing the caller sends can fix it.
Message ids are FOLDER:UID
A message id scopes the IMAP UID with the folder that holds it, e.g. INBOX:1042 or Clients/Acme:7. Moving a message (including to Trash) gives it a new id, returned by the move: the real UID the server assigned in the destination (UIDPLUS COPYUID), or null if the server did not report one -- never the old UID relabelled. A message in a shared mailbox keeps its Shared/<address>/ prefix on the new id. Treat ids as opaque and short-lived: a folder's uid_validity changing invalidates every id minted from it.
This page covers folders, messages, sending, contacts and settings. The same router also serves GET /capabilities, the security/2FA group, optional AI assistance, and the Sieve-backed rules/forwarding/vacation group -- the capability manifest says which of those exist on a deployment.
Capabilities¶
Runtime feature detection: which optional groups are configured on this deployment (rules, forwarding, vacation, ai, scheduled_send, ...). Clients fetch it right after sign-in and hide any control it does not advertise.
| Key | Means |
|---|---|
bulk_actions | POST /messages/bulk exists |
draft_attachments | Draft saves take files and keep_attachments, and return the new draft's attachments |
Keys are only ever added, never renamed -- this is a published contract and a rename is a breaking change for every self-hoster. An absent key means absent: a client reading scheduled_send off an older server gets undefined, which must read as "no", not as "probably yes".
Folders¶
List Folders¶
Response data -- an array, INBOX first, then special-use, then the rest alphabetically:
| Field | Type | Description |
|---|---|---|
id | string | Stable folder identifier (md5 of the name). This is what the folder routes below take in their path |
name | string | Full IMAP name, /-separated for nested folders (Clients/Acme) |
role | string|null | inbox, drafts, scheduled, sent, junk, trash, archive, or null for a user folder. Detected from the SPECIAL-USE attribute first, well-known names second. scheduled has no SPECIAL-USE attribute -- RFC 6154 defines none -- so it is recognised by name; it appears once the mailbox first schedules a message (Schedule Send) |
total / unread | integer | Message counts |
uid_next / uid_validity | integer | IMAP change tokens |
Cheap change detection
Poll this list and compare uid_next / uid_validity per folder: unchanged tokens mean nothing arrived, moved or was deleted -- one request, no messages transferred.
Create Folder¶
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | 1-255 chars; no /, \ or control characters |
Creates and subscribes the folder. Labels are folders here. Returns the full folder list.
Rename Folder¶
{folder} is the id from the folder list (the literal name is also accepted for flat folders, but names can contain /, so the id is the form to store).
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | The new last segment only; same character rules as creation |
A nested folder keeps its parent (Clients/Acme renamed to Acme Ltd becomes Clients/Acme Ltd), and subfolders move along with it. Returns {"id", "name", "folders"} -- the rename changes the folder's id, and every message id minted under the old name with it.
Errors
| Status | error_code | When |
|---|---|---|
409 | cannot_rename_special_folder | INBOX or a special-use folder (Sent, Drafts, Junk, Trash, Archive -- by SPECIAL-USE attribute or well-known name) |
400 | -- | Name already exists, reserved, or refused by the server |
Delete Folder¶
Deletes an empty, non-special folder (unsubscribing it first) and returns the updated folder list.
Errors
| Status | error_code | When |
|---|---|---|
409 | cannot_delete_special_folder | INBOX or a special-use folder |
409 | folder_not_empty | Messages still inside. Deliberate: IMAP DELETE destroys contents with no Trash step, so the holder empties the folder first. The client should warn the holder, not retry |
409 | folder_has_subfolders | Subfolders still inside |
404 | -- | No such folder |
Messages¶
List Messages¶
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
folder | string | INBOX | Folder name (not id). Omit it while passing a search to search the whole account |
search / q | string | -- | Server-side IMAP search over headers and bodies (both names accepted) |
unread | boolean | false | Only unread |
starred | boolean | false | Only starred |
limit | integer | 50 | 1-200 |
offset | integer | 0 | Paging offset |
Response data: {"messages", "total", "offset", "limit", "has_more", "folder"}. Each row carries:
id, folder, subject, from/to/cc/bcc/reply_to (arrays of {name, email}), received_at (ISO 8601 or null), size, has_attachment, is_read, is_starred, is_answered, is_draft, preview (~200 chars or null), and the threading identifiers below.
Threading fields (list rows and detail, always present, null when the message carries no identifier):
| Field | Description |
|---|---|
message_id | The RFC 5322 Message-ID, unfolded |
in_reply_to | The In-Reply-To header, unfolded |
references | The References header, unfolded (single-spaced) |
thread_id | The conversation key: the first References token (the root of the chain), else the In-Reply-To id, else the message's own message_id. Derived identically for list rows and detail, so grouping on it never disagrees between the two |
is_answered reflects the IMAP \Answered flag; the send route sets it on the original when a reply goes out (see Send).
Read One Message¶
Everything a list row has, plus body_text, body_html (each string or null), attachments (below), and the provenance block -- fields read from headers the receiving side stamped, never from anything the sender wrote for display. Each is always present and null when absent:
| Field | Type | Description |
|---|---|---|
mailed_by | string|null | Domain of the Return-Path (the envelope sender). Null for the null sender (<>) and for messages never received (e.g. your own Sent copies) |
signed_by | string|null | The d= domain of the message's DKIM signature. When several signatures exist, the one Authentication-Results reports as dkim=pass wins; otherwise the first signature's d= is still reported -- read authentication.dkim for the verdict |
security | string|null | "tls" when the receiving hop for this server used TLS (ESMTPS), "none" when it did not. Internal hops (Dovecot LMTP, loopback re-injection) are skipped. Null when the message has no Received trail at all |
list_unsubscribe | object|null | {"mailto", "url", "one_click"} parsed from List-Unsubscribe. one_click is true only for RFC 8058 compliance: an https target and List-Unsubscribe-Post: List-Unsubscribe=One-Click |
authentication | object | {"spf", "dkim", "dmarc"}, each pass | fail | softfail | neutral | none | temperror | permerror | null, from the topmost Authentication-Results (the one this server's resolver wrote at ingress) |
delivered_to | string[] | The addresses this server delivered the message to, from Delivered-To / X-Original-To, lowercased, de-duplicated. Unlike to, these are envelope recipients: mail forwarded from support@company.com still says To: support@company.com, and only this says it arrived for you+tag@yourdomain. Plus addressing is on (you+anything@ lands in you@), and the tag is kept here. Empty when absent |
auto_submitted | string|null | The Auto-Submitted header, lowercased (RFC 3834). Anything but no means software sent it -- an out-of-office or other automatic reply |
precedence | string|null | The Precedence header, lowercased: bulk, list, junk or auto_reply mark mail no person wrote |
x_autoreply | string|null | X-Autoreply (or X-Autorespond), lowercased, set by some auto-responders instead of Auto-Submitted |
Detail only
The provenance block requires the full header block, which list rows deliberately never fetch -- rows stay one cheap batched FETCH per page.
Each entry of attachments:
| Field | Description |
|---|---|
index | Position in MIME walk order -- the handle the download and forward routes take |
name, type, size | Filename, content type, decoded size in bytes |
is_inline | Declared Content-Disposition: inline (usually a picture shown in the body) |
content_id | The Content-ID the HTML references as cid:<content_id>, or null |
Reading a message does not mark it read (the fetch uses BODY.PEEK); the client says so explicitly via /mark-read.
Download the Original (raw RFC 822)¶
The stored bytes exactly as the mail server holds them -- headers, MIME structure and encodings untouched, never a re-serialisation. Returned as Content-Type: message/rfc822 with Content-Disposition: attachment and a sanitised <subject>-<uid>.eml filename. This is what "Show original" / "Download .eml" load. Does not mark the message read.
Attachments¶
GET /api/v1/mailbox/messages/{message_id}/attachments
GET /api/v1/mailbox/messages/{message_id}/attachments/{index}
The list route returns the same array as the detail view. The download route streams the decoded part with its own content type; Content-Disposition is always attachment (rendering sender-supplied HTML in the app's origin would be stored XSS). A stale index is a 404.
Thread¶
The conversation the message belongs to, oldest first, as list-row objects. Follows Message-ID / In-Reply-To / References -- never subject lines -- searching the message's own folder plus Sent.
Flags¶
POST /api/v1/mailbox/messages/{message_id}/mark-read
POST /api/v1/mailbox/messages/{message_id}/mark-unread
POST /api/v1/mailbox/messages/{message_id}/star
POST /api/v1/mailbox/messages/{message_id}/unstar
Each sets or clears one IMAP system flag and answers {"type": "success", "msg": "Message updated"}. A message that no longer exists (moved or deleted by another client) is a 404 with error_code: not_found -- IMAP itself answers a STORE on a missing UID with a silent OK, so the server checks first.
Move, Trash, Delete¶
POST /api/v1/mailbox/messages/{message_id}/move {"folder": "<name>"}
POST /api/v1/mailbox/messages/{message_id}/trash
DELETE /api/v1/mailbox/messages/{message_id}
move and trash return {"id": <new id>} -- the destination UID from COPYUID, or null if the server reported none. Trashing something already in Trash is a no-op that returns the same id, never a destroy. Permanent deletion is refused with 409 / error_code: not_in_trash unless the message is already in Trash -- nothing outside Trash is ever destroyed, and the expunge is UID EXPUNGE of that one UID, so messages another client has flagged \Deleted without expunging are left alone.
Errors (all three, and the flag routes above)
| Status | error_code | When |
|---|---|---|
404 | not_found | The message is no longer there. A client removing it can treat this as already done |
409 | not_in_trash | DELETE on a message outside Trash |
Bulk Actions¶
One action on many messages in one request: one IMAP connection per mailbox the ids live in, and one SELECT + one UID SEARCH + one UID command per folder. The per-message routes above cost a full connection each.
| Field | Type | Required | Description |
|---|---|---|---|
ids | array of string | Yes | 1-500 message ids. Duplicates are dropped; order is kept. Ids travel in the body, so nested and shared folders need no path encoding |
action | string | Yes | mark_read, mark_unread, star, unstar, move, trash, delete, labels |
folder | string | for move | Destination folder name. A plain name means that folder in whichever mailbox the message is in; a Shared/<address>/... name must be the message's own mailbox |
add, remove | array of string | for labels | Label names to add / remove (up to 20 each), normalised as on the single-message labels route |
trashresolves Trash by role. Ids already in Trash succeed withnew_idequal to their own id.deletedestroys only ids already in Trash; the rest getnot_in_trash.- A move into or out of Junk emits
spam.reported/spam.false_positiveand trains the classifier exactly as the single-message move does.
Response -- always 200 once the request itself is valid; the result list is the answer:
{
"type": "success",
"msg": "2 of 3 messages moved",
"data": {
"results": [
{ "id": "INBOX:4521", "ok": true, "new_id": "Archive:88" },
{ "id": "INBOX:4522", "ok": true, "new_id": "Archive:89" },
{ "id": "INBOX:4523", "ok": false, "error_code": "not_found",
"message": "That message no longer exists" }
],
"succeeded": 2,
"failed": 1
}
}
new_id appears on move and trash results only (and may be null, as above). Each failed result's error_code corresponds to the status the single-message route would have answered:
error_code | Single-route equivalent | Meaning |
|---|---|---|
not_found | 404 | The message is not there |
not_in_trash | 409 | delete outside Trash |
mail_server_unavailable | 502 | The connection failed before this id was attempted, or while its folder was being processed |
forbidden | 403 | A read-only member of that shared mailbox |
invalid_id | 400 | Not a FOLDER:UID id |
cross_mailbox | 400 | move to a shared folder in a different mailbox |
failed | 400 | The server refused the command (for example a move to a folder that does not exist) |
The request is a 502 only when the mail server could not be reached for any id at all; 422 when ids is empty or over 500, move has no folder, or labels has nothing to add or remove.
Drafts¶
Drafts are real messages in the Drafts folder, readable by every client on the account. Accepts JSON, or multipart/form-data when the draft carries new files. Repeated fields are accepted bare or PHP-style (to[]). All fields tolerate being absent: autosave never fails on a half-typed message.
| Field | Type | Description |
|---|---|---|
to, cc, bcc | array|string | As on send |
subject, body_text, body_html, in_reply_to, references | string | body_html up to 5 MB; data: images stay as they are in a draft, so a resumed draft keeps its pictures |
replace_id (alias replaces_id) | string | The draft this save supersedes, expunged only after the new copy lands. Must be a message in this mailbox's Drafts folder; any other id is ignored (and logged) rather than deleted |
attachments | file(s) | Multipart only. New files for this draft |
keep_attachments | array of int | Which of the replaced draft's attachments to carry over, by the index values its GET (or the previous save) returned. Absent keeps all of them; an explicit list keeps exactly those, and an empty list keeps none. In multipart, send keep_attachments[] once per index, or once with an empty value to keep none |
The 20-attachment / 25 MB limits cover new files and kept parts together. A carried inline image the new body_html still references by cid: stays inline under the same id.
Response data: {"id", "folder", "attachments"} -- the new draft's id and its attachment list, shaped as on Read One Message. Indexes change whenever the MIME structure does, so a client uses the ones from the latest save; it never has to fetch the draft again to learn them.
Errors
| Status | error_code | When |
|---|---|---|
409 | draft_gone | keep_attachments names parts, but the draft being replaced no longer exists (discarded or sent from another client) |
409 | not_a_draft | keep_attachments names parts, but replace_id is not a draft |
422 | -- | A keep_attachments index that is not an attachment of that draft (stale), keep_attachments without replace_id, or over the attachment limits |
The DELETE route permanently discards a draft (allowed outside Trash -- a discarded draft was never delivered mail). It is refused with 409 / error_code: not_a_draft for any message not in the Drafts folder, and answers 404 / not_found for a draft that is already gone.
Send a Message¶
Accepts JSON (no uploaded files) or multipart/form-data (with files). The From address is always the signed-in mailbox -- nothing the client sends can change it. Repeated fields are accepted both bare (to) and PHP-style (to[]).
| Field | Type | Required | Description |
|---|---|---|---|
to, cc, bcc | array|string | at least one recipient total | Bare addresses or Name <addr> forms; comma/semicolon lists accepted |
subject | string | No | |
body_text, body_html | string | one of them | data: images in the HTML are converted to Content-ID parts on send |
in_reply_to | string | No | The RFC message_id of the message being replied to (brackets optional). Sets the reply's In-Reply-To header and flags the original \Answered after a successful submit |
in_reply_to_id | string | No | The API id (FOLDER:UID) of the message being replied to. Optional hint that lets the \Answered flag land without a search; verified against the Message-ID before being trusted |
references | string | No | The reply's References chain (the original's references plus its message_id) |
attachments | file(s) | No | Multipart only; up to 20 files, 25 MB total |
forward_attachments_from | string | No | The API id of a message in this mailbox (or a shared one you are a member of) whose attachments should be carried over -- the client never re-uploads them. A draft is a valid source: sending a draft names the draft's own id here with the index values it kept, beside any new attachments |
attachment_indexes | array of int | string | No | Which index values from that message to carry (JSON array, repeated fields, or "1,3"). A stale index is a 422 naming it |
send_at | string | No | ISO-8601 instant to send at instead of now (schedule send). See below |
Forwarded inline parts whose content_id the new body_html still references (cid:<content_id>) go out as inline parts under the same id, so pictures render where the original showed them; carried parts the body no longer references become ordinary attachments rather than being dropped. The 20-attachment / 25 MB limits apply to uploads and carried parts together.
Response data: {"id", "message_id", "answered_id"} -- the Sent copy's API id (null if filing failed after a successful send), the new message's RFC Message-ID, and the API id of the message that was flagged \Answered (null when in_reply_to was absent or the original could not be found).
Bcc is never transmitted
Blind recipients are delivered to through the SMTP envelope; the Bcc header is removed from the message before it is handed to Postfix, and from the Sent copy. Postfix does not do this for you -- what an MUA presents on DATA is what is delivered -- so it is this endpoint's job.
Person-to-person mail is not tracked
Campaign mail on this platform gets an open pixel and rewritten links injected in transit. Mailbox sends opt out by default: the API adds an internal X-Mailyte-Tracking: off header, the filter skips injection and strips the header before delivery, so it never reaches a recipient. A deployment that wants mailbox sends tracked sets MAILBOX_SEND_TRACKING=true on the api service.
Schedule Send¶
Setting send_at on the send route stores the message and sends it later, rather than now.
curl -X POST -H "Authorization: Bearer $MAILBOX_TOKEN" \
-H "Content-Type: application/json" \
-d '{"to":["bob@example.net"],"subject":"Monday","body_text":"Morning.",
"send_at":"2026-09-15T08:00:00Z"}' \
http://your-server:8083/api/v1/mailbox/messages/send
Response data: {"id", "folder", "send_at", "message_id"} -- the id the message can be cancelled by, the folder it is waiting in, and the time it will go, echoed back to the second.
Where the message waits matters: it is APPENDed to the mailbox's own Scheduled folder as an ordinary message, so a pending send is visible in Outlook, Thunderbird and on a phone, not only in the webmail. The folders route reports it with role: "scheduled".
send_atis an absolute instant. Any ISO-8601 form is accepted; one with an offset (...Z,+01:00) is unambiguous and is what a client should send. A naive value is read as the server's local time.- It must be at least a minute ahead and at most a year (
SCHEDULED_SEND_MIN_LEAD_SECONDS,SCHEDULED_SEND_MAX_AHEAD_DAYS). Outside that,422with a message to show the user. - The message is assembled at schedule time by the same builder a send uses, so what goes out is what was written.
- The api service sweeps for due messages every
SCHEDULED_SEND_SWEEP_SECONDS(30 by default), under a MySQL advisory lock so replicas never double-send. - Moving or deleting the stored message in another client cancels the send. The sweeper finds no message and drops the schedule rather than sending some other copy.
- A send that fails permanently leaves the message in
Scheduledand the schedule markedfailedwith the reason -- it is never silently dropped, and never retried once it may already have reached Postfix.
GET /api/v1/mailbox/messages/scheduled
DELETE /api/v1/mailbox/messages/scheduled/{message_id}
GET returns {"messages": [...]}, each {"id", "folder", "send_at", "status", "attempts", "error", "created_at"}, soonest first. id is the ordinary FOLDER:UID message id, so this joins onto a folder listing rather than duplicating it. status is pending or failed; failed ones are included deliberately.
DELETE cancels: the message is moved back to Drafts, never destroyed, and the schedule is forgotten. Returns {"id", "folder"} with the id in Drafts (null if the server did not report it). 404 if that message is not scheduled, or -- with error_code: not_found -- if it has already left the Scheduled folder, which in practice means it was sent.
Capability key: scheduled_send on GET /api/v1/mailbox/capabilities. A client must gate on it -- a server that predates this ignores send_at and sends the message immediately.
Example (JSON reply)
curl -X POST -H "Authorization: Bearer $MAILBOX_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"to": ["bob@example.net"],
"subject": "Re: lunch?",
"body_text": "Sounds good.",
"in_reply_to": "<m1@example.net>",
"in_reply_to_id": "INBOX:1042",
"references": "<m0@example.net> <m1@example.net>"
}' \
http://your-server:8083/api/v1/mailbox/messages/send
Example (forward with carried attachments)
curl -X POST -H "Authorization: Bearer $MAILBOX_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"to": ["carol@example.com"],
"subject": "Fwd: contract",
"body_html": "<p>FYI</p>",
"forward_attachments_from": "INBOX:1042",
"attachment_indexes": [2, 3]
}' \
http://your-server:8083/api/v1/mailbox/messages/send
Contacts¶
Addresses this mailbox actually corresponds with -- harvested from Sent and INBOX headers, most-used first, as {name, email, count} -- for compose autocomplete. There is no address book to maintain.
Settings¶
Read Settings¶
Response data
| Field | Description |
|---|---|
email_address, name | The signed-in mailbox |
signature_html | Sanitised HTML signature (empty string when unset) |
signature_on_reply | Whether the signature is added to replies too |
display_density | comfortable or compact |
undo_send_enabled, undo_send_seconds | Undo window; seconds is one of 5, 10, 20, 30 |
storage | Live quota from the mail server: used_mb, quota_mb, percentage (null = unlimited), used_bytes, quota_bytes, messages |
Update Settings¶
Partial update -- only the fields present change. The signature is sanitised against an allowlist before storage (it is HTML this server later attaches to outgoing mail); an unsafe signature is a 422 with the reason. Returns the same payload as the read.
Related groups on this router¶
- Security & two-factor --
GET /security,POST /security/2fa/*,GET/DELETE /security/sessions*. Mailbox 2FA gates the webmail sign-in only, never IMAP/SMTP. - AI assistance --
POST /ai/compose,POST /ai/summarize/{message_id}; present only when the deployment configures an endpoint (503/ai_not_configuredotherwise). - Rules, forwarding, vacation --
GET/PUT /rules,/forwarding,/vacation; Sieve-backed, absent without a ManageSieve master credential (503/sieve_not_configured).