Skip to content

Contracts (L1)

1. Boundary

1.1 Contract

The API is the external /v1 contract for non-renderer clients, tools, tests, and integration surfaces. API clients call HTTP endpoints, receive camelCase JSON, fetch authenticated binary resources, and observe authority-server state through the /v1/events Server-Sent Events stream or its API-platform successor.

The bundled application renderer uses the runtime adapter's link-scoped RuntimeFrame stream for primary mail state. It must not depend on /v1/events as a renderer cache-invalidation path. The adapter preserves the same mail semantics where it exposes the same operations.

HTTP and SSE are delivery modes over the same mail-state projection model. HTTP returns requested snapshots, pages, details, command responses, and binary resources. SSE reports ordered state assertions and coarse lifecycle events. They must not become two separate descriptions of mail state.

For remote-authority deployments, /v1 is also a transport for a coherent link (replication L1): mutations (MutationRequest carrying the typed MailOperation) are the up-channel, before/after assertions are the down-channel, and settlement plus state tokens are the confirmation watermark a remote near node (a replica runtime) uses to retire optimistic mutations.

The API is provider-neutral. Provider-specific setup choices may appear in account configuration requests, but mail reads, commands, and events use Posthaste account, mailbox, message, conversation projection, smart mailbox, query scope, and runtime concepts.

Endpoint semantics belong to this domain; the per-group behavior and handler patterns are detailed in L3. Authority-server docs describe how the server routes and implements the contract.

1.1.1 Motivation

External clients need ordinary request/response endpoints for stateless reads, commands, scripts, MCP tools, tests, and debugging. Long-running API clients also need a state stream so they can observe authority-server changes without broad polling.

The shared mail-state projection model is the anti-drift rule: if HTTP and SSE both expose a state shape, they use the same named representation or an explicitly named projection of it.

1.2 Prefix and routing

All product API endpoints are under /v1. In browser-localhost mode, non-API paths may serve the web app shell; unknown /v1 paths return API JSON errors rather than the web app shell.

1.3 JSON and binary responses

JSON request and response bodies use camelCase field names. Binary routes return the requested bytes with an appropriate content type and use the same auth boundary as JSON routes.

The source formats and standards the contract is built on — OpenAPI, AsyncAPI/SSE, and the protocol references it draws from — are recorded in L3 §1.

2. Mail state projections

2.1 State model dependency

The API exposes the mail state model defined in Mail state. It does not create a second state model for transport convenience.

Messages are canonical client-visible mail resources. Conversations and query results are authority-server-derived projections over messages and related canonical state.

2.2 Named API projections

The API defines named projections before it defines delivery channels. HTTP responses and SSE assertions reference these projections.

Projection Purpose
AccountOverview Account configuration summary plus runtime status fields visible to clients.
MailboxSummary Source mailbox identity, display metadata, role, and counts.
SmartMailbox / SmartMailboxSummary Saved query definition and navigation metadata.
ConversationRef Opaque conversation identity and freshness token carried by every message projection.
MessageSummaryState Replica/list-safe message fields: identity, account, sender, recipients, subject, preview, received date, attachment flag, mailbox IDs, keywords, bodyToken, attachmentToken, and ConversationRef.
MessageDetailState Message detail fields, including sanitized body alternatives, attachment metadata, and ConversationRef; detail content corresponds to the current body and attachment tokens.
ConversationEnvelope Complete derived conversation summary for one conversationId and conversationToken.
ConversationView Display-oriented conversation detail containing the envelope and reader message projections.
QueryScope Canonical description of an authority-server-evaluated list/search scope.
QueryPage Finite page for one QueryScope, including items and cursor state.
MessageChangeAssertion Event assertion carrying before and after MessageSummaryState values for one message.
StateChangeAssertion<T> Event assertion carrying before and after values for another named state such as MailboxSummary, SmartMailbox, or AccountOverview.
DomainEvent Ordered event-log record carrying topic, account, sequence, occurred time, and payload.

MessageSummaryState and MessageDetailState are separate because message bodies and attachment bytes are heavier than list/replica state. Attachment bytes remain HTTP binary resources. ConversationEnvelope and ConversationView are derived from messages; they are not independently mutable conversation objects.

2.3 HTTP state delivery

HTTP read endpoints return named projections or pages of named projections. For example, message list endpoints return MessagePageResponse { items: MessageSummaryState[], nextCursor }, message detail returns MessageDetailState, conversation reads return ConversationEnvelope or ConversationView, and query/list endpoints return QueryPage responses plus cursor state.

HTTP is also the command submission channel. A command response may be small, but the command must cause the authority server to emit any resulting observable state assertions through the event contract.

2.4 SSE state delivery

SSE assertions carry named state changes. A message assertion carries MessageChangeAssertion. Mailbox, smart-mailbox, account, settings, and similar changes carry before/after assertions for their named state. The API does not emit query invalidation commands and does not emit conversation projection assertions for message changes. API clients that maintain local views derive staleness from state assertions and recover through HTTP reads.

