From e07ed4ca10ed11f02f75c51b9e0c8aa098e29c82 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 19 Jun 2026 14:02:20 -0700 Subject: [PATCH 1/3] fix(icypeas): handle 200 validationErrors body as a BAD_INPUT verdict --- apps/sim/tools/icypeas-hosting.test.ts | 66 ++++++++++++++++++++++++++ apps/sim/tools/icypeas/find_email.ts | 18 +++++++ apps/sim/tools/icypeas/verify_email.ts | 19 +++++++- 3 files changed, 102 insertions(+), 1 deletion(-) diff --git a/apps/sim/tools/icypeas-hosting.test.ts b/apps/sim/tools/icypeas-hosting.test.ts index 288f2e773f8..c0294903b41 100644 --- a/apps/sim/tools/icypeas-hosting.test.ts +++ b/apps/sim/tools/icypeas-hosting.test.ts @@ -83,6 +83,72 @@ describe('Icypeas verify-email pricing', () => { }) }) +describe('Icypeas transformResponse validation errors', () => { + it('verify-email maps a 200 validationErrors body to a BAD_INPUT verdict', async () => { + const response = new Response( + JSON.stringify({ + success: false, + validationErrors: [ + { expected: 'email', type: 'required', field: 'email', message: 'validation_required' }, + ], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + + const result = await icypeasVerifyEmailTool.transformResponse!(response, { + apiKey: 'test-key', + email: 'support@stripe.com', + } as any) + + expect(result.success).toBe(true) + expect((result.output as any).status).toBe('BAD_INPUT') + expect((result.output as any).valid).toBe(false) + expect((result.output as any).email).toBe('support@stripe.com') + expect((result.output as any).searchId).toBeNull() + }) + + it('find-email maps a 200 validationErrors body to a BAD_INPUT verdict', async () => { + const response = new Response( + JSON.stringify({ + success: false, + validationErrors: [ + { + expected: 'string', + type: 'required', + field: 'domainOrCompany', + message: 'validation_required', + }, + ], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + + const result = await icypeasFindEmailTool.transformResponse!(response, { + apiKey: 'test-key', + domainOrCompany: 'stripe.com', + } as any) + + expect(result.success).toBe(true) + expect((result.output as any).status).toBe('BAD_INPUT') + expect((result.output as any).email).toBeNull() + expect((result.output as any).searchId).toBeNull() + }) + + it('verify-email still throws when a success body has no item _id', async () => { + const response = new Response(JSON.stringify({ success: true, item: {} }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + + await expect( + icypeasVerifyEmailTool.transformResponse!(response, { + apiKey: 'test-key', + email: 'jane@example.com', + } as any) + ).rejects.toThrow(/item _id/) + }) +}) + describe('Icypeas find-email postProcess poll', () => { it('polls the results endpoint until terminal status and returns the email', async () => { vi.useFakeTimers() diff --git a/apps/sim/tools/icypeas/find_email.ts b/apps/sim/tools/icypeas/find_email.ts index 89fe64ab83e..c8557d53c92 100644 --- a/apps/sim/tools/icypeas/find_email.ts +++ b/apps/sim/tools/icypeas/find_email.ts @@ -112,6 +112,24 @@ export const icypeasFindEmailTool: ToolConfig + // Icypeas returns HTTP 200 with { success: false, validationErrors: [...] } when + // it rejects the input up front (missing/invalid name or domain). There is no item + // to poll — this is a BAD_INPUT verdict, not a transport error. Surface it as a + // successful run with a null email so the enrichment cascade records the verdict + // instead of inflating the runner's error count. + if (json.success === false || Array.isArray(json.validationErrors)) { + return { + success: true, + output: { + searchId: null, + status: 'BAD_INPUT', + email: null, + firstname: null, + lastname: null, + item: json, + }, + } + } // Submit response: { success: true, item: { _id: '...', status: 'NONE', ... } } const item = (json.item as Record | undefined) ?? {} const searchId = (item._id as string | undefined) ?? null diff --git a/apps/sim/tools/icypeas/verify_email.ts b/apps/sim/tools/icypeas/verify_email.ts index 827a81fc766..eac80d0a9b6 100644 --- a/apps/sim/tools/icypeas/verify_email.ts +++ b/apps/sim/tools/icypeas/verify_email.ts @@ -99,12 +99,29 @@ export const icypeasVerifyEmailTool: ToolConfig< }), }, - transformResponse: async (response: Response) => { + transformResponse: async (response: Response, params?: IcypeasVerifyEmailParams) => { if (!response.ok) { const errorText = await response.text() throw new Error(`Icypeas API error: ${response.status} - ${errorText}`) } const json = (await response.json()) as Record + // Icypeas returns HTTP 200 with { success: false, validationErrors: [...] } when + // it rejects the input up front (bad format, role-based address, etc.). There is + // no item to poll — this is a BAD_INPUT verdict, not a transport error. Surface it + // as a successful run with valid=false so the enrichment cascade records the + // verdict instead of inflating the runner's error count. + if (json.success === false || Array.isArray(json.validationErrors)) { + return { + success: true, + output: { + searchId: null, + status: 'BAD_INPUT', + email: params?.email ?? null, + valid: false, + item: json, + }, + } + } // Submit response: { success: true, item: { _id: '...', status: 'NONE', ... } } const item = (json.item as Record | undefined) ?? {} const searchId = (item._id as string | undefined) ?? null From 580ccdee78d26529d53334f803aaff6ac8a0d9a4 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 19 Jun 2026 14:25:23 -0700 Subject: [PATCH 2/3] fix(icypeas): skip polling on BAD_INPUT and only treat validationErrors as a verdict --- apps/sim/tools/icypeas-hosting.test.ts | 65 ++++++++++++++++++++++++++ apps/sim/tools/icypeas/find_email.ts | 17 ++++--- apps/sim/tools/icypeas/verify_email.ts | 17 ++++--- 3 files changed, 85 insertions(+), 14 deletions(-) diff --git a/apps/sim/tools/icypeas-hosting.test.ts b/apps/sim/tools/icypeas-hosting.test.ts index c0294903b41..d69ead97c73 100644 --- a/apps/sim/tools/icypeas-hosting.test.ts +++ b/apps/sim/tools/icypeas-hosting.test.ts @@ -147,6 +147,71 @@ describe('Icypeas transformResponse validation errors', () => { } as any) ).rejects.toThrow(/item _id/) }) + + it('verify-email throws on a bare success:false body without validationErrors', async () => { + const response = new Response(JSON.stringify({ success: false }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + + await expect( + icypeasVerifyEmailTool.transformResponse!(response, { + apiKey: 'test-key', + email: 'jane@example.com', + } as any) + ).rejects.toThrow(/item _id/) + }) +}) + +describe('Icypeas postProcess on BAD_INPUT', () => { + it('verify-email returns the BAD_INPUT verdict without polling or throwing', async () => { + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + + const result = await icypeasVerifyEmailTool.postProcess!( + { + success: true as const, + output: { + searchId: null, + status: 'BAD_INPUT', + email: 'support@stripe.com', + valid: false, + item: {}, + }, + } as any, + { apiKey: 'test-key', email: 'support@stripe.com' } as any, + vi.fn() + ) + + expect(fetchMock).not.toHaveBeenCalled() + expect(result.success).toBe(true) + expect((result.output as any).status).toBe('BAD_INPUT') + }) + + it('find-email returns the BAD_INPUT verdict without polling or throwing', async () => { + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + + const result = await icypeasFindEmailTool.postProcess!( + { + success: true as const, + output: { + searchId: null, + status: 'BAD_INPUT', + email: null, + firstname: null, + lastname: null, + item: {}, + }, + } as any, + { apiKey: 'test-key', domainOrCompany: 'stripe.com' } as any, + vi.fn() + ) + + expect(fetchMock).not.toHaveBeenCalled() + expect(result.success).toBe(true) + expect((result.output as any).status).toBe('BAD_INPUT') + }) }) describe('Icypeas find-email postProcess poll', () => { diff --git a/apps/sim/tools/icypeas/find_email.ts b/apps/sim/tools/icypeas/find_email.ts index c8557d53c92..bcf1bef0ffd 100644 --- a/apps/sim/tools/icypeas/find_email.ts +++ b/apps/sim/tools/icypeas/find_email.ts @@ -116,8 +116,10 @@ export const icypeasFindEmailTool: ToolConfig 0) { return { success: true, output: { @@ -145,16 +147,17 @@ export const icypeasFindEmailTool: ToolConfig { if (!result.success) return result + // If already terminal, return immediately — a BAD_INPUT verdict has no searchId + // to poll, so this must run before the searchId guard. + if (result.output.status && TERMINAL_STATUSES.has(result.output.status)) { + return result + } + const searchId = result.output.searchId if (!searchId) { throw new Error('Icypeas find-email result is missing a searchId') } - // If already terminal (unlikely on submit but defensive), return immediately. - if (result.output.status && TERMINAL_STATUSES.has(result.output.status)) { - return result - } - let elapsed = 0 while (elapsed < MAX_POLL_TIME_MS) { await sleep(POLL_INTERVAL_MS) diff --git a/apps/sim/tools/icypeas/verify_email.ts b/apps/sim/tools/icypeas/verify_email.ts index eac80d0a9b6..1ea79d302ed 100644 --- a/apps/sim/tools/icypeas/verify_email.ts +++ b/apps/sim/tools/icypeas/verify_email.ts @@ -109,8 +109,10 @@ export const icypeasVerifyEmailTool: ToolConfig< // it rejects the input up front (bad format, role-based address, etc.). There is // no item to poll — this is a BAD_INPUT verdict, not a transport error. Surface it // as a successful run with valid=false so the enrichment cascade records the - // verdict instead of inflating the runner's error count. - if (json.success === false || Array.isArray(json.validationErrors)) { + // verdict instead of inflating the runner's error count. Key on a non-empty + // validationErrors array specifically: a bare { success: false } is an unexpected + // failure shape that should fall through and throw, not be masked as BAD_INPUT. + if (Array.isArray(json.validationErrors) && json.validationErrors.length > 0) { return { success: true, output: { @@ -137,16 +139,17 @@ export const icypeasVerifyEmailTool: ToolConfig< postProcess: async (result, params) => { if (!result.success) return result + // If already terminal, return immediately — a BAD_INPUT verdict has no searchId + // to poll, so this must run before the searchId guard. + if (result.output.status && TERMINAL_STATUSES.has(result.output.status)) { + return result + } + const searchId = result.output.searchId if (!searchId) { throw new Error('Icypeas verify-email result is missing a searchId') } - // If already terminal, return immediately. - if (result.output.status && TERMINAL_STATUSES.has(result.output.status)) { - return result - } - let elapsed = 0 while (elapsed < MAX_POLL_TIME_MS) { await sleep(POLL_INTERVAL_MS) From 139f85b967a90116f457506aa80150da90804560 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 19 Jun 2026 14:43:45 -0700 Subject: [PATCH 3/3] fix(icypeas): surface submit validationErrors as a descriptive error, not a fake verdict --- apps/sim/tools/icypeas-hosting.test.ts | 128 +++++++------------------ apps/sim/tools/icypeas/find_email.ts | 42 ++++---- apps/sim/tools/icypeas/verify_email.ts | 44 ++++----- 3 files changed, 68 insertions(+), 146 deletions(-) diff --git a/apps/sim/tools/icypeas-hosting.test.ts b/apps/sim/tools/icypeas-hosting.test.ts index d69ead97c73..554ccbb9c67 100644 --- a/apps/sim/tools/icypeas-hosting.test.ts +++ b/apps/sim/tools/icypeas-hosting.test.ts @@ -84,133 +84,69 @@ describe('Icypeas verify-email pricing', () => { }) describe('Icypeas transformResponse validation errors', () => { - it('verify-email maps a 200 validationErrors body to a BAD_INPUT verdict', async () => { + it('verify-email throws the human-readable reason on a 200 validationErrors body', async () => { const response = new Response( JSON.stringify({ success: false, validationErrors: [ - { expected: 'email', type: 'required', field: 'email', message: 'validation_required' }, + { + field: 'type', + message: 'insufficient_credits', + humanReadableMessage: 'Insufficient credits to run the search', + type: 'InsufficientCredits', + }, ], }), { status: 200, headers: { 'Content-Type': 'application/json' } } ) - const result = await icypeasVerifyEmailTool.transformResponse!(response, { - apiKey: 'test-key', - email: 'support@stripe.com', - } as any) - - expect(result.success).toBe(true) - expect((result.output as any).status).toBe('BAD_INPUT') - expect((result.output as any).valid).toBe(false) - expect((result.output as any).email).toBe('support@stripe.com') - expect((result.output as any).searchId).toBeNull() + await expect(icypeasVerifyEmailTool.transformResponse!(response)).rejects.toThrow( + /Insufficient credits to run the search/ + ) }) - it('find-email maps a 200 validationErrors body to a BAD_INPUT verdict', async () => { + it('find-email throws the human-readable reason on a 200 validationErrors body', async () => { const response = new Response( JSON.stringify({ success: false, validationErrors: [ { - expected: 'string', - type: 'required', - field: 'domainOrCompany', - message: 'validation_required', + field: 'type', + message: 'insufficient_credits', + humanReadableMessage: 'Insufficient credits to run the search', + type: 'InsufficientCredits', }, ], }), { status: 200, headers: { 'Content-Type': 'application/json' } } ) - const result = await icypeasFindEmailTool.transformResponse!(response, { - apiKey: 'test-key', - domainOrCompany: 'stripe.com', - } as any) - - expect(result.success).toBe(true) - expect((result.output as any).status).toBe('BAD_INPUT') - expect((result.output as any).email).toBeNull() - expect((result.output as any).searchId).toBeNull() + await expect(icypeasFindEmailTool.transformResponse!(response)).rejects.toThrow( + /Insufficient credits to run the search/ + ) }) - it('verify-email still throws when a success body has no item _id', async () => { - const response = new Response(JSON.stringify({ success: true, item: {} }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }) + it('verify-email falls back to message when humanReadableMessage is absent', async () => { + const response = new Response( + JSON.stringify({ + success: false, + validationErrors: [{ field: 'email', message: 'validation_required' }], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) - await expect( - icypeasVerifyEmailTool.transformResponse!(response, { - apiKey: 'test-key', - email: 'jane@example.com', - } as any) - ).rejects.toThrow(/item _id/) + await expect(icypeasVerifyEmailTool.transformResponse!(response)).rejects.toThrow( + /validation_required/ + ) }) - it('verify-email throws on a bare success:false body without validationErrors', async () => { - const response = new Response(JSON.stringify({ success: false }), { + it('verify-email still throws when a success body has no item _id', async () => { + const response = new Response(JSON.stringify({ success: true, item: {} }), { status: 200, headers: { 'Content-Type': 'application/json' }, }) - await expect( - icypeasVerifyEmailTool.transformResponse!(response, { - apiKey: 'test-key', - email: 'jane@example.com', - } as any) - ).rejects.toThrow(/item _id/) - }) -}) - -describe('Icypeas postProcess on BAD_INPUT', () => { - it('verify-email returns the BAD_INPUT verdict without polling or throwing', async () => { - const fetchMock = vi.fn() - vi.stubGlobal('fetch', fetchMock) - - const result = await icypeasVerifyEmailTool.postProcess!( - { - success: true as const, - output: { - searchId: null, - status: 'BAD_INPUT', - email: 'support@stripe.com', - valid: false, - item: {}, - }, - } as any, - { apiKey: 'test-key', email: 'support@stripe.com' } as any, - vi.fn() - ) - - expect(fetchMock).not.toHaveBeenCalled() - expect(result.success).toBe(true) - expect((result.output as any).status).toBe('BAD_INPUT') - }) - - it('find-email returns the BAD_INPUT verdict without polling or throwing', async () => { - const fetchMock = vi.fn() - vi.stubGlobal('fetch', fetchMock) - - const result = await icypeasFindEmailTool.postProcess!( - { - success: true as const, - output: { - searchId: null, - status: 'BAD_INPUT', - email: null, - firstname: null, - lastname: null, - item: {}, - }, - } as any, - { apiKey: 'test-key', domainOrCompany: 'stripe.com' } as any, - vi.fn() - ) - - expect(fetchMock).not.toHaveBeenCalled() - expect(result.success).toBe(true) - expect((result.output as any).status).toBe('BAD_INPUT') + await expect(icypeasVerifyEmailTool.transformResponse!(response)).rejects.toThrow(/item _id/) }) }) diff --git a/apps/sim/tools/icypeas/find_email.ts b/apps/sim/tools/icypeas/find_email.ts index bcf1bef0ffd..ffc69c6c0f7 100644 --- a/apps/sim/tools/icypeas/find_email.ts +++ b/apps/sim/tools/icypeas/find_email.ts @@ -112,25 +112,18 @@ export const icypeasFindEmailTool: ToolConfig - // Icypeas returns HTTP 200 with { success: false, validationErrors: [...] } when - // it rejects the input up front (missing/invalid name or domain). There is no item - // to poll — this is a BAD_INPUT verdict, not a transport error. Surface it as a - // successful run with a null email so the enrichment cascade records the verdict - // instead of inflating the runner's error count. Key on a non-empty validationErrors - // array specifically: a bare { success: false } is an unexpected failure shape that - // should fall through and throw, not be masked as BAD_INPUT. - if (Array.isArray(json.validationErrors) && json.validationErrors.length > 0) { - return { - success: true, - output: { - searchId: null, - status: 'BAD_INPUT', - email: null, - firstname: null, - lastname: null, - item: json, - }, - } + // Icypeas signals submit-time failures as HTTP 200 { success: false, validationErrors: [...] } + // — malformed input, insufficient credits, rate limits, etc. None of these are a + // search verdict (those only come from polling), so fail fast with the human-readable + // reason rather than a cryptic missing-_id error or a fabricated empty result. + const validationErrors = json.validationErrors + if (Array.isArray(validationErrors) && validationErrors.length > 0) { + const first = validationErrors[0] as Record | undefined + const reason = + (first?.humanReadableMessage as string | undefined) ?? + (first?.message as string | undefined) ?? + 'validation error' + throw new Error(`Icypeas email-search rejected the request: ${reason}`) } // Submit response: { success: true, item: { _id: '...', status: 'NONE', ... } } const item = (json.item as Record | undefined) ?? {} @@ -147,17 +140,16 @@ export const icypeasFindEmailTool: ToolConfig { if (!result.success) return result - // If already terminal, return immediately — a BAD_INPUT verdict has no searchId - // to poll, so this must run before the searchId guard. - if (result.output.status && TERMINAL_STATUSES.has(result.output.status)) { - return result - } - const searchId = result.output.searchId if (!searchId) { throw new Error('Icypeas find-email result is missing a searchId') } + // If already terminal (unlikely on submit but defensive), return immediately. + if (result.output.status && TERMINAL_STATUSES.has(result.output.status)) { + return result + } + let elapsed = 0 while (elapsed < MAX_POLL_TIME_MS) { await sleep(POLL_INTERVAL_MS) diff --git a/apps/sim/tools/icypeas/verify_email.ts b/apps/sim/tools/icypeas/verify_email.ts index 1ea79d302ed..9b38381cf9b 100644 --- a/apps/sim/tools/icypeas/verify_email.ts +++ b/apps/sim/tools/icypeas/verify_email.ts @@ -99,30 +99,25 @@ export const icypeasVerifyEmailTool: ToolConfig< }), }, - transformResponse: async (response: Response, params?: IcypeasVerifyEmailParams) => { + transformResponse: async (response: Response) => { if (!response.ok) { const errorText = await response.text() throw new Error(`Icypeas API error: ${response.status} - ${errorText}`) } const json = (await response.json()) as Record - // Icypeas returns HTTP 200 with { success: false, validationErrors: [...] } when - // it rejects the input up front (bad format, role-based address, etc.). There is - // no item to poll — this is a BAD_INPUT verdict, not a transport error. Surface it - // as a successful run with valid=false so the enrichment cascade records the - // verdict instead of inflating the runner's error count. Key on a non-empty - // validationErrors array specifically: a bare { success: false } is an unexpected - // failure shape that should fall through and throw, not be masked as BAD_INPUT. - if (Array.isArray(json.validationErrors) && json.validationErrors.length > 0) { - return { - success: true, - output: { - searchId: null, - status: 'BAD_INPUT', - email: params?.email ?? null, - valid: false, - item: json, - }, - } + // Icypeas signals submit-time failures as HTTP 200 { success: false, validationErrors: [...] } + // — malformed input, insufficient credits, rate limits, etc. None of these are a + // deliverability verdict (those only come from polling), so fail fast with the + // human-readable reason rather than a cryptic missing-_id error or a fabricated + // valid=false result. + const validationErrors = json.validationErrors + if (Array.isArray(validationErrors) && validationErrors.length > 0) { + const first = validationErrors[0] as Record | undefined + const reason = + (first?.humanReadableMessage as string | undefined) ?? + (first?.message as string | undefined) ?? + 'validation error' + throw new Error(`Icypeas email-verification rejected the request: ${reason}`) } // Submit response: { success: true, item: { _id: '...', status: 'NONE', ... } } const item = (json.item as Record | undefined) ?? {} @@ -139,17 +134,16 @@ export const icypeasVerifyEmailTool: ToolConfig< postProcess: async (result, params) => { if (!result.success) return result - // If already terminal, return immediately — a BAD_INPUT verdict has no searchId - // to poll, so this must run before the searchId guard. - if (result.output.status && TERMINAL_STATUSES.has(result.output.status)) { - return result - } - const searchId = result.output.searchId if (!searchId) { throw new Error('Icypeas verify-email result is missing a searchId') } + // If already terminal, return immediately. + if (result.output.status && TERMINAL_STATUSES.has(result.output.status)) { + return result + } + let elapsed = 0 while (elapsed < MAX_POLL_TIME_MS) { await sleep(POLL_INTERVAL_MS)