diff --git a/README.md b/README.md index 90bf33f..8e5d53a 100644 --- a/README.md +++ b/README.md @@ -81,18 +81,18 @@ process.once("SIGTERM", close); Paths are relative to where the host mounts the sub-app. Every route returns 403 when the request has no principal. -| Method | Path | Grant | Purpose | -| ------ | ---------------------------- | -------------------- | -------------------------------------------------------------------------------------------------------------- | -| GET | `/me/inbox` | `mailbox:*` `read` | List a folder newest first. `?folder=` INBOX (default), Sent, Archive, Trash; `?limit=` (max 200), `?cursor=`. | -| GET | `/me/inbox/threads` | `mailbox:*` `read` | A folder as threads (REFERENCES algorithm). `?folder=` as above. | -| GET | `/me/inbox/threads/:rootUid` | `mailbox:*` `read` | One thread, rooted at `rootUid`. `?folder=` as above. | -| GET | `/me/inbox/events` | `mailbox:*` `read` | Server-sent `mailbox` events for the caller, with a heartbeat. | -| POST | `/me/inbox/send` | `mailbox:*` `create` | Build a message from `{ to, subject?, body, inReplyTo? }`, file it in `Sent`, call `deliver`. | -| POST | `/me/inbox/:uid/read` | `mailbox:*` `manage` | Set `\Seen` on a message in `?folder=` (INBOX by default). | -| POST | `/me/inbox/:uid/unread` | `mailbox:*` `manage` | Clear `\Seen` on a message in `?folder=` (INBOX by default). | -| POST | `/me/inbox/:uid/archive` | `mailbox:*` `manage` | Move from INBOX to Archive. | -| POST | `/me/inbox/:uid/trash` | `mailbox:*` `manage` | Move from INBOX to Trash. | -| POST | `/me/inbox/:uid/restore` | `mailbox:*` `manage` | Move back to INBOX from `?folder=`: Archive (default) or Trash. | +| Method | Path | Grant | Purpose | +| ------ | ---------------------------- | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| GET | `/me/inbox` | `mailbox:*` `read` | List a folder newest first. `?folder=` INBOX (default), Sent, Archive, Trash; `?limit=` (max 200), `?cursor=`. | +| GET | `/me/inbox/threads` | `mailbox:*` `read` | A folder as threads (REFERENCES algorithm). `?folder=` as above. | +| GET | `/me/inbox/threads/:rootUid` | `mailbox:*` `read` | One thread, rooted at `rootUid`. `?folder=` as above. | +| GET | `/me/inbox/events` | `mailbox:*` `read` | Server-sent `mailbox` events for the caller, with a heartbeat. | +| POST | `/me/inbox/send` | `mailbox:*` `create` | Build a message from `{ to, subject?, body, inReplyTo? }`, file it in `Sent`, call `deliver`. If `deliver` throws, the `Sent` copy gets the `$Undelivered` flag, an `undelivered` SSE event fires, and the error propagates. The flag means `deliver` threw, so with several recipients it may be a partial delivery, not "nobody received it". | +| POST | `/me/inbox/:uid/read` | `mailbox:*` `manage` | Set `\Seen` on a message in `?folder=` (INBOX by default). | +| POST | `/me/inbox/:uid/unread` | `mailbox:*` `manage` | Clear `\Seen` on a message in `?folder=` (INBOX by default). | +| POST | `/me/inbox/:uid/archive` | `mailbox:*` `manage` | Move from INBOX to Archive. | +| POST | `/me/inbox/:uid/trash` | `mailbox:*` `manage` | Move from INBOX to Trash. | +| POST | `/me/inbox/:uid/restore` | `mailbox:*` `manage` | Move back to INBOX from `?folder=`: Archive (default) or Trash. | The grant is `mailbox:*` rather than a per-message resource because every query is already scoped to the caller's own tenant and principal. diff --git a/e2e/mount-send.test.ts b/e2e/mount-send.test.ts index 98ee8b0..f8454bd 100644 --- a/e2e/mount-send.test.ts +++ b/e2e/mount-send.test.ts @@ -1,6 +1,7 @@ // POST /me/inbox/send: builds an RFC 5322 message, appends it to the // caller's Sent folder, and hands it to the host's `deliver` — this package // owns no transport of its own. +import { HTTPException } from "hono/http-exception"; import { beforeEach, describe, expect, test } from "bun:test"; import { createMailboxRoutes, @@ -201,3 +202,68 @@ describe("POST /me/inbox/send", () => { expect(res.status).toBe(403); }); }); + +describe("POST /me/inbox/send when deliver fails", () => { + function failingApp(error: Error) { + return mountAs( + SCOPE, + createMailboxRoutes({ + db, + requireGrant: allowAllGrants, + bus: createInMemoryMailboxEventBus(), + senderAddressFor: () => FROM, + deliver: () => { + throw error; + }, + }), + ); + } + + function send(app: ReturnType) { + return app.request("/me/inbox/send", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ to: ["bob@example.com"], body: "Hi" }), + }); + } + + test("keeps the Sent copy, flags it $Undelivered, and propagates the HTTP status", async () => { + const events: string[] = []; + const bus = createInMemoryMailboxEventBus(); + bus.subscribe(SCOPE, (e) => events.push(`${e.id}:${e.op}`)); + const app = mountAs( + SCOPE, + createMailboxRoutes({ + db, + requireGrant: allowAllGrants, + bus, + senderAddressFor: () => FROM, + deliver: () => { + throw new HTTPException(409, { message: "run finished" }); + }, + }), + ); + + const res = await send(app); + expect(res.status).toBe(409); + + const sent = await openNativeMailboxStore(db, { ...SCOPE, folder: "Sent" }); + expect(sent.messages).toHaveLength(1); + expect([...sent.messages[0]!.flags]).toContain("$Undelivered"); + expect(events).toEqual(["Sent:1:create", "Sent:1:undelivered"]); + + const list = await app.request("/me/inbox?folder=Sent"); + const { messages } = (await list.json()) as { + messages: { flags: string[] }[]; + }; + expect(messages[0]!.flags).toContain("$Undelivered"); + }); + + test("a non-HTTP failure still rejects and flags the Sent copy", async () => { + const app = failingApp(new Error("transport down")); + const res = await send(app); + expect(res.status).toBe(500); + const sent = await openNativeMailboxStore(db, { ...SCOPE, folder: "Sent" }); + expect([...sent.messages[0]!.flags]).toContain("$Undelivered"); + }); +}); diff --git a/src/bus.ts b/src/bus.ts index fc5e3a5..0fd219b 100644 --- a/src/bus.ts +++ b/src/bus.ts @@ -5,7 +5,7 @@ import { type } from "arktype"; * (a new message landed, from `writeMailboxMessage`, `deliverInboxItems`, or * `createMailboxPersist`) plus the five verbs `mount.ts`'s route table * registers over the native store (`mark_read`, `mark_unread`, `archive`, - * `trash`, `restore`). + * `trash`, `restore`), and `undelivered` (a Sent message whose delivery failed). */ export const MAILBOX_EVENT_OPS = [ "create", @@ -14,6 +14,7 @@ export const MAILBOX_EVENT_OPS = [ "trash", "archive", "restore", + "undelivered", ] as const; export type MailboxEventOp = (typeof MAILBOX_EVENT_OPS)[number]; diff --git a/src/index.ts b/src/index.ts index 7b9b040..ade8ffe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,11 @@ // human principals. This library exists ONLY to give a human principal a // native `@intx/mailbox` `MailboxStore` over Postgres, and the routes that // let a host's UI list, read, and file it — nothing else. -export { createMailboxRoutes, MAX_MAILBOX_PAGE_LIMIT } from "./mount.js"; +export { + createMailboxRoutes, + MAX_MAILBOX_PAGE_LIMIT, + UNDELIVERED_FLAG, +} from "./mount.js"; export type { CreateMailboxRoutesDeps, ResolvedPrincipal, diff --git a/src/mount.ts b/src/mount.ts index c25ed63..48aed7e 100644 --- a/src/mount.ts +++ b/src/mount.ts @@ -84,6 +84,13 @@ const DEFAULT_HEARTBEAT_INTERVAL_MS = 25_000; export const MAX_PENDING_SSE_EVENTS = 100; const DEFAULT_FOLDER = "INBOX"; +/** + * Set on a Sent message whose `deliver` call threw; the copy is kept. With + * several recipients the host may have delivered to some before throwing, so + * this means "deliver failed", not "nobody received it". + */ +export const UNDELIVERED_FLAG = "$Undelivered"; + /** Folders `?folder=` may name for `GET /me/inbox`. */ const LIST_FOLDERS = ["INBOX", "Sent", "Archive", "Trash"] as const; type ListFolder = (typeof LIST_FOLDERS)[number]; @@ -455,7 +462,10 @@ export function createMailboxRoutes( description: "Builds an RFC 5322 message, appends a copy to the caller's Sent " + "folder via the native store, then hands it to the host's own " + - "`deliver` — this package owns no transport.", + "`deliver` — this package owns no transport. If `deliver` throws, the " + + "Sent copy is kept with the `$Undelivered` flag and the error propagates. " + + "The flag means `deliver` threw; with several recipients it may have " + + "reached some of them.", responses: { 200: { description: "The Sent copy's messageId and uid" }, 400: { @@ -569,7 +579,18 @@ export function createMailboxRoutes( if (uid === null) throw new Error(`Sent already holds ${messageId}`); publish(resolved, `Sent:${uid}`, "create"); - await deliver({ raw, from: fromAddress, to: parsed.to, messageId }); + try { + await deliver({ raw, from: fromAddress, to: parsed.to, messageId }); + } catch (err) { + logger.error("deliver failed for Sent:{uid}", { + uid, + error: err instanceof Error ? err : new Error(String(err)), + }); + sentStore.addFlags(uid, [UNDELIVERED_FLAG]); + await sentStore.settled; + publish(resolved, `Sent:${uid}`, "undelivered"); + throw err; + } return c.json({ messageId, uid }); }, @@ -584,7 +605,7 @@ export function createMailboxRoutes( description: "Emits a `mailbox` event per affected message, plus a heartbeat " + "comment every 25s. Each event carries `op` " + - "(create/mark_read/mark_unread/archive/trash/restore) when the " + + "(create/mark_read/mark_unread/archive/trash/restore/undelivered) when the " + "publisher knows it.", responses: { 200: { description: "text/event-stream" },