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=8081The 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/jsonexecutes once and returns an ordinary JSON-RPC 2.0 response. JSON-RPC batches remain available in this mode.Accept: text/event-streamaccepts one live query. It emits the initial complete result as an SSEnextevent, then emits replacement results after relevant state changes.errorandcompleteevents 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:
| Surface | API key (wbi_…) | OAuth access token (wbo_…) | Session cookie |
|---|---|---|---|
MCP /api/mcp | yes | yes, bound to <origin>/api/mcp | no |
Operation HTTP /api/<Service>/<operation> | yes | yes, bound to <origin>/api | yes |
| Browser-only routes (AI chat, mailbox connect) | no | no | yes |
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
401carriesWWW-Authenticate: Bearer resource_metadata="…"pointing at the protected-resource metadata,/.well-known/oauth-protected-resource/api/mcp(or…/apifor 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/authorizeshows the signed-in user a consent page: the self-declared client name, the redirect host, and one checkbox per requested scope. Without ascope, the client asks for the read scopes. PKCE S256 is required, and a token is bound to one resource (/api/mcpby default, or/api).POST /oauth/tokenexchanges 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/revokerevokes 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/listandConnectedApp/revokemethods. 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.