Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 16 additions & 9 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ 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.
- `runMailboxMigrations(config, { schema })` — once at boot, before serving,
with the same arguments the host passes Interchange's `runMigrations`.
- `createMailboxRoutes(deps)` — returns a `Hono<TenantEnv>` sub-app serving
`/me/inbox*`, which the host mounts with `app.route`.

Expand Down Expand Up @@ -48,9 +49,9 @@ is reached for.

What it does **not** require: 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 —
Interchange-shaped control plane: `tenant` and `principal` in the host schema
of 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 `createMailboxRoutes`, on the write side:
Expand Down Expand Up @@ -171,7 +172,7 @@ 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
tables reference the host schema's `tenant` and `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
Expand Down Expand Up @@ -431,8 +432,14 @@ two would query columns or rely on indexes the migrations never created.

## Migrations

`runMailboxMigrations(db)` is idempotent and safe to call unconditionally on
every boot of every replica.
`runMailboxMigrations(config, { schema })` is idempotent and safe to call
unconditionally on every boot of every replica. It opens one connection from
`config` and closes it when done. `schema` names the host schema holding
`tenant` and `principal`; the FKs are pointed there at execution time, while
ledger checksums hash the statements as shipped, so the same migration has the
same checksum whatever the host schema. The FKs are fixed by the run that
first applies each migration; passing a different `schema` later does not move
them.

- The whole run is one transaction whose first statements are
`SET LOCAL client_min_messages = warning` and a **transaction-scoped**
Expand Down Expand Up @@ -474,8 +481,8 @@ every boot of every replica.
**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.
the control plane's: the DDL's foreign keys reference the host schema's
`tenant` and `principal`, so those tables must exist before the first run.

## Boundaries

Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,12 @@ always called out under their own heading.

### Breaking

