From 97e82d4bccf25ccd0e119ca6b7064b00f12fef1c Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 20 Sep 2026 22:14:43 -0700 Subject: [PATCH 01/18] docs(readme): registry install and current public API (CL-8775) Registry install (npm/pnpm/yarn/bun) is the primary path. Document the current public API. Fixes CL-8775. --- README.md | 171 +++++++++++++++++++++++++----------------------------- 1 file changed, 79 insertions(+), 92 deletions(-) diff --git a/README.md b/README.md index 0873c1a..44a6ba1 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,30 @@ # @corbits/mailbox -**[`@corbits/mailbox`](./package.json)** has one job: give a human principal -a native Interchange mailbox — a real `@intx/mailbox` `MailboxStore` backed by -Postgres, plus the thin HTTP routes a host's UI needs to list, read, and file -it. Backend only; this package ships no UI. Everything the earlier, -pre-native version of this package did — triage (priority/classification/ -status/assignee), delegation, host-defined vocabularies, its own -threading/search, `/me/threads*` — is gone. The vendored `@intx/mailbox` -`executeSearch`/`executeThread` are the search and thread primitives now, run -directly over the native store. - -Requires `@intx` 0.2.2 or newer and Node 24 or newer. +A native Interchange mailbox for human principals: a Postgres-backed `@intx/mailbox` `MailboxStore` plus the HTTP routes a host UI needs to list, read, and file it. Backend only — this package ships no UI. -See [ARCHITECTURE.md](./ARCHITECTURE.md) for the data model. +## Install + +Requires Node 24+ and `@intx` 0.2.2 or newer. + +```bash +npm install @corbits/mailbox +pnpm add @corbits/mailbox +yarn add @corbits/mailbox +bun add @corbits/mailbox +``` + +Peer stack: `@intx/log`, `drizzle-orm`, `hono`, `postgres`. -## Mount +## Use ```ts -import { mountMailbox, createInMemoryMailboxEventBus } from "@corbits/mailbox"; +import { + createInMemoryMailboxEventBus, + mountMailbox, + runMailboxMigrations, +} from "@corbits/mailbox"; +await runMailboxMigrations(db); mountMailbox(app, { db, bus: createInMemoryMailboxEventBus(), @@ -28,60 +34,60 @@ mountMailbox(app, { }); ``` -## Routes - -All under `/me/inbox`, scoped to the principal `resolvePrincipal` resolves for -the request. With no resolvable principal, list returns an empty page (200); -every other route returns 403. - -| | | -| --- | --- | -| `GET /me/inbox` | Newest first, keyset-paginated by uid. `?folder=` (`INBOX` default, `Sent`, `Archive`, or `Trash`), `?limit=`, `?cursor=`. Each item carries its `uid`, `flags`, parsed `envelope`, and base64 `raw` — the vendored `executeSearch` over the folder's native store, with envelope + raw fetched per ref. | -| `POST /me/inbox/:uid/read` | `addFlags(uid, ["\Seen"])` | -| `POST /me/inbox/:uid/unread` | `removeFlags(uid, ["\Seen"])` | -| `POST /me/inbox/:uid/archive` | `moveNativeMailboxMessage` INBOX → Archive | -| `POST /me/inbox/:uid/trash` | `moveNativeMailboxMessage` INBOX → Trash | -| `POST /me/inbox/:uid/restore` | `moveNativeMailboxMessage` (`?folder=`, default Archive) → INBOX | -| `GET /me/inbox/events` | SSE stream of `mailbox` events (`create`/`mark_read`/`mark_unread`/`archive`/`trash`/`restore`) for the caller's mailbox, plus a heartbeat every 25s. | -| `GET /me/inbox/threads` | The vendored `executeThread` (REFERENCES) over the folder's native store — roots + children, each ref carrying the same envelope fields as `GET /me/inbox`. `?folder=`. | -| `GET /me/inbox/threads/:rootUid` | The single native thread rooted at `rootUid`, same per-ref envelope fields. `?folder=`. | -| `POST /me/inbox/send` | Body `{ to, subject?, body, inReplyTo? }`; builds an RFC 5322 message, appends it to the caller's `Sent` folder, and returns `{ messageId, uid }`. | +Routes land under `/me/inbox`, scoped to the principal `resolvePrincipal` returns. -`POST /me/inbox/send` only builds the message and files the caller's own -`Sent` copy — the host's `deliver` mount dep owns actually getting the -message to its recipients. - -## Writing into a mailbox - -Every write path — host code, ingress adapters, and the transport dual-write -seam — lands through `NativeMailboxStore.append`, so uid/modseq are always -set. There is no other write path left. +## Full example ```ts -import { writeMailboxMessage, deliverInboxItems } from "@corbits/mailbox"; - -// One message, appended into the principal's INBOX. Deduped on `messageId` -// within that mailbox — a caller-supplied or minted one. -await writeMailboxMessage(db, { - tenantId, - principalId, - address: "usr_alice@acme.example", - fromAddress: "bot@acme.example", - subject: "Run finished", - body: "...", -}, bus); - -// Ingress adapters (mail connectors, webhooks): one item per external -// (source, externalId), deduped on a messageId minted from that pair. -await deliverInboxItems(db, [ - { tenantId, principalId, address, fromAddress, subject, body, source: "gmail", externalId: "msg-1" }, -], { bus, enqueue: ({ id, item }) => hostTriage(id, item) }); -``` +import { + createInMemoryMailboxEventBus, + createMailboxPersist, + deliverInboxItems, + mountMailbox, + runMailboxMigrations, + writeMailboxMessage, +} from "@corbits/mailbox"; + +await runMailboxMigrations(db); +const bus = createInMemoryMailboxEventBus(); -## Dual-write persist +mountMailbox(app, { + db, + bus, + resolvePrincipal: (ctx) => resolveCallerFromRequest(ctx), + senderAddressFor: (principal) => resolveCallerAddress(principal), + deliver: (message) => hostMailTransport.send(message), +}); -```ts -import { createMailboxPersist } from "@corbits/mailbox"; +await writeMailboxMessage( + db, + { + tenantId, + principalId, + address: "usr_alice@acme.example", + fromAddress: "bot@acme.example", + subject: "Run finished", + body: "…", + }, + bus, +); + +await deliverInboxItems( + db, + [ + { + tenantId, + principalId, + address, + fromAddress, + subject, + body, + source: "gmail", + externalId: "msg-1", + }, + ], + { bus }, +); const persist = createMailboxPersist(db, { upstream: hostMailTransport.persist, @@ -90,47 +96,28 @@ const persist = createMailboxPersist(db, { }); ``` -`upstream` throwing still attempts the mailbox append (and the upstream error -re-throws unchanged); a mailbox-append failure is logged and never rejects a -persist upstream already completed. One append per resolved recipient, into -their INBOX, deduped on the frame's Message-ID within that mailbox. +`POST /me/inbox/send` only builds the RFC 5322 message and files the caller's `Sent` copy — `deliver` owns putting it on the wire. -## Install +## How it works -Not published to npm yet. Until a registry publish, `bun add @corbits/mailbox` -404s. Git is the install path. +Every write lands through `NativeMailboxStore.append` (uid/modseq always set). Search and threading are the vendored `@intx/mailbox` `executeSearch` / `executeThread` over that store. `mountMailbox` exposes `/me/inbox` (list, flags, archive/trash/restore, SSE, threads, send). With no resolvable principal, list returns an empty page; every other route returns 403. -```sh -# from git (prepare hook builds dist/ on the way in) -bun add github:corbitsdev/corbits-mailbox -``` - -## Layout - -| | | -| --- | --- | -| `src/` | The published package. Owns `principal_mail` (the mail plane) and `mailbox_state` (per-folder IMAP counters) — the two tables `NativeMailboxStore` reads and writes. | -| `examples/reference-host` | Mounts it on a real `@intx/hub-api` app against a live Postgres and asserts the acceptance scenarios end to end. | +See [ARCHITECTURE.md](./ARCHITECTURE.md) for the data model. -## Working on it +## Contributing ```sh bun install docker run -d --name mailbox-pg -p 5433:5432 \ -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=mailbox_core postgres:16 -bun run test # unit + integration -bun run build # dist/ (JS + .d.ts) -bun test --cwd examples/reference-host # acceptance scenarios +bun run typecheck +bun run test # unit + integration +bun run build # dist/ (JS + .d.ts) +bun run test:acceptance # builds, then examples/reference-host ``` -Tests and the example expect `postgres://postgres:postgres@localhost:5433/mailbox_core`; -override with `MAILBOX_TEST_DATABASE_URL` / `MAILBOX_DATABASE_URL`. - -## Conventions - -Strict TypeScript, arktype at boundaries, drizzle for data access. Dependencies come -from public `@intx/*` on npm only. +Tests and the example expect `postgres://postgres:postgres@localhost:5433/mailbox_core`; override with `MAILBOX_TEST_DATABASE_URL` / `MAILBOX_DATABASE_URL`. See [CONTRIBUTING.md](./CONTRIBUTING.md). ## License From 5e990af8831261a0031ed90b656d36508559697e Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 20 Sep 2026 22:43:22 -0700 Subject: [PATCH 02/18] docs: align README headings with Interchange (CL-8775) Use Quickstart / How it works / Development / License instead of Install / Use / Full example / Contributing. --- README.md | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 44a6ba1..c077b29 100644 --- a/README.md +++ b/README.md @@ -2,21 +2,19 @@ A native Interchange mailbox for human principals: a Postgres-backed `@intx/mailbox` `MailboxStore` plus the HTTP routes a host UI needs to list, read, and file it. Backend only — this package ships no UI. -## Install +## Runtime support -Requires Node 24+ and `@intx` 0.2.2 or newer. +Node >= 24 consumes built `dist/`. `@intx` 0.2.2 or newer. Peer stack: `@intx/log`, `drizzle-orm`, `hono`, `postgres`. + +## Quickstart ```bash -npm install @corbits/mailbox +npm add @corbits/mailbox pnpm add @corbits/mailbox yarn add @corbits/mailbox bun add @corbits/mailbox ``` -Peer stack: `@intx/log`, `drizzle-orm`, `hono`, `postgres`. - -## Use - ```ts import { createInMemoryMailboxEventBus, @@ -36,8 +34,6 @@ mountMailbox(app, { Routes land under `/me/inbox`, scoped to the principal `resolvePrincipal` returns. -## Full example - ```ts import { createInMemoryMailboxEventBus, @@ -104,9 +100,11 @@ Every write lands through `NativeMailboxStore.append` (uid/modseq always set). S See [ARCHITECTURE.md](./ARCHITECTURE.md) for the data model. -## Contributing +## Development ```sh +git clone https://github.com/corbitsdev/corbits-mailbox.git +cd corbits-mailbox bun install docker run -d --name mailbox-pg -p 5433:5432 \ -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=mailbox_core postgres:16 From cd2a45b0398f55f48e78dab39436e5a82650635b Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 21 Sep 2026 11:07:55 -0700 Subject: [PATCH 03/18] docs: rewrite mailbox Quickstart as mount-then-curl (CL-8775) --- README.md | 112 ++++++++++++++++++++++-------------------------------- 1 file changed, 46 insertions(+), 66 deletions(-) diff --git a/README.md b/README.md index c077b29..0880c37 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # @corbits/mailbox -A native Interchange mailbox for human principals: a Postgres-backed `@intx/mailbox` `MailboxStore` plus the HTTP routes a host UI needs to list, read, and file it. Backend only — this package ships no UI. +Give a **person** in an Interchange hub an inbox: list, read, flag, send, SSE. You mount it on a Hono app you already have. Postgres holds the mail. This package ships **no UI**. ## Runtime support -Node >= 24 consumes built `dist/`. `@intx` 0.2.2 or newer. Peer stack: `@intx/log`, `drizzle-orm`, `hono`, `postgres`. +Node >= 24 consumes built `dist/`. Bun >= 1.2 runs TypeScript source. Peers: `@intx/log`, `drizzle-orm`, `hono`, `postgres`. ## Quickstart @@ -15,94 +15,74 @@ yarn add @corbits/mailbox bun add @corbits/mailbox ``` +You bring three things this library will not invent: + +1. A **Postgres** database (same one as the hub — mailbox tables live in schema `mailbox`). +2. A **Hono** app with middleware that can tell you who the HTTP caller is. +3. A **mail transport** that can actually deliver bytes (SMTP, the hub’s mail router, etc.). + +Migrate once, then mount: + ```ts +import { Hono } from "hono"; import { createInMemoryMailboxEventBus, + createMailboxDb, mountMailbox, runMailboxMigrations, } from "@corbits/mailbox"; +const { db } = createMailboxDb(process.env.DATABASE_URL!); await runMailboxMigrations(db); + +const app = new Hono(); + mountMailbox(app, { db, bus: createInMemoryMailboxEventBus(), - resolvePrincipal: (ctx) => resolveCallerFromRequest(ctx), - senderAddressFor: (principal) => resolveCallerAddress(principal), - deliver: (message) => hostMailTransport.send(message), + // Who is this request? Return null for anonymous. + resolvePrincipal: (ctx) => yourAuth.principalFrom(ctx), + // Their From: address when they hit POST /me/inbox/send. + senderAddressFor: (principal) => `${principal.principalId}@your-tenant.example`, + // Put the RFC 5322 message on the wire. We already filed Sent. + deliver: (message) => yourMail.send(message.raw, message.to), }); ``` -Routes land under `/me/inbox`, scoped to the principal `resolvePrincipal` returns. +That is the whole product. Routes are under `/me/inbox` for whoever `resolvePrincipal` returned. -```ts -import { - createInMemoryMailboxEventBus, - createMailboxPersist, - deliverInboxItems, - mountMailbox, - runMailboxMigrations, - writeMailboxMessage, -} from "@corbits/mailbox"; +```bash +curl -H "Cookie: …" http://localhost:3000/me/inbox +``` -await runMailboxMigrations(db); -const bus = createInMemoryMailboxEventBus(); +Anonymous list is an empty page. Every other route is 403 until you resolve a principal. -mountMailbox(app, { - db, - bus, - resolvePrincipal: (ctx) => resolveCallerFromRequest(ctx), - senderAddressFor: (principal) => resolveCallerAddress(principal), - deliver: (message) => hostMailTransport.send(message), -}); +To drop a message into someone’s inbox from **your** backend (not from the HTTP send route): -await writeMailboxMessage( - db, - { - tenantId, - principalId, - address: "usr_alice@acme.example", - fromAddress: "bot@acme.example", - subject: "Run finished", - body: "…", - }, - bus, -); - -await deliverInboxItems( - db, - [ - { - tenantId, - principalId, - address, - fromAddress, - subject, - body, - source: "gmail", - externalId: "msg-1", - }, - ], - { bus }, -); - -const persist = createMailboxPersist(db, { - upstream: hostMailTransport.persist, - authorizeSender: (address) => resolveActiveInstance(address), - bus, +```ts +import { writeMailboxMessage } from "@corbits/mailbox"; + +await writeMailboxMessage(db, { + tenantId, + principalId, + address: "usr_alice@acme.example", + fromAddress: "bot@acme.example", + subject: "Run finished", + body: "…", }); ``` -`POST /me/inbox/send` only builds the RFC 5322 message and files the caller's `Sent` copy — `deliver` owns putting it on the wire. +A full hub wiring lives in `examples/reference-host`. ## How it works -Every write lands through `NativeMailboxStore.append` (uid/modseq always set). Search and threading are the vendored `@intx/mailbox` `executeSearch` / `executeThread` over that store. `mountMailbox` exposes `/me/inbox` (list, flags, archive/trash/restore, SSE, threads, send). With no resolvable principal, list returns an empty page; every other route returns 403. +Writes go through a native `MailboxStore` (uid/modseq always set). Search and threads are vendored `@intx/mailbox` over that store. `POST /me/inbox/send` only builds the message and files `Sent` — `deliver` is how it leaves the machine. -See [ARCHITECTURE.md](./ARCHITECTURE.md) for the data model. +See [ARCHITECTURE.md](./ARCHITECTURE.md), [PRODUCT.md](./PRODUCT.md), and [IMPLEMENTATION.md](./IMPLEMENTATION.md) if those files are in the tree. ## Development -```sh +```bash git clone https://github.com/corbitsdev/corbits-mailbox.git cd corbits-mailbox bun install @@ -110,12 +90,12 @@ docker run -d --name mailbox-pg -p 5433:5432 \ -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=mailbox_core postgres:16 bun run typecheck -bun run test # unit + integration -bun run build # dist/ (JS + .d.ts) -bun run test:acceptance # builds, then examples/reference-host +bun run test +bun run build +bun run test:acceptance ``` -Tests and the example expect `postgres://postgres:postgres@localhost:5433/mailbox_core`; override with `MAILBOX_TEST_DATABASE_URL` / `MAILBOX_DATABASE_URL`. See [CONTRIBUTING.md](./CONTRIBUTING.md). +Tests expect `postgres://postgres:postgres@localhost:5433/mailbox_core` (override with `MAILBOX_TEST_DATABASE_URL` / `MAILBOX_DATABASE_URL`). See [CONTRIBUTING.md](./CONTRIBUTING.md). ## License From 113e44b42b978bea45004ed68ac8546808aec808 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 21 Sep 2026 11:12:52 -0700 Subject: [PATCH 04/18] docs: Quickstart installs peers and runs a complete mount (CL-8775) --- README.md | 60 ++++++++++++++++++++++++++++--------------------------- 1 file changed, 31 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 0880c37..eaa20da 100644 --- a/README.md +++ b/README.md @@ -8,20 +8,18 @@ Node >= 24 consumes built `dist/`. Bun >= 1.2 runs TypeScript source. Peers: `@i ## Quickstart +Install this package **and** the peers it expects the host to provide: + ```bash -npm add @corbits/mailbox -pnpm add @corbits/mailbox -yarn add @corbits/mailbox -bun add @corbits/mailbox +bun add @corbits/mailbox @intx/log hono postgres drizzle-orm +# npm add @corbits/mailbox @intx/log hono postgres drizzle-orm +# pnpm add @corbits/mailbox @intx/log hono postgres drizzle-orm +# yarn add @corbits/mailbox @intx/log hono postgres drizzle-orm ``` -You bring three things this library will not invent: - -1. A **Postgres** database (same one as the hub — mailbox tables live in schema `mailbox`). -2. A **Hono** app with middleware that can tell you who the HTTP caller is. -3. A **mail transport** that can actually deliver bytes (SMTP, the hub’s mail router, etc.). +`@intx/log` `^0.2.2`, `hono` `^4.12`, `postgres` `^3.4`, `drizzle-orm` `^0.45` (see `peerDependencies`). Postgres 13+ with a database URL. -Migrate once, then mount: +Minimum that actually runs — a fixed demo principal (swap for session auth) and a `deliver` that logs instead of sending mail: ```ts import { Hono } from "hono"; @@ -32,47 +30,51 @@ import { runMailboxMigrations, } from "@corbits/mailbox"; -const { db } = createMailboxDb(process.env.DATABASE_URL!); +const { db } = createMailboxDb( + process.env.DATABASE_URL ?? + "postgres://postgres:postgres@localhost:5433/mailbox_core", +); await runMailboxMigrations(db); -const app = new Hono(); +const DEMO = { tenantId: "tnt_demo", principalId: "usr_demo" }; +const app = new Hono(); mountMailbox(app, { db, bus: createInMemoryMailboxEventBus(), - // Who is this request? Return null for anonymous. - resolvePrincipal: (ctx) => yourAuth.principalFrom(ctx), - // Their From: address when they hit POST /me/inbox/send. - senderAddressFor: (principal) => `${principal.principalId}@your-tenant.example`, - // Put the RFC 5322 message on the wire. We already filed Sent. - deliver: (message) => yourMail.send(message.raw, message.to), + resolvePrincipal: () => DEMO, + senderAddressFor: (p) => `${p.principalId}@demo.example`, + deliver: async (message) => { + console.log("deliver", message.from, "→", message.to); + }, }); -``` -That is the whole product. Routes are under `/me/inbox` for whoever `resolvePrincipal` returned. +Bun.serve({ port: 3000, fetch: app.fetch }); +console.log("GET http://127.0.0.1:3000/me/inbox"); +``` ```bash -curl -H "Cookie: …" http://localhost:3000/me/inbox +curl http://127.0.0.1:3000/me/inbox ``` -Anonymous list is an empty page. Every other route is 403 until you resolve a principal. +That lists the demo user's inbox (empty until something writes a row). `POST /me/inbox/send` files `Sent` and calls `deliver` with `{ raw, from, to, messageId }`. -To drop a message into someone’s inbox from **your** backend (not from the HTTP send route): +From your own backend, insert a row without HTTP: ```ts import { writeMailboxMessage } from "@corbits/mailbox"; await writeMailboxMessage(db, { - tenantId, - principalId, - address: "usr_alice@acme.example", - fromAddress: "bot@acme.example", + tenantId: DEMO.tenantId, + principalId: DEMO.principalId, + address: "usr_demo@demo.example", + fromAddress: "bot@demo.example", subject: "Run finished", - body: "…", + body: "The job completed.", }); ``` -A full hub wiring lives in `examples/reference-host`. +In-tree host: `examples/reference-host`. Richer hub-shaped samples belong in [corbitsdev/examples](https://github.com/corbitsdev/examples), not this README. ## How it works From 2eacbad4bd243c61f40fb8153ca014a60efe8415 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 21 Sep 2026 11:29:04 -0700 Subject: [PATCH 05/18] docs: Quickstart is hub persistMail + mountMailbox, not a demo (CL-8775) --- README.md | 80 ++++++++++++++++++++++++++----------------------------- 1 file changed, 38 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index eaa20da..187dfcd 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Node >= 24 consumes built `dist/`. Bun >= 1.2 runs TypeScript source. Peers: `@i ## Quickstart -Install this package **and** the peers it expects the host to provide: +Install the package and the host peers: ```bash bun add @corbits/mailbox @intx/log hono postgres drizzle-orm @@ -17,64 +17,60 @@ bun add @corbits/mailbox @intx/log hono postgres drizzle-orm # yarn add @corbits/mailbox @intx/log hono postgres drizzle-orm ``` -`@intx/log` `^0.2.2`, `hono` `^4.12`, `postgres` `^3.4`, `drizzle-orm` `^0.45` (see `peerDependencies`). Postgres 13+ with a database URL. +Peers: `@intx/log` `^0.2.2`, `hono` `^4.12`, `postgres` `^3.4`, `drizzle-orm` `^0.45`. Postgres 13+. A hub also already has `@intx/hub-api` / `@intx/hub-sessions` / `@intx/db`. -Minimum that actually runs — a fixed demo principal (swap for session auth) and a `deliver` that logs instead of sending mail: +There are **two** host seams. Workbench uses (1) today. (2) is how a person sends from the inbox UI. + +**1. Agent frames land in the person's inbox** — wrap the hub's `persistMail` so every outbound agent mail dual-writes a mailbox row. This is Workbench `apps/hub/src/mailbox-persist.ts`. + +```ts +import { createMailboxPersist } from "@corbits/mailbox"; + +lookups.persistMail = createMailboxPersist(mailboxDb, { + upstream: hubPersistMail, // existing Interchange persistMail + authorizeSender: hubAuthorizeMailboxSender, // live run → { tenantId, domain } + bus: mailboxBus, +}); +``` + +`authorizeSender` is the host's call: only a live agent instance may write. Recipients outside that tenant domain are skipped. + +**2. HTTP inbox for the signed-in person** — mount under the hub tenant prefix. `resolvePrincipal` reads the same tenant/principal the hub middleware already set. `senderAddressFor` is their From:. `deliver` is the **host mail router** (SMTP, sidecar `routeMail`, whatever the hub already uses to send MIME) — not a log line. ```ts import { Hono } from "hono"; import { createInMemoryMailboxEventBus, - createMailboxDb, mountMailbox, runMailboxMigrations, } from "@corbits/mailbox"; -const { db } = createMailboxDb( - process.env.DATABASE_URL ?? - "postgres://postgres:postgres@localhost:5433/mailbox_core", -); -await runMailboxMigrations(db); - -const DEMO = { tenantId: "tnt_demo", principalId: "usr_demo" }; - -const app = new Hono(); -mountMailbox(app, { - db, - bus: createInMemoryMailboxEventBus(), - resolvePrincipal: () => DEMO, - senderAddressFor: (p) => `${p.principalId}@demo.example`, - deliver: async (message) => { - console.log("deliver", message.from, "→", message.to); +await runMailboxMigrations(mailboxDb); +const mailboxBus = createInMemoryMailboxEventBus(); +const mailboxApp = new Hono(); + +mountMailbox(mailboxApp, { + db: mailboxDb, + bus: mailboxBus, + resolvePrincipal: (ctx) => { + const c = ctx as { get(k: "tenant" | "principal"): { id: string } }; + return { + tenantId: c.get("tenant").id, + principalId: c.get("principal").id, + }; }, + senderAddressFor: (p) => `${p.principalId}@${mailDomain}`, + deliver: (message) => hubSendMime(message), }); -Bun.serve({ port: 3000, fetch: app.fetch }); -console.log("GET http://127.0.0.1:3000/me/inbox"); +app.route("/api/tenants/:tenantId/mailbox", mailboxApp); ``` -```bash -curl http://127.0.0.1:3000/me/inbox -``` - -That lists the demo user's inbox (empty until something writes a row). `POST /me/inbox/send` files `Sent` and calls `deliver` with `{ raw, from, to, messageId }`. - -From your own backend, insert a row without HTTP: +`hubSendMime` is **your** existing outbound path: `{ raw: Uint8Array, from, to, messageId }`. Workbench does **not** pass `deliver` / `senderAddressFor` yet and still sends `vocabulary` — that catch-up is [CL-8789](https://linear.app/abklabs/issue/CL-8789). Until the hub wires `deliver`, `POST .../mailbox/me/inbox/send` files Sent and then has nowhere to put the bytes. -```ts -import { writeMailboxMessage } from "@corbits/mailbox"; - -await writeMailboxMessage(db, { - tenantId: DEMO.tenantId, - principalId: DEMO.principalId, - address: "usr_demo@demo.example", - fromAddress: "bot@demo.example", - subject: "Run finished", - body: "The job completed.", -}); -``` +Solutions Builder does not mount this package; it talks to the hub. -In-tree host: `examples/reference-host`. Richer hub-shaped samples belong in [corbitsdev/examples](https://github.com/corbitsdev/examples), not this README. +In-tree composition proof: `examples/reference-host` (`createApp` from `@intx/hub-api` + this mount). Hub-scale samples belong in [corbitsdev/examples](https://github.com/corbitsdev/examples). ## How it works From 784f23c6fe873b8247b1bc565b2d8d61c756d69d Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 21 Sep 2026 16:47:33 -0700 Subject: [PATCH 06/18] docs: drop lookups.* from Quickstart; wrap persistMail and pass it back --- README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 187dfcd..ff59c01 100644 --- a/README.md +++ b/README.md @@ -21,16 +21,17 @@ Peers: `@intx/log` `^0.2.2`, `hono` `^4.12`, `postgres` `^3.4`, `drizzle-orm` `^ There are **two** host seams. Workbench uses (1) today. (2) is how a person sends from the inbox UI. -**1. Agent frames land in the person's inbox** — wrap the hub's `persistMail` so every outbound agent mail dual-writes a mailbox row. This is Workbench `apps/hub/src/mailbox-persist.ts`. +**1. Agent frames land in the person's inbox** — wrap the function the hub already uses to persist outbound mail (`persistMail` in Interchange session lookups). Workbench does this in `apps/hub/src/mailbox-persist.ts`. There is no `lookups` export from this package. ```ts import { createMailboxPersist } from "@corbits/mailbox"; -lookups.persistMail = createMailboxPersist(mailboxDb, { - upstream: hubPersistMail, // existing Interchange persistMail +const persistMail = createMailboxPersist(mailboxDb, { + upstream: hubPersistMail, // the persistMail you already pass into the hub authorizeSender: hubAuthorizeMailboxSender, // live run → { tenantId, domain } bus: mailboxBus, }); +// Pass `persistMail` into the hub in the same place you used to pass hubPersistMail. ``` `authorizeSender` is the host's call: only a live agent instance may write. Recipients outside that tenant domain are skipped. From 32879a5692650af1e87e1640d6d278ab6b37004c Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 21 Sep 2026 16:53:28 -0700 Subject: [PATCH 07/18] docs: generic hub Quickstart; no lookups.* (CL-8775) --- README.md | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index ff59c01..07fb747 100644 --- a/README.md +++ b/README.md @@ -17,26 +17,27 @@ bun add @corbits/mailbox @intx/log hono postgres drizzle-orm # yarn add @corbits/mailbox @intx/log hono postgres drizzle-orm ``` -Peers: `@intx/log` `^0.2.2`, `hono` `^4.12`, `postgres` `^3.4`, `drizzle-orm` `^0.45`. Postgres 13+. A hub also already has `@intx/hub-api` / `@intx/hub-sessions` / `@intx/db`. +Peers: `@intx/log` `^0.2.2`, `hono` `^4.12`, `postgres` `^3.4`, `drizzle-orm` `^0.45`. A hub also has `@intx/hub-api`, `@intx/hub-sessions`, `@intx/db`. Postgres 13+. -There are **two** host seams. Workbench uses (1) today. (2) is how a person sends from the inbox UI. +A **generic Interchange hub** does two things with this package. Neither is a demo user or `console.log`. -**1. Agent frames land in the person's inbox** — wrap the function the hub already uses to persist outbound mail (`persistMail` in Interchange session lookups). Workbench does this in `apps/hub/src/mailbox-persist.ts`. There is no `lookups` export from this package. +**1. Give the hub a persist function that also writes the inbox.** +Interchange already persists outbound mail (`persistMail`: `{ senderAddress, recipients, raw }`). Wrap it so each addressed **person** also gets a mailbox row. Pass the **wrapper** into hub construction as `persistMail` — the same slot you used for the unwrapped function. Do not assign onto a `lookups` object; that bag is hub-private. ```ts import { createMailboxPersist } from "@corbits/mailbox"; const persistMail = createMailboxPersist(mailboxDb, { - upstream: hubPersistMail, // the persistMail you already pass into the hub - authorizeSender: hubAuthorizeMailboxSender, // live run → { tenantId, domain } + upstream: hubPersistMail, // what you already passed into the hub + authorizeSender, // live run address → { tenantId, domain } or skip bus: mailboxBus, }); -// Pass `persistMail` into the hub in the same place you used to pass hubPersistMail. +// createApp / session setup: persistMail, ``` -`authorizeSender` is the host's call: only a live agent instance may write. Recipients outside that tenant domain are skipped. +`authorizeSender` is host policy (Workbench: live run only). Recipients outside `domain` are skipped. -**2. HTTP inbox for the signed-in person** — mount under the hub tenant prefix. `resolvePrincipal` reads the same tenant/principal the hub middleware already set. `senderAddressFor` is their From:. `deliver` is the **host mail router** (SMTP, sidecar `routeMail`, whatever the hub already uses to send MIME) — not a log line. +**2. Mount the person's HTTP inbox** on the hub app, under the tenant routes, using the same principal the hub session middleware already set. ```ts import { Hono } from "hono"; @@ -61,17 +62,17 @@ mountMailbox(mailboxApp, { }; }, senderAddressFor: (p) => `${p.principalId}@${mailDomain}`, - deliver: (message) => hubSendMime(message), + deliver: (message) => sendMime(message), }); app.route("/api/tenants/:tenantId/mailbox", mailboxApp); ``` -`hubSendMime` is **your** existing outbound path: `{ raw: Uint8Array, from, to, messageId }`. Workbench does **not** pass `deliver` / `senderAddressFor` yet and still sends `vocabulary` — that catch-up is [CL-8789](https://linear.app/abklabs/issue/CL-8789). Until the hub wires `deliver`, `POST .../mailbox/me/inbox/send` files Sent and then has nowhere to put the bytes. +`sendMime` is the hub's real outbound MIME path (`{ raw, from, to, messageId }`). Same transport you use for other human mail, not a log. -Solutions Builder does not mount this package; it talks to the hub. +Today Workbench does (1) by writing `lookups.persistMail` after the fact and (2) without `deliver` / `senderAddressFor`. That is host debt: [CL-8789](https://linear.app/abklabs/issue/CL-8789) (mount), [CL-8790](https://linear.app/abklabs/issue/CL-8790) (pass persistMail at construction). Solutions Builder talks to the hub; it does not mount this package. -In-tree composition proof: `examples/reference-host` (`createApp` from `@intx/hub-api` + this mount). Hub-scale samples belong in [corbitsdev/examples](https://github.com/corbitsdev/examples). +In-tree: `examples/reference-host` (`@intx/hub-api` `createApp` + this mount). Larger hosts: [corbitsdev/examples](https://github.com/corbitsdev/examples). ## How it works From cf5f7219371c22995321386069e6b08c8110e695 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 21 Sep 2026 16:57:39 -0700 Subject: [PATCH 08/18] docs: Quickstart is reference-host mountMailbox, not stub identifiers --- README.md | 82 ++++++++++++++++++++----------------------------------- 1 file changed, 29 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index 07fb747..a11905b 100644 --- a/README.md +++ b/README.md @@ -8,71 +8,47 @@ Node >= 24 consumes built `dist/`. Bun >= 1.2 runs TypeScript source. Peers: `@i ## Quickstart -Install the package and the host peers: - ```bash -bun add @corbits/mailbox @intx/log hono postgres drizzle-orm -# npm add @corbits/mailbox @intx/log hono postgres drizzle-orm -# pnpm add @corbits/mailbox @intx/log hono postgres drizzle-orm -# yarn add @corbits/mailbox @intx/log hono postgres drizzle-orm +bun add @corbits/mailbox @intx/log @intx/hub-api @intx/db @intx/hub-sessions hono postgres drizzle-orm ``` -Peers: `@intx/log` `^0.2.2`, `hono` `^4.12`, `postgres` `^3.4`, `drizzle-orm` `^0.45`. A hub also has `@intx/hub-api`, `@intx/hub-sessions`, `@intx/db`. Postgres 13+. - -A **generic Interchange hub** does two things with this package. Neither is a demo user or `console.log`. - -**1. Give the hub a persist function that also writes the inbox.** -Interchange already persists outbound mail (`persistMail`: `{ senderAddress, recipients, raw }`). Wrap it so each addressed **person** also gets a mailbox row. Pass the **wrapper** into hub construction as `persistMail` — the same slot you used for the unwrapped function. Do not assign onto a `lookups` object; that bag is hub-private. +**Run the host this package ships:** [`examples/reference-host`](./examples/reference-host). That file calls `createApp` from `@intx/hub-api`, then this: ```ts -import { createMailboxPersist } from "@corbits/mailbox"; - -const persistMail = createMailboxPersist(mailboxDb, { - upstream: hubPersistMail, // what you already passed into the hub - authorizeSender, // live run address → { tenantId, domain } or skip - bus: mailboxBus, -}); -// createApp / session setup: persistMail, -``` - -`authorizeSender` is host policy (Workbench: live run only). Recipients outside `domain` are skipped. - -**2. Mount the person's HTTP inbox** on the hub app, under the tenant routes, using the same principal the hub session middleware already set. - -```ts -import { Hono } from "hono"; -import { - createInMemoryMailboxEventBus, - mountMailbox, - runMailboxMigrations, -} from "@corbits/mailbox"; - -await runMailboxMigrations(mailboxDb); -const mailboxBus = createInMemoryMailboxEventBus(); -const mailboxApp = new Hono(); - -mountMailbox(mailboxApp, { - db: mailboxDb, - bus: mailboxBus, +const bus = createInMemoryMailboxEventBus(); +const deliveries: { + raw: Uint8Array; + from: string; + to: string[]; + messageId: string; +}[] = []; + +const api = new Hono(); +mountMailbox(api, { + db, // hub.db — one drizzle pool, mailbox schema on the same Postgres + bus, resolvePrincipal: (ctx) => { - const c = ctx as { get(k: "tenant" | "principal"): { id: string } }; - return { - tenantId: c.get("tenant").id, - principalId: c.get("principal").id, - }; + const user = (ctx as Context).get("user"); + if (!user) return null; + const [tenantId, principalId] = user.id.split(":"); + return tenantId && principalId ? { tenantId, principalId } : null; + }, + senderAddressFor: ({ tenantId, principalId }) => + `${principalId}@${tenantId}.example`, + deliver: (message) => { + deliveries.push(message); }, - senderAddressFor: (p) => `${p.principalId}@${mailDomain}`, - deliver: (message) => sendMime(message), }); - -app.route("/api/tenants/:tenantId/mailbox", mailboxApp); +app.route("/api", api); ``` -`sendMime` is the hub's real outbound MIME path (`{ raw, from, to, messageId }`). Same transport you use for other human mail, not a log. +`db`, `app`, `AppEnv`, and `getSession` are created in that same file (`createDB` + `createApp`). `deliver` in the example **appends to `deliveries`** so tests can assert without SMTP. Production replaces that push with the hub’s real send of `message.raw`. + +Inbox paths: `GET/POST /api/me/inbox…`. -Today Workbench does (1) by writing `lookups.persistMail` after the fact and (2) without `deliver` / `senderAddressFor`. That is host debt: [CL-8789](https://linear.app/abklabs/issue/CL-8789) (mount), [CL-8790](https://linear.app/abklabs/issue/CL-8790) (pass persistMail at construction). Solutions Builder talks to the hub; it does not mount this package. +Agent mail into a person’s inbox is `createMailboxPersist` wrapping the hub’s `persistMail`, passed **into** hub construction — not `lookups.persistMail`. Workbench still mutates lookups and still omits `deliver`: [CL-8789](https://linear.app/abklabs/issue/CL-8789), [CL-8790](https://linear.app/abklabs/issue/CL-8790). -In-tree: `examples/reference-host` (`@intx/hub-api` `createApp` + this mount). Larger hosts: [corbitsdev/examples](https://github.com/corbitsdev/examples). +SBA uses the hub; it does not mount this package. ## How it works From f6f50f46bbd9fdb2392a7871278b64016a68ccba Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 21 Sep 2026 17:00:42 -0700 Subject: [PATCH 09/18] docs: bus is SSE, mail is Postgres --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index a11905b..642671c 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ bun add @corbits/mailbox @intx/log @intx/hub-api @intx/db @intx/hub-sessions hon ```ts const bus = createInMemoryMailboxEventBus(); +// SSE only (inbox live updates). Mail itself is Postgres, not this bus. const deliveries: { raw: Uint8Array; from: string; From b36eaacbe971f700ebdc4258d8fd7f86ff30c899 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 21 Sep 2026 17:03:14 -0700 Subject: [PATCH 10/18] fix(mailbox): default SSE bus; keep it out of the Quickstart --- README.md | 14 ++------------ src/mount.ts | 11 +++++++++-- 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 642671c..86c544e 100644 --- a/README.md +++ b/README.md @@ -15,19 +15,9 @@ bun add @corbits/mailbox @intx/log @intx/hub-api @intx/db @intx/hub-sessions hon **Run the host this package ships:** [`examples/reference-host`](./examples/reference-host). That file calls `createApp` from `@intx/hub-api`, then this: ```ts -const bus = createInMemoryMailboxEventBus(); -// SSE only (inbox live updates). Mail itself is Postgres, not this bus. -const deliveries: { - raw: Uint8Array; - from: string; - to: string[]; - messageId: string; -}[] = []; - const api = new Hono(); mountMailbox(api, { - db, // hub.db — one drizzle pool, mailbox schema on the same Postgres - bus, + db, // hub.db — Postgres. Mail is not in-memory. resolvePrincipal: (ctx) => { const user = (ctx as Context).get("user"); if (!user) return null; @@ -43,7 +33,7 @@ mountMailbox(api, { app.route("/api", api); ``` -`db`, `app`, `AppEnv`, and `getSession` are created in that same file (`createDB` + `createApp`). `deliver` in the example **appends to `deliveries`** so tests can assert without SMTP. Production replaces that push with the hub’s real send of `message.raw`. +`db`, `app`, `AppEnv`, `deliveries`, and `getSession` are in [`examples/reference-host`](./examples/reference-host). Mail is Postgres. `bus` is optional: default is an in-process **SSE** fan-out (not the store). Pass `bus` only if several hub processes must share live inbox events. Inbox paths: `GET/POST /api/me/inbox…`. diff --git a/src/mount.ts b/src/mount.ts index 4e6a09f..9bfaaff 100644 --- a/src/mount.ts +++ b/src/mount.ts @@ -8,6 +8,7 @@ import type { Thread } from "@intx/types/runtime"; import type { MailboxDb } from "./db.js"; import type { NativeMailboxStore } from "./native-store.js"; import { + createInMemoryMailboxEventBus, publishMailboxEvent, type MailboxEvent, type MailboxEventBus, @@ -30,7 +31,12 @@ export type OutgoingMailboxMessage = { export type MountMailboxOpts = { db: MailboxDb; - bus: MailboxEventBus; + /** + * SSE live-update bus only. Mail is Postgres. Defaults to an in-process + * bus (one hub process). Pass a shared bus if several hub processes must + * fan the same inbox events. + */ + bus?: MailboxEventBus; resolvePrincipal: ( ctx: unknown, ) => Promise | ResolvedPrincipal | null; @@ -202,7 +208,8 @@ export function mountMailbox( app: Hono, opts: MountMailboxOpts, ): Hono { - const { db, bus, resolvePrincipal, senderAddressFor, deliver } = opts; + const { db, resolvePrincipal, senderAddressFor, deliver } = opts; + const bus = opts.bus ?? createInMemoryMailboxEventBus(); const heartbeatIntervalMs = opts.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS; if (!Number.isFinite(heartbeatIntervalMs) || heartbeatIntervalMs <= 0) { From c8a8ccb9089e2207dc66e1fa0196f45be61027e7 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 21 Sep 2026 17:04:30 -0700 Subject: [PATCH 11/18] docs: deliver is outbound send, not an in-memory mailbox --- README.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 86c544e..9339b1c 100644 --- a/README.md +++ b/README.md @@ -26,14 +26,12 @@ mountMailbox(api, { }, senderAddressFor: ({ tenantId, principalId }) => `${principalId}@${tenantId}.example`, - deliver: (message) => { - deliveries.push(message); - }, + deliver: (message) => sendRawMail(message), }); app.route("/api", api); ``` -`db`, `app`, `AppEnv`, `deliveries`, and `getSession` are in [`examples/reference-host`](./examples/reference-host). Mail is Postgres. `bus` is optional: default is an in-process **SSE** fan-out (not the store). Pass `bus` only if several hub processes must share live inbox events. +`db` and `app` come from `createApp` / `createDB` in that file. Mail **rows** are Postgres. `deliver` is not storage — the library already wrote Sent. `sendRawMail` is the hub’s outbound transport (SMTP, SES, sidecar `routeMail`). The example host implements it as a test spy so acceptance tests can assert the send happened without opening SMTP. Production must actually send `message.raw`. Inbox paths: `GET/POST /api/me/inbox…`. From 3cdfa29fb88e6516e612335ef79bf206f494238a Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 21 Sep 2026 17:13:26 -0700 Subject: [PATCH 12/18] docs: Quickstart is mount opts, not invented helpers or id splits --- README.md | 45 +++++++++++++++++++++++++-------------------- 1 file changed, 25 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 9339b1c..8c46e44 100644 --- a/README.md +++ b/README.md @@ -9,35 +9,40 @@ Node >= 24 consumes built `dist/`. Bun >= 1.2 runs TypeScript source. Peers: `@i ## Quickstart ```bash -bun add @corbits/mailbox @intx/log @intx/hub-api @intx/db @intx/hub-sessions hono postgres drizzle-orm +bun add @corbits/mailbox @intx/log hono postgres drizzle-orm ``` -**Run the host this package ships:** [`examples/reference-host`](./examples/reference-host). That file calls `createApp` from `@intx/hub-api`, then this: +This is not an app. A hub mounts it. The only **complete** program here is [`examples/reference-host`](./examples/reference-host) (`createApp` + this mount, Postgres, acceptance tests). + +`mountMailbox(app, opts)` — every field is a **host** function except `db`: + +| `opts` | What you pass | +| --- | --- | +| `db` | The hub’s existing drizzle/Postgres handle. Mail is stored there (schema `mailbox`). | +| `resolvePrincipal` | Who this HTTP request is. Return `{ tenantId, principalId }` or `null`. | +| `senderAddressFor` | That person’s From: address as **your directory** stores it (not a string you invent in the mount). | +| `deliver` | After Send has been filed in Postgres, **transmit** `{ raw, from, to, messageId }`. This package does not send SMTP. | +| `bus` | Optional. SSE only. Default is fine for one hub process. | + +**`resolvePrincipal` in a real hub** (Workbench already does this — tenant and principal are already on the request): ```ts -const api = new Hono(); -mountMailbox(api, { - db, // hub.db — Postgres. Mail is not in-memory. - resolvePrincipal: (ctx) => { - const user = (ctx as Context).get("user"); - if (!user) return null; - const [tenantId, principalId] = user.id.split(":"); - return tenantId && principalId ? { tenantId, principalId } : null; - }, - senderAddressFor: ({ tenantId, principalId }) => - `${principalId}@${tenantId}.example`, - deliver: (message) => sendRawMail(message), -}); -app.route("/api", api); +resolvePrincipal: (ctx) => { + const c = ctx as { get(k: "tenant" | "principal"): { id: string } | undefined }; + const tenant = c.get("tenant"); + const principal = c.get("principal"); + if (!tenant || !principal) return null; + return { tenantId: tenant.id, principalId: principal.id }; +}; ``` -`db` and `app` come from `createApp` / `createDB` in that file. Mail **rows** are Postgres. `deliver` is not storage — the library already wrote Sent. `sendRawMail` is the hub’s outbound transport (SMTP, SES, sidecar `routeMail`). The example host implements it as a test spy so acceptance tests can assert the send happened without opening SMTP. Production must actually send `message.raw`. +Do **not** copy `user.id.split(":")`. That is only the reference-host test encoding (`tenantId:principalId` stuffed into one Better Auth user id). Production IDs are two fields on the hub context. -Inbox paths: `GET/POST /api/me/inbox…`. +**`deliver`:** you pass a function **you already have** to send MIME. There is no `sendRawMail` export. The example host keeps an array so tests can assert “send was called” without SMTP. Workbench does not pass `deliver` yet ([CL-8789](https://linear.app/abklabs/issue/CL-8789)). -Agent mail into a person’s inbox is `createMailboxPersist` wrapping the hub’s `persistMail`, passed **into** hub construction — not `lookups.persistMail`. Workbench still mutates lookups and still omits `deliver`: [CL-8789](https://linear.app/abklabs/issue/CL-8789), [CL-8790](https://linear.app/abklabs/issue/CL-8790). +**Agent → person’s inbox** is `createMailboxPersist` wrapping the hub’s `persistMail`, passed in at hub construction ([CL-8790](https://linear.app/abklabs/issue/CL-8790) — don’t assign `lookups.persistMail`). SBA uses the hub; it does not mount this package. -SBA uses the hub; it does not mount this package. +Routes the mount adds: `/me/inbox…` (host usually nests them under `/api`). ## How it works From dff2715c6ef036e1925d006573f9a2739fcaab73 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 21 Sep 2026 18:18:11 -0700 Subject: [PATCH 13/18] =?UTF-8?q?docs:=20fix=20README=20accuracy=20?= =?UTF-8?q?=E2=80=94=20install=20variants,=20migration=20step,=20ARCHITECT?= =?UTF-8?q?URE=20link=20(CL-8775)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8c46e44..e6bbd14 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,9 @@ Node >= 24 consumes built `dist/`. Bun >= 1.2 runs TypeScript source. Peers: `@i ```bash bun add @corbits/mailbox @intx/log hono postgres drizzle-orm +# or: npm install @corbits/mailbox @intx/log hono postgres drizzle-orm +# or: pnpm add @corbits/mailbox @intx/log hono postgres drizzle-orm +# or: yarn add @corbits/mailbox @intx/log hono postgres drizzle-orm ``` This is not an app. A hub mounts it. The only **complete** program here is [`examples/reference-host`](./examples/reference-host) (`createApp` + this mount, Postgres, acceptance tests). @@ -24,6 +27,23 @@ This is not an app. A hub mounts it. The only **complete** program here is [`exa | `deliver` | After Send has been filed in Postgres, **transmit** `{ raw, from, to, messageId }`. This package does not send SMTP. | | `bus` | Optional. SSE only. Default is fine for one hub process. | +Run the migrations once at host boot, before mounting (same order as +[`examples/reference-host/src/index.ts`](./examples/reference-host/src/index.ts)): + +```ts +import { runMailboxMigrations, mountMailbox } from "@corbits/mailbox"; + +await runMailboxMigrations(db); + +mountMailbox(app, { + db, + resolvePrincipal, + senderAddressFor, + deliver, + // bus omitted: the default in-process bus is fine for one hub process. +}); +``` + **`resolvePrincipal` in a real hub** (Workbench already does this — tenant and principal are already on the request): ```ts @@ -48,7 +68,7 @@ Routes the mount adds: `/me/inbox…` (host usually nests them under `/api`). Writes go through a native `MailboxStore` (uid/modseq always set). Search and threads are vendored `@intx/mailbox` over that store. `POST /me/inbox/send` only builds the message and files `Sent` — `deliver` is how it leaves the machine. -See [ARCHITECTURE.md](./ARCHITECTURE.md), [PRODUCT.md](./PRODUCT.md), and [IMPLEMENTATION.md](./IMPLEMENTATION.md) if those files are in the tree. +See [ARCHITECTURE.md](./ARCHITECTURE.md). ## Development From 23de3017afd34cc4afd0253d7b83263f4f72e88a Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 21 Sep 2026 18:50:09 -0700 Subject: [PATCH 14/18] docs: product-voice README, typed mount table (CL-8775) --- README.md | 38 ++++++++++++++++---------------------- 1 file changed, 16 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index e6bbd14..7f37fcc 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # @corbits/mailbox -Give a **person** in an Interchange hub an inbox: list, read, flag, send, SSE. You mount it on a Hono app you already have. Postgres holds the mail. This package ships **no UI**. +Give a **person** in an Interchange hub an inbox: list, read, flag, send, and live updates over SSE. You mount it on a Hono app you already have. Postgres holds the mail. This package ships **no UI**. ## Runtime support @@ -15,20 +15,18 @@ bun add @corbits/mailbox @intx/log hono postgres drizzle-orm # or: yarn add @corbits/mailbox @intx/log hono postgres drizzle-orm ``` -This is not an app. A hub mounts it. The only **complete** program here is [`examples/reference-host`](./examples/reference-host) (`createApp` + this mount, Postgres, acceptance tests). +`mountMailbox(app, opts)` adds the inbox routes to your app. Every field of `opts` is a host responsibility: -`mountMailbox(app, opts)` — every field is a **host** function except `db`: +| `opts` | Type | What the host provides | +| --- | --- | --- | +| `db` | `MailboxDb` | The host's existing drizzle/Postgres handle. Mail is stored there (schema `mailbox`). | +| `resolvePrincipal` | `(ctx: unknown) => ResolvedPrincipal \| null` | Who this HTTP request is. Return `{ tenantId, principalId }` or `null` for anonymous requests. | +| `senderAddressFor` | `(principal: ResolvedPrincipal) => string` | That person's From: address, as resolved from the host's own directory. | +| `deliver` | `(message: OutgoingMailboxMessage) => void` | The host's mail transport. Called once per send with `{ raw, from, to, messageId }` after the message has been filed in Postgres. This package builds MIME and files `Sent`; transmission is the host's job. | +| `bus` | `MailboxEventBus` (optional) | SSE fan-out only. Omit it for a single-process host; the default in-process bus applies. Pass a shared bus when several host processes must fan the same inbox events. | +| `heartbeatIntervalMs` | `number` (optional) | SSE keep-alive period. Defaults to 25s. | -| `opts` | What you pass | -| --- | --- | -| `db` | The hub’s existing drizzle/Postgres handle. Mail is stored there (schema `mailbox`). | -| `resolvePrincipal` | Who this HTTP request is. Return `{ tenantId, principalId }` or `null`. | -| `senderAddressFor` | That person’s From: address as **your directory** stores it (not a string you invent in the mount). | -| `deliver` | After Send has been filed in Postgres, **transmit** `{ raw, from, to, messageId }`. This package does not send SMTP. | -| `bus` | Optional. SSE only. Default is fine for one hub process. | - -Run the migrations once at host boot, before mounting (same order as -[`examples/reference-host/src/index.ts`](./examples/reference-host/src/index.ts)): +Run the migrations once at host boot, before mounting: ```ts import { runMailboxMigrations, mountMailbox } from "@corbits/mailbox"; @@ -40,11 +38,11 @@ mountMailbox(app, { resolvePrincipal, senderAddressFor, deliver, - // bus omitted: the default in-process bus is fine for one hub process. + // bus omitted: the default in-process bus fits a single-process host. }); ``` -**`resolvePrincipal` in a real hub** (Workbench already does this — tenant and principal are already on the request): +`resolvePrincipal` reads whatever identity the host middleware already placed on the request context and maps it to `{ tenantId, principalId }`: ```ts resolvePrincipal: (ctx) => { @@ -56,17 +54,13 @@ resolvePrincipal: (ctx) => { }; ``` -Do **not** copy `user.id.split(":")`. That is only the reference-host test encoding (`tenantId:principalId` stuffed into one Better Auth user id). Production IDs are two fields on the hub context. - -**`deliver`:** you pass a function **you already have** to send MIME. There is no `sendRawMail` export. The example host keeps an array so tests can assert “send was called” without SMTP. Workbench does not pass `deliver` yet ([CL-8789](https://linear.app/abklabs/issue/CL-8789)). - -**Agent → person’s inbox** is `createMailboxPersist` wrapping the hub’s `persistMail`, passed in at hub construction ([CL-8790](https://linear.app/abklabs/issue/CL-8790) — don’t assign `lookups.persistMail`). SBA uses the hub; it does not mount this package. +Agent-originated mail reaches a person's inbox through `createMailboxPersist`, which wraps the host's own persistence function and is passed in at hub construction. -Routes the mount adds: `/me/inbox…` (host usually nests them under `/api`). +Routes the mount adds: `/me/inbox…` (hosts typically nest them under `/api`). ## How it works -Writes go through a native `MailboxStore` (uid/modseq always set). Search and threads are vendored `@intx/mailbox` over that store. `POST /me/inbox/send` only builds the message and files `Sent` — `deliver` is how it leaves the machine. +Writes go through a native `MailboxStore` (uid/modseq always set). Search and threads are vendored `@intx/mailbox` over that store. `POST /me/inbox/send` builds the RFC 5322 message and files a copy in `Sent`, then calls the host's `deliver` exactly once to transmit it. See [ARCHITECTURE.md](./ARCHITECTURE.md). From ee9b39bcba4e5b64a848bdce1236ebb8da3422dc Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 21 Sep 2026 20:40:25 -0700 Subject: [PATCH 15/18] docs(readme): runnable-only Quickstart, no host-declared stubs (CL-8775) --- README.md | 47 +++++++++++++++++++++++++++++------------------ 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 7f37fcc..4f6ebef 100644 --- a/README.md +++ b/README.md @@ -19,41 +19,52 @@ bun add @corbits/mailbox @intx/log hono postgres drizzle-orm | `opts` | Type | What the host provides | | --- | --- | --- | -| `db` | `MailboxDb` | The host's existing drizzle/Postgres handle. Mail is stored there (schema `mailbox`). | +| `db` | `MailboxDb` | Mail lives there (schema `mailbox`). `createMailboxDb` opens a handle; a hub that already has one passes it as `db` instead. | | `resolvePrincipal` | `(ctx: unknown) => ResolvedPrincipal \| null` | Who this HTTP request is. Return `{ tenantId, principalId }` or `null` for anonymous requests. | | `senderAddressFor` | `(principal: ResolvedPrincipal) => string` | That person's From: address, as resolved from the host's own directory. | | `deliver` | `(message: OutgoingMailboxMessage) => void` | The host's mail transport. Called once per send with `{ raw, from, to, messageId }` after the message has been filed in Postgres. This package builds MIME and files `Sent`; transmission is the host's job. | | `bus` | `MailboxEventBus` (optional) | SSE fan-out only. Omit it for a single-process host; the default in-process bus applies. Pass a shared bus when several host processes must fan the same inbox events. | | `heartbeatIntervalMs` | `number` (optional) | SSE keep-alive period. Defaults to 25s. | -Run the migrations once at host boot, before mounting: +The program below is complete: it opens a handle with `createMailboxDb`, runs the migrations, and mounts the inbox on a fresh Hono app. A hub with several processes passes a shared `bus`; otherwise the default in-process bus applies. ```ts -import { runMailboxMigrations, mountMailbox } from "@corbits/mailbox"; +import { Hono } from "hono"; +import { + createMailboxDb, + mountMailbox, + runMailboxMigrations, +} from "@corbits/mailbox"; + +const DATABASE_URL = "postgres://localhost/mailbox"; +const { db } = createMailboxDb(DATABASE_URL); await runMailboxMigrations(db); +const app = new Hono(); mountMailbox(app, { db, - resolvePrincipal, - senderAddressFor, - deliver, - // bus omitted: the default in-process bus fits a single-process host. + resolvePrincipal: (ctx) => { + const c = ctx as { + get(k: "tenant" | "principal"): { id: string } | undefined; + }; + const tenant = c.get("tenant"); + const principal = c.get("principal"); + if (!tenant || !principal) return null; + return { tenantId: tenant.id, principalId: principal.id }; + }, + senderAddressFor: ({ principalId, tenantId }) => + `${principalId}@${tenantId}.example`, + deliver: (message) => { + console.log(`filed ${message.messageId} for ${message.to.join(", ")}`); + }, }); -``` - -`resolvePrincipal` reads whatever identity the host middleware already placed on the request context and maps it to `{ tenantId, principalId }`: -```ts -resolvePrincipal: (ctx) => { - const c = ctx as { get(k: "tenant" | "principal"): { id: string } | undefined }; - const tenant = c.get("tenant"); - const principal = c.get("principal"); - if (!tenant || !principal) return null; - return { tenantId: tenant.id, principalId: principal.id }; -}; +export default app; ``` +The inline `resolvePrincipal` reads whatever identity the host middleware already placed on the request context and maps it to `{ tenantId, principalId }`. `senderAddressFor` answers that person's From: address from the host's own directory; `deliver` transmits what the package already filed in `Sent`. + Agent-originated mail reaches a person's inbox through `createMailboxPersist`, which wraps the host's own persistence function and is passed in at hub construction. Routes the mount adds: `/me/inbox…` (hosts typically nest them under `/api`). From dc1bf66845e15b46d621572c5a9820b2eb89ef51 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 21 Sep 2026 21:06:41 -0700 Subject: [PATCH 16/18] docs(readme): Quickstart shows real host usage (CL-8775) --- README.md | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4f6ebef..a03b3f0 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ The program below is complete: it opens a handle with `createMailboxDb`, runs th ```ts import { Hono } from "hono"; import { + createInMemoryMailboxEventBus, createMailboxDb, mountMailbox, runMailboxMigrations, @@ -41,9 +42,14 @@ const { db } = createMailboxDb(DATABASE_URL); await runMailboxMigrations(db); +// In-process fan-out for a single hub instance; pass a shared bus instead +// once more than one process needs to see the same SSE events. +const bus = createInMemoryMailboxEventBus(); + const app = new Hono(); mountMailbox(app, { db, + bus, resolvePrincipal: (ctx) => { const c = ctx as { get(k: "tenant" | "principal"): { id: string } | undefined; @@ -65,10 +71,52 @@ export default app; The inline `resolvePrincipal` reads whatever identity the host middleware already placed on the request context and maps it to `{ tenantId, principalId }`. `senderAddressFor` answers that person's From: address from the host's own directory; `deliver` transmits what the package already filed in `Sent`. -Agent-originated mail reaches a person's inbox through `createMailboxPersist`, which wraps the host's own persistence function and is passed in at hub construction. - Routes the mount adds: `/me/inbox…` (hosts typically nest them under `/api`). +### Agent-originated mail + +`mountMailbox` covers a person's own inbox. A message that originates elsewhere — an agent replying through the host's own transport — reaches that inbox by wrapping the host's existing persist function with `createMailboxPersist`, once, at host construction: + +```ts +import postgres from "postgres"; +import { + createMailboxPersist, + type AuthorizeMailboxSender, + type MailboxPersistArgs, +} from "@corbits/mailbox"; + +const directory = postgres(DATABASE_URL); + +// Only a sender address the host recognizes gets a mailbox row; anything +// else is skipped rather than filed under a tenant it doesn't belong to. +const authorizeSender: AuthorizeMailboxSender = async (senderAddress) => { + const [row] = await directory<{ tenantId: string; domain: string }[]>` + select tenant_id as "tenantId", domain + from sender_directory + where address = ${senderAddress} + `; + return row ?? null; +}; + +// The host's own transport record for this frame — already written today, +// independent of the mailbox. `createMailboxPersist` calls it unconditionally +// and re-throws whatever it throws, after the mailbox write is attempted. +async function persistToTransportLog(args: MailboxPersistArgs): Promise { + await directory` + insert into transport_log (sender_address, recipients, raw) + values (${args.senderAddress}, ${args.recipients}, ${args.raw}) + `; +} + +const persistMail = createMailboxPersist(db, { + upstream: persistToTransportLog, + authorizeSender, + bus, +}); +``` + +Call the resulting `persistMail` wherever the host currently delegates an outbound frame; it does both writes. + ## How it works Writes go through a native `MailboxStore` (uid/modseq always set). Search and threads are vendored `@intx/mailbox` over that store. `POST /me/inbox/send` builds the RFC 5322 message and files a copy in `Sent`, then calls the host's `deliver` exactly once to transmit it. From c3ab54ba345ebe206c28c09a17d4c1a49ee35b16 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 21 Sep 2026 21:15:05 -0700 Subject: [PATCH 17/18] docs(readme): host-owned mailbox deps as parameters (CL-8775) --- README.md | 122 +++++++++++++++++++++++++++--------------------------- 1 file changed, 60 insertions(+), 62 deletions(-) diff --git a/README.md b/README.md index a03b3f0..1da547d 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ bun add @corbits/mailbox @intx/log hono postgres drizzle-orm | `bus` | `MailboxEventBus` (optional) | SSE fan-out only. Omit it for a single-process host; the default in-process bus applies. Pass a shared bus when several host processes must fan the same inbox events. | | `heartbeatIntervalMs` | `number` (optional) | SSE keep-alive period. Defaults to 25s. | -The program below is complete: it opens a handle with `createMailboxDb`, runs the migrations, and mounts the inbox on a fresh Hono app. A hub with several processes passes a shared `bus`; otherwise the default in-process bus applies. +Wire it as one function your app calls at boot with its own `databaseUrl` and the two things only the host can answer — `senderAddressFor` and `deliver` — as typed parameters, not example bodies: ```ts import { Hono } from "hono"; @@ -34,88 +34,86 @@ import { createInMemoryMailboxEventBus, createMailboxDb, mountMailbox, - runMailboxMigrations, + type MailboxDb, + type MailboxEventBus, + type MountMailboxOpts, } from "@corbits/mailbox"; -const DATABASE_URL = "postgres://localhost/mailbox"; -const { db } = createMailboxDb(DATABASE_URL); - -await runMailboxMigrations(db); - -// In-process fan-out for a single hub instance; pass a shared bus instead -// once more than one process needs to see the same SSE events. -const bus = createInMemoryMailboxEventBus(); - -const app = new Hono(); -mountMailbox(app, { - db, - bus, - resolvePrincipal: (ctx) => { - const c = ctx as { - get(k: "tenant" | "principal"): { id: string } | undefined; - }; - const tenant = c.get("tenant"); - const principal = c.get("principal"); - if (!tenant || !principal) return null; - return { tenantId: tenant.id, principalId: principal.id }; +export function installMailbox( + app: Hono, + opts: { + databaseUrl: string; + resolvePrincipal?: MountMailboxOpts["resolvePrincipal"]; + senderAddressFor: MountMailboxOpts["senderAddressFor"]; + deliver: MountMailboxOpts["deliver"]; }, - senderAddressFor: ({ principalId, tenantId }) => - `${principalId}@${tenantId}.example`, - deliver: (message) => { - console.log(`filed ${message.messageId} for ${message.to.join(", ")}`); - }, -}); - -export default app; +): { db: MailboxDb; bus: MailboxEventBus } { + const { db } = createMailboxDb(opts.databaseUrl); + // In-process fan-out for a single hub instance; pass a shared bus instead + // once more than one process needs to see the same SSE events. + const bus = createInMemoryMailboxEventBus(); + + const mailboxApp = new Hono(); + mountMailbox(mailboxApp, { + db, + bus, + resolvePrincipal: + opts.resolvePrincipal ?? + ((ctx) => { + const c = ctx as { + get(k: "tenant" | "principal"): { id: string } | undefined; + }; + const tenant = c.get("tenant"); + const principal = c.get("principal"); + return tenant && principal + ? { tenantId: tenant.id, principalId: principal.id } + : null; + }), + senderAddressFor: opts.senderAddressFor, + deliver: opts.deliver, + }); + // Mounted under the tenant it belongs to, alongside a host's other + // session-authenticated routes. + app.route("/api/tenants/:tenantId/mailbox", mailboxApp); + + return { db, bus }; +} ``` -The inline `resolvePrincipal` reads whatever identity the host middleware already placed on the request context and maps it to `{ tenantId, principalId }`. `senderAddressFor` answers that person's From: address from the host's own directory; `deliver` transmits what the package already filed in `Sent`. +`resolvePrincipal` defaults to reading whatever identity the host's own auth/tenant middleware already placed on the request context; pass your own to read it differently. `senderAddressFor` is a lookup into the host's own directory — a hub with `principal`/`tenant` tables answers with that person's address, lowercased, at their tenant's mail domain. `deliver` is the host's real mail transport — a hub with a request pipeline of its own hands the built frame back into it (so a message addressed to a running agent reaches it through the same route stack as everything else), rather than putting a byte on a wire itself here. -Routes the mount adds: `/me/inbox…` (hosts typically nest them under `/api`). +Routes the mount adds: `/me/inbox…`. ### Agent-originated mail `mountMailbox` covers a person's own inbox. A message that originates elsewhere — an agent replying through the host's own transport — reaches that inbox by wrapping the host's existing persist function with `createMailboxPersist`, once, at host construction: ```ts -import postgres from "postgres"; import { createMailboxPersist, type AuthorizeMailboxSender, + type MailboxDb, + type MailboxEventBus, type MailboxPersistArgs, } from "@corbits/mailbox"; -const directory = postgres(DATABASE_URL); - -// Only a sender address the host recognizes gets a mailbox row; anything -// else is skipped rather than filed under a tenant it doesn't belong to. -const authorizeSender: AuthorizeMailboxSender = async (senderAddress) => { - const [row] = await directory<{ tenantId: string; domain: string }[]>` - select tenant_id as "tenantId", domain - from sender_directory - where address = ${senderAddress} - `; - return row ?? null; -}; - -// The host's own transport record for this frame — already written today, -// independent of the mailbox. `createMailboxPersist` calls it unconditionally -// and re-throws whatever it throws, after the mailbox write is attempted. -async function persistToTransportLog(args: MailboxPersistArgs): Promise { - await directory` - insert into transport_log (sender_address, recipients, raw) - values (${args.senderAddress}, ${args.recipients}, ${args.raw}) - `; +export function wrapPersistMail( + db: MailboxDb, + bus: MailboxEventBus, + opts: { + upstream: (args: MailboxPersistArgs) => Promise; + authorizeSender: AuthorizeMailboxSender; + }, +): (args: MailboxPersistArgs) => Promise { + return createMailboxPersist(db, { + upstream: opts.upstream, + authorizeSender: opts.authorizeSender, + bus, + }); } - -const persistMail = createMailboxPersist(db, { - upstream: persistToTransportLog, - authorizeSender, - bus, -}); ``` -Call the resulting `persistMail` wherever the host currently delegates an outbound frame; it does both writes. +`authorizeSender` is the host's own check that a sender address is one it recognizes right now — a hub answers by looking up the tenant a mailbox-routable address (a person, or a live agent run) currently resolves to, and refusing anything else. `upstream` is the host's own pre-existing mail-persist path — the write it already made before this package existed; `createMailboxPersist` calls it unconditionally and layers the durable inbox write on top, so a transport failure never costs a recipient the copy that makes the message readable later. Call the wrapped `persistMail` wherever the host currently delegates an outbound frame; it does both writes. ## How it works From c1601d40db988b917d019c080bd1c1b7ec0f2d04 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 21 Sep 2026 22:16:16 -0700 Subject: [PATCH 18/18] docs(readme): keep this PR docs-only; bus stays required (CL-8775) --- README.md | 16 ++++++++-------- src/mount.ts | 11 ++--------- 2 files changed, 10 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 1da547d..8c3224a 100644 --- a/README.md +++ b/README.md @@ -17,14 +17,14 @@ bun add @corbits/mailbox @intx/log hono postgres drizzle-orm `mountMailbox(app, opts)` adds the inbox routes to your app. Every field of `opts` is a host responsibility: -| `opts` | Type | What the host provides | -| --- | --- | --- | -| `db` | `MailboxDb` | Mail lives there (schema `mailbox`). `createMailboxDb` opens a handle; a hub that already has one passes it as `db` instead. | -| `resolvePrincipal` | `(ctx: unknown) => ResolvedPrincipal \| null` | Who this HTTP request is. Return `{ tenantId, principalId }` or `null` for anonymous requests. | -| `senderAddressFor` | `(principal: ResolvedPrincipal) => string` | That person's From: address, as resolved from the host's own directory. | -| `deliver` | `(message: OutgoingMailboxMessage) => void` | The host's mail transport. Called once per send with `{ raw, from, to, messageId }` after the message has been filed in Postgres. This package builds MIME and files `Sent`; transmission is the host's job. | -| `bus` | `MailboxEventBus` (optional) | SSE fan-out only. Omit it for a single-process host; the default in-process bus applies. Pass a shared bus when several host processes must fan the same inbox events. | -| `heartbeatIntervalMs` | `number` (optional) | SSE keep-alive period. Defaults to 25s. | +| `opts` | Type | What the host provides | +| --------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `db` | `MailboxDb` | Mail lives there (schema `mailbox`). `createMailboxDb` opens a handle; a hub that already has one passes it as `db` instead. | +| `resolvePrincipal` | `(ctx: unknown) => ResolvedPrincipal \| null` | Who this HTTP request is. Return `{ tenantId, principalId }` or `null` for anonymous requests. | +| `senderAddressFor` | `(principal: ResolvedPrincipal) => string` | That person's From: address, as resolved from the host's own directory. | +| `deliver` | `(message: OutgoingMailboxMessage) => void` | The host's mail transport. Called once per send with `{ raw, from, to, messageId }` after the message has been filed in Postgres. This package builds MIME and files `Sent`; transmission is the host's job. | +| `bus` | `MailboxEventBus` | SSE fan-out only; mail itself is Postgres. `createInMemoryMailboxEventBus()` for a single-process host; a shared bus when several host processes must fan the same inbox events. | +| `heartbeatIntervalMs` | `number` (optional) | SSE keep-alive period. Defaults to 25s. | Wire it as one function your app calls at boot with its own `databaseUrl` and the two things only the host can answer — `senderAddressFor` and `deliver` — as typed parameters, not example bodies: diff --git a/src/mount.ts b/src/mount.ts index 9bfaaff..4e6a09f 100644 --- a/src/mount.ts +++ b/src/mount.ts @@ -8,7 +8,6 @@ import type { Thread } from "@intx/types/runtime"; import type { MailboxDb } from "./db.js"; import type { NativeMailboxStore } from "./native-store.js"; import { - createInMemoryMailboxEventBus, publishMailboxEvent, type MailboxEvent, type MailboxEventBus, @@ -31,12 +30,7 @@ export type OutgoingMailboxMessage = { export type MountMailboxOpts = { db: MailboxDb; - /** - * SSE live-update bus only. Mail is Postgres. Defaults to an in-process - * bus (one hub process). Pass a shared bus if several hub processes must - * fan the same inbox events. - */ - bus?: MailboxEventBus; + bus: MailboxEventBus; resolvePrincipal: ( ctx: unknown, ) => Promise | ResolvedPrincipal | null; @@ -208,8 +202,7 @@ export function mountMailbox( app: Hono, opts: MountMailboxOpts, ): Hono { - const { db, resolvePrincipal, senderAddressFor, deliver } = opts; - const bus = opts.bus ?? createInMemoryMailboxEventBus(); + const { db, bus, resolvePrincipal, senderAddressFor, deliver } = opts; const heartbeatIntervalMs = opts.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS; if (!Number.isFinite(heartbeatIntervalMs) || heartbeatIntervalMs <= 0) {