Skip to content
Open
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
11 changes: 11 additions & 0 deletions src/wrapper/Client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<FernAgentMailClient.Options, "apiKey">;
Expand All @@ -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;
Expand Down
129 changes: 129 additions & 0 deletions src/wrapper/idempotency.ts
Original file line number Diff line number Diff line change
@@ -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<T extends { idempotencyKey?: string | undefined } | undefined>(
requestOptions: T,
): NonNullable<T> {
return { ...requestOptions, idempotencyKey: requestOptions?.idempotencyKey ?? uuidv4() } as NonNullable<T>;
}

export class MessagesClient extends FernMessagesClient {
public override send(
inbox_id: AgentMail.inboxes.InboxId,
request: AgentMail.SendMessageRequest,
requestOptions?: FernMessagesClient.IdempotentRequestOptions,
): core.HttpResponsePromise<AgentMail.SendMessageResponse> {
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<AgentMail.SendMessageResponse> {
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<AgentMail.SendMessageResponse> {
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<AgentMail.SendMessageResponse> {
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<AgentMail.SendMessageResponse> {
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));
}
}
160 changes: 160 additions & 0 deletions tests/unit/wrapper/idempotency.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading