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
59 changes: 56 additions & 3 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,55 @@ optional host `enqueue` hook run only after commit, and only for newly inserted
ids. Both side effects are best-effort: a throw is logged with the message id
and never rejects the delivery.

**Two write paths, two shapes of batch.** `deliverInboxItems` is the
**notify-item path**: one external item, fanned out to every addressed
principal, keyed by `mailboxKey.inbox(source, externalId)` — unchanged by the
addition below. `writeMailboxMessages(db, items, opts?)` is the
**conversation path**: an arbitrary batch of `{ scope, args }` pairs — a
sender's own outbound copy alongside every recipient's inbound copy of the
same turn, mixed tenants and principals allowed — committed in the same
single-transaction-or-none shape, with per-row `onConflictDoNothing` dedupe on
the same `messageKey` partial unique index and bus events published only
after commit, one per row this call actually inserted. A throw from any one
item (an invalid scope, an oversize frame, a control-plane FK the item's
scope does not satisfy) rolls back every row the batch would otherwise have
written, including ones already inserted earlier in the same call — same
atomicity guarantee as `deliverInboxItems`, over a caller-shaped item instead
of an ingress-shaped one. Each item's `args` is
`Omit<WriteMailboxMessageArgs, "tenantId" | "principalId">` — `scope` is the
sole source of both, so there is no second copy of the scope an item could
disagree with. `writeMailboxMessages` returns one `{ messageKey, id }` entry
per item, in item order — matching `deliverInboxItems`'s `DeliveredInboxItem`
shape — with `id: null` exactly for an item whose messageKey deduped against
an existing row, rather than a filtered array of inserted ids.