- **`runMailboxMigrations(config, { schema })` replaces `runMailboxMigrations(db)`.**
It takes the same `DBConfig` (`@intx/db`, now a peer) and `schema` the host
passes Interchange's `runMigrations`. `schema` is the host schema holding
`tenant` and `principal`; the mailbox FKs point there. The mailbox tables
stay in the `mailbox` schema. `createMailboxDb` is no longer exported; hosts
pass the handle they already have (e.g. `@intx/db`'s `createDB`).
- **`mountMailbox` is replaced by `createMailboxRoutes(deps): Hono<TenantEnv>`.**
The host mounts the returned sub-app with `app.route`. `deps.requireGrant`
(`@intx/hub-api`'s `RequireGrant`, now a peer) gates reads on `mailbox:*`
Expand Down
50 changes: 25 additions & 25 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,24 @@ Give a **person** in an Interchange hub an inbox: list, read, flag, send, and li

## Runtime support

Node >= 24 consumes built `dist/`. Bun >= 1.2 runs TypeScript source. Peers: `@intx/hub-api`, `@intx/log`, `@intx/mailbox`, `@intx/mime`, `@intx/types`, `drizzle-orm`, `hono`, `postgres`.
Node >= 24 consumes built `dist/`. Bun >= 1.2 runs TypeScript source. Peers: `@intx/db`, `@intx/hub-api`, `@intx/log`, `@intx/mailbox`, `@intx/mime`, `@intx/types`, `drizzle-orm`, `hono`, `postgres`.

## Quickstart

```bash
npm add @corbits/mailbox @intx/hub-api @intx/log @intx/mailbox @intx/mime @intx/types drizzle-orm hono postgres
npm add @corbits/mailbox @intx/db @intx/hub-api @intx/log @intx/mailbox @intx/mime @intx/types drizzle-orm hono postgres
```

```ts
import { createDB } from "@intx/db";
import {
createInMemoryMailboxEventBus,
createMailboxDb,
createMailboxRoutes,
runMailboxMigrations,
} from "@corbits/mailbox";

const { db, close } = createMailboxDb(databaseUrl);
await runMailboxMigrations(db);
await runMailboxMigrations(dbConfig, { schema: "public" });
const { db, close } = createDB(dbConfig);

app.route(
"/api/tenants/:tenantId/mailbox",
Expand All @@ -37,33 +37,33 @@ app.route(
process.once("SIGTERM", close);
```

`app` is the host's `Hono<TenantEnv>` behind its tenant middleware, `requireGrant` comes from `@intx/hub-api`'s `createRequireGrant`, and `addressOf` and `transport` are the host's own directory and mail transport. `runMailboxMigrations` creates the `mailbox` schema; it needs the host's `tenant` and `principal` tables to exist first.
`app` is the host's `Hono<TenantEnv>` behind its tenant middleware, `requireGrant` comes from `@intx/hub-api`'s `createRequireGrant`, and `addressOf` and `transport` are the host's own directory and mail transport. `dbConfig` is the same config and `schema` the host passes Interchange's `runMigrations`: the schema holding its `tenant` and `principal` tables, which must exist first. The mailbox's own tables always live in the `mailbox` schema.

| `deps` | Type | What the host provides |
| --------------------- | ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `db` | `MailboxDb` | Mail lives there (schema `mailbox`). `createMailboxDb` opens a handle; a hub that already has one passes it instead. |
| `bus` | `MailboxEventBus` | SSE fan-out only; mail itself is Postgres. `createInMemoryMailboxEventBus()` for a single process; a shared bus when several processes must fan the same inbox events. |
| `requireGrant` | `RequireGrant` | Gates reads on `mailbox:*` `read`, send on `create`, and the flag and move verbs on `manage`. |
| `senderAddressFor` | `(principal: ResolvedPrincipal) => string` | That person's From: address, 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 is filed in `Sent`. This package builds MIME; transmission is the host's job. |
| `heartbeatIntervalMs` | `number` (optional) | SSE keep-alive period. Defaults to 25s. |
| `deps` | Type | What the host provides |
| --------------------- | ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `db` | `MailboxDb` | Mail lives there (schema `mailbox`). A hub passes the handle it already has. |
| `bus` | `MailboxEventBus` | SSE fan-out only; mail itself is Postgres. `createInMemoryMailboxEventBus()` for a single process; a shared bus when several processes must fan the same inbox events. |
| `requireGrant` | `RequireGrant` | Gates reads on `mailbox:*` `read`, send on `create`, and the flag and move verbs on `manage`. |
| `senderAddressFor` | `(principal: ResolvedPrincipal) => string` | That person's From: address, 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 is filed in `Sent`. This package builds MIME; transmission is the host's job. |
| `heartbeatIntervalMs` | `number` (optional) | SSE keep-alive period. Defaults to 25s. |

### Routes

Paths are relative to where the host mounts the sub-app. Every route reads or writes only the caller's own mailbox.

| Method | Path | Purpose |
| ------ | ---------------------------- | ------------------------------------------------------------------------------------------- |
| Method | Path | Purpose |
| ------ | ---------------------------- | ---------------------------------------------------------------------------------------------------- |
| GET | `/me/inbox` | List a folder newest first. `?folder=` INBOX (default), Sent, Archive, Trash; `?limit=`, `?cursor=`. |
| GET | `/me/inbox/threads` | A folder as threads (REFERENCES algorithm). `?folder=` as above. |
| GET | `/me/inbox/threads/:rootUid` | One thread, rooted at `rootUid`. `?folder=` as above. |
| POST | `/me/inbox/send` | Build a message from `{ to, subject?, body, inReplyTo? }`, file it in `Sent`, call `deliver`. |
| GET | `/me/inbox/events` | Server-sent `mailbox` events for the caller, with a heartbeat. |
| POST | `/me/inbox/:uid/read` | Set `\Seen` on a message in `?folder=` (INBOX by default). |
| POST | `/me/inbox/:uid/unread` | Clear `\Seen` on a message in `?folder=` (INBOX by default). |
| POST | `/me/inbox/:uid/archive` | Move from INBOX to Archive. |
| POST | `/me/inbox/:uid/trash` | Move from INBOX to Trash. |
| POST | `/me/inbox/:uid/restore` | Move back to INBOX from `?folder=` (Archive by default). |
| GET | `/me/inbox/threads` | A folder as threads (REFERENCES algorithm). `?folder=` as above. |
| GET | `/me/inbox/threads/:rootUid` | One thread, rooted at `rootUid`. `?folder=` as above. |
| POST | `/me/inbox/send` | Build a message from `{ to, subject?, body, inReplyTo? }`, file it in `Sent`, call `deliver`. |
| GET | `/me/inbox/events` | Server-sent `mailbox` events for the caller, with a heartbeat. |
| POST | `/me/inbox/:uid/read` | Set `\Seen` on a message in `?folder=` (INBOX by default). |
| POST | `/me/inbox/:uid/unread` | Clear `\Seen` on a message in `?folder=` (INBOX by default). |
| POST | `/me/inbox/:uid/archive` | Move from INBOX to Archive. |
| POST | `/me/inbox/:uid/trash` | Move from INBOX to Trash. |
| POST | `/me/inbox/:uid/restore` | Move back to INBOX from `?folder=` (Archive by default). |

### Agent-originated mail

Expand Down
2 changes: 2 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
"hono-openapi": "1.3.1"
},
"peerDependencies": {
"@intx/db": "^0.4.0",
"@intx/hub-api": "^0.4.0",
"@intx/log": "^0.4.0",
"@intx/mailbox": "^0.4.0",
Expand All @@ -71,6 +72,7 @@
"postgres": "^3.4.0"
},
"devDependencies": {
"@intx/db": "0.4.0",
"@intx/hub-api": "0.4.0",
"@intx/log": "0.4.0",
"@intx/mailbox": "0.4.0",
Expand Down
15 changes: 1 addition & 14 deletions src/db.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { drizzle, type PostgresJsDatabase } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";

/**
* The db handle this package expects a host to hand in — the drizzle instance
Expand All @@ -13,15 +12,3 @@ import postgres from "postgres";
// handle bound to its own (e.g. `createDB`'s). Nothing here reads `db.query`.
export type MailboxDb = PostgresJsDatabase<any>;

/**
* Opens a standalone handle, for hosts and scripts that don't already have one.
* `close` drains the pool — without it a migrate-only script keeps an open
* socket and never exits.
*/
export function createMailboxDb(connectionString: string): {
db: MailboxDb;
close: () => Promise<void>;
} {
const client = postgres(connectionString);
return { db: drizzle(client), close: () => client.end() };
}
1 change: 0 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ export { runMailboxMigrations, MigrationChecksumError } from "./migrations.js";

export { SchemaTypeMismatchError } from "./schema-check.js";

export { createMailboxDb } from "./db.js";
export type { MailboxDb } from "./db.js";

// The native `MailboxStore` over `mailbox.principal_mail` /
Expand Down
Loading
Loading