From ff30d5083b18848edc9d342b07eabb3d2c9e6298 Mon Sep 17 00:00:00 2001 From: Jack <72348727+Jack-GitHub12@users.noreply.github.com> Date: Thu, 9 Apr 2026 18:06:50 -0500 Subject: [PATCH] feat: add Hunter.io integration with 3 nodes Add Hunter.io email finder and verifier integration (issue #14): - hunterDomainSearchNode: search emails by domain with filters - hunterEmailFinderNode: find email for person at domain - hunterEmailVerifierNode: verify email deliverability - hunterCredential: API key auth via query parameter - 47 tests covering all operations, errors, and edge cases Spec: tasks/hunter-io-integration/spec.md --- packages/core/src/types/node.ts | 4 + packages/nodes/src/index.ts | 25 + .../hunter/__tests__/hunter.test.ts | 917 ++++++++++++++++++ .../src/integrations/hunter/credentials.ts | 21 + .../hunter/hunter-domain-search.ts | 137 +++ .../hunter/hunter-email-finder.ts | 88 ++ .../hunter/hunter-email-verifier.ts | 98 ++ .../nodes/src/integrations/hunter/index.ts | 25 + .../nodes/src/integrations/hunter/schemas.ts | 84 ++ packages/nodes/src/integrations/index.ts | 20 + 10 files changed, 1419 insertions(+) create mode 100644 packages/nodes/src/integrations/hunter/__tests__/hunter.test.ts create mode 100644 packages/nodes/src/integrations/hunter/credentials.ts create mode 100644 packages/nodes/src/integrations/hunter/hunter-domain-search.ts create mode 100644 packages/nodes/src/integrations/hunter/hunter-email-finder.ts create mode 100644 packages/nodes/src/integrations/hunter/hunter-email-verifier.ts create mode 100644 packages/nodes/src/integrations/hunter/index.ts create mode 100644 packages/nodes/src/integrations/hunter/schemas.ts diff --git a/packages/core/src/types/node.ts b/packages/core/src/types/node.ts index 45327ef..2443e1f 100644 --- a/packages/core/src/types/node.ts +++ b/packages/core/src/types/node.ts @@ -84,6 +84,10 @@ export interface NodeCredentials { refreshToken: string expiresAt: number } + /** Hunter.io API credentials */ + hunter?: { + apiKey: string + } } /** diff --git a/packages/nodes/src/index.ts b/packages/nodes/src/index.ts index 97e62dc..9a3c00f 100644 --- a/packages/nodes/src/index.ts +++ b/packages/nodes/src/index.ts @@ -196,6 +196,17 @@ export { readOutputSchema, updateInputSchema, updateOutputSchema, + // Hunter + hunterDomainSearchNode, + HunterDomainSearchInputSchema, + HunterDomainSearchOutputSchema, + hunterEmailFinderNode, + HunterEmailFinderInputSchema, + HunterEmailFinderOutputSchema, + hunterEmailVerifierNode, + HunterEmailVerifierInputSchema, + HunterEmailVerifierOutputSchema, + hunterCredential, } from './integrations/index.js' export type { @@ -276,6 +287,13 @@ export type { ReadOutput, UpdateInput, UpdateOutput, + // Hunter + HunterDomainSearchInput, + HunterDomainSearchOutput, + HunterEmailFinderInput, + HunterEmailFinderOutput, + HunterEmailVerifierInput, + HunterEmailVerifierOutput, } from './integrations/index.js' // AI nodes @@ -348,6 +366,9 @@ import { googleSheetsClearNode, googleSheetsReadNode, googleSheetsUpdateNode, + hunterDomainSearchNode as hunterDomainSearchNode_, + hunterEmailFinderNode as hunterEmailFinderNode_, + hunterEmailVerifierNode as hunterEmailVerifierNode_, } from './integrations/index.js' import { socialKeywordGeneratorNode, @@ -407,6 +428,10 @@ export const builtInNodes = [ googleSheetsClearNode, googleSheetsReadNode, googleSheetsUpdateNode, + // Hunter + hunterDomainSearchNode_, + hunterEmailFinderNode_, + hunterEmailVerifierNode_, // AI socialKeywordGeneratorNode, draftEmailsNode, diff --git a/packages/nodes/src/integrations/hunter/__tests__/hunter.test.ts b/packages/nodes/src/integrations/hunter/__tests__/hunter.test.ts new file mode 100644 index 0000000..b096342 --- /dev/null +++ b/packages/nodes/src/integrations/hunter/__tests__/hunter.test.ts @@ -0,0 +1,917 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + hunterCredential, + hunterDomainSearchNode, + hunterEmailFinderNode, + hunterEmailVerifierNode, + HunterDomainSearchInputSchema, + HunterEmailFinderInputSchema, + HunterEmailVerifierInputSchema, +} from '../index.js'; + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +const baseContext = { + userId: 'u1', + workflowExecutionId: 'w1', + variables: {}, + resolveNestedPath: () => undefined, +}; + +const authedContext = { + ...baseContext, + credentials: { hunter: { apiKey: 'test-hunter-key' } }, +}; + +// ─── Credential metadata ─────────────────────────────────────────────────── + +describe('hunter credentials', () => { + it('defines apiKey credential metadata', () => { + expect(hunterCredential.name).toBe('hunter'); + expect(hunterCredential.type).toBe('apiKey'); + expect(hunterCredential.displayName).toBe('Hunter.io API Key'); + }); + + it('uses query param authentication', () => { + expect(hunterCredential.authenticate.type).toBe('query'); + expect(hunterCredential.authenticate.properties.api_key).toBe('{{apiKey}}'); + }); + + it('includes documentation URL', () => { + expect(hunterCredential.documentationUrl).toBe('https://hunter.io/api-documentation/v2'); + }); +}); + +// ─── Schema validation ───────────────────────────────────────────────────── + +describe('hunter schemas', () => { + // Domain Search + it('validates domain search input with required domain', () => { + const result = HunterDomainSearchInputSchema.safeParse({ domain: 'example.com' }); + expect(result.success).toBe(true); + }); + + it('rejects domain search input missing domain', () => { + const result = HunterDomainSearchInputSchema.safeParse({}); + expect(result.success).toBe(false); + }); + + it('rejects domain search input with empty domain', () => { + const result = HunterDomainSearchInputSchema.safeParse({ domain: '' }); + expect(result.success).toBe(false); + }); + + it('validates domain search with optional filters', () => { + const result = HunterDomainSearchInputSchema.safeParse({ + domain: 'example.com', + limit: 50, + type: 'personal', + seniority: ['senior', 'executive'], + department: ['sales', 'marketing'], + }); + expect(result.success).toBe(true); + }); + + it('rejects domain search with invalid seniority value', () => { + const result = HunterDomainSearchInputSchema.safeParse({ + domain: 'example.com', + seniority: ['intern'], + }); + expect(result.success).toBe(false); + }); + + it('rejects domain search with limit out of range (0)', () => { + const result = HunterDomainSearchInputSchema.safeParse({ + domain: 'example.com', + limit: 0, + }); + expect(result.success).toBe(false); + }); + + it('rejects domain search with limit out of range (101)', () => { + const result = HunterDomainSearchInputSchema.safeParse({ + domain: 'example.com', + limit: 101, + }); + expect(result.success).toBe(false); + }); + + // Email Finder + it('validates email finder input with all required fields', () => { + const result = HunterEmailFinderInputSchema.safeParse({ + domain: 'example.com', + firstName: 'John', + lastName: 'Doe', + }); + expect(result.success).toBe(true); + }); + + it('rejects email finder input missing firstName', () => { + const result = HunterEmailFinderInputSchema.safeParse({ + domain: 'example.com', + lastName: 'Doe', + }); + expect(result.success).toBe(false); + }); + + it('rejects email finder input with empty strings', () => { + const result = HunterEmailFinderInputSchema.safeParse({ + domain: '', + firstName: '', + lastName: '', + }); + expect(result.success).toBe(false); + }); + + // Email Verifier + it('validates email verifier input', () => { + const result = HunterEmailVerifierInputSchema.safeParse({ email: 'test@example.com' }); + expect(result.success).toBe(true); + }); + + it('rejects email verifier input missing email', () => { + const result = HunterEmailVerifierInputSchema.safeParse({}); + expect(result.success).toBe(false); + }); + + it('rejects email verifier input with empty email', () => { + const result = HunterEmailVerifierInputSchema.safeParse({ email: '' }); + expect(result.success).toBe(false); + }); +}); + +// ─── hunter_domain_search ────────────────────────────────────────────────── + +describe('hunter_domain_search', () => { + const mockDomainSearchResponse = { + data: { + emails: [ + { + value: 'john@example.com', + type: 'personal', + confidence: 95, + first_name: 'John', + last_name: 'Doe', + position: 'CEO', + department: 'executive', + sources: [ + { + domain: 'example.com', + uri: 'https://example.com/about', + extracted_on: '2024-01-01', + last_seen_on: '2024-06-01', + still_on_page: true, + }, + ], + }, + { + value: 'info@example.com', + type: 'generic', + confidence: 80, + first_name: null, + last_name: null, + position: null, + department: null, + sources: [], + }, + ], + meta: { results: 2, limit: 10, offset: 0 }, + }, + }; + + it('fails when API key is missing', async () => { + const result = await hunterDomainSearchNode.executor( + { domain: 'example.com' }, + baseContext + ); + expect(result.success).toBe(false); + expect(result.error).toContain('hunter.apiKey'); + }); + + it('searches domain and returns emails', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify(mockDomainSearchResponse), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ); + vi.stubGlobal('fetch', fetchMock); + + const result = await hunterDomainSearchNode.executor( + { domain: 'example.com' }, + authedContext + ); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.output.emails).toHaveLength(2); + expect(result.output.emails[0]?.value).toBe('john@example.com'); + expect(result.output.emails[0]?.firstName).toBe('John'); + expect(result.output.emails[0]?.lastName).toBe('Doe'); + expect(result.output.emails[0]?.position).toBe('CEO'); + expect(result.output.emails[0]?.confidence).toBe(95); + expect(result.output.meta.results).toBe(2); + expect(result.output.meta.limit).toBe(10); + expect(result.output.meta.offset).toBe(0); + } + }); + + it('sends api_key as query parameter in URL', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify(mockDomainSearchResponse), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ); + vi.stubGlobal('fetch', fetchMock); + + await hunterDomainSearchNode.executor( + { domain: 'example.com' }, + authedContext + ); + + const [url] = fetchMock.mock.calls[0] as [string]; + const params = new URLSearchParams(url.split('?')[1]); + expect(params.get('api_key')).toBe('test-hunter-key'); + }); + + it('sends domain as query parameter', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify(mockDomainSearchResponse), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ); + vi.stubGlobal('fetch', fetchMock); + + await hunterDomainSearchNode.executor( + { domain: 'stripe.com' }, + authedContext + ); + + const [url] = fetchMock.mock.calls[0] as [string]; + const params = new URLSearchParams(url.split('?')[1]); + expect(params.get('domain')).toBe('stripe.com'); + }); + + it('sends optional filters as query params', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify(mockDomainSearchResponse), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ); + vi.stubGlobal('fetch', fetchMock); + + await hunterDomainSearchNode.executor( + { + domain: 'example.com', + limit: 50, + type: 'personal', + seniority: ['senior', 'executive'], + department: ['sales', 'marketing'], + }, + authedContext + ); + + const [url] = fetchMock.mock.calls[0] as [string]; + const params = new URLSearchParams(url.split('?')[1]); + expect(params.get('limit')).toBe('50'); + expect(params.get('type')).toBe('personal'); + expect(params.get('seniority')).toBe('senior,executive'); + expect(params.get('department')).toBe('sales,marketing'); + }); + + it('maps snake_case response to camelCase output', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify(mockDomainSearchResponse), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ); + vi.stubGlobal('fetch', fetchMock); + + const result = await hunterDomainSearchNode.executor( + { domain: 'example.com' }, + authedContext + ); + + expect(result.success).toBe(true); + if (result.success) { + const email = result.output.emails[0]!; + expect(email.firstName).toBe('John'); + expect(email.lastName).toBe('Doe'); + expect(email.sources[0]?.extractedOn).toBe('2024-01-01'); + expect(email.sources[0]?.lastSeenOn).toBe('2024-06-01'); + expect(email.sources[0]?.stillOnPage).toBe(true); + } + }); + + it('returns error on 401', async () => { + const fetchMock = vi.fn().mockRejectedValue( + Object.assign(new Error('Authentication error: 401 Unauthorized'), { + name: 'FetchRetryError', + status: 401, + }) + ); + vi.stubGlobal('fetch', fetchMock); + + const result = await hunterDomainSearchNode.executor( + { domain: 'example.com' }, + authedContext + ); + + expect(result.success).toBe(false); + expect(result.error).toContain('401'); + }); + + it('returns error on 429', async () => { + const fetchMock = vi.fn().mockRejectedValue( + Object.assign(new Error('Rate limit exceeded after 3 attempts'), { + name: 'FetchRetryError', + status: 429, + }) + ); + vi.stubGlobal('fetch', fetchMock); + + const result = await hunterDomainSearchNode.executor( + { domain: 'example.com' }, + authedContext + ); + + expect(result.success).toBe(false); + expect(result.error).toContain('Rate limit'); + }); + + it('returns error on 5xx', async () => { + const fetchMock = vi.fn().mockRejectedValue( + Object.assign(new Error('Server error: 500 Internal Server Error'), { + name: 'FetchRetryError', + status: 500, + }) + ); + vi.stubGlobal('fetch', fetchMock); + + const result = await hunterDomainSearchNode.executor( + { domain: 'example.com' }, + authedContext + ); + + expect(result.success).toBe(false); + expect(result.error).toContain('500'); + }); + + it('handles empty email results', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ data: { emails: [], meta: { results: 0, limit: 10, offset: 0 } } }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + ); + vi.stubGlobal('fetch', fetchMock); + + const result = await hunterDomainSearchNode.executor( + { domain: 'unknown-domain.com' }, + authedContext + ); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.output.emails).toHaveLength(0); + expect(result.output.meta.results).toBe(0); + } + }); + + it('handles network error', async () => { + const fetchMock = vi.fn().mockRejectedValue(new Error('Network request failed')); + vi.stubGlobal('fetch', fetchMock); + + const result = await hunterDomainSearchNode.executor( + { domain: 'example.com' }, + authedContext + ); + + expect(result.success).toBe(false); + expect(result.error).toContain('Network request failed'); + }); +}); + +// ─── hunter_email_finder ─────────────────────────────────────────────────── + +describe('hunter_email_finder', () => { + const mockEmailFinderResponse = { + data: { + email: 'john.doe@example.com', + score: 92, + position: 'CEO', + company: 'Example Inc', + }, + }; + + it('fails when API key is missing', async () => { + const result = await hunterEmailFinderNode.executor( + { domain: 'example.com', firstName: 'John', lastName: 'Doe' }, + baseContext + ); + expect(result.success).toBe(false); + expect(result.error).toContain('hunter.apiKey'); + }); + + it('finds email for person at domain', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify(mockEmailFinderResponse), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ); + vi.stubGlobal('fetch', fetchMock); + + const result = await hunterEmailFinderNode.executor( + { domain: 'example.com', firstName: 'John', lastName: 'Doe' }, + authedContext + ); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.output.email).toBe('john.doe@example.com'); + expect(result.output.score).toBe(92); + expect(result.output.position).toBe('CEO'); + expect(result.output.company).toBe('Example Inc'); + } + }); + + it('sends first_name and last_name as snake_case query params', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify(mockEmailFinderResponse), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ); + vi.stubGlobal('fetch', fetchMock); + + await hunterEmailFinderNode.executor( + { domain: 'example.com', firstName: 'John', lastName: 'Doe' }, + authedContext + ); + + const [url] = fetchMock.mock.calls[0] as [string]; + const params = new URLSearchParams(url.split('?')[1]); + expect(params.get('first_name')).toBe('John'); + expect(params.get('last_name')).toBe('Doe'); + }); + + it('sends api_key as query parameter in URL', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify(mockEmailFinderResponse), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ); + vi.stubGlobal('fetch', fetchMock); + + await hunterEmailFinderNode.executor( + { domain: 'example.com', firstName: 'John', lastName: 'Doe' }, + authedContext + ); + + const [url] = fetchMock.mock.calls[0] as [string]; + const params = new URLSearchParams(url.split('?')[1]); + expect(params.get('api_key')).toBe('test-hunter-key'); + }); + + it('URL-encodes special characters in names', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify(mockEmailFinderResponse), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ); + vi.stubGlobal('fetch', fetchMock); + + await hunterEmailFinderNode.executor( + { domain: 'example.com', firstName: 'Jean-Claude', lastName: "O'Brien" }, + authedContext + ); + + const [url] = fetchMock.mock.calls[0] as [string]; + const params = new URLSearchParams(url.split('?')[1]); + expect(params.get('first_name')).toBe('Jean-Claude'); + expect(params.get('last_name')).toBe("O'Brien"); + }); + + it('returns error on API failure (401)', async () => { + const fetchMock = vi.fn().mockRejectedValue( + Object.assign(new Error('Authentication error: 401 Unauthorized'), { + name: 'FetchRetryError', + status: 401, + }) + ); + vi.stubGlobal('fetch', fetchMock); + + const result = await hunterEmailFinderNode.executor( + { domain: 'example.com', firstName: 'John', lastName: 'Doe' }, + authedContext + ); + + expect(result.success).toBe(false); + expect(result.error).toContain('401'); + }); + + it('returns error on 5xx', async () => { + const fetchMock = vi.fn().mockRejectedValue( + Object.assign(new Error('Server error: 500 Internal Server Error'), { + name: 'FetchRetryError', + status: 500, + }) + ); + vi.stubGlobal('fetch', fetchMock); + + const result = await hunterEmailFinderNode.executor( + { domain: 'example.com', firstName: 'John', lastName: 'Doe' }, + authedContext + ); + + expect(result.success).toBe(false); + expect(result.error).toContain('500'); + }); + + it('handles null/missing fields in response', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + data: { email: null, score: 0, position: null, company: null }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + ); + vi.stubGlobal('fetch', fetchMock); + + const result = await hunterEmailFinderNode.executor( + { domain: 'example.com', firstName: 'Unknown', lastName: 'Person' }, + authedContext + ); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.output.email).toBeNull(); + expect(result.output.score).toBe(0); + expect(result.output.position).toBeNull(); + expect(result.output.company).toBeNull(); + } + }); + + it('handles network error', async () => { + const fetchMock = vi.fn().mockRejectedValue(new Error('Network request failed')); + vi.stubGlobal('fetch', fetchMock); + + const result = await hunterEmailFinderNode.executor( + { domain: 'example.com', firstName: 'John', lastName: 'Doe' }, + authedContext + ); + + expect(result.success).toBe(false); + expect(result.error).toContain('Network request failed'); + }); +}); + +// ─── hunter_email_verifier ───────────────────────────────────────────────── + +describe('hunter_email_verifier', () => { + const mockVerifierResponse = { + data: { + status: 'valid', + score: 95, + regexp: true, + gibberish: false, + disposable: false, + webmail: false, + mx_records: true, + smtp_server: true, + smtp_check: true, + accept_all: false, + }, + }; + + it('fails when API key is missing', async () => { + const result = await hunterEmailVerifierNode.executor( + { email: 'test@example.com' }, + baseContext + ); + expect(result.success).toBe(false); + expect(result.error).toContain('hunter.apiKey'); + }); + + it('verifies email and returns all fields', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify(mockVerifierResponse), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ); + vi.stubGlobal('fetch', fetchMock); + + const result = await hunterEmailVerifierNode.executor( + { email: 'test@example.com' }, + authedContext + ); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.output.status).toBe('valid'); + expect(result.output.score).toBe(95); + expect(result.output.regexp).toBe(true); + expect(result.output.gibberish).toBe(false); + expect(result.output.disposable).toBe(false); + expect(result.output.webmail).toBe(false); + expect(result.output.mxRecords).toBe(true); + expect(result.output.smtpServer).toBe(true); + expect(result.output.smtpCheck).toBe(true); + expect(result.output.acceptAll).toBe(false); + } + }); + + it('sends email and api_key as query parameters', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify(mockVerifierResponse), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ); + vi.stubGlobal('fetch', fetchMock); + + await hunterEmailVerifierNode.executor( + { email: 'test@example.com' }, + authedContext + ); + + const [url] = fetchMock.mock.calls[0] as [string]; + const params = new URLSearchParams(url.split('?')[1]); + expect(params.get('api_key')).toBe('test-hunter-key'); + expect(params.get('email')).toBe('test@example.com'); + }); + + it('maps all snake_case fields to camelCase', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify(mockVerifierResponse), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ); + vi.stubGlobal('fetch', fetchMock); + + const result = await hunterEmailVerifierNode.executor( + { email: 'test@example.com' }, + authedContext + ); + + expect(result.success).toBe(true); + if (result.success) { + // Verify snake_case -> camelCase mapping + expect(result.output).toHaveProperty('mxRecords'); + expect(result.output).toHaveProperty('smtpServer'); + expect(result.output).toHaveProperty('smtpCheck'); + expect(result.output).toHaveProperty('acceptAll'); + // Verify no snake_case keys leak through + expect(result.output).not.toHaveProperty('mx_records'); + expect(result.output).not.toHaveProperty('smtp_server'); + expect(result.output).not.toHaveProperty('smtp_check'); + expect(result.output).not.toHaveProperty('accept_all'); + } + }); + + it('all boolean fields are mapped correctly', async () => { + const allTrueResponse = { + data: { + status: 'valid', + score: 100, + regexp: true, + gibberish: true, + disposable: true, + webmail: true, + mx_records: true, + smtp_server: true, + smtp_check: true, + accept_all: true, + }, + }; + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify(allTrueResponse), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ); + vi.stubGlobal('fetch', fetchMock); + + const result = await hunterEmailVerifierNode.executor( + { email: 'test@example.com' }, + authedContext + ); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.output.regexp).toBe(true); + expect(result.output.gibberish).toBe(true); + expect(result.output.disposable).toBe(true); + expect(result.output.webmail).toBe(true); + expect(result.output.mxRecords).toBe(true); + expect(result.output.smtpServer).toBe(true); + expect(result.output.smtpCheck).toBe(true); + expect(result.output.acceptAll).toBe(true); + } + }); + + it('returns error on API failure (401)', async () => { + const fetchMock = vi.fn().mockRejectedValue( + Object.assign(new Error('Authentication error: 401 Unauthorized'), { + name: 'FetchRetryError', + status: 401, + }) + ); + vi.stubGlobal('fetch', fetchMock); + + const result = await hunterEmailVerifierNode.executor( + { email: 'test@example.com' }, + authedContext + ); + + expect(result.success).toBe(false); + expect(result.error).toContain('401'); + }); + + it('returns error on 5xx', async () => { + const fetchMock = vi.fn().mockRejectedValue( + Object.assign(new Error('Server error: 500 Internal Server Error'), { + name: 'FetchRetryError', + status: 500, + }) + ); + vi.stubGlobal('fetch', fetchMock); + + const result = await hunterEmailVerifierNode.executor( + { email: 'test@example.com' }, + authedContext + ); + + expect(result.success).toBe(false); + expect(result.error).toContain('500'); + }); + + it('handles unverifiable email (status unknown)', async () => { + const unknownResponse = { + data: { + status: 'unknown', + score: 0, + regexp: true, + gibberish: false, + disposable: false, + webmail: false, + mx_records: false, + smtp_server: false, + smtp_check: false, + accept_all: false, + }, + }; + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify(unknownResponse), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ); + vi.stubGlobal('fetch', fetchMock); + + const result = await hunterEmailVerifierNode.executor( + { email: 'unknown@mystery.com' }, + authedContext + ); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.output.status).toBe('unknown'); + expect(result.output.score).toBe(0); + } + }); + + it('handles network error', async () => { + const fetchMock = vi.fn().mockRejectedValue(new Error('Network request failed')); + vi.stubGlobal('fetch', fetchMock); + + const result = await hunterEmailVerifierNode.executor( + { email: 'test@example.com' }, + authedContext + ); + + expect(result.success).toBe(false); + expect(result.error).toContain('Network request failed'); + }); +}); + +// ─── Integration tests ───────────────────────────────────────────────────── + +describe('hunter integration flow', () => { + it('domain search -> email finder -> email verifier flow', async () => { + let callCount = 0; + const fetchMock = vi.fn().mockImplementation(async (url: string) => { + callCount++; + if (url.includes('/domain-search')) { + return new Response( + JSON.stringify({ + data: { + emails: [ + { + value: 'john@example.com', + type: 'personal', + confidence: 90, + first_name: 'John', + last_name: 'Doe', + position: 'CTO', + department: 'it', + sources: [], + }, + ], + meta: { results: 1, limit: 10, offset: 0 }, + }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ); + } + if (url.includes('/email-finder')) { + return new Response( + JSON.stringify({ + data: { email: 'john.doe@example.com', score: 95, position: 'CTO', company: 'Example' }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ); + } + if (url.includes('/email-verifier')) { + return new Response( + JSON.stringify({ + data: { + status: 'valid', + score: 95, + regexp: true, + gibberish: false, + disposable: false, + webmail: false, + mx_records: true, + smtp_server: true, + smtp_check: true, + accept_all: false, + }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ); + } + throw new Error(`Unexpected URL: ${url}`); + }); + vi.stubGlobal('fetch', fetchMock); + + // Step 1: Domain search + const searchResult = await hunterDomainSearchNode.executor( + { domain: 'example.com' }, + authedContext + ); + expect(searchResult.success).toBe(true); + + // Step 2: Email finder + const finderResult = await hunterEmailFinderNode.executor( + { domain: 'example.com', firstName: 'John', lastName: 'Doe' }, + authedContext + ); + expect(finderResult.success).toBe(true); + + // Step 3: Email verifier + const verifierResult = await hunterEmailVerifierNode.executor( + { email: 'john.doe@example.com' }, + authedContext + ); + expect(verifierResult.success).toBe(true); + + expect(callCount).toBe(3); + }); + + it('all nodes fail identically when credentials are missing', async () => { + const domainResult = await hunterDomainSearchNode.executor( + { domain: 'example.com' }, + baseContext + ); + const finderResult = await hunterEmailFinderNode.executor( + { domain: 'example.com', firstName: 'John', lastName: 'Doe' }, + baseContext + ); + const verifierResult = await hunterEmailVerifierNode.executor( + { email: 'test@example.com' }, + baseContext + ); + + expect(domainResult.success).toBe(false); + expect(finderResult.success).toBe(false); + expect(verifierResult.success).toBe(false); + + expect(domainResult.error).toContain('hunter.apiKey'); + expect(finderResult.error).toContain('hunter.apiKey'); + expect(verifierResult.error).toContain('hunter.apiKey'); + }); +}); diff --git a/packages/nodes/src/integrations/hunter/credentials.ts b/packages/nodes/src/integrations/hunter/credentials.ts new file mode 100644 index 0000000..206d666 --- /dev/null +++ b/packages/nodes/src/integrations/hunter/credentials.ts @@ -0,0 +1,21 @@ +import { defineApiKeyCredential } from '@jam-nodes/core'; +import { z } from 'zod'; + +export const hunterCredential = defineApiKeyCredential({ + name: 'hunter', + displayName: 'Hunter.io API Key', + documentationUrl: 'https://hunter.io/api-documentation/v2', + schema: z.object({ + apiKey: z.string(), + }), + authenticate: { + type: 'query', + properties: { + api_key: '{{apiKey}}', + }, + }, + testRequest: { + url: 'https://api.hunter.io/v2/account', + method: 'GET', + }, +}); diff --git a/packages/nodes/src/integrations/hunter/hunter-domain-search.ts b/packages/nodes/src/integrations/hunter/hunter-domain-search.ts new file mode 100644 index 0000000..c718bed --- /dev/null +++ b/packages/nodes/src/integrations/hunter/hunter-domain-search.ts @@ -0,0 +1,137 @@ +import { defineNode } from '@jam-nodes/core'; +import { fetchWithRetry } from '../../utils/http.js'; +import { + HunterDomainSearchInputSchema, + HunterDomainSearchOutputSchema, + type HunterDomainSearchInput, + type HunterDomainSearchOutput, +} from './schemas.js'; + +export { + HunterDomainSearchInputSchema, + HunterDomainSearchOutputSchema, + type HunterDomainSearchInput, + type HunterDomainSearchOutput, +} from './schemas.js'; + +const HUNTER_API_BASE = 'https://api.hunter.io/v2'; + +interface HunterSource { + domain: string; + uri: string; + extracted_on: string; + last_seen_on: string; + still_on_page: boolean; +} + +interface HunterEmail { + value: string; + type: string | null; + confidence: number; + first_name: string | null; + last_name: string | null; + position: string | null; + department: string | null; + sources: HunterSource[]; +} + +interface HunterDomainSearchResponse { + data: { + emails: HunterEmail[]; + meta: { + results: number; + limit: number; + offset: number; + }; + }; +} + +export const hunterDomainSearchNode = defineNode({ + type: 'hunter_domain_search', + name: 'Hunter Domain Search', + description: 'Search for email addresses associated with a domain using Hunter.io', + category: 'integration', + inputSchema: HunterDomainSearchInputSchema, + outputSchema: HunterDomainSearchOutputSchema, + estimatedDuration: 5, + capabilities: { + supportsRerun: true, + }, + executor: async (input: HunterDomainSearchInput, context) => { + try { + const apiKey = context.credentials?.hunter?.apiKey; + if (!apiKey) { + return { + success: false, + error: 'Hunter API key not configured. Please provide context.credentials.hunter.apiKey.', + }; + } + + const params = new URLSearchParams({ + api_key: apiKey, + domain: input.domain, + }); + + if (input.limit !== undefined) { + params.set('limit', String(input.limit)); + } + if (input.type) { + params.set('type', input.type); + } + if (input.seniority && input.seniority.length > 0) { + params.set('seniority', input.seniority.join(',')); + } + if (input.department && input.department.length > 0) { + params.set('department', input.department.join(',')); + } + + const response = await fetchWithRetry( + `${HUNTER_API_BASE}/domain-search?${params}`, + { method: 'GET' }, + { maxRetries: 3, backoffMs: 1000, timeoutMs: 30000 } + ); + + if (!response.ok) { + const errorText = await response.text(); + return { + success: false, + error: `Hunter API error: ${response.status} - ${errorText}`, + }; + } + + const json = (await response.json()) as HunterDomainSearchResponse; + const data = json.data; + + const output: HunterDomainSearchOutput = { + emails: data.emails.map((email) => ({ + value: email.value, + type: email.type as 'personal' | 'generic' | null, + confidence: email.confidence, + firstName: email.first_name, + lastName: email.last_name, + position: email.position, + department: email.department, + sources: email.sources.map((source) => ({ + domain: source.domain, + uri: source.uri, + extractedOn: source.extracted_on, + lastSeenOn: source.last_seen_on, + stillOnPage: source.still_on_page, + })), + })), + meta: { + results: data.meta.results, + limit: data.meta.limit, + offset: data.meta.offset, + }, + }; + + return { success: true, output }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to search domain emails', + }; + } + }, +}); diff --git a/packages/nodes/src/integrations/hunter/hunter-email-finder.ts b/packages/nodes/src/integrations/hunter/hunter-email-finder.ts new file mode 100644 index 0000000..89c099c --- /dev/null +++ b/packages/nodes/src/integrations/hunter/hunter-email-finder.ts @@ -0,0 +1,88 @@ +import { defineNode } from '@jam-nodes/core'; +import { fetchWithRetry } from '../../utils/http.js'; +import { + HunterEmailFinderInputSchema, + HunterEmailFinderOutputSchema, + type HunterEmailFinderInput, + type HunterEmailFinderOutput, +} from './schemas.js'; + +export { + HunterEmailFinderInputSchema, + HunterEmailFinderOutputSchema, + type HunterEmailFinderInput, + type HunterEmailFinderOutput, +} from './schemas.js'; + +const HUNTER_API_BASE = 'https://api.hunter.io/v2'; + +interface HunterEmailFinderResponse { + data: { + email: string | null; + score: number; + position: string | null; + company: string | null; + }; +} + +export const hunterEmailFinderNode = defineNode({ + type: 'hunter_email_finder', + name: 'Hunter Email Finder', + description: 'Find the email address of a person at a domain using Hunter.io', + category: 'integration', + inputSchema: HunterEmailFinderInputSchema, + outputSchema: HunterEmailFinderOutputSchema, + estimatedDuration: 5, + capabilities: { + supportsRerun: true, + }, + executor: async (input: HunterEmailFinderInput, context) => { + try { + const apiKey = context.credentials?.hunter?.apiKey; + if (!apiKey) { + return { + success: false, + error: 'Hunter API key not configured. Please provide context.credentials.hunter.apiKey.', + }; + } + + const params = new URLSearchParams({ + api_key: apiKey, + domain: input.domain, + first_name: input.firstName, + last_name: input.lastName, + }); + + const response = await fetchWithRetry( + `${HUNTER_API_BASE}/email-finder?${params}`, + { method: 'GET' }, + { maxRetries: 3, backoffMs: 1000, timeoutMs: 30000 } + ); + + if (!response.ok) { + const errorText = await response.text(); + return { + success: false, + error: `Hunter API error: ${response.status} - ${errorText}`, + }; + } + + const json = (await response.json()) as HunterEmailFinderResponse; + const data = json.data; + + const output: HunterEmailFinderOutput = { + email: data.email ?? null, + score: data.score, + position: data.position ?? null, + company: data.company ?? null, + }; + + return { success: true, output }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to find email', + }; + } + }, +}); diff --git a/packages/nodes/src/integrations/hunter/hunter-email-verifier.ts b/packages/nodes/src/integrations/hunter/hunter-email-verifier.ts new file mode 100644 index 0000000..4737332 --- /dev/null +++ b/packages/nodes/src/integrations/hunter/hunter-email-verifier.ts @@ -0,0 +1,98 @@ +import { defineNode } from '@jam-nodes/core'; +import { fetchWithRetry } from '../../utils/http.js'; +import { + HunterEmailVerifierInputSchema, + HunterEmailVerifierOutputSchema, + type HunterEmailVerifierInput, + type HunterEmailVerifierOutput, +} from './schemas.js'; + +export { + HunterEmailVerifierInputSchema, + HunterEmailVerifierOutputSchema, + type HunterEmailVerifierInput, + type HunterEmailVerifierOutput, +} from './schemas.js'; + +const HUNTER_API_BASE = 'https://api.hunter.io/v2'; + +interface HunterEmailVerifierResponse { + data: { + status: string; + score: number; + regexp: boolean; + gibberish: boolean; + disposable: boolean; + webmail: boolean; + mx_records: boolean; + smtp_server: boolean; + smtp_check: boolean; + accept_all: boolean; + }; +} + +export const hunterEmailVerifierNode = defineNode({ + type: 'hunter_email_verifier', + name: 'Hunter Email Verifier', + description: 'Verify the deliverability of an email address using Hunter.io', + category: 'integration', + inputSchema: HunterEmailVerifierInputSchema, + outputSchema: HunterEmailVerifierOutputSchema, + estimatedDuration: 5, + capabilities: { + supportsRerun: true, + }, + executor: async (input: HunterEmailVerifierInput, context) => { + try { + const apiKey = context.credentials?.hunter?.apiKey; + if (!apiKey) { + return { + success: false, + error: 'Hunter API key not configured. Please provide context.credentials.hunter.apiKey.', + }; + } + + const params = new URLSearchParams({ + api_key: apiKey, + email: input.email, + }); + + const response = await fetchWithRetry( + `${HUNTER_API_BASE}/email-verifier?${params}`, + { method: 'GET' }, + { maxRetries: 3, backoffMs: 1000, timeoutMs: 30000 } + ); + + if (!response.ok) { + const errorText = await response.text(); + return { + success: false, + error: `Hunter API error: ${response.status} - ${errorText}`, + }; + } + + const json = (await response.json()) as HunterEmailVerifierResponse; + const data = json.data; + + const output: HunterEmailVerifierOutput = { + status: data.status, + score: data.score, + regexp: data.regexp, + gibberish: data.gibberish, + disposable: data.disposable, + webmail: data.webmail, + mxRecords: data.mx_records, + smtpServer: data.smtp_server, + smtpCheck: data.smtp_check, + acceptAll: data.accept_all, + }; + + return { success: true, output }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to verify email', + }; + } + }, +}); diff --git a/packages/nodes/src/integrations/hunter/index.ts b/packages/nodes/src/integrations/hunter/index.ts new file mode 100644 index 0000000..ab988d3 --- /dev/null +++ b/packages/nodes/src/integrations/hunter/index.ts @@ -0,0 +1,25 @@ +export { + hunterDomainSearchNode, + HunterDomainSearchInputSchema, + HunterDomainSearchOutputSchema, + type HunterDomainSearchInput, + type HunterDomainSearchOutput, +} from './hunter-domain-search.js'; + +export { + hunterEmailFinderNode, + HunterEmailFinderInputSchema, + HunterEmailFinderOutputSchema, + type HunterEmailFinderInput, + type HunterEmailFinderOutput, +} from './hunter-email-finder.js'; + +export { + hunterEmailVerifierNode, + HunterEmailVerifierInputSchema, + HunterEmailVerifierOutputSchema, + type HunterEmailVerifierInput, + type HunterEmailVerifierOutput, +} from './hunter-email-verifier.js'; + +export { hunterCredential } from './credentials.js'; diff --git a/packages/nodes/src/integrations/hunter/schemas.ts b/packages/nodes/src/integrations/hunter/schemas.ts new file mode 100644 index 0000000..1a69069 --- /dev/null +++ b/packages/nodes/src/integrations/hunter/schemas.ts @@ -0,0 +1,84 @@ +import { z } from 'zod'; + +// ----- Domain Search ----- + +export const HunterDomainSearchInputSchema = z.object({ + domain: z.string().min(1, 'Domain is required'), + limit: z.number().int().min(1).max(100).optional(), + type: z.enum(['personal', 'generic']).optional(), + seniority: z.array(z.enum(['junior', 'senior', 'executive'])).optional(), + department: z.array(z.enum(['sales', 'marketing', 'hr', 'it', 'finance', 'executive'])).optional(), +}); + +const SourceSchema = z.object({ + domain: z.string(), + uri: z.string(), + extractedOn: z.string(), + lastSeenOn: z.string(), + stillOnPage: z.boolean(), +}); + +const EmailResultSchema = z.object({ + value: z.string(), + type: z.enum(['personal', 'generic']).nullable(), + confidence: z.number(), + firstName: z.string().nullable(), + lastName: z.string().nullable(), + position: z.string().nullable(), + department: z.string().nullable(), + sources: z.array(SourceSchema), +}); + +export const HunterDomainSearchOutputSchema = z.object({ + emails: z.array(EmailResultSchema), + meta: z.object({ + results: z.number(), + limit: z.number(), + offset: z.number(), + }), +}); + +// ----- Email Finder ----- + +export const HunterEmailFinderInputSchema = z.object({ + domain: z.string().min(1, 'Domain is required'), + firstName: z.string().min(1, 'First name is required'), + lastName: z.string().min(1, 'Last name is required'), +}); + +export const HunterEmailFinderOutputSchema = z.object({ + email: z.string().nullable(), + score: z.number(), + position: z.string().nullable(), + company: z.string().nullable(), +}); + +// ----- Email Verifier ----- + +export const HunterEmailVerifierInputSchema = z.object({ + email: z.string().min(1, 'Email is required'), +}); + +export const HunterEmailVerifierOutputSchema = z.object({ + status: z.string(), + score: z.number(), + regexp: z.boolean(), + gibberish: z.boolean(), + disposable: z.boolean(), + webmail: z.boolean(), + mxRecords: z.boolean(), + smtpServer: z.boolean(), + smtpCheck: z.boolean(), + acceptAll: z.boolean(), +}); + +// ----- Inferred types ----- + +export type HunterDomainSearchInput = z.infer; +export type HunterDomainSearchOutput = z.infer; + +export type HunterEmailFinderInput = z.infer; +export type HunterEmailFinderOutput = z.infer; + +export type HunterEmailVerifierInput = z.infer; +export type HunterEmailVerifierOutput = z.infer; diff --git a/packages/nodes/src/integrations/index.ts b/packages/nodes/src/integrations/index.ts index 25707be..88c4e30 100644 --- a/packages/nodes/src/integrations/index.ts +++ b/packages/nodes/src/integrations/index.ts @@ -275,3 +275,23 @@ export { type SlackSearchMatch, slackCredential, } from './slack/index.js' + +// Hunter integrations +export { + hunterDomainSearchNode, + HunterDomainSearchInputSchema, + HunterDomainSearchOutputSchema, + type HunterDomainSearchInput, + type HunterDomainSearchOutput, + hunterEmailFinderNode, + HunterEmailFinderInputSchema, + HunterEmailFinderOutputSchema, + type HunterEmailFinderInput, + type HunterEmailFinderOutput, + hunterEmailVerifierNode, + HunterEmailVerifierInputSchema, + HunterEmailVerifierOutputSchema, + type HunterEmailVerifierInput, + type HunterEmailVerifierOutput, + hunterCredential, +} from './hunter/index.js'