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
24 changes: 12 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
66 changes: 66 additions & 0 deletions e2e/mount-send.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<typeof failingApp>) {
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");
});
});
3 changes: 2 additions & 1 deletion src/bus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -14,6 +14,7 @@ export const MAILBOX_EVENT_OPS = [
"trash",
"archive",
"restore",
"undelivered",
] as const;
export type MailboxEventOp = (typeof MAILBOX_EVENT_OPS)[number];

Expand Down
6 changes: 5 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
27 changes: 24 additions & 3 deletions src/mount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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 });
},
Expand All @@ -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" },
Expand Down
Loading