import { randomUUID } from "node:crypto"; import { describe, expect, it, vi } from "vitest"; import { Webhook } from "svix"; import { agentmailApi, AgentmailApiError, agentmailMessageSchema, emailText, emailReplyRecipients, isAutomaticEmail, isFilteredEmail, normalizeAgentmailEvent, verifyAgentmailWebhook, } from "../services/agentmail-api.js"; import { emailSendSchema } from "@paperclipai/shared"; import { buildRunnerApiCatalog } from "../services/native-runtime/runner-api-catalog.js"; const message = (extra = {}) => agentmailMessageSchema.parse({ inbox_id: "agent@agentmail.to", thread_id: "thread", message_id: "message", timestamp: new Date().toISOString(), ...extra, }); describe("AgentMail protocol boundary", () => { it("checks the visible inbox list without probing an uncreated address", async () => { const fetcher = vi.fn(async () => Response.json({ inboxes: [{ inbox_id: "Ralph@agentmail.to" }] })); await expect(agentmailApi("private-key", fetcher).checkAddress("ralph@agentmail.to")) .resolves.toEqual({ address: "ralph@agentmail.to", status: "taken" }); expect(fetcher).toHaveBeenCalledOnce(); expect(fetcher).toHaveBeenCalledWith("https://api.agentmail.to/v0/inboxes?limit=100", expect.objectContaining({ method: "GET", body: undefined })); }); it("never treats an unlisted inbox as proof that an address is available", async () => { const fetcher = vi.fn(async () => Response.json({ inboxes: [], next_page_token: "more-inboxes" })); await expect(agentmailApi("private-key", fetcher).checkAddress("ralph@agentmail.to")) .resolves.toEqual({ address: "ralph@agentmail.to", status: "unknown" }); }); it("can create the checked address and its access key when the provider caches missing inbox lookups", async () => { let created = false; let negativeLookupCached = false; const fetcher = vi.fn(async (url: string | URL | Request, init?: RequestInit) => { const path = new URL(String(url)).pathname; if (path === "/v0/inboxes" && init?.method === "GET") return Response.json({ inboxes: [] }); if (path === "/v0/inboxes" && init?.method === "POST") { created = true; return Response.json({ inbox_id: "ralph@agentmail.to" }); } if (path === "/v0/inboxes/ralph%40agentmail.to" && !created) negativeLookupCached = true; if (!created || negativeLookupCached) return Response.json({ code: "not_found" }, { status: 404 }); return Response.json({ api_key: "test-runtime-key", api_key_id: "runtime-id" }); }); const api = agentmailApi("test-account-key", fetcher); await expect(api.checkAddress("ralph@agentmail.to")).resolves.toMatchObject({ status: "unknown" }); const inbox = await api.createInbox({ username: "ralph" }); await expect(api.createInboxKey(inbox.inbox_id)).resolves.toMatchObject({ api_key_id: "runtime-id" }); expect(negativeLookupCached).toBe(false); }); it.each([401, 403, 404, 429, 503])("preserves lookup errors (%s) rather than claiming an address is taken or free", async status => { const fetcher = vi.fn(async () => Response.json({ code: "missing_permission" }, { status })); await expect(agentmailApi("private-key", fetcher).checkAddress("ralph@agentmail.to")) .rejects.toMatchObject({ status, operation: "list_inboxes" }); }); it("verifies the exact raw body and rejects forged or stale Svix signatures", () => { const secret = `whsec_${Buffer.from("a-test-secret-only").toString("base64")}`; const body = JSON.stringify({ event_type: "message.received", message: message(), }); const timestamp = new Date(); const id = randomUUID(); const headers = { "svix-id": id, "svix-timestamp": String(Math.floor(timestamp.getTime() / 1000)), "svix-signature": new Webhook(secret).sign(id, timestamp, body), }; expect(verifyAgentmailWebhook(Buffer.from(body), headers, secret)).toEqual( JSON.parse(body), ); expect(() => verifyAgentmailWebhook(Buffer.from(body + " "), headers, secret), ).toThrow(); expect(() => verifyAgentmailWebhook( Buffer.from(body), { ...headers, "svix-timestamp": "1" }, secret, ), ).toThrow(); }); it("normalizes both transports and keeps delivery receipts separate from incoming mail", () => { const m = { inbox_id: "inbox", message_id: "message" }; expect( normalizeAgentmailEvent({ event_type: "message.received", message: m }) ?.kind, ).toBe("message.received"); expect( normalizeAgentmailEvent({ type: "message_received", message: m })?.kind, ).toBe("message.received"); expect( normalizeAgentmailEvent({ type: "message_delivered", message: m })?.kind, ).toBe("message.delivered"); expect(normalizeAgentmailEvent({ type: "subscribed" })).toBeNull(); expect(() => normalizeAgentmailEvent({ event_type: "message.received", message: {} }), ).toThrow(); }); it.each([ ["message.sent", "send"], ["message.delivered", "delivery"], ["message.bounced", "bounce"], ["message.complained", "complaint"], ["message.rejected", "reject"], ])("admits the documented %s receipt envelope through either transport", (kind, field) => { for (const transport of [{ type: "event", event_type: kind }, { type: kind.replace(".", "_") }]) { expect(normalizeAgentmailEvent({ ...transport, event_id: "provider-event", [field]: { inbox_id: "inbox", thread_id: "thread", message_id: "sent-message" }, })).toEqual({ kind, inbox_id: "inbox", message_id: "sent-message", eventId: "provider-event" }); } }); it("prefers extracted text, strips HTML and recognizes provider filtering and auto-replies", () => { expect( emailText( message({ extracted_text: "New reply", text: "Quoted history" }), ), ).toBe("New reply"); expect( emailText( message({ html: '

Hello

', }), ), ).not.toContain("tracking.test"); expect( isAutomaticEmail( message({ headers: { "Auto-Submitted": "auto-replied" } }), ), ).toBe(true); expect( isAutomaticEmail(message({ headers: { "Auto-Submitted": "no" } })), ).toBe(false); for (const label of ["spam", "blocked", "unauthenticated"]) expect(isFilteredEmail(message({ labels: [label] }))).toBe(true); }); it("pins the API host, encodes message IDs and preserves the provider idempotency key", async () => { const fetcher = vi .fn() .mockResolvedValue( new Response( JSON.stringify({ message_id: "sent", thread_id: "thread" }), ), ); await agentmailApi("private-key", fetcher).send( "agent@agentmail.to", { text: "Reply", reply_all: false }, "stable-key", "", ); expect(fetcher).toHaveBeenCalledWith( "https://api.agentmail.to/v0/inboxes/agent%40agentmail.to/messages/%3Cmessage%40domain%3E/reply", expect.objectContaining({ redirect: "error", headers: expect.objectContaining({ "Idempotency-Key": "stable-key" }), }), ); }); it("redacts provider error bodies", async () => { const fetcher = vi .fn() .mockResolvedValue( new Response("private email and credentials", { status: 403 }), ); await expect(agentmailApi("private-key", fetcher).whoami()).rejects.toThrow( "AgentMail request failed (403)", ); }); it.each(["missing_permission", "limit_exceeded", "domain_not_verified"])( "keeps a bounded create-inbox diagnostic for %s without provider data", async (code) => { const privateValue = "private-address@example.test"; const fetcher = vi.fn().mockResolvedValue(new Response(JSON.stringify({ code, message: privateValue, fix: `Use credential ${privateValue}`, docs: `https://example.test/${privateValue}`, nested: { code: privateValue }, }), { status: 403 })); const error = await agentmailApi(privateValue, fetcher) .createInbox({ username: privateValue }).catch((error: unknown) => error); expect(error).toBeInstanceOf(AgentmailApiError); expect(error).toMatchObject({ status: 403, operation: "create_inbox", providerCode: code }); expect(String(error)).toBe(`Error: AgentMail request failed (403) [operation=create_inbox, code=${code}]`); expect(JSON.stringify(error)).not.toContain(privateValue); expect((error as Error).stack).not.toContain(privateValue); expect(fetcher).toHaveBeenCalledTimes(1); }, ); it.each([ "private email and credentials", JSON.stringify({ code: "private-address@example.test" }), JSON.stringify({ code: ["missing_permission"] }), JSON.stringify([{ code: "missing_permission" }]), JSON.stringify({ message: "Forbidden" }), JSON.stringify(null), ])("does not copy unrecognized provider bodies into diagnostics (%#)", async (body) => { const fetcher = vi.fn().mockResolvedValue(new Response(body, { status: 403 })); await expect(agentmailApi("private-key", fetcher).whoami()).rejects.toMatchObject({ status: 403, operation: "inspect_key", providerCode: "unknown", message: "AgentMail request failed (403) [operation=inspect_key, code=unknown]", }); }); it.each([ ["GET", "/inboxes?limit=100", "list_inboxes"], ["GET", "/inboxes/private%40example.test", "get_inbox"], ["GET", "/domains?limit=100", "list_domains"], ["GET", "/domains/private.example.test", "get_domain"], ["POST", "/inboxes/private%40example.test/api-keys", "create_inbox_key"], ["DELETE", "/inboxes/private%40example.test/api-keys/private-key", "delete_inbox_key"], ["POST", "/inboxes/private%40example.test/webhooks", "create_webhook"], ["DELETE", "/inboxes/private%40example.test/webhooks/private-hook", "delete_webhook"], ["GET", "/inboxes/private%40example.test/messages?page_token=private-token", "list_messages"], ["GET", "/inboxes/private%40example.test/messages/private-message", "get_message"], ["GET", "/inboxes/private%40example.test/threads/private-thread", "get_thread"], ["POST", "/inboxes/private%40example.test/messages/send", "send_message"], ["POST", "/inboxes/private%40example.test/messages/private-message/reply", "reply_message"], ["GET", "/inboxes/private%40example.test/messages/private-message/attachments/private-attachment", "get_attachment"], ["POST", "/private-path?key=private-key", "request"], ["PATCH", "/inboxes/private%40example.test", "request"], ])("uses a fixed operation for %s route %#", async (method, path, operation) => { const fetcher = vi.fn().mockResolvedValue(new Response(null, { status: 403 })); const error = await agentmailApi("private-key", fetcher).request(path, method).catch((error: unknown) => error); expect(error).toMatchObject({ operation, providerCode: "unknown" }); expect(String(error)).not.toContain("private"); expect(JSON.stringify(error)).not.toContain("private"); }); it("bounds the error body read and cancels the remaining stream", async () => { const cancel = vi.fn(); const response = new Response(new ReadableStream({ start(controller) { controller.enqueue(new TextEncoder().encode(JSON.stringify({ code: "missing_permission", message: "x".repeat(8192) }))); }, cancel, }), { status: 403 }); await expect(agentmailApi("private-key", vi.fn().mockResolvedValue(response)).whoami()) .rejects.toMatchObject({ status: 403, providerCode: "unknown" }); expect(cancel).toHaveBeenCalledOnce(); }); it("keeps the HTTP status when the error body stalls or cancellation fails", async () => { vi.useFakeTimers(); try { const cancel = vi.fn().mockRejectedValue(new Error("private provider error")); const response = new Response(new ReadableStream({ cancel }), { status: 403 }); const failure = expect(agentmailApi("private-key", vi.fn().mockResolvedValue(response)).whoami()) .rejects.toMatchObject({ status: 403, providerCode: "unknown" }); await vi.advanceTimersByTimeAsync(1000); await failure; expect(cancel).toHaveBeenCalledOnce(); } finally { vi.useRealTimers(); } }); it("keeps retry classification when reading an error body fails", async () => { const response = new Response(new ReadableStream({ start(controller) { controller.error(new Error("private provider error")); }, }), { status: 429, headers: { "retry-after": "9" } }); const fetcher = vi.fn().mockResolvedValue(response); await expect(agentmailApi("private-key", fetcher).send("private@example.test", {}, "private-key")) .rejects.toMatchObject({ status: 429, retryAfterMs: 9000, providerCode: "unknown", operation: "send_message" }); expect(fetcher).toHaveBeenCalledOnce(); }); it.each([403, 429])("preserves HTTP %s when the response body is already locked", async (status) => { const response = new Response(JSON.stringify({ code: "missing_permission" }), { status, headers: { "retry-after": "9" }, }); const owner = response.body!.getReader(); try { await expect(agentmailApi("private-key", vi.fn().mockResolvedValue(response)) .send("private@example.test", {}, "private-key")) .rejects.toMatchObject({ status, retryAfterMs: 9000, providerCode: "unknown", operation: "send_message" }); } finally { owner.releaseLock(); } }); it("constructs deliberate reply-all from visible recipients, excluding self and Bcc", () => { const envelope = { from: "Sender ", to: ["agent@agentmail.to", "visible@example.test"], cc: ["visible@example.test", "cc@example.test"], bcc: ["private@example.test"], subject: "Hello", }; expect(emailReplyRecipients(envelope, "agent@agentmail.to", false)).toEqual( { to: ["sender@example.test"], cc: [], bcc: [], reply_all: false }, ); expect(emailReplyRecipients(envelope, "agent@agentmail.to", true)).toEqual({ to: ["sender@example.test", "visible@example.test"], cc: ["cc@example.test"], bcc: [], reply_all: false, }); }); it("honors Reply-To for reply and reply-all without adding the forwarding sender or Bcc", () => { const envelope = { from: "Forwarder ", replyTo: ["Reply desk ", "agent@agentmail.to"], to: ["agent@agentmail.to", "visible@example.test"], cc: ["reply@example.test", "cc@example.test"], bcc: ["private@example.test"], subject: "Forwarded request", }; expect(emailReplyRecipients(envelope, "agent@agentmail.to", false)).toEqual({ to: ["reply@example.test"], cc: [], bcc: [], reply_all: false, }); expect(emailReplyRecipients(envelope, "agent@agentmail.to", true)).toEqual({ to: ["reply@example.test", "visible@example.test"], cc: ["cc@example.test"], bcc: [], reply_all: false, }); expect(emailReplyRecipients({ ...envelope, replyTo: [] }, "agent@agentmail.to", false).to) .toEqual(["forwarder@example.test"]); }); it("validates explicit new-message and reply envelopes, rejecting header injection and Bcc reuse", () => { const base = { endpointId: randomUUID(), idempotencyKey: randomUUID(), text: "Hello", }; expect( emailSendSchema.safeParse({ ...base, parentIssueId: randomUUID(), to: ["person@example.test"], subject: "Hi\r\nBcc: hidden@example.test", }).success, ).toBe(false); expect( emailSendSchema.safeParse({ ...base, conversationId: randomUUID(), replyToMessageId: "message", bcc: ["hidden@example.test"], }).success, ).toBe(false); expect( emailSendSchema.parse({ ...base, conversationId: randomUUID(), replyToMessageId: "message", }).replyAll, ).toBe(false); }); it("exposes explicit email actions in runtime API discovery and keeps credential setup board-only", () => { const operations = buildRunnerApiCatalog(); const send = operations.find(o => o.path === "/api/companies/{companyId}/email/send"); expect(send?.method).toBe("POST"); expect(send?.requestBody).toBeDefined(); expect(send?.responses).toHaveProperty("202"); const setup = operations.find(o => o.path === "/api/companies/{companyId}/email/inspect"); expect(JSON.stringify(setup?.authorization)).toContain("board"); const check = operations.find(o => o.path === "/api/companies/{companyId}/email/connections/{connectionId}/check-address"); expect(check?.requestBody).toBeDefined(); expect(JSON.stringify(check?.authorization)).toContain("board"); expect(check?.responses).toHaveProperty("429"); }); });