API Reference
Base path: /api/v1. All responses are JSON. All IDs are ULIDs (time-sortable, globally unique).
The full OpenAPI 3.1 spec is also served at runtime at GET /api/v1/openapi.yaml.
REST and WebSocket API for RustMail, a self-hosted SMTP mail catcher. All IDs are ULIDs (time-sortable). All timestamps are ISO 8601 UTC. Base path: /api/v1
Requests a browser made, carrying fetch metadata (Sec-Fetch-*), an Origin, or a Mozilla/ user agent, are answered only when addressed to an IP address, to localhost, or to a name given to --allowed-host; anything else gets 403. Other clients (CI scripts, curl, the TUI) are answered on any host.
A GET or HEAD still running after 30 seconds is abandoned and answered 503 with the usual Error body. Writes are never cut short, so a 503 never stands for a change that still lands. The WebSocket is exempt.
A path under /api/ that matches no endpoint is answered 404 with {"error": "Unknown API endpoint"}, never with the UI's HTML. Request bodies are read as application/json; a body without that content type, or one the JSON extractor cannot map onto the request schema, is rejected before the handler runs with a text/plain body (415, 400 or 422) rather than the Error object.
Servers
List messages
Returns messages newest first, in arrival order. Page with before: pass the previous response's next_cursor to get the next page. A cursor page costs the same at any depth and does not shift when new mail arrives, where offset does both. offset is still accepted, but not together with before. starred, unread, has_attachments and tag narrow the listing on the server; they combine with AND, with each other and with q. total counts the messages matching q and the filters, before pagination is applied.
Parameters
Query Parameters
Full-text search query (matches subject, body, sender, recipients)
Maximum number of results to return. Values outside 1 to 200 (0 and negatives included) are clamped into that range rather than rejected; the response's limit reports the value applied. A value that is not an integer is a 400.
50Number of results to skip. A negative value is treated as 0. Kept for compatibility; prefer before, which it cannot be combined with.
0Keyset cursor: the id of a message. Returns only messages strictly older than it, in the same order. Use a previous response's next_cursor. Works with q. An id that names no stored message is a 400 with code: unknown_cursor.
When true, only starred messages. false does not filter: it is the same as leaving the parameter out, not its negation. Only the literals true and false are accepted; anything else, 1 included, is a 400. If repeated, the last value wins.
When true, only messages not marked as read. false does not filter: it is the same as leaving the parameter out, not its negation. Only the literals true and false are accepted; anything else, 1 included, is a 400. If repeated, the last value wins.
When true, only messages with at least one attachment. false does not filter: it is the same as leaving the parameter out, not its negation. Only the literals true and false are accepted; anything else, 1 included, is a 400. If repeated, the last value wins.
Only messages carrying this tag, compared exactly and case-sensitively. May repeat (tag=a&tag=b): a message matches if it carries any of the given tags. More than 20 is a 400.
20Responses
Paginated list of message summaries. Sent with Cache-Control: no-store.
Samples
Delete all messages
Get a single message with full body
Responses
Full message including parsed body fields. Sent with Cache-Control: no-store.
Samples
Delete a single message
Update message fields
Sets whichever of is_read, is_starred and tags the body names and leaves the others alone. The body must be sent as Content-Type: application/json. Unknown fields are ignored, and {} is accepted as a no-op. Each field that was set is announced on the WebSocket (message:read, message:starred, message:tags); a rejected update announces nothing.
Request Body
Responses
Update applied. No body; re-read the message to see its new state.
Samples
Download raw RFC 822 message source
Returns the whole source by default. Pass limit to read only the first N bytes, which is what the UI's Raw tab does so that opening a multi-megabyte message neither transfers nor renders all of it.
Parameters
Query Parameters
Maximum number of bytes to return, counted from the start of the message.
1310721Responses
Raw RFC 822 message bytes, truncated to limit when given (a limit past the end returns the whole source). Sent with Content-Type: message/rfc822 and Cache-Control: private, max-age=31536000, immutable, and no Content-Disposition: use /export for a download.
Samples
List the message header fields
Returns the header section only, in wire order, with duplicates preserved and folded lines joined by a single space. Cheaper than fetching the raw source when only the headers are needed.
Responses
Header fields in wire order
Samples
List attachments for a message (metadata only)
Returns a bare JSON array, in MIME part order. An id that names no stored message is not an error: it answers 200 with [].
Responses
Attachment metadata, possibly empty. Sent with Cache-Control: private, max-age=31536000, immutable.
Samples
Download an attachment
Responses
Attachment bytes, decoded. Always sent as Content-Type: application/octet-stream, whatever the stored content_type (read that from the attachment list), with Content-Disposition: attachment; filename="<name>", where every character outside A-Z a-z 0-9 . _ - is replaced by _ and a part with no filename is named attachment. Also sent with Cache-Control: private, max-age=31536000, immutable and X-Content-Type-Options: nosniff, and never compressed.
Samples
Get an inline image by Content-ID
Returns the part whose Content-ID matches cid exactly (case-sensitive, without angle brackets). Used by the HTML preview to resolve cid: references. The stored content type is sent only when it is one of image/png, image/jpeg, image/gif, image/webp, image/avif or image/bmp; any other part is served as application/octet-stream.
Responses
Part bytes, decoded. Sent with Cache-Control: private, max-age=31536000, immutable, X-Content-Type-Options: nosniff and Content-Security-Policy: default-src 'none'.
Samples
Export a message as EML or JSON
Parameters
Query Parameters
eml (the default) returns the raw RFC 822 bytes as a downloadable file. json returns the same Message object as GET /messages/{id}, as a download.
"eml""json""eml"Responses
Exported message with Content-Disposition: attachment; filename="<id>.eml" or "<id>.json". eml is message/rfc822 with Cache-Control: private, max-age=31536000, immutable; json is application/json with Cache-Control: no-store.
Samples
Forward a captured message to a real SMTP server
Disabled unless the server was started with --release-host. The message's raw source is sent unchanged, with the captured envelope (MAIL FROM and RCPT TO), to host over TLS. On port 465 the connection is implicit TLS (SMTPS), TLS from its first byte. On every other allowed port (25, 587, 2525) it starts in plaintext and STARTTLS is required: if the relay does not offer it or the upgrade fails, the message is not sent. Checks run in this order: release enabled, host, configured port, allowed port, message lookup, envelope.
Request Body
Responses
The relay accepted the message
Samples
Authentication
Email authentication header parsing (DKIM, SPF, DMARC, ARC)
Operations
Get email authentication results
Parses authentication-related headers from the raw message and returns structured DKIM, SPF, DMARC, and ARC results. Reads Authentication-Results, DKIM-Signature, Received-SPF, and ARC-Authentication-Results headers. Does not perform cryptographic validation; it displays what upstream mail servers have already verified.
Responses
Parsed authentication results
Samples
Assert message count matches constraints
Returns 200 OK when the number of messages matching the given filters satisfies the min/max bounds. Returns 417 Expectation Failed otherwise. Designed for use in CI pipelines with curl -f. min defaults to 1 and max to no upper bound, so a bare call asserts that at least one message matches; pass min=0 to only read the count. The filters combine with AND.
Parameters
Query Parameters
Minimum number of matching messages (inclusive)
1Maximum number of matching messages (inclusive). Unbounded when omitted.
"int64"Filter by subject substring, case-insensitive. % and _ match themselves, not as wildcards.
Filter by sender address substring, case-insensitive. % and _ match themselves, not as wildcards.
Filter by recipient address substring, case-insensitive. % and _ match themselves, not as wildcards.
Responses
Assertion passed
Samples
WebSocket real-time event stream
Upgrade to a WebSocket connection to receive real-time inbox events. Each event is one JSON text frame of the form {"type": "<event>", "data": {...}}; messages:clear has no data field. Only events that happen after the connection opens are sent: there is no replay, so fetch GET /api/v1/messages to build the initial view. Frames the client sends carry no meaning to the server.
Event types:
| type | data | |---|---| | message:new | MessageSummary object, recipients and tags as arrays | | message:delete | { "id": "<ulid>" } | | message:read | { "id": "<ulid>", "is_read": true \| false } | | message:starred | { "id": "<ulid>", "is_starred": true \| false } | | message:tags | { "id": "<ulid>", "tags": ["tag1", "tag2"] } | | messages:clear | (no data field) |
One PATCH that sets several fields sends one event per field, in the order read, starred, tags.
Keepalive and idle timeout. The server pings on connect and every 30 seconds. Only frames from the client (a pong, or anything else) count as activity: a client silent for 90 seconds is sent a Close frame and disconnected, however many events are flowing to it. Browsers answer pings on their own; other clients must answer them. A single send that cannot complete within 10 seconds (a client that stopped reading) also closes the connection.
Slow clients. Events are fanned out through a bounded buffer. A client that falls far enough behind to lose events is not sent the ones that survive: it is sent a Close frame and disconnected. Its incremental view is already wrong at that point, so on any disconnect a client should reconnect and refetch the message list rather than resume from the events it saw.
Responses
Switching Protocols. The WebSocket handshake succeeded.
