Skip to content

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​

http://localhost:8025Default local server

List messages​

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

q

Full-text search query (matches subject, body, sender, recipients)

Type
string
limit

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.

Type
integer
Default
50
offset

Number of results to skip. A negative value is treated as 0. Kept for compatibility; prefer before, which it cannot be combined with.

Type
integer
Default
0
before

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

Type
string
starred

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.

Type
boolean
unread

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.

Type
boolean
has_attachments

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.

Type
boolean
tag

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.

Type
array
Max Items
20

Responses​

Paginated list of message summaries. Sent with Cache-Control: no-store.

application/json
JSON
{
  
"messages": [
  
  
{
  
  
  
"id": "01HZ9RQABCDEFGHJKMNPQRSTUV",
  
  
  
"sender": "alice@example.com",
  
  
  
"recipients": [
  
  
  
  
[
  
  
  
  
  
"bob@example.com",
  
  
  
  
  
"carol@example.com"
  
  
  
  
]
  
  
  
],
  
  
  
"subject": "Hello world",
  
  
  
"size": 1024,
  
  
  
"has_attachments": false,
  
  
  
"is_read": false,
  
  
  
"is_starred": false,
  
  
  
"tags": [
  
  
  
  
[
  
  
  
  
]
  
  
  
],
  
  
  
"created_at": "2026-03-21T10:00:00Z"
  
  
}
  
],
  
"total": 120,
  
"next_cursor": "01HZ9RQABCDEFGHJKMNPQRSTUV",
  
"limit": 50
}

Samples​


Delete all messages​

DELETE
/api/v1/messages

Responses​

Number of messages deleted

application/json
JSON
{
  
"deleted": 42
}

Samples​


Get a single message with full body​

GET
/api/v1/messages/{id}

Responses​

Full message including parsed body fields. Sent with Cache-Control: no-store.

application/json
JSON
{
  
"id": "01HZ9RQABCDEFGHJKMNPQRSTUV",
  
"sender": "alice@example.com",
  
"recipients": [
  
  
[
  
  
  
"bob@example.com",
  
  
  
"carol@example.com"
  
  
]
  
],
  
"subject": "Hello world",
  
"size": 1024,
  
"has_attachments": false,
  
"is_read": false,
  
"is_starred": false,
  
"tags": [
  
  
[
  
  
]
  
],
  
"created_at": "2026-03-21T10:00:00Z",
  
"text_body": "string",
  
"html_body": "string"
}

Samples​


Delete a single message​

DELETE
/api/v1/messages/{id}

Responses​

Message deleted

Samples​


Update message fields​

PATCH
/api/v1/messages/{id}

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​

application/json
JSON
{
  
"is_read": true,
  
"is_starred": true,
  
"tags": [
  
  
"string"
  
]
}

Responses​

Update applied. No body; re-read the message to see its new state.

Samples​


Download raw RFC 822 message source​

GET
/api/v1/messages/{id}/raw

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

limit

Maximum number of bytes to return, counted from the start of the message.

Type
integer
Example131072
Minimum
1

Responses​

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.

message/rfc822

Samples​


List the message header fields​

GET
/api/v1/messages/{id}/headers

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

application/json
JSON
[
  
{
  
  
"name": "Subject",
  
  
"value": "Welcome aboard"
  
}
]

Samples​


List attachments for a message (metadata only)​

GET
/api/v1/messages/{id}/attachments

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.

application/json
JSON
[
  
{
  
  
"id": "01HZ9RQABCDEFGHJKMNPQRFILE",
  
  
"message_id": "01HZ9RQABCDEFGHJKMNPQRSTUV",
  
  
"filename": "invoice.pdf",
  
  
"content_type": "application/pdf",
  
  
"content_id": "logo@example.com",
  
  
"size": 48321
  
}
]

Samples​


Download an attachment​

GET
/api/v1/messages/{id}/attachments/{aid}

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.

application/octet-stream

Samples​


Get an inline image by Content-ID​

GET
/api/v1/messages/{id}/inline/{cid}

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​

Download messages in alternative formats


Export a message as EML or JSON​

GET
/api/v1/messages/{id}/export

Parameters​

Query Parameters

format

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.

Type
string
Valid values
"eml""json"
Default
"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​


Release​

Forward a captured message to a real SMTP server


Forward a captured message to a real SMTP server​

POST
/api/v1/messages/{id}/release

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​

application/json
JSON
{
  
"host": "smtp.mailgun.org",
  
"port": 587
}

Responses​

The relay accepted the message

application/json
JSON
{
  
"released": true
}

Samples​


Authentication​

Email authentication header parsing (DKIM, SPF, DMARC, ARC)


Get email authentication results​

GET
/api/v1/messages/{id}/auth

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

application/json
JSON
{
  
"dkim": [
  
  
{
  
  
  
"status": "pass",
  
  
  
"details": "dkim=pass header.d=example.com header.s=selector1"
  
  
}
  
],
  
"spf": [
  
  
{
  
  
  
"status": "pass",
  
  
  
"details": "dkim=pass header.d=example.com header.s=selector1"
  
  
}
  
],
  
"dmarc": [
  
  
{
  
  
  
"status": "pass",
  
  
  
"details": "dkim=pass header.d=example.com header.s=selector1"
  
  
}
  
],
  
"arc": [
  
  
{
  
  
  
"status": "pass",
  
  
  
"details": "dkim=pass header.d=example.com header.s=selector1"
  
  
}
  
]
}

Samples​


Assert​

CI/CD assertion endpoints; return 200 on pass, 417 on failure


Assert message count matches constraints​

GET
/api/v1/assert/count

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

min

Minimum number of matching messages (inclusive)

Type
integer
Default
1
max

Maximum number of matching messages (inclusive). Unbounded when omitted.

Type
integer
Format
"int64"
subject

Filter by subject substring, case-insensitive. % and _ match themselves, not as wildcards.

Type
string
sender

Filter by sender address substring, case-insensitive. % and _ match themselves, not as wildcards.

Type
string
recipient

Filter by recipient address substring, case-insensitive. % and _ match themselves, not as wildcards.

Type
string

Responses​

Assertion passed

application/json
JSON
{
  
"ok": true,
  
"count": 2,
  
"expected_min": 1,
  
"expected_max": 9223372036854776000
}

Samples​


WebSocket​

Real-time event stream


WebSocket real-time event stream​

GET
/api/v1/ws

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.

Samples​


Powered by VitePress OpenAPI

Released under the MIT / Apache 2.0 License.