A message assertion has this payload shape:

{
  "type": "message",
  "kind": "updated",
  "accountId": "account-id",
  "messageId": "message-id",
  "before": {},
  "after": {}
}

before and after are MessageSummaryState | null. For created, before is null; for destroyed, after is null; for updated, both are present. Body and attachment changes are represented by changed bodyToken or attachmentToken values.

Message assertions are post-state assertions, not operational deltas. after is authoritative when present. before is context for removing stale projections, conversation/content freshness checks, and query-scope stale marking. Message metadata changes are coalesced under message.updated; its payload carries a changes object (for facets such as keywords, mailboxes, and arrived) plus the relevant post-state fields such as keywords, mailboxIds, and arrivedMailboxIds. Clients that need finer-grained behavior inspect the payload rather than subscribing to separate metadata topics.

Runtime view endpoints accept an optional sourceId query parameter. Account-scoped capability tokens must provide a matching sourceId; the runtime then rejects view descriptors and stream subscriptions outside that source scope.

2.5 Coarse events

Some topics are coarse by design. Settings changes, config reloads, account lifecycle events, runtime status changes, sync lifecycle events, push status changes, and body-cache notifications may carry topic-specific payloads instead of object-state assertions when no stable per-row state representation is useful.

Coarse events are still ordered and durable. API clients use them to update the relevant named state when the payload is sufficient, or to refetch through HTTP when it is not.

3. Endpoint inventory

The complete operation inventory — every method, path, summary, and operation ID, grouped by tag — is the generated endpoint inventory, emitted from openapi.json (the source of truth for the HTTP surface). Per-group endpoint semantics and handler patterns are in L3 §2. This contract does not hand-maintain a route table.

4. Authentication and authorization

4.1 Bearer token contract

When API auth is required, clients send Authorization: Bearer <token>. Token-bearing clients must not send tokens in query parameters.

/health, /openapi.json, and /asyncapi.json are liveness/contract-artifact routes. They do not require a bearer token, but host validation still applies when auth is enabled.

4.2 Capability actions

Capability tokens may restrict the actions available to a client.

Action Meaning
read Read API resources and event streams.
manage Manage accounts, settings, config reload, sync triggers, smart mailbox definitions, and token minting.
send Send mail from an account.
tag Change message keywords.
move Change mailbox membership.
delete Destroy messages.

4.3 Resource axes

Capability tokens may also restrict account, mailbox, and message axes. A route either gates access against a resource in the path, filters aggregate access through query parameters, or has no satisfiable resource axis. Aggregate routes that cannot safely enforce a scoped caveat reject scoped tokens instead of returning unscoped data.

4.4 Token minting

POST /auth/tokens returns a token attenuated from the caller's token. Requested caveats may narrow the caller's authority by action, account, mailbox, message, or expiry. The endpoint must not widen authority beyond the presented token.

5. Errors

5.1 Format

All API error responses are JSON objects:

{ "code": "not_found", "message": "account not found", "details": {} }

code is a stable snake_case machine-readable value. message is human-readable. details is an object and may be empty.

5.2 Status mapping

Error code family HTTP status
malformed input, validation failure, invalid cursor/query/limit/compose/secret/provider/account/logo/OAuth request 400
missing or invalid bearer token 401
valid token outside required action/resource scope 403
not found 404
conflict or state mismatch 409
provider network failure 502
provider unavailable 503
storage failure, config I/O failure, unexpected authority-server failure 500

6. PATCH and sparse update rules

PATCH endpoints preserve omitted fields. Sending null only clears a field when that field's schema explicitly allows clearing. Sparse merge applies to account patches, settings patches, smart mailbox patches, and mailbox metadata patches according to each request schema.

7. Lists, search, and pagination

7.1 Cursor contract

Conversation and message list endpoints accept limit, cursor, sort, sortDir, and q where applicable.

The default limit is 100. The maximum limit is 250. A limit outside the allowed range returns invalid_limit.

Cursors are opaque strings. Clients must not inspect or construct them. A response includes nextCursor when another page exists and null when the current page is complete.

7.2 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

7.3 Search query parameter

Message list endpoints accept q as search query text, evaluated relative to the endpoint's scope; /messages/search requires q and searches across sources. Result membership is authority-server-evaluated, not client-derived. Invalid query text returns invalid_query. The scope-alias equivalences (in:<source>/<mailbox>, smart-mailbox and conversation scopes, global search) are detailed in L3 §3.

8. Events

8.1 Event sequence

GET /events opens either a live Server-Sent Events stream or a finite SSE response. Event sequence numbers come from authority-server event history. Clients use the last applied sequence number as their resume cursor. Domain-event frames have id set to the authority-server event sequence number and data set to the JSON-serialized DomainEvent.

8.2 Query parameters

Param Type Description
accountId string? Filter live events to one account; required for snapshot mode.
topic string? Filter live events by topic.
mailboxId string? Filter live events by mailbox.
afterSeq integer? Resume live mode or collapsed catch-up from this sequence number, exclusive.
collapse byId? Finite catch-up mode; requires afterSeq.
mode live | snapshot? Omit or use live for streaming/catch-up; use snapshot for current-state pull.
objectType message? Required for mode=snapshot.

