Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ jobs:
run: bun run typecheck

- name: unit + integration tests
run: bun run test:coverage
run: bun test src

- name: reference-host acceptance
run: bun test --cwd examples/reference-host
Expand Down
154 changes: 79 additions & 75 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,83 @@
# corbits-mailbox

**[`@corbits/mailbox`](./package.json)** — a universal, principal-keyed inbox,
mountable onto a Hono host backed by an Interchange-shaped Postgres. Its tables
live in a dedicated `mailbox` schema in the host's database, foreign-keyed to
the host's `tenant` and `principal` tables. Backend only; this package ships no
UI.
**[`@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.

See [ARCHITECTURE.md](./ARCHITECTURE.md) for the data model.

## Mount

```ts
import { mountMailbox, createInMemoryMailboxEventBus } from "@corbits/mailbox";

mountMailbox(app, {
db,
bus: createInMemoryMailboxEventBus(),
resolvePrincipal: (ctx) => resolveCallerFromRequest(ctx),
senderAddressFor: (principal) => resolveCallerAddress(principal),
deliver: (message) => hostMailTransport.send(message),
});
```

## 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, `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 }`. |

`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.

```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) });
```

## Dual-write persist

```ts
Expand All @@ -18,21 +86,14 @@ import { createMailboxPersist } from "@corbits/mailbox";
const persist = createMailboxPersist(db, {
upstream: hostMailTransport.persist,
authorizeSender: (address) => resolveActiveInstance(address),
// Called once per frame, before the transaction — every recipient row
// gets the same refs, so a bus subscriber sees them on the `create` event.
resolveRefs: ({ decoded }) =>
decoded ? [{ kind: "workbench", id: decoded.messageId ?? "" }] : undefined,
bus,
});
```

`resolveRefs` runs after `upstream` resolves and serially with it, and its
refs are frozen at the frame's first successful insert — a retry still runs
the resolver but a different result on that later call is discarded. Return
a small set with the load-bearing ref first: the list is capped at
`MAX_MAILBOX_REFS` by truncating from the end.

See ARCHITECTURE.md's persist section for the full contract, including
`resolveRefs`'s dual-write-failure semantics.
`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.

## Install

Expand All @@ -48,66 +109,9 @@ bun add github:corbitsdev/corbits-mailbox

| | |
| --- | --- |
| `src/` | The published package. Owns `principal_mail` (the message, immutable) and `mailbox` (the management layer, created eagerly with each message). |
| `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. |

## Write paths

`src/write.ts` exports two batch write functions, each for a different shape
of caller:

- **`deliverInboxItems`** — the notify-item path. One external item (an
ingress adapter: a mail connector, a webhook), fanned out to every
addressed principal, deduped on `mailboxKey.inbox(source, externalId)`.
- **`writeMailboxMessages`** — the conversation path. An arbitrary batch of
`{ scope, args }` pairs — for example a sender's own outbound copy
alongside every recipient's inbound copy of the same turn — committed in
one transaction with per-row dedupe on the `messageKey` unique index.

Both commit every new row in the call as a single transaction (or none), and
publish bus events only after commit, one per row actually written. See
[ARCHITECTURE.md](./ARCHITECTURE.md) for the full write-path writeup,
including `writeMailboxMessage`'s caller-supplied `messageId`, `direction`,
and default `messageKey`.

## Thread reads

```ts
import {
readMailboxThread,
readMailboxMessageByMessageId,
} from "@corbits/mailbox";

// The conversation under one entity ref, oldest first, keyset-paged.
const page = await readMailboxThread(
db,
{ tenantId, principalId },
{ ref: { kind: "workbench", id: "wb-1" }, limit: 50 },
);
// page.items: { id, messageId, inReplyTo?, references, fromAddress, subject?,
// createdAt, read, archived, parentId, body }
// page.nextCursor: pass back as `cursor` for the next page.

// One message by its Message-ID, scoped to this mailbox.
const message = await readMailboxMessageByMessageId(
db,
{ tenantId, principalId },
"<child@acme.example>",
);
```

