From 65b36e71a14ec5d0405210a4ebefeab047db4a3a Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 11:29:02 -0700 Subject: [PATCH 1/2] Add tests for mailbox thread client (CL-8174) --- packages/inbox/src/thread-client.test.ts | 99 ++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 packages/inbox/src/thread-client.test.ts diff --git a/packages/inbox/src/thread-client.test.ts b/packages/inbox/src/thread-client.test.ts new file mode 100644 index 0000000000..82bd1bad43 --- /dev/null +++ b/packages/inbox/src/thread-client.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, test } from "bun:test"; + +import { + MailboxThreadFetchError, + listMailboxThreadsClient, + readMailboxThreadClient, +} from "./thread-client"; + +function fetchStub(body: unknown, status = 200): typeof fetch { + return (async () => { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + } as Response; + }) as unknown as typeof fetch; +} + +describe("listMailboxThreadsClient", () => { + test("parses a page of thread summaries", async () => { + const page = await listMailboxThreadsClient( + "/api/tenants/t1/mailbox", + { refs: [{ kind: "workbench", id: "w1" }], limit: 10 }, + fetchStub({ + threads: [ + { + rootId: "m1", + rootMessageId: "m1@mailbox", + messageCount: 2, + unreadCount: 1, + lastMessageId: "m2@mailbox", + lastFromAddress: "routine:x", + lastCreatedAt: "2026-01-01T00:00:00.000000Z", + }, + ], + nextCursor: "cur1", + }), + ); + expect(page.threads).toHaveLength(1); + expect(page.nextCursor).toBe("cur1"); + }); + + test("throws MailboxThreadFetchError on a malformed body", async () => { + await expect( + listMailboxThreadsClient( + "/api/tenants/t1/mailbox", + {}, + fetchStub({ threads: "not-an-array" }), + ), + ).rejects.toBeInstanceOf(MailboxThreadFetchError); + }); + + test("throws MailboxThreadFetchError on a non-ok response", async () => { + await expect( + listMailboxThreadsClient( + "/api/tenants/t1/mailbox", + {}, + fetchStub({ error: "bad refs" }, 400), + ), + ).rejects.toBeInstanceOf(MailboxThreadFetchError); + }); +}); + +describe("readMailboxThreadClient", () => { + test("parses a thread's messages oldest-first", async () => { + const page = await readMailboxThreadClient( + "/api/tenants/t1/mailbox", + "root@mailbox", + {}, + fetchStub({ + messages: [ + { + id: "m1", + messageId: "m1@mailbox", + references: [], + fromAddress: "routine:x", + createdAt: "2026-01-01T00:00:00.000000Z", + read: true, + archived: false, + parentId: null, + }, + ], + }), + ); + expect(page.messages).toHaveLength(1); + expect(page.nextCursor).toBeUndefined(); + }); + + test("404 surfaces as MailboxThreadFetchError", async () => { + await expect( + readMailboxThreadClient( + "/api/tenants/t1/mailbox", + "unknown@mailbox", + {}, + fetchStub({ error: "Thread not found" }, 404), + ), + ).rejects.toBeInstanceOf(MailboxThreadFetchError); + }); +}); From 3555072acb95a9ca5d5d23f91eabbf53f802bbff Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 11:29:02 -0700 Subject: [PATCH 2/2] feat(inbox): pin @corbits/mailbox thread routes and expose a typed client (CL-8174) Pin @corbits/mailbox to fea6211e42fd, which adds GET /me/threads (paged thread summaries, optional refs filter) and GET /me/threads/:rootMessageId (oldest-first messages). Add a browser-safe fetch client in packages/inbox for both routes, reusing the library's own wire schemas and types so this checkout and the pinned mailbox version cannot drift. --- apps/hub/package.json | 2 +- bun.lock | 20 ++--- packages/chat/package.json | 2 +- packages/inbox/package.json | 2 +- packages/inbox/src/client.ts | 30 ++++--- packages/inbox/src/thread-client.ts | 119 ++++++++++++++++++++++++++++ 6 files changed, 148 insertions(+), 27 deletions(-) create mode 100644 packages/inbox/src/thread-client.ts diff --git a/apps/hub/package.json b/apps/hub/package.json index 9257b6c754..b93e14cb12 100644 --- a/apps/hub/package.json +++ b/apps/hub/package.json @@ -40,7 +40,7 @@ "@corbits/inference-catalog": "workspace:*", "@corbits/insights": "workspace:*", "@corbits/last-30-days-research-workflow": "workspace:*", - "@corbits/mailbox": "github:corbitsdev/corbits-mailbox#118a2cd3b0b2d83abcd57b4bed1698e95ee41b6d", + "@corbits/mailbox": "github:corbitsdev/corbits-mailbox#fea6211e42fddfc87e8e1755c219c437e45a7d62", "@corbits/mcp-tools": "workspace:*", "@corbits/memory": "github:corbitsdev/corbits-memory#9e6f213fa2c002b531d3f6af1aa0abd737b8afe3", "@corbits/memory-hub": "workspace:*", diff --git a/bun.lock b/bun.lock index 9f61fade0e..c1c9ef7696 100644 --- a/bun.lock +++ b/bun.lock @@ -65,7 +65,7 @@ "@corbits/inference-catalog": "workspace:*", "@corbits/insights": "workspace:*", "@corbits/last-30-days-research-workflow": "workspace:*", - "@corbits/mailbox": "github:corbitsdev/corbits-mailbox#118a2cd3b0b2d83abcd57b4bed1698e95ee41b6d", + "@corbits/mailbox": "github:corbitsdev/corbits-mailbox#fea6211e42fddfc87e8e1755c219c437e45a7d62", "@corbits/mcp-tools": "workspace:*", "@corbits/memory": "github:corbitsdev/corbits-memory#9e6f213fa2c002b531d3f6af1aa0abd737b8afe3", "@corbits/memory-hub": "workspace:*", @@ -446,7 +446,7 @@ "@corbits/connections": "workspace:*", "@corbits/error-sink": "workspace:*", "@corbits/hub-api-client": "workspace:*", - "@corbits/mailbox": "github:corbitsdev/corbits-mailbox#118a2cd3b0b2d83abcd57b4bed1698e95ee41b6d", + "@corbits/mailbox": "github:corbitsdev/corbits-mailbox#fea6211e42fddfc87e8e1755c219c437e45a7d62", "@corbits/memory": "github:corbitsdev/corbits-memory#9e6f213fa2c002b531d3f6af1aa0abd737b8afe3", "@corbits/turn-artifacts": "workspace:*", "@corbits/url-path": "workspace:*", @@ -806,7 +806,7 @@ "version": "0.0.1", "dependencies": { "@corbits/error-sink": "workspace:*", - "@corbits/mailbox": "github:corbitsdev/corbits-mailbox#118a2cd3b0b2d83abcd57b4bed1698e95ee41b6d", + "@corbits/mailbox": "github:corbitsdev/corbits-mailbox#fea6211e42fddfc87e8e1755c219c437e45a7d62", "@corbits/notify": "workspace:*", "@intx/hub-api": "workspace:*", "@intx/log": "0.3.0", @@ -2207,7 +2207,7 @@ "@corbits/longevity-sim": ["@corbits/longevity-sim@workspace:packages/longevity-sim"], - "@corbits/mailbox": ["@corbits/mailbox@github:corbitsdev/corbits-mailbox#118a2cd", { "dependencies": { "@hono/standard-validator": "0.2.3", "@standard-community/standard-json": "0.3.5", "@standard-community/standard-openapi": "0.2.9", "arktype": "2.1.29", "hono-openapi": "1.3.1" }, "peerDependencies": { "@intx/log": "^0.2.2", "@intx/mime": "^0.2.2", "@intx/types": "^0.2.2", "drizzle-orm": "^0.45.2", "hono": "^4.12.0", "postgres": "^3.4.0" } }, "corbitsdev-corbits-mailbox-118a2cd", "sha512-jVRl6/IH34JBPVUz7mqWyBm1w7uJxvYAI27gjw7zL1dFO4L+D0E5q/8b+mpwKMNOKKlGXU6UF5pdHnY8ilYx8g=="], + "@corbits/mailbox": ["@corbits/mailbox@github:corbitsdev/corbits-mailbox#fea6211", { "dependencies": { "@hono/standard-validator": "0.2.3", "@standard-community/standard-json": "0.3.5", "@standard-community/standard-openapi": "0.2.9", "arktype": "2.1.29", "hono-openapi": "1.3.1" }, "peerDependencies": { "@intx/log": "^0.2.2", "@intx/mime": "^0.2.2", "@intx/types": "^0.2.2", "drizzle-orm": "^0.45.2", "hono": "^4.12.0", "postgres": "^3.4.0" } }, "corbitsdev-corbits-mailbox-fea6211", "sha512-FfJsBGUSr7gRcPn9L0hmlAmqW8VATXP02qzColrshfJJzJHJCEICfzTi0ER+KDeAvNcvvxY7YqQ9IAsx9Pl9gg=="], "@corbits/manus-tools": ["@corbits/manus-tools@workspace:packages/manus-tools"], @@ -3629,18 +3629,12 @@ "@changesets/write/prettier": ["prettier@2.8.8", "", { "bin": { "prettier": "bin-prettier.js" } }, "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q=="], - "@corbits/artifacts-hub/@corbits/artifacts": ["@corbits/artifacts@github:corbitsdev/corbits-artifacts#dc435ba", { "dependencies": { "@hono/standard-validator": "^0.2.3" }, "peerDependencies": { "@intx/hub-api": "^0.2.2", "@intx/types": "^0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.2", "hono": "^4.12.32", "hono-openapi": "^1.2.0", "postgres": "^3.4.9" } }, "corbitsdev-corbits-artifacts-dc435ba", "sha512-VksIyrrJY9nRke0QyjgqGdW3gLD73Bi3+CzpJWG+l8vRJKdUxVi7ms2Mp95NmJXr4TpVX6i1z4odufX/QBxnSw=="], - - "@corbits/chat-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], + "@corbits/artifact-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], - "@corbits/context-menu/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], + "@corbits/artifacts-hub/@corbits/artifacts": ["@corbits/artifacts@github:corbitsdev/corbits-artifacts#dc435ba", { "dependencies": { "@hono/standard-validator": "^0.2.3" }, "peerDependencies": { "@intx/hub-api": "^0.2.2", "@intx/types": "^0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.2", "hono": "^4.12.32", "hono-openapi": "^1.2.0", "postgres": "^3.4.9" } }, "corbitsdev-corbits-artifacts-dc435ba", "sha512-VksIyrrJY9nRke0QyjgqGdW3gLD73Bi3+CzpJWG+l8vRJKdUxVi7ms2Mp95NmJXr4TpVX6i1z4odufX/QBxnSw=="], "@corbits/mailbox/hono-openapi": ["hono-openapi@1.3.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.11.2", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-NLVeVkhKZ3drmQNEIPac8HX8Y54uf1hJAgIM/7MfDsaeVVmB+QILWQxx5x3R3NvRHgedcbEbOCGY2uR7WQYyMw=="], - "@corbits/plugins-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], - - "@corbits/settings-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], - "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], @@ -3675,8 +3669,6 @@ "@workbench/sidecar/@corbits/oauth-core": ["@corbits/oauth-core@github:corbitsdev/corbits-oauth-core#e1e69e6", { "dependencies": { "arktype": "2.2.3" } }, "corbitsdev-corbits-oauth-core-e1e69e6", "sha512-wUrD73iVyk/Dtb4yRn3hCh6N8syfsEvAkRz4XYqT0FIB3sYDSMYEWHcDMJx9nq64my/HBOeN1J6PHIOtpjhtPg=="], - "@workbench/web/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], - "ajv-formats/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], "better-call/@better-auth/utils": ["@better-auth/utils@0.5.0", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA=="], diff --git a/packages/chat/package.json b/packages/chat/package.json index 8d466f3d48..3f90f9d9a2 100644 --- a/packages/chat/package.json +++ b/packages/chat/package.json @@ -45,7 +45,7 @@ "@corbits/commands": "workspace:*", "@corbits/error-sink": "workspace:*", "@corbits/hub-api-client": "workspace:*", - "@corbits/mailbox": "github:corbitsdev/corbits-mailbox#118a2cd3b0b2d83abcd57b4bed1698e95ee41b6d", + "@corbits/mailbox": "github:corbitsdev/corbits-mailbox#fea6211e42fddfc87e8e1755c219c437e45a7d62", "@corbits/memory": "github:corbitsdev/corbits-memory#9e6f213fa2c002b531d3f6af1aa0abd737b8afe3", "@corbits/turn-artifacts": "workspace:*", "@corbits/workflows": "workspace:*", diff --git a/packages/inbox/package.json b/packages/inbox/package.json index 7e579fdde9..398c434fbe 100644 --- a/packages/inbox/package.json +++ b/packages/inbox/package.json @@ -16,7 +16,7 @@ }, "dependencies": { "@corbits/error-sink": "workspace:*", - "@corbits/mailbox": "github:corbitsdev/corbits-mailbox#118a2cd3b0b2d83abcd57b4bed1698e95ee41b6d", + "@corbits/mailbox": "github:corbitsdev/corbits-mailbox#fea6211e42fddfc87e8e1755c219c437e45a7d62", "@corbits/notify": "workspace:*", "@intx/hub-api": "workspace:*", "@intx/log": "0.3.0", diff --git a/packages/inbox/src/client.ts b/packages/inbox/src/client.ts index 8de3dbf73c..9ab565d879 100644 --- a/packages/inbox/src/client.ts +++ b/packages/inbox/src/client.ts @@ -1,14 +1,17 @@ // Browser-safe Inbox domain surface: the wire schemas/types a UI parses -// responses with, group classification, and the pure bulk-op eligibility -// rules. Re-exported from their owning modules rather than redeclared — -// `./project`, `./group`, and `./bulk` already import nothing but arktype -// and (type-only) `@corbits/mailbox`, so this subpath adds no code of its -// own, only a browser-safe cut of the package root. The root export (".") -// also carries `./routes` (hono, `@intx/hub-api`), `./delivery` -// (`@corbits/notify/mailbox`), and `./migrations` (`@corbits/mailbox`'s -// Postgres migration runner) — none of which belong in a browser bundle — -// which is why a UI imports this subpath instead of the root. Enforced -// by `bun run check:browser-safe-subpaths` +// responses with, group classification, the pure bulk-op eligibility rules, +// and the mailbox thread-read fetch client. Re-exported from their owning +// modules rather than redeclared — `./project`, `./group`, and `./bulk` +// import nothing but arktype and (type-only) `@corbits/mailbox`; `./thread- +// client` imports arktype and `@corbits/mailbox`'s own thread schemas +// (a value import, but `@corbits/mailbox` is an external git dependency the +// browser-safe-subpaths check treats as an opaque leaf, not something it +// walks into) — so this subpath adds only `fetch` composition, no server- +// only dependency. The root export (".") also carries `./routes` (hono, +// `@intx/hub-api`), `./delivery` (`@corbits/notify/mailbox`), and +// `./migrations` (`@corbits/mailbox`'s Postgres migration runner) — none of +// which belong in a browser bundle — which is why a UI imports this subpath +// instead of the root. Enforced by `bun run check:browser-safe-subpaths` // (scripts/checks/browser-safe-subpaths.ts), not just by convention. export { itemsEligibleForClearDone, itemsEligibleForMarkAllRead } from "./bulk"; @@ -29,3 +32,10 @@ export { type InboxItem, type InboxItemDetail, } from "./project"; +export { + MailboxThreadFetchError, + listMailboxThreadsClient, + readMailboxThreadClient, + type MailboxThreadListResult, + type MailboxThreadResult, +} from "./thread-client"; diff --git a/packages/inbox/src/thread-client.ts b/packages/inbox/src/thread-client.ts new file mode 100644 index 0000000000..d22e4f453f --- /dev/null +++ b/packages/inbox/src/thread-client.ts @@ -0,0 +1,119 @@ +// Typed client for the two `@corbits/mailbox` thread-read routes +// (`GET /me/threads`, `GET /me/threads/:rootMessageId`), mounted by +// `apps/hub` under `${TENANT_PREFIX}/mailbox` (see apps/hub/src/index.ts). +// Fetch composition only — the wire schemas and result types are +// `@corbits/mailbox`'s own, reused rather than redeclared so this checkout +// and the pinned mailbox version can never silently drift apart. +// +// `@corbits/mailbox` is an external git dependency, so importing it here +// (even its runtime arktype schemas) is not a browser-safe-subpath +// violation: the check treats an unowned package as an opaque leaf. + +import { type } from "arktype"; +import { + MailboxThreadListResponseSchema, + MailboxThreadResponseSchema, + type MailboxRef, +} from "@corbits/mailbox"; + +export class MailboxThreadFetchError extends Error { + constructor( + message: string, + readonly status: number | undefined, + readonly path: string, + ) { + super(message); + this.name = "MailboxThreadFetchError"; + } +} + +export type MailboxThreadListResult = + typeof MailboxThreadListResponseSchema.infer; + +export type MailboxThreadResult = typeof MailboxThreadResponseSchema.infer; + +async function requestMailboxThreadJSON( + path: string, + schema: (data: unknown) => T | type.errors, + fetchImpl: typeof fetch, +): Promise { + let response: Response; + try { + response = await fetchImpl(path, { + headers: { accept: "application/json" }, + }); + } catch (cause) { + throw new MailboxThreadFetchError( + cause instanceof Error ? cause.message : String(cause), + undefined, + path, + ); + } + if (!response.ok) { + const detail = await response + .json() + .then((body: unknown) => + typeof body === "object" && body !== null && "error" in body + ? String((body as { error: unknown }).error) + : "", + ) + .catch(() => ""); + throw new MailboxThreadFetchError( + detail === "" ? `the server answered ${response.status}` : detail, + response.status, + path, + ); + } + const body: unknown = await response.json().catch(() => undefined); + const parsed = schema(body); + if (parsed instanceof type.errors) { + throw new MailboxThreadFetchError( + `unexpected response shape from ${path}: ${parsed.summary}`, + response.status, + path, + ); + } + return parsed; +} + +/** + * List the caller's conversations, newest activity first. `refs` (optional) + * is JSON-encoded the same way the route documents — an array of + * `{kind, id}` pairs OR'd together to scope the listing. + */ +export function listMailboxThreadsClient( + mailboxBasePath: string, + args: { refs?: MailboxRef[]; cursor?: string; limit?: number } = {}, + fetchImpl: typeof fetch = fetch, +): Promise { + const params = new URLSearchParams(); + if (args.refs !== undefined) params.set("refs", JSON.stringify(args.refs)); + if (args.cursor !== undefined) params.set("cursor", args.cursor); + if (args.limit !== undefined) params.set("limit", String(args.limit)); + const query = params.toString(); + const path = `${mailboxBasePath}/me/threads${query === "" ? "" : `?${query}`}`; + return requestMailboxThreadJSON( + path, + MailboxThreadListResponseSchema, + fetchImpl, + ); +} + +/** + * Read one conversation by its root Message-ID, oldest message first. + */ +export function readMailboxThreadClient( + mailboxBasePath: string, + rootMessageId: string, + args: { cursor?: string; limit?: number } = {}, + fetchImpl: typeof fetch = fetch, +): Promise { + const params = new URLSearchParams(); + if (args.cursor !== undefined) params.set("cursor", args.cursor); + if (args.limit !== undefined) params.set("limit", String(args.limit)); + const query = params.toString(); + const path = `${mailboxBasePath}/me/threads/${encodeURIComponent(rootMessageId)}${ + query === "" ? "" : `?${query}` + }`; + return requestMailboxThreadJSON(path, MailboxThreadResponseSchema, fetchImpl); +}