How @corbits/mailbox is put together, and what it does and does not ask of
the host that mounts it. For install, the mount snippet, the route table and the
response contracts, see the README —
this document is about structure and reasoning, and does not repeat them.
A library, not a service. It creates no HTTP server, opens no connection pool by default, owns no configuration, and starts no background work. A host calls two functions:
runMailboxMigrations(db)— once at boot, before serving.mountMailbox(app, opts)— registers routes under/me/inbox*on a Hono app the host already built, and returns the same app.
The core registers root-relative paths and takes no base path, so the mount
point is the host's decision. The convention every @corbits/*-core package
documents is /api — the prefix Interchange serves its own routes under.
No /v1 segment, no vendor prefix.
const api = new Hono<AppEnv>();
mountMailbox(api, { db, bus, resolvePrincipal, vocabulary });
app.route("/api", api);which serves /api/me/inbox, /api/me/inbox/unread-count,
/api/me/inbox/events, /api/me/inbox/:id, and the POST mutations beneath
them. Nesting rather than teaching the core a base path keeps the frozen
mountX<E extends Env>(app, opts) => Hono<E> seam untouched, and lands the
mailbox behind whatever the host already declared for /api/me/* — on an
Interchange host, requireAuth.
mountMailbox<E extends Env>(app: Hono<E>, opts): Hono<E> is generic over the
host's Hono Env and returns the app unchanged in type. Everything the package
cannot know on its own arrives through opts; nothing is reached for.
| Option | Required | What it is |
|---|---|---|
db |
yes | A drizzle postgres-js handle. The schema generic is any on purpose, so the host passes the handle it already has instead of opening a second pool. |
bus |
yes | MailboxEventBus — per-mailbox fan-out backing the SSE route, keyed by the (tenantId, principalId) pair (MailboxEventScope). createInMemoryMailboxEventBus() ships as the zero-config default. |
resolvePrincipal(ctx) |
yes | { tenantId, principalId } | null. ctx is typed unknown, so no Hono context typing leaks into the seam. |
vocabulary |
yes | { priorities, statuses } — the host's triage taxonomy; there is no default. |
resolveSenderDisplays |
no | Batched (tenantId, fromHeaders) => Map<address, label>. Omitted, messages carry only the raw From: header. |
heartbeatIntervalMs |
no | SSE keep-alive period, default 25s (under the 30s idle timeout most proxies default to). Exists so a test can observe a heartbeat without waiting. |
What it does not require: no auth middleware, no session library, no logger
configuration, no UI. What it does require of the database is an
Interchange-shaped control plane: public.tenant and public.principal in the
same database, in place before runMailboxMigrations runs, because the mailbox
tables foreign-key to both. Nothing changed in Interchange to make that work —
the coupling lives entirely on this side.
One further seam lives outside mountMailbox, on the write side:
createMailboxPersist(db, { upstream, authorizeSender, bus?, onRow?, resolveRefs? })
wraps a host's own mail-persist function so every addressed principal also
gets a durable row. authorizeSender(address) => { tenantId, domain } | null
is the host's decision — whether a sender address belongs to a live agent
instance is not a schema fact. Returning null skips the mailbox write
entirely while the frame still goes upstream. On the recipient side the
package does consult the control plane: an address whose local part matches
no known principal in the authorized tenant is skipped with a warning rather
than minting a phantom mailbox row, and never costs the frame's real
recipients their durable copy.
The wrapper's contract is dual-write independence in both directions: an
upstream throw still attempts the mailbox write and then re-throws the
original error, and a mailbox-write failure is logged and never rejects a
persist that upstream already completed. Under retry, the mailbox side is
idempotent: package-owned transport messageKeys plus onConflictDoNothing
collapse duplicate frames without failing the call or re-announcing.
resolveRefs(args) — args is MailboxPersistArgs plus the resolved
senderAuthorization and the decoded frame (or null if the parser
rejected it) — is called ONCE per frame, before the transaction opens, not
once per recipient: a host pointing every row at the same upstream entity
does one lookup, not N. It runs AFTER upstream resolves and serially with
it, so its latency adds to the call rather than overlapping. Its result is
validated with MailboxRefArraySchema and capped at MAX_MAILBOX_REFS the
same way writeMailboxMessage's refs argument is (excess entries
truncated from the end of the list, logged with messageId and
senderAddress, never a throw) — so a resolver must return a small set with
the load-bearing ref FIRST, since anything past the cap is silently dropped.
Refs are then stored on every recipient row of that frame INSIDE the same
transaction — so the post-commit bus create event and any SSE subscriber
already see refs on the row once it's readable.
Refs are frozen at the FIRST successful insert for a given frame: a retry
(same idempotency key) still calls resolveRefs — it is not skipped — but
because onConflictDoNothing writes no row on a retry, a different result
from that second call is simply discarded; only the first call's refs ever
land. A throwing resolveRefs is handled exactly like any other
pre-transaction failure: it falls under the same dual-write contract as the
rest of the wrapper — logged (naming resolveRefs as the failing stage),
upstream unaffected (already ran, or still will, independently of this), and
no mailbox row for that frame.
resolvePrincipal's signature is identical across the Corbits cores, so a host
mounting more than one passes the same function to each.
| File | Role |
|---|---|
mount.ts |
HTTP surface. Parsing, validation, status codes, SSE. No SQL. |
read.ts |
List (cached columns, no raw) and detail (frame-decoded) projection, keyset paging, snippets on detail. |
thread.ts |
The conversation under one entity ref: keyset-paged oldest-first, parents resolved by RFC 5256 References linking, plus the msg-id lookup. |
mutations.ts |
Read/unread, archive, trash, restore, bulk, enrich, assign. |
write.ts |
writeMailboxMessage / deliverInboxItems — the host-facing write API. |
persist.ts |
The transport dual-write wrapper and the authorizeSender seam. |
frame.ts |
Building and decoding RFC 5322 frames, multipart included. |
cursor.ts |
Cursor encoding plus the view/sort/filter vocabulary and its fingerprints. |
recipients.ts |
Address-list parsing and domain-scoped recipient resolution. |
sender-display.ts |
The pure half of display names, plus the resolver seam. |
vocabulary.ts |
The host's triage vocabulary: validation, the generated rank, the ordering fingerprint. |
schema.ts / migrations.ts |
The two tables, and the DDL that creates them. |
bus.ts / db.ts / refs.ts |
The event-bus port, the db handle type, the ref schema. |
Two physical tables, plus this package's own migration ledger — all living in a
dedicated mailbox Postgres schema in the HOST's database
("mailbox"."principal_mail", "mailbox"."mailbox"), never in public and
never in a database of their own. Every row in either belongs to exactly one
(tenant_id, principal_id) mailbox.
principal_mail the message as delivered. IMMUTABLE.
id, tenant_id, principal_id, address, direction, raw,
subject, from_address, message_id, in_reply_to,
message_key, refs, created_at
mailbox the management layer, keyed by mail id. Mutable.
read_at, archived_at, trashed_at (universal)
priority, classification, status, assignee (triage)
Why the split. Interchange's session_mail is the message as delivered and
nothing more — no read_at, no archive, no triage — because agents don't
triage their inbox. The moment mail is served to a human all of that becomes
necessary, so the management layer is genuinely ours to own; it just does not
belong on the mail row, which has to keep reading 1-1 with Interchange's.
principal_mail matches session_mail for every column the two share, and
everything a human does to a message afterwards lives one join away. One name
deliberately does not line up: session_mail.status is delivery state
while mailbox.status is triage state — same word, different meaning, and at
least on different tables.
The management row is created eagerly, with its message, in one
transaction — both on the writeMailboxMessage path and on the
createMailboxPersist path. An all-NULL row means delivered-and-untouched.
Guaranteed presence is what makes the rest of the design simple:
- Every mutation is a plain scoped
UPDATEonmailbox— no upsert, no first-touch race. A message outside the caller's scope matches no row, which the routes read as 404. - The unread count is an index-only scan of the partial index
mailbox_tenant_id_principal_id_unread_idx(WHERE read_at IS NULL AND archived_at IS NULL AND trashed_at IS NULL) — possible only because every message carries a row. - The single transaction is load-bearing: split, a crash between the two writes
would commit the mail row alone, and a retry would hit the
messageKeydedupe and return null, leaving a message no mutation can reach.
One foreign key of our own. mailbox.id REFERENCES principal_mail(id) ON DELETE CASCADE makes a message and its management state one lifecycle.
Each purge (purgeTenantMailbox, purgePrincipalMailbox) is therefore a
single DELETE on principal_mail — the management rows follow through
the cascade, so a purge is atomic by construction, with no transaction to
manage. Both take the caller's db handle, so a host can run them inside its
own offboarding transaction; neither is scoped by view, because an offboarded
tenant's trash is as much their data as their inbox.
Hard control-plane foreign keys. tenant_id and principal_id on both
tables reference the host's public.tenant and public.principal, both
ON DELETE CASCADE — the same posture as Interchange's own
session_mail.tenant_id, extended to the principal. Consequences, stated
rather than hidden: the control plane and the mail plane must share one
database, the control-plane tables must exist before runMailboxMigrations
runs, and there is no separate-database mode. Deleting a tenant or principal
row carries every one of its mailbox rows out; the explicit purges exist for
hosts that soft-delete control-plane rows, where no cascade ever fires.
(assignee and address remain plain text held by value — an assignment
must survive the assignee's principal being offboarded.)
The migration DDL is the single owner of the constraints: the FKs are
declared there and deliberately not restated as drizzle .references() thunks
in schema.ts, which declares only the columns. The one host table schema.ts
still stubs is principal (hostPrincipal), read by the delivery-time
recipient existence check — never created or migrated here.
Two layers sit deliberately in front of the FKs:
- The write boundary (
src/scope.ts). Every write path refuses a blank or whitespace-onlytenantId/principalIdwith aRangeErrorat the boundary, where the caller still has a stack — the FK would refuse it too, but as a driver error deep in the insert.deliverInboxItemschecks the whole batch before writing any of it, so the refusal is all-or-nothing. Identifiers are never trimmed on the caller's behalf. - The delivery filter (
src/persist.ts). Recipient local parts are sender-controlled; unknown locals are resolved againstpublic.principalfirst and skipped with a warning, so one typo'd address never costs the real recipients on the same frame their durable copy, and external mail cannot mint a phantom mailbox row.
Column types are Interchange's. Ids are text defaulting to
gen_random_uuid()::text, not uuid — every Interchange table is
text("id").primaryKey(), and an id should not change type at the seam.
Timestamps are timestamp without time zone holding UTC, and the rule that
follows is one the read path must keep: the column is never cast.
timestamp → timestamptz is STABLE, not IMMUTABLE, so a cast on the column
side drops the keyset page out of Index Cond into Filter. The cursor is
cast instead, and to ::timestamp — a timestamptz literal resolves through
the session's TimeZone, so on a non-UTC host the same cursor silently seeks to
a different row. src/read-non-utc-session.test.ts pins a non-UTC session for
exactly that reason.
The raw frame is the authority on detail. raw bytea holds the complete
MIME frame; subject and from_address are caches parsed once at write time.
List reads those caches only (no raw, no decode, no snippet). A frame the MIME
parser rejects still persists — detail reads degrade to an empty body rather
than a 500 — and improving the parser improves existing rows, because nothing
was thrown away at write time.
Dedupe is partial on purpose. The unique index on
(tenant_id, principal_id, message_key) is WHERE message_key IS NOT NULL.
Mail arriving without a stable key — most external mail — is left
unconstrained rather than collapsed onto a single NULL-keyed row per mailbox.
Keys are namespaced by path:
- Inbox ingress (
mailboxKey.inbox) uses a versioned length-prefixed encodinginbox2:<source.length>:<source>:<externalId>so pairs that contain:cannot collide, and so the space is disjoint from pre-upgradeinbox:<source>:<externalId>keys (length-prefix underinbox:alone would false-collide when a historical source was pure decimal). No migration is performed; redelivery after upgrade may insert a second row. - Transport dual-write stamps
transport:mid:<Message-ID>:<principalId>ortransport:raw:<sha256>:<principalId>and inserts withonConflictDoNothing, so a retried frame does not fail on unique-violation. Management rows and bus announce only for rows returned byRETURNING. - Gate / run keys remain under their own namespaces via
mailboxKey.
Batch delivery is one transaction. deliverInboxItems prevalidates blank
scopes, then commits every new row in the call in a single transaction (or
none). Deduped keys are no-ops inside the transaction. Bus publish and the
optional host enqueue hook run only after commit, and only for newly inserted
ids. Both side effects are best-effort: a throw is logged with the message id
and never rejects the delivery.
Two write paths, two shapes of batch. deliverInboxItems is the
notify-item path: one external item, fanned out to every addressed
principal, keyed by mailboxKey.inbox(source, externalId) — unchanged by the
addition below. writeMailboxMessages(db, items, opts?) is the
conversation path: an arbitrary batch of { scope, args } pairs — a
sender's own outbound copy alongside every recipient's inbound copy of the
same turn, mixed tenants and principals allowed — committed in the same
single-transaction-or-none shape, with per-row onConflictDoNothing dedupe on
the same messageKey partial unique index and bus events published only
after commit, one per row this call actually inserted. A throw from any one
item (an invalid scope, an oversize frame, a control-plane FK the item's
scope does not satisfy) rolls back every row the batch would otherwise have
written, including ones already inserted earlier in the same call — same
atomicity guarantee as deliverInboxItems, over a caller-shaped item instead
of an ingress-shaped one. Each item's args is
Omit<WriteMailboxMessageArgs, "tenantId" | "principalId"> — scope is the
sole source of both, so there is no second copy of the scope an item could
disagree with. writeMailboxMessages returns one { messageKey, id } entry
per item, in item order — matching deliverInboxItems's DeliveredInboxItem
shape — with id: null exactly for an item whose messageKey deduped against
an existing row, rather than a filtered array of inserted ids.
A write's Message-ID, direction, and dedupe key are now the caller's to
set. WriteMailboxMessageArgs.messageId lets a caller hand the write path
the exact msg-id its own frame must carry (validated as a bracketed msg-id;
RangeError otherwise) instead of always minting one — needed when a
message's id has to be predictable ahead of the write, e.g. so a later
inReplyTo can reference it. direction (default "inbound") is a stored
fact: an outbound row is the sender's own durable copy, and is created
already-read — its mailbox.read_at is pinned to its own created_at at
insert — so it is excluded from the unread count and the unread view without
either needing a direction predicate of its own. listUserMailbox and
getMailboxMessage accept an optional direction?: "inbound" | "outbound" | "all" (default "inbound", preserving today's contract) so a thread reader
can fetch a principal's own sent copies or both directions together — see
Known limits. And messageKey, when the caller omits it, now defaults to
mailboxKey.transport(messageId, principalId, direction): for the default
"inbound" direction this is transport:mid:<Message-ID>:<principalId> —
the same shape persist.ts's transport dual-write already uses, byte for
byte, so a frame persist.ts already delivered and a direct inbound write
for the same Message-ID + principal still dedupe onto the same row — while
"outbound" gets a :outbound suffix, so a sender's own copy of a turn
never collapses onto an inbound copy that reuses the identical
caller-supplied messageId for the same principal. A retry that reuses the
same caller-supplied messageId (and direction) therefore dedupes for free
— the write returns null — while two writes that each mint their own
messageId, or that differ in direction, never collide. A caller-supplied
messageKey still overrides the default, exactly as before.
Bus publish isolates listeners. publishMailboxEvent invokes each
subscriber independently; one throwing listener does not stop the others. SSE
connections serialize writes, bound the pending queue, and close on overflow or
write failure rather than buffering forever.
The event names the operation that fired. publishMailboxEvent takes a
required op (MailboxEventOp: create, mark_read, mark_unread, trash,
archive, restore, enrich, assign) and includes it on the published
event. Every call site in this package passes one — the two delivery paths
(writeMailboxMessage, deliverInboxItems) and the transport dual-write
(createMailboxPersist) publish create; mountMailbox's route table passes
the mutation's own identifier, reusing MailboxBulkAction's vocabulary for
the single-message verbs so "read one" and "read fifty" report the same op.
op stays optional on MailboxEventSchema even though it is required to
publish — additive, not a reshape: a listener built against the original
{ type, id } shape still validates, and a historical event replayed from
before this field existed still passes. Requiring it on publishMailboxEvent
is what keeps every call site in this package honest going forward; it
cannot reach a caller outside the package, which is the other reason the
schema field has to stay optional.
MailboxEventOp deliberately keeps its own name and vocabulary rather than
reusing MailboxBulkAction. It is a superset — create, enrich, and
assign are not bulk actions, and never will be — so aliasing the two would
claim an equivalence that does not hold. mount.ts's route table is the one
place that has to know both: it maps HTTP verbs to MailboxBulkAction values
that also happen to be valid MailboxEventOp values, and a test in
bus.test.ts keeps that overlap from drifting silently.
Triage enriches the message, not a task. priority, classification,
status and assignee are columns on the message's management row, not a
spawned work item. Delegation is the assignee ref: the item stays in the
delegator's mailbox. The vocabulary is the host's — priorities is ordered,
most urgent first, and that order is the ranking sort=priority uses;
priority and status are plain text with no CHECK, because a constraint
here would freeze one product's taxonomy into every adopter's database. A value
the host no longer lists — including NULL — ranks last.
Cursors are bound to the result set that minted them. A priority cursor
carries a canonical rendering of the host's ordering, and a mismatch is a
400 — a reordered vocabulary must not silently redefine what an in-flight
rank means. A malformed cursor is always a 400, never a 500: the decoded
createdAt is pinned to exactly the microsecond to_char shape this package
mints, and the priority rank must be a safe integer, so a crafted cursor never
reaches Postgres.
Indexes are query-shaped, named the way Interchange names them, and every
one leads with (tenant_id, principal_id) because every query is scoped to one
mailbox. The keyset — (tenant_id, principal_id, created_at DESC, id DESC) —
stays on principal_mail, matching the list's ORDER BY and its row-value
cursor seek exactly, so the default (highest-traffic) page remains a
single-table index scan that stops at limit + 1 rows. The triage indexes and
the three partial view indexes (unread, archived_at, trashed_at) live on
mailbox. The thread read adds three more on principal_mail:
(tenant_id, principal_id, message_id) — not unique, since a msg-id is the
sender's identifier and nothing stops two delivered frames carrying the same
one — a GIN index on refs, the only kind that can serve the refs @> …
containment filter the ref scope is expressed as, and
(tenant_id, principal_id, created_at, id) matching readMailboxThread's own
oldest-first ORDER BY verbatim. That last one covers the same three leading
columns the list path's own keyset index does, in the opposite direction; a
backward scan of the list's index already serves the thread query, but a
dedicated index removes the dependence on the planner choosing to scan the
other one in reverse. Whichever index a page's plan uses, a ref whose messages
cluster at one end of the principal's own created_at history while a page
seeks from the other end still costs a Filter proportional to how much
unrelated history sits between them — no index shape fixes that; only
clustering by ref would, and this package deliberately holds no opinion on
physical row order. See "What the split costs, measured" below.
readMailboxThread(db, scope, { ref, cursor?, limit? }) answers the
conversation under one entity ref: oldest first, keyset-paged on
(created_at, id), scoped to (tenant_id, principal_id) and filtered by
jsonb containment on refs.
Parents are resolved by RFC 5256 References linking, never by subject. For
each message the candidate ancestors are its In-Reply-To followed by its
References chain walked newest-to-oldest, and the first candidate present in
this mailbox under this ref wins. An ancestor that is not present yields
parentId: null — a message whose parent lives in another principal's mailbox,
or under a different ref, is a root of what this reader can see, and inventing
a node for it would be a lie about the conversation.
parentId chains are acyclic. RFC 5256 step 1.B calls out that nothing
stops a delivered frame's In-Reply-To/References from naming a msg-id that,
directly or through further ancestors, points back at the frame itself. Before
a page is projected, the candidate-parent graph is walked (breadth-first,
beyond the page itself when a chain reaches further) and every edge that would
close a loop is cut: the LATER-created message in the cycle (ties broken by
id) becomes a root instead, deterministically — the cut depends only on the
cycle's own membership, never on which page or cursor triggered the read.
The ancestor lookup spans the whole ref-scoped set rather than the current
page, so a chain crossing a page boundary cannot report a parent on one page
and null on another. It costs one query per hop of the ancestry graph — a
msg-id map over the ids referenced so far, served by
principal_mail_tenant_id_principal_id_message_id_idx — capped defensively at
MAX_THREAD_ANCESTRY_NODES so a pathological reference graph degrades a
resolved parent to null rather than reading an unbounded number of rows.
The whole module runs on the list path and never selects raw. That is what
the cached message_id, in_reply_to and references columns exist for: a
thread is read on every conversation open, and decoding one MIME frame per row
would make the cache pointless. readMailboxMessageByMessageId(db, scope, messageId) is the same posture — a scoped lookup on the list projection,
oldest match winning, null when this mailbox holds no such message.
EXPLAIN (ANALYZE, BUFFERS) on one principal with 60 000 inbound messages
(including a 20 000-row created_at tie group) plus 30 000 belonging to
others:
| Query | Before (one table) | After (split) |
|---|---|---|
default created_at keyset page, deep cursor |
0.08 ms, 9 buffers | 0.36 ms, 172 buffers — same plan shape, no sort |
sort=priority page, deep cursor |
28 ms, 10 332 | 106 ms, 8 031 |
| unread count | index-only scan | index-only scan on mailbox |
The keyset path does not regress in kind — the extra buffers are the
primary-key probes into mailbox, one per candidate row. The unread count,
which regressed badly under an earlier lazy-row design, is resolved by eager
row creation: it is once again a single index-only scan of a partial index
that matches its predicate exactly. What remains, honestly: sort=priority
pays a join over the management layer on top of a rank that was never
index-servable, ~3.8x its pre-split cost.
schema.ts and migrations.ts must agree statement for statement: the drizzle
table object is a public export, so a host pointing drizzle-kit at it would
otherwise recreate indexes the migrations do not have.
src/schema-ddl-parity.test.ts diffs the two against a live database.
runMailboxMigrations(db) is idempotent and safe to call unconditionally on
every boot of every replica.
- The whole run is one transaction whose first statements are
SET LOCAL client_min_messages = warningand a transaction-scoped advisory lock. A transaction pins one pooled connection, so the lock, the ledger read and the DDL are the same session, and the lock releases on commit or rollback with no unlock call to lose.CREATE TABLE IF NOT EXISTSis not itself race-safe, so the lock — not theIF NOT EXISTS— is what makes concurrent cold starts safe. - Lowering
client_min_messagesis why a re-run prints nothing: every statement isIF NOT EXISTS, and postgres.js would otherwise dump each NOTICE object to the console on every replica start. - The ledger is this package's own table,
"mailbox"."corbits_mailbox_migrations", never shared with the host's migration bookkeeping. Each row records a checksum of the migration's rendered statements,NOT NULL, so editing a shipped migration fails with a namedMigrationChecksumErroron the next boot instead of leaving deployed databases silently behind fresh ones. Ship a new migration instead. The checksum normalization is character-for-character the sibling cores'. - Each migration applies inside a savepoint together with its ledger row, so it can never be recorded as applied with only some statements run.
- Last, on the same transaction,
assertExpectedColumnTypesruns (src/schema-check.ts).CREATE TABLE IF NOT EXISTScompares the table name and nothing else, so against a host that already owns amailboxorprincipal_mailit would silently no-op and every read would decode the host's columns through our codec. The expectation is derived from the drizzle table objects, so it cannot drift; a rejected boot rolls the ledger row back with it. - A migration can also run that same check early, via
Migration.assertColumnsBeforeStatement.0003_mail_referencessets it: a host whoseprincipal_mailpredates this package leavesrefsmissing (it is only ever declared inline in0001'sCREATE TABLE, which no-ops against a pre-existing table), and without the early check the first statement to notice would be0003'sCREATE INDEX ... USING gin ("refs")— a raw Postgres "column "refs" does not exist" instead of the namedSchemaTypeMismatchErrordiagnostic. The knob only changes when the runner calls the check, neverMigration.statements, so it cannot changemigrationChecksum.
Everything lands in the mailbox schema, fully qualified. Nothing resolves
through search_path, so the host's own setting cannot redirect or shadow
where the mailbox tables live. The one ordering constraint mounting imposes is
the control plane's: the DDL's foreign keys reference public.tenant and
public.principal, so those tables must exist before the first run.
Owned by this package: the mailbox Postgres schema, its two tables, their
indexes and migrations; the /me/inbox* HTTP surface, its validation and
status codes; MIME frame construction and decoding; the durable write path and
its idempotency; and the triage mechanism — the ranking, the filters, the
delegation ref.
Supplied by the host: the Hono app and the database handle (pointed at the
database where tenant and principal live); the triage vocabulary; who
the caller is (resolvePrincipal); whether a sender may deliver
(authorizeSender) and to which tenant; display names
(resolveSenderDisplays); an event bus, if one process is not enough; and the
actual mail transport — this package neither sends nor receives SMTP.
- The default bus is single-process.
createInMemoryMailboxEventBus()fans out within one process only. A host running multiple replicas must supply a broker-backedMailboxEventBus, or SSE clients will only see events raised by the replica they are connected to. - SSE events are non-durable nudges. Publication is best-effort after
commit, each connection's queue is bounded at
MAX_PENDING_SSE_EVENTS(100), and a consumer that stops reading is disconnected rather than buffered for. Events can be missed (dropped publish, overflow disconnect); duplicated, but only when there is no stable dedupe key to prevent it — an inbox item redelivered without one, or a broker-backed bus itself redelivering; or arrive out of order (no cross-replica ordering guarantee). The client contract — reconnect and refetch the list and unread count on any disconnect, and never trust event arrival order over a refetch — is documented in the package README. sort=prioritypays a cross-table join on top of a rank that was never index-servable; see the measurements above.- List routes default to inbound rows. The
directioncolumn admits outbound rows and the write path can create them;listUserMailboxandgetMailboxMessagedefault to"inbound"(preserving the mounted route table's existing behavior) but acceptdirection: "outbound" | "all"for a caller — a thread reader, not yet a mounted route — that needs a principal's own sent copies. There is still no "sent" view or send route on the mounted API itself. - No search. Filtering is by view, priority, classification, status and assignee. There is no full-text index over subjects or bodies.
- Reordering the host's
prioritiesinvalidates in-flight priority cursors — they 400 rather than paging against a ranking that changed underneath them. Appending a new band has the same effect, because it changes what the trailing "unknown" rank means. ?limit=is refused, not clamped, above 200. A caller that asked for 500 and silently received 200 would page as though it had 500 rows.- Bulk actions cap at 50 ids and report per-id results; partial success is the normal outcome, not an error.
- Frame size is hard-capped at
MAX_MAILBOX_FRAME_BYTES(1 MiB) on both direct write (afterbuildMailFrame) and the transport dual-write path (raw bytes). Transport recipient lists hard-cap atMAX_MAILBOX_RECIPIENTS(50) before resolve / multi-row insert. Both refuse withRangeErrorrather than clamping; the transport path still preserves dual-write independence (mailbox refusal does not reject upstream success).