Supermail
Concepts

Inbox

The reference inbox application and how it stays on Supermail's public API boundary.

Inbox is Supermail's first reference consumer application. It is a separate service with its own durable state. It deliberately knows only Gateway's public OIDC provider and gateway.v1 gateway—never Sync's endpoint, database, or shared secret.

The service is a Bun HTTP backend with a statically compiled React/Vite default UI. @traummail/inbox-api owns the browser/server operation contract, @traummail/inbox-client owns browser transport, and @traummail/inbox-core holds pure rules. The React surface matches Gateway's frontend foundation: React 19, Tailwind CSS v4, shadcn/base-ui components, TanStack Query, and the same shared dependencies. TanStack Table owns message grids. Every browser request and successful response is validated with Zod and its TypeScript type is inferred from that schema.

The customizable authenticated UI owns /app/**, including /app/inbox and /app/settings/**. Basic provides Settings when no custom frontend is installed. Bun serves the fixed /internal/login, /internal/onboarding, and /internal/frontend/** pages. After login, an owner can install a complete static frontend from an HTTPS manifest; the backend verifies and stores its files, then serves them ahead of the compiled default. Gateway returns to the fixed /internal/open-app route, where the backend chooses the active UI's home entrypoint. Custom frontend JavaScript runs with the owner's Inbox API authority and must come from a trusted source. It can also shape the Settings screen and already has same-origin access to Inbox APIs, including settings.

Expected failures cross feature, RPC, MIME, crypto, and content-archive boundaries as a Rust-like Result<T, E> / AsyncResult<T, E>. Exceptions from third-party libraries are converted once at infrastructure boundaries instead of leaking through application code.

On the server, GatewayAdapter is the single external Gateway dependency. It contains narrow OIDC and gateway facets, so the identity feature can rotate a credential while mailbox, archive, and submission features make gateway calls without each configuring a second dependency. runtime/context.ts only builds and connects adapters. Service folders own behavior; operation HTTP and MCP, and timer code are adapters that invoke it. API routes map each public method to one semantic application call. Live routes additionally declare the typed update selectors that can make their result stale; those selectors remain an API concern rather than becoming a second service-to-service architecture.

Sign-in and token custody

Inbox is a confidential OIDC client and still uses S256 PKCE. Gateway returns an EdDSA-signed ID token and a rotating refresh family. Inbox verifies the issuer, audience, expiry, subject, signing key, and login nonce before creating its local user.

The browser receives one opaque HttpOnly, SameSite=Lax browser cookie. Server-side browser sessions record which Traummail accounts are signed in on that browser, so several accounts can be open at once and signing out revokes the session immediately. Each tab remembers its own account without putting it in the URL, so two tabs can show two accounts side by side. An Inbox sign-in lasts eight hours. When it merely expires, opening Inbox signs the same account in again, and choosing a remembered account in Inbox's own chooser names it to Gateway (login_hint); Gateway continues without asking while that account is still signed in there. Any other sign-in starts at Gateway's account chooser (prompt=select_account), so signing out of Inbox and back in never silently reuses the previous account. Gateway access and refresh tokens remain server-side and are sealed by the Inbox KMS key inbox-state-seal (RFC 20). If rotation or grant authorization fails, Inbox parks the grant as reauth_required and asks the user to reconnect instead of replaying a retired refresh token.

Required OIDC configuration

Register <public Inbox origin>/auth/callback as a confidential application in Gateway before starting Inbox, then provide:

INBOX_ISSUER=https://gateway.example.com
INBOX_GATEWAY_INTERNAL_URL=https://gateway.example.com
INBOX_CLIENT_ID=app_...
INBOX_KMS_KEYRING_FILE=/run/traummail/inbox-kms/keyring.json
INBOX_KMS_MASTER_KEY_FILE=/run/secrets/inbox_kms_master_key
INBOX_DATABASE_URL=postgres://inbox:...@inbox-db:5432/inbox
INBOX_MAIL_CONSUMER_WRITER_DATABASE_URL=postgres://inbox_consumer:...@inbox-db:5432/inbox
INBOX_MAIL_QUERY_READER_DATABASE_URL=postgres://inbox_mail_reader:...@inbox-db:5432/inbox
INBOX_GATEWAY_DUCS_DEPLOYMENT_ID=<base64url, 32 bytes>
INBOX_GATEWAY_DUCS_ROOT_PUBLIC_KEY=<base64url Ed25519 public key>
HOST=0.0.0.0
PORT=8081

The Inbox authenticates to Gateway with RFC 7523 private_key_jwt signed by its KMS key inbox-oauth-client; no client secret exists. The client id is required configuration. Missing or empty values fail startup; Inbox has no runtime setup mode. PostgreSQL owns the application-managed SQL migration sequence and exact schema contract. The three database URLs must name three distinct roles: the Inbox application, the Mail Consumer writer, and the Mail query reader.

INBOX_GATEWAY_DUCS_DEPLOYMENT_ID and INBOX_GATEWAY_DUCS_ROOT_PUBLIC_KEY pin the deployment trust root that every signed stream artifact is verified against. The DUCS endpoint is INBOX_GATEWAY_INTERNAL_URL, which must be the public canonical HTTPS Gateway origin, its origin evidence is computed from that URL, and the root key id is fetched from Gateway's public /.well-known/traummail/service-trust and accepted only when the deployment id and root public key equal the pins. Stream bindings are created by registration at runtime and are never process configuration.

Mail replication

Inbox is a DUCS Consumer of client-owned mail replication (RFC 18). Sync, embedded in Gateway, is the Producer. For each grant-visible account, Inbox registers or resumes one mail-replication stream through Gateway, which binds it to the application, subject, grant, account, and provider-account incarnation. One server-side WebSocket to /gateway/ducs per user multiplexes that user's streams.

@traummail/replication-sdk verifies and opens every sealed Commit against the pinned Gateway trust root before Inbox applies its Mail/1 payload to its own PostgreSQL relation. Applying the effect and recording the receipt intent are one database transaction; the signed receipt is sent only after that transaction has committed, so a restart resumes instead of losing or double-applying a Commit. A gap requests the retained range. A closed Lineage (history loss, source reset, revocation, erasure, or Producer-state loss) becomes a terminal lifecycle state and a fresh baseline replaces it.

Messages are identified by their Mail/1 message id within the stream. A removal deletes the row, and Gmail-style multi-folder membership is native. Folder roles are Sync truth derived from provider-native metadata, never inferred from display names.

Inbox and content archive

The browser opens on a unified inbox across every grant-visible account, with provider-aware standard folders, unread counts and stable cursor paging. Read state comes exclusively from the provider's replicated seen flag. Accounts without modify capability remain read-only rather than creating local state. The Mailbox/get, Message/list, and Message/get operation HTTP methods read Inbox's replicated relation; the browser never receives a provider content reference or a Gateway bearer token. Lists show one row per conversation (the provider's own thread where it has one, otherwise the reply chain and subject), or one row per message when the conversation view is off. An action on a row changes that conversation's messages where you are looking; your own sent replies stay where they are.

Metadata application never waits for body bytes. A replicated message carries an opaque content reference and a content_revision. Each account defaults to automatic body fetch from the date of its 1,000th newest dated current message through now, with 365 days of retention after leaving that range. Attachments default to manual fetch only, with 30 days of retention outside their range. The account owner can set or reset each of the four values independently. Inbox queues selected durable content jobs, and a separate continuous pipeline fetches the bytes from Gateway's /gateway/content/v1 content lane with the user's authorization. It claims a replacement job as soon as one finishes, shares free capacity round-robin across accounts, keeps one slot for a message the user opens, and paces each mailbox by Gmail's quota, backing off when Gmail throttles. INBOX_BODY_FETCH_GLOBAL_CONCURRENCY and INBOX_BODY_FETCH_PER_ACCOUNT_CONCURRENCY bound that pipeline. The content lane is separate from DUCS and supports Gmail and IMAP.

The encrypted content archive lives in PostgreSQL. Verified raw MIME is projected into a renderable body without MIME attachment payloads before body storage. Exact raw source is fetched for the requesting operation; attachments without an independent provider part reference are extracted from verified raw MIME and retained as separate attachment objects when their policy allows it. Every durable object gets a fresh data key wrapped by the Inbox KMS key inbox-archive-key-wrap, and only authenticated ciphertext chunks are stored. The object commits only if the message's content_revision is still current, so a stale reference cannot bind its bytes to a newer message. Each message reports its body as not_requested, pending, archived, or failed. Explicit retention: null makes an out-of-range part unreadable immediately; a manual read then uses request-scoped temporary bytes. Disabling local custody durably purges archived content; Inbox owns retention, deletion, backup, recovery, and erasure for its copy.

HTML is parsed and sanitized again on every render, then isolated in an iframe with an empty sandbox. Remote images are stripped unless the user explicitly loads them for that message. Attachments are decoded from the local archive and returned by Message/get_attachment as an authenticated base64 result. The browser creates and immediately revokes a local Blob URL; upstream pointers and tokens never become browser-visible transport details.

Recovery and settings

Each account exposes its replication lifecycle as pending, active, refused, recovery_unknown, recovery_required, closing, or terminal, with safe refusal and closure reasons. Account connection does not itself start replication: Inbox registers a stream for each owned account, and the Producer publishes a complete baseline followed by incremental Commits. A Gateway token-refresh or authentication refusal parks the grant as reauth_required without resetting Consumer state.

Settings show the live grant and scope, each account's raw and effective body/attachment custody values, per-kind coverage, and Gateway deep links. ContentCustody/put_policy replaces one account's optional overrides with a revision check; ContentArchive/set_enabled separately disables and purges or re-enables local custody. Disconnect first revokes the refresh family through RFC 7009, then deletes the local grant. Sign out ends the tab's account on this browser; Sign out of all accounts ends every account on the browser and then signs the browser out of Gateway too. Neither revokes the grant; disconnecting revokes it and also signs the tab's account out.

AI providers, search, and workspace

AI settings separate providers from models. Connect a provider once (OpenRouter, OpenAI, Anthropic, Google Gemini or any OpenAI-compatible endpoint) with its Base URL and API key; Inbox lists its models and shows what each can do: chat, embeddings or decisions, which inputs it accepts, whether it calls tools and its context size. A model the provider does not list can be added by its id. Each feature then picks models from those catalogs: choose chat favorites and a default for the AI panel, prepare an embedding model for a future search index, and choose a decision model for AI Label. Keys are sealed by the Inbox KMS key inbox-state-seal, never shown again, and replacing a key cancels running chats and pending approvals. Removing a provider removes its catalog and every selection that uses it; if it served AI Label, labeling stops and existing results stay until you delete them.

Search matches the sender, recipient and subject metadata of current mail. An embedding profile can be configured, but search indexing is not available yet: no message content is sent to an embedding model and search does not use it. API-key and OAuth clients reach the same search through the MCP tool list_messages with a query; provider settings and keys remain browser-session-only.

AI Label sorts incoming mail into views you define. Under Settings → AI, choose a decision model from a connected provider (an OpenRouter decider model, OpenAI's gpt-6-luna through the Decisions API, or a model on an OpenAI-compatible server that offers the same endpoint), pick the accounts, and add AI Labels: a name, a yes/no question such as "Is this a receipt or invoice?", a sensitivity and whether it shows in the sidebar. A message can land in several AI Labels or only in its best match. Before anything is sent, Inbox shows how many messages are in the chosen scope and roughly how many input tokens labeling them takes, and asks for consent. Changing a threshold, a name or the order applies at once; changing a question or the model asks again before older mail is re-scanned. AI Labels only change what is shown: archiving or deleting a message removes it from its AI Labels, and nothing is moved in your mailbox.

Inbox also has a distinct resizable AI workspace that slides in from the right without replacing the message or draft pane. Its server-side Vercel AI SDK ToolLoopAgent runs on the chosen chat model (the default favorite, or another model picked in the panel for its next request), the same authorized agent tool catalog as MCP, and seven additional tab-local tools for reading the UI context, selecting a mailbox, changing search, opening a message or draft, and preparing the ordinary composer. UI tools are strict revision-fenced state intents, not DOM or browser automation, and are never published through MCP. Durable mail changes use the normal API kernel and a one-use approval bound to the exact tool arguments; submission always requires its own final approval. Chat history is kept per user in this browser's storage only, never on the server, until you clear it.

JSON-RPC live queries, API keys, OAuth, and MCP

Inbox defines one Zod method catalog. Every method declares whether it is a query or command and whether it supports a live subscription. The same validated route powers ordinary browser calls, Astro server loaders, MCP, and live results.

POST /api/rpc selects the response mode through content negotiation:

  • Accept: application/json executes once and returns an ordinary JSON-RPC 2.0 response. JSON-RPC batches remain available in this mode.
  • Accept: text/event-stream accepts one live query. It emits the initial complete result as an SSE next event, then emits replacement results after relevant state changes. error and complete events use the same JSON-RPC envelope.

Streaming mode is a small Inbox extension to JSON-RPC 2.0. Commands, batches, and attachment-byte queries cannot subscribe. Because native EventSource cannot send a POST body, the browser uses streaming fetch and validates every SSE result with the method's normal Zod response schema. Authentication, authorization, CSRF checks, and response validation remain inside the shared kernel. OIDC redirects and the health check remain ordinary HTTP endpoints.

Live queries wake through a narrow SubscriptionUpdatesBus. A cue says only that a scoped read model may have changed; it never carries authoritative state. The server reruns the route's normal query and sends the complete result only when it differs from the previous result. Inbox ships with a process-local implementation, while the contract can be backed by Redis, NATS, or another distributed fanout without changing routes or browser code.

Inbox accepts three credentials. Each surface declares which:

SurfaceAPI key (wbi_…)OAuth access token (wbo_…)Session cookie
MCP /api/mcpyesyes, bound to <origin>/api/mcpno
Operation HTTP /api/<Service>/<operation>yesyes, bound to <origin>/apiyes
Browser-only routes (AI chat, mailbox connect)nonoyes

Any Authorization header is the credential: a bearer that does not resolve is 401, and the browser cookie is never consulted. This keeps revocation observable instead of silently falling back to the signed-in session; browser calls omit the header. API keys and OAuth tokens share one scope vocabulary (account:read, mail:read, mail:write, mail:send, settings:read, settings:write); with the same scopes they have the same authority. Neither can list, mint, or revoke API keys or connected apps: that is session-only.

Settings → API access can generate, list, and revoke API keys. A 256-bit key is displayed exactly once; only its SHA-256 digest, label, display prefix, scopes, and audit timestamps are stored.

Inbox is also the OAuth 2.1 authorization server for its own API, so MCP clients that implement the MCP authorization flow connect without a pasted key:

  • A 401 carries WWW-Authenticate: Bearer resource_metadata="…" pointing at the protected-resource metadata, /.well-known/oauth-protected-resource/api/mcp (or …/api for operation HTTP), which names the authorization server /.well-known/oauth-authorization-server.
  • Clients register at POST /oauth/register (dynamic registration, public clients only, no client secret). Redirect URIs must be HTTPS, loopback HTTP, or a private-use scheme, and match exactly (loopback ports excepted). A client that never obtains an authorization is deleted after 24 hours.
  • /oauth/authorize shows the signed-in user a consent page: the self-declared client name, the redirect host, and one checkbox per requested scope. Without a scope, the client asks for the read scopes. PKCE S256 is required, and a token is bound to one resource (/api/mcp by default, or /api).
  • POST /oauth/token exchanges the code (valid 60 seconds, single use) for a one-hour access token and a 30-day refresh token. Every refresh returns a new refresh token; presenting a spent one revokes the whole authorization, except within 10 seconds of its rotation, when it is the client's own concurrent retry and is only refused. POST /oauth/revoke revokes it too.
  • Settings → API access lists connected apps (name, the host of the redirect URI the user approved, resource, scopes, last use) through the session-only ConnectedApp/list and ConnectedApp/revoke methods. Revoking one, disconnecting, or deleting the user stops its tokens at the next request. Codes and tokens are stored only as SHA-256 digests.

Either credential connects an MCP client to POST /api/mcp. MCP serves the agent tool catalog: 21 task-shaped tools, from get_context (accounts, folders, labels; the agent's first call) through list_messages, get_message, get_conversation, and get_attachment to update_message, delete_message, folder and label tools, drafts, send_message, and send_draft. Tools accept names a person uses (an account's address, a folder's role or path, a label's name) and refuse an ambiguous reference with its candidates. Each tool publishes input and output schemas and the MCP read-only, destructive, idempotent, and open-world annotations. A result is structured content plus compact text (TOON for lists); mail bodies are fenced as untrusted content. Tools that change mail take an optional request_key so a retried call never acts twice. Attachments are downloaded and uploaded through short-lived capability URLs under /api/mcp/files/ and /api/mcp/uploads/ instead of inline base64. Every tool call goes through the API kernel with the caller's credential, so account ownership and live capability checks are identical to operation HTTP. MCP lists only the tools the credential's scopes allow; credential-management methods are absent from MCP.

The persistence model is transport-independent too. Zod schemas define the internal database contract. The PostgreSQL adapter owns its Drizzle schema, mapper, SQL migration history, and local schema version. Astro advances that version at startup; there is no separate migration command. The PostgreSQL-encrypted content-archive adapter owns per-object encryption and stores only authenticated ciphertext.

Astro renders useful first-load HTML instead of an empty client shell. The server calls the direct adapter with the incoming credential, seeds the exact TanStack Query keys needed by the current route, and dehydrates them into the React island. After hydration, the browser's JSON-RPC client opens live query streams and writes replacement results into those same keys. A manual refetch() still performs the ordinary one-shot query; the business path and auth policy are identical on both sides.

Provider writes and direct submission

The write path runs through Gateway's public gateway. Message flags, move, archive/trash, spam/not-spam, permanent delete, custom-folder CRUD, and native drafts require the account's live working_modify capability. Inbox keeps no local write state: the server forwards each action to Gateway, and the change becomes durable in Inbox only when the next replicated Commit reflects it. The browser applies read and flag changes to its query cache immediately and restores the cached results if the call is refused; other actions appear once the provider echo is replicated.

Compose, reply, and forward use a self-hosted TinyMCE authoring surface with synchronized Editor, HTML, and sandboxed Preview tabs. The server sanitizes the canonical HTML and builds validated RFC 5322. Send uploads that MIME and calls Gateway immediately with one end-to-end idempotency key. Inbox retains no MIME, send row, retry lease, or submission status; Sync owns the durable outbox and provider-recovery lifecycle. Scheduling, delay, cancellation, and undo-send are not part of the model or API. Bcc is intentionally unavailable until the platform can keep its envelope address out of delivered MIME headers.

Pages and live state

The sidebar exposes URL-backed unified Inbox, Drafts, Sent, Archive, Spam, and Trash pages across every visible account, followed by each account's provider folders. Successful replication, archive, identity, and settings commits publish user/account/message-scoped update cues. A live query subscribes before its first read so a concurrent change cannot be lost, then serializes and coalesces recomputation. Duplicate cues are harmless, unchanged results are suppressed, credentials are rechecked, and disconnect cancellation uses one idempotent cleanup path.

The browser reference-counts identical subscriptions and reconnects a dropped stream with bounded backoff; every reconnect starts with a fresh authoritative result. Each loaded message-list cursor page has its own subscription. If an updated page points to a different next cursor, later pages are discarded because they belong to the old cursor chain. Message bodies in pending state therefore update from an archive cue rather than one-second polling, while the refresh button remains an explicit manual query.

Custom-folder rename/delete is safe across replication lag: Inbox blocks a folder only when the replicated relation identifies it as a standard role. Unknown ids are forwarded to Gateway, which lets a just-created custom folder work immediately and remains authoritative for a truly missing folder.

INBOX_GATEWAY_INTERNAL_URL must be the public canonical HTTPS Gateway origin (the same origin as INBOX_ISSUER). It carries server-only discovery, token, JWKS, revoke, gateway, and DUCS calls; because the DUCS origin evidence is computed from it, a container-internal http:// address is refused.

On this page