Mail state — Model (L1)
1. Boundary
1.1 Contract
The mail state domain defines which mail facts are canonical, which facts are derived projections, where queries are evaluated, what change events mean, and what guarantees runtime views can rely on.
This domain is transport-neutral. The API spec describes how mail state crosses HTTP and SSE. The backend spec describes how state is persisted, indexed, synced, and mutated. The client spec describes how it is rendered.
1.2 Motivation
Mail state is easy to duplicate accidentally. Messages, conversations, query results, sidebar counts, and event payloads can all appear to be independent objects. The state model prevents that drift by naming the source of truth for each fact.
1.3 Reference models
This domain follows several established models without adopting any one protocol wholesale.
JMAP RFC 8620 and JMAP Mail RFC 8621 are the main reference for server-owned state strings, query truth, change recovery, push notification, and fallback when incremental changes cannot be calculated. Posthaste does not expose JMAP directly to clients, but it borrows the rule that opaque server state and authority-server query evaluation beat client reconstruction. Stalwart is a useful open-source JMAP implementation reference, especially its query_changes path.
CQRS is the reference vocabulary for separating command writes from optimized read projections. Posthaste does not need event sourcing to use this split: commands and sync mutate canonical records; query endpoints read authority-server projections.
SQLite query planning, SQLite optimizer behavior, and expression indexes are the implementation references for keeping authority-server query reads fast. Query-scope and predicate composition belongs in store reads when representable.
2. State ownership
2.1 Authority-server state
The authority server’s local store is authoritative for renderer-visible mail state in bundled application mode. It is built from provider sync, provider command outcomes, local configuration, and cache fetches.
Provider state is upstream authority, but renderers do not talk to providers directly. Renderers read through the runtime boundary. External API clients read through the API and observe authority-server state changes through the API event stream.
2.2 Renderer state
The UI renderer may hold presentation mirrors of runtime state for rendering, but it does not hold the authoritative message corpus or query results.
The renderer must not be required to evaluate global mailbox, search, smart-mailbox, or conversation completeness from locally loaded messages.
2.3 Implementation caches
The authority server may materialize projections or indexes for performance. A materialized projection is not canonical unless this domain names it as canonical.
3. Canonical mail state
Canonical mail state consists of authority-server-owned records that cannot be derived solely from other authority-server records without provider input or prior writes:
- account/source identity and provider identity needed to scope mail records
- mailbox identity, display metadata, role, and provider identifiers
- message identity, account/source, provider thread ID, subject, sender, recipients, preview, dates, attachment flag, body token, and attachment token
- message-to-mailbox membership
- message keyword/tag state
- body, body-part, attachment metadata, and local cache availability
- provider sync cursors and local metadata-version state
Conversations, query result sets, smart-mailbox contents, list pages, unread counts, and latest-message summaries are derived from canonical mail state.
Cached body/blob bytes are authority-server-owned local cache records. They may be evictable; their presence is not the same kind of canonical fact as message metadata or provider identity.
4. Derived mail projections
4.1 Message projections
MessageSummaryState is a list-safe projection of canonical message state. It includes identity, account/source, mailbox IDs, keywords, sender, recipients, subject, preview, date fields, attachment flag, bodyToken, attachmentToken, and ConversationRef.
bodyToken is an opaque equality token for the message body state visible through detail reads and body-dependent search. It changes when the body text, sanitized body, draft body content, or authority-server body representation for the message changes.
attachmentToken is an opaque equality token for attachment state. It changes when the attachment set, attachment metadata, or attachment content identity changes.
MessageDetailState extends message state with sanitized body alternatives, body metadata, and attachment metadata matching the current body and attachment tokens. Attachment bytes remain separate authenticated binary resources.
4.2 Conversation projections
A conversation is a derived grouping over messages, not an independently mutable object.
ConversationRef is carried by every message projection:
{ "conversationId": "conversation-id", "conversationToken": "opaque-token"}conversationId identifies the derived group. conversationToken is server-owned and opaque. It changes whenever the authority server’s complete conversation membership or aggregate data changes in a way that can make a hydrated conversation envelope stale.
ConversationEnvelope is the complete derived summary for one conversation at one token. It includes the conversation ID, token, ordered message references, latest-message summary, participants or sender summary, counts, source/account scope, attachment flag, unread state, and flagged state.
ConversationView is the display-oriented detail projection for opening a conversation. It includes the envelope and the message projections needed by the reader.
4.3 Query projections
A query projection is an authority-server-owned filtered view over canonical message state. Query projections include source mailbox lists, smart-mailbox lists, global search results, conversation lists, and any future saved or ad hoc query scopes.
QueryScope is the normalized description of one authority-server-evaluated view: base scope, optional query text, sort, direction, cursor, limit, and any resource filters. The base scope decides where query text applies.
QueryPage is a finite response for one QueryScope. It contains projected items, cursor state, and the authority-server sequence or read watermark when available.
MailListViewState is the runtime-owned active window for a renderer mail list. It is the bundled-runtime view form of a query projection. It names the QueryScope, projection kind, window request, rows, continuation state, read watermark, lifecycle affordance, and pending row markers.
MailListRowState is one row in a runtime-owned mail list window. It carries a stable row key, resource reference, runtime-produced display projection, ordering metadata needed by the renderer, and any runtime mutation markers for that row.
Runtime query dependency analysis is a stale-marking and recomputation aid, not a query plan. It may name coarse dependencies such as base axes, message sender fields, subject, preview, mailbox membership, keywords, date, attachment flag, bodyToken, attachmentToken, mailbox metadata, or smart-mailbox definition state.
Query projection membership, ordering, page boundaries, and cursor continuity are not derived by the renderer from loaded messages. The runtime or authority server settles the exact window.
5. Query and filter evaluation
5.1 Authority-server evaluation rule
Search strings and filter predicates are evaluated by the authority server against local indexed message projections.
For example, from:Alex is parsed by the authority-server query language and evaluated against sender name and sender email fields in the local store. The browser may validate syntax, show completions, and preserve the typed query, but it does not decide the authoritative result set.
5.2 Scope composition
Query text is the universal selector for mail-list reads. in:Inbox names a saved smart mailbox query, while in:Account/ names a concrete source and in:Account/Archive names a concrete mailbox under that source. If the user is in Inbox and applies from:Alex, the authority server evaluates in:Inbox from:Alex. If the user switches to Archive under a source, the runtime evaluates in:Account/Archive from:Alex.
Global search is a query without an in: selector. It is not implied by typing query text while a mailbox or smart mailbox is selected. Opening a derived conversation uses a query such as conversation:conv-id and returns messages in that conversation.
5.3 Shared evaluator rule
Saved smart mailboxes, source mailbox filtering, global search, conversation search, automation previews, and query-page reads use the same authority-server predicate semantics for the same query language.
If a query prefix, operator, or field changes, every authority-server query surface using that language changes together or explicitly documents a narrower subset.
6. Conversation model
6.1 Derived identity
Conversation identity is derived from provider thread identity and account/source scope when the provider supplies a thread identity. Fallback identity must be deterministic and authority-server-owned.
Consumers treat conversationId as opaque. They must not reconstruct conversation membership from the ID.
6.2 Freshness token
The authority server computes the conversation token from complete authority-server knowledge of the conversation. The implementation may use a stable hash, a projection revision, an event watermark, or another opaque value.
The token must change when any of these changes for the conversation:
- membership set
- ordering used by conversation views
- latest-message pointer or latest preview
- unread or flagged aggregate state
- attachment aggregate state
- participant/sender summary exposed by the envelope
Equal tokens mean the envelope is fresh for the fields covered by the token. Different tokens mean the envelope must be treated as stale. Tokens have no ordering or meaning beyond equality. A rebuild may change an opaque token even when visible state is equivalent; this only causes safe extra refetch.
6.3 No mandatory conversation event
A message event carries the message change assertion and the ConversationRef values inside its before/after message summaries. The system does not emit separate conversation projection events for message changes.
The runtime recovers stale visible conversations by fetching or recomputing the conversation envelope or view using the changed ConversationRef.
7. Message change assertions
7.1 Before/after shape
Message events carry before and after summary state. This covers ordinary messages and drafts; a draft body edit is a message update that changes bodyToken.
{ "type": "message", "kind": "updated", "accountId": "account-id", "messageId": "message-id", "before": {}, "after": {}}before and after are MessageSummaryState | null.
created:beforeis null andafteris the current summary.updated:beforeis the previous summary andafteris the current summary.destroyed:beforeis the previous summary andafteris null.
The after value is the authoritative post-state for the message. The before value is context for removing stale projections, detecting conversation-token changes, detecting body or attachment token changes, and deciding which runtime views may need recomputation.
Message change assertions ride the event stream as prompts: consumers react to them by re-querying, never by folding them into held state. This domain owns the assertion shape; the API owns delivery and its guarantees.
7.2 Summary-state limit
Message change assertions carry full message summary state, not message detail state, body content, attachment bytes, or provider operation deltas. Body and attachment changes are represented by changed bodyToken or attachmentToken values.
The runtime may compare before/after summaries to decide whether a loaded base scope can be affected. It must not use before/after summaries as the authoritative evaluator for arbitrary query text when an exact authority-server/store query is required.
7.3 Other state assertions
Authority-server state that affects query scopes is reported as state assertions for that state, not as query invalidation commands.
Examples:
- smart-mailbox definition changes are
SmartMailboxbefore/after assertions - mailbox name, role, or hierarchy changes are
MailboxSummarybefore/after assertions - account visibility or runtime metadata changes are
AccountOverviewbefore/after assertions when client-visible - search corpus or index state changes that are independent of message
bodyTokenare modeled as before/after assertions for a named search-corpus state before clients depend on them
The runtime maps these facts to active QueryScopes using dependency analysis and base scope.
7.4 Query dependency matching
For an active query view, the runtime may skip recomputation only when dependency analysis proves that the received state assertion cannot affect membership or ordering. If dependency data is missing, unknown, or intersects a changed field/resource, the runtime recomputes the visible query window.
7.5 Collapsed recovery
Collapsed catch-up may merge multiple changes for the same message. The retained assertion should use the first retained before summary and the latest after summary. Clients treat collapsed catch-up as recovery, not as a complete operational history.
8. Runtime view model
8.1 Loaded messages
Every loaded message has a ConversationRef. This is the minimum information needed for the runtime to group, select, and detect stale conversation data.
8.2 Hydrated conversations
The runtime may hold a ConversationEnvelope for a conversation. The envelope is complete only for its exact conversationToken.
If the runtime receives or loads a message with the same conversationId and a different conversationToken, the local envelope is stale. If the conversation is visible or opened, the runtime fetches or recomputes a fresh envelope or view. If it is not visible, the runtime may defer the work and keep only the stale marker.
8.3 Active query views
The runtime may hold active query windows for renderer views. A query window is complete only as the finite result for its exact QueryScope, projection kind, window request, and read watermark.
A MailListViewState is not a client-side filtered page. It is a runtime assertion of the rows currently visible or requested for one list view. Replacement rows, insertion position, row removal, cursor movement, and continuation state all come from runtime/authority-server query evaluation.
When a state assertion intersects an active query’s base scope and dependency analysis, the runtime recomputes that query window. If the query is inactive, the runtime may defer recomputation until the scope is opened again.
The renderer is not required to repair exact query windows locally. A single changed message can affect insertion position, page boundaries, replacement rows, and cursors; those are runtime/authority-server query results.
8.4 Visible-state invariant
A visible or opened conversation must either have a token-matching envelope/view or show an explicit loading/stale state while the runtime fetches or recomputes one.
A visible query view must reflect the latest runtime-computed window or show an explicit runtime affordance such as loading, updating, error, or a coverage/freshness warning supplied by the runtime. The renderer is not required to keep complete envelopes or fresh query pages for background state.
9. Assertions
| ID | Sev. | Assertion |
|---|---|---|
| mail-authority-server-authority | MUST | The authority-server local store is authoritative for renderer-visible mail state in bundled application mode. |
| renderer-not-query-authority | MUST | The renderer must not be required to evaluate global query or conversation completeness from loaded messages. |
| conversations-derived | MUST | Conversations are derived projections over messages, not independently mutable canonical objects. |
| conversation-ref-present | MUST | Message summary and detail projections include an opaque ConversationRef. |
| conversation-token-opaque | MUST | Runtime consumers compare conversation tokens for freshness but do not compute or interpret them. |
| authority-server-query-evaluation | MUST | Search and filter predicates such as from:Alex are evaluated authority-server-side against local indexed projections. |
| query-scope-explicit | MUST | Query text is evaluated relative to an explicit base scope; global search is a separate base scope. |
| shared-query-semantics | MUST | Saved smart mailboxes, source mailbox filtering, global search, conversation search, automation preview, and query pages use shared authority-server predicate semantics unless a narrower subset is documented. |
| query-dependency-safe | MUST | The runtime skips active query recomputation only when dependency analysis proves the state assertion cannot affect that query’s membership or ordering. |
| query-window-runtime-settled | MUST | Exact query page membership, ordering, replacement rows, and cursors are settled by runtime/authority-server query results, not renderer repair. |
| mail-list-view-state-runtime | MUST | Runtime-owned mail list view state carries scope, projection kind, rows, continuation state, read watermark, coverage, lifecycle, and pending row markers. |
| no-query-invalidation-events | MUST | The authority server emits state assertions, not query-scope invalidation commands. |
| message-change-before-after | MUST | Message change assertions carry before and after message summary state with nulls for create/destroy. |
| message-content-tokens | MUST | Message summary state includes opaque body and attachment tokens that change when detail/body or attachment state changes. |
| visible-conversation-complete | MUST | Visible/opened conversations have a token-matching envelope/view or an explicit stale/loading state. |
| visible-query-explicit-state | MUST | Visible query views show either the latest runtime-computed window for their scope or an explicit runtime lifecycle state during recomputation. |