API domain -- L1¶
Endpoint table¶
All endpoints are prefixed with /v1.
In browser-localhost mode, posthaste serve serves the built React frontend on non-API paths and keeps all JSON/SSE endpoints under /v1. Unknown /v1 paths return API 404s rather than the frontend shell.
Health¶
| Method | Path | Handler | Request | Response |
|---|---|---|---|---|
| GET | /health |
health |
-- | HealthResponse |
HealthResponse contains only status: "ok" for product API readiness; richer lab diagnostics are not exposed by this endpoint.
Settings¶
| Method | Path | Handler | Request | Response |
|---|---|---|---|---|
| GET | /settings |
get_settings |
-- | AppSettings |
| PATCH | /settings |
patch_settings |
PatchSettingsRequest |
AppSettings |
| POST | /automation-rules:preview |
preview_automation_rule |
PreviewAutomationRuleRequest |
AutomationRulePreviewResponse |
Accounts¶
| Method | Path | Handler | Request | Response |
|---|---|---|---|---|
| GET | /accounts |
list_accounts |
-- | AccountOverview[] |
| POST | /accounts |
create_account |
CreateAccountRequest |
AccountOverview |
| GET | /accounts/{account_id} |
get_account |
-- | AccountOverview |
| PATCH | /accounts/{account_id} |
patch_account |
PatchAccountRequest |
AccountOverview |
| DELETE | /accounts/{account_id} |
delete_account |
-- | OkResponse |
| POST | /accounts/{account_id}/verify |
verify_account |
-- | VerificationResponse |
| POST | /oauth/start |
start_provider_oauth |
StartProviderOAuthRequest |
StartOAuthResponse |
| POST | /accounts/{account_id}/enable |
enable_account |
-- | OkResponse |
| POST | /accounts/{account_id}/disable |
disable_account |
-- | OkResponse |
| POST | /accounts/{account_id}/logo |
upload_account_logo |
raw image bytes | AccountOverview |
| GET | /account-assets/logos/{image_id} |
get_account_logo |
-- | image bytes |
Smart mailboxes¶
| Method | Path | Handler | Request | Response |
|---|---|---|---|---|
| GET | /smart-mailboxes |
list_smart_mailboxes |
-- | SmartMailboxSummary[] |
| POST | /smart-mailboxes |
create_smart_mailbox |
CreateSmartMailboxRequest |
SmartMailbox |
| GET | /smart-mailboxes/{id} |
get_smart_mailbox |
-- | SmartMailbox |
| PATCH | /smart-mailboxes/{id} |
patch_smart_mailbox |
PatchSmartMailboxRequest |
SmartMailbox |
| DELETE | /smart-mailboxes/{id} |
delete_smart_mailbox |
-- | OkResponse |
| POST | /smart-mailboxes:reset-defaults |
reset_default_smart_mailboxes |
-- | SmartMailboxSummary[] |
| GET | /smart-mailboxes/{id}/messages |
list_smart_mailbox_messages |
ListSmartMailboxMessagesQuery |
MessagePageResponse |
| GET | /smart-mailboxes/{id}/conversations |
list_smart_mailbox_conversations |
ListConversationsQuery |
ConversationPageResponse |
Typed read calls¶
| Method | Path | Handler | Request | Response |
|---|---|---|---|---|
| POST | /read |
read |
ReadRequest |
ReadResponse |
POST /read executes typed, read-only domain operations in order. It is the
client-composable replacement for UI-shaped bootstrap endpoints: the backend
exposes domain read methods, while clients decide which graph to request.
Supported operation names:
| Operation | Args | Result |
|---|---|---|
Account/list |
-- | { ids, enabledIds, items: AccountOverview[] } |
Mailbox/list |
accountIds?: string[] | "#<callId>.ids" | "#<callId>.enabledIds" |
{ byAccountId: Record<accountId, MailboxSummary[]> } |
SmartMailbox/list |
-- | { items: SmartMailboxSummary[] } |
Tag/list |
accountIds?: string[] | "#<callId>.ids" | "#<callId>.enabledIds" |
{ items: TagSummary[] } |
Later calls may reference earlier Account/list results. For example, a client
can request accounts, then mailboxes and tags for only enabled accounts in the
same round trip:
{
"calls": [
{ "id": "accounts", "op": "Account/list" },
{
"id": "mailboxes",
"op": "Mailbox/list",
"args": { "accountIds": "#accounts.enabledIds" }
},
{ "id": "smartMailboxes", "op": "SmartMailbox/list" },
{
"id": "tags",
"op": "Tag/list",
"args": { "accountIds": "#accounts.enabledIds" }
}
]
}
A read request may contain at most 16 calls. Invalid call IDs or result references
return invalid_query with HTTP 400. The first implementation fails the whole read
request on an invalid call or backend read error rather than returning partial
per-call errors.
Conversations and messages¶
| Method | Path | Handler | Request | Response |
|---|---|---|---|---|
| GET | /views/conversations |
list_conversations |
ListConversationsQuery |
ConversationPageResponse |
| GET | /views/conversations/{id} |
get_conversation |
-- | ConversationView |
| GET | /messages/search |
search_messages |
SearchMessagesQuery |
MessagePageResponse |
| GET | /sources/{source_id}/mailboxes |
list_mailboxes |
-- | MailboxSummary[] |
| PATCH | /sources/{source_id}/mailboxes/{mailbox_id} |
patch_mailbox |
PatchMailboxRequest |
MailboxSummary[] |
| GET | /sources/{source_id}/messages |
list_source_messages |
ListSourceMessagesQuery |
MessagePageResponse |
| GET | /sources/{source_id}/messages/{id} |
get_message |
-- | MessageDetail |
Compose¶
| Method | Path | Handler | Request | Response |
|---|---|---|---|---|
| GET | /sender-addresses |
list_sender_addresses |
-- | CachedSenderAddress[] |
| GET | /sources/{source_id}/identity |
get_identity |
-- | Identity |
| GET | /sources/{source_id}/messages/{id}/reply-context |
get_reply_context |
-- | ReplyContext |
| POST | /sources/{source_id}/commands/send |
send_message |
SendMessageRequest |
OkResponse |
SendMessageRequest includes optional from: Recipient. When present, the
backend uses that sender address for the outgoing RFC 5322 From field. The
route source_id is still the account that submits the message; the frontend
may choose that account from configured sender suggestions or wildcard ownership.
After a successful send, the backend records the selected sender in the local
SQLite sender_address_cache; failed or rejected sends do not update it.
GET /sender-addresses returns those accepted free-form sender suggestions
across accounts for compose autosuggest.
Message commands¶
| Method | Path | Handler | Request | Response |
|---|---|---|---|---|
| POST | /sources/{sid}/commands/messages/{mid}/set-keywords |
set_keywords |
SetKeywordsCommand |
CommandResult |
| POST | /sources/{sid}/commands/messages/{mid}/add-to-mailbox |
add_to_mailbox |
AddToMailboxCommand |
CommandResult |
| POST | /sources/{sid}/commands/messages/{mid}/remove-from-mailbox |
remove_from_mailbox |
RemoveFromMailboxCommand |
CommandResult |
| POST | /sources/{sid}/commands/messages/{mid}/replace-mailboxes |
replace_mailboxes |
ReplaceMailboxesCommand |
CommandResult |
| POST | /sources/{sid}/commands/messages/{mid}/destroy |
destroy_message |
-- | CommandResult |
Sync and events¶
| Method | Path | Handler | Request | Response |
|---|---|---|---|---|
| POST | /sources/{source_id}/commands/sync |
trigger_sync |
TriggerSyncRequest |
{ ok, eventCount, mode } |
| POST | /config:reload |
reload_config |
-- | OkResponse |
| GET | /events |
stream_events |
EventsQuery |
SSE stream |
TriggerSyncRequest.mode defaults to incremental. fullMetadata performs an authoritative message metadata refresh for the account while preserving optional body/blob cache entries where possible.
SSE resource-change contract¶
SSE events are durable declarative facts, not frontend commands. Every mutating settings/config endpoint must append and publish a resource-change event after the durable write succeeds. The frontend maps changed resources to read-model invalidations, then refetches truth through normal REST endpoints.
| Mutation | Event topic | Resource payload |
|---|---|---|
PATCH /settings |
settings.updated |
appSettings.updated, plus changed[] section names (cachePolicy, defaultAccount, automationRules, automationDrafts) |
| account create/update/delete/enable/disable/logo/OAuth | account.created, account.updated, account.deleted |
account.{created,updated,deleted} |
POST /smart-mailboxes |
smart_mailbox.created |
smartMailbox.created |
PATCH /smart-mailboxes/{id} |
smart_mailbox.updated |
smartMailbox.updated |
DELETE /smart-mailboxes/{id} |
smart_mailbox.deleted |
smartMailbox.deleted |
POST /smart-mailboxes:reset-defaults |
smart_mailbox.reset |
smartMailbox.reset |
PATCH /sources/{source_id}/mailboxes/{mailbox_id} |
mailbox.updated/sync events |
mailbox.updated |
POST /sources/{source_id}/commands/sync |
sync.completed or sync.failed |
sync.completed/sync.failed, with mode when available |
POST /config:reload |
config.reloaded |
config.reloaded, plus source account resources from the diff |
Global config events use the reserved event account id app because the current event log schema requires an account_id. Unfiltered /events consumers receive them; account-filtered consumers must subscribe to the global stream as well if they need app-wide settings changes.
Error format¶
All error responses are JSON objects with three fields:
Error code mapping¶
ServiceError code |
HTTP status |
|---|---|
not_found |
404 |
conflict, state_mismatch |
409 |
auth_error |
401 |
gateway_unavailable |
503 |
network_error |
502 |
gateway_rejected, secret_unavailable, secret_unsupported |
400 |
config_validation, config_parse |
400 |
config_io |
500 |
| (other) | 500 |
Request validation errors use handler-specific codes: invalid_account, invalid_secret, invalid_cursor, invalid_limit, invalid_query, invalid_compose, invalid_mailbox.
Mailbox metadata¶
PATCH /sources/{source_id}/mailboxes/{mailbox_id} updates mailbox metadata. The initial supported request field is role; valid values are inbox, archive, drafts, sent, junk, trash, or null to clear the role. JMAP accounts apply this through Mailbox/set. IMAP/SMTP accounts persist the role as a local override over SPECIAL-USE discovery, because IMAP has no portable remote role mutation. When assigning a role that another mailbox currently owns, the server first clears the old owner, then assigns the new owner. After the mutation succeeds, the server refreshes the account's mailbox projection and returns the current MailboxSummary[].
Cursor pagination¶
Conversation and message list endpoints accept limit, cursor, sort, sortDir, and q query parameters. The default limit is 100; the maximum is 250. A limit of 0 or above 250 returns invalid_limit.
Message list endpoints return MessagePageResponse { items, nextCursor }. They accept q as the same search query text used by the command/search panel. For source message lists, q is ANDed with the selected source and optional mailbox. For smart-mailbox message lists, q is ANDed with the saved smart-mailbox rule. GET /messages/search requires q and searches across all sources without issuing one request per source. Invalid query text returns invalid_query.
Sort parameters¶
| Param | Type | Default | Values |
|---|---|---|---|
sort |
ConversationSortField? |
date |
date, from, subject, source, threadSize, flagged, attachment |
sort |
MessageSortField? |
date |
date, from, subject, source, flagged, attachment |
sortDir |
SortDirection? |
desc |
asc, desc |
The backend sorts conversations by (sort_key, conversation_id) and messages by (sort_key, source_id, message_id) in the requested direction. For example, sort=from&sortDir=asc orders by sender ascending, breaking ties by stable IDs.
Cursor format¶
The cursor is an opaque string. Conversation cursors encode the active sort value and conversation ID; message cursors encode the active sort value, source ID, and message ID. Clients must not inspect the format. The backend decodes the cursor and uses seek-based pagination to produce the next page. The response includes nextCursor if more results exist; null otherwise. Pages are strictly past the cursor in the current sort order, with no OFFSET-based skipping.
SSE event stream¶
GET /v1/events opens a Server-Sent Events stream. Query parameters:
| Param | Type | Description |
|---|---|---|
accountId |
string? | Filter events to a single account |
topic |
string? | Filter by event topic |
mailboxId |
string? | Filter by mailbox |
afterSeq |
integer? | Resume from this sequence number (exclusive) |
When afterSeq is provided, the backend replays matching events from the event_log table (backlog) before switching to the live broadcast stream. This allows the frontend to reconnect without missing events.
Each SSE event has id set to the event's sequence number and data set to the JSON-serialized DomainEvent.
The machine-readable event contract is the AsyncAPI 3.0 document asyncapi.json at the repo root (served at /v1/asyncapi.json); its EventTopic enum is drift-checked against posthaste_domain::ALL_EVENT_TOPICS.
The stream sends keepalive comments at the default Axum interval to prevent connection timeout.
account.status_changed events include the account runtime fields status,
push, lastSyncAt, lastSyncError, lastSyncErrorCode, and
syncProgress. These values use the same JSON shape as AccountOverview, so
clients may patch cached account rows directly instead of refetching on every
progress tick.
Account CRUD lifecycle¶
Create: POST /accounts accepts account name, optional full name, email address/pattern ownership, provider driver, transport details, and a secret instruction. If id is omitted, the backend derives an internal unique ID from the first email pattern or account name. The endpoint applies the secret instruction, validates required fields, persists to config, starts the supervisor runtime, and emits an account.created event. JMAP accounts require a base URL and configured secret; username is optional for bearer-token auth. IMAP/SMTP accounts require username, configured secret, explicit IMAP and SMTP endpoints, and a concrete sender address via emailPatterns.
Patch: PATCH /accounts/{id} merges provided fields into the existing account. Omitted fields in the transport sub-object preserve their current values (sparse merge). Secret handling uses the backend SecretWriteMode tri-state: keep (preserve existing), replace (store new secret in keyring), clear (delete managed secret). The settings UI exposes this as an empty password field to keep the configured secret or a filled password field to replace it.
Delete: DELETE /accounts/{id} removes the managed OS keyring secret (if any), treating an already-missing keyring entry as deleted, stops the supervisor runtime, deletes the config file, and emits an account.deleted event.
Verify: POST /accounts/{id}/verify attempts provider connection setup and returns whether the connection succeeded, the primary identity email when available, and whether push is supported.
OAuth: POST /oauth/start starts provider-first OAuth setup for a built-in provider. POST /accounts/{id}/oauth/start starts the same authorization-code flow for an existing account whose provider has a built-in profile. The request supplies OAuth clientId, optional clientSecret for providers such as Google Desktop OAuth that require it at the token endpoint, and loopback redirectUri; the backend stores the PKCE verifier and OIDC nonce, then returns only the authorization URL, state, and redirect URI. GET /oauth/callback validates the one-time state, exchanges the authorization code, discovers and caches the provider JWKS, verifies the ID-token signature, checks the issuer, audience, expiry, nonce, and verified-email status, stores the token set as an OS-keyring secret, and either creates a new IMAP/SMTP account from the provider identity email or updates the existing account secret, switches the existing transport to auth: oauth2, restarts the supervisor runtime, and emits the matching account.created or account.updated event.
Enable/Disable: Toggle enabled flag, re-persist, and restart the supervisor (which respects the flag).
Transport and connection variants: Account create/patch transport JSON uses camelCase. Common input fields are provider, auth, username, secret, and optional JMAP baseUrl. provider remains the compatibility setup field; account overview connection responses also expose providerKind as the stable provider family identity. IMAP/SMTP account input also includes imap and smtp endpoint objects with host, port, and security (tls, startTls, or plain). PATCH /accounts/{id} sparse-merges the transport object and preserves omitted sub-fields. AccountOverview exposes this as a connection variant tagged by kind: manualCredentials for password/app-password accounts with editable server credentials, or managedOAuth for provider-owned OAuth accounts whose endpoint and credential details are managed by the OAuth flow.
Runtime progress: AccountOverview includes syncProgress while a sync is
running and null otherwise. The object contains syncId, trigger,
startedAt, stage, detail, and optional mailboxName, mailboxIndex,
mailboxCount, messageCount, and totalCount. The values are user-facing
progress hints, not a complete execution trace; backend logs remain the detailed
diagnostic source.
Appearance: AccountOverview includes a resolved appearance object for the account mark. Account config may persist either { kind: "initials", initials, colorHue } or { kind: "image", imageId, initials, colorHue }. If no appearance is configured, the API derives initials and a stable hue from the account. PATCH /accounts/{id} can update letter/color appearance. POST /accounts/{id}/logo accepts raw PNG, JPEG, WebP, or GIF bytes up to 2 MiB, stores the image under the config root, updates account appearance to image, and returns the updated overview. Logo bytes are served from GET /account-assets/logos/{image_id}.
Application settings¶
App-wide settings carried by AppSettings / PatchSettingsRequest — automation
rules and cache policy — plus the automation-rule preview endpoint. These are
mail/backend settings semantics, distinct from account lifecycle above and from
client presentation preferences such as theme or layout.
Automation rules: AppSettings and PatchSettingsRequest include automationRules for active rules and automationDrafts for persisted incomplete editor state. Each rule has id, name, enabled, triggers, condition, actions, and backfill. condition uses the same smart-mailbox rule tree as saved searches. Account and mailbox restrictions are ordinary query conditions, not a separate rule scope. PATCH replaces the full active rule list when automationRules is present and preserves it when omitted; the same replacement rule applies to automationDrafts. Active rule IDs must be unique, active rules need at least one trigger and one action, tag actions must target non-system keywords, and move actions must target a non-empty mailbox ID. Draft rule IDs must be present and unique across active and draft rules, but draft names, triggers, and actions may be incomplete. Draft rules are not executed and do not enqueue backfill. When automationRules is present, the backend saves the rules and enqueues durable low-priority backfill jobs for enabled accounts if the current enabled backfill-rule fingerprint has not already completed.
Global appearance preferences are client-owned presentation state, not daemon
application settings. The frontend stores them through the client preferences
boundary described in L1-ui; they are intentionally absent from
AppSettings, PatchSettingsRequest, and settings.updated resource changes.
Cache policy: AppSettings and PatchSettingsRequest include cachePolicy with softCapBytes, hardCapBytes, cacheBodies, cacheRawMessages, and cacheAttachments. PATCH preserves the existing policy when omitted. When provided, the backend normalizes hardCapBytes to be at least softCapBytes before persisting the settings.
POST /automation-rules:preview accepts a draft rule condition and optional limit, then returns AutomationRulePreviewResponse { total, items } using the same indexed rule evaluator as smart mailboxes. Results are newest-first. The default preview limit is 5 and the maximum is 50.
The frontend exposes mailbox action editors, but they persist through global automationRules and automationDrafts. Pressing Save action writes the current editor item as an active rule when it passes active-rule validation, or as a draft otherwise. A smart-mailbox action is represented as a global automation with an ID prefix owned by that smart mailbox and a condition combining the selected account condition, the smart-mailbox rule, and the action rule's own condition. A source-mailbox action is represented as a global automation with an ID prefix owned by the source mailbox and a condition combining the selected account condition, the source mailbox ID condition, and the action rule's own condition.
Secret management¶
Account secrets are opaque authentication material. For JMAP accounts this may be an OAuth token set, a provider API token, or a development credential accepted by the provider. For OAuth accounts, the stored OS-keyring value is a JSON token set containing the access token, refresh token when granted, expiry, scopes, provider, and OAuth client ID; the runtime refreshes it before provider connection and passes only the current access token to XOAUTH2-capable gateways. The API must not assume that the value is a Fastmail app-specific password.
Secrets use a tri-state write mode:
| Mode | Behavior |
|---|---|
keep |
Preserve existing secret_ref; no secret value allowed |
replace |
Store the submitted secret value in OS keyring under account:{id} key; secret value required |
clear |
Delete managed OS secret; no secret value allowed |
The API never returns secret values. Responses include SecretStatus with storage (os/env), configured (bool), and label (env var name for env-type, redacted for os-type).
Smart mailbox CRUD¶
Create: POST /smart-mailboxes generates an ID from the name (sm-{slug}-{uuid}), persists to config.
Patch: PATCH /smart-mailboxes/{id} merges name, position, and rule fields.
Reset defaults: POST /smart-mailboxes:reset-defaults restores all default smart mailboxes (Inbox, Archive, Drafts, Sent, Junk, Trash, All Mail) and returns the full list.
Message body sanitization¶
Message summary and detail responses include sender metadata and the to recipient list captured during provider sync. Clients use the stored recipients to label sent/outgoing messages without re-fetching raw message headers.
GET /sources/{source_id}/messages/{id} sanitizes body_html through sanitize_email_html before returning to the frontend. This is the only place HTML is sanitized in the API layer; the sanitization runs in Rust before the response is serialized.
Assertions¶
| ID | Sev. | Assertion |
|---|---|---|
| error-format | MUST | All error responses are JSON with code, message, details fields |
| cursor-opaque | MUST | Conversation cursors are opaque to clients; format is not part of the contract |
| camelcase-json | MUST | All response bodies use camelCase keys |
| sse-resume | MUST | SSE clients can resume from afterSeq without replaying history |
| status-event-runtime-payload | SHOULD | account.status_changed carries account runtime fields, including sync progress, in the same JSON shape as AccountOverview |
| html-sanitized | MUST | Message body HTML is sanitized in Rust before reaching the response |
| secret-redacted | MUST | Secret values are never included in API responses |
| sparse-merge | MUST | PATCH endpoints preserve omitted fields rather than nulling them |
| limit-bounds | MUST | Conversation and message limits are between 1 and 250; invalid values return 400 |