8.3 Live stream

When afterSeq is provided without collapse, the authority server replays matching events from event history before switching to live events, so clients reconnect without missing ordered state.

Live streams may emit explicit gap frames when the server-side live receiver has dropped events. A client that sees a gap frame uses collapsed catch-up or snapshot recovery rather than pretending the stream remained contiguous.

8.4 Collapsed catch-up

collapse=byId returns a finite catch-up response; it requires afterSeq. Message assertions after afterSeq collapse to the first retained before value and latest after value per (account, messageId). Coarse events pass through without object collapse unless their topic defines a collapse rule. The response uses the same DomainEvent shape as live events.

8.5 Snapshot mode

mode=snapshot returns a finite current-state snapshot for one authoritative message-event scope. It requires accountId and objectType=message and cannot be combined with live-only filters such as topic, mailboxId, or collapse. Conversation envelopes and query pages recover through their normal HTTP endpoints rather than SSE snapshots.

Snapshot responses use SSE control frames:

Event name Meaning
snapshot.begin Announces scope, watermark sequence, and state token when available.
snapshot.assertion Carries one DomainEvent assertion for the requested scope.
snapshot.end Confirms the snapshot completed and repeats the final watermark.

Clients apply snapshot assertions with the same primitive used for live and catch-up assertions, then resume live or catch-up from the snapshot watermark.

8.6 State tokens

sync.completed events include state tokens for the account/object types advanced by the sync. Snapshot control frames include the token for their scope when available.

API clients store state tokens per account/message scope to decide whether a missed range can be repaired with catch-up or needs an authoritative snapshot. Conversation freshness uses the conversationToken carried by ConversationRef. Query page freshness is recovered by refetching the relevant QueryScope over HTTP.

For a replica near node, settlement (mutation outcome by clientMutationId, with any assigned entity id) and these account/object state tokens together form the confirmation watermark: the replica retires an optimistic mutation when settlement or an authoritative assertion at or beyond the mutation's effect arrives, not when the mutation request returned. Confirmation follows the durable authoritative write.

9. Generated contracts

The OpenAPI document is the machine-readable HTTP contract; the AsyncAPI document is the machine-readable event contract for /v1/events (SSE channel, topics, assertion envelopes, snapshot frames, coarse payloads). TypeScript clients for API adapters, the MCP adapter, and remote clients are generated from the committed openapi.json when they use /v1.

OpenAPI and AsyncAPI must describe the same named projections when both channels expose the same state. Changes to externally visible request, response, error, event, or projection shape must update the generated artifacts and clients (the change procedure is in L3 §5).

10. Assertions

ID Sev. Assertion
shared-projection-model MUST HTTP responses and SSE assertions use the same named mail-state projections or explicitly named narrower projections.
conversation-ref-on-messages MUST Message summary/detail responses and message assertions include ConversationRef.
message-assertion-before-after MUST Message assertions carry before and after MessageSummaryState values with nulls for create/destroy.
message-content-tokens MUST Message summaries expose body and attachment tokens that change when detail/body or attachment state changes.
no-conversation-projection-events MUST The API does not emit separate conversation projection assertions for message changes.
authority-server-query-authority MUST Search/filter result membership is authority-server-evaluated, not client-derived from loaded messages.
explicit-query-scope MUST Query text is evaluated relative to the endpoint/base scope; global search uses an explicit global endpoint/scope.
no-query-invalidation-events MUST API events carry state assertions, not query-scope invalidation commands.
camelcase-json MUST JSON request and response bodies use camelCase field names.
error-format MUST API error responses contain code, message, and details.
bearer-header-only MUST Authenticated API clients present bearer tokens in the Authorization header, not query parameters.
token-attenuation MUST POST /auth/tokens does not mint a token with broader authority than the caller's token.
sparse-merge MUST PATCH endpoints preserve omitted fields.
cursor-opaque MUST Pagination cursors are opaque to clients.
limit-bounds MUST Conversation and message list limits are between 1 and 250.
assertion-idempotent MUST Message event assertions are post-state assertions and are idempotent to apply.
confirmation-watermark MUST Settlement (by clientMutationId, with any assigned entity id) and account/object state tokens together let a replica near node retire confirmed optimistic mutations; confirmation follows the durable authoritative write, not the request return.
sse-resume MUST SSE clients can resume from afterSeq without replaying the full event history.
collapsed-catch-up MUST collapse=byId catch-up returns at most one message assertion per (account, messageId) with first retained before and latest after.
snapshot-same-assertions MUST Snapshot mode emits message assertions with the same payload shape used by live and catch-up events.
event-after-write MUST Events are published after the durable authority-server write succeeds.
secret-redacted MUST Secret values are never included in API responses.
generated-drift-check SHOULD OpenAPI and AsyncAPI artifacts are checked for drift from authority-server source.