diff --git a/src/wrapper/Client.ts b/src/wrapper/Client.ts index c00aec39..892a0706 100644 --- a/src/wrapper/Client.ts +++ b/src/wrapper/Client.ts @@ -7,6 +7,7 @@ import { AgentMailClient as FernAgentMailClient } from "../Client.js"; import { type GetPaymentCredentials, WebsocketsClient } from "./WebsocketsClient.js"; import { getPaymentCredentials as getX402Credentials } from "./x402.js"; +import { InboxesClient } from "./idempotency.js"; import { type MppxClient, getPaymentCredentials as getMppCredentials } from "./mppx.js"; type SharedOptions = Omit; @@ -23,12 +24,22 @@ export declare namespace AgentMailClient { export class AgentMailClient extends FernAgentMailClient { protected declare _websockets: WebsocketsClient | undefined; + protected declare _inboxes: InboxesClient | undefined; private readonly _getPaymentCredentials: GetPaymentCredentials | undefined; public override get websockets(): WebsocketsClient { return (this._websockets ??= new WebsocketsClient(this._options, this._getPaymentCredentials)); } + /** + * Returns an InboxesClient whose `messages` / `drafts` clients auto-mint a + * fresh UUID4 `Idempotency-Key` for send / reply / replyAll / forward when + * the caller does not supply one (see src/wrapper/idempotency.ts). + */ + public override get inboxes(): InboxesClient { + return (this._inboxes ??= new InboxesClient(this._options)); + } + constructor(options: AgentMailClient.Options = {}) { if (options.x402) { const { x402, ...rest } = options; diff --git a/src/wrapper/idempotency.ts b/src/wrapper/idempotency.ts new file mode 100644 index 00000000..3f983a92 --- /dev/null +++ b/src/wrapper/idempotency.ts @@ -0,0 +1,129 @@ +/** + * Auto-minting of `Idempotency-Key` for the send endpoints. + * + * This lives in src/wrapper (the sanctioned custom-code area) rather than in + * the generated code or core fetcher: Fern's generator can only emit a typed + * pass-through for idempotency headers (`requestOptions.idempotencyKey`); + * generating a default VALUE is client behavior. Implementing it here as + * subclass overrides means no generated files are pinned in .fernignore. + * + * The generated `__send`-style methods merge `requestOptions.idempotencyKey` + * into the request headers ONCE, before calling `core.fetcher`, and retries + * happen INSIDE the fetcher reusing those headers — so defaulting the key at + * the method level is automatically retry-stable: every internal retry of one + * logical call carries the SAME key, while separate calls get fresh keys. + */ +import type * as AgentMail from "../api/index.js"; +import { InboxesClient as FernInboxesClient } from "../api/resources/inboxes/client/Client.js"; +import { DraftsClient as FernDraftsClient } from "../api/resources/inboxes/resources/drafts/client/Client.js"; +import { MessagesClient as FernMessagesClient } from "../api/resources/inboxes/resources/messages/client/Client.js"; +import type * as core from "../core/index.js"; + +/** + * Generate a RFC 4122 version 4 UUID using the environment-agnostic Web Crypto + * API that this SDK already targets (available in Node 19+, browsers, Deno, Bun, + * Cloudflare Workers, etc.). Falls back to a `getRandomValues`-based + * implementation when `crypto.randomUUID` is unavailable, and finally to + * `Math.random` as a last resort so the SDK never throws in an exotic runtime. + * + * No new npm dependencies are introduced. + */ +export function uuidv4(): string { + const cryptoObj: Crypto | undefined = + typeof globalThis !== "undefined" ? (globalThis.crypto as Crypto | undefined) : undefined; + + if (cryptoObj != null && typeof cryptoObj.randomUUID === "function") { + return cryptoObj.randomUUID(); + } + + const bytes = new Uint8Array(16); + if (cryptoObj != null && typeof cryptoObj.getRandomValues === "function") { + cryptoObj.getRandomValues(bytes); + } else { + for (let i = 0; i < bytes.length; i++) { + bytes[i] = Math.floor(Math.random() * 256); + } + } + + // Per RFC 4122 §4.4: set the version (4) and variant (10xx) bits. + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + + const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} + +/** + * Default `idempotencyKey` to a fresh UUID4 when the caller has not supplied + * one. Written as `requestOptions?.idempotencyKey ?? uuidv4()` (NOT a + * spread-over-default) so an explicit `idempotencyKey: undefined` still gets a + * minted key, while any caller-supplied value always wins. A caller-supplied + * raw `requestOptions.headers["Idempotency-Key"]` also wins, because the + * generated header merge applies `requestOptions.headers` last. + */ +function withDefaultIdempotencyKey( + requestOptions: T, +): NonNullable { + return { ...requestOptions, idempotencyKey: requestOptions?.idempotencyKey ?? uuidv4() } as NonNullable; +} + +export class MessagesClient extends FernMessagesClient { + public override send( + inbox_id: AgentMail.inboxes.InboxId, + request: AgentMail.SendMessageRequest, + requestOptions?: FernMessagesClient.IdempotentRequestOptions, + ): core.HttpResponsePromise { + return super.send(inbox_id, request, withDefaultIdempotencyKey(requestOptions)); + } + + public override reply( + inbox_id: AgentMail.inboxes.InboxId, + message_id: AgentMail.MessageId, + request: AgentMail.ReplyToMessageRequest, + requestOptions?: FernMessagesClient.IdempotentRequestOptions, + ): core.HttpResponsePromise { + return super.reply(inbox_id, message_id, request, withDefaultIdempotencyKey(requestOptions)); + } + + public override replyAll( + inbox_id: AgentMail.inboxes.InboxId, + message_id: AgentMail.MessageId, + request: AgentMail.ReplyAllMessageRequest, + requestOptions?: FernMessagesClient.IdempotentRequestOptions, + ): core.HttpResponsePromise { + return super.replyAll(inbox_id, message_id, request, withDefaultIdempotencyKey(requestOptions)); + } + + public override forward( + inbox_id: AgentMail.inboxes.InboxId, + message_id: AgentMail.MessageId, + request: AgentMail.SendMessageRequest, + requestOptions?: FernMessagesClient.IdempotentRequestOptions, + ): core.HttpResponsePromise { + return super.forward(inbox_id, message_id, request, withDefaultIdempotencyKey(requestOptions)); + } +} + +export class DraftsClient extends FernDraftsClient { + public override send( + inbox_id: AgentMail.inboxes.InboxId, + draft_id: AgentMail.DraftId, + request: AgentMail.UpdateMessageRequest, + requestOptions?: FernDraftsClient.IdempotentRequestOptions, + ): core.HttpResponsePromise { + return super.send(inbox_id, draft_id, request, withDefaultIdempotencyKey(requestOptions)); + } +} + +export class InboxesClient extends FernInboxesClient { + protected declare _messages: MessagesClient | undefined; + protected declare _drafts: DraftsClient | undefined; + + public override get messages(): MessagesClient { + return (this._messages ??= new MessagesClient(this._options)); + } + + public override get drafts(): DraftsClient { + return (this._drafts ??= new DraftsClient(this._options)); + } +} diff --git a/tests/unit/wrapper/idempotency.test.ts b/tests/unit/wrapper/idempotency.test.ts new file mode 100644 index 00000000..da0bb88a --- /dev/null +++ b/tests/unit/wrapper/idempotency.test.ts @@ -0,0 +1,160 @@ +import { AgentMailClient } from "../../../src/wrapper/Client"; + +const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +function sendMessageResponse(): Response { + return jsonResponse({ message_id: "msg_123", thread_id: "thread_123" }); +} + +/** Pull the Idempotency-Key out of the RequestInit a mocked fetch was called with. */ +function idempotencyKeyFromCall(call: unknown[]): string | null { + const init = call[1] as RequestInit; + return new Headers(init.headers).get("Idempotency-Key"); +} + +function makeClient(fetchMock: typeof fetch): AgentMailClient { + return new AgentMailClient({ apiKey: "am_us_test123", fetch: fetchMock }); +} + +describe("Idempotency-Key auto-minting (wrapper)", () => { + it("mints a valid UUID4 Idempotency-Key on messages.send with none supplied", async () => { + const fetchMock = vi.fn().mockResolvedValue(sendMessageResponse()); + const client = makeClient(fetchMock); + + await client.inboxes.messages.send("inbox_id", { text: "hi" }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const key = idempotencyKeyFromCall(fetchMock.mock.calls[0]); + expect(key).toMatch(UUID_V4); + }); + + it("uses a caller-supplied requestOptions.idempotencyKey verbatim", async () => { + const fetchMock = vi.fn().mockResolvedValue(sendMessageResponse()); + const client = makeClient(fetchMock); + + await client.inboxes.messages.send("inbox_id", { text: "hi" }, { idempotencyKey: "caller-key" }); + + const key = idempotencyKeyFromCall(fetchMock.mock.calls[0]); + expect(key).toBe("caller-key"); + }); + + it("mints when the caller passes an explicit idempotencyKey: undefined", async () => { + const fetchMock = vi.fn().mockResolvedValue(sendMessageResponse()); + const client = makeClient(fetchMock); + + await client.inboxes.messages.send("inbox_id", { text: "hi" }, { idempotencyKey: undefined }); + + const key = idempotencyKeyFromCall(fetchMock.mock.calls[0]); + expect(key).toMatch(UUID_V4); + }); + + it("lets a caller-supplied raw Idempotency-Key header win over the minted key", async () => { + const fetchMock = vi.fn().mockResolvedValue(sendMessageResponse()); + const client = makeClient(fetchMock); + + await client.inboxes.messages.send( + "inbox_id", + { text: "hi" }, + { headers: { "Idempotency-Key": "raw-header-key" } }, + ); + + const key = idempotencyKeyFromCall(fetchMock.mock.calls[0]); + expect(key).toBe("raw-header-key"); + }); + + it("carries the IDENTICAL minted key across internal retries (503 then 200)", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response("busy", { status: 503, headers: { "Retry-After": "1" } })) + .mockResolvedValueOnce(sendMessageResponse()); + const client = makeClient(fetchMock); + + await client.inboxes.messages.send("inbox_id", { text: "hi" }, { maxRetries: 1 }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + const first = idempotencyKeyFromCall(fetchMock.mock.calls[0]); + const second = idempotencyKeyFromCall(fetchMock.mock.calls[1]); + expect(first).toMatch(UUID_V4); + expect(second).toBe(first); + }, 15000); + + it("mints DIFFERENT keys for two separate calls", async () => { + const fetchMock = vi.fn(async () => sendMessageResponse()); + const client = makeClient(fetchMock); + + await client.inboxes.messages.send("inbox_id", { text: "one" }); + await client.inboxes.messages.send("inbox_id", { text: "two" }); + + const first = idempotencyKeyFromCall(fetchMock.mock.calls[0]); + const second = idempotencyKeyFromCall(fetchMock.mock.calls[1]); + expect(first).toMatch(UUID_V4); + expect(second).toMatch(UUID_V4); + expect(second).not.toBe(first); + }); + + it("mints on messages.reply", async () => { + const fetchMock = vi.fn().mockResolvedValue(sendMessageResponse()); + const client = makeClient(fetchMock); + + await client.inboxes.messages.reply("inbox_id", "message_id", { text: "hi" }); + + expect(idempotencyKeyFromCall(fetchMock.mock.calls[0])).toMatch(UUID_V4); + }); + + it("mints on messages.replyAll", async () => { + const fetchMock = vi.fn().mockResolvedValue(sendMessageResponse()); + const client = makeClient(fetchMock); + + await client.inboxes.messages.replyAll("inbox_id", "message_id", { text: "hi" }); + + expect(idempotencyKeyFromCall(fetchMock.mock.calls[0])).toMatch(UUID_V4); + }); + + it("mints on messages.forward", async () => { + const fetchMock = vi.fn().mockResolvedValue(sendMessageResponse()); + const client = makeClient(fetchMock); + + await client.inboxes.messages.forward("inbox_id", "message_id", { text: "hi" }); + + expect(idempotencyKeyFromCall(fetchMock.mock.calls[0])).toMatch(UUID_V4); + }); + + it("mints on drafts.send", async () => { + const fetchMock = vi.fn().mockResolvedValue(sendMessageResponse()); + const client = makeClient(fetchMock); + + await client.inboxes.drafts.send("inbox_id", "draft_id", {}); + + expect(idempotencyKeyFromCall(fetchMock.mock.calls[0])).toMatch(UUID_V4); + }); + + it("does NOT attach an Idempotency-Key to non-send methods", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse({ count: 0, messages: [] })) + .mockResolvedValueOnce( + jsonResponse({ + inbox_id: "inbox_id", + organization_id: "org_id", + display_name: "Test", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + }), + ); + const client = makeClient(fetchMock); + + await client.inboxes.messages.list("inbox_id"); + await client.inboxes.create({ username: "test" }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(idempotencyKeyFromCall(fetchMock.mock.calls[0])).toBeNull(); + expect(idempotencyKeyFromCall(fetchMock.mock.calls[1])).toBeNull(); + }); +});