diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b8f6751 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,20 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run build + - run: npm run typecheck + - run: npm test diff --git a/README.md b/README.md index 6a654e9..c5da939 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,19 @@ await agentmail.getThread(ctx, inboxId, threadId); await agentmail.getMessage(ctx, inboxId, messageId); ``` +## Inbox cache + +`createInbox` and `getInbox` store the inbox in the component's own table and `deleteInbox` removes it, so you can read inboxes from a query — and from React via `useQuery` — without an AgentMail round-trip: + +```ts +export const inboxes = query({ + args: {}, + handler: (ctx) => agentmail.listCachedInboxes(ctx), +}); +``` + +`getCachedInbox(ctx, inboxId)` returns one entry, or `null` if it is not cached. + ## Send mail (with labels) ```ts @@ -180,6 +193,10 @@ The `thread` argument carries everything AgentMail knows about the thread at the For EU residency, set `AGENTMAIL_BASE_URL=https://api.agentmail.eu/v0`. +### Retention + +Finalized outbound rows (sent, delivered, bounced, complained, rejected, failed) are kept for 7 days, then swept by an hourly cron the component schedules itself. To sweep sooner, run `components.agentmail.lib.cleanupFinalizedOutbound` with `{ olderThan }` in milliseconds. + ## License Apache-2.0 diff --git a/example/convex/_generated/api.d.ts b/example/convex/_generated/api.d.ts new file mode 100644 index 0000000..36c7a4e --- /dev/null +++ b/example/convex/_generated/api.d.ts @@ -0,0 +1,53 @@ +/* eslint-disable */ +/** + * Generated `api` utility. + * + * THIS CODE IS AUTOMATICALLY GENERATED. + * + * To regenerate, run `npx convex dev`. + * @module + */ + +import type * as example from "../example.js"; +import type * as http from "../http.js"; + +import type { + ApiFromModules, + FilterApi, + FunctionReference, +} from "convex/server"; + +declare const fullApi: ApiFromModules<{ + example: typeof example; + http: typeof http; +}>; + +/** + * A utility for referencing Convex functions in your app's public API. + * + * Usage: + * ```js + * const myFunctionReference = api.myModule.myFunction; + * ``` + */ +export declare const api: FilterApi< + typeof fullApi, + FunctionReference +>; + +/** + * A utility for referencing Convex functions in your app's internal API. + * + * Usage: + * ```js + * const myFunctionReference = internal.myModule.myFunction; + * ``` + */ +export declare const internal: FilterApi< + typeof fullApi, + FunctionReference +>; + +export declare const components: { + agentmail: import("@agentmail/convex/_generated/component.js").ComponentApi<"agentmail">; +}; diff --git a/example/convex/_generated/api.js b/example/convex/_generated/api.js new file mode 100644 index 0000000..44bf985 --- /dev/null +++ b/example/convex/_generated/api.js @@ -0,0 +1,23 @@ +/* eslint-disable */ +/** + * Generated `api` utility. + * + * THIS CODE IS AUTOMATICALLY GENERATED. + * + * To regenerate, run `npx convex dev`. + * @module + */ + +import { anyApi, componentsGeneric } from "convex/server"; + +/** + * A utility for referencing Convex functions in your app's API. + * + * Usage: + * ```js + * const myFunctionReference = api.myModule.myFunction; + * ``` + */ +export const api = anyApi; +export const internal = anyApi; +export const components = componentsGeneric(); diff --git a/example/convex/_generated/dataModel.d.ts b/example/convex/_generated/dataModel.d.ts new file mode 100644 index 0000000..f97fd19 --- /dev/null +++ b/example/convex/_generated/dataModel.d.ts @@ -0,0 +1,60 @@ +/* eslint-disable */ +/** + * Generated data model types. + * + * THIS CODE IS AUTOMATICALLY GENERATED. + * + * To regenerate, run `npx convex dev`. + * @module + */ + +import type { + DataModelFromSchemaDefinition, + DocumentByName, + TableNamesInDataModel, + SystemTableNames, +} from "convex/server"; +import type { GenericId } from "convex/values"; +import schema from "../schema.js"; + +/** + * The names of all of your Convex tables. + */ +export type TableNames = TableNamesInDataModel; + +/** + * The type of a document stored in Convex. + * + * @typeParam TableName - A string literal type of the table name (like "users"). + */ +export type Doc = DocumentByName< + DataModel, + TableName +>; + +/** + * An identifier for a document in Convex. + * + * Convex documents are uniquely identified by their `Id`, which is accessible + * on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids). + * + * Documents can be loaded using `db.get(tableName, id)` in query and mutation functions. + * + * IDs are just strings at runtime, but this type can be used to distinguish them from other + * strings when type checking. + * + * @typeParam TableName - A string literal type of the table name (like "users"). + */ +export type Id = + GenericId; + +/** + * A type describing your Convex data model. + * + * This type includes information about what tables you have, the type of + * documents stored in those tables, and the indexes defined on them. + * + * This type is used to parameterize methods like `queryGeneric` and + * `mutationGeneric` to make them type-safe. + */ +export type DataModel = DataModelFromSchemaDefinition; diff --git a/example/convex/_generated/server.d.ts b/example/convex/_generated/server.d.ts new file mode 100644 index 0000000..f235db4 --- /dev/null +++ b/example/convex/_generated/server.d.ts @@ -0,0 +1,162 @@ +/* eslint-disable */ +/** + * Generated utilities for implementing server-side Convex query and mutation functions. + * + * THIS CODE IS AUTOMATICALLY GENERATED. + * + * To regenerate, run `npx convex dev`. + * @module + */ + +import { + ActionBuilder, + HttpActionBuilder, + MutationBuilder, + QueryBuilder, + GenericActionCtx, + GenericMutationCtx, + GenericQueryCtx, + GenericDatabaseReader, + GenericDatabaseWriter, +} from "convex/server"; +import type { DataModel } from "./dataModel.js"; + +/** + * Typesafe environment variables. + * + * This includes platform-provided env vars and any variables declared in + * `convex.config.ts`. + */ +type Env = { + readonly CONVEX_CLOUD_URL: string; + readonly CONVEX_SITE_URL: string; +}; + +/** + * Define a query in this Convex app's public API. + * + * This function will be allowed to read your Convex database and will be accessible from the client. + * + * @param func - The query function. It receives a {@link QueryCtx} as its first argument. + * @returns The wrapped query. Include this as an `export` to name it and make it accessible. + */ +export declare const query: QueryBuilder; + +/** + * Define a query that is only accessible from other Convex functions (but not from the client). + * + * This function will be allowed to read from your Convex database. It will not be accessible from the client. + * + * @param func - The query function. It receives a {@link QueryCtx} as its first argument. + * @returns The wrapped query. Include this as an `export` to name it and make it accessible. + */ +export declare const internalQuery: QueryBuilder; + +/** + * Define a mutation in this Convex app's public API. + * + * This function will be allowed to modify your Convex database and will be accessible from the client. + * + * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. + * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. + */ +export declare const mutation: MutationBuilder; + +/** + * Define a mutation that is only accessible from other Convex functions (but not from the client). + * + * This function will be allowed to modify your Convex database. It will not be accessible from the client. + * + * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. + * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. + */ +export declare const internalMutation: MutationBuilder; + +/** + * Define an action in this Convex app's public API. + * + * An action is a function which can execute any JavaScript code, including non-deterministic + * code and code with side-effects, like calling third-party services. + * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. + * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. + * + * @param func - The action. It receives an {@link ActionCtx} as its first argument. + * @returns The wrapped action. Include this as an `export` to name it and make it accessible. + */ +export declare const action: ActionBuilder; + +/** + * Define an action that is only accessible from other Convex functions (but not from the client). + * + * @param func - The function. It receives an {@link ActionCtx} as its first argument. + * @returns The wrapped function. Include this as an `export` to name it and make it accessible. + */ +export declare const internalAction: ActionBuilder; + +/** + * Define an HTTP action. + * + * The wrapped function will be used to respond to HTTP requests received + * by a Convex deployment if the requests matches the path and method where + * this action is routed. Be sure to route your httpAction in `convex/http.js`. + * + * @param func - The function. It receives an {@link ActionCtx} as its first argument + * and a Fetch API `Request` object as its second. + * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. + */ +export declare const httpAction: HttpActionBuilder; + +/** + * Typesafe environment variables. + * + * This includes platform-provided env vars and any variables declared in + * `convex.config.ts`. + */ +export declare const env: Env; + +/** + * A set of services for use within Convex query functions. + * + * The query context is passed as the first argument to any Convex query + * function run on the server. + * + * This differs from the {@link MutationCtx} because all of the services are + * read-only. + */ +export type QueryCtx = GenericQueryCtx; + +/** + * A set of services for use within Convex mutation functions. + * + * The mutation context is passed as the first argument to any Convex mutation + * function run on the server. + */ +export type MutationCtx = GenericMutationCtx; + +/** + * A set of services for use within Convex action functions. + * + * The action context is passed as the first argument to any Convex action + * function run on the server. + */ +export type ActionCtx = GenericActionCtx; + +/** + * An interface to read from the database within Convex query functions. + * + * The two entry points are {@link DatabaseReader.get}, which fetches a single + * document by its {@link Id}, or {@link DatabaseReader.query}, which starts + * building a query. + */ +export type DatabaseReader = GenericDatabaseReader; + +/** + * An interface to read from and write to the database within Convex mutation + * functions. + * + * Convex guarantees that all writes within a single mutation are + * executed atomically, so you never have to worry about partial writes leaving + * your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control) + * for the guarantees Convex provides your functions. + */ +export type DatabaseWriter = GenericDatabaseWriter; diff --git a/example/convex/_generated/server.js b/example/convex/_generated/server.js new file mode 100644 index 0000000..2dbe0db --- /dev/null +++ b/example/convex/_generated/server.js @@ -0,0 +1,101 @@ +/* eslint-disable */ +/** + * Generated utilities for implementing server-side Convex query and mutation functions. + * + * THIS CODE IS AUTOMATICALLY GENERATED. + * + * To regenerate, run `npx convex dev`. + * @module + */ + +import { + actionGeneric, + httpActionGeneric, + queryGeneric, + mutationGeneric, + internalActionGeneric, + internalMutationGeneric, + internalQueryGeneric, +} from "convex/server"; + +/** + * Define a query in this Convex app's public API. + * + * This function will be allowed to read your Convex database and will be accessible from the client. + * + * @param func - The query function. It receives a {@link QueryCtx} as its first argument. + * @returns The wrapped query. Include this as an `export` to name it and make it accessible. + */ +export const query = queryGeneric; + +/** + * Define a query that is only accessible from other Convex functions (but not from the client). + * + * This function will be allowed to read from your Convex database. It will not be accessible from the client. + * + * @param func - The query function. It receives a {@link QueryCtx} as its first argument. + * @returns The wrapped query. Include this as an `export` to name it and make it accessible. + */ +export const internalQuery = internalQueryGeneric; + +/** + * Define a mutation in this Convex app's public API. + * + * This function will be allowed to modify your Convex database and will be accessible from the client. + * + * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. + * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. + */ +export const mutation = mutationGeneric; + +/** + * Define a mutation that is only accessible from other Convex functions (but not from the client). + * + * This function will be allowed to modify your Convex database. It will not be accessible from the client. + * + * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. + * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. + */ +export const internalMutation = internalMutationGeneric; + +/** + * Define an action in this Convex app's public API. + * + * An action is a function which can execute any JavaScript code, including non-deterministic + * code and code with side-effects, like calling third-party services. + * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. + * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. + * + * @param func - The action. It receives an {@link ActionCtx} as its first argument. + * @returns The wrapped action. Include this as an `export` to name it and make it accessible. + */ +export const action = actionGeneric; + +/** + * Define an action that is only accessible from other Convex functions (but not from the client). + * + * @param func - The function. It receives an {@link ActionCtx} as its first argument. + * @returns The wrapped function. Include this as an `export` to name it and make it accessible. + */ +export const internalAction = internalActionGeneric; + +/** + * Define an HTTP action. + * + * The wrapped function will be used to respond to HTTP requests received + * by a Convex deployment if the requests matches the path and method where + * this action is routed. Be sure to route your httpAction in `convex/http.js`. + * + * @param func - The function. It receives an {@link ActionCtx} as its first argument + * and a Fetch API `Request` object as its second. + * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. + */ +export const httpAction = httpActionGeneric; + +/** + * Typesafe environment variables. + * + * This includes platform-provided env vars and any variables declared in + * `convex.config.ts`. + */ +export const env = process.env; diff --git a/example/convex/example.ts b/example/convex/example.ts index 95d76e3..d05e2f0 100644 --- a/example/convex/example.ts +++ b/example/convex/example.ts @@ -1,9 +1,17 @@ import { v } from "convex/values"; import { components, internal } from "./_generated/api"; -import { action, internalMutation, mutation, query } from "./_generated/server"; +import { + action, + internalAction, + internalMutation, + mutation, + query, +} from "./_generated/server"; import { AgentMail, type OutboundId } from "@agentmail/convex"; -const agentmail = new AgentMail(components.agentmail, { +// Annotated: `internal` is derived from this module's exports, whose types +// depend on `agentmail`, so inference alone would be circular. +const agentmail: AgentMail = new AgentMail(components.agentmail, { onMessageReceived: internal.example.onMessageReceived, }); @@ -71,7 +79,7 @@ export const onMessageReceived = internalMutation({ }, }); -export const draftAutoReply = action({ +export const draftAutoReply = internalAction({ args: { inboxId: v.string(), messageId: v.string(), diff --git a/example/convex/tsconfig.json b/example/convex/tsconfig.json index ecdb091..07c21bf 100644 --- a/example/convex/tsconfig.json +++ b/example/convex/tsconfig.json @@ -7,7 +7,25 @@ "skipLibCheck": true, "isolatedModules": true, "allowSyntheticDefaultImports": true, - "lib": ["ES2021", "dom"] + "lib": [ + "ES2021", + "dom" + ], + "noEmit": true, + "baseUrl": ".", + "paths": { + "@agentmail/convex": [ + "../../src/client/index.ts" + ], + "@agentmail/convex/convex.config": [ + "../../src/component/convex.config.ts" + ], + "@agentmail/convex/_generated/component.js": [ + "../../src/component/_generated/component.d.ts" + ] + } }, - "include": ["./**/*"] + "include": [ + "./**/*" + ] } diff --git a/package-lock.json b/package-lock.json index 28b541b..3df3b18 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,7 +54,6 @@ "integrity": "sha512-NKBGBSIKUG584qrS1tyxVpX/AKJKQw5HgjYEnPLC0QsTw79JrGn+qUr8CXFb955Iy7GUdiiUv1rJ6JBGvaKb6w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@edge-runtime/primitives": "6.0.0" }, @@ -62,13 +61,39 @@ "node": ">=18" } }, + "node_modules/@emnapi/core": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz", + "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.3", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", + "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -748,6 +773,40 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@rolldown/binding-win32-arm64-msvc": { "version": "1.0.0-rc.17", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", @@ -800,8 +859,7 @@ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "devOptional": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@tybys/wasm-util": { "version": "0.10.2", @@ -845,7 +903,6 @@ "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1141,7 +1198,6 @@ "resolved": "https://registry.npmjs.org/convex/-/convex-1.37.0.tgz", "integrity": "sha512-xGSx5edIsXCEex3OU2U2N0oyB/cOa9qGwKiImF9yOWqjqZgOkx39idtpdlwNBTBSt4S30oAvs4yeXY5xxPIX3A==", "license": "Apache-2.0", - "peer": true, "dependencies": { "esbuild": "0.27.0", "prettier": "^3.0.0", @@ -1180,7 +1236,6 @@ "resolved": "https://registry.npmjs.org/convex-helpers/-/convex-helpers-0.1.115.tgz", "integrity": "sha512-f9gyJO+hzNBfSNV9moW7HaLY6bWFpOQR6EAXtD9yPbuPX8xGm0H1dtce1GjdVHhZgUam/svmutpbZssJ1z3t1A==", "license": "Apache-2.0", - "peer": true, "bin": { "convex-helpers": "bin.cjs" }, @@ -2130,7 +2185,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2175,7 +2229,6 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -2196,7 +2249,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -2218,7 +2270,6 @@ "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", diff --git a/package.json b/package.json index 0093172..d83374f 100644 --- a/package.json +++ b/package.json @@ -29,10 +29,11 @@ "lint": "eslint .", "test": "vitest run", "test:watch": "vitest --clearScreen false", - "test:codegen": "vitest run src/**/*.codegen.test.ts", + "test:codegen": "vitest run src/component/lib.codegen.test.ts", "smoke": "tsx scripts/smoke.ts", "smoke:send": "tsx scripts/smoke.ts --send", - "prepublishOnly": "npm whoami", + "preversion": "npm run build && npm test", + "prepublishOnly": "npm whoami && npm run build && npm test", "release": "npm version patch && npm publish && git push --follow-tags" }, "publishConfig": { diff --git a/scripts/smoke.ts b/scripts/smoke.ts index d615e52..281ebfd 100644 --- a/scripts/smoke.ts +++ b/scripts/smoke.ts @@ -75,7 +75,6 @@ async function main() { // 3. send a message const sendOk = step("send message"); const send = (await agentmailFetch( - config, `/inboxes/${inbox.inbox_id}/messages/send`, { method: "POST", @@ -91,7 +90,6 @@ async function main() { // 4. fetch the message to confirm it was stored const getOk = step("get message"); const fetched = (await agentmailFetch( - config, `/inboxes/${inbox.inbox_id}/messages/${send.message_id}`, { method: "GET" }, )) as { message_id: string; from: string; subject?: string }; diff --git a/src/client/index.test.ts b/src/client/index.test.ts index 5074e3f..f01c605 100644 --- a/src/client/index.test.ts +++ b/src/client/index.test.ts @@ -41,6 +41,8 @@ const calls: Array<[string, (ctx: Ctx) => Promise, keyof typeof lib]> = ["listInboxes", (ctx) => agentmail.listInboxes(ctx), "listInboxes"], ["getInbox", (ctx) => agentmail.getInbox(ctx, "inbox_1"), "getInboxRemote"], ["deleteInbox", (ctx) => agentmail.deleteInbox(ctx, "inbox_1"), "deleteInbox"], + ["listCachedInboxes", (ctx) => agentmail.listCachedInboxes(ctx), "listCachedInboxes"], + ["getCachedInbox", (ctx) => agentmail.getCachedInbox(ctx, "inbox_1"), "getCachedInbox"], ["sendMessage", (ctx) => agentmail.sendMessage(ctx, "inbox_1", send), "enqueueSend"], ["replyToMessage", (ctx) => agentmail.replyToMessage(ctx, "inbox_1", "msg_1", send), "enqueueSend"], ["forwardMessage", (ctx) => agentmail.forwardMessage(ctx, "inbox_1", "msg_1", send), "enqueueSend"], diff --git a/src/client/index.ts b/src/client/index.ts index dafab49..f5241fa 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -140,6 +140,19 @@ export class AgentMail { }); } + /** + * Read inboxes from the component's local cache, which createInbox and + * getInbox fill and deleteInbox clears. Works from queries and reactive + * clients, with no AgentMail round-trip. + */ + async listCachedInboxes(ctx: RunQueryCtx) { + return await ctx.runQuery(this.component.lib.listCachedInboxes, {}); + } + + async getCachedInbox(ctx: RunQueryCtx, inboxId: string) { + return await ctx.runQuery(this.component.lib.getCachedInbox, { inboxId }); + } + // ---- Sending --------------------------------------------------------- /** diff --git a/src/component/_generated/api.d.ts b/src/component/_generated/api.d.ts index 19d8fce..21ba33a 100644 --- a/src/component/_generated/api.d.ts +++ b/src/component/_generated/api.d.ts @@ -8,6 +8,7 @@ * @module */ +import type * as crons from "../crons.js"; import type * as eventLogic from "../eventLogic.js"; import type * as lib from "../lib.js"; import type * as shared from "../shared.js"; @@ -20,6 +21,7 @@ import type { } from "convex/server"; declare const fullApi: ApiFromModules<{ + crons: typeof crons; eventLogic: typeof eventLogic; lib: typeof lib; shared: typeof shared; diff --git a/src/component/_generated/component.d.ts b/src/component/_generated/component.d.ts index 6c2860c..f613179 100644 --- a/src/component/_generated/component.d.ts +++ b/src/component/_generated/component.d.ts @@ -65,10 +65,10 @@ export type ComponentApi< null, Name >; - listCachedInboxes: FunctionReference<"query", "public", {}, any, Name>; + listCachedInboxes: FunctionReference<"query", "internal", {}, any, Name>; getCachedInbox: FunctionReference< "query", - "public", + "internal", { inboxId: string }, any, Name @@ -77,7 +77,7 @@ export type ComponentApi< // ---- Sending lifecycle --------------------------------------------- enqueueSend: FunctionReference< "mutation", - "public", + "internal", { config: RuntimeConfig; inboxId: string; @@ -90,14 +90,14 @@ export type ComponentApi< >; cancelSend: FunctionReference< "mutation", - "public", + "internal", { outboundId: GenericId<"outboundMessages"> }, null, Name >; getOutboundStatus: FunctionReference< "query", - "public", + "internal", { outboundId: GenericId<"outboundMessages"> }, { status: OutboundStatus; @@ -139,7 +139,7 @@ export type ComponentApi< >; listInboundMessages: FunctionReference< "query", - "public", + "internal", { inboxId?: string; threadId?: string }, Array, Name @@ -148,7 +148,7 @@ export type ComponentApi< // ---- Webhook ingestion --------------------------------------------- handleEvent: FunctionReference< "mutation", - "public", + "internal", { config: RuntimeConfig; event: AgentMailEvent }, null, Name @@ -157,7 +157,7 @@ export type ComponentApi< // ---- Maintenance --------------------------------------------------- cleanupFinalizedOutbound: FunctionReference< "mutation", - "public", + "internal", { olderThan?: number }, null, Name diff --git a/src/component/crons.ts b/src/component/crons.ts new file mode 100644 index 0000000..9bcb03a --- /dev/null +++ b/src/component/crons.ts @@ -0,0 +1,16 @@ +import { cronJobs } from "convex/server"; +import { api } from "./_generated/api.js"; + +// Finalized outbound rows are only useful for status polling for a while; +// without a sweeper they accumulate forever. The mutation stays public so an +// app can sweep sooner with its own `olderThan`. +const crons = cronJobs(); + +crons.interval( + "cleanup finalized outbound", + { hours: 1 }, + api.lib.cleanupFinalizedOutbound, + {}, +); + +export default crons; diff --git a/src/component/lib.codegen.test.ts b/src/component/lib.codegen.test.ts index 8116c53..3c434b6 100644 --- a/src/component/lib.codegen.test.ts +++ b/src/component/lib.codegen.test.ts @@ -1,7 +1,6 @@ -// Integration tests for the component using convex-test. -// These require codegen to have been run (e.g. via `npx convex dev` once). -// They are excluded from the default vitest pattern; run with: -// npm run test:codegen +// Integration tests for the component using convex-test. They run under +// `npm test` (alone: `npm run test:codegen`) and need src/component/_generated +// to be present. // // What's covered: // - enqueueSend inserts an outboundMessages row in "pending" and queues workpool @@ -11,7 +10,7 @@ // - handleEvent dedupes on event_id (idempotent) // - handleEvent persists message.received to inboundMessages // - handleEvent updates matched outbound rows on delivered/bounced -// - cleanupFinalizedOutbound deletes only delivered+old rows +// - cleanupFinalizedOutbound sweeps only finalized rows past retention import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { convexTest } from "convex-test"; @@ -36,16 +35,23 @@ function setupTest() { let fetchSpy: ReturnType; let originalFetch: typeof globalThis.fetch; +let originalApiKey: string | undefined; beforeEach(() => { originalFetch = globalThis.fetch; fetchSpy = vi.fn(); // @ts-expect-error -- replace global fetch for the duration of the test globalThis.fetch = fetchSpy; + // performSend reads the API key from the deployment env, not from config. + originalApiKey = process.env.AGENTMAIL_API_KEY; + process.env.AGENTMAIL_API_KEY = "test-key"; + vi.useFakeTimers(); }); afterEach(() => { globalThis.fetch = originalFetch; + if (originalApiKey === undefined) delete process.env.AGENTMAIL_API_KEY; + else process.env.AGENTMAIL_API_KEY = originalApiKey; vi.useRealTimers(); }); @@ -123,7 +129,7 @@ describe("enqueueSend", () => { const status = await t.query(api.lib.getOutboundStatus, { outboundId: id }); expect(status?.status).toBe("failed"); // workpool should have called fetch retryAttempts times before giving up - expect(fetchSpy.mock.calls.length).toBeGreaterThanOrEqual(2); + expect(fetchSpy.mock.calls.length).toBe(2); }); it("constructs the correct path for reply / reply_all / forward", async () => { @@ -320,3 +326,51 @@ describe("listInboundMessages", () => { expect(messages[2].messageId).toBe("msg_2"); }); }); + +describe("cleanupFinalizedOutbound", () => { + const DAY = 24 * 60 * 60 * 1000; + const send = (t: ReturnType) => + t.mutation(api.lib.enqueueSend, { + config, + inboxId: "inb_1", + kind: "send", + payload: { to: "x@example.com", subject: "hi", text: "hello" }, + }); + + it("deletes finalized rows past retention and keeps fresh or unfinished ones", async () => { + const t = setupTest(); + // A Response body can be read once; each send needs its own. + fetchSpy.mockImplementation(() => + jsonResponse({ message_id: "msg_1", thread_id: "thr_1" }), + ); + + const stale = await send(t); + await t.finishAllScheduledFunctions(vi.runAllTimers); + vi.setSystemTime(Date.now() + 8 * DAY); + const fresh = await send(t); + await t.finishAllScheduledFunctions(vi.runAllTimers); + const pending = await send(t); + + await t.mutation(api.lib.cleanupFinalizedOutbound, {}); + + expect(await t.query(api.lib.getOutboundStatus, { outboundId: stale })).toBeNull(); + expect((await t.query(api.lib.getOutboundStatus, { outboundId: fresh }))?.status).toBe("sent"); + expect((await t.query(api.lib.getOutboundStatus, { outboundId: pending }))?.status).toBe("pending"); + }); + + it("honours a shorter olderThan", async () => { + const t = setupTest(); + // A Response body can be read once; each send needs its own. + fetchSpy.mockImplementation(() => + jsonResponse({ message_id: "msg_1", thread_id: "thr_1" }), + ); + + const id = await send(t); + await t.finishAllScheduledFunctions(vi.runAllTimers); + vi.setSystemTime(Date.now() + DAY); + + await t.mutation(api.lib.cleanupFinalizedOutbound, { olderThan: DAY / 2 }); + + expect(await t.query(api.lib.getOutboundStatus, { outboundId: id })).toBeNull(); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index a3c4c3d..d46710e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,7 +5,7 @@ export default defineConfig({ environment: "edge-runtime", server: { deps: { inline: ["convex-test"] } }, include: ["src/**/*.test.ts", "scripts/**/*.test.ts"], - exclude: ["**/*.codegen.test.ts", "node_modules/**", "dist/**"], + exclude: ["node_modules/**", "dist/**"], typecheck: { enabled: false }, }, });