**A write's Message-ID, direction, and dedupe key are now the caller's to
set.** `WriteMailboxMessageArgs.messageId` lets a caller hand the write path
the exact msg-id its own frame must carry (validated as a bracketed msg-id;
`RangeError` otherwise) instead of always minting one — needed when a
message's id has to be predictable ahead of the write, e.g. so a later
`inReplyTo` can reference it. `direction` (default `"inbound"`) is a stored
fact: an outbound row is the sender's own durable copy, and is created
already-read — its `mailbox.read_at` is pinned to its own `created_at` at
insert — so it is excluded from the unread count and the unread view without
either needing a direction predicate of its own. `listUserMailbox` and
`getMailboxMessage` accept an optional `direction?: "inbound" | "outbound" |
"all"` (default `"inbound"`, preserving today's contract) so a thread reader
can fetch a principal's own sent copies or both directions together — see
Known limits. And `messageKey`, when the caller omits it, now defaults to
`mailboxKey.transport(messageId, principalId, direction)`: for the default
`"inbound"` direction this is `transport:mid:<Message-ID>:<principalId>` —
the same shape `persist.ts`'s transport dual-write already uses, byte for
byte, so a frame `persist.ts` already delivered and a direct inbound write
for the same Message-ID + principal still dedupe onto the same row — while
`"outbound"` gets a `:outbound` suffix, so a sender's own copy of a turn
never collapses onto an inbound copy that reuses the identical
caller-supplied `messageId` for the same principal. A retry that reuses the
same caller-supplied `messageId` (and direction) therefore dedupes for free
— the write returns `null` — while two writes that each mint their own
`messageId`, or that differ in direction, never collide. A caller-supplied
`messageKey` still overrides the default, exactly as before.

**Bus publish isolates listeners.** `publishMailboxEvent` invokes each
subscriber independently; one throwing listener does not stop the others. SSE
connections serialize writes, bound the pending queue, and close on overflow or
Expand Down Expand Up @@ -403,9 +452,13 @@ actual mail transport — this package neither sends nor receives SMTP.
documented in the package README.
- **`sort=priority` pays a cross-table join** on top of a rank that was never
index-servable; see the measurements above.
- **List routes read inbound rows.** The `direction` column admits outbound
rows and the write path can create them, but the inbox views are
inbound-only; there is no "sent" view and no send route.
- **List routes default to inbound rows.** The `direction` column admits
outbound rows and the write path can create them; `listUserMailbox` and
`getMailboxMessage` default to `"inbound"` (preserving the mounted route
table's existing behavior) but accept `direction: "outbound" | "all"` for
a caller — a thread reader, not yet a mounted route — that needs a
principal's own sent copies. There is still no "sent" view or send route
on the mounted API itself.
- **No search.** Filtering is by view, priority, classification, status and
assignee. There is no full-text index over subjects or bodies.
- **Reordering the host's `priorities` invalidates in-flight priority
Expand Down
55 changes: 55 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,28 @@ always called out under their own heading.
already carries refs. A throwing `resolveRefs` follows the existing
dual-write contract: logged, upstream unaffected, no mailbox row for that
frame.
- New `writeMailboxMessages(db, items, opts?)` writes an arbitrary batch
of `{ scope, args }` pairs — e.g. a sender's outbound copy alongside
every recipient's inbound copy of the same conversation turn — in ONE
transaction: every row is scope-checked, field-checked, encoded, and
frame-asserted before the transaction opens, all new rows commit
together or none do, and a throw from any single item (an invalid
scope, an oversize frame, an unknown control-plane principal) rolls
back the whole batch. Per-row dedupe still runs through
`onConflictDoNothing` on the existing `messageKey` partial unique
index, so a retried batch dedupes row-by-row without failing. Each
item's `args` omits `tenantId`/`principalId` (`Omit<WriteMailboxMessageArgs,
"tenantId" | "principalId">`) — `scope` is the sole source of both, so
there is no second copy of the scope that could disagree with it. Bus
events publish only after commit, one per written row. This is the
**conversation path**; `deliverInboxItems` remains the **notify-item
path** for ingress adapters and is unchanged.
- `listUserMailbox` and `getMailboxMessage` accept an optional
`direction?: "inbound" | "outbound" | "all"` (default `"inbound"`), so a
thread reader can fetch a principal's own sent copies (`"outbound"`) or
both directions together (`"all"`) alongside the existing inbox-only
default.

- **Threading headers on the frame and in the list projection.**
`buildMailFrame` accepts `references` — the thread's ancestry, oldest
first — and emits it as a folded `References:` header; `In-Reply-To`
Expand Down Expand Up @@ -79,6 +101,39 @@ always called out under their own heading.

### Changed

- **Caller-supplied Message-ID, direction, and message key on writes.**
`WriteMailboxMessageArgs` gains `messageId?: string` — when supplied it
must be a bracketed msg-id (validated with `assertMsgId`) and becomes
the built frame's own `Message-ID:` header and the cached
`principal_mail.message_id`; omitted, one is still minted exactly as
before. It also gains `direction?: "inbound" | "outbound"` (default
`"inbound"`). Omitting `messageKey` no longer leaves the row unkeyed: it
now defaults to `mailboxKey.transport(messageId, principalId, direction)`.
For the default `"inbound"` direction this is
`transport:mid:<Message-ID>:<principalId>` — the exact shape
`persist.ts`'s transport dual-write already uses, byte for byte — so a
frame `persist.ts` already delivered and a direct inbound write for the
same Message-ID + principal dedupe onto the same row, as before, and
retrying a write with the same caller-supplied `messageId` dedupes
without the caller minting its own key. `"outbound"` gets a
`:outbound` suffix instead, so a sender's own copy of a turn never
collides with an inbound copy that reuses the identical caller-supplied
`messageId` for the same principal — two independent (differently
keyed) writes still never collide either way. A caller-supplied
`messageKey` still overrides the default. A *colliding* caller
`messageId` (same effective key) is a no-op: the write returns `null`
rather than a second row.
An outbound row is also created already-read — its `mailbox.read_at` is
pinned to its own `created_at` at insert — so it never counts toward
`countUnreadActiveMailbox` or appears in the unread view without either
needing a direction predicate of its own; `listUserMailbox` and
`getMailboxMessage` still default to `"inbound"` only, unaffected by
this.
`writeMailboxMessages` returns `Array<{ messageKey: string; id: string |
null }>`, one entry per item, in item order — matching
`deliverInboxItems`'s `DeliveredInboxItem` shape — rather than a
filtered array of inserted ids; `id` is `null` exactly when that item's
messageKey deduped against an existing row.
- **Inbox list no longer loads or decodes full MIME frames.** List selects every
`principal_mail` column except `raw`, and projects `subject` / `from` from the
denormalized caches only — no list `snippet`, and list `date` / `messageId` /
Expand Down
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,25 @@ 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). |
| `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`.

## Working on it

```sh
Expand Down
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export type {

export {
writeMailboxMessage,
writeMailboxMessages,
deliverInboxItems,
mailboxKey,
MAX_MAILBOX_REFS,
Expand All @@ -77,6 +78,8 @@ export {
} from "./write.js";
export type {
WriteMailboxMessageArgs,
WriteMailboxMessagesItem,
WriteMailboxMessagesOpts,
InboxItem,
DeliverInboxItemsOpts,
DeliveredInboxItem,
Expand Down
16 changes: 14 additions & 2 deletions src/read.ts
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,14 @@ export type MailboxScope = {
priorities: readonly string[];
/** Host seam for turning sender addresses into human labels; see `SenderDisplayResolver`. */
resolveSenderDisplays?: SenderDisplayResolver;
/**
* Which direction of mail to serve. Defaults to `"inbound"` — the
* long-standing contract, since the inbox has only ever shown delivered
* mail. `"outbound"` reads a principal's own sent copies; `"all"` returns
* both, e.g. for a thread reader that needs a sender's copy alongside its
* recipients' copies.
*/
direction?: "inbound" | "outbound" | "all";
};

export type MailboxPage = {
Expand All @@ -541,10 +549,11 @@ export async function listUserMailbox(
const sort: MailboxSort = scope.sort ?? "date";
const filter: MailboxFilter = scope.filter ?? {};
const PRIORITY_RANK = priorityRank(scope.priorities);
const direction = scope.direction ?? "inbound";
const conditions = [
eq(principalMail.tenantId, scope.tenantId),
eq(principalMail.principalId, scope.principalId),
eq(principalMail.direction, "inbound"),
...(direction === "all" ? [] : [eq(principalMail.direction, direction)]),
...viewConditions(scope.view),
...filterConditions(filter),
];
Expand Down Expand Up @@ -651,8 +660,11 @@ export async function getMailboxMessage(
principalId: string;
id: string;
resolveSenderDisplays?: SenderDisplayResolver;
/** Defaults to `"inbound"`; see `MailboxScope.direction`. */
direction?: "inbound" | "outbound" | "all";
},
): Promise<MailboxMessageDetail | null> {
const direction = args.direction ?? "inbound";
const [row] = await db
.select({ ...getTableColumns(principalMail), ...STATE_COLUMNS })
.from(principalMail)
Expand All @@ -662,7 +674,7 @@ export async function getMailboxMessage(
eq(principalMail.id, args.id),
eq(principalMail.tenantId, args.tenantId),
eq(principalMail.principalId, args.principalId),
eq(principalMail.direction, "inbound"),
...(direction === "all" ? [] : [eq(principalMail.direction, direction)]),
),
)
.limit(1);
Expand Down
Loading
Loading