`body` is the message's full text, decoded from the stored MIME frame in the
same scan — the same body `getMailboxMessage`'s detail read returns for a
single message. A frame the MIME parser rejects degrades to `""` (logged),
never a failed read.

`parentId` is resolved by RFC 5256 References linking — `In-Reply-To` first,
then the `References` chain newest-to-oldest — across the whole ref-scoped set,
not just the current page. It is `null`, never fabricated, when the nearest
ancestor is not in this mailbox under this ref. Subjects are never used to
group. A cursor is bound to the ref that minted it; paging it into a different
ref is a `RangeError`, as is a malformed cursor or an out-of-range limit.

## Working on it

```sh
Expand Down
49 changes: 49 additions & 0 deletions VENDORED.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Vendored code

`@corbits/mailbox` consumes Interchange as published packages wherever a
publish covers the needed capability. `@intx/mailbox` has never been
published, so it is hand-copied here as a sanctioned escape hatch — never a
submodule, never touched upstream. Its two compile-time dependencies that
are published only at an older API surface (`@intx/mime`, `@intx/types`)
are vendored alongside it at the same commit so the tree never mixes pins;
`@intx/crypto`, byte-identical between npm `0.3.0` and this commit, is
consumed as an ordinary npm dependency instead.

## Rules

- Vendoring is hand-copied files only — never a git submodule.
- Every vendored path has exactly one row below, with a kill date and a
dated test that fails after it.
- The upstream repository is never modified, committed to, or pushed to.
- Retiring a vendored copy closes the entry: delete the row and the files
together.

## Ledger

| Path | Contents | Upstream | Local delta | Kill condition |
| --- | --- | --- | --- | --- |
| `vendor/intx-mailbox` | `@intx/mailbox` source (`src/`, `package.json`, `LICENSE`) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `692c3106` (origin/main, 2026-09-03), copied from Workbench's own `vendor/intx/mailbox` pin | Package-manager pins only: `catalog:` ranges resolved to this repo's fixed versions (`arktype` 2.1.29, `typescript` 5.7.2, `@types/bun` 1.1.14); dependency versions repointed to `workspace:*` for `@intx/mime`/`@intx/types`. No source delta. | First npm publish of `@intx/mailbox` |
| `vendor/intx-mime` | `@intx/mime` source at the `@intx/mailbox` pin | same commit as above | Same pin/catalog cleanup as above. No source delta. Needed because npm `0.3.0` predates `buildMessageHeaders` and other exports `@intx/mailbox` at this pin imports. | Retired when `@intx/mailbox` publishes against a released `@intx/mime` that carries these exports |
| `vendor/intx-types` | `@intx/types` source at the `@intx/mailbox` pin | same commit as above | Same pin/catalog cleanup as above. No source delta. Needed because npm `0.3.0` predates the runtime types (`InterchangeType`, `Thread`, `SearchQuery`, `base64Decode`) `@intx/mailbox`/`@intx/mime` at this pin import. | Retired when `@intx/mailbox` publishes against a released `@intx/types` that carries these exports |

## Upstream ask

Publishing `@intx/mailbox` (and refreshing the `@intx/mime`/`@intx/types`
npm releases to the pin it needs) retires all three rows in one move.

## Published artifact

None of the three vendored packages appear in the published manifest's
`dependencies`/`peerDependencies` — there is nothing on npm for a consumer
to install against. Instead `scripts/build.mjs` (invoked by `bun run
build`) bundles `vendor/intx-mailbox`, `vendor/intx-mime`, and
`vendor/intx-types` straight into `dist/index.js`, and compiles their
declarations separately into `dist/vendor/<name>/`, rewriting the bare
`@intx/mailbox`/`@intx/mime`/`@intx/types` specifiers in every emitted
`.d.ts` to relative paths into that directory. This keeps the tarball
self-contained for both `node --experimental-...`-free runtime use and a
consumer's own `tsc`. It is a build-time workaround, not a vendoring
delta: once `@intx/mailbox` (and the `@intx/mime`/`@intx/types` pins it
needs) are published, the three packages move back to ordinary
`dependencies`/`peerDependencies`, `scripts/build.mjs` goes back to a
plain `tsc` invocation, and this section is deleted.
Loading
Loading