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
91 changes: 91 additions & 0 deletions apps/hub/src/mailbox-send.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// A person's mailbox send to an agent is a deployment trigger: a run
// address is not a mailbox, so the frame cannot ride the mail persist
// path. It is handed to stock's own `POST /workflows/:runId/mail`, the
// single path that signs, authorizes and dispatches a person's message to
// a run.

import { AsyncLocalStorage } from "node:async_hooks";
import type { Context, MiddlewareHandler } from "hono";
import { parseRunAddress } from "@intx/types";
import type { OutgoingMailboxMessage } from "@corbits/mailbox";
import { reportError } from "@corbits/error-sink";

/** The send route's own request, so `deliver` can replay the caller's
* session against the stock trigger route it has no context for. */
const mailboxRequest = new AsyncLocalStorage<Context>();

export function captureMailboxRequest(): MiddlewareHandler {
return (c, next) => mailboxRequest.run(c as Context, next);
}

/** The text of a frame this package built: flat, so everything after the
* header section is the body. */
function frameBody(raw: Uint8Array): string {
const text = new TextDecoder().decode(raw);
const split = text.indexOf("\r\n\r\n");
return split < 0 ? "" : text.slice(split + 4).trimEnd();
}

export type MailboxDeliverOpts = {
/** The hub app itself — the trigger runs through its real route stack,
* middleware and grants included. */
readonly app: { request(input: string, init?: RequestInit): Response | Promise<Response> };
readonly persistMail: (args: {
senderAddress: string;
recipients: string[];
raw: Uint8Array;
}) => Promise<unknown>;
};

export function createMailboxDeliver(
opts: MailboxDeliverOpts,
): (message: OutgoingMailboxMessage) => Promise<void> {
return async (message) => {
const runs = message.to.flatMap((address) => {
const parsed = parseRunAddress(address);
return parsed === null ? [] : [parsed.runId];
});
const others = message.to.filter((address) => parseRunAddress(address) === null);

if (runs.length > 0) {
const c = mailboxRequest.getStore();
if (c === undefined) {
throw new Error("mailbox deliver ran outside a mailbox request");
}
const tenantId = c.req.param("tenantId") ?? "";
const headers = new Headers({ "content-type": "application/json" });
for (const name of ["cookie", "authorization"]) {
const value = c.req.header(name);
if (value !== undefined) headers.set(name, value);
}
const content = frameBody(message.raw);
for (const runId of runs) {
const path = `/api/tenants/${encodeURIComponent(tenantId)}/workflows/${encodeURIComponent(runId)}/mail`;
const response = await opts.app.request(path, {
method: "POST",
headers,
body: JSON.stringify({ content }),
});
if (!response.ok) {
const detail = await response.text().catch(() => "");
const err = new Error(
`trigger for ${runId} answered ${String(response.status)}: ${detail}`,
);
reportError(err, {
operation: "hub.mailboxDeliver.trigger",
extra: { runId, tenantId },
});
throw err;
}
}
}

if (others.length > 0) {
await opts.persistMail({
senderAddress: message.from,
recipients: others,
raw: message.raw,
});
}
};
}
25 changes: 16 additions & 9 deletions apps/hub/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
resolveFrameSenderKey,
resolveSenderKey,
} from "@intx/db";
import { tenant as tenantTable } from "@intx/db/schema";
import { principal as principalTable, tenant as tenantTable } from "@intx/db/schema";
import { eq } from "drizzle-orm";
import { createEnvKeyCredentialCipher, sha256 } from "@intx/crypto";
import { hexDecode, hexEncode, type SidecarCapabilityRule } from "@intx/types";
Expand Down Expand Up @@ -64,6 +64,7 @@ import {
createHubMailboxAuthorizeSender,
createHubPersistMailWithSessionEnsure,
} from "./mailbox-persist";
import { captureMailboxRequest, createMailboxDeliver } from "./mailbox-send";
import { installWebhooks, type HookMailRouter } from "@corbits/webhooks";
import { createWorkflowAuthorRegistry, createWorkflowAuthorRoutes } from "@corbits/workflows";
import {
Expand Down Expand Up @@ -511,13 +512,17 @@ export async function createHubServer({
}
{
const mailboxApp = new Hono<TenantEnv>();
// Registered before the routes: Hono runs handlers in registration order.
mailboxApp.use("/me/inbox/send", captureMailboxRequest());
mountMailbox(mailboxApp, {
db: mailboxDb,
bus: mailboxBus,
resolvePrincipal: (ctx) => {
const c = ctx as { get(key: "tenant" | "principal"): { id: string } };
return { tenantId: c.get("tenant").id, principalId: c.get("principal").id };
},
// The address stock itself stamps on a person's outbound mail, so a
// Sent copy and the agent's reply share one identity.
senderAddressFor: async (principal) => {
const [tenantRow] = await db
.select({ domain: tenantTable.domain })
Expand All @@ -527,15 +532,17 @@ export async function createHubServer({
if (tenantRow === undefined) {
throw new Error(`no tenant "${principal.tenantId}" to address a mailbox sender from`);
}
return `${principal.principalId}@${tenantRow.domain}`;
},
deliver: async (message) => {
await mailboxLookups.persistMail({
senderAddress: message.from,
recipients: message.to,
raw: message.raw,
});
const [principalRow] = await db
.select({ refId: principalTable.refId })
.from(principalTable)
.where(eq(principalTable.id, principal.principalId))
.limit(1);
if (principalRow === undefined) {
throw new Error(`no principal "${principal.principalId}" to address a mailbox sender as`);
}
return `${principalRow.refId}@${tenantRow.domain}`;
},
deliver: createMailboxDeliver({ app, persistMail: mailboxLookups.persistMail }),
});
app.route(`${TENANT_PREFIX}/mailbox`, mailboxApp);
}
Expand Down
Loading
Loading