Skip to content

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

Text Only
GET /api/v1/mailbox/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

Text Only
GET /api/v1/mailbox/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

Text Only
POST /api/v1/mailbox/folders
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

Text Only
PATCH /api/v1/mailbox/folders/{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

Text Only
DELETE /api/v1/mailbox/folders/{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

Text Only
GET /api/v1/mailbox/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

Text Only
GET /api/v1/mailbox/messages/{message_id}

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)

Text Only
GET /api/v1/mailbox/messages/{message_id}/raw

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

Text Only
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

Text Only
GET /api/v1/mailbox/messages/{message_id}/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

Text Only
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

Text Only
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

Text Only
POST /api/v1/mailbox/messages/bulk

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
  • trash resolves Trash by role. Ids already in Trash succeed with new_id equal to their own id.
  • delete destroys only ids already in Trash; the rest get not_in_trash.
  • A move into or out of Junk emits spam.reported / spam.false_positive and 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:

JSON
{
  "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

Text Only
POST /api/v1/mailbox/messages/draft
DELETE /api/v1/mailbox/messages/draft/{message_id}

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

Text Only
POST /api/v1/mailbox/messages/send

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.

Bash
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_at is 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, 422 with 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 Scheduled and the schedule marked failed with the reason -- it is never silently dropped, and never retried once it may already have reached Postfix.
Text Only
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)

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

Bash
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

Text Only
GET /api/v1/mailbox/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

Text Only
GET /api/v1/mailbox/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

Text Only
PUT /api/v1/mailbox/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.

  • 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_configured otherwise).
  • Rules, forwarding, vacation -- GET/PUT /rules, /forwarding, /vacation; Sieve-backed, absent without a ManageSieve master credential (503 / sieve_not_configured).